Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Sources/Cloud/CloudTreeTerminalRow+Directory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 27 additions & 8 deletions Sources/Surfaces/SurfaceCatalog+CloudDirectoryMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
59 changes: 53 additions & 6 deletions cmuxTests/CloudDirectoryLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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))
Expand All @@ -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()
Expand Down
Loading