diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index fc5f9804470a..12f4de80e1ec 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -608614,6 +608614,131 @@ } } } + }, + "commandPalette.kind.cloudWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud Workspace" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Arbeitsbereich" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace de travail cloud" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل سحابية" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio de trabajo en la nube" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "雲端工作區" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "云工作区" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클라우드 워크스페이스" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラウドワークスペース" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Radni prostor u oblaku" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Cloud-arbejdsområde" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Spazio di lavoro cloud" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កន្លែងធ្វើការលើ Cloud" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skyarbeidsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Obszar roboczy w chmurze" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Workspace na nuvem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Облачная рабочая область" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซบนคลาวด์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bulut çalışma alanı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Хмарний робочий простір" + } + } + } } }, "version": "1.0" diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8cbbff5f8f64..e55ab2a63012 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -19,6 +19,7 @@ import CmuxSidebarProviderKit import CmuxExtensionSidebarExamples import CmuxSettingsUI import CmuxSidebar +import CmuxSurfaceCatalogModel import CmuxSidebarRemoteRender import CmuxSwiftRender import CmuxSwiftRenderUI @@ -1003,6 +1004,10 @@ struct ContentView: View { @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] @State private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] + @State private var commandPaletteCloudWorkspaceTargetsCache: [CommandPaletteCloudWorkspaceTarget] = [] + @State private var commandPaletteCloudWorkspaceTargetsFingerprint: Int? + @State private var commandPaletteCloudWorkspaceTargetsCacheRevision: UInt64 = 0 + @State private var commandPaletteCloudWorkspaceTargetsCachedRevision: UInt64? private var isCommandPalettePresented: Bool { commandPaletteOverlayState.isCommandPalettePresented @@ -2686,6 +2691,17 @@ struct ContentView: View { } }) + view = AnyView(view.onReceive( + NotificationCenter.default.publisher(for: SurfaceCatalog.didChangeNotification, object: SurfaceCatalog.shared) + ) { _ in + invalidateCommandPaletteCloudWorkspaceTargets() + }) + view = AnyView(view.onReceive( + NotificationCenter.default.publisher(for: CloudSidebarOrganizationStore.didChangeNotification, object: SurfaceCatalog.shared.sidebarOrganization) + ) { _ in + invalidateCommandPaletteCloudWorkspaceTargets() + }) + view = AnyView(view.onChange(of: tabManager.selectedTabId) { newValue in // SwiftUI may deliver an earlier selection after the model has // already advanced again. Reconcile the current model value once, @@ -5071,7 +5087,8 @@ struct ContentView: View { return commandPaletteEntriesFingerprint( for: scope, includeSurfaces: commandPaletteSwitcherIncludesSurfaceEntries, - commandsContext: scope == .commands ? commandPaletteCachedCommandsContext() : nil + commandsContext: scope == .commands ? commandPaletteCachedCommandsContext() : nil, + cloudWorkspaceTargets: nil ) } @@ -5179,20 +5196,25 @@ struct ContentView: View { private func commandPaletteEntries(for scope: CommandPaletteListScope) -> [CommandPaletteCommand] { commandPaletteEntries( for: scope, - includeSurfaces: commandPaletteSwitcherIncludesSurfaceEntries + includeSurfaces: commandPaletteSwitcherIncludesSurfaceEntries, + cloudWorkspaceTargets: scope == .switcher ? commandPaletteCloudWorkspaceTargets() : nil ) } private func commandPaletteEntries( for scope: CommandPaletteListScope, includeSurfaces: Bool, - commandsContext: CommandPaletteCommandsContext? = nil + commandsContext: CommandPaletteCommandsContext? = nil, + cloudWorkspaceTargets: [CommandPaletteCloudWorkspaceTarget]? = nil ) -> [CommandPaletteCommand] { switch scope { case .commands: return commandPaletteCommands(commandsContext: commandsContext ?? commandPaletteCachedCommandsContext()) case .switcher: - return commandPaletteSwitcherEntries(includeSurfaces: includeSurfaces) + return commandPaletteSwitcherEntries( + includeSurfaces: includeSurfaces, + cloudWorkspaceTargets: cloudWorkspaceTargets + ) } } @@ -5228,10 +5250,14 @@ struct ContentView: View { let commandsContext = scope == .commands ? commandPaletteCommandsContext(terminalOpenTargets: terminalOpenTargets) : nil + let cloudWorkspaceTargets = scope == .switcher + ? commandPaletteCloudWorkspaceTargets() + : nil let fingerprint = commandPaletteEntriesFingerprint( for: scope, includeSurfaces: includeSurfaces, - commandsContext: commandsContext + commandsContext: commandsContext, + cloudWorkspaceTargets: cloudWorkspaceTargets ) guard force || cachedCommandPaletteScope != scope || cachedCommandPaletteFingerprint != fingerprint else { return @@ -5240,7 +5266,8 @@ struct ContentView: View { let entries = commandPaletteEntries( for: scope, includeSurfaces: includeSurfaces, - commandsContext: commandsContext + commandsContext: commandsContext, + cloudWorkspaceTargets: cloudWorkspaceTargets ) commandPaletteSearchCommandsByID = CommandPaletteSearchOrchestrator.firstValueDictionary( entries, @@ -5613,7 +5640,8 @@ struct ContentView: View { private func commandPaletteEntriesFingerprint( for scope: CommandPaletteListScope, includeSurfaces: Bool, - commandsContext: CommandPaletteCommandsContext? = nil + commandsContext: CommandPaletteCommandsContext? = nil, + cloudWorkspaceTargets: [CommandPaletteCloudWorkspaceTarget]? = nil ) -> Int { switch scope { case .commands: @@ -5621,7 +5649,10 @@ struct ContentView: View { commandsContext: commandsContext ?? commandPaletteCachedCommandsContext() ) case .switcher: - return commandPaletteSwitcherEntriesFingerprint(includeSurfaces: includeSurfaces) + return commandPaletteSwitcherEntriesFingerprint( + includeSurfaces: includeSurfaces, + cloudWorkspaceTargets: cloudWorkspaceTargets + ) } } @@ -5632,7 +5663,10 @@ struct ContentView: View { return hasher.finalize() } - private func commandPaletteSwitcherEntriesFingerprint(includeSurfaces: Bool) -> Int { + private func commandPaletteSwitcherEntriesFingerprint( + includeSurfaces: Bool, + cloudWorkspaceTargets: [CommandPaletteCloudWorkspaceTarget]? = nil + ) -> Int { if commandPaletteCurrentWorkSnapshot != nil { var hasher = Hasher() hasher.combine("current-work") @@ -5672,7 +5706,9 @@ struct ContentView: View { } ) } - return CommandPaletteSwitcherFingerprintContext.fingerprint(windowContexts: fingerprintContexts) + var fingerprint = CommandPaletteSwitcherFingerprintContext.fingerprint(windowContexts: fingerprintContexts) + fingerprint = fingerprint &* 31 &+ (commandPaletteCloudWorkspaceTargetsFingerprint ?? 0) + return fingerprint } private static func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set) -> Text { @@ -5743,7 +5779,94 @@ struct ContentView: View { } } - private func commandPaletteSwitcherEntries(includeSurfaces: Bool) -> [CommandPaletteCommand] { + private struct CommandPaletteCloudWorkspaceTarget { + let machine: SurfaceMachineID + let workspace: SurfaceRemoteWorkspace + let group: SurfaceResourceGroup + } + + private func commandPaletteCloudWorkspaceTargets() -> [CommandPaletteCloudWorkspaceTarget] { + guard CloudMachinesFeature.isEnabled else { return [] } + if commandPaletteCloudWorkspaceTargetsCachedRevision == commandPaletteCloudWorkspaceTargetsCacheRevision { + return commandPaletteCloudWorkspaceTargetsCache + } + let catalog = SurfaceCatalog.shared + let snapshot = catalog.snapshot + let allNodes = CloudTreeNodeBuilder.nodes( + machines: [], + snapshot: snapshot, + localWorkspaces: [], + includeLocalMachine: false + ) + let sidebarNodes = CloudSidebarOrganizationTree(nodes: allNodes).arrange( + using: catalog.sidebarOrganization.state + ) + let sidebarWorkspaceIDs = Set( + CloudTreeNodeBuilder.flattened(sidebarNodes).compactMap { node -> String? in + guard case .workspace(let machine, let workspace, _, _, _) = node.kind else { return nil } + return "\(machine.rawValue):\(workspace.id)" + } + ) + + let orderedNodes = CloudTreeNodeBuilder.flattened(sidebarNodes) + + CloudTreeNodeBuilder.flattened(allNodes).filter { node in + guard case .workspace(let machine, let workspace, _, _, _) = node.kind else { return false } + return !sidebarWorkspaceIDs.contains("\(machine.rawValue):\(workspace.id)") + } + + var seen = Set() + let targets: [CommandPaletteCloudWorkspaceTarget] = orderedNodes.compactMap { node in + guard case .workspace(let machine, let workspace, _, _, _) = node.kind, + let group = node.dragGroup, + seen.insert("\(machine.rawValue):\(workspace.id)").inserted else { return nil } + return CommandPaletteCloudWorkspaceTarget(machine: machine, workspace: workspace, group: group) + } + var fingerprintHasher = Hasher() + for target in targets { + fingerprintHasher.combine(target.machine.rawValue) + let machineName = catalog.machineInfo(for: target.machine)?.name ?? target.machine.rawValue + fingerprintHasher.combine(machineName) + fingerprintHasher.combine(target.workspace.id) + fingerprintHasher.combine(target.workspace.name) + fingerprintHasher.combine(target.group) + } + commandPaletteCloudWorkspaceTargetsFingerprint = fingerprintHasher.finalize() + commandPaletteCloudWorkspaceTargetsCache = targets + commandPaletteCloudWorkspaceTargetsCachedRevision = commandPaletteCloudWorkspaceTargetsCacheRevision + return targets + } + + private func openCommandPaletteCloudWorkspace(_ target: CommandPaletteCloudWorkspaceTarget) { + let actions = CloudTreeNodeActions.bound( + navigationHost: AppDelegate.makeCloudTerminalNavigationHost(), + catalog: { SurfaceCatalog.shared }, + selectedWorkspaceID: { self.tabManager.selectedTabId }, + selectLocalWorkspace: { workspaceID in self.tabManager.selectedTabId = workspaceID }, + onDidMutate: {}, + onFailure: { _ in NSSound.beep() }, + refresh: {}, + workspaceCreationHost: { CloudWorkspaceCreationHost(manager: self.tabManager) } + ) + actions.openWorkspace(target.machine, target.workspace, target.group) + } + + private func invalidateCommandPaletteCloudWorkspaceTargets() { + commandPaletteCloudWorkspaceTargetsCacheRevision &+= 1 + commandPaletteCloudWorkspaceTargetsCachedRevision = nil + commandPaletteCloudWorkspaceTargetsCache = [] + commandPaletteCloudWorkspaceTargetsFingerprint = nil + guard isCommandPalettePresented, + commandPaletteListScope == .switcher else { return } + scheduleCommandPaletteResultsRefresh( + query: commandPaletteQuery, + forceSearchCorpusRefresh: true + ) + } + + private func commandPaletteSwitcherEntries( + includeSurfaces: Bool, + cloudWorkspaceTargets: [CommandPaletteCloudWorkspaceTarget]? = nil + ) -> [CommandPaletteCommand] { if let snapshot = commandPaletteCurrentWorkSnapshot { return commandPaletteCurrentWorkEntries(snapshot: snapshot) } @@ -5854,6 +5977,36 @@ struct ContentView: View { } } + let cloudWorkspaceKind = String(localized: "commandPalette.kind.cloudWorkspace", defaultValue: "Cloud Workspace") + for target in cloudWorkspaceTargets ?? commandPaletteCloudWorkspaceTargets() { + let machineName = SurfaceCatalog.shared.machineInfo(for: target.machine)?.name ?? target.machine.rawValue + let title = target.workspace.name + let commandID = "switcher.cloudWorkspace.\(target.machine.rawValue).\(target.workspace.id)" + let keywords = CommandPaletteSwitcherSearchIndexer( + baseKeywords: [ + "cloud", "workspace", "remote", "vm", "open", "go", "switch", title, machineName + ], + metadata: CommandPaletteSwitcherSearchMetadata(), + detail: .workspace + ).keywords + entries.append( + CommandPaletteCommand( + id: commandID, + rank: nextRank, + title: title, + subtitle: Self.commandPaletteSwitcherSubtitle(base: cloudWorkspaceKind + " • " + machineName, windowLabel: nil), + shortcutHint: nil, + kindLabel: cloudWorkspaceKind, + keywords: keywords, + dismissOnRun: true, + action: { + self.openCommandPaletteCloudWorkspace(target) + } + ) + ) + nextRank += 1 + } + return entries } @@ -10027,6 +10180,7 @@ struct ContentView: View { } private func openCommandPaletteSwitcher() { + invalidateCommandPaletteCloudWorkspaceTargets() handleCommandPaletteListRequest(scope: .switcher) }