| 1 | import { useMemo } from "react"; |
| 2 | import { app } from "../lib/bridge"; |
| 3 | import { useCommittedCommand } from "../lib/useCommittedCommand"; |
| 4 | import { useGlobalShortcut } from "../lib/keyboardShortcuts"; |
| 5 | import { clearThemePack } from "../lib/themePack"; |
| 6 | import { paletteSessionDisplayTitle, paletteSessionHint, paletteSessionKeywords, sessionActivityTime } from "../lib/session"; |
| 7 | import { useOverlayStore } from "../store/overlays"; |
| 8 | import { useAppNavigationStore } from "../store/appNavigation"; |
| 9 | import { useRemoteStore } from "../store/remote"; |
| 10 | import { activeTabMirror } from "./activeTabMirror"; |
| 11 | import type { RemoteHostView, SessionMeta } from "../lib/types"; |
| 12 | import type { Translator } from "../lib/i18n"; |
| 13 | import type { PaletteItem } from "../components/CommandPalette"; |
| 14 | |
| 15 | export type PaletteCommandsInput = { |
| 16 | managementActive: boolean; |
| 17 | activeTabId: string | undefined; |
| 18 | remoteSurfaceActive: boolean; |
| 19 | t: Translator; |
| 20 | notice(message: string, kind?: "info" | "warn" | "error"): void; |
| 21 | showToast(message: string, level: "info" | "warn" | "error", options?: { durationMs?: number }): void; |
| 22 | ports: { |
| 23 | handleNewTab(): void; |
| 24 | listSessions(): Promise<SessionMeta[]>; |
| 25 | openTrash(): void; |
| 26 | onResumeSession(session: SessionMeta): Promise<void>; |
| 27 | openRemoteWorkspaceFromStatus(host: RemoteHostView): void; |
| 28 | connectAndOpenRemoteWorkspace(host: RemoteHostView): void; |
| 29 | toggleTerminalPanel(): void; |
| 30 | setTasksOpen(open: false | "session" | "all"): void; |
| 31 | handleTabClose(id: string): void; |
| 32 | toggleSidebar(): void; |
| 33 | returnToWorkspace(): void; |
| 34 | }; |
| 35 | }; |
| 36 | |
| 37 | /** |
| 38 | * Owns the command palette: its open action (snapshotting sessions and |
| 39 | * extension actions), its items and the global command shortcuts that open it |
| 40 | * or the new-session/settings/tab-close/shortcuts/sidebar surfaces. Session, |
| 41 | * extension, remote-host and navigation targets come from their stores. |
| 42 | */ |
| 43 | export function usePaletteCommands(input: PaletteCommandsInput) { |
| 44 | const { managementActive, activeTabId, remoteSurfaceActive, t, notice, showToast, ports } = input; |
| 45 | const setPaletteOpen = useOverlayStore((state) => state.setPaletteOpen); |
| 46 | const paletteSessions = useOverlayStore((state) => state.paletteSessions); |
| 47 | const setPaletteSessions = useOverlayStore((state) => state.setPaletteSessions); |
| 48 | const paletteExtensionActions = useOverlayStore((state) => state.paletteExtensionActions); |
| 49 | const setPaletteExtensionActions = useOverlayStore((state) => state.setPaletteExtensionActions); |
| 50 | const setShortcutsOpen = useOverlayStore((state) => state.setShortcutsOpen); |
| 51 | const setTransientOverlayDismissSignal = useOverlayStore((state) => state.setTransientOverlayDismissSignal); |
| 52 | const remoteHosts = useRemoteStore((state) => state.hosts); |
| 53 | const remoteStatuses = useRemoteStore((state) => state.statuses); |
| 54 | |
| 55 | const closeTransientOverlays = useCommittedCommand(() => { |
| 56 | setTransientOverlayDismissSignal((signal) => signal + 1); |
| 57 | }); |
| 58 | |
| 59 | const openPalette = useCommittedCommand(async () => { |
| 60 | closeTransientOverlays(); |
| 61 | setPaletteOpen(true); |
| 62 | setPaletteSessions(await ports.listSessions().catch(() => [])); |
| 63 | setPaletteExtensionActions(await app.ExtensionActions(activeTabMirror().current ?? "").catch(() => [])); |
| 64 | }); |
| 65 | |
| 66 | useGlobalShortcut("commandPalette.open", () => { |
| 67 | setPaletteOpen((current) => { |
| 68 | if (!current) void openPalette(); |
| 69 | return !current; // toggle the state so the palette actually opens/closes |
| 70 | }); |
| 71 | }, [openPalette]); |
| 72 | useGlobalShortcut("app.newSession", () => void ports.handleNewTab(), [ports.handleNewTab]); |
| 73 | useGlobalShortcut("settings.open", () => { |
| 74 | closeTransientOverlays(); |
| 75 | useAppNavigationStore.getState().setSettingsTarget(useAppNavigationStore.getState().lastSettingsTarget); |
| 76 | }, [closeTransientOverlays]); |
| 77 | useGlobalShortcut("tab.close", () => { |
| 78 | if (managementActive) ports.returnToWorkspace(); |
| 79 | else if (activeTabId) void ports.handleTabClose(activeTabId); |
| 80 | }, [activeTabId, managementActive, ports.handleTabClose, ports.returnToWorkspace], managementActive || Boolean(activeTabId)); |
| 81 | useGlobalShortcut("shortcuts.show", () => setShortcutsOpen(true)); |
| 82 | useGlobalShortcut("sidebar.toggle", ports.toggleSidebar, [ports.toggleSidebar], !managementActive); |
| 83 | |
| 84 | const paletteItems = useMemo<PaletteItem[]>(() => { |
| 85 | const navigation = useAppNavigationStore.getState(); |
| 86 | const cmds: PaletteItem[] = [ |
| 87 | { id: "cmd-new", group: t("palette.group.commands"), title: t("palette.cmd.newSession"), icon: "new", compact: true, keywords: ["new", "新建"], run: () => void ports.handleNewTab() }, |
| 88 | { id: "cmd-automation", group: t("palette.group.commands"), title: t("sidebar.automation"), icon: "automation", compact: true, keywords: ["automation", "自动化"], run: () => navigation.openPage({ kind: "automation" }) }, |
| 89 | { id: "cmd-trash", group: t("palette.group.commands"), title: t("palette.cmd.trash"), icon: "trash", compact: true, keywords: ["trash", "回收站"], run: () => void ports.openTrash() }, |
| 90 | { id: "cmd-settings", group: t("palette.group.commands"), title: t("palette.cmd.settings"), icon: "settings", compact: true, keywords: ["settings", "设置"], run: () => navigation.setSettingsTarget(navigation.lastSettingsTarget) }, |
| 91 | { id: "cmd-appearance", group: t("palette.group.commands"), title: t("palette.cmd.appearance"), icon: "appearance", compact: true, keywords: ["theme", "appearance", "外观", "主题"], run: () => navigation.setSettingsTarget("appearance") }, |
| 92 | { |
| 93 | id: "cmd-theme-reset", |
| 94 | group: t("palette.group.commands"), |
| 95 | title: t("settings.themeLibrary.reset"), |
| 96 | icon: "appearance", |
| 97 | compact: true, |
| 98 | keywords: ["theme", "reset", "default", "恢复默认", "主题"], |
| 99 | run: () => { |
| 100 | void app.ResetThemePack() |
| 101 | .then(() => { |
| 102 | clearThemePack(); |
| 103 | notice(t("settings.themeReset")); |
| 104 | }) |
| 105 | .catch((err) => showToast(err instanceof Error ? err.message : String(err), "error")); |
| 106 | }, |
| 107 | }, |
| 108 | { id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), icon: "memory", compact: true, keywords: ["memory", "记忆"], run: () => navigation.setSettingsTarget("memory") }, |
| 109 | { id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), icon: "models", compact: true, keywords: ["model", "模型"], run: () => navigation.setSettingsTarget("models") }, |
| 110 | { |
| 111 | id: "cmd-usage-stats", |
| 112 | group: t("palette.group.commands"), |
| 113 | title: t("palette.cmd.usageStats"), |
| 114 | icon: "usage", |
| 115 | compact: true, |
| 116 | keywords: ["usage", "stats", "statistics", "用量", "统计"], |
| 117 | run: () => { |
| 118 | navigation.setSettingsFocus((current) => ({ |
| 119 | target: "model-stats", |
| 120 | requestId: (current?.requestId ?? 0) + 1, |
| 121 | })); |
| 122 | navigation.setSettingsTarget("models"); |
| 123 | }, |
| 124 | }, |
| 125 | { id: "cmd-task-center", group: t("palette.group.commands"), title: t("palette.cmd.taskCenter"), icon: "tasks", compact: true, keywords: ["task", "tasks", "center", "任务", "任务中心"], run: () => ports.setTasksOpen("all") }, |
| 126 | { id: "cmd-terminal", group: t("palette.group.commands"), title: t("rightDock.terminal"), icon: "terminal", compact: true, keywords: ["terminal", "shell", "终端"], run: () => ports.toggleTerminalPanel() }, |
| 127 | { |
| 128 | id: "cmd-reload-runtime", |
| 129 | group: t("palette.group.commands"), |
| 130 | title: t("palette.cmd.reloadRuntime"), |
| 131 | icon: "reload", |
| 132 | compact: true, |
| 133 | keywords: ["reload", "runtime", "重载", "运行时"], |
| 134 | run: () => { |
| 135 | const tabID = activeTabId; |
| 136 | if (!tabID) return; |
| 137 | // Success/queued feedback arrives as a tab notice; only hard failures need a toast. |
| 138 | void app.ReloadRuntime(tabID).catch((err) => showToast(err instanceof Error ? err.message : String(err), "error")); |
| 139 | }, |
| 140 | }, |
| 141 | ]; |
| 142 | const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); |
| 143 | const dayLabel = (ms: number) => { |
| 144 | const days = Math.round((startOfDay(new Date()) - startOfDay(new Date(ms))) / 86_400_000); |
| 145 | if (days <= 0) return t("history.today"); |
| 146 | if (days === 1) return t("history.yesterday"); |
| 147 | return new Date(ms).toLocaleDateString(); |
| 148 | }; |
| 149 | const sessionItems: PaletteItem[] = paletteSessions.slice(0, 12).map((s) => ({ |
| 150 | id: `sess-${s.path}`, |
| 151 | group: t("palette.group.sessions"), |
| 152 | title: paletteSessionDisplayTitle(s, t("history.emptySession")), |
| 153 | hint: paletteSessionHint(s), |
| 154 | keywords: paletteSessionKeywords(s), |
| 155 | meta: dayLabel(sessionActivityTime(s)), |
| 156 | badge: t(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns }), |
| 157 | run: () => void ports.onResumeSession(s), |
| 158 | })); |
| 159 | const remoteItems: PaletteItem[] = remoteHosts.map((host) => { |
| 160 | const status = remoteStatuses[host.id]; |
| 161 | const connected = status?.state === "connected" || status?.state === "degraded"; |
| 162 | const target = `${host.user ? `${host.user}@` : ""}${host.host}${host.port && host.port !== 22 ? `:${host.port}` : ""}`; |
| 163 | return { |
| 164 | id: `remote-${host.id}`, |
| 165 | group: t("palette.group.remote"), |
| 166 | title: connected |
| 167 | ? t("palette.remote.open", { host: host.label }) |
| 168 | : t("palette.remote.connect", { host: host.label }), |
| 169 | hint: host.defaultWorkspace || target, |
| 170 | icon: "remote", |
| 171 | keywords: ["ssh", "remote", "远程", "连接", host.label, host.host], |
| 172 | run: () => { |
| 173 | if (connected) ports.openRemoteWorkspaceFromStatus(host); |
| 174 | else ports.connectAndOpenRemoteWorkspace(host); |
| 175 | }, |
| 176 | }; |
| 177 | }); |
| 178 | const extensionItems: PaletteItem[] = paletteExtensionActions.map((action) => ({ |
| 179 | id: `ext-${action.slash}`, |
| 180 | group: t("palette.group.extensions"), |
| 181 | title: action.description || action.slash, |
| 182 | hint: action.slash, |
| 183 | icon: "extension", |
| 184 | keywords: ["extension", "扩展", action.plugin, action.action, action.slash], |
| 185 | run: () => { |
| 186 | const tabID = activeTabId; |
| 187 | if (!tabID) return; |
| 188 | // The extension's result message is user-facing feedback; only hard |
| 189 | // failures need an error toast. |
| 190 | void app.InvokeExtensionAction(tabID, action.slash, {}) |
| 191 | .then((message) => { |
| 192 | if (message) showToast(message, "info"); |
| 193 | }) |
| 194 | .catch((err) => showToast(err instanceof Error ? err.message : String(err), "error")); |
| 195 | }, |
| 196 | })); |
| 197 | return [...(remoteSurfaceActive ? cmds.filter((item) => item.id !== "cmd-terminal" && item.id !== "cmd-reload-runtime") : cmds), ...extensionItems, ...remoteItems, ...sessionItems]; |
| 198 | }, [t, paletteSessions, paletteExtensionActions, remoteHosts, remoteStatuses, activeTabId, remoteSurfaceActive, ports, showToast, notice]); |
| 199 | |
| 200 | return { openPalette, paletteItems }; |
| 201 | } |
| 202 |