Files

275 lines
11 KiB
Swift

// 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
}
}