Files
Termind/TFI/Features/Workspaces/MobileWorkspaceListView.swift
T
Kaede Akino 82d871c4fe refactor: separate feature policies and UI workflows
Move connection policy out of ProAccess, keep terminal uploads with their
session, and share question interaction and highlight editing operations.
Consolidate CPU usage and settings snapshot handling, and remove obsolete
terminal selection, theme helpers, and hard-coded latest-schema assertions.

Fix draining connection-pool recovery and stale pane upload bindings. Keep
screenshot matrices opt-in and retain behavioral migration coverage.

Validation: 2468 macOS tests passed serially, 31 iOS tests passed, and both
SSH forwarding regressions passed against the local OpenSSH fixture.
Both app builds, Swift formatting, AppStrings, and Chinese localization
checks passed. Parallel test runs still exhibit intermittent timeouts.
2026-10-02 16:40:27 +08:00

316 lines
13 KiB
Swift

import SwiftUI
import TermindDomain
import TermindPresentation
import UIKit
private enum MobileWorkspaceListMetrics {
static let leadingIconSize: CGFloat = 36
static let rowSpacing: CGFloat = 12
}
enum MobileWorkspaceConnectionIndicator: Equatable {
case connected
case busy
static func resolve(connected: Bool, busy: Bool, reconnecting: Bool) -> Self? {
if connected { return .connected }
if busy || reconnecting { return .busy }
return nil
}
var status: ConnectionStatus {
switch self {
case .connected: .connected
case .busy: .connecting
}
}
}
/// The registered Workspaces destination.
struct MobileWorkspaceListView: View {
let app: MobileAppModel
@Bindable var scene: MobileSceneModel
@Environment(\.locale) private var locale
@State private var clearingRecentHost: Host?
static let historyLimit = 5
var body: some View {
List {
Section {
Button(action: scene.openTransfers) {
Label(AppStrings.SFTP.transfers.swiftUIKey, systemImage: "arrow.up.arrow.down")
}
}
if !scene.workspaceSession.orderedWorkspaceItems.isEmpty || historyRows.isEmpty {
Section(AppStrings.Navigation.workspaces.swiftUIKey) { workspaceRows }
}
if scene.workspaceSession.orderedWorkspaceItems.isEmpty, !historyRows.isEmpty {
Section(AppStrings.Workspace.recents.swiftUIKey) { historyRowsContent }
}
}
.navigationTitle(AppStrings.Navigation.workspaces.swiftUIKey)
.navigationDestination(isPresented: $scene.showsTransfers) {
MobileTransfersView(transfers: app.transfers, hosts: app.library)
}
.toolbar {
MobileWorkspaceToolbar(
hasWorkspaces: scene.canCloseAllWorkspaces,
closeAll: scene.closeAllWorkspaces,
connect: scene.openConnectionPicker,
batchStartup: scene.openBatchStartup
)
}
.navigationDestination(item: $scene.workspaceID) { id in
if let workspace = scene.workspaceSession.workspaces.first(where: { $0.id == id }) {
MobileWorkspaceView(
library: app.library, commandGenerator: app.commandGenerator,
workspace: workspace, workspaces: scene.workspaceSession.orderedWorkspaceItems.map(\.descriptor),
unreadWorkspaceIDs: scene.workspaceSession.unreadWorkspaceIDs,
selectWorkspace: scene.selectWorkspace,
renameWorkspace: scene.beginWorkspaceRename,
canCloseMultipleWorkspaces: scene.canCloseMultipleWorkspaces,
openSameDirectory: scene.openSameDirectory,
closeWorkspace: scene.closeWorkspace,
closeOtherWorkspaces: scene.closeOtherWorkspaces
)
// The title menu replaces this pushed detail in place. A new identity tears down
// SFTP's native search state before an SSH detail is mounted.
.id(workspace.id)
}
}
.sheet(isPresented: $scene.isConnectionPickerPresented) {
NavigationStack {
MobileConnectionPicker(
library: app.library,
connect: { hostID, kind in
scene.isConnectionPickerPresented = false
scene.open(hostID: hostID, kind: kind)
},
cancel: { scene.isConnectionPickerPresented = false }
)
}
.mobileSheetPresentation(compact: true)
}
.sheet(isPresented: $scene.isBatchStartupPresented, onDismiss: scene.completeBatchStartup) {
MobileBatchStartupPicker(library: app.library, connect: scene.prepareBatchStartup)
}
.task(id: scene.selectedDestination) {
guard scene.selectedDestination == .workspaces else { return }
await Task.yield()
scene.activatePendingWorkspace()
}
.confirmationDialog(
AppStrings.Workspace.clearRecentConnectionsConfirmation,
isPresented: Binding(
get: { clearingRecentHost != nil },
set: { if !$0 { clearingRecentHost = nil } }
),
titleVisibility: .visible
) {
Button(AppStrings.Common.clearHistory.swiftUIKey, role: .destructive) {
if let host = clearingRecentHost {
app.connectionHistory.clear(for: host.id)
}
clearingRecentHost = nil
}
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { clearingRecentHost = nil }
} message: {
Text(AppStrings.Workspace.clearHostHistoryWarning)
}
}
@ViewBuilder
private var workspaceRows: some View {
if scene.workspaceSession.orderedWorkspaceItems.isEmpty {
ContentUnavailableView(
AppStrings.Workspace.noWorkspaces.swiftUIKey, systemImage: "terminal",
description: Text(AppStrings.Workspace.useConnectToStartAWorkspace)
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
ForEach(scene.workspaceSession.orderedWorkspaceItems) { item in
Button {
scene.selectWorkspace(item.id)
} label: {
HStack(spacing: MobileWorkspaceListMetrics.rowSpacing) {
HostConnectionIcon(
host: item.host, size: MobileWorkspaceListMetrics.leadingIconSize,
status: item.workspace.flatMap {
MobileWorkspaceConnectionIndicator.resolve(
connected: $0.connected, busy: $0.busy, reconnecting: $0.reconnectState != nil
)?.status
},
badgeStyle: .card(background: Color(uiColor: .systemBackground), pulses: false)
)
VStack(alignment: .leading, spacing: 2) {
Text(item.descriptor.title).foregroundStyle(.primary).lineLimit(1)
HStack(alignment: .center, spacing: 12) {
Text(item.loginAddress)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 12)
protocolLabel(item.descriptor.kind)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if scene.workspaceSession.unreadCount(for: item.id) > 0 {
NotificationUnreadDot()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityValue(
scene.workspaceSession.unreadCount(for: item.id) > 0
? String(
localized: AppStrings.Notifications.unread(
scene.workspaceSession.unreadCount(for: item.id))) : ""
)
.accessibilityIdentifier("workspace-\(item.id)")
.swipeActions {
Button(AppStrings.Common.close.swiftUIKey, systemImage: "xmark", role: .destructive) {
scene.closeWorkspace(item.id)
}
}
.contextMenu {
let hasUnread = scene.workspaceSession.unreadCount(for: item.id) > 0
MobileWorkspaceLifecycleMenuActions(
canCloseMultipleWorkspaces: scene.canCloseMultipleWorkspaces,
rename: { scene.beginWorkspaceRename(item.descriptor) },
close: { scene.closeWorkspace(item.id) },
closeOthers: { scene.closeOtherWorkspaces(than: item.id) },
hasUnread: hasUnread,
toggleRead: {
if hasUnread {
scene.workspaceSession.markRead(item.id)
} else {
scene.workspaceSession.markUnread(item.id)
}
}
)
}
}
}
var historyRows: [MobileRecentHostRow] {
_ = app.connectionHistory.revision
return MobileConnectionHistory.recentHostRows(
history: app.connectionHistory,
library: app.library,
limit: Self.historyLimit
)
}
@ViewBuilder
private var historyRowsContent: some View {
ForEach(historyRows) { row in
historyButton(row)
.contextMenu {
let kinds = MobileConnectionHistoryActionPolicy.availableMenuKinds(for: row.host)
ForEach(kinds, id: \.self) { kind in
historyConnectionButton(kind, host: row.host)
}
if !kinds.isEmpty { Divider() }
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
requestHistoryClear(for: row.host)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
requestHistoryClear(for: row.host)
}
ForEach(
MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: row.host), id: \.self
) { kind in
historyConnectionButton(kind, host: row.host)
.tint(swipeTint(for: kind))
}
}
}
}
@ViewBuilder
private func historyButton(_ row: MobileRecentHostRow) -> some View {
if let preferredKind = row.preferredKind {
Button {
scene.open(hostID: row.host.id, kind: preferredKind)
} label: {
historyLabel(row)
}
.buttonStyle(.plain)
} else {
historyLabel(row)
.opacity(0.55)
}
}
private func requestHistoryClear(for host: Host) {
if DeletionContentCategory.recordedContent.requiresConfirmation(for: .clearCollection) {
clearingRecentHost = host
} else {
app.connectionHistory.clear(for: host.id)
}
}
private func historyLabel(_ row: MobileRecentHostRow) -> some View {
HStack(spacing: MobileWorkspaceListMetrics.rowSpacing) {
hostIcon(row.host)
VStack(alignment: .leading, spacing: 2) {
Text(row.host.name).foregroundStyle(.primary).lineLimit(1)
HStack(alignment: .center, spacing: 12) {
Text(row.loginAddress)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 12)
Text(row.entry.connectedAt, format: .relative(presentation: .numeric).locale(locale))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
private func hostIcon(_ host: Host?) -> some View {
HostDistributionIcon(host: host, size: MobileWorkspaceListMetrics.leadingIconSize)
}
private func protocolLabel(_ kind: ConnectionHistoryEntry.Kind) -> some View {
Text(kind.label)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.accessibilityHidden(true)
}
private func historyConnectionButton(
_ kind: ConnectionHistoryEntry.Kind, host: Host
) -> some View {
Button(kind.label, systemImage: kind.symbol) {
guard MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host) else { return }
scene.open(hostID: host.id, kind: kind)
}
.disabled(!MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host))
}
private func swipeTint(for kind: ConnectionHistoryEntry.Kind) -> Color {
switch kind {
case .ssh: .accentColor
case .telnet: .purple
case .sftp: .orange
}
}
}