diff --git a/Sources/Cloud/CloudTreeTerminalRow+Directory.swift b/Sources/Cloud/CloudTreeTerminalRow+Directory.swift index bee3d98592cb..ecafea1b2b48 100644 --- a/Sources/Cloud/CloudTreeTerminalRow+Directory.swift +++ b/Sources/Cloud/CloudTreeTerminalRow+Directory.swift @@ -5,7 +5,10 @@ extension CloudTreeTerminalRow { guard !resource.machine.isLocal else { return resource.detail.flatMap { $0.isEmpty ? nil : CloudTreeTerminalRowContent.abbreviated($0) } } - guard directoryIsCurrent, let path = resource.detail?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty else { + // `resourceForPresentation` removes unconfirmed paths before the row is built. A stale + // row may therefore safely retain the last accepted remote cwd; freshness alone must not + // replace that known path with the unavailable placeholder. + guard let path = resource.detail?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty else { return CloudWorkspaceSidebarPresentation.unavailableDirectory } // A remote absolute path must never be shortened against the Mac's HOME. diff --git a/Sources/Surfaces/SurfaceCatalog+CloudDirectoryMetadata.swift b/Sources/Surfaces/SurfaceCatalog+CloudDirectoryMetadata.swift index 9eb6c1b36685..dd6e4342bac1 100644 --- a/Sources/Surfaces/SurfaceCatalog+CloudDirectoryMetadata.swift +++ b/Sources/Surfaces/SurfaceCatalog+CloudDirectoryMetadata.swift @@ -2,17 +2,33 @@ import CmuxSurfaceCatalogModel import Foundation extension SurfaceCatalog { - /// Retain stale or not-yet-confirmed graphs for diagnostics without presenting their cwd as - /// current in the tree or CLI. A Cloud terminal's directory counts only once the machine's - /// accepted state is current; a requested launch directory or a stale graph is withheld. + /// Returns the last accepted cwd for a stale Cloud terminal, when its identity still exists. + /// + /// A stale graph is useful display state, but a resource row can also contain an optimistic + /// requested cwd. Read the cached value from the accepted graph so that a request, a retired + /// provider, or a resource whose identity disappeared cannot become a displayed path. + private func acceptedStaleCloudDirectory(for resource: SurfaceResource) -> String? { + guard resource.kind == .terminal, + resource.machine.cloudMachineID != nil, + cloudStateObservations[resource.machine]?.freshness == .stale, + let directory = cloudStates[resource.machine]?.lookupIndex.terminal(id: resource.id.key)?.cwd else { + return nil + } + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Retain stale graphs for diagnostics while presenting a previously accepted cwd as a + /// cached value. A requested launch directory or a stale graph with no accepted cwd remains + /// unavailable. func resourceForPresentation(_ resource: SurfaceResource) -> SurfaceResource { // Cloud VM freshness is tracked in `cloudStateObservations`; device // mirrors receive their directory from the synced workspace record and // intentionally have no CloudVM observation to consult. - guard resource.kind == .terminal, resource.machine.cloudMachineID != nil, - cloudStateObservations[resource.machine]?.freshness != .current else { return resource } + guard resource.kind == .terminal, resource.machine.cloudMachineID != nil else { return resource } + guard cloudStateObservations[resource.machine]?.freshness != .current else { return resource } var result = resource - result.detail = nil + result.detail = acceptedStaleCloudDirectory(for: resource) return result } @@ -63,9 +79,12 @@ extension SurfaceCatalog { let directory: String? if machine.deviceInstance != nil { directory = resource?.kind == .terminal ? resource?.detail : nil + } else if let resource, resource.kind == .terminal { + directory = current + ? cloudStates[machine]?.lookupIndex.terminal(id: projection.resource.key)?.cwd + : acceptedStaleCloudDirectory(for: resource) } else { - directory = current && resource?.kind == .terminal - ? cloudStates[machine]?.lookupIndex.terminal(id: projection.resource.key)?.cwd : nil + directory = nil } if let directory, workspace.reportedPanelDirectory(panelId: projection.panelID) == directory { continue } workspace.updateCloudPanelDirectory(panelId: projection.panelID, directory: directory) diff --git a/cmuxTests/CloudDirectoryLifecycleTests.swift b/cmuxTests/CloudDirectoryLifecycleTests.swift index 3856b6aa72f9..1d5c73fb4468 100644 --- a/cmuxTests/CloudDirectoryLifecycleTests.swift +++ b/cmuxTests/CloudDirectoryLifecycleTests.swift @@ -71,6 +71,12 @@ struct CloudDirectoryLifecycleTests { #expect(text.contains("Directory unavailable")) #expect(!text.contains("local-checkout")) #expect(!text.contains("first")) + fixture.catalog.markCloudStateStale(on: fixture.machine, reason: "reconnecting") + let stale = fixture.catalog.snapshot + let staleResource = try #require(stale.resources.first { $0.id == fixture.resourceID(0) }) + #expect(staleResource.detail == nil) + let staleRow = CloudTreeTerminalRow(resource: staleResource, isOpen: true, directoryIsCurrent: false) + #expect(staleRow.directoryText == CloudWorkspaceSidebarPresentation.unavailableDirectory) } @Test("An unconfirmed Cloud terminal withholds its requested cwd until the machine's state is current") @@ -111,20 +117,23 @@ struct CloudDirectoryLifecycleTests { #expect(snapshot.resources.first { $0.id == local }?.detail == "/Users/alice/project") } - @Test("A stale graph loses cwd trust until a fresh snapshot confirms it") + @Test("A stale graph keeps the last accepted cwd until a fresh snapshot replaces it") func reconnectAndStalePublication() throws { let fixture = try CloudDirectoryTestFixture() defer { fixture.close() } let old = try #require(fixture.provider.cloudState) - fixture.catalog.markCloudStateStale(on: fixture.machine, reason: "temporary gap") try fixture.changeDirectory("/srv/resumed", terminal: 0) #expect(fixture.workspace.reportedPanelDirectory(panelId: fixture.panels[1]) == "/home/cmux/second") fixture.catalog.markCloudStateStale(on: fixture.machine, reason: "reconnecting") - #expect(fixture.workspace.presentedCurrentDirectory == nil) + #expect(fixture.workspace.presentedCurrentDirectory == "/srv/resumed") #expect(fixture.catalog.snapshot.staleMachineIDs.contains(fixture.machine)) - let row = CloudTreeTerminalRow(resource: try #require(fixture.catalog.resources[fixture.resourceID(0)]), isOpen: true, directoryIsCurrent: false) - #expect(row.directoryText == "Directory unavailable") - #expect(try fixture.sidebarText().contains("Directory unavailable")) + let staleSnapshot = fixture.catalog.snapshot + let staleResource = try #require(staleSnapshot.resources.first { $0.id == fixture.resourceID(0) }) + #expect(staleResource.detail == "/srv/resumed") + let row = CloudTreeTerminalRow(resource: staleResource, isOpen: true, directoryIsCurrent: false) + #expect(row.directoryText == "/srv/resumed") + #expect(try !fixture.sidebarText().contains("Directory unavailable")) + #expect(try fixture.sidebarText().contains("/srv/resumed")) let current = try fixture.install(paths: ["/srv/reconnected", nil], revision: 1, generation: "replacement") #expect(fixture.workspace.presentedCurrentDirectory == "/srv/reconnected") #expect(!fixture.provider.installSnapshotIfNewer(old)) @@ -134,6 +143,44 @@ struct CloudDirectoryLifecycleTests { #expect(fixture.workspace.presentedCurrentDirectory == "/srv/reconnected") } + @Test("A replacement provider accepts its cwd and ignores retired callbacks") + func providerReplacement() throws { + let fixture = try CloudDirectoryTestFixture() + defer { fixture.close() } + let old = try #require(fixture.provider.cloudState) + fixture.catalog.markCloudStateStale(on: fixture.machine, reason: "provider replacement") + let replacement = CmuxTuiSurfaceProvider( + summary: VMSummary(id: fixture.machine.rawValue, provider: "freestyle", status: "running", image: "cmux-devbox", createdAt: 0, base: nil), + links: CloudMachineLinkManager(clientURL: nil, hostThemeColors: { nil }), catalog: fixture.catalog + ) + fixture.catalog.register(replacement) + let stale = fixture.catalog.snapshot + #expect(stale.resources.first { $0.id == fixture.resourceID(0) }?.detail == "/home/cmux/first") + + let current = try fixture.state(paths: ["/srv/replacement", nil], revision: 1, generation: "replacement") + #expect(replacement.installSnapshotIfNewer(current)) + replacement.publish(current, ports: []) + #expect(fixture.workspace.presentedCurrentDirectory == "/srv/replacement") + + fixture.provider.publish(old, ports: []) + #expect(fixture.workspace.presentedCurrentDirectory == "/srv/replacement") + #expect(fixture.catalog.cloudStates[fixture.machine] == current) + } + + @Test("Deleting a terminal clears its cached cwd") + func deletedTerminalClearsDirectory() throws { + let fixture = try CloudDirectoryTestFixture() + defer { fixture.close() } + fixture.catalog.markCloudStateStale(on: fixture.machine, reason: "refresh") + let current = try fixture.install(paths: ["/home/cmux/first"], revision: 2, generation: "replacement") + #expect(current.lookupIndex.terminal(id: "term_1") == nil) + #expect(fixture.catalog.snapshot.resources.contains { $0.id == fixture.resourceID(1) } == false) + #expect(fixture.workspace.reportedPanelDirectory(panelId: fixture.panels[1]) == nil) + let text = try fixture.sidebarText() + #expect(text.contains("Directory unavailable")) + #expect(!text.contains("/home/cmux/second")) + } + @Test("Older and equal-cursor conflicting snapshots cannot overwrite a live cd") func outOfOrderReports() throws { let fixture = try CloudDirectoryTestFixture()