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.
316 lines
13 KiB
Swift
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
|
|
}
|
|
}
|
|
|
|
}
|