Reuse existing mobile SSH workspaces

This commit is contained in:
Kaede Akino committed 2026-09-13 17:58:28 +08:00
1 parent 0f31ddef4b
commit 4e2442fb8d
7 files changed
+137 -5

No files matched your search

+1
View File
@@ -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
+28
View File
@@ -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
}
+5 -2
View File
@@ -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<Host?>,
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) }
+7 -2
View File
@@ -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 })
}
}
+3 -1
View File
@@ -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) {
@@ -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"),
+87
View File
@@ -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")