Files
Termind/TFI/Features/Workspaces/MobileWorkspaceListView.swift

334 lines
14 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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
}
}
}