From 6140bc3ebe8a9dc0f913fdcc98f4d1ef993e3db2 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 27 Sep 2026 15:55:11 +0800 Subject: [PATCH] fix(connections): show mobile Vault status for selected workspace --- .../FeatureModels/HostLibraryModel.swift | 9 +++---- TFI/App/MobileSceneModel.swift | 23 ++++++++++------ TFI/Features/Vault/MobileVaultView.swift | 18 +++++++++++-- TFITests/MobileWorkspaceSessionTests.swift | 26 +++++++++++++++++++ TFM/App/AppModel.swift | 5 ++-- .../FeatureModels/HostLibraryModel+Demo.swift | 4 +-- TFM/Features/StatusBar/StatusBarView.swift | 1 - 7 files changed, 65 insertions(+), 21 deletions(-) diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 1a4d24f1..fe93fbb8 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -1009,11 +1009,10 @@ public final class HostLibraryModel: RemoteSessionHostStore { statuses[id] = status } - /// Records one live connection's state and projects terminals and File - /// Transfer browsers for the same host into the status dot shown by Hosts - /// and host pickers. A connected source wins over a connecting or failed - /// sibling, so closing one pane cannot make an otherwise-online host appear - /// offline. + /// Records one live connection's state for Host-level consumers, including + /// the Vault and Recents dots and the agent Host listing. A connected source + /// wins over a connecting or failed sibling, so closing one pane cannot make + /// an otherwise-online Host appear offline. public func setConnectionStatus( _ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID ) { diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 6445519e..595c31be 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -37,7 +37,7 @@ final class MobileSceneModel { @ObservationIgnored private var pendingWorkspaceID: MobileWorkspace.ID? @ObservationIgnored private var pendingBatchStartup: (hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol)? - @ObservationIgnored private var recentWorkspaceIDs: [MobileWorkspace.ID] = [] + private var recentWorkspaceIDs: [MobileWorkspace.ID] = [] init(app: MobileAppModel, sessionID: String) { workspaceSession = app.workspaceSession(for: sessionID) @@ -179,21 +179,28 @@ final class MobileSceneModel { return } let kind: ConnectionHistoryEntry.Kind = connectionProtocol == .ssh ? .ssh : .telnet + guard let workspace = workspaceActivatedByHostRow(for: host) else { + open(hostID: hostID, kind: kind) + return + } + selectWorkspace(workspace.id) + } + + /// The connection a Vault Host card will reopen in this window. + func workspaceActivatedByHostRow(for host: Host) -> MobileWorkspace? { + guard let connectionProtocol = host.preferredTerminalProtocol else { return nil } + let kind: ConnectionHistoryEntry.Kind = connectionProtocol == .ssh ? .ssh : .telnet let matchingIDs = Set( workspaceSession.workspaces.lazy - .filter { $0.descriptor.hostID == hostID && $0.descriptor.kind == kind } + .filter { $0.descriptor.hostID == host.id && $0.descriptor.kind == kind } .map(\.id) ) let workspaceID = recentWorkspaceIDs.first(where: matchingIDs.contains) ?? workspaceSession.workspaces.last(where: { - $0.descriptor.hostID == hostID && $0.descriptor.kind == kind + $0.descriptor.hostID == host.id && $0.descriptor.kind == kind })?.id - guard let workspaceID else { - open(hostID: hostID, kind: kind) - return - } - selectWorkspace(workspaceID) + return workspaceID.flatMap { id in workspaceSession.workspaces.first { $0.id == id } } } func openSameDirectory(_ launch: MobileWorkspaceDirectoryLaunch) { diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 4ace166c..81c43891 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -784,8 +784,22 @@ struct MobileVaultView: View { } content: { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 12) { - HostSystemIcon(asset: entry?.asset, size: 36) - .tint(tint) + ZStack(alignment: .bottomTrailing) { + HostSystemIcon(asset: entry?.asset, size: 36) + .tint(tint) + if let workspace = reviewScene?.workspaceActivatedByHostRow(for: host), + let indicator = MobileWorkspaceConnectionIndicator.resolve( + connected: workspace.connected, + busy: workspace.busy, + reconnecting: workspace.reconnectState != nil + ) + { + Circle() + .fill(indicator.tint) + .frame(width: 8, height: 8) + .overlay { Circle().stroke(Color(uiColor: .systemBackground), lineWidth: 1.5) } + } + } VStack(alignment: .leading, spacing: 4) { Text(host.name).font(.headline).foregroundStyle(.primary).lineLimit(1) Text(app.library.preferredTerminalLoginAddress(for: host)).font(.caption.monospaced()) diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index 266d4e85..c841a4f8 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -171,6 +171,32 @@ struct MobileWorkspaceSessionTests { #expect(second.isWorkspaceUnavailable) } + @Test func vaultHostIndicatorFollowsTheWorkspaceOpenedByItsCard() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let app = try fixture.makeApp() + let scene = MobileSceneModel(app: app, sessionID: "vault-card") + let other = MobileSceneModel(app: app, sessionID: "other-window") + let host = fixture.host + + other.open(hostID: host.id, kind: .ssh) + #expect(scene.workspaceActivatedByHostRow(for: host) == nil) + + scene.open(hostID: host.id, kind: .sftp) + #expect(scene.workspaceActivatedByHostRow(for: host) == nil) + + scene.open(hostID: host.id, kind: .ssh) + let first = try #require(scene.workspaceActivatedByHostRow(for: host)) + scene.open(hostID: host.id, kind: .ssh) + let second = try #require(scene.workspaceActivatedByHostRow(for: host)) + #expect(first !== second) + + scene.selectWorkspace(first.id) + #expect(scene.workspaceActivatedByHostRow(for: host) === first) + scene.activateHost(host.id) + #expect(scene.activeWorkspace === first) + } + @Test func everyCreationEntryPointUsesTheReceivingWindow() throws { let fixture = try Fixture() defer { fixture.cleanUp() } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index b6e54e41..be78cc8e 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -1035,12 +1035,11 @@ final class AppModel { return !unsafeExtensions.contains((file.name as NSString).pathExtension.lowercased()) } - /// Mark as recently used, and flip the status dot. + /// Optimistically shows connecting until the session reports its own state. /// /// Its own method because three code paths now open hosts — one, a batch of /// workspaces, and a batch into one Workspace — and *where it lands* is the only - /// thing that should differ between them. Inlining this was fine while there - /// were two. + /// thing that should differ between them. private func beginOpening(_ host: Host, as target: HostOpenTarget) { switch target { case .preferredTerminal, .sshSession, .telnetSession: diff --git a/TFM/Application/FeatureModels/HostLibraryModel+Demo.swift b/TFM/Application/FeatureModels/HostLibraryModel+Demo.swift index ddc30501..1193fb9e 100644 --- a/TFM/Application/FeatureModels/HostLibraryModel+Demo.swift +++ b/TFM/Application/FeatureModels/HostLibraryModel+Demo.swift @@ -237,8 +237,8 @@ extension HostLibraryModel { knownHosts: knownHosts ) - // A spread of connection states so the sidebar, workspace list, and status bar - // can be checked against every dot color without a server. + // A spread of connection states so Vault and Welcome Host cards can be + // checked against every dot color without a server. store.setStatus(.connected, for: web01.id) store.setStatus(.connecting, for: db.id) if let staging = hosts.first(where: { $0.name == "staging-app" }) { diff --git a/TFM/Features/StatusBar/StatusBarView.swift b/TFM/Features/StatusBar/StatusBarView.swift index caa76063..38f0a10c 100644 --- a/TFM/Features/StatusBar/StatusBarView.swift +++ b/TFM/Features/StatusBar/StatusBarView.swift @@ -231,7 +231,6 @@ struct StatusBarView: View { sshSession?.status ?? sftpChannel?.status ?? serialSession?.status - ?? pane?.hostID.map { app.hostLibrary.status(of: $0) } ?? .disconnected }