// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import TermindApplication import TermindDomain import TermindPresentation import struct TermindDomain.Host nonisolated enum AppCommandSection: String, CaseIterable, Hashable, Sendable, Identifiable { case navigation case conversations case workspaces case hosts case actions nonisolated var id: Self { self } var title: String { switch self { case .navigation: String(localized: AppStrings.Navigation.navigation) case .conversations: String(localized: AppStrings.Assistant.chats) case .workspaces: String(localized: AppStrings.Navigation.workspaces) case .hosts: String(localized: AppStrings.Vault.hosts) case .actions: String(localized: AppStrings.Common.actions) } } } nonisolated enum AppCommandTarget: Hashable, Sendable { case vaultPage(VaultPage) case vault case assistant case settings case assistantConversation(ConversationModel.ID) case workspace(Workspace.ID) case workspaceTemplate(WorkspaceTemplate.ID) case connectHost(Host.ID, HostOpenTarget) case newWindow case moveWorkspaceToNewWindow(UUID) case newChat case newHost case showAssistant } nonisolated struct AppCommandDescriptor: Identifiable, Hashable, Sendable { let id: String let title: String let subtitle: String? let symbol: String let section: AppCommandSection let keywords: [String] let target: AppCommandTarget func matches(_ query: String) -> Bool { let needle = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !needle.isEmpty else { return true } return ([title, subtitle ?? ""] + keywords).contains { $0.localizedCaseInsensitiveContains(needle) } } } /// The single catalog used by Quick Open and menu-level navigation actions. /// It owns command identity and routing; views only render descriptors. @MainActor final class AppCommandRegistry { func commands(app: AppModel, window: WindowModel) -> [AppCommandDescriptor] { fixedCommands + vaultCommands + conversationCommands(window.assistantLibrary) + window.workspaces.map { workspaceCommand($0, store: app.hostLibrary) } + app.hostLibrary.sortedWorkspaceTemplates.map { workspaceTemplateCommand($0, store: app.hostLibrary) } + app.hostLibrary.searchHosts("").flatMap(hostCommands) + actionCommands(workspaceID: window.selectedWorkspaceID) } func perform(_ target: AppCommandTarget, app: AppModel, window: WindowModel) { switch target { case .vaultPage(let page): window.openPage(page) case .vault: window.selectVault() case .assistant: window.selectAssistant() case .settings: window.openSettings() case .assistantConversation(let id): window.openAssistantFullScreen(id) case .workspace(let id): window.select(id) case .workspaceTemplate(let id): guard let template = app.hostLibrary.workspaceTemplate(id) else { return } window.openWorkspaceTemplate(template) case .connectHost(let id, let target): guard let host = app.hostLibrary.host(id) else { return } app.open(host, as: target, in: window) case .newWindow: app.mainWindows.newWindow(from: window) case .moveWorkspaceToNewWindow(let id): app.mainWindows.moveWorkspace(id, from: window) case .newChat: window.openNewAssistantChat() case .newHost: window.openHostEditor(host: nil) case .showAssistant: window.showAssistant() } } private var fixedCommands: [AppCommandDescriptor] { [ AppCommandDescriptor( id: "fixed.vault", title: String(localized: AppStrings.Navigation.vault), subtitle: String(localized: AppStrings.Workspace.navigator), symbol: "lock.square.stack", section: .navigation, keywords: ["hosts", "keys", "snippets"], target: .vault ), AppCommandDescriptor( id: "fixed.assistant", title: String(localized: AppStrings.Navigation.assistant), subtitle: String(localized: AppStrings.Assistant.chats), symbol: "sparkles", section: .navigation, keywords: ["chat", "conversations"], target: .assistant ), AppCommandDescriptor( id: "fixed.settings", title: String(localized: AppStrings.Navigation.settings), subtitle: String(localized: AppStrings.Workspace.navigator), symbol: "gearshape", section: .navigation, keywords: ["preferences"], target: .settings ), ] } private var vaultCommands: [AppCommandDescriptor] { VaultPage.allCases.map { page in AppCommandDescriptor( id: "vault.\(page.rawValue)", title: page.title(), subtitle: String(localized: AppStrings.Navigation.vault), symbol: page.symbol, section: .navigation, keywords: [page.rawValue], target: .vaultPage(page) ) } } private func conversationCommands( _ library: AssistantLibraryModel ) -> [AppCommandDescriptor] { library.search("", scope: .active).map { conversation in AppCommandDescriptor( id: "conversation.\(conversation.id.uuidString)", title: conversation.derivedTitle, subtitle: String(localized: AppStrings.Assistant.chat), symbol: "sparkles", section: .conversations, // Search only what the conversation UI exposes. Hidden reasoning, // tool output, and the raw transcript deliberately stay out. keywords: conversation.messages.map(\.text), target: .assistantConversation(conversation.id) ) } } private func workspaceCommand( _ workspace: Workspace, store: HostLibraryModel ) -> AppCommandDescriptor { AppCommandDescriptor( id: "workspace.\(workspace.id.uuidString)", title: workspace.title(in: store), subtitle: String(localized: AppStrings.Vault.openWorkspace), symbol: workspace.symbol, section: .workspaces, keywords: workspace.panes.map { $0.sidebarTitle(in: store) }, target: .workspace(workspace.id) ) } private func workspaceTemplateCommand( _ template: WorkspaceTemplate, store: HostLibraryModel ) -> AppCommandDescriptor { AppCommandDescriptor( id: "workspace-template.\(template.id.uuidString)", title: template.name, subtitle: String(localized: AppStrings.Workspace.workspaceTemplate), symbol: "rectangle.split.2x1", section: .workspaces, keywords: template.layout.hostIDs.compactMap { store.host($0)?.name }, target: .workspaceTemplate(template.id) ) } private func hostCommands(_ host: Host) -> [AppCommandDescriptor] { let keywords = [host.host] + host.supportedTerminalProtocols.map { host.displayAddress(for: $0) } + host.tags var commands = host.preferredFirstTerminalProtocols.map { transport in let address = host.displayAddress(for: transport) let subtitle: LocalizedStringResource switch transport { case .ssh: subtitle = AppStrings.Connections.connectWithSsh(address) case .mosh: subtitle = AppStrings.Connections.connectWithMosh(address) case .eternalTerminal: subtitle = AppStrings.Connections.connectWithEternalTerminal(address) case .telnet: subtitle = AppStrings.Connections.connectWithTelnet(address) } return AppCommandDescriptor( id: "host.\(transport.rawValue).\(host.id.uuidString)", title: host.name, subtitle: String(localized: subtitle), symbol: transport.symbol, section: .hosts, keywords: keywords + [transport.rawValue, transport.displayName], target: .connectHost(host.id, .terminal(transport)) ) } if host.sshEnabled { commands.append( AppCommandDescriptor( id: "host.sftp.\(host.id.uuidString)", title: host.name, subtitle: String(localized: AppStrings.SFTP.browseWithSftp(host.displayAddress(for: .ssh))), symbol: "folder", section: .hosts, keywords: keywords + ["sftp", "files"], target: .connectHost(host.id, .sftpChannel) )) } return commands } private func actionCommands(workspaceID: UUID?) -> [AppCommandDescriptor] { var commands = [ AppCommandDescriptor( id: "action.new-window", title: String(localized: AppStrings.Workspace.newWindow), subtitle: String(localized: AppStrings.Common.action), symbol: MenuSymbol.newWindow, section: .actions, keywords: ["window"], target: .newWindow ), AppCommandDescriptor( id: "action.new-chat", title: String(localized: AppStrings.Assistant.newChat), subtitle: String(localized: AppStrings.Common.action), symbol: "plus.bubble", section: .actions, keywords: ["assistant", "conversation"], target: .newChat ), AppCommandDescriptor( id: "action.new-host", title: String(localized: AppStrings.Vault.newHost), subtitle: String(localized: AppStrings.Common.action), symbol: "plus.rectangle", section: .actions, keywords: ["server", "add"], target: .newHost ), ] if let workspaceID { commands.append( AppCommandDescriptor( id: "action.move-workspace-window", title: String(localized: AppStrings.Workspace.moveWorkspaceToNewWindow), subtitle: String(localized: AppStrings.Common.action), symbol: MenuSymbol.moveToNewWindow, section: .actions, keywords: ["window", "move"], target: .moveWorkspaceToNewWindow(workspaceID) )) } commands.append( AppCommandDescriptor( id: "action.show-assistant", title: String(localized: AppStrings.Assistant.showAssistant), subtitle: String(localized: AppStrings.Common.action), symbol: "sidebar.left", section: .actions, keywords: ["sidebar", "assistant", "context", "details"], target: .showAssistant )) return commands } }