From 4e2442fb8d3ae80fbbfc9534e668841ec26e80ab Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 13 Sep 2026 17:57:52 +0800 Subject: [PATCH] Reuse existing mobile SSH workspaces --- TFI/App/MobileFeatureRegistry.swift | 1 + TFI/App/MobileSceneModel.swift | 28 +++++++ TFI/Features/Vault/MobileVaultView.swift | 7 +- TFITests/MobileCardRenderingTests.swift | 9 ++- TFITests/MobileIconRenderingTests.swift | 4 +- TFITests/MobileNavigationParityTests.swift | 6 ++ TFITests/MobileShortcutTests.swift | 87 ++++++++++++++++++++++ 7 files changed, 137 insertions(+), 5 deletions(-) diff --git a/TFI/App/MobileFeatureRegistry.swift b/TFI/App/MobileFeatureRegistry.swift index 3ac6649b..b57b500b 100644 --- a/TFI/App/MobileFeatureRegistry.swift +++ b/TFI/App/MobileFeatureRegistry.swift @@ -98,6 +98,7 @@ struct MobileFeatureRegistry { get: { scene.editingHost }, set: { scene.editingHost = $0 } ), + activateHost: scene.activateHost, open: scene.open, showsMetrics: surface == .phoneTab, executeSnippet: scene.execute diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index c5a82bc1..526bc964 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -27,6 +27,7 @@ final class MobileSceneModel { @ObservationIgnored private let app: MobileAppModel @ObservationIgnored private var pendingWorkspaceID: MobileWorkspace.ID? + @ObservationIgnored private var recentWorkspaceIDs: [MobileWorkspace.ID] = [] init(app: MobileAppModel) { self.app = app @@ -81,6 +82,27 @@ final class MobileSceneModel { selectWorkspace(id) } + /// Reuses this scene's most recently selected SSH workspace for the Host. + /// Explicit connection actions continue to call `open(hostID:kind:)` so + /// users can still create independent SSH and SFTP workspaces. + func activateHost(_ hostID: Host.ID) { + let matchingIDs = Set( + app.workspaces.lazy + .filter { $0.descriptor.hostID == hostID && $0.descriptor.kind == .ssh } + .map(\.id) + ) + let workspaceID = + recentWorkspaceIDs.first(where: matchingIDs.contains) + ?? app.workspaces.last(where: { + $0.descriptor.hostID == hostID && $0.descriptor.kind == .ssh + })?.id + guard let workspaceID else { + open(hostID: hostID, kind: .ssh) + return + } + selectWorkspace(workspaceID) + } + func openSameDirectory(_ launch: MobileWorkspaceDirectoryLaunch) { guard let id = app.open(hostID: launch.hostID, kind: launch.kind, initialPath: launch.path) else { return } selectWorkspace(id) @@ -112,6 +134,8 @@ final class MobileSceneModel { guard app.workspaces.contains(where: { $0.id == id }) else { return } selectedDestination = .workspaces workspaceID = id + recentWorkspaceIDs.removeAll { $0 == id } + recentWorkspaceIDs.insert(id, at: 0) app.focusWorkspaceActivity(id) } @@ -168,6 +192,7 @@ final class MobileSceneModel { func closeWorkspace(_ id: MobileWorkspace.ID) { app.close(id) + recentWorkspaceIDs.removeAll { $0 == id } if pendingWorkspaceID == id { pendingWorkspaceID = nil } if workspaceID == id { workspaceID = nil } } @@ -179,11 +204,14 @@ final class MobileSceneModel { func closeAllWorkspaces() { app.closeAll() + recentWorkspaceIDs.removeAll() pendingWorkspaceID = nil workspaceID = nil } func reconcileWorkspaceIDs(_ workspaceIDs: [MobileWorkspace.ID]) { + let existingIDs = Set(workspaceIDs) + recentWorkspaceIDs.removeAll { !existingIDs.contains($0) } if let pendingWorkspaceID, !workspaceIDs.contains(pendingWorkspaceID) { self.pendingWorkspaceID = nil } diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 44551090..0e6da0eb 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -84,6 +84,7 @@ struct MobileVaultView: View { @Environment(\.locale) private var locale let app: MobileAppModel + let activateHost: (Host.ID) -> Void let open: (UUID, ConnectionHistoryEntry.Kind) -> Void let showsMetrics: Bool let executeSnippet: (Snippet, [Host.ID]) -> Void @@ -96,12 +97,14 @@ struct MobileVaultView: View { init( app: MobileAppModel, editingHost: Binding, + activateHost: @escaping (Host.ID) -> Void, open: @escaping (UUID, ConnectionHistoryEntry.Kind) -> Void, showsMetrics: Bool = true, executeSnippet: @escaping (Snippet, [Host.ID]) -> Void = { _, _ in } ) { self.app = app _editingHost = editingHost + self.activateHost = activateHost self.open = open self.showsMetrics = showsMetrics self.executeSnippet = executeSnippet @@ -405,7 +408,7 @@ struct MobileVaultView: View { } let tint = host.colorTag.color ?? brand ?? .accentColor return MobileActionCard { - open(host.id, .ssh) + activateHost(host.id) } content: { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 12) { @@ -439,7 +442,7 @@ struct MobileVaultView: View { } } .accessibilityElement(children: .combine) - .accessibilityHint("Quick Connect with SSH") + .accessibilityHint("Open Workspace") } actions: { Button("SSH", systemImage: "terminal") { open(host.id, .ssh) } Button("SFTP", systemImage: "folder") { open(host.id, .sftp) } diff --git a/TFITests/MobileCardRenderingTests.swift b/TFITests/MobileCardRenderingTests.swift index 4a8e8da6..22e92540 100644 --- a/TFITests/MobileCardRenderingTests.swift +++ b/TFITests/MobileCardRenderingTests.swift @@ -97,7 +97,9 @@ struct MobileCardRenderingTests { for scheme in [ColorScheme.light, .dark] { try await capture("vault", language: language, scheme: scheme) { NavigationStack { - MobileVaultView(app: app, editingHost: .constant(nil), open: { _, _ in }) + MobileVaultView( + app: app, editingHost: .constant(nil), activateHost: { _ in }, + open: { _, _ in }) } } try await capture("snippets", language: language, scheme: scheme) { @@ -153,7 +155,9 @@ struct MobileCardRenderingTests { } try await capture("large-type", language: language, scheme: .light) { NavigationStack { - MobileVaultView(app: app, editingHost: .constant(nil), open: { _, _ in }) + MobileVaultView( + app: app, editingHost: .constant(nil), activateHost: { _ in }, + open: { _, _ in }) } .environment(\.dynamicTypeSize, .accessibility3) } @@ -171,6 +175,7 @@ struct MobileCardRenderingTests { NavigationStack { MobileVaultView( app: try! MobileAppModel(inMemory: true), editingHost: .constant(nil), + activateHost: { _ in }, open: { _, _ in }) } } diff --git a/TFITests/MobileIconRenderingTests.swift b/TFITests/MobileIconRenderingTests.swift index be268680..69c964d9 100644 --- a/TFITests/MobileIconRenderingTests.swift +++ b/TFITests/MobileIconRenderingTests.swift @@ -52,7 +52,9 @@ struct MobileIconRenderingTests { let suffix = scheme == .dark ? "dark" : "light" try await capture("vault-\(suffix)", language: language, scheme: scheme) { NavigationStack { - MobileVaultView(app: app, editingHost: .constant(nil), open: { _, _ in }) + MobileVaultView( + app: app, editingHost: .constant(nil), activateHost: { _ in }, + open: { _, _ in }) }.environment(\.locale, locale) } try await capture("snippets-\(suffix)", language: language, scheme: scheme) { diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index 97774533..5ebde863 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -798,6 +798,12 @@ struct MobileNavigationParityTests { #expect(!vault.contains("List {")) #expect(vault.contains("app.library.setExpanded(!isExpanded, for: id)")) #expect(vault.contains("app.library.setUngroupedExpanded(!isExpanded)")) + let hostRow = try #require(vault.components(separatedBy: "private func row(_ host: Host)").last) + #expect(hostRow.contains("activateHost(host.id)")) + #expect(hostRow.contains("Button(\"SSH\", systemImage: \"terminal\") { open(host.id, .ssh) }")) + #expect(hostRow.contains("Button(\"SFTP\", systemImage: \"folder\") { open(host.id, .sftp) }")) + #expect(hostRow.contains(".accessibilityHint(\"Open Workspace\")")) + #expect(!hostRow.contains(".accessibilityHint(\"Quick Connect with SSH\")")) let workspaceList = try String( contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceListView.swift"), diff --git a/TFITests/MobileShortcutTests.swift b/TFITests/MobileShortcutTests.swift index 09912da9..4445eafc 100644 --- a/TFITests/MobileShortcutTests.swift +++ b/TFITests/MobileShortcutTests.swift @@ -72,6 +72,93 @@ struct MobileShortcutTests { #expect(scene.assistant.conversation.id != previousConversationID) } + @Test func hostCardActivationReusesTheScenesMostRecentSSHWorkspace() throws { + let app = try MobileAppModel(inMemory: true) + let host = Host(name: "Test", host: "example.invalid") + app.library.upsert(host) + let first = try #require(app.open(hostID: host.id, kind: .ssh)) + let second = try #require(app.open(hostID: host.id, kind: .ssh)) + _ = try #require(app.open(hostID: host.id, kind: .sftp)) + defer { app.closeAll() } + + let firstScene = MobileSceneModel(app: app) + firstScene.selectWorkspace(first) + firstScene.activatePendingWorkspace() + firstScene.selectWorkspace(second) + + let secondScene = MobileSceneModel(app: app) + secondScene.selectWorkspace(second) + secondScene.activatePendingWorkspace() + secondScene.selectWorkspace(first) + + let workspaceCount = app.workspaces.count + let historyCount = app.connectionHistory.entries(for: host.id).count + firstScene.openSettings() + firstScene.activateHost(host.id) + firstScene.activatePendingWorkspace() + secondScene.openSettings() + secondScene.activateHost(host.id) + secondScene.activatePendingWorkspace() + + #expect(firstScene.workspaceID == second) + #expect(secondScene.workspaceID == first) + #expect(app.workspaces.count == workspaceCount) + #expect(app.connectionHistory.entries(for: host.id).count == historyCount) + } + + @Test func hostCardActivationFallsBackToTheNewestMatchingSSHWorkspace() throws { + let app = try MobileAppModel(inMemory: true) + let host = Host(name: "Test", host: "example.invalid") + app.library.upsert(host) + _ = try #require(app.open(hostID: host.id, kind: .ssh)) + let newestSSH = try #require(app.open(hostID: host.id, kind: .ssh)) + _ = try #require(app.open(hostID: host.id, kind: .sftp)) + defer { app.closeAll() } + let scene = MobileSceneModel(app: app) + + scene.activateHost(host.id) + scene.activatePendingWorkspace() + + #expect(scene.workspaceID == newestSSH) + #expect(app.workspaces.count == 3) + } + + @Test func hostCardActivationCreatesSSHWhenOnlySFTPExists() throws { + let app = try MobileAppModel(inMemory: true) + let host = Host(name: "Test", host: "example.invalid") + app.library.upsert(host) + let sftp = try #require(app.open(hostID: host.id, kind: .sftp)) + defer { app.closeAll() } + let scene = MobileSceneModel(app: app) + + scene.activateHost(host.id) + scene.activatePendingWorkspace() + + #expect(app.workspaces.count == 2) + #expect(scene.workspaceID != sftp) + #expect(scene.activeWorkspace?.descriptor.hostID == host.id) + #expect(scene.activeWorkspace?.descriptor.kind == .ssh) + } + + @Test func hostCardActivationForgetsAClosedRecentWorkspace() throws { + let app = try MobileAppModel(inMemory: true) + let host = Host(name: "Test", host: "example.invalid") + app.library.upsert(host) + let first = try #require(app.open(hostID: host.id, kind: .ssh)) + let second = try #require(app.open(hostID: host.id, kind: .ssh)) + defer { app.closeAll() } + let scene = MobileSceneModel(app: app) + scene.selectWorkspace(first) + scene.activatePendingWorkspace() + scene.selectWorkspace(second) + + scene.closeWorkspace(second) + scene.activateHost(host.id) + + #expect(scene.workspaceID == first) + #expect(app.workspaces.map(\.id) == [first]) + } + @Test func workspaceCommandsSelectByIndexAndWrapInBothDirections() throws { let app = try MobileAppModel(inMemory: true) let host = Host(name: "Test", host: "example.invalid")