Skip to content
Closed
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
15 changes: 15 additions & 0 deletions Sources/AppDelegate+CloudTerminalNavigation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

extension AppDelegate {
/// Keeps focus and workspace teardown with the live app owner at effect time.
static func makeCloudTerminalNavigationHost() -> CloudTerminalNavigationHost {
CloudTerminalNavigationHost(
focus: { panelID, workspaceID in SurfacePaneFactory.focus(panelID: panelID, in: workspaceID) },
closeWorkspace: { workspaceID in
guard let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceID),
let workspace = manager.tabs.first(where: { $0.id == workspaceID }) else { return }
_ = manager.closeWorkspaceNonInteractively(workspace, recordHistory: false, allowPinned: true)
}
)
}
}
13 changes: 13 additions & 0 deletions Sources/Cloud/CloudTerminalNavigationCatalog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

/// Catalog capabilities consumed by terminal navigation, without owning a catalog or its stores.
@MainActor
protocol CloudTerminalNavigationCatalog: AnyObject {
func checkCloudWorkspaceNavigation(machine: SurfaceMachineID, workspaceID: String) throws
func localWorkspaceShowing(remoteWorkspaceID: String, placements: [SurfaceResourcePlacement]) -> UUID?
func projectTerminal(_ resource: SurfaceResourceID, in workspaceID: UUID, view: SurfaceRemoteView?) async throws -> SurfaceProjection
func terminalWorkspaceLayout(machine: SurfaceMachineID, workspaceID: String) async -> SurfaceProjectionLayout?
func openTerminalWorkspace(_ group: SurfaceResourceGroup, title: String, layout: SurfaceProjectionLayout?) async throws
-> (workspaceID: UUID, projections: [SurfaceProjection])
func bindTerminalWorkspace(localWorkspaceID: UUID, machine: SurfaceMachineID, remoteWorkspaceID: String, generatedTitle: String)
}
8 changes: 8 additions & 0 deletions Sources/Cloud/CloudTerminalNavigationHost.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

/// App-owned presentation effects used after catalog navigation succeeds or is cancelled.
@MainActor
struct CloudTerminalNavigationHost {
var focus: @MainActor (_ panelID: UUID, _ workspaceID: UUID) -> Void
var closeWorkspace: @MainActor (UUID) -> Void
}
10 changes: 10 additions & 0 deletions Sources/Cloud/CloudTerminalNavigationScheduling.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

