275 lines
11 KiB
Swift
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
|
|
}
|
|
}
|