Reuse existing mobile SSH workspaces
This commit is contained in:
1 parent
0f31ddef4b
commit
4e2442fb8d
7 files changed
+137
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user