From 438890699a64904f71df1dac452b8f58caddb4ef Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:53:51 -0700 Subject: [PATCH] Backfill Cmd+K command palette to native-menu parity (Phase 1) (#2679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(open-knowledge): spec command palette / menu parity registry Add a spec for a single command registry as the source of truth for command identity, so every native-menu action is reachable from the Cmd+K palette and a conformance ratchet keeps the two surfaces from drifting. Internal spec only (specs/ is not mirrored). * docs(open-knowledge): revise cmdk parity spec after adversarial review Fold in an independent 3-reviewer adversarial pass. Corrections: remove a fabricated command (toggle-linter); use the real 32-member OkMenuAction union and real menu labels; add the APP_RESERVED bucket for leaf-less ids and bespoke leaves; fix the inventory (report-bug is PRESENT, add the three extra view toggles); re-scope so Phase 1 is backfill + a conformance ratchet (registry/lingui-in-core/bus-refactor demoted to deferred Phase 2, which cannot compile msg in core); fix the web dispatch story; add acceptance criteria; define the 7-to-4 target projection. * docs(open-knowledge): fold keyboard-shortcut parity into cmdk spec Keyboard shortcuts are the fourth fragmented id-space for the same commands, and menu.ts hand-types accelerators without importing the shortcut registry, so menu-vs-shortcut accelerator drift is live and unguarded. Add a staged plan (13.1): Level 0 in Phase 1 (palette rows show accelerators via formatShortcut; Ratchet D guards accelerator parity), Levels 1-2 in Phase 2. Adds Ratchet D and D9; refines Q5/Q6. * docs(open-knowledge): add agent (MCP-tool) surface as Phase 3 to cmdk spec Fifth command surface: MCP tools in packages/server/src/mcp/tools (registry in index.ts). Distinct external contract, cannot render from the command registry, but overlapping mutations already share the api-extension HTTP handler spine (move->rename-path, write->agent-write-md) guarded by attribution-sweep. Integrate via a capability-parity map + ratchet keyed on those handlers (exposure parity, not implementation). Phase 3 / parallel track, gated on a product decision (Q10). Adds 13.2, D10, Q10, a 3 non-goal. * docs(open-knowledge): add menu de-dup + settings-to-agents to cmdk spec Menu de-dup (13.3): "Check for updates..." is emitted twice and shows on macOS in both the App menu and Help menu (App-menu leaf is isMac-gated, Help-menu leaf is unconditional). Phase 1 fix = gate Help on !isMac (mirror the Settings platform-XOR); Phase 2 = single-declaration placement + Ratchet B flags undeclared same-platform multi-placement. Settings-to-agents (13.4): toggle-shaped settings (theme, spell-check) are commands and ride the registry + 13.2 agent parity; general config-as-data is a sibling config-contract (guarded config.set beyond the read-only config MCP tool), not this registry. Adds D11, D12, Q11. * feat(open-knowledge): menu-action bus, view-menu-state store, two bridge invokes, updates menu de-dup Foundational layer for Cmd+K / menu parity (Phase 1): - local-menu-action-bus: renderer-local fan-out with a ref-counted single bridge.onMenuAction forwarder so both surfaces converge on one dispatch path. - view-menu-state-store: renderer mirror of the push-only View-menu snapshot so the palette can render state-reflecting toggle labels and gate kill-terminal. - Export OkMenuAction + OkEditorViewMenuStateSnapshot for the bus/store/ratchet. - Two new bridge invokes: mcpWiring.reconfigure() and spellcheck.toggle(), each delegating to the existing main-side body (reconfigureMcpWiringNow extracted). - Gate the Help 'Check for updates' entry on !isMac so macOS shows it once (App menu), mirroring the Settings platform-XOR. * refactor(open-knowledge): migrate the nine menu-action subscribers onto the local bus All nine bridge.onMenuAction subscribers (FileSidebar, EditorArea, EditorPane, TerminalSessionsHost, ProjectSwitcher, CreateProjectMenuTrigger, ReportBugMenuTrigger, NavigatorApp, DocumentContext) now attach to subscribeLocalMenuAction instead. The bus's ref-counted forwarder becomes the only bridge.onMenuAction listener, so a real menu click fires each handler once (no double-fire) and host-agnostic commands (view/panel/tree toggles) are now reachable on the web host. The four view-menu-state producers also mirror their snapshot into the renderer store so the palette can read it. Drop the now-unused bridge prop from ReportBugMenuTrigger. * feat(open-knowledge): backfill the Cmd+K command / menu parity gap Adds the ~24 §14 ADD commands to the Cmd+K palette, each dispatched through the smallest existing seam: the local menu-action bus for id-backed commands (view/tree toggles, terminal, worktree, contextual file ops), a bridge invoke for the three main-side commands (check for updates, set up integrations, spell check), and a direct renderer call for GitHub. Rows are search-only so the empty-open state is unchanged; view toggles show state-reflecting labels from the renderer view-menu-state store; contextual rows gate on the active editor target via the 7-to-4 projection; rows with a shortcut show the accelerator. Shared labels route through MENU_LABELS so the palette and native menu read identically, guarded by the extended label-parity test. * test(open-knowledge): drive migrated menu-action subscribers via the bus Updates the per-subscriber DOM tests to fire actions through emitLocalMenuAction (the same fan-out a real menu click reaches via the bus forwarder) instead of a captured bridge.onMenuAction callback. Also hardens the bus forwarder to guard a truthy-but-thin window.okDesktop (partial stub or session-only host) that lacks onMenuAction, so it never throws. * test(open-knowledge): command/menu parity ratchet + acceptance coverage Installs the durable 'nothing is missed' guarantee (Ratchets A-D) plus the Phase-1 acceptance criteria: - Ratchet A: OK_MENU_ACTIONS runtime array with a compile-time drift guard against the OkMenuAction union. - Ratchet B: every OkMenuAction id and every buildMenuTemplate leaf (both platform branches) is classified palette-command / OS-role / app-reserved; untracked is empty. - Ratchet C + AC1/AC3/AC5-AC8: each backfilled palette row renders under its enabling context and fires its declared dispatch (bus emit / bridge invoke / external URL); web-parity subset, state-reflecting toggle labels, contextual gating, and search-only empty-open. - Ratchet D: menu accelerators agree with the keyboard-shortcut registry. - FR3/AC2: a source scan asserts the bus forwarder is the only bridge.onMenuAction listener (no subscriber double-fires). - Bus unit tests for subscribe/emit/unsubscribe. * chore(open-knowledge): commit i18n .po catalogs + satisfy knip - Add the extracted en/pseudo .po source catalogs for the backfill strings. - Ratchet derives the menu-item type from buildMenuTemplate (drop the direct electron import the app package does not depend on). - Un-export the view-menu-state store's internal get/subscribe (used only via useViewMenuState). * chore(open-knowledge): relocate parity ratchet to unit tier + comment discipline Move the command/menu parity ratchet from tests/integration (which mandates markdown @covers-* tags that do not apply to a menu ratchet) to src/lib so it runs in the unit tier. Strip acceptance-criterion / functional-requirement id tokens from comments (they live in the PR body); the test titles keep them. * fix(open-knowledge): keep bridge mirror + IPC ratchets in sync with new invokes - Mirror mcpWiring.reconfigure() + spellcheck.toggle() into the core OkDesktopBridge copy so the 3-way drift catcher (M1 smoke) stays green. - Bump the RequestChannels count ratchet cap 83 -> 85 for the two new command invoke channels. - Update the menu unit test to assert the de-duplicated Check-for-updates (App-menu only on macOS, not also in Help). - Land the comment-discipline fixes on the parity tests. * test(open-knowledge): real-cmdk e2e smoke for the palette backfill Closes the DOM tests' mocked-cmdk fidelity gap: drives the real palette on the web host — a backfilled toggle is search-only on empty open, renders under a query with a state-reflecting label, and activating it round-trips through the bus to FileSidebar (the re-queried label flips). Registered in the CI e2e list. * chore(open-knowledge): changeset for the Cmd+K command / menu parity backfill * fix(open-knowledge): converge Cmd+K parity review findings - Import afterEach in CommandPalette.dom.test so the backfill suite registers (a missing import silently skipped ~29 tests). - Wire Ratchet C completeness: share the palette-command id classification via a test-helper and cross-check it against the rendered id-backed rows, so a classified-but-unrendered command turns the DOM suite red. - Gate Expand all / Collapse all palette rows on canExpandAll/canCollapseAll, matching the native menu's smart-hide. - Add bus tests for single-inbound dispatch, ref-counted forwarder teardown, and the partial-bridge guard. - Harden the double-fire guard to a repo-wide scan asserting a single production onMenuAction call site. - Cover doc-panel/terminal toggle labels and kill-terminal gating; make the asset-target gating test deterministic. - Correct the emitLocalMenuAction JSDoc and the mcp-wiring channel comment; strip spec-section citations from source comments. * chore(open-knowledge): raise main bundle budget for Cmd+K backfill The Cmd+K menu-parity backfill adds ~24 palette commands and their icons to the eagerly-loaded CommandPalette, growing the main index bundle by ~1.3 kB gzipped (477.35 kB). Raise the main-bundle size-limit 476 to 480 kB — a proportionate bump for real feature surface, consistent with prior budget raises as the app grew. The other two budgets (all-chunks 3.31/3.35 MB, CSS 54.57/55 kB) are unchanged and still green. * fix(open-knowledge): isolate menu bus subscriber faults Converge the claude review on the Cmd+K parity PR: - Wrap each local-menu-action-bus subscriber dispatch in try/catch with a console breadcrumb, plus a behavioral test, so one throwing handler cannot starve later subscribers (native menu clicks and palette commands share this single dispatch path). - Refresh the ipc-channels.ts header count narrative to 85 in lock-step with the ratchet test, appending the 83 to 85 bump rationale. - Add a unit suite for the view-menu-state store covering the partial-merge contract, subscriber notification, snapshot stability, and the test reset. - Document why EditorPane's store mirror deliberately sits behind both restore gates, unlike the unconditional sibling mirrors. * chore(open-knowledge): apply re-review suggestions on Cmd+K parity - Share the repository URL as OPEN_KNOWLEDGE_GITHUB_URL in core so the native Help menu and the palette command cannot drift (the parity ratchets check labels, not URLs). - Cover the openExternalUrl window.open fallback on the web host. - Cover the folder target kind in the AC7 contextual gating test. * test(open-knowledge): make ProblemsPanel lingui macro mocks race-proof Both lingui macro specifiers alias to one shim module in the vitest dom config, so this file's two mock.module registrations raced for a single resolved module id and only one factory survived. The specifier-shaped split (t on core/macro, components on react/macro) lost useLingui when the core factory won, failing all 14 tests. Both registrations now return the same superset object so either race winner carries every export. The race is latent (15 sibling dom test files share the pattern); this branch's regenerated lingui catalogs shifted registration timing enough to flip this one file. Full dom tier verified locally: 218 files, 2064 tests green. * test(open-knowledge): widen ReportBugDialog lazy-mount wait for CI The renderDialog helper waits for the lazy-loaded dialog body with findByRole's 1s default timeout. The file's first render pays the chunk's cold transform and import cost, which can exceed that deadline on a contended CI runner: the dom tier failed twice in a row on exactly this wait (empty body, no chunk-load error in the log) while passing locally and on the two prior CI runs. A generous explicit deadline only lengthens the failure path. GitOrigin-RevId: 3c124b0df9e7cfd46a6b1d0034ca651c3666f26e --- .changeset/command-palette-menu-parity.md | 9 + packages/app/package.json | 4 +- packages/app/src/App.tsx | 2 +- .../components/CommandPalette.dom.test.tsx | 318 +++++++++++++- .../app/src/components/CommandPalette.tsx | 389 +++++++++++++++++- .../CreateProjectMenuTrigger.dom.test.tsx | 91 ++-- .../components/CreateProjectMenuTrigger.tsx | 5 +- packages/app/src/components/EditorArea.tsx | 9 +- packages/app/src/components/EditorPane.tsx | 14 +- packages/app/src/components/FileSidebar.tsx | 16 +- .../NavigatorApp.menu-action.dom.test.tsx | 22 +- packages/app/src/components/NavigatorApp.tsx | 5 +- .../src/components/ProblemsPanel.dom.test.tsx | 14 +- .../components/ProjectSwitcher.dom.test.tsx | 20 +- .../app/src/components/ProjectSwitcher.tsx | 5 +- .../components/ReportBugDialog.dom.test.tsx | 5 +- .../ReportBugMenuTrigger.dom.test.tsx | 93 ++--- .../src/components/ReportBugMenuTrigger.tsx | 8 +- .../src/components/TerminalDock.dom.test.tsx | 24 +- .../src/components/TerminalSessionsHost.tsx | 12 +- .../components/TerminalWindowApp.dom.test.tsx | 24 +- .../src/editor/DocumentContext.dom.test.tsx | 14 +- packages/app/src/editor/DocumentContext.tsx | 7 +- .../lib/command-menu-parity.test-helper.ts | 67 +++ .../app/src/lib/command-menu-parity.test.ts | 363 ++++++++++++++++ packages/app/src/lib/desktop-bridge-types.ts | 19 +- .../app/src/lib/local-menu-action-bus.test.ts | 162 ++++++++ packages/app/src/lib/local-menu-action-bus.ts | 100 +++++ packages/app/src/lib/ok-menu-actions.ts | 58 +++ .../lib/view-menu-state-store.dom.test.tsx | 55 +++ packages/app/src/lib/view-menu-state-store.ts | 58 +++ packages/app/src/locales/en/messages.json | 18 + packages/app/src/locales/en/messages.po | 86 ++++ packages/app/src/locales/pseudo/messages.json | 18 + packages/app/src/locales/pseudo/messages.po | 86 ++++ .../stress/command-palette-parity.e2e.ts | 64 +++ .../tests/stress/fixtures/handoff-mocks.ts | 4 + packages/core/src/constants/menu-labels.ts | 27 ++ packages/core/src/desktop-bridge.ts | 15 + packages/core/src/index.ts | 6 +- packages/desktop/src/main/index.ts | 61 ++- packages/desktop/src/main/menu.ts | 43 +- packages/desktop/src/preload/index.ts | 5 + .../desktop/src/shared/bridge-contract.ts | 15 + packages/desktop/src/shared/ipc-channels.ts | 26 +- .../ipc-channel-count-ratchet.test.ts | 8 +- packages/desktop/tests/main/menu.test.ts | 7 +- 47 files changed, 2219 insertions(+), 262 deletions(-) create mode 100644 .changeset/command-palette-menu-parity.md create mode 100644 packages/app/src/lib/command-menu-parity.test-helper.ts create mode 100644 packages/app/src/lib/command-menu-parity.test.ts create mode 100644 packages/app/src/lib/local-menu-action-bus.test.ts create mode 100644 packages/app/src/lib/local-menu-action-bus.ts create mode 100644 packages/app/src/lib/ok-menu-actions.ts create mode 100644 packages/app/src/lib/view-menu-state-store.dom.test.tsx create mode 100644 packages/app/src/lib/view-menu-state-store.ts create mode 100644 packages/app/tests/stress/command-palette-parity.e2e.ts diff --git a/.changeset/command-palette-menu-parity.md b/.changeset/command-palette-menu-parity.md new file mode 100644 index 000000000..9188d778b --- /dev/null +++ b/.changeset/command-palette-menu-parity.md @@ -0,0 +1,9 @@ +--- +"@inkeep/open-knowledge": minor +--- + +The Cmd+K command palette now reaches the actions that used to live only in the native menu bar. You can search for and run Check for updates, New from template, New worktree, Switch worktree, Rename, Duplicate, Move to Trash, Reveal in Finder, Copy full path, Copy relative path, Close tab, the View toggles (sidebar, document panel, terminal, hidden files, .ok folders, only markdown files, skills section), Expand all, Collapse all, New Terminal, Kill Terminal, Set up OpenKnowledge integrations, Check spelling while typing, and OpenKnowledge on GitHub, all from Cmd+K. + +Toggle commands show their current state (for example the sidebar row reads "Hide sidebar" when the sidebar is open), and the commands that do not need the desktop shell now work in the web app too, not just Desktop. These rows appear once you start typing, so the palette's empty state stays lean. + +On macOS, "Check for updates" no longer appears twice. It shows once, in the App menu, matching where Settings lives. diff --git a/packages/app/package.json b/packages/app/package.json index 1a11c6689..493e4b280 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,7 +26,7 @@ "measure:fuzz": "bash scripts/measure-fuzz.sh", "measure:stress": "bash scripts/measure-stress.sh", "perf:compare": "bash scripts/perf-compare.sh", - "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts", + "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts tests/stress/command-palette-parity.e2e.ts", "test:e2e:install-browsers": "playwright install chromium webkit firefox", "test:visual": "playwright test --config playwright.visual.config.ts", "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", @@ -45,7 +45,7 @@ { "name": "main app bundle (gzipped)", "path": "dist/assets/index-*.js", - "limit": "476 kB", + "limit": "480 kB", "gzip": true, "running": false }, diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index e17e7bd23..a6d221e66 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -505,7 +505,7 @@ function AppBody() { {desktopBridge ? : null} {/* Help → Report a Bug… opens ReportBugDialog here — same desktop-only App-root trigger pattern as CreateProjectMenuTrigger. */} - {desktopBridge ? : null} + {desktopBridge ? : null} {/* First-launch consent dialog — host-agnostic. Self-gates on the shared `mcpConsentStore` snapshot; renders nothing until main fires `ok:mcp-wiring:show`. Mounted identically in diff --git a/packages/app/src/components/CommandPalette.dom.test.tsx b/packages/app/src/components/CommandPalette.dom.test.tsx index f109b7af6..fb3d27ff3 100644 --- a/packages/app/src/components/CommandPalette.dom.test.tsx +++ b/packages/app/src/components/CommandPalette.dom.test.tsx @@ -1,6 +1,15 @@ -import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; +import { + PALETTE_COMMAND_IDS, + PRE_EXISTING_PALETTE_IDS, +} from '@/lib/command-menu-parity.test-helper'; +import { + __resetLocalMenuActionBusForTests, + subscribeLocalMenuAction, +} from '@/lib/local-menu-action-bus'; +import { __resetViewMenuStateForTests, setViewMenuState } from '@/lib/view-menu-state-store'; import { renderLinguiTemplate } from '@/test-utils/lingui-mock'; type CommandDialogProps = { @@ -23,7 +32,12 @@ type CommandItemProps = { }; let activeDocName: string | null = 'docs/active'; -let activeTarget: { kind: 'doc'; docName: string } | null = { kind: 'doc', docName: 'docs/active' }; +// Loosely typed so parity tests can set folder / asset / missing targets to +// exercise the contextual-command projection. +let activeTarget: { kind: string; [key: string]: unknown } | null = { + kind: 'doc', + docName: 'docs/active', +}; let requestDocPanelTabCalls: string[] = []; let seedDialogProps: Array<{ open: boolean }> = []; let newItemDialogProps: Array<{ open: boolean; kind: string; initialDir: string }> = []; @@ -241,6 +255,19 @@ function createBridge() { }), ), }, + // Surfaces the backfilled bridge-invoke commands reach. + update: { + checkNow: mock(() => Promise.resolve()), + }, + mcpWiring: { + reconfigure: mock(() => Promise.resolve(true)), + }, + spellcheck: { + toggle: mock(() => Promise.resolve(true)), + }, + shell: { + openExternal: mock(() => Promise.resolve()), + }, }; } @@ -818,3 +845,290 @@ describe('NavigationItem path subtitle', () => { expect(folderRow.querySelector('[data-testid="file-entry-extension-badge"]')).toBeNull(); }); }); + +// Ratchet C + acceptance criteria for the menu-parity backfill. Honest limit: cmdk and +// the hooks are mocked, so these assert the registry-driven branch emits the row +// and wires the dispatch under its declared enabling context — not cmdk's own +// filtering (covered by the palette DOM tests above + a Playwright smoke). +describe('Cmd+K menu-parity backfill', () => { + let busActions: string[]; + let unsubscribeBus: (() => void) | null = null; + + beforeEach(() => { + cleanup(); + __resetLocalMenuActionBusForTests(); + __resetViewMenuStateForTests(); + activeDocName = 'docs/active'; + activeTarget = { kind: 'doc', docName: 'docs/active' }; + pageListLoading = false; + window.location.hash = ''; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ results: [] }), { status: 200 })), + ) as never; + // Panels visible + a live terminal so the state-aware View toggles render + // their "Hide …" variant and Kill Terminal is enabled. + setViewMenuState({ + sidebarVisible: true, + docPanelVisible: true, + terminalVisible: true, + terminalLive: true, + }); + busActions = []; + unsubscribeBus = subscribeLocalMenuAction((action) => busActions.push(action)); + }); + + afterEach(() => { + unsubscribeBus?.(); + unsubscribeBus = null; + __resetLocalMenuActionBusForTests(); + __resetViewMenuStateForTests(); + }); + + // Each id-backed backfill row renders under a matching query and emits its + // OkMenuAction id on the bus when selected. + const ID_BACKED: Array<{ testid: string; query: string; id: string }> = [ + { + testid: 'command-palette-new-from-template', + query: 'new from template', + id: 'new-from-template', + }, + { testid: 'command-palette-toggle-sidebar', query: 'sidebar', id: 'toggle-sidebar' }, + { testid: 'command-palette-toggle-doc-panel', query: 'document panel', id: 'toggle-doc-panel' }, + { testid: 'command-palette-toggle-terminal', query: 'hide terminal', id: 'toggle-terminal' }, + { + testid: 'command-palette-toggle-show-hidden-files', + query: 'hidden files', + id: 'toggle-show-hidden-files', + }, + { + testid: 'command-palette-toggle-show-ok-folders', + query: 'ok folders', + id: 'toggle-show-ok-folders', + }, + { + testid: 'command-palette-toggle-show-only-markdown-files', + query: 'only markdown', + id: 'toggle-show-only-markdown-files', + }, + { + testid: 'command-palette-toggle-show-skills-section', + query: 'skills section', + id: 'toggle-show-skills-section', + }, + { testid: 'command-palette-expand-all-tree', query: 'expand all', id: 'expand-all-tree' }, + { testid: 'command-palette-collapse-all-tree', query: 'collapse all', id: 'collapse-all-tree' }, + { testid: 'command-palette-new-terminal', query: 'new terminal', id: 'new-terminal' }, + { testid: 'command-palette-kill-terminal', query: 'kill terminal', id: 'kill-terminal' }, + { testid: 'command-palette-new-worktree', query: 'new worktree', id: 'new-worktree' }, + { testid: 'command-palette-switch-worktree', query: 'switch worktree', id: 'switch-worktree' }, + { testid: 'command-palette-close-tab', query: 'close tab', id: 'close-active-tab-or-window' }, + { testid: 'command-palette-rename', query: 'rename', id: 'rename' }, + { testid: 'command-palette-duplicate', query: 'duplicate', id: 'duplicate' }, + { testid: 'command-palette-move-to-trash', query: 'move to trash', id: 'move-to-trash' }, + { + testid: 'command-palette-reveal-in-finder', + query: 'reveal in finder', + id: 'reveal-in-finder', + }, + { testid: 'command-palette-copy-full-path', query: 'copy full path', id: 'copy-full-path' }, + { + testid: 'command-palette-copy-relative-path', + query: 'copy relative path', + id: 'copy-relative-path', + }, + ]; + + for (const { testid, query, id } of ID_BACKED) { + test(`AC1: "${id}" renders on query and emits on the bus`, async () => { + await renderPalette({ bridge: createBridge() }); + await setQuery(query); + const row = screen.getByTestId(testid); + expect(row).not.toBeNull(); + fireEvent.click(row); + expect(busActions).toContain(id); + }); + } + + // Ratchet C completeness: every id classified as a palette command (shared with + // the id-classification ratchet via command-menu-parity.test-helper) must be + // covered by a rendered id-backed row above OR a pre-existing palette surface. + // A future id classified into PALETTE_COMMAND_IDS with no rendered row — which + // satisfies only Ratchets A/B — turns this red, closing the "classified but + // unreachable" gap that the id ratchets alone cannot see. + test('Ratchet C: every classified palette-command id renders a row or is a pre-existing surface', () => { + const rendered = new Set(ID_BACKED.map((row) => row.id)); + const covered = new Set([...rendered, ...PRE_EXISTING_PALETTE_IDS]); + const missing = [...PALETTE_COMMAND_IDS].filter((id) => !covered.has(id)); + expect(missing).toEqual([]); + // No ID_BACKED row tests an id that isn't classified (stale row test). + const staleRows = [...rendered].filter((id) => !PALETTE_COMMAND_IDS.has(id)); + expect(staleRows).toEqual([]); + // The pre-existing escape hatch stays honest: every entry is still classified. + const stalePreExisting = [...PRE_EXISTING_PALETTE_IDS].filter( + (id) => !PALETTE_COMMAND_IDS.has(id), + ); + expect(stalePreExisting).toEqual([]); + }); + + test('AC1: check-for-updates invokes bridge.update.checkNow', async () => { + const { bridge } = await renderPalette({ bridge: createBridge() }); + await setQuery('check for updates'); + fireEvent.click(screen.getByTestId('command-palette-check-for-updates')); + await waitFor(() => expect(bridge?.update.checkNow).toHaveBeenCalledTimes(1)); + }); + + test('AC1: set-up-integrations invokes bridge.mcpWiring.reconfigure', async () => { + const { bridge } = await renderPalette({ bridge: createBridge() }); + await setQuery('set up openknowledge integrations'); + fireEvent.click(screen.getByTestId('command-palette-set-up-integrations')); + await waitFor(() => expect(bridge?.mcpWiring.reconfigure).toHaveBeenCalledTimes(1)); + }); + + test('AC1: OpenKnowledge on GitHub opens the repository URL', async () => { + const { bridge } = await renderPalette({ bridge: createBridge() }); + await setQuery('github'); + fireEvent.click(screen.getByTestId('command-palette-open-github')); + expect(bridge?.shell.openExternal).toHaveBeenCalledWith( + 'https://github.com/inkeep/open-knowledge', + ); + }); + + test('AC1: OpenKnowledge on GitHub falls back to window.open on the web host', async () => { + const openSpy = mock(() => null); + const originalOpen = window.open; + window.open = openSpy as unknown as typeof window.open; + try { + await renderPalette({ bridge: null }); + await setQuery('github'); + fireEvent.click(screen.getByTestId('command-palette-open-github')); + expect(openSpy).toHaveBeenCalledWith( + 'https://github.com/inkeep/open-knowledge', + '_blank', + 'noopener,noreferrer', + ); + } finally { + window.open = originalOpen; + } + }); + + test('AC3: web host shows host-agnostic toggles but hides desktop-only commands', async () => { + await renderPalette({ bridge: null }); + + await setQuery('sidebar'); + expect(screen.queryByTestId('command-palette-toggle-sidebar')).not.toBeNull(); + await setQuery('expand all'); + expect(screen.queryByTestId('command-palette-expand-all-tree')).not.toBeNull(); + + await setQuery('new terminal'); + expect(screen.queryByTestId('command-palette-new-terminal')).toBeNull(); + await setQuery('check for updates'); + expect(screen.queryByTestId('command-palette-check-for-updates')).toBeNull(); + await setQuery('rename'); + expect(screen.queryByTestId('command-palette-rename')).toBeNull(); + }); + + test('AC5: the sidebar toggle label reflects view-menu-state', async () => { + setViewMenuState({ sidebarVisible: true }); + await renderPalette({ bridge: createBridge() }); + await setQuery('sidebar'); + expect(screen.getByTestId('command-palette-toggle-sidebar').textContent).toContain( + 'Hide sidebar', + ); + + cleanup(); + setViewMenuState({ sidebarVisible: false }); + await renderPalette({ bridge: createBridge() }); + await setQuery('sidebar'); + expect(screen.getByTestId('command-palette-toggle-sidebar').textContent).toContain( + 'Show sidebar', + ); + }); + + test('AC5: doc-panel and terminal toggle labels reflect view-menu-state', async () => { + setViewMenuState({ docPanelVisible: true, terminalVisible: true }); + await renderPalette({ bridge: createBridge() }); + await setQuery('document panel'); + expect(screen.getByTestId('command-palette-toggle-doc-panel').textContent).toContain( + 'Hide document panel', + ); + await setQuery('hide terminal'); + expect(screen.getByTestId('command-palette-toggle-terminal').textContent).toContain( + 'Hide Terminal', + ); + + cleanup(); + setViewMenuState({ docPanelVisible: false, terminalVisible: false }); + await renderPalette({ bridge: createBridge() }); + await setQuery('document panel'); + expect(screen.getByTestId('command-palette-toggle-doc-panel').textContent).toContain( + 'Show document panel', + ); + await setQuery('show terminal'); + expect(screen.getByTestId('command-palette-toggle-terminal').textContent).toContain( + 'Show Terminal', + ); + }); + + test('AC5: kill-terminal is search-visible only when a terminal is live', async () => { + setViewMenuState({ terminalLive: true }); + await renderPalette({ bridge: createBridge() }); + await setQuery('kill terminal'); + expect(screen.queryByTestId('command-palette-kill-terminal')).not.toBeNull(); + + cleanup(); + setViewMenuState({ terminalLive: false }); + await renderPalette({ bridge: createBridge() }); + await setQuery('kill terminal'); + expect(screen.queryByTestId('command-palette-kill-terminal')).toBeNull(); + }); + + test('AC6: the spell-check row toggles via the bridge invoke, not view-menu-state', async () => { + const { bridge } = await renderPalette({ bridge: createBridge() }); + await setQuery('check spelling while typing'); + fireEvent.click(screen.getByTestId('command-palette-toggle-spell-check')); + await waitFor(() => expect(bridge?.spellcheck.toggle).toHaveBeenCalledTimes(1)); + }); + + test('AC7: contextual commands gate on the active editor target', async () => { + // Missing target → no contextual rows. + await renderPalette({ bridge: createBridge(), docName: null }); + await setQuery('rename'); + expect(screen.queryByTestId('command-palette-rename')).toBeNull(); + await setQuery('duplicate'); + expect(screen.queryByTestId('command-palette-duplicate')).toBeNull(); + + // Doc target → rename + duplicate present. + cleanup(); + await renderPalette({ bridge: createBridge(), docName: 'docs/thing' }); + await setQuery('rename'); + expect(screen.queryByTestId('command-palette-rename')).not.toBeNull(); + await setQuery('duplicate'); + expect(screen.queryByTestId('command-palette-duplicate')).not.toBeNull(); + + // Asset-like target → duplicate hidden, rename still present (target-kind projection). + // Clear the query first so re-typing 'duplicate' is a real value change that forces + // a re-render after the activeTarget mutation (a same-value setState would bail). + activeTarget = { kind: 'asset', assetPath: 'img.png' }; + await setQuery(''); + await setQuery('duplicate'); + expect(screen.queryByTestId('command-palette-duplicate')).toBeNull(); + await setQuery('rename'); + expect(screen.queryByTestId('command-palette-rename')).not.toBeNull(); + + // Folder target → duplicate re-enabled (the doc-OR-folder availability + // clause), so narrowing the gate to doc-only turns this red. + activeTarget = { kind: 'folder', folderPath: 'docs/notes' }; + await setQuery(''); + await setQuery('duplicate'); + expect(screen.queryByTestId('command-palette-duplicate')).not.toBeNull(); + }); + + test('AC8: backfilled rows do not render on empty open', async () => { + await renderPalette({ bridge: createBridge() }); + // No query typed — the backfill long-tail is search-only. + expect(screen.queryByTestId('command-palette-rename')).toBeNull(); + expect(screen.queryByTestId('command-palette-toggle-sidebar')).toBeNull(); + expect(screen.queryByTestId('command-palette-check-for-updates')).toBeNull(); + expect(screen.queryByTestId('command-palette-new-terminal')).toBeNull(); + }); +}); diff --git a/packages/app/src/components/CommandPalette.tsx b/packages/app/src/components/CommandPalette.tsx index 708f6be5b..89df21a7d 100644 --- a/packages/app/src/components/CommandPalette.tsx +++ b/packages/app/src/components/CommandPalette.tsx @@ -8,29 +8,48 @@ * bridge is available. */ -import { SHOW_INSTALL_SKILL, type WorktreeSelectorEntry } from '@inkeep/open-knowledge-core'; +import { + OPEN_KNOWLEDGE_GITHUB_URL, + SHOW_INSTALL_SKILL, + type WorktreeSelectorEntry, +} from '@inkeep/open-knowledge-core'; import { Plural, Trans, useLingui } from '@lingui/react/macro'; import { + Blocks, Bug, + Check, + Copy, Download, + Eye, FilePlus2, FileText, Folder, FolderOpen, FolderPlus, + FoldVertical, GitBranch, Hash, LayoutGrid, Loader2, Network, Package, + PanelLeft, + PanelRight, + Pencil, Plus, + RefreshCw, Settings, Sparkles, + SpellCheck, + SquareTerminal, + Trash2, + UnfoldVertical, + X, } from 'lucide-react'; import { type Dispatch, type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, type SetStateAction, useDeferredValue, useEffect, @@ -69,7 +88,9 @@ import { import { requestDocPanelTab } from '@/components/doc-panel-events'; import { FileEntryIcon } from '@/components/file-entry-icon'; import { defaultInitialDir } from '@/components/file-tree-utils'; +import { GithubIcon } from '@/components/icons/github'; import { NewItemDialog } from '@/components/NewItemDialog'; +import type { ResolvedNavigationTarget } from '@/components/navigation-targets'; import { usePageList } from '@/components/PageListContext'; import { ReportBugDialog } from '@/components/ReportBugDialog'; import { SeedDialog } from '@/components/SeedDialog'; @@ -87,15 +108,17 @@ import type { TagSummaryEntry } from '@/editor/extensions/tag-suggestion'; import { useIsEmbedded } from '@/hooks/use-is-embedded'; import { useSemanticSearchStatus } from '@/hooks/use-semantic-search-status'; import { useWorktrees } from '@/hooks/use-worktrees'; -import type { OkDesktopBridge, RecentProjectEntry } from '@/lib/desktop-bridge-types'; +import type { OkDesktopBridge, OkMenuAction, RecentProjectEntry } from '@/lib/desktop-bridge-types'; import { hashFromDocName } from '@/lib/doc-hash'; import { runWithToast as runWithToastBase } from '@/lib/error-state'; import { VISIBLE_TARGETS } from '@/lib/handoff/targets'; import { formatShortcut, matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts'; +import { emitLocalMenuAction } from '@/lib/local-menu-action-bus'; import { useSingleFileMode } from '@/lib/single-file-mode'; import { SETTINGS_OPEN_HASH } from '@/lib/use-settings-route'; import { useWorkspace } from '@/lib/use-workspace'; import { cn } from '@/lib/utils.ts'; +import { useViewMenuState } from '@/lib/view-menu-state-store'; import { refreshWorktrees } from '@/lib/worktree-store'; import { buildHandoffInput, useHandoffDispatch } from './handoff/useHandoffDispatch'; import { useInstalledAgents } from './handoff/useInstalledAgents'; @@ -129,6 +152,53 @@ interface CommandPaletteProps { onOpenChange: Dispatch>; } +/** + * A backfilled Cmd+K command. Rendered search-only so the empty- + * open state is unchanged. `run` dispatches through the smallest existing seam — + * the local menu-action bus (id-backed), a bridge invoke (main-side), or a + * direct renderer call — so no handler is re-implemented here. + */ +interface BackfillCommandRow { + testid: string; + label: string; + keywords: string[]; + icon: ReactNode; + group: 'file' | 'view' | 'terminal' | 'app'; + available: boolean; + /** Trailing check indicator for checkbox-style View toggles (state reflection). */ + checked?: boolean; + /** Accelerator glyphs for commands whose native menu item carries one. */ + shortcut?: string; + run: () => void; +} + +/** + * Project the palette's 7-kind {@link ResolvedNavigationTarget} onto the 4-kind + * gating the native File menu uses for contextual commands: doc-like and folder + * allow every contextual command (folder still allows Duplicate); asset-like + * (asset / skill-file / large-file) disables Duplicate; a missing / absent + * target hides them all. + */ +type ContextualTargetKind = 'doc' | 'folder' | 'asset' | 'none'; +function projectContextualTargetKind( + target: ResolvedNavigationTarget | null, +): ContextualTargetKind { + if (target === null) return 'none'; + switch (target.kind) { + case 'doc': + case 'folder-index': + return 'doc'; + case 'folder': + return 'folder'; + case 'asset': + case 'skill-file': + case 'large-file': + return 'asset'; + case 'missing': + return 'none'; + } +} + function navigateToDocHash(docName: string): void { window.location.assign(hashFromDocName(docName)); } @@ -895,6 +965,286 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal const showTagDocsEmpty = paletteMode.kind === 'tag-docs' && tagDocsStatus === 'success' && tagDocs.length === 0; + // Backfilled Cmd+K commands. Rendered search-only (each row + // appears only under a matching query) so the empty-open state is unchanged. + // Dispatch via the smallest existing seam — the local menu-action bus + // (id-backed), a bridge invoke (main-side), or a direct renderer call — so no + // handler is re-implemented here. Host-agnostic rows (view/tree toggles, + // new-from-template, GitHub) stay available on the web host; desktop-only rows + // gate on `bridge !== null`. + const viewMenuState = useViewMenuState(); + const contextualTargetKind = projectContextualTargetKind(activeTarget); + const contextualAvailable = bridge !== null && contextualTargetKind !== 'none'; + const runMenuAction = (action: OkMenuAction) => { + onOpenChange(false); + emitLocalMenuAction(action); + }; + const openExternalUrl = (url: string) => { + onOpenChange(false); + if (bridge) { + void bridge.shell.openExternal(url); + } else { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }; + const backfillCommands: BackfillCommandRow[] = [ + { + testid: 'command-palette-new-from-template', + label: t`New from template`, + keywords: ['template', 'create', 'new'], + icon: , + group: 'file', + available: !singleFile, + run: () => runMenuAction('new-from-template'), + }, + { + testid: 'command-palette-rename', + label: t`Rename`, + keywords: ['rename', 'file', 'folder'], + icon: , + group: 'file', + available: contextualAvailable, + run: () => runMenuAction('rename'), + }, + { + testid: 'command-palette-duplicate', + label: t`Duplicate`, + keywords: ['duplicate', 'copy', 'file', 'folder'], + icon: , + group: 'file', + available: + bridge !== null && (contextualTargetKind === 'doc' || contextualTargetKind === 'folder'), + shortcut: formatShortcut('file-tree-duplicate'), + run: () => runMenuAction('duplicate'), + }, + { + testid: 'command-palette-move-to-trash', + label: t`Move to Trash`, + keywords: ['delete', 'trash', 'remove'], + icon: , + group: 'file', + available: contextualAvailable, + shortcut: formatShortcut('file-tree-delete'), + run: () => runMenuAction('move-to-trash'), + }, + { + testid: 'command-palette-reveal-in-finder', + label: t`Reveal in Finder`, + keywords: ['finder', 'reveal', 'show', 'file'], + icon: , + group: 'file', + available: contextualAvailable, + run: () => runMenuAction('reveal-in-finder'), + }, + { + testid: 'command-palette-copy-full-path', + label: t`Copy full path`, + keywords: ['copy', 'path', 'absolute', 'full'], + icon: , + group: 'file', + available: contextualAvailable, + run: () => runMenuAction('copy-full-path'), + }, + { + testid: 'command-palette-copy-relative-path', + label: t`Copy relative path`, + keywords: ['copy', 'path', 'relative'], + icon: , + group: 'file', + available: contextualAvailable, + run: () => runMenuAction('copy-relative-path'), + }, + { + testid: 'command-palette-close-tab', + label: t`Close tab`, + keywords: ['close', 'tab', 'window'], + icon: , + group: 'file', + available: bridge !== null, + run: () => runMenuAction('close-active-tab-or-window'), + }, + { + testid: 'command-palette-new-worktree', + label: t`New worktree`, + keywords: ['worktree', 'branch', 'new'], + icon: , + group: 'file', + available: bridge !== null, + run: () => runMenuAction('new-worktree'), + }, + { + testid: 'command-palette-switch-worktree', + label: t`Switch worktree`, + keywords: ['worktree', 'switch', 'branch'], + icon: , + group: 'file', + available: bridge !== null, + run: () => runMenuAction('switch-worktree'), + }, + { + testid: 'command-palette-toggle-sidebar', + label: viewMenuState.sidebarVisible ? t`Hide sidebar` : t`Show sidebar`, + keywords: ['sidebar', 'files', 'panel', 'toggle'], + icon: , + group: 'view', + available: true, + shortcut: formatShortcut('toggle-files-sidebar'), + run: () => runMenuAction('toggle-sidebar'), + }, + { + testid: 'command-palette-toggle-doc-panel', + label: viewMenuState.docPanelVisible ? t`Hide document panel` : t`Show document panel`, + keywords: ['document', 'panel', 'info', 'toggle'], + icon: , + group: 'view', + available: true, + shortcut: formatShortcut('toggle-document-panel'), + run: () => runMenuAction('toggle-doc-panel'), + }, + { + testid: 'command-palette-toggle-terminal', + label: viewMenuState.terminalVisible ? t`Hide Terminal` : t`Show Terminal`, + keywords: ['terminal', 'shell', 'console', 'toggle'], + icon: , + group: 'view', + available: bridge !== null, + shortcut: formatShortcut('toggle-terminal-panel'), + run: () => runMenuAction('toggle-terminal'), + }, + { + testid: 'command-palette-toggle-show-hidden-files', + label: t`Show hidden files`, + keywords: ['hidden', 'dotfiles', 'files', 'show'], + icon: , + group: 'view', + available: true, + checked: viewMenuState.showHiddenFiles === true, + run: () => runMenuAction('toggle-show-hidden-files'), + }, + { + testid: 'command-palette-toggle-show-ok-folders', + label: t`Show .ok folders`, + keywords: ['ok', 'folders', 'hidden', 'show'], + icon: , + group: 'view', + available: true, + checked: viewMenuState.showOkFolders === true, + run: () => runMenuAction('toggle-show-ok-folders'), + }, + { + testid: 'command-palette-toggle-show-only-markdown-files', + label: t`Show only markdown files`, + keywords: ['markdown', 'filter', 'files', 'only'], + icon: , + group: 'view', + available: true, + checked: viewMenuState.showOnlyMarkdownFiles === true, + run: () => runMenuAction('toggle-show-only-markdown-files'), + }, + { + testid: 'command-palette-toggle-show-skills-section', + label: t`Show skills section`, + keywords: ['skills', 'section', 'sidebar', 'show'], + icon: , + group: 'view', + available: true, + checked: viewMenuState.showSkillsSection === true, + run: () => runMenuAction('toggle-show-skills-section'), + }, + { + testid: 'command-palette-expand-all-tree', + label: t`Expand all`, + keywords: ['expand', 'tree', 'folders', 'all'], + icon: , + group: 'view', + // Mirror the native menu's smart-hide (visible: canExpandAll ?? true): + // suppress the row when the tree is already fully expanded, so searching + // "expand" doesn't surface a pure no-op. Unknown (web/pre-push) stays available. + available: viewMenuState.canExpandAll !== false, + run: () => runMenuAction('expand-all-tree'), + }, + { + testid: 'command-palette-collapse-all-tree', + label: t`Collapse all`, + keywords: ['collapse', 'tree', 'folders', 'all'], + icon: , + group: 'view', + // Mirror the native menu's smart-hide (visible: canCollapseAll ?? true). + available: viewMenuState.canCollapseAll !== false, + run: () => runMenuAction('collapse-all-tree'), + }, + { + testid: 'command-palette-new-terminal', + label: t`New Terminal`, + keywords: ['terminal', 'shell', 'new', 'tab'], + icon: , + group: 'terminal', + available: bridge !== null, + run: () => runMenuAction('new-terminal'), + }, + { + testid: 'command-palette-kill-terminal', + label: t`Kill Terminal`, + keywords: ['terminal', 'kill', 'close', 'session'], + icon: , + group: 'terminal', + available: bridge !== null && viewMenuState.terminalLive === true, + run: () => runMenuAction('kill-terminal'), + }, + { + testid: 'command-palette-check-for-updates', + label: t`Check for updates`, + keywords: ['update', 'upgrade', 'version', 'check'], + icon: , + group: 'app', + available: bridge !== null, + run: () => + runAction(async () => { + await bridge?.update.checkNow(); + }), + }, + { + testid: 'command-palette-set-up-integrations', + label: t`Set up OpenKnowledge integrations`, + keywords: ['integrations', 'mcp', 'setup', 'claude', 'configure'], + icon: , + group: 'app', + available: bridge !== null, + run: () => + runAction(async () => { + await bridge?.mcpWiring.reconfigure(); + }), + }, + { + testid: 'command-palette-toggle-spell-check', + label: t`Check spelling while typing`, + keywords: ['spell', 'spelling', 'check', 'typing'], + icon: , + group: 'app', + available: bridge !== null, + run: () => + runAction(async () => { + await bridge?.spellcheck.toggle(); + }), + }, + { + testid: 'command-palette-open-github', + label: t`OpenKnowledge on GitHub`, + keywords: ['github', 'source', 'repository', 'code'], + icon: , + group: 'app', + available: true, + run: () => openExternalUrl(OPEN_KNOWLEDGE_GITHUB_URL), + }, + ]; + const visibleBackfillCommands = backfillCommands.filter( + (cmd) => + cmd.available && + trimmedDeferredQuery !== '' && + matchesCommandQuery(cmd.label, deferredQuery, cmd.keywords), + ); + const hasBackfillCommand = visibleBackfillCommands.length > 0; + const hasAnyResults = inExclusiveMode || showRecentNavigation || @@ -912,7 +1262,8 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal showInstallClaudeDesktop || showReportBug || showProjectRecents || - showAgentGroup; + showAgentGroup || + hasBackfillCommand; function navigateToTagDocs(tagName: string) { setQuery(`${TAG_QUERY_PREFIX}${tagName}`); @@ -1674,6 +2025,38 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal ) : null} + {(['file', 'view', 'terminal', 'app'] as const).map((groupKey) => { + const rows = visibleBackfillCommands.filter((cmd) => cmd.group === groupKey); + if (rows.length === 0) return null; + const heading = + groupKey === 'file' + ? t`File` + : groupKey === 'view' + ? t`View` + : groupKey === 'terminal' + ? t`Terminal` + : t`Application`; + return ( + + {rows.map((cmd) => ( + + {cmd.icon} + {cmd.label} + {cmd.checked ? ( + + ))} + + ); + })} + {showNavigation ? ( {visibleSearchResults.map((entry) => ( diff --git a/packages/app/src/components/CreateProjectMenuTrigger.dom.test.tsx b/packages/app/src/components/CreateProjectMenuTrigger.dom.test.tsx index dc532de86..4c1f6ec93 100644 --- a/packages/app/src/components/CreateProjectMenuTrigger.dom.test.tsx +++ b/packages/app/src/components/CreateProjectMenuTrigger.dom.test.tsx @@ -1,26 +1,27 @@ /** * DOM mount test for CreateProjectMenuTrigger — the App-root surface that - * opens CreateProjectDialog when main fires the `new-project` menu action + * opens CreateProjectDialog when the `new-project` menu action fires * (File → New project…). * * Pins the user-visible contract: the dialog is closed until the menu action * fires, opens on `new-project`, and ignores unrelated menu actions. The - * trigger subscribes to `bridge.onMenuAction`; this test captures the - * subscribed callback through a fake bridge and invokes it directly — the - * same path main's `sendMenuActionToFocused('new-project')` drives over IPC. + * trigger subscribes to the renderer-local menu-action bus (a real menu click + * reaches it via main → `ok:menu-action` → the bus forwarder), so this test + * drives it with `emitLocalMenuAction`. The `bridge` prop is still threaded + * into CreateProjectDialog, so the fake bridge keeps the surface the dialog + * touches on open. * * Invocation: `bun run test:dom` from `packages/app/`. */ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; +import { + __resetLocalMenuActionBusForTests, + emitLocalMenuAction, +} from '@/lib/local-menu-action-bus'; import { CreateProjectMenuTrigger } from './CreateProjectMenuTrigger'; -// `OkMenuAction` is module-private in desktop-bridge-types.ts; mirror just the -// members this test fires. The trigger only branches on the literal -// 'new-project', so an exact union is unnecessary. -type MenuActionLike = 'new-project' | 'new-doc' | 'toggle-sidebar'; - // Radix UI primitives (shadcn Dialog) reach for DOM globals at mount. The // broadly-needed constructors (MutationObserver) live in the shared // tests/dom/jsdom-preload.ts; NodeFilter (react-focus-scope) and @@ -47,31 +48,19 @@ if (globalWithDomShims.ResizeObserver === undefined) { const ASYNC_TIMEOUT_MS = 2000; -interface MenuActionBridgeStub { - bridge: OkDesktopBridge; - /** Invoke the most recently subscribed onMenuAction callback. */ - fire(action: MenuActionLike): void; - readonly unsubscribeCalls: number; +// Wrap the bus emit in act so the resulting setOpen state flush is applied +// before assertions run (mirrors fireEvent's internal act wrapping). +function fireMenuAction(action: Parameters[0]): void { + act(() => emitLocalMenuAction(action)); } /** - * Fake bridge exposing just the surface CreateProjectMenuTrigger + - * CreateProjectDialog touch on open: `onMenuAction` (subscription) and - * `fs.defaultProjectsRoot` (hydrated when the dialog opens). The captured - * callback is fired by `fire(...)` to simulate main's menu dispatch. + * Fake bridge exposing just the surface CreateProjectDialog touches on open: + * `fs.defaultProjectsRoot` + a few project/dialog stubs. `onMenuAction` is no + * longer read by the trigger (it listens on the bus), so it is omitted. */ -function makeMenuActionBridge(): MenuActionBridgeStub { - let captured: ((action: MenuActionLike) => void) | null = null; - let unsubscribeCalls = 0; - - const bridge = { - onMenuAction: (cb: (action: MenuActionLike) => void) => { - captured = cb; - return () => { - unsubscribeCalls += 1; - captured = null; - }; - }, +function makeBridge(): OkDesktopBridge { + return { fs: { defaultProjectsRoot: async (): Promise => '/Users/test/Projects', }, @@ -84,20 +73,6 @@ function makeMenuActionBridge(): MenuActionBridgeStub { openFolder: async (): Promise => null, }, } as unknown as OkDesktopBridge; - - return { - bridge, - fire: (action) => { - if (captured) { - // Wrap in act so the resulting setOpen state flush is applied before - // assertions run (mirrors fireEvent's internal act wrapping). - act(() => captured?.(action)); - } - }, - get unsubscribeCalls() { - return unsubscribeCalls; - }, - }; } describe('CreateProjectMenuTrigger', () => { @@ -111,21 +86,20 @@ describe('CreateProjectMenuTrigger', () => { afterEach(() => { cleanup(); + __resetLocalMenuActionBusForTests(); consoleWarnSpy.mockRestore(); }); test('dialog is closed until the new-project menu action fires', () => { - const stub = makeMenuActionBridge(); - render(); + render(); // Radix Dialog renders nothing when closed — no portal, no testid. expect(screen.queryByTestId('create-project-dialog') !== null).toBe(false); }); test('new-project menu action opens CreateProjectDialog', async () => { - const stub = makeMenuActionBridge(); - render(); + render(); - stub.fire('new-project'); + fireMenuAction('new-project'); await waitFor( () => { @@ -138,22 +112,23 @@ describe('CreateProjectMenuTrigger', () => { }); test('unrelated menu actions do not open the dialog', async () => { - const stub = makeMenuActionBridge(); - render(); + render(); - stub.fire('new-doc'); - stub.fire('toggle-sidebar'); + fireMenuAction('new-doc'); + fireMenuAction('toggle-sidebar'); // Give any erroneous open a chance to render before asserting absence. await new Promise((r) => setTimeout(r, 50)); expect(screen.queryByTestId('create-project-dialog') !== null).toBe(false); }); - test('unsubscribes from onMenuAction on unmount', () => { - const stub = makeMenuActionBridge(); - const { unmount } = render(); - expect(stub.unsubscribeCalls).toBe(0); + test('unsubscribes from the bus on unmount', async () => { + const { unmount } = render(); unmount(); - expect(stub.unsubscribeCalls).toBe(1); + + // After unmount the subscription is gone, so a later emit must not reopen. + fireMenuAction('new-project'); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByTestId('create-project-dialog') !== null).toBe(false); }); }); diff --git a/packages/app/src/components/CreateProjectMenuTrigger.tsx b/packages/app/src/components/CreateProjectMenuTrigger.tsx index 7278faaff..40e46a154 100644 --- a/packages/app/src/components/CreateProjectMenuTrigger.tsx +++ b/packages/app/src/components/CreateProjectMenuTrigger.tsx @@ -12,15 +12,16 @@ import { useEffect, useState } from 'react'; import { CreateProjectDialog } from '@/components/CreateProjectDialog'; import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; +import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; export function CreateProjectMenuTrigger({ bridge }: { bridge: OkDesktopBridge }) { const [open, setOpen] = useState(false); useEffect(() => { - return bridge.onMenuAction((action) => { + return subscribeLocalMenuAction((action) => { if (action === 'new-project') setOpen(true); }); - }, [bridge]); + }, []); return ; } diff --git a/packages/app/src/components/EditorArea.tsx b/packages/app/src/components/EditorArea.tsx index 918119c50..5d693a700 100644 --- a/packages/app/src/components/EditorArea.tsx +++ b/packages/app/src/components/EditorArea.tsx @@ -40,6 +40,7 @@ import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; import { docNameFromHash, hashFromDocName } from '@/lib/doc-hash'; import { getInitialDocPanelWidth, writeDocPanelWidth } from '@/lib/doc-panel-width-store'; import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts'; +import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; import { ProfilerBoundary } from '@/lib/perf'; import { matchesShareReceiveMiss, @@ -55,6 +56,7 @@ import { } from '@/lib/terminal-width-store'; import { useSettingsRoute } from '@/lib/use-settings-route'; import { cn } from '@/lib/utils'; +import { setViewMenuState } from '@/lib/view-menu-state-store'; import { useSyncStatus } from '@/presence/use-sync-status'; import { BottomComposer } from './BottomComposer'; import { shouldShowBottomComposer, shouldShowFolderComposer } from './bottom-composer-gate'; @@ -660,13 +662,16 @@ function EditorAreaInner({ }, [isCollapsed]); useEffect(() => { + // Mirror into the renderer store unconditionally (works on web) so the + // Cmd+K palette can show a state-reflecting "Show/Hide document panel" + // label. The bridge push below is desktop-only (drives the native menu). + setViewMenuState({ docPanelVisible: !isCollapsed }); if (window.okDesktop == null) return; window.okDesktop.editor.notifyViewMenuStateChanged({ docPanelVisible: !isCollapsed }); }, [isCollapsed]); useEffect(() => { - if (window.okDesktop == null) return; - return window.okDesktop.onMenuAction((action) => { + return subscribeLocalMenuAction((action) => { if (action === 'toggle-doc-panel') { togglePanel(); } diff --git a/packages/app/src/components/EditorPane.tsx b/packages/app/src/components/EditorPane.tsx index 1c61df6e0..017000066 100644 --- a/packages/app/src/components/EditorPane.tsx +++ b/packages/app/src/components/EditorPane.tsx @@ -13,6 +13,7 @@ import { useWorktreeAutoSyncNotice } from '@/hooks/use-worktree-autosync-notice' import { useConfigContext } from '@/lib/config-provider'; import { resolveDefaultCli } from '@/lib/default-cli-resolver'; import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts'; +import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; import { getInitialTerminalDock, type TerminalDockPosition, @@ -21,6 +22,7 @@ import { import { readPreferBareTerminal } from '@/lib/terminal-new-tab-store'; import { recordTerminalOpened } from '@/lib/terminal-telemetry'; import { loadStickyAgent } from '@/lib/unified-agent-store'; +import { setViewMenuState } from '@/lib/view-menu-state-store'; import { AuthModal } from './AuthModal'; import { AutoSyncOnboardingDialog } from './AutoSyncOnboardingDialog'; import { shouldShowAutoSyncOnboarding } from './auto-sync-onboarding-gate'; @@ -250,9 +252,7 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { // With a selection, ⌘J sends it to the terminal (reusing the active tab) // instead of toggling. useEffect(() => { - const bridge = window.okDesktop; - if (bridge == null) return; - return bridge.onMenuAction((action) => { + return subscribeLocalMenuAction((action) => { if (action === 'toggle-terminal') { if (sendSelectionToTerminalEvent(false)) return; setTerminalVisible((visible) => !visible); @@ -330,6 +330,14 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { useEffect(() => { if (window.okDesktop == null) return; if (!dockRestoreSettled) return; + // Mirror into the renderer store so the Cmd+K palette can show a + // state-reflecting "Show/Hide terminal" label (bridge push is main-only). + // Deliberately behind BOTH gates, unlike the unconditional sibling mirrors + // in FileSidebar / EditorArea: publishing the mount-initial `false` before + // the restore settles would flash a wrong palette label on a window whose + // dock is about to re-expand, and the web host has no terminal (and never + // settles the gate) so there is no state to mirror. + setViewMenuState({ terminalVisible }); window.okDesktop.editor.notifyViewMenuStateChanged({ terminalVisible }); }, [terminalVisible, dockRestoreSettled]); diff --git a/packages/app/src/components/FileSidebar.tsx b/packages/app/src/components/FileSidebar.tsx index c558f43e5..83a5160c4 100644 --- a/packages/app/src/components/FileSidebar.tsx +++ b/packages/app/src/components/FileSidebar.tsx @@ -90,11 +90,13 @@ import { emitFileTreeMenuActionRename, } from '@/lib/file-tree-menu-action-events'; import { VISIBLE_TARGETS } from '@/lib/handoff/targets'; +import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; import { ProfilerBoundary } from '@/lib/perf'; import { scheduleClipboardWrite } from '@/lib/share/clipboard-adapter'; import { buildFolderShareInput, runShareAction } from '@/lib/share/run-share-action'; import { useWorkspace } from '@/lib/use-workspace'; import { cn } from '@/lib/utils'; +import { setViewMenuState } from '@/lib/view-menu-state-store'; interface FileSidebarProps { onOpenSearch: () => void; @@ -479,8 +481,7 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) { // so every surface agrees on what counts as a no-op action. // `sidebarVisible` flips the View → Show/Hide Sidebar label main-side. useEffect(() => { - if (!bridge) return; - bridge.editor.notifyViewMenuStateChanged({ + const snapshot = { showHiddenFiles, showOkFolders, showOnlyMarkdownFiles, @@ -488,7 +489,13 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) { canExpandAll: showExpandAll, canCollapseAll: showCollapseAll, sidebarVisible: sidebarState === 'expanded', - }); + }; + // Mirror into the renderer store unconditionally (works on web) so the + // Cmd+K palette can render state-reflecting toggle labels. The bridge push + // below is desktop-only (drives the native View menu's Show/Hide labels). + setViewMenuState(snapshot); + if (!bridge) return; + bridge.editor.notifyViewMenuStateChanged(snapshot); }, [ bridge, showHiddenFiles, @@ -532,8 +539,7 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) { // without stale-closure bugs. // biome-ignore lint/correctness/useExhaustiveDependencies: patchSidebarVisibility is behaviorally stable — it reads only projectLocalBinding + t, both already deps; listing the helper itself would re-create the subscription every render (sibling pattern: CommandPalette's refreshSemanticStatus). useEffect(() => { - if (!bridge) return; - return bridge.onMenuAction((action) => { + return subscribeLocalMenuAction((action) => { // Revealed `.ok` targets are read-only: the mutating picks below // (rename / duplicate / move-to-trash) quietly no-op on them, mirroring // the row menu's suppressed affordances. The server's reserved-path diff --git a/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx b/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx index 603ca2750..8ccd59780 100644 --- a/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx +++ b/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx @@ -16,6 +16,10 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; +import { + __resetLocalMenuActionBusForTests, + emitLocalMenuAction, +} from '@/lib/local-menu-action-bus'; // `next-themes` is consumed at the top of NavigatorApp; provide a stable // stub so the test mount doesn't require a ThemeProvider. @@ -76,8 +80,6 @@ interface NavigatorBridgeStub { * callback; `fire(...)` invokes it the way main's menu dispatch would. */ function makeNavigatorBridge(): NavigatorBridgeStub { - let captured: ((action: MenuActionLike) => void) | null = null; - const bridge = { config: { collabUrl: '', @@ -86,12 +88,7 @@ function makeNavigatorBridge(): NavigatorBridgeStub { projectName: 'Project Navigator', mode: 'navigator', }, - onMenuAction: (cb: (action: MenuActionLike) => void) => { - captured = cb; - return () => { - captured = null; - }; - }, + onMenuAction: () => () => {}, project: { listRecent: async () => [], removeRecent: async () => undefined, @@ -124,11 +121,9 @@ function makeNavigatorBridge(): NavigatorBridgeStub { return { bridge, fire: (action) => { - if (captured) { - // Wrap in act so the resulting setOpen state flush is applied before - // assertions run (mirrors fireEvent's internal act wrapping). - act(() => captured?.(action)); - } + // Wrap in act so the resulting setOpen state flush is applied before + // assertions run (mirrors fireEvent's internal act wrapping). + act(() => emitLocalMenuAction(action)); }, }; } @@ -147,6 +142,7 @@ describe('NavigatorApp new-project menu-action subscription', () => { afterEach(() => { cleanup(); + __resetLocalMenuActionBusForTests(); consoleWarnSpy.mockRestore(); consoleErrorSpy.mockRestore(); }); diff --git a/packages/app/src/components/NavigatorApp.tsx b/packages/app/src/components/NavigatorApp.tsx index 78b0debc7..ebc24d168 100644 --- a/packages/app/src/components/NavigatorApp.tsx +++ b/packages/app/src/components/NavigatorApp.tsx @@ -31,6 +31,7 @@ import { resolveErrorMessage, runWithErrorStatePure as runWithErrorStatePureBase, } from '@/lib/error-state'; +import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; import { seedClient } from '@/lib/seed-client'; import { createCloneController } from '@/lib/share/clone-controller'; import { ipcAuthQueryTransport } from '@/lib/transports/auth-query-transport'; @@ -204,12 +205,12 @@ export function NavigatorApp({ bridge }: { bridge: OkDesktopBridge }) { // Help → Report a Bug…, which here opens the report dialog scoped // system-wide (the Navigator has no project). useEffect(() => { - return bridge.onMenuAction((action) => { + return subscribeLocalMenuAction((action) => { if (action === 'new-project') setCreateDialogOpen(true); if (action === 'report-bug') setReportBugOpen(true); if (action === 'close-active-tab-or-window') window.close(); }); - }, [bridge]); + }, []); /** * Wrap any bridge call in a visible error state. Without this the IPC diff --git a/packages/app/src/components/ProblemsPanel.dom.test.tsx b/packages/app/src/components/ProblemsPanel.dom.test.tsx index 5b7420eb4..7a7354845 100644 --- a/packages/app/src/components/ProblemsPanel.dom.test.tsx +++ b/packages/app/src/components/ProblemsPanel.dom.test.tsx @@ -13,14 +13,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import type { ReactNode } from 'react'; import { renderLinguiTemplate } from '@/test-utils/lingui-mock'; -mock.module('@lingui/core/macro', () => ({ t: renderLinguiTemplate })); -mock.module('@lingui/react/macro', () => ({ +// Both lingui macro specifiers alias to ONE shim module under the vitest dom +// config, so two mock registrations race for a single resolved module id and +// only one factory survives. The factories must be the same superset object — +// a specifier-shaped split (t on core, components on react) loses whichever +// half the race drops (observed: useLingui vanishing when the core factory +// won). +const linguiMacroMock = { + t: renderLinguiTemplate, Trans: ({ children }: { children: ReactNode }) => children, Plural: ({ value, one, other }: { value: number; one: string; other: string }) => ( <>{(value === 1 ? one : other).replace('#', String(value))} ), useLingui: () => ({ t: renderLinguiTemplate }), -})); +}; +mock.module('@lingui/core/macro', () => linguiMacroMock); +mock.module('@lingui/react/macro', () => linguiMacroMock); let auditCalls = 0; let runLintAuditImpl: () => Promise = async () => null; diff --git a/packages/app/src/components/ProjectSwitcher.dom.test.tsx b/packages/app/src/components/ProjectSwitcher.dom.test.tsx index 42a5dcbeb..dfd60036a 100644 --- a/packages/app/src/components/ProjectSwitcher.dom.test.tsx +++ b/packages/app/src/components/ProjectSwitcher.dom.test.tsx @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { createContext, type ReactNode, use } from 'react'; +import { + __resetLocalMenuActionBusForTests, + emitLocalMenuAction, +} from '@/lib/local-menu-action-bus'; type MenuProps = { children?: ReactNode; @@ -242,17 +246,10 @@ function createBridge() { list: mock(() => Promise.resolve({ ok: false as const, reason: 'no-git' as const })), create: mock(() => Promise.resolve({ ok: false as const, reason: 'no-git' as const })), }, - onMenuAction: mock((cb: (action: string) => void) => { - menuActionCb = cb; - return () => { - menuActionCb = null; - }; - }), + onMenuAction: mock(() => () => {}), }; } -let menuActionCb: ((action: string) => void) | null = null; - async function openMenu() { fireEvent.click(screen.getByTestId('project-switcher-trigger')); act(() => { @@ -274,7 +271,7 @@ describe('ProjectSwitcher dropdown behavior', () => { keydownBubbleCount = 0; createDialogProps = []; newWorktreeProps = []; - menuActionCb = null; + __resetLocalMenuActionBusForTests(); (window as unknown as { okDesktop?: unknown }).okDesktop = undefined; }); @@ -519,14 +516,13 @@ describe('ProjectSwitcher dropdown behavior', () => { test('File menu "new-worktree" action opens the New Worktree dialog; "switch-worktree" opens the dropdown', async () => { const bridge = createBridge(); render(); - expect(menuActionCb).not.toBeNull(); - act(() => menuActionCb?.('new-worktree')); + act(() => emitLocalMenuAction('new-worktree')); await waitFor(() => expect(screen.getByTestId('new-worktree-dialog').getAttribute('data-open')).toBe('true'), ); - act(() => menuActionCb?.('switch-worktree')); + act(() => emitLocalMenuAction('switch-worktree')); await waitFor(() => { expect(screen.getByTestId('project-switcher-search')).not.toBeNull(); }); diff --git a/packages/app/src/components/ProjectSwitcher.tsx b/packages/app/src/components/ProjectSwitcher.tsx index 6425f85df..6937f55c8 100644 --- a/packages/app/src/components/ProjectSwitcher.tsx +++ b/packages/app/src/components/ProjectSwitcher.tsx @@ -33,6 +33,7 @@ import { useCurrentBranch } from '@/hooks/use-current-branch'; import { useWorktrees } from '@/hooks/use-worktrees'; import type { OkDesktopBridge, RecentProjectEntry } from '@/lib/desktop-bridge-types'; import { runWithToast as runWithToastBase } from '@/lib/error-state'; +import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; import { cn } from '@/lib/utils'; import { CreateProjectDialog } from './CreateProjectDialog'; import { NewWorktreeDialog } from './NewWorktreeDialog'; @@ -153,7 +154,7 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) { // `new-worktree` opens the create dialog; `switch-worktree` opens this // dropdown (which lazy-loads the worktree list). useEffect(() => { - return bridge.onMenuAction((action) => { + return subscribeLocalMenuAction((action) => { if (action === 'new-worktree') { setOpen(false); setFlyoutPath(null); @@ -163,7 +164,7 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) { setOpen(true); } }); - }, [bridge]); + }, []); const onOpenFolder = () => { handleOpenChange(false); diff --git a/packages/app/src/components/ReportBugDialog.dom.test.tsx b/packages/app/src/components/ReportBugDialog.dom.test.tsx index 49b231cc0..b4495cd0e 100644 --- a/packages/app/src/components/ReportBugDialog.dom.test.tsx +++ b/packages/app/src/components/ReportBugDialog.dom.test.tsx @@ -190,7 +190,10 @@ async function renderDialog( ); // ReportBugDialog is lazy-loaded — wait for the body chunk to resolve and // mount before returning so callers' synchronous queries see the dialog. - await screen.findByRole('dialog'); + // Generous deadline: the file's first render pays the chunk's cold + // transform+import cost, which can exceed findByRole's 1s default on a + // contended CI runner (only the failure path ever waits this long). + await screen.findByRole('dialog', {}, { timeout: 15_000 }); return { openChangeCalls }; } diff --git a/packages/app/src/components/ReportBugMenuTrigger.dom.test.tsx b/packages/app/src/components/ReportBugMenuTrigger.dom.test.tsx index 4972fd7a7..534cc1a01 100644 --- a/packages/app/src/components/ReportBugMenuTrigger.dom.test.tsx +++ b/packages/app/src/components/ReportBugMenuTrigger.dom.test.tsx @@ -1,26 +1,23 @@ /** * DOM mount test for ReportBugMenuTrigger — the App-root surface that opens - * ReportBugDialog when main fires the `report-bug` menu action - * (Help → Report a Bug…). + * ReportBugDialog when the `report-bug` menu action fires (Help → Report a Bug…). * * Pins the user-visible contract: the dialog is closed until the menu action - * fires, opens on `report-bug`, and ignores unrelated menu actions. The - * trigger subscribes to `bridge.onMenuAction`; this test captures the - * subscribed callback through a fake bridge and invokes it directly — the - * same path main's `sendMenuActionToFocused('report-bug')` drives over IPC. + * fires, opens on `report-bug`, and ignores unrelated menu actions. The trigger + * now subscribes to the renderer-local menu-action bus (a real menu click + * reaches it via main → `ok:menu-action` → the bus forwarder), so this test + * drives it with `emitLocalMenuAction` — the same fan-out a menu click hits. * * Invocation: `bun run test:dom` from `packages/app/`. */ import { afterEach, describe, expect, test } from 'bun:test'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; -import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; +import { + __resetLocalMenuActionBusForTests, + emitLocalMenuAction, +} from '@/lib/local-menu-action-bus'; import { ReportBugMenuTrigger } from './ReportBugMenuTrigger'; -// `OkMenuAction` is module-private in desktop-bridge-types.ts; mirror just the -// members this test fires. The trigger only branches on the literal -// 'report-bug', so an exact union is unnecessary. -type MenuActionLike = 'report-bug' | 'new-doc' | 'toggle-sidebar'; - // Radix UI primitives (shadcn Dialog) reach for DOM globals at mount. The // broadly-needed constructors (MutationObserver) live in the shared // tests/dom/jsdom-preload.ts; NodeFilter (react-focus-scope) and @@ -47,65 +44,28 @@ if (globalWithDomShims.ResizeObserver === undefined) { const ASYNC_TIMEOUT_MS = 2000; -interface MenuActionBridgeStub { - bridge: OkDesktopBridge; - /** Invoke the most recently subscribed onMenuAction callback. */ - fire(action: MenuActionLike): void; - readonly unsubscribeCalls: number; -} - -/** - * Fake bridge exposing just the surface ReportBugMenuTrigger touches: - * `onMenuAction` (subscription). ReportBugDialog itself talks to - * `window.okDesktop` only on user actions (create/send), which this test - * never reaches — opening the compose phase needs no further bridge surface. - */ -function makeMenuActionBridge(): MenuActionBridgeStub { - let captured: ((action: MenuActionLike) => void) | null = null; - let unsubscribeCalls = 0; - - const bridge = { - onMenuAction: (cb: (action: MenuActionLike) => void) => { - captured = cb; - return () => { - unsubscribeCalls += 1; - captured = null; - }; - }, - } as unknown as OkDesktopBridge; - - return { - bridge, - fire: (action) => { - if (captured) { - // Wrap in act so the resulting setOpen state flush is applied before - // assertions run (mirrors fireEvent's internal act wrapping). - act(() => captured?.(action)); - } - }, - get unsubscribeCalls() { - return unsubscribeCalls; - }, - }; +// Wrap the bus emit in act so the resulting setOpen state flush is applied +// before assertions run (mirrors fireEvent's internal act wrapping). +function fireMenuAction(action: Parameters[0]): void { + act(() => emitLocalMenuAction(action)); } describe('ReportBugMenuTrigger', () => { afterEach(() => { cleanup(); + __resetLocalMenuActionBusForTests(); }); test('dialog is closed until the report-bug menu action fires', () => { - const stub = makeMenuActionBridge(); - render(); + render(); // Radix Dialog renders nothing when closed — no portal, no dialog role. expect(screen.queryByRole('dialog')).toBeNull(); }); test('report-bug menu action opens ReportBugDialog', async () => { - const stub = makeMenuActionBridge(); - render(); + render(); - stub.fire('report-bug'); + fireMenuAction('report-bug'); await waitFor( () => { @@ -118,22 +78,23 @@ describe('ReportBugMenuTrigger', () => { }); test('unrelated menu actions do not open the dialog', async () => { - const stub = makeMenuActionBridge(); - render(); + render(); - stub.fire('new-doc'); - stub.fire('toggle-sidebar'); + fireMenuAction('new-doc'); + fireMenuAction('toggle-sidebar'); // Give any erroneous open a chance to render before asserting absence. await new Promise((r) => setTimeout(r, 50)); expect(screen.queryByRole('dialog')).toBeNull(); }); - test('unsubscribes from onMenuAction on unmount', () => { - const stub = makeMenuActionBridge(); - const { unmount } = render(); - expect(stub.unsubscribeCalls).toBe(0); + test('unsubscribes from the bus on unmount', async () => { + const { unmount } = render(); unmount(); - expect(stub.unsubscribeCalls).toBe(1); + + // After unmount the subscription is gone, so a later emit must not reopen. + fireMenuAction('report-bug'); + await new Promise((r) => setTimeout(r, 50)); + expect(screen.queryByRole('dialog')).toBeNull(); }); }); diff --git a/packages/app/src/components/ReportBugMenuTrigger.tsx b/packages/app/src/components/ReportBugMenuTrigger.tsx index 4c7ea4e01..706e8af33 100644 --- a/packages/app/src/components/ReportBugMenuTrigger.tsx +++ b/packages/app/src/components/ReportBugMenuTrigger.tsx @@ -11,16 +11,16 @@ import { useEffect, useState } from 'react'; import { ReportBugDialog } from '@/components/ReportBugDialog'; -import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; +import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; -export function ReportBugMenuTrigger({ bridge }: { bridge: OkDesktopBridge }) { +export function ReportBugMenuTrigger() { const [open, setOpen] = useState(false); useEffect(() => { - return bridge.onMenuAction((action) => { + return subscribeLocalMenuAction((action) => { if (action === 'report-bug') setOpen(true); }); - }, [bridge]); + }, []); return ; } diff --git a/packages/app/src/components/TerminalDock.dom.test.tsx b/packages/app/src/components/TerminalDock.dom.test.tsx index 8afacfd72..183465264 100644 --- a/packages/app/src/components/TerminalDock.dom.test.tsx +++ b/packages/app/src/components/TerminalDock.dom.test.tsx @@ -21,7 +21,11 @@ import { act, cleanup, render, screen, waitFor, within } from '@testing-library/ import userEvent from '@testing-library/user-event'; import { useEffect, useRef, useState } from 'react'; import { TooltipProvider } from '@/components/ui/tooltip'; -import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; +import type { OkDesktopBridge, OkMenuAction } from '@/lib/desktop-bridge-types'; +import { + __resetLocalMenuActionBusForTests, + emitLocalMenuAction, +} from '@/lib/local-menu-action-bus'; import type { TerminalDockPosition } from '@/lib/terminal-dock-store'; import { requestActiveTerminalInput } from './handoff/terminal-input-events'; @@ -156,7 +160,6 @@ const { TerminalSessionsHost } = await import('./TerminalSessionsHost'); const { STAGE_PASTE_SETTLE_MS } = await import('./TerminalPanel'); function makeBridge() { - const menuHandlers: Array<(action: string) => void> = []; const viewMenuPushes: Array<{ terminalLive?: boolean }> = []; // Hand each session a distinct PTY id (pty-1, pty-2, …) so a close can assert // exactly which session's PTY was reaped — the demux the dock owns. @@ -170,13 +173,7 @@ function makeBridge() { // existing PTY (it opens its own tab), so tests assert `input` stays unused. const input = mock((_ptyId: string, _data: string) => {}); const bridge = { - onMenuAction(cb: (action: string) => void) { - menuHandlers.push(cb); - return () => { - const index = menuHandlers.indexOf(cb); - if (index >= 0) menuHandlers.splice(index, 1); - }; - }, + onMenuAction: () => () => {}, editor: { notifyViewMenuStateChanged(state: { terminalLive?: boolean }) { viewMenuPushes.push(state); @@ -190,8 +187,11 @@ function makeBridge() { kill, input, viewMenuPushes, - dispatchMenuAction(action: string) { - for (const cb of menuHandlers) cb(action); + // TerminalSessionsHost now listens on the renderer-local menu-action bus + // (a real menu click reaches it via main → the bus forwarder), so the test + // drives it with emitLocalMenuAction. + dispatchMenuAction(action: OkMenuAction) { + emitLocalMenuAction(action); }, }; } @@ -330,9 +330,11 @@ describe('TerminalDock multi-session', () => { panelHandle.expand.mockClear(); sharedPanelRef.current = panelHandle; titleEmitters.clear(); + __resetLocalMenuActionBusForTests(); }); afterEach(() => { cleanup(); + __resetLocalMenuActionBusForTests(); }); test('tab strip exposes the dock-toggle + collapse buttons and no drag grip', () => { diff --git a/packages/app/src/components/TerminalSessionsHost.tsx b/packages/app/src/components/TerminalSessionsHost.tsx index a2a4fd1e7..0b1f1713e 100644 --- a/packages/app/src/components/TerminalSessionsHost.tsx +++ b/packages/app/src/components/TerminalSessionsHost.tsx @@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'; import { TabsContent } from '@/components/ui/tabs'; import { resolveDefaultCli } from '@/lib/default-cli-resolver'; import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; +import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; import type { TerminalDockPosition } from '@/lib/terminal-dock-store'; import { getInitialPreferBareTerminal, @@ -12,6 +13,7 @@ import { } from '@/lib/terminal-new-tab-store'; import { loadStickyAgent, saveStickyAgent, terminalCliId } from '@/lib/unified-agent-store'; import { cn } from '@/lib/utils'; +import { setViewMenuState } from '@/lib/view-menu-state-store'; import type { TerminalLaunchIntent } from './EditorPane'; import { visibleTerminalClis } from './handoff/terminal-cli-display'; import { subscribeToActiveTerminalInput } from './handoff/terminal-input-events'; @@ -577,13 +579,13 @@ export function TerminalSessionsHost({ // editor window the doc tree owns ⌘W (DocumentContext closes the active doc // tab); handling it here too would double-close. useEffect(() => { - return bridge.onMenuAction((action) => { + return subscribeLocalMenuAction((action) => { if (action === 'new-terminal') openSessionRef.current(null); else if (action === 'kill-terminal') closeActiveRef.current(); else if (action === 'close-active-tab-or-window' && variant === 'window') closeActiveRef.current(); }); - }, [bridge, variant]); + }, [variant]); // ⌘1–⌘9 jump straight to the Nth tab. Capture phase so a focused xterm can't // swallow the chord; scoped to focus inside the stable host div (which follows @@ -654,7 +656,11 @@ export function TerminalSessionsHost({ // enables only while at least one session is live. A collapsed-but-alive // terminal still counts: the session list tracks the latch, not visibility. useEffect(() => { - bridge.editor.notifyViewMenuStateChanged({ terminalLive: sessions.length > 0 }); + const terminalLive = sessions.length > 0; + // Mirror into the renderer store so the Cmd+K palette can gate "Kill + // terminal" on a live session (the bridge push below is main-only). + setViewMenuState({ terminalLive }); + bridge.editor.notifyViewMenuStateChanged({ terminalLive }); }, [bridge, sessions.length]); useEffect(() => { diff --git a/packages/app/src/components/TerminalWindowApp.dom.test.tsx b/packages/app/src/components/TerminalWindowApp.dom.test.tsx index 0c16d9469..b94401ff0 100644 --- a/packages/app/src/components/TerminalWindowApp.dom.test.tsx +++ b/packages/app/src/components/TerminalWindowApp.dom.test.tsx @@ -18,7 +18,11 @@ import { act, cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useEffect, useRef } from 'react'; import { TooltipProvider } from '@/components/ui/tooltip'; -import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; +import type { OkDesktopBridge, OkMenuAction } from '@/lib/desktop-bridge-types'; +import { + __resetLocalMenuActionBusForTests, + emitLocalMenuAction, +} from '@/lib/local-menu-action-bus'; mock.module('./TerminalGate', () => ({ // biome-ignore lint/suspicious/noExplicitAny: test stub @@ -52,7 +56,6 @@ const { TerminalWindowApp } = await import('./TerminalWindowApp'); const { selectDesktopRootApp } = await import('./desktop-root-app'); function makeBridge() { - const menuHandlers: Array<(action: string) => void> = []; const viewMenuPushes: Array<{ terminalLive?: boolean }> = []; let ptyCounter = 0; const create = mock(async () => { @@ -62,13 +65,7 @@ function makeBridge() { const kill = mock(async (_id: string) => {}); const bridge = { config: { mode: 'terminal' }, - onMenuAction(cb: (action: string) => void) { - menuHandlers.push(cb); - return () => { - const index = menuHandlers.indexOf(cb); - if (index >= 0) menuHandlers.splice(index, 1); - }; - }, + onMenuAction: () => () => {}, editor: { notifyViewMenuStateChanged(state: { terminalLive?: boolean }) { viewMenuPushes.push(state); @@ -81,8 +78,11 @@ function makeBridge() { create, kill, viewMenuPushes, - dispatchMenuAction(action: string) { - for (const cb of menuHandlers) cb(action); + // TerminalSessionsHost now listens on the renderer-local menu-action bus + // (a real menu click reaches it via main → the bus forwarder), so the test + // drives it with emitLocalMenuAction. + dispatchMenuAction(action: OkMenuAction) { + emitLocalMenuAction(action); }, }; } @@ -114,6 +114,7 @@ function activePanelId(): string | null { describe('TerminalWindowApp', () => { afterEach(() => { cleanup(); + __resetLocalMenuActionBusForTests(); }); test('opens with exactly one shell tab on mount', () => { @@ -299,6 +300,7 @@ describe('selectDesktopRootApp routing', () => { }); afterEach(() => { cleanup(); + __resetLocalMenuActionBusForTests(); }); test('mode=terminal selects the terminal window app', () => { diff --git a/packages/app/src/editor/DocumentContext.dom.test.tsx b/packages/app/src/editor/DocumentContext.dom.test.tsx index 4ad7de58d..1a1f81550 100644 --- a/packages/app/src/editor/DocumentContext.dom.test.tsx +++ b/packages/app/src/editor/DocumentContext.dom.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import { type ReactNode, useLayoutEffect, useState } from 'react'; import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; import { hashFromAssetPath } from '@/lib/doc-hash'; +import { emitLocalMenuAction } from '@/lib/local-menu-action-bus'; import { assetTabId, docTabId, localTabSessionStorageKey } from './editor-tabs'; let mockCollabUrl: string | null = null; @@ -91,7 +92,6 @@ interface DeferredSessionBridgeStub { } function makeEditorBridgeStub(): EditorBridgeStub { - let captured: ((action: MenuActionLike) => void) | null = null; const bridge = { config: { mode: 'editor', @@ -100,12 +100,7 @@ function makeEditorBridgeStub(): EditorBridgeStub { projectPath: '', projectName: 'Test Project', }, - onMenuAction: (cb: (action: MenuActionLike) => void) => { - captured = cb; - return () => { - captured = null; - }; - }, + onMenuAction: () => () => {}, project: { getSessionState: async () => ({ openTabs: [], @@ -120,8 +115,11 @@ function makeEditorBridgeStub(): EditorBridgeStub { return { bridge, + // The editor window's close-tab handler now listens on the renderer-local + // menu-action bus (a real menu click reaches it via the bus forwarder), so + // the test drives it with emitLocalMenuAction. fire: (action) => { - act(() => captured?.(action)); + act(() => emitLocalMenuAction(action)); }, }; } diff --git a/packages/app/src/editor/DocumentContext.tsx b/packages/app/src/editor/DocumentContext.tsx index 2cb27d53c..2badc4e21 100644 --- a/packages/app/src/editor/DocumentContext.tsx +++ b/packages/app/src/editor/DocumentContext.tsx @@ -18,6 +18,7 @@ import { skillFileFromHash, } from '@/lib/doc-hash'; import { emitBranchChanged, emitDocumentsChanged } from '@/lib/documents-events'; +import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus'; import { mark } from '@/lib/perf'; import { refreshServerInfo } from '@/lib/server-info-refresh'; import { useCollabUrl } from '@/lib/use-collab-url'; @@ -1428,14 +1429,12 @@ export function DocumentProvider({ children }: { children: ReactNode }) { }; useEffect(() => { - const bridge = getDesktopBridge(); - if (!bridge) return; - return bridge.onMenuAction((action) => { + return subscribeLocalMenuAction((action) => { if (action !== 'close-active-tab-or-window') return; if (!closeActiveTabOrWindow()) window.close(); }); }, [ - // biome-ignore lint/correctness/useExhaustiveDependencies: closeActiveTabOrWindow is render-bound; re-subscribing keeps the desktop menu handler fresh for current tab state. + // biome-ignore lint/correctness/useExhaustiveDependencies: closeActiveTabOrWindow is render-bound; re-subscribing keeps the menu handler fresh for current tab state. closeActiveTabOrWindow, ]); diff --git a/packages/app/src/lib/command-menu-parity.test-helper.ts b/packages/app/src/lib/command-menu-parity.test-helper.ts new file mode 100644 index 000000000..c36c380c7 --- /dev/null +++ b/packages/app/src/lib/command-menu-parity.test-helper.ts @@ -0,0 +1,67 @@ +/** + * Shared OkMenuAction classification for the command-palette / menu parity + * ratchets. Lives in a test-helper (not a `.test.ts`) so the ratchet suite + * (Ratchet A/B, `command-menu-parity.test.ts`) and the DOM render suite + * (Ratchet C, `CommandPalette.dom.test.tsx`) consume ONE source of truth. That + * shared list is what makes Ratchet C durable: a newly-classified palette id + * with no rendered row (and not on the pre-existing escape hatch) turns the DOM + * suite red instead of silently satisfying only the id-classification ratchets. + */ + +// Ids reachable from Cmd+K after Phase 1 (backfilled or already present). +// `send-to-ai` is palette-present via the per-target Open-with-AI group. +export const PALETTE_COMMAND_IDS = new Set([ + 'new-doc', + 'new-folder', + 'new-project', + 'new-from-template', + 'rename', + 'duplicate', + 'move-to-trash', + 'reveal-in-finder', + 'send-to-ai', + 'copy-full-path', + 'copy-relative-path', + 'toggle-sidebar', + 'toggle-show-hidden-files', + 'toggle-show-ok-folders', + 'toggle-show-only-markdown-files', + 'toggle-show-skills-section', + 'expand-all-tree', + 'collapse-all-tree', + 'toggle-doc-panel', + 'toggle-terminal', + 'new-terminal', + 'kill-terminal', + 'new-worktree', + 'switch-worktree', + 'close-active-tab-or-window', + 'report-bug', +]); + +// Ids deliberately NOT palette rows — each with a stated reason. +export const APP_RESERVED_IDS = new Map([ + ['delete', 'sidebar Trash id, distinct from the menu move-to-trash; not separately surfaced'], + ['toggle-source', 'source-mode toggle owned by the editor, not a palette action today'], + ['save-version', 'deferred Project menu — not yet a shipped command anywhere'], + ['version-history', 'deferred Project menu — not yet a shipped command anywhere'], + ['focus-search', 'focus-routing id, not a user-facing command'], + ['focus-command-palette', 'focus-routing id; self-referential inside the palette'], +]); + +// Palette-command ids that reach Cmd+K through a PRE-EXISTING surface rather than +// a backfilled id-backed row, so they carry no `ID_BACKED` entry in the DOM +// suite. Each has its own rendered palette row/group (verified below), so Ratchet +// C treats them as covered: +// new-doc → the pre-existing "New file" row (testid command-palette-new-file) +// new-folder → the pre-existing "New folder" row +// new-project → the pre-existing "New project" row +// send-to-ai → the per-target "Open with AI" group +// report-bug → the pre-existing "Report a Bug" row +export const PRE_EXISTING_PALETTE_IDS = new Set([ + 'new-doc', + 'new-folder', + 'new-project', + 'send-to-ai', + 'report-bug', +]); diff --git a/packages/app/src/lib/command-menu-parity.test.ts b/packages/app/src/lib/command-menu-parity.test.ts new file mode 100644 index 000000000..f0475e4bc --- /dev/null +++ b/packages/app/src/lib/command-menu-parity.test.ts @@ -0,0 +1,363 @@ +/** + * Command-palette / menu parity ratchet (Ratchet A + B). + * + * Modeled on `attribution-sweep-coverage.test.ts`: partitions every command + * identity into exactly one classification and fails when something new is + * neither reachable from Cmd+K nor explicitly reserved with a reason. This is + * the durable "nothing is missed" guarantee — a new `OkMenuAction` id or a new + * native-menu leaf that no one classified turns this test red. + * + * Two id-spaces are swept: + * - Ratchet A: the `OK_MENU_ACTIONS` runtime array (its drift from the + * `OkMenuAction` type is a compile error in `ok-menu-actions.ts`; here we + * assert every id is classified palette-command vs. app-reserved). + * - Ratchet B: every actionable leaf parsed from `buildMenuTemplate` across + * both platform branches is classified palette-command / OS-role / reserved. + */ + +import { describe, expect, test } from 'bun:test'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { APP_RESERVED_IDS, PALETTE_COMMAND_IDS } from '@/lib/command-menu-parity.test-helper'; +import { formatShortcut, type KeyboardShortcutId } from '@/lib/keyboard-shortcuts'; +import { OK_MENU_ACTIONS } from '@/lib/ok-menu-actions'; +import { buildMenuTemplate, type MenuDeps } from '../../../desktop/src/main/menu.ts'; + +// Derive the menu-item type from buildMenuTemplate itself, so the ratchet does +// not take a direct `electron` dependency (the app package has none). +type MenuTemplateItem = ReturnType[number]; + +// ─── Ratchet A: OkMenuAction id classification ────────────────────────────── +// The classification sets (PALETTE_COMMAND_IDS / APP_RESERVED_IDS) are shared +// with the DOM render suite via command-menu-parity.test-helper. + +// ─── Ratchet B: native-menu leaf classification ───────────────────────────── + +// Electron `role:` items — keyboard-native / platform-standard / dev-only. This +// allowlist is maintained by hand against Electron's role set. +const OS_ROLE_EXEMPT = new Set([ + 'about', + 'services', + 'hide', + 'hideOthers', + 'unhide', + 'quit', + 'undo', + 'redo', + 'cut', + 'copy', + 'paste', + 'selectAll', + 'reload', + 'forceReload', + 'toggleDevTools', + 'resetZoom', + 'zoomIn', + 'zoomOut', + 'togglefullscreen', + 'minimize', + 'zoom', + 'front', + 'close', +]); + +// Actionable menu leaves that ARE palette commands (labels, ellipsis-normalized; +// state-aware View toggles contribute both their Show/Hide variants). "Full path" +// / "Relative path" are the Copy-path submenu leaves the palette flattens. +const PALETTE_COMMAND_LABELS = new Set([ + 'New file', + 'New folder', + 'New from template', + 'New project', + 'Switch project', + 'Open folder', + 'New worktree', + 'Switch worktree', + 'Duplicate', + 'Rename', + 'Move to Trash', + 'Reveal in Finder', + 'Open with AI', + 'Full path', + 'Relative path', + 'Set up OpenKnowledge integrations', + 'Close tab', + 'Check for updates', + 'Settings', + 'Check spelling while typing', + 'Show sidebar', + 'Hide sidebar', + 'Show document panel', + 'Hide document panel', + 'Show Terminal', + 'Hide Terminal', + 'Show hidden files', + 'Show .ok folders', + 'Show only markdown files', + 'Show skills section', + 'Expand all', + 'Collapse all', + 'New Terminal', + 'Kill Terminal', + 'OpenKnowledge on GitHub', + 'Report a Bug', + // Present already; not a single registry command but reachable from the palette + // as its own surface (Install for Claude, gated behind SHOW_INSTALL_SKILL). + 'Install for Claude Chat & Cowork (desktop app)', +]); + +// Non-role, non-palette leaves deliberately kept out of Cmd+K — each reasoned. +const APP_RESERVED_LABELS = new Map([ + ['Uninstall OpenKnowledge', 'rare + destructive; deliberately not a quick-launch row'], + ['New Terminal Window', 'opens directly in main with no renderer handler; window management'], +]); + +/** All the deps present so every conditional menu item renders. */ +function makeFullDeps(): MenuDeps { + const noop = () => {}; + return { + appName: 'OpenKnowledge', + showDevToolsMenu: true, + dialog: {} as MenuDeps['dialog'], + openNavigator: noop, + openProject: () => Promise.resolve(), + getRecentProjects: () => [], + clearRecentProjects: noop, + openExternalUrl: noop, + reconfigureMcpWiring: noop, + openInstallSkillDialog: noop, + openSettings: noop, + onReportBug: noop, + onCheckForUpdates: noop, + onUninstall: noop, + activeTarget: { kind: 'doc', target: 'doc.md' } as MenuDeps['activeTarget'], + onNewFile: noop, + onNewFolder: noop, + onNewFromTemplate: noop, + onNewProject: noop, + onNewWorktree: noop, + onSwitchWorktree: noop, + onRename: noop, + onDuplicate: noop, + onMoveToTrash: noop, + onCloseActiveTabOrWindow: noop, + onRevealInFinder: noop, + onSendToAi: noop, + onCopyFullPath: noop, + onCopyRelativePath: noop, + showHiddenFilesChecked: false, + onToggleShowHiddenFiles: noop, + showOkFoldersChecked: false, + onToggleShowOkFolders: noop, + showOnlyMarkdownFilesChecked: false, + onToggleShowOnlyMarkdownFiles: noop, + showSkillsSectionChecked: false, + onToggleShowSkillsSection: noop, + sidebarVisible: true, + onToggleSidebar: noop, + docPanelVisible: true, + onToggleDocPanel: noop, + terminalVisible: true, + onToggleTerminal: noop, + onNewTerminal: noop, + onKillTerminal: noop, + onNewTerminalWindow: noop, + terminalLive: true, + canExpandAll: true, + canCollapseAll: true, + onExpandAll: noop, + onCollapseAll: noop, + spellCheckEnabled: true, + onToggleSpellCheck: noop, + }; +} + +interface Leaf { + label: string; + role?: string; + accelerator?: string; +} + +/** Recurse the template, collecting actionable leaves (skip separators, disabled + * placeholders, and submenu parents — which contribute their children). */ +function collectLeaves(items: readonly MenuTemplateItem[], out: Leaf[]): void { + for (const item of items) { + if (item.type === 'separator') continue; + const sub = item.submenu; + if (Array.isArray(sub)) { + collectLeaves(sub, out); + continue; + } + if (item.enabled === false) continue; // disabled placeholder (e.g. "No recent projects") + const accelerator = typeof item.accelerator === 'string' ? item.accelerator : undefined; + if (item.role) { + out.push({ + label: typeof item.label === 'string' ? item.label : '', + role: item.role, + accelerator, + }); + continue; + } + if (typeof item.label === 'string') { + out.push({ label: item.label, accelerator }); + } + } +} + +/** Strip the trailing platform ellipsis the native menu appends at call time. */ +function normalizeLabel(label: string): string { + return label.replace(/…$/, '').trim(); +} + +function collectLeavesForPlatform(platform: NodeJS.Platform): Leaf[] { + const original = process.platform; + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + try { + const leaves: Leaf[] = []; + collectLeaves(buildMenuTemplate(makeFullDeps()), leaves); + return leaves; + } finally { + Object.defineProperty(process, 'platform', { value: original, configurable: true }); + } +} + +describe('command-menu parity ratchet', () => { + test('Ratchet A: every OkMenuAction id is classified palette-command or app-reserved', () => { + const untracked = OK_MENU_ACTIONS.filter( + (id) => !PALETTE_COMMAND_IDS.has(id) && !APP_RESERVED_IDS.has(id), + ); + expect(untracked).toEqual([]); + }); + + test('Ratchet A: every classified id is a real OkMenuAction (no stale entries)', () => { + const known = new Set(OK_MENU_ACTIONS); + const stale = [...PALETTE_COMMAND_IDS, ...APP_RESERVED_IDS.keys()].filter( + (id) => !known.has(id), + ); + expect(stale).toEqual([]); + }); + + test('Ratchet B: every actionable menu leaf is classified across both platforms', () => { + const leaves = [...collectLeavesForPlatform('darwin'), ...collectLeavesForPlatform('win32')]; + const untracked = leaves.filter((leaf) => { + if (leaf.role) return !OS_ROLE_EXEMPT.has(leaf.role); + const label = normalizeLabel(leaf.label); + return !PALETTE_COMMAND_LABELS.has(label) && !APP_RESERVED_LABELS.has(label); + }); + expect(untracked.map((l) => l.role ?? normalizeLabel(l.label))).toEqual([]); + }); + + test('Ratchet B sanity: the sweep actually found the backfilled leaves', () => { + const labels = new Set(collectLeavesForPlatform('darwin').map((l) => normalizeLabel(l.label))); + // A few representative backfilled palette leaves must be present, or the sweep is inert. + // makeFullDeps sets the panels visible, so the state-aware View toggle + // renders its "Hide …" variant. + expect(labels.has('Check for updates')).toBe(true); + expect(labels.has('Move to Trash')).toBe(true); + expect(labels.has('Hide sidebar')).toBe(true); + expect(labels.has('New Terminal')).toBe(true); + }); + + // ─── Ratchet D: menu accelerator ↔ keyboard-shortcut registry parity ──────── + // Commands with BOTH a native-menu accelerator AND a keyboard-shortcuts.ts + // binding must agree. Guards the live drift between menu.ts's hand-typed + // accelerators and the shortcut registry (they share no import); the id-spaces + // differ, so this map bridges menu label → shortcut id. + const MENU_SHORTCUT_PAIRS: Array<{ menuLabel: string; shortcutId: KeyboardShortcutId }> = [ + { menuLabel: 'New file', shortcutId: 'new-item' }, + { menuLabel: 'New folder', shortcutId: 'new-folder' }, + { menuLabel: 'Switch project', shortcutId: 'switch-project' }, + { menuLabel: 'Open folder', shortcutId: 'open-folder' }, + { menuLabel: 'Duplicate', shortcutId: 'file-tree-duplicate' }, + { menuLabel: 'Move to Trash', shortcutId: 'file-tree-delete' }, + { menuLabel: 'Settings', shortcutId: 'settings' }, + { menuLabel: 'Hide sidebar', shortcutId: 'toggle-files-sidebar' }, + { menuLabel: 'Hide document panel', shortcutId: 'toggle-document-panel' }, + { menuLabel: 'Hide Terminal', shortcutId: 'toggle-terminal-panel' }, + ]; + + // Canonical token set — order-insensitive; treats Cmd/Ctrl as MOD and + // Delete/Backspace as one key, so the Electron accelerator string and the + // display-glyph binding compare equal when they mean the same chord. + function chordTokens(s: string): string { + const tokens = new Set(); + if (/CmdOrCtrl|Cmd|Ctrl|⌘|⌃/.test(s)) tokens.add('MOD'); + if (/Shift|⇧/.test(s)) tokens.add('SHIFT'); + if (/Alt|Option|⌥/.test(s)) tokens.add('ALT'); + let base = s.replace(/CmdOrCtrl|Cmd|Ctrl|Shift|Alt|Option/g, '').replace(/[⌘⌃⇧⌥+\s]/g, ''); + if (/^(Delete|Backspace|⌫)$/i.test(base)) base = 'DEL'; + tokens.add(`KEY:${base.toUpperCase()}`); + return [...tokens].sort().join(','); + } + + test('Ratchet D: menu accelerators agree with the keyboard-shortcut registry', () => { + const accelByLabel = new Map(); + for (const leaf of collectLeavesForPlatform('darwin')) { + if (leaf.accelerator) accelByLabel.set(normalizeLabel(leaf.label), leaf.accelerator); + } + const mismatches: Array<{ menuLabel: string; accelerator?: string; shortcut: string }> = []; + for (const { menuLabel, shortcutId } of MENU_SHORTCUT_PAIRS) { + const accelerator = accelByLabel.get(menuLabel); + const shortcut = formatShortcut(shortcutId, 'mac'); + if (accelerator === undefined || chordTokens(accelerator) !== chordTokens(shortcut)) { + mismatches.push({ menuLabel, accelerator, shortcut }); + } + } + expect(mismatches).toEqual([]); + }); + + // Exactly one bridge.onMenuAction listener (the bus forwarder) — no subscriber + // may double-fire alongside it. + test('FR3: the bus forwarder is the only bridge.onMenuAction listener', () => { + const appSrc = join(import.meta.dir, '..', '..', 'src'); + const migrated = [ + 'components/FileSidebar.tsx', + 'components/EditorArea.tsx', + 'components/EditorPane.tsx', + 'components/TerminalSessionsHost.tsx', + 'components/ProjectSwitcher.tsx', + 'components/CreateProjectMenuTrigger.tsx', + 'components/ReportBugMenuTrigger.tsx', + 'components/NavigatorApp.tsx', + 'editor/DocumentContext.tsx', + ]; + for (const rel of migrated) { + const source = readFileSync(join(appSrc, rel), 'utf8'); + // No migrated subscriber may still listen on the bridge directly (that + // would double-fire alongside the forwarder); each attaches to the bus. + expect({ file: rel, listensOnBridge: source.includes('.onMenuAction(') }).toEqual({ + file: rel, + listensOnBridge: false, + }); + expect(source.includes('subscribeLocalMenuAction')).toBe(true); + } + // The bus module owns exactly one bridge.onMenuAction call — the forwarder. + const busSource = readFileSync(join(appSrc, 'lib/local-menu-action-bus.ts'), 'utf8'); + expect(busSource.split('.onMenuAction(').length - 1).toBe(1); + }); + + // Repo-wide guard: exactly one production `.onMenuAction(` call site — the bus + // forwarder. Unlike the hand-maintained `migrated` allowlist above, this walks + // all of src, so a NEW subscriber that listens on the bridge directly (a + // double-fire) turns this red without being added to any list. + test('FR3: exactly one production bridge.onMenuAction call site (the bus forwarder)', () => { + const appSrc = join(import.meta.dir, '..', '..', 'src'); + const isTestLike = (name: string) => /\.(test|test-helper)\.[cm]?tsx?$/.test(name); + const callSites: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!/\.[cm]?tsx?$/.test(entry.name) || isTestLike(entry.name)) continue; + if (readFileSync(full, 'utf8').includes('.onMenuAction(')) { + callSites.push(relative(appSrc, full)); + } + } + }; + walk(appSrc); + expect(callSites.sort()).toEqual(['lib/local-menu-action-bus.ts']); + }); +}); diff --git a/packages/app/src/lib/desktop-bridge-types.ts b/packages/app/src/lib/desktop-bridge-types.ts index 93622162d..0e93cae04 100644 --- a/packages/app/src/lib/desktop-bridge-types.ts +++ b/packages/app/src/lib/desktop-bridge-types.ts @@ -193,7 +193,7 @@ export interface OkDesktopConfig { readonly startupTraceparent?: string; } -type OkMenuAction = +export type OkMenuAction = | 'new-doc' | 'new-folder' // Opens the create-new-project dialog in the focused window (a whole new @@ -709,7 +709,7 @@ type OkEditorActiveTargetSnapshot = * Sibling of `OkEditorActiveTargetSnapshot`. See canonical JSDoc in * `packages/desktop/src/shared/bridge-contract.ts`. */ -interface OkEditorViewMenuStateSnapshot { +export interface OkEditorViewMenuStateSnapshot { readonly showHiddenFiles: boolean; readonly showOkFolders: boolean; readonly showOnlyMarkdownFiles: boolean; @@ -1256,6 +1256,21 @@ export interface OkDesktopBridge { signalReady(): void; confirm(request: OkMcpWiringConfirmRequest): Promise; skip(): Promise; + /** + * Re-arm the MCP consent dialog (File → "Set up OpenKnowledge integrations…" + * and the Cmd+K command). Resolves `true` when armed, `false` when + * unavailable (non-darwin / unpackaged / arming threw). Mirror of the + * canonical `bridge-contract.ts` method. + */ + reconfigure(): Promise; + }; + /** + * App-wide spell-check toggle (Edit → "Check spelling while typing" and the + * Cmd+K command). `toggle()` flips the flag and resolves to the new enabled + * state. Mirror of the canonical `bridge-contract.ts` method. + */ + spellcheck: { + toggle(): Promise; }; /** * Settings → AI tools: live per-component install state + one-component diff --git a/packages/app/src/lib/local-menu-action-bus.test.ts b/packages/app/src/lib/local-menu-action-bus.test.ts new file mode 100644 index 000000000..7162628a8 --- /dev/null +++ b/packages/app/src/lib/local-menu-action-bus.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import type { OkMenuAction } from './desktop-bridge-types'; +import { + __resetLocalMenuActionBusForTests, + emitLocalMenuAction, + subscribeLocalMenuAction, +} from './local-menu-action-bus'; + +describe('local menu-action bus', () => { + afterEach(() => { + __resetLocalMenuActionBusForTests(); + }); + + test('emit reaches every subscriber exactly once (no double-fire)', () => { + const a = mock(() => {}); + const b = mock(() => {}); + subscribeLocalMenuAction(a); + subscribeLocalMenuAction(b); + + emitLocalMenuAction('toggle-sidebar'); + + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + expect(a).toHaveBeenLastCalledWith('toggle-sidebar'); + expect(b).toHaveBeenLastCalledWith('toggle-sidebar'); + }); + + test('the same handler subscribed once fires once per emit', () => { + const handler = mock(() => {}); + subscribeLocalMenuAction(handler); + + emitLocalMenuAction('new-terminal'); + emitLocalMenuAction('new-terminal'); + + expect(handler).toHaveBeenCalledTimes(2); + }); + + test('unsubscribe stops delivery to that handler only', () => { + const kept = mock(() => {}); + const dropped = mock(() => {}); + subscribeLocalMenuAction(kept); + const unsubscribe = subscribeLocalMenuAction(dropped); + + unsubscribe(); + emitLocalMenuAction('duplicate'); + + expect(kept).toHaveBeenCalledTimes(1); + expect(dropped).toHaveBeenCalledTimes(0); + }); + + test('a handler that unsubscribes itself mid-dispatch still lets siblings run', () => { + const order: string[] = []; + let unsub: (() => void) | null = null; + const first = mock(() => { + order.push('first'); + unsub?.(); + }); + const second = mock(() => { + order.push('second'); + }); + unsub = subscribeLocalMenuAction(first); + subscribeLocalMenuAction(second); + + emitLocalMenuAction('rename'); + + expect(order).toEqual(['first', 'second']); + expect(second).toHaveBeenCalledTimes(1); + }); + + test('a throwing subscriber does not block delivery to later subscribers', () => { + const originalConsoleError = console.error; + const errorSpy = mock(() => {}); + console.error = errorSpy; + try { + const order: string[] = []; + subscribeLocalMenuAction(() => { + order.push('thrower'); + throw new Error('subscriber bug'); + }); + subscribeLocalMenuAction(() => { + order.push('sibling'); + }); + + expect(() => emitLocalMenuAction('rename')).not.toThrow(); + expect(order).toEqual(['thrower', 'sibling']); + expect(errorSpy).toHaveBeenCalledTimes(1); + } finally { + console.error = originalConsoleError; + } + }); + + test('emitting with no subscribers is a no-op', () => { + expect(() => emitLocalMenuAction('report-bug')).not.toThrow(); + }); +}); + +// The forwarder path reads `window.okDesktop`, absent in this non-DOM unit env. +// These tests stub it and MUST restore `globalThis.window` afterward — a leaked +// stub breaks unrelated non-DOM tests on Linux CI. +describe('local menu-action bus — bridge forwarder', () => { + const originalWindow = globalThis.window; + + function setDesktop(okDesktop: unknown): void { + globalThis.window = { okDesktop } as unknown as Window & typeof globalThis; + } + + afterEach(() => { + __resetLocalMenuActionBusForTests(); + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + globalThis.window = originalWindow; + } + }); + + test('a single inbound native menu action fires each handler exactly once', () => { + let inbound: ((action: OkMenuAction) => void) | null = null; + setDesktop({ + onMenuAction: (cb: (action: OkMenuAction) => void) => { + inbound = cb; + return () => {}; + }, + }); + + const handler = mock(() => {}); + subscribeLocalMenuAction(handler); + // The forwarder installed exactly one bridge listener. + expect(inbound).not.toBeNull(); + // One inbound native action → exactly one handler invocation (no double-fire). + inbound?.('toggle-sidebar'); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenLastCalledWith('toggle-sidebar'); + }); + + test('the forwarder installs once (ref-counted) and tears down when the last subscriber leaves', () => { + let installs = 0; + const unsubscribe = mock(() => {}); + setDesktop({ + onMenuAction: () => { + installs += 1; + return unsubscribe; + }, + }); + + const off1 = subscribeLocalMenuAction(() => {}); + const off2 = subscribeLocalMenuAction(() => {}); + // One forwarder shared across subscribers, not one per subscriber. + expect(installs).toBe(1); + off1(); + expect(unsubscribe).toHaveBeenCalledTimes(0); // one subscriber remains + off2(); + expect(unsubscribe).toHaveBeenCalledTimes(1); // last out → forwarder torn down + }); + + test('a partial bridge without onMenuAction never throws; direct emits still deliver', () => { + setDesktop({}); // truthy-but-thin host (session-only / test stub) + const handler = mock(() => {}); + expect(() => subscribeLocalMenuAction(handler)).not.toThrow(); + emitLocalMenuAction('rename'); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/lib/local-menu-action-bus.ts b/packages/app/src/lib/local-menu-action-bus.ts new file mode 100644 index 000000000..b60c482e9 --- /dev/null +++ b/packages/app/src/lib/local-menu-action-bus.ts @@ -0,0 +1,100 @@ +import type { OkMenuAction } from './desktop-bridge-types'; + +/** + * Renderer-local fan-out for menu actions, so both invocation surfaces converge + * on ONE dispatch path per window: + * + * - Native menu → main sends `ok:menu-action` → a single forwarder (installed + * here, ref-counted) re-emits it onto this bus. + * - Cmd+K command palette → emits directly onto this bus for id-backed commands. + * + * Every renderer subscriber (FileSidebar, EditorArea, EditorPane, …) listens on + * this bus instead of `bridge.onMenuAction` directly. That decoupling is what + * makes host-agnostic commands (view/panel/tree toggles) reachable from the + * palette on the WEB host, where `window.okDesktop` is absent and no bridge + * listener exists: the forwarder simply never installs and the palette's direct + * emit still reaches every subscriber. + * + * The forwarder is the ONLY remaining `bridge.onMenuAction` listener (precedent: + * one inbound path). No subscriber listens on both the bridge and this bus, so a + * real menu click fires each handler exactly once (no double-fire). + */ + +type Listener = (action: OkMenuAction) => void; + +const listeners = new Set(); + +// The single `bridge.onMenuAction` forwarder. Installed lazily when the first +// subscriber attaches and torn down when the last detaches, so the bus owns the +// bridge wiring rather than every window root having to install it. +let bridgeForwarderUnsubscribe: (() => void) | null = null; + +function ensureBridgeForwarder(): void { + if (bridgeForwarderUnsubscribe !== null) return; + const bridge = typeof window !== 'undefined' ? window.okDesktop : undefined; + // Web host (or pre-preload): no bridge to forward from. The palette still + // emits directly onto the bus, so host-agnostic subscribers keep working. + // A partial bridge (session-only host, or a test stub) may not expose + // `onMenuAction` — guard so the forwarder never throws on a truthy-but-thin + // `window.okDesktop`; direct palette emits still reach every subscriber. + if (bridge == null || typeof bridge.onMenuAction !== 'function') return; + bridgeForwarderUnsubscribe = bridge.onMenuAction((action) => { + emitLocalMenuAction(action); + }); +} + +function teardownBridgeForwarderIfIdle(): void { + if (listeners.size === 0 && bridgeForwarderUnsubscribe !== null) { + bridgeForwarderUnsubscribe(); + bridgeForwarderUnsubscribe = null; + } +} + +/** + * Subscribe to menu actions delivered on this window's bus. Returns an + * unsubscribe closure with the same contract as `bridge.onMenuAction` (call it + * on effect cleanup / unmount), so a migrating subscriber is a drop-in swap. + */ +export function subscribeLocalMenuAction(cb: Listener): () => void { + listeners.add(cb); + ensureBridgeForwarder(); + return () => { + listeners.delete(cb); + teardownBridgeForwarderIfIdle(); + }; +} + +/** + * Fan an action out to every current subscriber synchronously. The palette + * calls this for id-backed commands; the inbound-IPC forwarder calls it for + * native menu clicks. Iterates the live subscriber set — JS Set iteration + * tolerates a subscriber that unsubscribes itself mid-dispatch; a subscriber + * must not synchronously unsubscribe a sibling from within its handler. + */ +export function emitLocalMenuAction(action: OkMenuAction): void { + // Iterate the live set: JS Set iteration tolerates a subscriber that + // unsubscribes itself mid-dispatch. Subscribers must not synchronously + // unsubscribe a sibling during their handler. + for (const cb of listeners) { + try { + cb(action); + } catch (err) { + // Isolate subscriber faults: this bus is the single dispatch path for + // native menu clicks AND palette commands, so one throwing handler must + // not silently starve every subscriber after it in insertion order. + console.error('[local-menu-action-bus] subscriber threw during dispatch:', err); + } + } +} + +/** + * Test-only reset — clears subscribers and drops the bridge forwarder so each + * test starts from a clean module singleton. Not part of the runtime contract. + */ +export function __resetLocalMenuActionBusForTests(): void { + listeners.clear(); + if (bridgeForwarderUnsubscribe !== null) { + bridgeForwarderUnsubscribe(); + bridgeForwarderUnsubscribe = null; + } +} diff --git a/packages/app/src/lib/ok-menu-actions.ts b/packages/app/src/lib/ok-menu-actions.ts new file mode 100644 index 000000000..737fe7cf3 --- /dev/null +++ b/packages/app/src/lib/ok-menu-actions.ts @@ -0,0 +1,58 @@ +import type { OkMenuAction } from './desktop-bridge-types'; + +/** + * Runtime enumeration of every {@link OkMenuAction} id. TypeScript unions are + * erased at runtime, so the command-parity ratchet needs this array to iterate + * the id-space and assert each id is classified (palette command vs. reserved). + * + * Ratchet A (drift guard): `satisfies readonly OkMenuAction[]` rejects an id + * here that is not in the union, and the `_MenuActionDrift` guard below rejects + * a union member missing from this array — so adding an `OkMenuAction` without + * listing it here fails to compile, and this array can never silently drift + * from the type. + */ +export const OK_MENU_ACTIONS = [ + 'new-doc', + 'new-folder', + 'new-project', + 'rename', + 'delete', + 'close-active-tab-or-window', + 'toggle-sidebar', + 'toggle-source', + 'save-version', + 'version-history', + 'focus-search', + 'focus-command-palette', + 'new-from-template', + 'duplicate', + 'move-to-trash', + 'reveal-in-finder', + 'send-to-ai', + 'copy-full-path', + 'copy-relative-path', + 'toggle-show-hidden-files', + 'toggle-show-ok-folders', + 'toggle-show-only-markdown-files', + 'toggle-show-skills-section', + 'expand-all-tree', + 'collapse-all-tree', + 'toggle-doc-panel', + 'toggle-terminal', + 'new-terminal', + 'kill-terminal', + 'new-worktree', + 'switch-worktree', + 'report-bug', +] as const satisfies readonly OkMenuAction[]; + +// Compile-time completeness: any OkMenuAction member missing from the array +// above resolves to a non-`never` type here, and assigning `true` to the +// resulting tuple type fails — so the typecheck gate goes red until the id is +// listed. (The reverse — an array entry that is not a real id — is caught by +// the `satisfies` clause.) +type _MenuActionDrift = Exclude; +const _menuActionDriftGuard: [_MenuActionDrift] extends [never] + ? true + : ['OK_MENU_ACTIONS is missing an OkMenuAction id', _MenuActionDrift] = true; +void _menuActionDriftGuard; diff --git a/packages/app/src/lib/view-menu-state-store.dom.test.tsx b/packages/app/src/lib/view-menu-state-store.dom.test.tsx new file mode 100644 index 000000000..0bac87da1 --- /dev/null +++ b/packages/app/src/lib/view-menu-state-store.dom.test.tsx @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { act, cleanup, renderHook } from '@testing-library/react'; +import { + __resetViewMenuStateForTests, + setViewMenuState, + useViewMenuState, +} from './view-menu-state-store'; + +describe('view-menu-state store', () => { + afterEach(() => { + cleanup(); + __resetViewMenuStateForTests(); + }); + + test('partials from different producers merge instead of replacing the snapshot', () => { + const { result } = renderHook(() => useViewMenuState()); + + act(() => setViewMenuState({ docPanelVisible: true })); + act(() => setViewMenuState({ terminalVisible: false })); + + expect(result.current).toEqual({ docPanelVisible: true, terminalVisible: false }); + }); + + test('a later partial overwrites the same field', () => { + const { result } = renderHook(() => useViewMenuState()); + + act(() => setViewMenuState({ terminalVisible: false })); + act(() => setViewMenuState({ terminalVisible: true })); + + expect(result.current.terminalVisible).toBe(true); + }); + + test('writes notify subscribers; the snapshot is referentially stable between writes', () => { + const { result, rerender } = renderHook(() => useViewMenuState()); + + act(() => setViewMenuState({ sidebarVisible: true })); + const afterWrite = result.current; + rerender(); + // No write between renders → same snapshot reference (useSyncExternalStore + // relies on this to skip re-renders). + expect(result.current).toBe(afterWrite); + + act(() => setViewMenuState({ sidebarVisible: false })); + expect(result.current).not.toBe(afterWrite); + expect(result.current.sidebarVisible).toBe(false); + }); + + test('the test-only reset returns the store to an empty snapshot', () => { + act(() => setViewMenuState({ docPanelVisible: true })); + __resetViewMenuStateForTests(); + + const { result } = renderHook(() => useViewMenuState()); + expect(result.current).toEqual({}); + }); +}); diff --git a/packages/app/src/lib/view-menu-state-store.ts b/packages/app/src/lib/view-menu-state-store.ts new file mode 100644 index 000000000..c91536c20 --- /dev/null +++ b/packages/app/src/lib/view-menu-state-store.ts @@ -0,0 +1,58 @@ +import { useSyncExternalStore } from 'react'; +import type { OkEditorViewMenuStateSnapshot } from './desktop-bridge-types'; + +/** + * Renderer-side mirror of the View-menu state that components already push to + * main via `bridge.editor.notifyViewMenuStateChanged(...)`. That push is + * write-only (main consumes it to build "Show/Hide …" menu labels); nothing in + * the renderer could read it back. The command palette needs the same values to + * render state-reflecting toggle labels and to gate `kill-terminal` on a live + * terminal, so each producer writes the SAME partial here alongside its bridge + * push. This store is that single readable snapshot. + * + * All fields are optional: producers push partials, and a field stays undefined + * until its producer mounts. Consumers treat undefined as "unknown / default". + */ +export type ViewMenuState = Partial; + +let state: ViewMenuState = {}; +const listeners = new Set<() => void>(); + +/** Current snapshot. Referentially stable between `setViewMenuState` calls. */ +function getViewMenuState(): ViewMenuState { + return state; +} + +/** + * Merge a partial into the snapshot and notify subscribers. Producers call this + * with the same object they pass to `notifyViewMenuStateChanged`, keeping the + * renderer mirror and the main-side menu in lockstep. + */ +export function setViewMenuState(partial: ViewMenuState): void { + state = { ...state, ...partial }; + for (const cb of listeners) { + cb(); + } +} + +function subscribeViewMenuState(cb: () => void): () => void { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} + +/** Test-only reset so each test starts from an empty module singleton. */ +export function __resetViewMenuStateForTests(): void { + state = {}; + listeners.clear(); +} + +/** + * React binding for the palette. `getViewMenuState` returns a stable reference + * between updates (a new object is only created on `setViewMenuState`), so + * `useSyncExternalStore` re-renders exactly when a producer changes state. + */ +export function useViewMenuState(): ViewMenuState { + return useSyncExternalStore(subscribeViewMenuState, getViewMenuState, getViewMenuState); +} diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index c0c220432..bb7104612 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -86,6 +86,7 @@ "0yL--g": ["Review conflicts"], "10g8I_": ["Turn off Only markdown files"], "12F9WL": ["(no id)"], + "151wvk": ["Show sidebar"], "16D5Zj": ["Are you sure you want to delete ", ["itemName"], "? This action cannot be undone."], "1AumWo": ["Push failed — check the server logs for details."], "1CalO6": ["Style"], @@ -284,6 +285,7 @@ "5Umg9i": ["Disable external link previews"], "5WDhAk": ["Link to a page or external URL."], "5ZB7XC": ["File drop zone"], + "5_j-DA": ["Kill Terminal"], "5e1Dri": ["The menu label agents pick this template by (required)."], "5gr_ev": [ "Becomes the document's content when someone creates a doc from this template. Type <0>", @@ -418,6 +420,7 @@ "8PmoQF": ["Open the insert menu in an empty or active text position."], "8RtNI0": ["Failed to load documents"], "8TvlHF": ["Crash dump"], + "8Vq20-": ["Switch worktree"], "8XQRON": ["Loading repositories"], "8_brI5": ["Name is required"], "8cS0nH": ["Config sharing unavailable: ", ["0"], "."], @@ -471,6 +474,7 @@ "9oE4C3": ["Browse your repos:"], "9sBe85": ["Follow us on"], "9tJX8r": ["By meaning"], + "9xCnv8": ["Hide Terminal"], "9zb2WA": ["Connecting"], "A1taO8": ["Search"], "A50vr3": ["Use lowercase letters, digits, and <0>- only."], @@ -505,6 +509,7 @@ "AddR83": ["Could not create project. Try again or pick a different location."], "AeHuzT": ["# One pattern per line.\n# Examples:\n# drafts/\n# *.draft.md\n# !keep.md"], "AeXO77": ["Account"], + "AhoGwP": ["Set up OpenKnowledge integrations"], "AiNMQ7": [ "This project lives only on this computer. Connect it to GitHub to back it up and share it with other people." ], @@ -775,6 +780,7 @@ "GLxIKR": ["How to push manually"], "GN6L5F": ["Filename"], "GQDSBN": ["Layout options"], + "GSYWCm": ["Hide document panel"], "GUYwCr": ["Dismiss notice"], "GUqTzn": ["Competitor research"], "GXf9hG": ["Cloning ", ["repoName"], "..."], @@ -861,6 +867,7 @@ "Server-side cleanup failed for ", ["failedCount", "plural", { "one": ["#", " item"], "other": ["#", " items"] }] ], + "IrC12v": ["Application"], "IreQBq": ["Repository"], "It9ARY": ["Toggle the right document panel."], "Ix0K8o": ["Hide external URL nodes"], @@ -963,6 +970,7 @@ "Lcehxn": ["Save resolution"], "LdABLa": ["Could not open ", ["target"], ". Try opening it manually."], "LdKIHS": ["Draft a spec"], + "LeGeTf": ["OpenKnowledge on GitHub"], "LhQlGR": [ "This project is running an older version of OpenKnowledge (v", ["0"], @@ -1000,6 +1008,7 @@ ], "M5_9s2": ["Sending report"], "M5mfAe": ["Add tag"], + "M6mvVi": ["Copy relative path"], "M9597s": ["Preparing share URL..."], "MCjC9A": ["Delete ", ["deleteDescriptorLabel"]], "MDZSJe": ["When the agent runs its scheduled work."], @@ -1177,6 +1186,7 @@ "PrJYSN": ["This folder isn't on GitHub yet. The link won't work until it's pushed."], "PrhzDE": ["Pan up"], "Pyp1NA": ["Restore to this point?"], + "Q1mtyd": ["Hide sidebar"], "Q5fqMz": ["Connection error — try again"], "Q6hhn8": ["Preferences"], "Q7W-mC": ["Same folder as current file"], @@ -1421,6 +1431,7 @@ "WHPvOb": ["All files are hidden by view filters."], "WJjviG": ["What to include"], "WL6puM": ["Path resolves outside project"], + "WMCwmR": ["Check for updates"], "WMIKXK": [ "Are you sure you want to delete ", ["targetCount"], @@ -1561,6 +1572,7 @@ "). Other files and writers are unaffected." ], "ZBt4rI": ["Instruction for the AI"], + "ZBxvV3": ["New Terminal"], "ZI8uvA": ["No problems found."], "ZLQol-": ["You can turn this on later in <0>Settings → Sync."], "ZNK2Tq": ["root"], @@ -1582,6 +1594,7 @@ "Take a piece of writing from a rough idea to a finished draft to something you're ready to share. Good for blog posts, essays, or anything you want to move through clear stages." ], "ZgGuA4": [["0", "plural", { "one": ["File"], "other": ["Files"] }]], + "ZgJVnp": ["Check spelling while typing"], "ZhhOwV": ["Quote"], "ZjtgI-": ["Command palette"], "ZoOlzR": [ @@ -1833,6 +1846,7 @@ "fJgQHX": ["Failed to create file"], "fNr-MX": ["Disable semantic search"], "fR-Kdc": ["(project-level only)"], + "fSRzLo": ["Show document panel"], "fVcm9t": [["0", "plural", { "one": ["#", " doc"], "other": ["#", " docs"] }]], "fWi7pn": ["Initialize starter pack"], "fWsBTs": ["Something went wrong. Please try again."], @@ -2036,6 +2050,7 @@ "jhtG3I": [" Your selection has been restored."], "jjtmV_": ["First file name"], "jol95m": ["Failed to open Project Navigator."], + "jpctdh": ["View"], "jtg_8-": [ "Build a knowledge wiki about the different types of flightless birds. Start with an overview page, then add cross-linked pages for each major group (ratites like ostriches, emus, and kiwis; penguins; and flightless rails), the regions they live in, and a timeline of how flightlessness evolved." ], @@ -2337,6 +2352,7 @@ "pvnfJD": ["Dark"], "pzutoc": ["Italic"], "q2kXMG": ["Inline Math"], + "q3TWG7": ["Move to Trash"], "q4YJl_": ["The terminal session ended (exit code ", ["0"], ")."], "q6-JVU": ["Loading pages"], "q7F5am": ["title"], @@ -2378,6 +2394,7 @@ "r2huLx": ["Undo, redo, or undo selection changes through CodeMirror history."], "r4R7AZ": ["Network error during cleanup"], "r51jLC": ["File saved & Claude Desktop App opened"], + "r6ncaO": ["Close tab"], "r871ed": [ "Your knowledge base is now on GitHub at <0>", ["ownerLogin"], @@ -2648,6 +2665,7 @@ "xeXS4P": ["Or use a starter pack"], "xeiujy": ["Text"], "xhlFsx": ["Inherited — set a value here to replace it."], + "xi9KGG": ["Show Terminal"], "xiv-dW": ["No files yet."], "xloBjn": ["Relative Path"], "xnP32l": ["No description yet — add one so agents know when to use this skill."], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 7c34081e5..62f3f2558 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -999,6 +999,10 @@ msgstr "App & system info, recent app logs, and project server logs: the essenti msgid "Append-only log of what changed." msgstr "Append-only log of what changed." +#: src/components/CommandPalette.tsx +msgid "Application" +msgstr "Application" + #: src/components/settings/TerminalSection.tsx msgid "Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs \"ok open\"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent." msgstr "Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs \"ok open\"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent." @@ -1379,6 +1383,10 @@ msgstr "Cancelled" msgid "Cannot upload: no document is open" msgstr "Cannot upload: no document is open" +#: src/components/CommandPalette.tsx +msgid "Check for updates" +msgstr "Check for updates" + #: src/components/NewWorktreeDialog.tsx msgid "Check out remote branch" msgstr "Check out remote branch" @@ -1387,6 +1395,10 @@ msgstr "Check out remote branch" msgid "Check out worktree" msgstr "Check out worktree" +#: src/components/CommandPalette.tsx +msgid "Check spelling while typing" +msgstr "Check spelling while typing" + #: src/components/PropertyWidgets.tsx msgid "Checkbox" msgstr "Checkbox" @@ -1585,6 +1597,10 @@ msgstr "Close new tab" msgid "Close others" msgstr "Close others" +#: src/components/CommandPalette.tsx +msgid "Close tab" +msgstr "Close tab" + #: src/components/TerminalRefusalNotice.tsx msgid "Close terminal" msgstr "Close terminal" @@ -1642,6 +1658,7 @@ msgstr "Collapse {docName}" msgid "Collapse {keyName}" msgstr "Collapse {keyName}" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/FileTree.tsx msgid "Collapse all" @@ -1937,6 +1954,7 @@ msgstr "Copy code {userCode}" msgid "Copy command" msgstr "Copy command" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx msgid "Copy full path" msgstr "Copy full path" @@ -1953,6 +1971,10 @@ msgstr "Copy path" msgid "Copy Path" msgstr "Copy Path" +#: src/components/CommandPalette.tsx +msgid "Copy relative path" +msgstr "Copy relative path" + #: src/lib/keyboard-shortcuts.ts msgid "Copy selected file or folder" msgstr "Copy selected file or folder" @@ -2848,6 +2870,7 @@ msgstr "Drag {patternText} to reorder" msgid "Drag item {0} to reorder" msgstr "Drag item {0} to reorder" +#: src/components/CommandPalette.tsx #: src/components/FileTree.tsx #: src/components/skill-actions.tsx msgid "Duplicate" @@ -3144,6 +3167,7 @@ msgstr "Expand {docName}" msgid "Expand {keyName}" msgstr "Expand {keyName}" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/FileTree.tsx msgid "Expand all" @@ -3462,6 +3486,7 @@ msgstr "Fetch failed — check the server logs for details." msgid "Fetching" msgstr "Fetching" +#: src/components/CommandPalette.tsx #: src/components/settings/AiToolsSection.tsx #: src/components/settings/ProjectAiToolsSection.tsx #: src/components/share-metadata-rows.tsx @@ -3865,6 +3890,10 @@ msgstr "Hide" msgid "Hide advanced" msgstr "Hide advanced" +#: src/components/CommandPalette.tsx +msgid "Hide document panel" +msgstr "Hide document panel" + #: src/components/GraphPanel.tsx msgid "Hide external URL nodes" msgstr "Hide external URL nodes" @@ -3909,6 +3938,14 @@ msgstr "Hide replace" msgid "Hide selection preview" msgstr "Hide selection preview" +#: src/components/CommandPalette.tsx +msgid "Hide sidebar" +msgstr "Hide sidebar" + +#: src/components/CommandPalette.tsx +msgid "Hide Terminal" +msgstr "Hide Terminal" + #: src/components/FileTree.tsx msgid "Hide this file" msgstr "Hide this file" @@ -4279,6 +4316,10 @@ msgstr "Key ending in {keyHint}" msgid "Keyboard shortcuts available in the editor and workspace." msgstr "Keyboard shortcuts available in the editor and workspace." +#: src/components/CommandPalette.tsx +msgid "Kill Terminal" +msgstr "Kill Terminal" + #: src/editor/extensions/WikiLinkPropPanel.tsx msgid "Label <0>(optional display text)" msgstr "Label <0>(optional display text)" @@ -4708,6 +4749,10 @@ msgstr "Move to the next visual-editor find result." msgid "Move to the previous visual-editor find result." msgstr "Move to the previous visual-editor find result." +#: src/components/CommandPalette.tsx +msgid "Move to Trash" +msgstr "Move to Trash" + #: src/editor/extensions/JsxComponentView.tsx msgid "Move up" msgstr "Move up" @@ -4864,6 +4909,7 @@ msgstr "New file from template \"{displayTitle}\" ({fileName}) in the project ro msgid "New folder" msgstr "New folder" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/FileTree.tsx msgid "New from template" @@ -4903,10 +4949,15 @@ msgstr "New template" msgid "New terminal" msgstr "New terminal" +#: src/components/CommandPalette.tsx +msgid "New Terminal" +msgstr "New Terminal" + #: src/lib/keyboard-shortcuts.ts msgid "New terminal tab" msgstr "New terminal tab" +#: src/components/CommandPalette.tsx #: src/components/NewWorktreeDialog.tsx #: src/components/ProjectSwitcher.tsx msgid "New worktree" @@ -5595,6 +5646,10 @@ msgstr "OpenKnowledge is using a GitHub account provided by the gh CLI. There's msgid "OpenKnowledge is using this GitHub account." msgstr "OpenKnowledge is using this GitHub account." +#: src/components/CommandPalette.tsx +msgid "OpenKnowledge on GitHub" +msgstr "OpenKnowledge on GitHub" + #: src/components/ReportBugDialogBody.tsx msgid "OpenKnowledge quit unexpectedly last time." msgstr "OpenKnowledge quit unexpectedly last time." @@ -6259,6 +6314,7 @@ msgstr "Removes this skill from your editors." msgid "Removing" msgstr "Removing" +#: src/components/CommandPalette.tsx #: src/components/FileTree.tsx #: src/components/skill-actions.tsx #: src/components/SkillRenameDialog.tsx @@ -6524,6 +6580,7 @@ msgstr "Reveal in File Explorer" msgid "Reveal in File Explorer, {hint}" msgstr "Reveal in File Explorer, {hint}" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/FileTree.tsx #: src/components/ReportBugDialogBody.tsx @@ -6883,6 +6940,10 @@ msgstr "Set identity" msgid "Set the auto-sync default for users opening this project for the first time. This setting is committed to your repository." msgstr "Set the auto-sync default for users opening this project for the first time. This setting is committed to your repository." +#: src/components/CommandPalette.tsx +msgid "Set up OpenKnowledge integrations" +msgstr "Set up OpenKnowledge integrations" + #: src/components/settings/SettingsDialogBody.tsx msgid "Set up syncing" msgstr "Set up syncing" @@ -6979,6 +7040,7 @@ msgstr "Sharing a doc needs a GitHub repository. Create one for this project." msgid "Show" msgstr "Show" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx msgid "Show .ok folders" msgstr "Show .ok folders" @@ -7005,6 +7067,10 @@ msgstr "Show advanced" msgid "Show diagram" msgstr "Show diagram" +#: src/components/CommandPalette.tsx +msgid "Show document panel" +msgstr "Show document panel" + #: src/components/GraphPanel.tsx msgid "Show external URL nodes" msgstr "Show external URL nodes" @@ -7025,6 +7091,7 @@ msgstr "Show Files ({sidebarShortcut})" msgid "Show Files ({sidebarShortcutLabel})" msgstr "Show Files ({sidebarShortcutLabel})" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/NotInSidebarIndicator.tsx msgid "Show hidden files" @@ -7038,6 +7105,7 @@ msgstr "Show HTML preview" msgid "Show less" msgstr "Show less" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx msgid "Show only markdown files" msgstr "Show only markdown files" @@ -7070,6 +7138,11 @@ msgstr "Show replace" msgid "Show selection preview" msgstr "Show selection preview" +#: src/components/CommandPalette.tsx +msgid "Show sidebar" +msgstr "Show sidebar" + +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx msgid "Show skills section" msgstr "Show skills section" @@ -7082,6 +7155,10 @@ msgstr "Show source" msgid "Show terminal" msgstr "Show terminal" +#: src/components/CommandPalette.tsx +msgid "Show Terminal" +msgstr "Show Terminal" + #: src/editor/components/Pdf.tsx msgid "Show thumbnails" msgstr "Show thumbnails" @@ -7381,6 +7458,10 @@ msgstr "Switch project" msgid "Switch to local-only refused" msgstr "Switch to local-only refused" +#: src/components/CommandPalette.tsx +msgid "Switch worktree" +msgstr "Switch worktree" + #: src/components/ShareBranchSwitchDialog.tsx msgid "Switching branches" msgstr "Switching branches" @@ -7580,6 +7661,7 @@ msgstr "Templates available" #. Group label above the shell-PATH row in Settings → AI tools & CLI #. Section label above the shell-PATH toggle in the first-launch dialog +#: src/components/CommandPalette.tsx #: src/components/empty-state/CreatePromptComposer.tsx #: src/components/handoff/AgentSplitButton.tsx #: src/components/handoff/OpenInAgentContextSubmenu.tsx @@ -8410,6 +8492,10 @@ msgstr "Value is required" msgid "via {viaTags}" msgstr "via {viaTags}" +#: src/components/CommandPalette.tsx +msgid "View" +msgstr "View" + #: src/components/AssetPreview.tsx msgid "View as text" msgstr "View as text" diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 886b65e92..6ee049602 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -86,6 +86,7 @@ "0yL--g": ["Ŕēvĩēŵ ćōńƒĺĩćţś"], "10g8I_": ["Ţũŕń ōƒƒ Ōńĺŷ ḿàŕķďōŵń ƒĩĺēś"], "12F9WL": ["(ńō ĩď)"], + "151wvk": ["Śĥōŵ śĩďēƀàŕ"], "16D5Zj": ["Àŕē ŷōũ śũŕē ŷōũ ŵàńţ ţō ďēĺēţē ", ["itemName"], "? Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē."], "1AumWo": ["Ƥũśĥ ƒàĩĺēď — ćĥēćķ ţĥē śēŕvēŕ ĺōĝś ƒōŕ ďēţàĩĺś."], "1CalO6": ["Śţŷĺē"], @@ -284,6 +285,7 @@ "5Umg9i": ["Ďĩśàƀĺē ēxţēŕńàĺ ĺĩńķ ƥŕēvĩēŵś"], "5WDhAk": ["Ĺĩńķ ţō à ƥàĝē ōŕ ēxţēŕńàĺ ŨŔĹ."], "5ZB7XC": ["Ƒĩĺē ďŕōƥ źōńē"], + "5_j-DA": ["Ķĩĺĺ Ţēŕḿĩńàĺ"], "5e1Dri": ["Ţĥē ḿēńũ ĺàƀēĺ àĝēńţś ƥĩćķ ţĥĩś ţēḿƥĺàţē ƀŷ (ŕēǫũĩŕēď)."], "5gr_ev": [ "ßēćōḿēś ţĥē ďōćũḿēńţ'ś ćōńţēńţ ŵĥēń śōḿēōńē ćŕēàţēś à ďōć ƒŕōḿ ţĥĩś ţēḿƥĺàţē. Ţŷƥē <0>", @@ -418,6 +420,7 @@ "8PmoQF": ["Ōƥēń ţĥē ĩńśēŕţ ḿēńũ ĩń àń ēḿƥţŷ ōŕ àćţĩvē ţēxţ ƥōśĩţĩōń."], "8RtNI0": ["Ƒàĩĺēď ţō ĺōàď ďōćũḿēńţś"], "8TvlHF": ["Ćŕàśĥ ďũḿƥ"], + "8Vq20-": ["Śŵĩţćĥ ŵōŕķţŕēē"], "8XQRON": ["Ĺōàďĩńĝ ŕēƥōśĩţōŕĩēś"], "8_brI5": ["Ńàḿē ĩś ŕēǫũĩŕēď"], "8cS0nH": ["Ćōńƒĩĝ śĥàŕĩńĝ ũńàvàĩĺàƀĺē: ", ["0"], "."], @@ -471,6 +474,7 @@ "9oE4C3": ["ßŕōŵśē ŷōũŕ ŕēƥōś:"], "9sBe85": ["Ƒōĺĺōŵ ũś ōń"], "9tJX8r": ["ßŷ ḿēàńĩńĝ"], + "9xCnv8": ["Ĥĩďē Ţēŕḿĩńàĺ"], "9zb2WA": ["Ćōńńēćţĩńĝ"], "A1taO8": ["Śēàŕćĥ"], "A50vr3": ["Ũśē ĺōŵēŕćàśē ĺēţţēŕś, ďĩĝĩţś, àńď <0>- ōńĺŷ."], @@ -505,6 +509,7 @@ "AddR83": ["Ćōũĺď ńōţ ćŕēàţē ƥŕōĴēćţ. Ţŕŷ àĝàĩń ōŕ ƥĩćķ à ďĩƒƒēŕēńţ ĺōćàţĩōń."], "AeHuzT": ["# Ōńē ƥàţţēŕń ƥēŕ ĺĩńē.\n# Ēxàḿƥĺēś:\n# ďŕàƒţś/\n# *.ďŕàƒţ.ḿď\n# !ķēēƥ.ḿď"], "AeXO77": ["Àććōũńţ"], + "AhoGwP": ["Śēţ ũƥ ŌƥēńĶńōŵĺēďĝē ĩńţēĝŕàţĩōńś"], "AiNMQ7": [ "Ţĥĩś ƥŕōĴēćţ ĺĩvēś ōńĺŷ ōń ţĥĩś ćōḿƥũţēŕ. Ćōńńēćţ ĩţ ţō ĜĩţĤũƀ ţō ƀàćķ ĩţ ũƥ àńď śĥàŕē ĩţ ŵĩţĥ ōţĥēŕ ƥēōƥĺē." ], @@ -775,6 +780,7 @@ "GLxIKR": ["Ĥōŵ ţō ƥũśĥ ḿàńũàĺĺŷ"], "GN6L5F": ["Ƒĩĺēńàḿē"], "GQDSBN": ["Ĺàŷōũţ ōƥţĩōńś"], + "GSYWCm": ["Ĥĩďē ďōćũḿēńţ ƥàńēĺ"], "GUYwCr": ["Ďĩśḿĩśś ńōţĩćē"], "GUqTzn": ["Ćōḿƥēţĩţōŕ ŕēśēàŕćĥ"], "GXf9hG": ["Ćĺōńĩńĝ ", ["repoName"], "..."], @@ -861,6 +867,7 @@ "Śēŕvēŕ-śĩďē ćĺēàńũƥ ƒàĩĺēď ƒōŕ ", ["failedCount", "plural", { "one": ["#", " ĩţēḿ"], "other": ["#", " ĩţēḿś"] }] ], + "IrC12v": ["Àƥƥĺĩćàţĩōń"], "IreQBq": ["Ŕēƥōśĩţōŕŷ"], "It9ARY": ["Ţōĝĝĺē ţĥē ŕĩĝĥţ ďōćũḿēńţ ƥàńēĺ."], "Ix0K8o": ["Ĥĩďē ēxţēŕńàĺ ŨŔĹ ńōďēś"], @@ -963,6 +970,7 @@ "Lcehxn": ["Śàvē ŕēśōĺũţĩōń"], "LdABLa": ["Ćōũĺď ńōţ ōƥēń ", ["target"], ". Ţŕŷ ōƥēńĩńĝ ĩţ ḿàńũàĺĺŷ."], "LdKIHS": ["Ďŕàƒţ à śƥēć"], + "LeGeTf": ["ŌƥēńĶńōŵĺēďĝē ōń ĜĩţĤũƀ"], "LhQlGR": [ "Ţĥĩś ƥŕōĴēćţ ĩś ŕũńńĩńĝ àń ōĺďēŕ vēŕśĩōń ōƒ ŌƥēńĶńōŵĺēďĝē (v", ["0"], @@ -1000,6 +1008,7 @@ ], "M5_9s2": ["Śēńďĩńĝ ŕēƥōŕţ"], "M5mfAe": ["Àďď ţàĝ"], + "M6mvVi": ["Ćōƥŷ ŕēĺàţĩvē ƥàţĥ"], "M9597s": ["Ƥŕēƥàŕĩńĝ śĥàŕē ŨŔĹ..."], "MCjC9A": ["Ďēĺēţē ", ["deleteDescriptorLabel"]], "MDZSJe": ["Ŵĥēń ţĥē àĝēńţ ŕũńś ĩţś śćĥēďũĺēď ŵōŕķ."], @@ -1177,6 +1186,7 @@ "PrJYSN": ["Ţĥĩś ƒōĺďēŕ ĩśń'ţ ōń ĜĩţĤũƀ ŷēţ. Ţĥē ĺĩńķ ŵōń'ţ ŵōŕķ ũńţĩĺ ĩţ'ś ƥũśĥēď."], "PrhzDE": ["Ƥàń ũƥ"], "Pyp1NA": ["Ŕēśţōŕē ţō ţĥĩś ƥōĩńţ?"], + "Q1mtyd": ["Ĥĩďē śĩďēƀàŕ"], "Q5fqMz": ["Ćōńńēćţĩōń ēŕŕōŕ — ţŕŷ àĝàĩń"], "Q6hhn8": ["Ƥŕēƒēŕēńćēś"], "Q7W-mC": ["Śàḿē ƒōĺďēŕ àś ćũŕŕēńţ ƒĩĺē"], @@ -1421,6 +1431,7 @@ "WHPvOb": ["Àĺĺ ƒĩĺēś àŕē ĥĩďďēń ƀŷ vĩēŵ ƒĩĺţēŕś."], "WJjviG": ["Ŵĥàţ ţō ĩńćĺũďē"], "WL6puM": ["Ƥàţĥ ŕēśōĺvēś ōũţśĩďē ƥŕōĴēćţ"], + "WMCwmR": ["Ćĥēćķ ƒōŕ ũƥďàţēś"], "WMIKXK": [ "Àŕē ŷōũ śũŕē ŷōũ ŵàńţ ţō ďēĺēţē ", ["targetCount"], @@ -1561,6 +1572,7 @@ "). Ōţĥēŕ ƒĩĺēś àńď ŵŕĩţēŕś àŕē ũńàƒƒēćţēď." ], "ZBt4rI": ["Ĩńśţŕũćţĩōń ƒōŕ ţĥē ÀĨ"], + "ZBxvV3": ["Ńēŵ Ţēŕḿĩńàĺ"], "ZI8uvA": ["Ńō ƥŕōƀĺēḿś ƒōũńď."], "ZLQol-": ["Ŷōũ ćàń ţũŕń ţĥĩś ōń ĺàţēŕ ĩń <0>Śēţţĩńĝś → Śŷńć."], "ZNK2Tq": ["ŕōōţ"], @@ -1582,6 +1594,7 @@ "Ţàķē à ƥĩēćē ōƒ ŵŕĩţĩńĝ ƒŕōḿ à ŕōũĝĥ ĩďēà ţō à ƒĩńĩśĥēď ďŕàƒţ ţō śōḿēţĥĩńĝ ŷōũ'ŕē ŕēàďŷ ţō śĥàŕē. Ĝōōď ƒōŕ ƀĺōĝ ƥōśţś, ēśśàŷś, ōŕ àńŷţĥĩńĝ ŷōũ ŵàńţ ţō ḿōvē ţĥŕōũĝĥ ćĺēàŕ śţàĝēś." ], "ZgGuA4": [["0", "plural", { "one": ["Ƒĩĺē"], "other": ["Ƒĩĺēś"] }]], + "ZgJVnp": ["Ćĥēćķ śƥēĺĺĩńĝ ŵĥĩĺē ţŷƥĩńĝ"], "ZhhOwV": ["Ǫũōţē"], "ZjtgI-": ["Ćōḿḿàńď ƥàĺēţţē"], "ZoOlzR": [ @@ -1833,6 +1846,7 @@ "fJgQHX": ["Ƒàĩĺēď ţō ćŕēàţē ƒĩĺē"], "fNr-MX": ["Ďĩśàƀĺē śēḿàńţĩć śēàŕćĥ"], "fR-Kdc": ["(ƥŕōĴēćţ-ĺēvēĺ ōńĺŷ)"], + "fSRzLo": ["Śĥōŵ ďōćũḿēńţ ƥàńēĺ"], "fVcm9t": [["0", "plural", { "one": ["#", " ďōć"], "other": ["#", " ďōćś"] }]], "fWi7pn": ["Ĩńĩţĩàĺĩźē śţàŕţēŕ ƥàćķ"], "fWsBTs": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ. Ƥĺēàśē ţŕŷ àĝàĩń."], @@ -2036,6 +2050,7 @@ "jhtG3I": [" Ŷōũŕ śēĺēćţĩōń ĥàś ƀēēń ŕēśţōŕēď."], "jjtmV_": ["Ƒĩŕśţ ƒĩĺē ńàḿē"], "jol95m": ["Ƒàĩĺēď ţō ōƥēń ƤŕōĴēćţ Ńàvĩĝàţōŕ."], + "jpctdh": ["Vĩēŵ"], "jtg_8-": [ "ßũĩĺď à ķńōŵĺēďĝē ŵĩķĩ àƀōũţ ţĥē ďĩƒƒēŕēńţ ţŷƥēś ōƒ ƒĺĩĝĥţĺēśś ƀĩŕďś. Śţàŕţ ŵĩţĥ àń ōvēŕvĩēŵ ƥàĝē, ţĥēń àďď ćŕōśś-ĺĩńķēď ƥàĝēś ƒōŕ ēàćĥ ḿàĴōŕ ĝŕōũƥ (ŕàţĩţēś ĺĩķē ōśţŕĩćĥēś, ēḿũś, àńď ķĩŵĩś; ƥēńĝũĩńś; àńď ƒĺĩĝĥţĺēśś ŕàĩĺś), ţĥē ŕēĝĩōńś ţĥēŷ ĺĩvē ĩń, àńď à ţĩḿēĺĩńē ōƒ ĥōŵ ƒĺĩĝĥţĺēśśńēśś ēvōĺvēď." ], @@ -2337,6 +2352,7 @@ "pvnfJD": ["Ďàŕķ"], "pzutoc": ["Ĩţàĺĩć"], "q2kXMG": ["Ĩńĺĩńē Ḿàţĥ"], + "q3TWG7": ["Ḿōvē ţō Ţŕàśĥ"], "q4YJl_": ["Ţĥē ţēŕḿĩńàĺ śēśśĩōń ēńďēď (ēxĩţ ćōďē ", ["0"], ")."], "q6-JVU": ["Ĺōàďĩńĝ ƥàĝēś"], "q7F5am": ["ţĩţĺē"], @@ -2378,6 +2394,7 @@ "r2huLx": ["Ũńďō, ŕēďō, ōŕ ũńďō śēĺēćţĩōń ćĥàńĝēś ţĥŕōũĝĥ ĆōďēḾĩŕŕōŕ ĥĩśţōŕŷ."], "r4R7AZ": ["Ńēţŵōŕķ ēŕŕōŕ ďũŕĩńĝ ćĺēàńũƥ"], "r51jLC": ["Ƒĩĺē śàvēď & Ćĺàũďē Ďēśķţōƥ Àƥƥ ōƥēńēď"], + "r6ncaO": ["Ćĺōśē ţàƀ"], "r871ed": [ "Ŷōũŕ ķńōŵĺēďĝē ƀàśē ĩś ńōŵ ōń ĜĩţĤũƀ àţ <0>", ["ownerLogin"], @@ -2648,6 +2665,7 @@ "xeXS4P": ["Ōŕ ũśē à śţàŕţēŕ ƥàćķ"], "xeiujy": ["Ţēxţ"], "xhlFsx": ["Ĩńĥēŕĩţēď — śēţ à vàĺũē ĥēŕē ţō ŕēƥĺàćē ĩţ."], + "xi9KGG": ["Śĥōŵ Ţēŕḿĩńàĺ"], "xiv-dW": ["Ńō ƒĩĺēś ŷēţ."], "xloBjn": ["Ŕēĺàţĩvē Ƥàţĥ"], "xnP32l": ["Ńō ďēśćŕĩƥţĩōń ŷēţ — àďď ōńē śō àĝēńţś ķńōŵ ŵĥēń ţō ũśē ţĥĩś śķĩĺĺ."], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 16dbfef1f..2d0c30cda 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -995,6 +995,10 @@ msgstr "" msgid "Append-only log of what changed." msgstr "" +#: src/components/CommandPalette.tsx +msgid "Application" +msgstr "" + #: src/components/settings/TerminalSection.tsx msgid "Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs \"ok open\"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent." msgstr "" @@ -1375,6 +1379,10 @@ msgstr "" msgid "Cannot upload: no document is open" msgstr "" +#: src/components/CommandPalette.tsx +msgid "Check for updates" +msgstr "" + #: src/components/NewWorktreeDialog.tsx msgid "Check out remote branch" msgstr "" @@ -1383,6 +1391,10 @@ msgstr "" msgid "Check out worktree" msgstr "" +#: src/components/CommandPalette.tsx +msgid "Check spelling while typing" +msgstr "" + #: src/components/PropertyWidgets.tsx msgid "Checkbox" msgstr "" @@ -1581,6 +1593,10 @@ msgstr "" msgid "Close others" msgstr "" +#: src/components/CommandPalette.tsx +msgid "Close tab" +msgstr "" + #: src/components/TerminalRefusalNotice.tsx msgid "Close terminal" msgstr "" @@ -1638,6 +1654,7 @@ msgstr "" msgid "Collapse {keyName}" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/FileTree.tsx msgid "Collapse all" @@ -1933,6 +1950,7 @@ msgstr "" msgid "Copy command" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx msgid "Copy full path" msgstr "" @@ -1949,6 +1967,10 @@ msgstr "" msgid "Copy Path" msgstr "" +#: src/components/CommandPalette.tsx +msgid "Copy relative path" +msgstr "" + #: src/lib/keyboard-shortcuts.ts msgid "Copy selected file or folder" msgstr "" @@ -2844,6 +2866,7 @@ msgstr "" msgid "Drag item {0} to reorder" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/FileTree.tsx #: src/components/skill-actions.tsx msgid "Duplicate" @@ -3140,6 +3163,7 @@ msgstr "" msgid "Expand {keyName}" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/FileTree.tsx msgid "Expand all" @@ -3458,6 +3482,7 @@ msgstr "" msgid "Fetching" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/settings/AiToolsSection.tsx #: src/components/settings/ProjectAiToolsSection.tsx #: src/components/share-metadata-rows.tsx @@ -3861,6 +3886,10 @@ msgstr "" msgid "Hide advanced" msgstr "" +#: src/components/CommandPalette.tsx +msgid "Hide document panel" +msgstr "" + #: src/components/GraphPanel.tsx msgid "Hide external URL nodes" msgstr "" @@ -3905,6 +3934,14 @@ msgstr "" msgid "Hide selection preview" msgstr "" +#: src/components/CommandPalette.tsx +msgid "Hide sidebar" +msgstr "" + +#: src/components/CommandPalette.tsx +msgid "Hide Terminal" +msgstr "" + #: src/components/FileTree.tsx msgid "Hide this file" msgstr "" @@ -4275,6 +4312,10 @@ msgstr "" msgid "Keyboard shortcuts available in the editor and workspace." msgstr "" +#: src/components/CommandPalette.tsx +msgid "Kill Terminal" +msgstr "" + #: src/editor/extensions/WikiLinkPropPanel.tsx msgid "Label <0>(optional display text)" msgstr "" @@ -4704,6 +4745,10 @@ msgstr "" msgid "Move to the previous visual-editor find result." msgstr "" +#: src/components/CommandPalette.tsx +msgid "Move to Trash" +msgstr "" + #: src/editor/extensions/JsxComponentView.tsx msgid "Move up" msgstr "" @@ -4860,6 +4905,7 @@ msgstr "" msgid "New folder" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/FileTree.tsx msgid "New from template" @@ -4899,10 +4945,15 @@ msgstr "" msgid "New terminal" msgstr "" +#: src/components/CommandPalette.tsx +msgid "New Terminal" +msgstr "" + #: src/lib/keyboard-shortcuts.ts msgid "New terminal tab" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/NewWorktreeDialog.tsx #: src/components/ProjectSwitcher.tsx msgid "New worktree" @@ -5591,6 +5642,10 @@ msgstr "" msgid "OpenKnowledge is using this GitHub account." msgstr "" +#: src/components/CommandPalette.tsx +msgid "OpenKnowledge on GitHub" +msgstr "" + #: src/components/ReportBugDialogBody.tsx msgid "OpenKnowledge quit unexpectedly last time." msgstr "" @@ -6255,6 +6310,7 @@ msgstr "" msgid "Removing" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/FileTree.tsx #: src/components/skill-actions.tsx #: src/components/SkillRenameDialog.tsx @@ -6520,6 +6576,7 @@ msgstr "" msgid "Reveal in File Explorer, {hint}" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/FileTree.tsx #: src/components/ReportBugDialogBody.tsx @@ -6879,6 +6936,10 @@ msgstr "" msgid "Set the auto-sync default for users opening this project for the first time. This setting is committed to your repository." msgstr "" +#: src/components/CommandPalette.tsx +msgid "Set up OpenKnowledge integrations" +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx msgid "Set up syncing" msgstr "" @@ -6975,6 +7036,7 @@ msgstr "" msgid "Show" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx msgid "Show .ok folders" msgstr "" @@ -7001,6 +7063,10 @@ msgstr "" msgid "Show diagram" msgstr "" +#: src/components/CommandPalette.tsx +msgid "Show document panel" +msgstr "" + #: src/components/GraphPanel.tsx msgid "Show external URL nodes" msgstr "" @@ -7021,6 +7087,7 @@ msgstr "" msgid "Show Files ({sidebarShortcutLabel})" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/NotInSidebarIndicator.tsx msgid "Show hidden files" @@ -7034,6 +7101,7 @@ msgstr "" msgid "Show less" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx msgid "Show only markdown files" msgstr "" @@ -7066,6 +7134,11 @@ msgstr "" msgid "Show selection preview" msgstr "" +#: src/components/CommandPalette.tsx +msgid "Show sidebar" +msgstr "" + +#: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx msgid "Show skills section" msgstr "" @@ -7078,6 +7151,10 @@ msgstr "" msgid "Show terminal" msgstr "" +#: src/components/CommandPalette.tsx +msgid "Show Terminal" +msgstr "" + #: src/editor/components/Pdf.tsx msgid "Show thumbnails" msgstr "" @@ -7377,6 +7454,10 @@ msgstr "" msgid "Switch to local-only refused" msgstr "" +#: src/components/CommandPalette.tsx +msgid "Switch worktree" +msgstr "" + #: src/components/ShareBranchSwitchDialog.tsx msgid "Switching branches" msgstr "" @@ -7576,6 +7657,7 @@ msgstr "" #. Group label above the shell-PATH row in Settings → AI tools & CLI #. Section label above the shell-PATH toggle in the first-launch dialog +#: src/components/CommandPalette.tsx #: src/components/empty-state/CreatePromptComposer.tsx #: src/components/handoff/AgentSplitButton.tsx #: src/components/handoff/OpenInAgentContextSubmenu.tsx @@ -8406,6 +8488,10 @@ msgstr "" msgid "via {viaTags}" msgstr "" +#: src/components/CommandPalette.tsx +msgid "View" +msgstr "" + #: src/components/AssetPreview.tsx msgid "View as text" msgstr "" diff --git a/packages/app/tests/stress/command-palette-parity.e2e.ts b/packages/app/tests/stress/command-palette-parity.e2e.ts new file mode 100644 index 000000000..298e265c1 --- /dev/null +++ b/packages/app/tests/stress/command-palette-parity.e2e.ts @@ -0,0 +1,64 @@ +/** + * Real-cmdk smoke for the Cmd+K menu-parity backfill. + * + * The unit/DOM tests mock cmdk (they assert the registry-driven branch emits a + * row and wires its dispatch). This test closes that fidelity gap against the + * REAL palette in a running app: it proves a backfilled host-agnostic command + * (Show/Hide sidebar) is search-only, renders under a matching query in the + * real cmdk list, and — when activated — dispatches through the local + * menu-action bus to the real FileSidebar handler, which flips the sidebar and + * therefore the state-reflecting label on the next open. + * + * Runs on the web host (dev server, no desktop bridge), so it exercises exactly + * the host-agnostic path that Phase 1 makes reachable on web. + */ + +import { expect, test } from './_helpers'; + +const SEED_DOCS = [{ name: 'note', markdown: '# note\n\nHello there.\n' }]; + +async function openPalette(page: import('@playwright/test').Page) { + // `ControlOrMeta+k` picks Meta on darwin, Control elsewhere — matches the + // in-app handler's `isMacOS() ? metaKey : ctrlKey`. + await page.keyboard.press('ControlOrMeta+k'); + const list = page.locator('[data-slot="command-list"]'); + await expect(list).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('[data-slot="command-input"]')).toBeFocused(); + return list; +} + +test.describe('command-palette — menu-parity backfill (real cmdk)', () => { + test('a backfilled toggle is search-only, renders in the real list, and dispatches to its handler', async ({ + page, + api, + }) => { + await api.seedDocs(SEED_DOCS); + await page.goto('/'); + await page.waitForSelector('[role="treeitem"]', { timeout: 15_000 }); + + // 1) Empty open: the backfilled long-tail row must NOT render (search-only). + const list = await openPalette(page); + await expect(page.getByTestId('command-palette-toggle-sidebar')).toHaveCount(0); + + // 2) Type a matching query: the real cmdk renders the backfilled row with a + // state-reflecting label (sidebar starts expanded → "Hide sidebar"). + await page.keyboard.type('sidebar'); + const row = page.getByTestId('command-palette-toggle-sidebar'); + await expect(row).toBeVisible({ timeout: 5_000 }); + await expect(row).toContainText('Hide sidebar'); + + // 3) Activate it: the palette closes and the action dispatches through the + // bus to FileSidebar's handler (toggles the sidebar). + await row.click(); + await expect(list).toBeHidden({ timeout: 5_000 }); + + // 4) Re-open + re-query: the label now reads "Show sidebar", proving the + // real dispatch actually flipped the sidebar state (round-trip through + // the bus → FileSidebar → view-menu-state store → label). + await openPalette(page); + await page.keyboard.type('sidebar'); + const rowAfter = page.getByTestId('command-palette-toggle-sidebar'); + await expect(rowAfter).toBeVisible({ timeout: 5_000 }); + await expect(rowAfter).toContainText('Show sidebar'); + }); +}); diff --git a/packages/app/tests/stress/fixtures/handoff-mocks.ts b/packages/app/tests/stress/fixtures/handoff-mocks.ts index b7d5a2449..717b32c93 100644 --- a/packages/app/tests/stress/fixtures/handoff-mocks.ts +++ b/packages/app/tests/stress/fixtures/handoff-mocks.ts @@ -492,6 +492,10 @@ export async function installHandoffMocks(page: Page, cfg: HandoffMockConfig): P signalReady: () => {}, confirm: async () => ({ ok: true }), skip: async () => ({ ok: true }), + reconfigure: async () => false, + }, + spellcheck: { + toggle: async () => false, }, integrations: { status: async () => ({ diff --git a/packages/core/src/constants/menu-labels.ts b/packages/core/src/constants/menu-labels.ts index d316b7080..8d1ee7e3a 100644 --- a/packages/core/src/constants/menu-labels.ts +++ b/packages/core/src/constants/menu-labels.ts @@ -38,6 +38,33 @@ export const MENU_LABELS = { showSkillsSection: 'Show skills section', expandAll: 'Expand all', collapseAll: 'Collapse all', + // Move to Trash keeps "Trash" capitalized — the macOS destination proper noun, + // same treatment as Finder / Terminal above. + moveToTrash: 'Move to Trash', + // Copy path is nested in the native menu (Copy path ▸ Full path / Relative + // path); the Cmd+K palette flattens it, so these two are palette-side labels + // that the parity test still keeps present in the compiled catalog. + copyFullPath: 'Copy full path', + copyRelativePath: 'Copy relative path', + // Backfilled Cmd+K commands whose native-menu counterparts are static-label + // items (not state-aware Show/Hide toggles). Shared so both surfaces read + // identically; the native menu appends the … ellipsis at its call site. + checkForUpdates: 'Check for updates', + setUpIntegrations: 'Set up OpenKnowledge integrations', + closeTab: 'Close tab', + newWorktree: 'New worktree', + switchWorktree: 'Switch worktree', + newTerminal: 'New Terminal', + killTerminal: 'Kill Terminal', + checkSpelling: 'Check spelling while typing', + openOnGithub: 'OpenKnowledge on GitHub', } as const; export type MenuLabelKey = keyof typeof MENU_LABELS; + +/** + * Canonical repository URL shared by the native Help menu and the Cmd+K + * palette's "OpenKnowledge on GitHub" command, so the two surfaces cannot + * drift (the parity ratchets check labels, not URLs). + */ +export const OPEN_KNOWLEDGE_GITHUB_URL = 'https://github.com/inkeep/open-knowledge'; diff --git a/packages/core/src/desktop-bridge.ts b/packages/core/src/desktop-bridge.ts index 7fc44c0c1..c38e2218e 100644 --- a/packages/core/src/desktop-bridge.ts +++ b/packages/core/src/desktop-bridge.ts @@ -1493,6 +1493,21 @@ export interface OkDesktopBridge { confirm(request: OkMcpWiringConfirmRequest): Promise; /** User clicked Skip (or pressed ESC). */ skip(): Promise; + /** + * Re-arm the consent dialog on demand (File → "Set up OpenKnowledge + * integrations…" and the Cmd+K command). Resolves `true` when armed, + * `false` when unavailable (non-darwin / unpackaged / arming threw). + */ + reconfigure(): Promise; + }; + + /** + * App-wide spell-check toggle (Edit → "Check spelling while typing" and the + * Cmd+K command). `toggle()` flips the flag and resolves to the new enabled + * state. Bespoke main-side setting; NOT part of the View-menu-state snapshot. + */ + spellcheck: { + toggle(): Promise; }; /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index da9b8c940..273166548 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -226,7 +226,11 @@ export { } from './constants/github.ts'; export { isOrphanMode, ORPHAN_MODES, type OrphanMode } from './constants/graph.ts'; export { GREP_MAX_RESULTS, MCP_SERVER_NAME, READ_DOCUMENT_HISTORY_DEPTH } from './constants/mcp.ts'; -export { MENU_LABELS, type MenuLabelKey } from './constants/menu-labels.ts'; +export { + MENU_LABELS, + type MenuLabelKey, + OPEN_KNOWLEDGE_GITHUB_URL, +} from './constants/menu-labels.ts'; export { LOCAL_DIR, OK_DIR, OK_PROJECT_MARKER } from './constants/ok-dir.ts'; export { PREVIEW_EMBED_STARTERS, diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 7d2591cb9..84944ea95 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -2250,22 +2250,8 @@ async function runApplicationMenuRefresh(): Promise { // window that opens. reconfigureMcpWiring: process.platform === 'darwin' && app.isPackaged - ? async () => { - mcpWiringHandle?.destroy(); - mcpWiringHandle = null; - try { - mcpWiringHandle = armMcpWiring({ - forceShow: true, - immediateDispatchTarget: pickLoadedRendererForMcpDialog(), - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error('[main] reconfigureMcpWiring failed', { err: message }); - dialog.showErrorBox( - 'Set up OpenKnowledge integrations failed', - `OpenKnowledge couldn't re-arm the MCP consent dialog:\n\n${message}`, - ); - } + ? () => { + reconfigureMcpWiringNow(); } : undefined, // Help → Install in Claude Desktop… opens the skill install dialog in @@ -2956,6 +2942,37 @@ function armMcpWiring(opts: ArmMcpWiringOpts = {}): RunMcpWiringHandle { return runMcpWiringOnFirstLaunch(createMcpWiringOpts(opts)); } +/** + * Re-arm the MCP consent dialog on demand — the shared body behind both the + * File → "Set up OpenKnowledge integrations…" menu dep and the Cmd+K command's + * `ok:mcp-wiring:reconfigure` invoke. Tears down any prior handle then arms a + * fresh one with `forceShow: true` so the marker-present gate is bypassed, and + * hands it an already-loaded window so the dialog opens immediately. Returns + * `false` when the surface is unavailable (non-darwin / unpackaged — same gate + * that hides the menu leaf) or when arming threw, so the palette can toast + * rather than silently no-op. + */ +function reconfigureMcpWiringNow(): boolean { + if (!(process.platform === 'darwin' && app.isPackaged)) return false; + mcpWiringHandle?.destroy(); + mcpWiringHandle = null; + try { + mcpWiringHandle = armMcpWiring({ + forceShow: true, + immediateDispatchTarget: pickLoadedRendererForMcpDialog(), + }); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error('[main] reconfigureMcpWiring failed', { err: message }); + dialog.showErrorBox( + 'Set up OpenKnowledge integrations failed', + `OpenKnowledge couldn't re-arm the MCP consent dialog:\n\n${message}`, + ); + return false; + } +} + function formatUnknownError(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -3194,6 +3211,18 @@ const RECENT_GIT_ROOTS_CAP = 256; function registerIpcHandlers() { const handle = createHandler(ipcMain); + // File → "Set up OpenKnowledge integrations…" / Cmd+K command. Shares the + // exact body the menu dep runs; resolves false when the surface is + // unavailable so the palette can toast instead of no-op. + handle('ok:mcp-wiring:reconfigure', async (): Promise => reconfigureMcpWiringNow()); + + // Edit → "Check spelling while typing" / Cmd+K command. Same single-source + // toggle the menu dep uses; returns the new app-wide enabled state. + handle('ok:spellcheck:toggle', async (): Promise => { + setSpellCheckEnabledAppWide(!appState.spellCheckEnabled); + return appState.spellCheckEnabled; + }); + // Per-session membership set for `ok:fs:remove-git-folder`. Populated // by `ok:fs:find-enclosing-git-root` returns; read by the destructive // handler via the `allowedGitRoots` dep on `removeGitFolder`. Scope- diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts index 77017200b..91a38e9bf 100644 --- a/packages/desktop/src/main/menu.ts +++ b/packages/desktop/src/main/menu.ts @@ -31,7 +31,11 @@ * free of runtime electron bindings. */ -import { MENU_LABELS, SHOW_INSTALL_SKILL } from '@inkeep/open-knowledge-core'; +import { + MENU_LABELS, + OPEN_KNOWLEDGE_GITHUB_URL, + SHOW_INSTALL_SKILL, +} from '@inkeep/open-knowledge-core'; import type { Dialog, MenuItemConstructorOptions } from 'electron'; import type { EntryPoint } from '../shared/entry-point.ts'; import type { EditorActiveTargetSnapshot } from '../shared/ipc-channels.ts'; @@ -366,7 +370,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] ...(deps.onCheckForUpdates ? ([ { - label: 'Check for updates…', + label: `${MENU_LABELS.checkForUpdates}\u2026`, click: deps.onCheckForUpdates, }, { type: 'separator' as const }, @@ -471,12 +475,12 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] // renderer surface. Disabled (not hidden) in the Navigator window and // in unit-test contexts, where the deps aren't wired. { - label: 'New worktree…', + label: `${MENU_LABELS.newWorktree}\u2026`, enabled: deps.onNewWorktree !== undefined, click: () => deps.onNewWorktree?.(), }, { - label: 'Switch worktree…', + label: `${MENU_LABELS.switchWorktree}\u2026`, enabled: deps.onSwitchWorktree !== undefined, click: () => deps.onSwitchWorktree?.(), }, @@ -500,7 +504,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] click: () => deps.onRename?.(), }, { - label: 'Move to Trash', + label: MENU_LABELS.moveToTrash, accelerator: 'CmdOrCtrl+Delete', enabled: deps.onMoveToTrash !== undefined && @@ -543,7 +547,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] ...(deps.reconfigureMcpWiring ? ([ { - label: 'Set up OpenKnowledge integrations…', + label: `${MENU_LABELS.setUpIntegrations}\u2026`, click: () => { void deps.reconfigureMcpWiring?.(); }, @@ -566,7 +570,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] : []), isMac ? { - label: 'Close tab', + label: MENU_LABELS.closeTab, accelerator: 'CmdOrCtrl+W', enabled: deps.onCloseActiveTabOrWindow !== undefined, click: () => deps.onCloseActiveTabOrWindow?.(), @@ -591,7 +595,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] // in-editor context menu) rebuilds the menu so the checkmark stays in // sync. No accelerator — macOS has no canonical one for this item. { - label: 'Check spelling while typing', + label: MENU_LABELS.checkSpelling, type: 'checkbox', checked: deps.spellCheckEnabled ?? true, enabled: deps.onToggleSpellCheck !== undefined, @@ -721,7 +725,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] // Show/Hide Terminal toggle, and advertising the same key on two // items only mislabels this one — the OS does not guarantee which // item a duplicate accelerator dispatches to. - label: 'New Terminal', + label: MENU_LABELS.newTerminal, enabled: deps.onNewTerminal !== undefined, click: () => deps.onNewTerminal?.(), }, @@ -737,7 +741,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] // Closes the active tab — kills its shell (not just hide). Enabled // only when at least one session is live; a collapsed-but-alive // terminal still qualifies. - label: 'Kill Terminal', + label: MENU_LABELS.killTerminal, enabled: deps.onKillTerminal !== undefined && deps.terminalLive === true, click: () => deps.onKillTerminal?.(), }, @@ -771,23 +775,24 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] ] satisfies MenuItemConstructorOptions[]) : []), { - label: 'OpenKnowledge on GitHub', - click: () => deps.openExternalUrl('https://github.com/inkeep/open-knowledge'), + label: MENU_LABELS.openOnGithub, + click: () => deps.openExternalUrl(OPEN_KNOWLEDGE_GITHUB_URL), }, { label: 'Report a Bug…', click: () => deps.onReportBug?.(), }, - // Cross-platform "Check for updates…" — Windows/Linux convention - // is to place this in Help, since those platforms have no - // application menu. macOS users get the Apple-HIG-canonical - // placement under the App menu instead, but the Help entry is - // also kept for discoverability (mirrors VS Code, Slack, etc.). - ...(deps.onCheckForUpdates + // "Check for updates…" — non-macOS only. Windows/Linux have no + // application menu, so Help is the convention there. macOS gets the + // Apple-HIG-canonical placement under the App menu (above), so gating + // this on `!isMac` keeps exactly one entry per platform — mirroring the + // Settings XOR (App menu on macOS, File menu elsewhere). Without the + // guard macOS renders the item in BOTH menus. + ...(!isMac && deps.onCheckForUpdates ? ([ { type: 'separator' as const }, { - label: 'Check for updates…', + label: `${MENU_LABELS.checkForUpdates}\u2026`, click: deps.onCheckForUpdates, }, ] satisfies MenuItemConstructorOptions[]) diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 8fd02c486..7e3f13976 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -579,6 +579,11 @@ const bridge: OkDesktopBridge = { skills: request.skills, }), skip: () => invoke('ok:mcp-wiring:skip'), + reconfigure: () => invoke('ok:mcp-wiring:reconfigure'), + }, + + spellcheck: { + toggle: () => invoke('ok:spellcheck:toggle'), }, integrations: { diff --git a/packages/desktop/src/shared/bridge-contract.ts b/packages/desktop/src/shared/bridge-contract.ts index 8701abd64..7f45bcc36 100644 --- a/packages/desktop/src/shared/bridge-contract.ts +++ b/packages/desktop/src/shared/bridge-contract.ts @@ -1731,6 +1731,21 @@ export interface OkDesktopBridge { confirm(request: OkMcpWiringConfirmRequest): Promise; /** User clicked Skip (or pressed ESC). */ skip(): Promise; + /** + * Re-arm the consent dialog on demand (File → "Set up OpenKnowledge + * integrations…" and the Cmd+K command). Resolves `true` when armed, + * `false` when unavailable (non-darwin / unpackaged / arming threw). + */ + reconfigure(): Promise; + }; + + /** + * App-wide spell-check toggle (Edit → "Check spelling while typing" and the + * Cmd+K command). `toggle()` flips the flag and resolves to the new enabled + * state. Bespoke main-side setting; NOT part of the View-menu-state snapshot. + */ + spellcheck: { + toggle(): Promise; }; /** diff --git a/packages/desktop/src/shared/ipc-channels.ts b/packages/desktop/src/shared/ipc-channels.ts index 13f355560..15f55f573 100644 --- a/packages/desktop/src/shared/ipc-channels.ts +++ b/packages/desktop/src/shared/ipc-channels.ts @@ -30,7 +30,7 @@ * existing channels is preferred over net-new hand-rolled channels until * that migration lands. * - * Count is 83 (ratchet cap 83). The 74→75 bump reconciled a merge collision: + * Count is 85 (ratchet cap 85). The 74→75 bump reconciled a merge collision: * the worktree selector (`ok:worktree:dispatch`) and the terminal-controls PR * (`ok:terminal:cli-installed-map`) each landed in the base tree's single free * slot concurrently. The 75→76 bump then unioned in the desktop @@ -49,8 +49,11 @@ * operations widen its payload rather than adding channels). The 82→83 bump * added the terminal clickable-links out-of-project reveal * (`ok:shell:reveal-external`): a distinct trust boundary from `reveal-asset` - * (uncontained + dialog-gated), so it could not fold onto it. Full rationale in - * the ratchet test header. + * (uncontained + dialog-gated), so it could not fold onto it. The 83→85 bump + * added the two Cmd+K / native-menu command invokes: `ok:mcp-wiring:reconfigure` + * (File → "Set up OpenKnowledge integrations…") and `ok:spellcheck:toggle` + * (Edit → "Check spelling while typing"), each delegating to an existing + * main-side function. Full rationale in the ratchet test header. */ import type { @@ -1285,6 +1288,23 @@ export interface RequestChannels { */ 'ok:mcp-wiring:renderer-ready': { args: []; result: undefined }; + /** + * File → "Set up OpenKnowledge integrations…" and the Cmd+K command of the + * same name. Re-arms the MCP consent dialog (`armMcpWiring({forceShow:true})`) + * — the same body the menu dep runs. Resolves to `true` when the dialog was + * armed, `false` when the surface is unavailable (non-darwin / unpackaged / + * arming threw); callers may use the result to decide whether to surface + * feedback. + */ + 'ok:mcp-wiring:reconfigure': { args: []; result: boolean }; + /** + * Edit → "Check spelling while typing" and the Cmd+K command of the same name. + * Toggles the app-wide spell-check flag via `setSpellCheckEnabledAppWide` and + * resolves to the new enabled state so the caller can reflect it. Distinct from + * the View-menu-state snapshot (spell-check is a bespoke app-wide setting). + */ + 'ok:spellcheck:toggle': { args: []; result: boolean }; + /** * Settings → AI tools. One consolidated discriminated channel — following * the `ok:worktree:dispatch` precedent rather than adding two net-new diff --git a/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts b/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts index 7d968e8ea..1a911af61 100644 --- a/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts +++ b/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts @@ -340,8 +340,14 @@ const CHANNELS_SRC = readFileSync(SRC_PATH, 'utf-8'); * onto the containment-checked asset reveal would wear one channel over two * opposite security contracts. Single member; the typed-ipc migration remains the * committed end state, with the `ipc-channels.ts` header updated in lock-step. + * + * Bumped from 83 to 85 for the two Cmd+K / native-menu command invokes: + * `ok:mcp-wiring:reconfigure` (File → "Set up OpenKnowledge integrations…") + * and `ok:spellcheck:toggle` (Edit → "Check spelling while typing"). Each + * delegates to an existing main-side function; the typed-ipc migration remains + * the committed end state, with the `ipc-channels.ts` header in lock-step. */ -const REQUEST_CHANNEL_CAP = 83; +const REQUEST_CHANNEL_CAP = 85; /** * Extract the body of an interface block by name. Returns the substring diff --git a/packages/desktop/tests/main/menu.test.ts b/packages/desktop/tests/main/menu.test.ts index acaf75841..6ddb7cacd 100644 --- a/packages/desktop/tests/main/menu.test.ts +++ b/packages/desktop/tests/main/menu.test.ts @@ -346,14 +346,17 @@ describe('buildMenuTemplate', () => { expect(settingsIdx).toBeGreaterThan(checkIdx); }); - test('macOS: also appears in Help menu (cross-platform discoverability)', () => { + test('macOS: NOT in the Help menu — exactly one entry, in the App menu', () => { + // De-duplicated: the Apple-HIG App-menu placement is the single macOS + // entry (mirroring the Settings platform-XOR). The Help entry is + // non-macOS only, so macOS must not render it twice. const onCheckForUpdates = mock(() => {}); const deps = makeDeps({ onCheckForUpdates }); const template = buildMenuTemplate(deps); const helpMenu = template.find((t) => t.label === 'Help'); const sub = helpMenu?.submenu as MenuItemConstructorOptions[] | undefined; if (!sub) throw new Error('Help submenu missing'); - expect(sub.find((i) => i.label === 'Check for updates…')).toBeDefined(); + expect(sub.find((i) => i.label === 'Check for updates…')).toBeUndefined(); }); } else { test('non-mac: appears in Help menu only (no App menu on these platforms)', () => {