/// Submits one navigation per workspace to the app's existing operation owner.
@MainActor
protocol CloudTerminalNavigationScheduling: AnyObject {
typealias Operation = @MainActor () async throws -> Void

@discardableResult
func start(key: String, _ operation: @escaping Operation) -> Bool
}
3 changes: 1 addition & 2 deletions Sources/Cloud/CloudTreeNodeActions+WorkspaceLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ extension CloudTreeNodeActions {
/// machine's raw provider id. `hostName` (the machine's friendly label)
/// only shows up when the workspace itself has no name to show.
static func localWorkspaceTitle(hostName: String, group: SurfaceResourceGroup) -> String {
let name = group.title.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? hostName : name
group.localWorkspaceTitle(hostName: hostName)
}
/// The machine's friendly label — `SurfaceMachineInfo.name` (the same
/// preferred name its own sidebar row shows), never the raw provider VM
Expand Down
6 changes: 5 additions & 1 deletion Sources/Cloud/CloudTreeNodeActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ struct CloudTreeNodeActions {

@MainActor
static func bound(
navigationHost: CloudTerminalNavigationHost,
catalog: @escaping @MainActor () -> SurfaceCatalog,
selectedWorkspaceID: @escaping @MainActor () -> UUID?,
selectLocalWorkspace: @escaping @MainActor (UUID) -> Void,
Expand Down Expand Up @@ -410,10 +411,13 @@ struct CloudTreeNodeActions {
)
actions.organize = { action, id, _ in catalog().organizeSidebar(action, nodeID: id) }
actions.refreshMachine = refreshMachine
let navigationRun: CloudTreeTerminalNavigationCoordinator.Run = run
let navigationRun: CloudTreeTerminalNavigationCoordinator.Run = { label, operation in
run(label) { catalog in try await operation(catalog) }
}
let navigation = CloudTreeTerminalNavigationCoordinator(
machineName: machineName,
run: navigationRun,
host: navigationHost,
operationController: operationController ?? AppDelegate.shared?.cloudWorkspaceOperationController
)
actions.openRemoteTerminal = { navigation.open(machine: $0, group: $1, resource: $2, view: $3, openIn: $4) }
Expand Down
27 changes: 27 additions & 0 deletions Sources/Cloud/CloudTreeOutlineView+TerminalNavigation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

extension CloudTreeOutlineView.Coordinator {
/// Resolves a terminal row's workspace parent before dispatching its open verb.
func openTerminalRow(_ node: CloudTreeNode, row: CloudTreeTerminalRow) {
if let parent = outlineView?.parent(forItem: node) as? CloudTreeNode,
case .workspace(let machine, let workspace, _, _, let openIn) = parent.kind {
guard machine == row.resource.machine,
let group = parent.dragGroup,
group.remoteWorkspaceID == workspace.id,
row.remoteView?.workspace.id == nil || row.remoteView?.workspace.id == workspace.id else {
#if DEBUG
cmuxDebugLog("cloudTree.open terminal staleOwner resource=\(row.resource.id.rawValue)")
#endif
return
}
nodeActions.openRemoteTerminal(machine, group, row.resource.id, row.remoteView, openIn)
} else if let view = row.remoteView {
#if DEBUG
cmuxDebugLog("cloudTree.open terminal missingOwner resource=\(row.resource.id.rawValue) view=\(view.tabID)")
#endif
} else {
// Pool terminals have no owner; retain their selected-workspace behavior.
nodeActions.project(row.resource.id, .tab, true)
}
}
}
1 change: 1 addition & 0 deletions Sources/Cloud/CloudTreeStyleGalleryWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ private struct CloudTreeStyleGalleryColumn: View {
localWorkspaces: localWorkspaces,
machineActions: MachineRowActions.bound(onDidMutate: {}),
nodeActions: CloudTreeNodeActions.bound(
navigationHost: AppDelegate.makeCloudTerminalNavigationHost(),
catalog: { SurfaceCatalog.shared },
selectedWorkspaceID: { AppDelegate.shared?.tabManager?.selectedTabId },
selectLocalWorkspace: { workspaceID in
Expand Down
106 changes: 20 additions & 86 deletions Sources/Cloud/CloudTreeTerminalNavigationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,23 @@ import Foundation
final class CloudTreeTerminalNavigationCoordinator {
typealias Run = @MainActor (
_ label: String,
_ operation: @escaping @MainActor (SurfaceCatalog) async throws -> Void
_ operation: @escaping @MainActor (any CloudTerminalNavigationCatalog) async throws -> Void
) -> Task<Void, Never>

private let machineName: @MainActor (SurfaceMachineID) -> String
private let run: Run
private let operationController: CloudWorkspaceOperationController?
private let host: CloudTerminalNavigationHost
private let operationController: (any CloudTerminalNavigationScheduling)?

init(
machineName: @escaping @MainActor (SurfaceMachineID) -> String,
run: @escaping Run,
operationController: CloudWorkspaceOperationController?
host: CloudTerminalNavigationHost,
operationController: (any CloudTerminalNavigationScheduling)?
) {
self.machineName = machineName
self.run = run
self.host = host
self.operationController = operationController
}

Expand Down Expand Up @@ -68,7 +71,7 @@ final class CloudTreeTerminalNavigationCoordinator {
}

private func navigate(
catalog: SurfaceCatalog,
catalog: any CloudTerminalNavigationCatalog,
machine: SurfaceMachineID,
group: SurfaceResourceGroup,
resource: SurfaceResourceID,
Expand All @@ -77,79 +80,48 @@ final class CloudTreeTerminalNavigationCoordinator {
openIn: UUID?
) async throws {
try catalog.checkCloudWorkspaceNavigation(machine: machine, workspaceID: remoteWorkspaceID)
let localWorkspaceID = CloudTreeNodeBuilder.localWorkspaceShowing(
let localWorkspaceID = catalog.localWorkspaceShowing(
remoteWorkspaceID: remoteWorkspaceID,
placements: group.placements,
snapshot: catalog.snapshot
placements: group.placements
) ?? openIn
if let localWorkspaceID {
let opened: (projection: SurfaceProjection, reused: Bool)
if let view {
opened = try await catalog.project(
resource,
into: .workspace(id: localWorkspaceID, placement: .tab),
focus: true,
reuseExisting: true,
reuseInWorkspace: localWorkspaceID,
remoteView: view
)
} else {
opened = try await catalog.project(
resource,
into: .workspace(id: localWorkspaceID, placement: .tab),
focus: true,
reuseExisting: true,
reuseInWorkspace: localWorkspaceID
)
}
SurfacePaneFactory.focus(
panelID: opened.projection.panelID,
in: opened.projection.workspaceID
)
let projection = try await catalog.projectTerminal(resource, in: localWorkspaceID, view: view)
host.focus(projection.panelID, projection.workspaceID)
return
}

let layout = await CloudWorkspaceLayoutTranslator.fetch(
let layout = await catalog.terminalWorkspaceLayout(
machine: machine,
workspaceID: remoteWorkspaceID,
catalog: catalog
workspaceID: remoteWorkspaceID
)
try catalog.checkCloudWorkspaceNavigation(machine: machine, workspaceID: remoteWorkspaceID)
let opened = try await catalog.projectGroupAsNewLocalWorkspace(
let opened = try await catalog.openTerminalWorkspace(
group,
title: CloudTreeNodeActions.localWorkspaceTitle(
hostName: machineName(machine),
group: group
),
focus: true,
host: .appOptimistic,
title: group.localWorkspaceTitle(hostName: machineName(machine)),
layout: layout
)
guard !Task.isCancelled else {
closeOpenedWorkspace(opened.workspaceID)
host.closeWorkspace(opened.workspaceID)
throw CancellationError()
}
catalog.bindCloudWorkspace(
catalog.bindTerminalWorkspace(
localWorkspaceID: opened.workspaceID,
machine: machine,
remoteWorkspaceID: remoteWorkspaceID,
generatedTitle: CloudTreeNodeActions.localWorkspaceTitle(
hostName: machineName(machine),
group: group
)
generatedTitle: group.localWorkspaceTitle(hostName: machineName(machine))
)
guard let target = targetProjection(
in: opened.projections,
resource: resource,
view: view,
remoteWorkspaceID: remoteWorkspaceID
) else {
closeOpenedWorkspace(opened.workspaceID)
host.closeWorkspace(opened.workspaceID)
throw SurfaceCatalogError.destinationNotFound(
String(localized: "cloudTree.error.terminalRestoreFailed", defaultValue: "The clicked Cloud terminal could not be restored in its workspace.")
)
}
SurfacePaneFactory.focus(panelID: target.panelID, in: target.workspaceID)
host.focus(target.panelID, target.workspaceID)
}

private func targetProjection(
Expand All @@ -166,42 +138,4 @@ final class CloudTreeTerminalNavigationCoordinator {
}
return matches.count == 1 ? matches[0] : nil
}

private func closeOpenedWorkspace(_ workspaceID: UUID) {
guard let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceID),
let workspace = manager.tabs.first(where: { $0.id == workspaceID }) else {
return
}
_ = manager.closeWorkspaceNonInteractively(
workspace,
recordHistory: false,
allowPinned: true
)
}
}

extension CloudTreeOutlineView.Coordinator {
/// Resolves a terminal row's workspace parent before dispatching its open verb.
func openTerminalRow(_ node: CloudTreeNode, row: CloudTreeTerminalRow) {
if let parent = outlineView?.parent(forItem: node) as? CloudTreeNode,
case .workspace(let machine, let workspace, _, _, let openIn) = parent.kind {
guard machine == row.resource.machine,
let group = parent.dragGroup,
group.remoteWorkspaceID == workspace.id,
row.remoteView?.workspace.id == nil || row.remoteView?.workspace.id == workspace.id else {
#if DEBUG
cmuxDebugLog("cloudTree.open terminal staleOwner resource=\(row.resource.id.rawValue)")
#endif
return
}
nodeActions.openRemoteTerminal(machine, group, row.resource.id, row.remoteView, openIn)
} else if let view = row.remoteView {
#if DEBUG
cmuxDebugLog("cloudTree.open terminal missingOwner resource=\(row.resource.id.rawValue) view=\(view.tabID)")
#endif
} else {
// Pool terminals have no owner; retain their selected-workspace behavior.
nodeActions.project(row.resource.id, .tab, true)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
extension CloudWorkspaceOperationController: CloudTerminalNavigationScheduling {}
1 change: 1 addition & 0 deletions Sources/Cloud/MachinesPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ struct MachinesPanelView: View {
viewModel.bindMachineOrdering(to: &machineActions)
machineActions.create = MachineCreateRowActions.bound(coordinator: viewModel.createCoordinator)
let nodeActions = CloudTreeNodeActions.bound(
navigationHost: AppDelegate.makeCloudTerminalNavigationHost(),
catalog: { SurfaceCatalog.shared },
selectedWorkspaceID: { tabManager?.selectedTabId },
selectLocalWorkspace: { workspaceID in
Expand Down
9 changes: 9 additions & 0 deletions Sources/Cloud/SurfaceResourceGroup+CloudNavigation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

extension SurfaceResourceGroup {
/// Uses the workspace's own name, falling back to the machine's friendly label.
func localWorkspaceTitle(hostName: String) -> String {
let name = title.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? hostName : name
}
}
41 changes: 41 additions & 0 deletions Sources/Surfaces/SurfaceCatalog+CloudTerminalNavigation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

extension SurfaceCatalog: CloudTerminalNavigationCatalog {
func localWorkspaceShowing(remoteWorkspaceID: String, placements: [SurfaceResourcePlacement]) -> UUID? {
CloudTreeNodeBuilder.localWorkspaceShowing(
remoteWorkspaceID: remoteWorkspaceID, placements: placements, snapshot: snapshot
)
}

func projectTerminal(_ resource: SurfaceResourceID, in workspaceID: UUID, view: SurfaceRemoteView?) async throws -> SurfaceProjection {
let opened: (projection: SurfaceProjection, reused: Bool)
if let view {
opened = try await project(
resource, into: .workspace(id: workspaceID, placement: .tab),
focus: true, reuseExisting: true, reuseInWorkspace: workspaceID, remoteView: view
)
} else {
opened = try await project(
resource, into: .workspace(id: workspaceID, placement: .tab),
focus: true, reuseExisting: true, reuseInWorkspace: workspaceID
)
}
return opened.projection
}

func terminalWorkspaceLayout(machine: SurfaceMachineID, workspaceID: String) async -> SurfaceProjectionLayout? {
await CloudWorkspaceLayoutTranslator.fetch(machine: machine, workspaceID: workspaceID, catalog: self)
}

func openTerminalWorkspace(_ group: SurfaceResourceGroup, title: String, layout: SurfaceProjectionLayout?) async throws
-> (workspaceID: UUID, projections: [SurfaceProjection]) {
try await projectGroupAsNewLocalWorkspace(group, title: title, focus: true, host: .appOptimistic, layout: layout)
}

func bindTerminalWorkspace(localWorkspaceID: UUID, machine: SurfaceMachineID, remoteWorkspaceID: String, generatedTitle: String) {
bindCloudWorkspace(
localWorkspaceID: localWorkspaceID, machine: machine, remoteWorkspaceID: remoteWorkspaceID,
generatedTitle: generatedTitle
)
}
}
Loading
Loading