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.
331 lines
14 KiB
Swift
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
|
|
}
|
|
}
|
|
|
|
}
|