Files
Termind/TFI/Features/Workspaces/MobileWorkspaceListView.swift
T
Kaede Akino a04db5f867 fix(workspace): close sessions without waiting for remote cleanup
Keep mobile list snapshots and section identity stable during swipe closure.

Move managed-session termination to app-owned background cleanup, transfer existing SSH connections, and allow one silent reconnect when needed. Preserve passive teardown behavior and prevent reopened workspaces from attaching to sessions being terminated.

Cover connection ownership, cleanup cancellation, saved workspace closure, and list updates with regression tests.
2026-10-17 14:34:55 +08:00

331 lines
14 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 evaluates its content lazily. Keep section metadata and rows on the same snapshot.
let items = scene.workspaceSession.orderedWorkspaceItems
let recentHosts = items.isEmpty ? historyRows : []
List {
Section {
Button(action: scene.openTransfers) {
Label(AppStrings.SFTP.transfers.swiftUIKey, systemImage: "arrow.up.arrow.down")
}
}
// Do not replace the section while UIKit is applying workspace row deletions.
Section {
if items.isEmpty && recentHosts.isEmpty {
ContentUnavailableView(
AppStrings.Workspace.noWorkspaces.swiftUIKey, systemImage: "terminal",
description: Text(AppStrings.Workspace.useConnectToStartAWorkspace)
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
workspaceRows(items)
historyRowsContent(recentHosts)
} header: {
Text(
recentHosts.isEmpty
? AppStrings.Navigation.workspaces.swiftUIKey : AppStrings.Workspace.recents.swiftUIKey)
}
}
.navigationTitle(AppStrings.Navigation.workspaces.swiftUIKey)
.toolbar {
MobileWorkspaceToolbar(
hasWorkspaces: scene.canCloseAllWorkspaces,
closeAll: scene.closeAllWorkspaces,
connect: scene.openConnectionPicker,
batchStartup: scene.openBatchStartup
)
}
.navigationDestination(item: $scene.workspaceDestination) { destination in
switch destination {
case .transfers:
MobileTransfersView(transfers: app.transfers, hosts: app.library)
case .workspace(let id):
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(item: $scene.workspaceSheet, onDismiss: scene.completeBatchStartup) { sheet in
switch sheet {
case .connectionPicker:
NavigationStack {
MobileConnectionPicker(
library: app.library,
connect: { hostID, kind in
scene.workspaceSheet = nil
scene.open(hostID: hostID, kind: kind)
},
cancel: { scene.workspaceSheet = nil }
)
}
.mobileSheetPresentation(compact: true)
case .batchStartup:
MobileBatchStartupPicker(library: app.library, connect: scene.prepareBatchStartup)
}
}
.task(id: scene.selectedDestination) {
guard scene.selectedDestination == .workspaces else { return }
await Task.yield()
scene.activatePendingWorkspace()
}
}
@ViewBuilder
private func workspaceRows(_ items: [MobileWorkspaceItem]) -> some View {
ForEach(items) { 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) {
HStack(spacing: MobileWorkspaceListMetrics.rowSpacing) {
Text(item.descriptor.title)
.foregroundStyle(.primary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
if scene.workspaceSession.unreadCount(for: item.id) > 0 {
NotificationUnreadDot()
}
}
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)
}
.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 {
// Let the model's list snapshot own row removal, not the swipe action's optimistic deletion.
Button(AppStrings.Common.close.swiftUIKey, systemImage: "xmark") {
scene.closeWorkspace(item.id)
}
.tint(.red)
}
.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 func historyRowsContent(_ rows: [MobileRecentHostRow]) -> some View {
ForEach(rows) { 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") {
requestHistoryClear(for: row.host)
}
.tint(.red)
ForEach(
MobileConnectionHistoryActionPolicy.availableSwipeKinds(for: row.host), id: \.self
) { kind in
historyConnectionButton(kind, host: row.host)
.tint(swipeTint(for: kind))
}
}
.operationConfirmation(
.init(AppStrings.Workspace.clearRecentConnectionsConfirmation),
isPresented: $clearingRecentHost.isPresenting { $0.id == row.host.id },
actions: [
.init(AppStrings.Common.clearHistory, role: .destructive) {
if let host = clearingRecentHost {
app.connectionHistory.clear(for: host.id)
}
clearingRecentHost = nil
},
.init(AppStrings.Common.cancel, role: .cancel) { clearingRecentHost = nil },
],
message: .init(AppStrings.Workspace.clearHostHistoryWarning)
)
}
}
@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, .mosh, .eternalTerminal: .accentColor
case .telnet: .purple
case .sftp: .orange
}
}
}