From d73295389309c2729596660c4926e756b42abeaa Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:04:24 -0700 Subject: [PATCH] Render the native menu from the shared command registry (Phase 2b) (#2721) Promote command identity to one serializable declaration in @inkeep/open-knowledge-core: COMMAND_IDENTITIES plus a pure evaluateCommandAvailability over a declarative spec and a plain context. The native application menu and the Cmd+K palette now render their actionable command leaves from that single source. Labels resolve through each command's labelKey (the palette maps it to a Lingui descriptor, the menu reads MENU_LABELS directly), accelerators are single-sourced per menu placement, availability flows through the shared evaluator, and platform placement is data (the Check for updates and Settings App/File/Help split). The menu's structural scaffolding stays declarative: Electron role items, separators, submenu parents, the dynamic Recent-project list, and the isMac branches. A desktop-side binding wires each command's click, dep-presence gate, presence, and checkbox state by id. The palette renders the same rows from a thin app-side wrapper over the same identity; the registry join fails loud (throws at module load) if a command is ever missing an icon. No user-facing change on any platform: menu items, order, labels, accelerators, and enabled/checked states are verified byte-identical across a platform, deps, and target matrix. The parity ratchets stay green, assert the state-dependent menu output (Show/Hide toggle labels, smart-hide visibility, checkbox state, presence-gated absence), and gain declaration-level checks that fail if a command is placed twice in one platform's menu bar or if a menu-placed command lacks a desktop binding. GitOrigin-RevId: e314ae854e07a911d39dffd9964c7fb04a6f922e --- .changeset/menu-registry-phase-2b.md | 16 + .../components/command-palette-commands.ts | 735 ++++++------------ .../app/src/lib/command-menu-parity.test.ts | 237 +++++- .../app/src/lib/menu-label-parity.test.ts | 65 +- .../src/commands/command-identity.test.ts | 149 ++++ .../core/src/commands/command-identity.ts | 633 +++++++++++++++ packages/core/src/constants/menu-labels.ts | 43 +- packages/core/src/index.ts | 18 + packages/desktop/src/main/menu.ts | 684 ++++++++-------- packages/desktop/src/shared/labels.ts | 20 - 10 files changed, 1722 insertions(+), 878 deletions(-) create mode 100644 .changeset/menu-registry-phase-2b.md create mode 100644 packages/core/src/commands/command-identity.test.ts create mode 100644 packages/core/src/commands/command-identity.ts delete mode 100644 packages/desktop/src/shared/labels.ts diff --git a/.changeset/menu-registry-phase-2b.md b/.changeset/menu-registry-phase-2b.md new file mode 100644 index 000000000..8604d75b7 --- /dev/null +++ b/.changeset/menu-registry-phase-2b.md @@ -0,0 +1,16 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Internal refactor (no user-facing change): the native application menu now +renders its actionable command leaves from the same shared command registry the +Cmd+K palette uses, so each command's identity — label, accelerator, keyboard +shortcut, availability, and menu placement — is declared once in +`@inkeep/open-knowledge-core` instead of being hand-maintained in two lists. +Command availability is a pure, declarative evaluator both surfaces call; the +menu's structural scaffolding (roles, separators, submenus, the dynamic +Recent-project list, and the platform branches) stays declarative. The +"Check for updates" and "Settings" platform placements are now data-driven, +and a ratchet fails the build if a command is ever hand-placed twice in the same +platform's menu bar. Menu items, order, labels, accelerators, and enabled/checked +states are unchanged on every platform. diff --git a/packages/app/src/components/command-palette-commands.ts b/packages/app/src/components/command-palette-commands.ts index 9ab4922db..3b8813abd 100644 --- a/packages/app/src/components/command-palette-commands.ts +++ b/packages/app/src/components/command-palette-commands.ts @@ -1,5 +1,13 @@ -import { OPEN_KNOWLEDGE_GITHUB_URL, SHOW_INSTALL_SKILL } from '@inkeep/open-knowledge-core'; -import { t } from '@lingui/core/macro'; +import { + COMMAND_IDENTITIES, + type CommandContext, + type CommandIdentity, + evaluateCommandAvailability, + OPEN_KNOWLEDGE_GITHUB_URL, + SHOW_INSTALL_SKILL, +} from '@inkeep/open-knowledge-core'; +import type { MessageDescriptor } from '@lingui/core'; +import { msg, t } from '@lingui/core/macro'; import { Blocks, Bug, @@ -34,36 +42,34 @@ import { requestDocPanelTab } from '@/components/doc-panel-events'; import { GithubIcon } from '@/components/icons/github'; import type { ResolvedNavigationTarget } from '@/components/navigation-targets'; import type { OkDesktopBridge, OkMenuAction } from '@/lib/desktop-bridge-types'; +import { i18n } from '@/lib/i18n'; import type { KeyboardShortcutId } from '@/lib/keyboard-shortcuts'; import { SETTINGS_OPEN_HASH } from '@/lib/use-settings-route'; import type { ViewMenuState } from '@/lib/view-menu-state-store'; /** - * The command registry for the Cmd+K palette's FIXED command rows — the single - * source of truth the palette renders from, and the source the parity ratchets - * derive their palette-reachable id classification from - * (`command-menu-parity.test-helper.ts`). - * - * Fixed commands only. The palette's query/state-driven populations stay - * bespoke in `CommandPalette.tsx` and are deliberately NOT registry entries: - * search results, tag mode, semantic ("by meaning"), recents, the recent-project - * and worktree-switch lists, and the per-target Open-with-AI group (`send-to-ai` - * is one install-gated row per agent target, which a single descriptor cannot - * represent). + * The Cmd+K palette's FIXED command rows, joined here from the shared command + * identity (`@inkeep/open-knowledge-core/commands`, `COMMAND_IDENTITIES`) with + * the renderer presentation the identity cannot carry: `lucide` icons, Lingui + * `msg` labels (resolved through the app's global i18n), and dispatch closures + * over the palette context. The native menu joins the SAME identity with its + * own plain-string labels + click deps (`packages/desktop/src/main/menu.ts`), + * so command identity has one declaration point across both surfaces. * - * The native menu stays hand-authored (`packages/desktop/src/main/menu.ts`); - * Ratchet B classifies its leaves against this registry's id space. Rendering - * the menu from the registry is a separate step gated on where labels and - * availability predicates can live (the menu runs in the Electron main process, - * outside the Lingui macro pipeline). + * The palette's query/state-driven populations stay bespoke in + * `CommandPalette.tsx` and are NOT registry rows: search results, tag mode, + * semantic ("by meaning"), recents, the recent-project and worktree-switch + * lists, and the per-target Open-with-AI group (`send-to-ai` is one + * install-gated row per agent target, which a single descriptor cannot carry — + * it stays a menu-only identity in the core registry). */ /** * Projection of 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. + * gating kinds the shared availability spec reads. The palette never produces + * `project` (the menu's project-scope signal); a missing/absent target is + * `none`, which is how reveal / copy-path hide with no target here yet stay + * actionable in the menu's project scope — one spec, two contexts. */ export type ContextualTargetKind = 'doc' | 'folder' | 'asset' | 'none'; @@ -91,7 +97,7 @@ export function projectContextualTargetKind( * palette component assembles this per render: state snapshots for * `available` / `label` / `checked`, and dispatch seams that reuse the existing * handlers (the local menu-action bus, toast-wrapped bridge calls, and the - * palette's own dialog launchers) so no handler logic is re-implemented here. + * palette's own dialog launchers). */ export interface PaletteCommandContext { bridge: OkDesktopBridge | null; @@ -121,21 +127,12 @@ export interface PaletteCommand { id: string; /** * The `OkMenuAction` this row makes palette-reachable, feeding the derived - * `PALETTE_COMMAND_IDS` classification (Ratchets A/C). Two roles: - * - `busDispatch` rows: this IS the id `dispatch` emits on the menu-action - * bus, and the DOM suite's `ID_BACKED` loop pins that emission, so the two - * cannot drift. - * - dialog rows (whose `dispatch` opens a dialog instead of calling - * `emitMenuAction`): a classification-only annotation. Dispatch emits - * nothing, so no dispatch test guards it; set it by hand to the matching - * native menu leaf's action. + * `PALETTE_COMMAND_IDS` classification. For bus-dispatched rows this IS the id + * `dispatch` emits, and the DOM suite's `ID_BACKED` loop pins that emission. * Absent for commands with no menu-action id (Settings, Open graph, …). */ menuActionId?: OkMenuAction; - /** - * Localized label, resolved at render time so it tracks the active locale - * and can reflect state (Show/Hide toggles read `ctx.viewMenuState`). - */ + /** Localized label, resolved at render so it tracks locale and reflects state. */ label(ctx: PaletteCommandContext): string; /** Extra `matchesCommandQuery` tokens beyond the label. Not localized. */ keywords: readonly string[]; @@ -143,16 +140,12 @@ export interface PaletteCommand { group: PaletteCommandGroup; /** * `always` rows render on empty open (query-filtered once the user types); - * `search-only` rows render only under a matching non-empty query, keeping - * the empty-open state lean. + * `search-only` rows render only under a matching non-empty query. */ visibility: 'always' | 'search-only'; /** Accelerator glyphs rendered via `formatShortcut(shortcutId)`. */ shortcutId?: KeyboardShortcutId; - /** - * The binding only fires through a native-menu accelerator, so the glyphs - * render on the desktop host only. - */ + /** The binding fires only through a native-menu accelerator (desktop host only). */ shortcutDesktopOnly?: boolean; /** Trailing check indicator for checkbox-style View toggles. */ checked?(ctx: PaletteCommandContext): boolean; @@ -160,456 +153,218 @@ export interface PaletteCommand { dispatch(ctx: PaletteCommandContext): void; } -const contextualAvailable = (ctx: PaletteCommandContext): boolean => - ctx.bridge !== null && ctx.contextualTargetKind !== 'none'; +/** + * Palette label descriptors keyed by the registry `labelKey` (and Show/Hide + * toggle keys). The label-parity test asserts this map covers every registry + * labelKey and that each string is present in the compiled catalog, keeping the + * palette in lockstep with the native menu's `MENU_LABELS` source. + */ +const PALETTE_COMMAND_LABELS = { + newFile: msg`New file`, + newFolder: msg`New folder`, + openGraph: msg`Open graph`, + initializeStarterPack: msg`Initialize starter pack`, + newProject: msg`New project`, + openFolderOnDisk: msg`Open folder on disk`, + switchProject: msg`Switch project`, + settings: msg`Settings`, + installClaudeDesktop: msg`Install for Claude Chat & Cowork (Desktop App)`, + reportBug: msg`Report a bug`, + newFromTemplate: msg`New from template`, + rename: msg`Rename`, + duplicate: msg`Duplicate`, + moveToTrash: msg`Move to Trash`, + revealInFinder: msg`Reveal in Finder`, + copyFullPath: msg`Copy full path`, + copyRelativePath: msg`Copy relative path`, + closeTab: msg`Close tab`, + newWorktree: msg`New worktree`, + switchWorktree: msg`Switch worktree`, + sidebarShow: msg`Show sidebar`, + sidebarHide: msg`Hide sidebar`, + docPanelShow: msg`Show document panel`, + docPanelHide: msg`Hide document panel`, + terminalShow: msg`Show Terminal`, + terminalHide: msg`Hide Terminal`, + showHiddenFiles: msg`Show hidden files`, + showOkFolders: msg`Show .ok folders`, + showOnlyMarkdownFiles: msg`Show only markdown files`, + showSkillsSection: msg`Show skills section`, + expandAll: msg`Expand all`, + collapseAll: msg`Collapse all`, + newTerminal: msg`New Terminal`, + killTerminal: msg`Kill Terminal`, + checkForUpdates: msg`Check for updates`, + setUpIntegrations: msg`Set up OpenKnowledge integrations`, + checkSpelling: msg`Check spelling while typing`, + openOnGithub: msg`OpenKnowledge on GitHub`, +} as const satisfies Record; + +/** Exported for the label-parity test (completeness + catalog presence). */ +export type PaletteLabelKey = keyof typeof PALETTE_COMMAND_LABELS; +export { PALETTE_COMMAND_LABELS }; + +/** Renderer icon per command id. Every palette command must have one: + * `toPaletteCommand` throws at module load if an id is missing, rather than + * silently substituting a wrong default. */ +const COMMAND_ICONS: Record> = { + 'new-file': FilePlus2, + 'new-folder': FolderPlus, + 'open-graph': Network, + 'initialize-starter-pack': Package, + 'new-project': Plus, + 'open-folder': FolderOpen, + 'switch-project': LayoutGrid, + settings: Settings, + 'install-claude-desktop': Download, + 'report-bug': Bug, + 'new-from-template': FilePlus2, + rename: Pencil, + duplicate: Copy, + 'move-to-trash': Trash2, + 'reveal-in-finder': FolderOpen, + 'copy-full-path': Copy, + 'copy-relative-path': Copy, + 'close-tab': X, + 'new-worktree': GitBranch, + 'switch-worktree': GitBranch, + 'toggle-sidebar': PanelLeft, + 'toggle-doc-panel': PanelRight, + 'toggle-terminal': SquareTerminal, + 'toggle-show-hidden-files': Eye, + 'toggle-show-ok-folders': Folder, + 'toggle-show-only-markdown-files': FileText, + 'toggle-show-skills-section': Sparkles, + 'expand-all-tree': UnfoldVertical, + 'collapse-all-tree': FoldVertical, + 'new-terminal': SquareTerminal, + 'kill-terminal': SquareTerminal, + 'check-for-updates': RefreshCw, + 'set-up-integrations': Blocks, + 'toggle-spell-check': SpellCheck, + 'open-github': GithubIcon, +}; /** - * Fields for a command whose dispatch is exactly "emit this id on the local - * menu-action bus": one action literal produces BOTH the classification - * (`menuActionId`) and the dispatch that emits it, so the two cannot drift. + * Dispatch closures for commands that do NOT route through the menu-action bus + * (dialog launchers, bridge invokes, direct renderer calls). Bus commands fall + * through to {@link busDispatch}, which emits their `menuActionId`. */ -const busDispatch = (action: OkMenuAction): Pick => ({ - menuActionId: action, - dispatch: (ctx) => ctx.emitMenuAction(action), -}); +const COMMAND_DISPATCH: Record void> = { + 'new-file': (ctx) => { + ctx.closePalette(); + ctx.openNewItemDialog('file'); + }, + 'new-folder': (ctx) => { + ctx.closePalette(); + ctx.openNewItemDialog('folder'); + }, + 'open-graph': (ctx) => { + ctx.closePalette(); + requestDocPanelTab('graph'); + }, + 'initialize-starter-pack': (ctx) => { + ctx.closePalette(); + ctx.openSeedDialog(); + }, + 'new-project': (ctx) => { + ctx.closePalette(); + ctx.openCreateProjectDialog(); + }, + 'open-folder': (ctx) => { + const bridge = ctx.bridge; + if (!bridge) return; + ctx.runAction(async () => { + const path = await bridge.dialog.openFolder(); + if (!path) return; + await bridge.project.open({ path, target: 'new-window', entryPoint: 'pick-existing' }); + }); + }, + 'switch-project': (ctx) => { + const bridge = ctx.bridge; + if (!bridge) return; + ctx.runAction(() => bridge.navigator.open(), t`Failed to open Project Navigator.`); + }, + settings: (ctx) => { + ctx.closePalette(); + if (window.location.hash !== SETTINGS_OPEN_HASH) { + window.location.hash = SETTINGS_OPEN_HASH; + } + }, + 'install-claude-desktop': (ctx) => { + ctx.closePalette(); + window.location.hash = '#install-claude-desktop'; + }, + 'report-bug': (ctx) => { + ctx.closePalette(); + ctx.openReportBugDialog(); + }, + 'check-for-updates': (ctx) => + ctx.runAction(async () => { + await ctx.bridge?.update.checkNow(); + }), + 'set-up-integrations': (ctx) => + ctx.runAction(async () => { + await ctx.bridge?.mcpWiring.reconfigure(); + }), + 'toggle-spell-check': (ctx) => + ctx.runAction(async () => { + await ctx.bridge?.spellcheck.toggle(); + }), + 'open-github': (ctx) => ctx.openExternalUrl(OPEN_KNOWLEDGE_GITHUB_URL), +}; -export const PALETTE_COMMANDS: readonly PaletteCommand[] = [ - // ── Commands ────────────────────────────────────────────────────────────── - { - id: 'new-file', - menuActionId: 'new-doc', - label: () => t`New file`, - keywords: ['create file'], - icon: FilePlus2, - group: 'commands', - visibility: 'always', - shortcutId: 'new-item', - available: () => true, - dispatch: (ctx) => { - ctx.closePalette(); - ctx.openNewItemDialog('file'); - }, - }, - { - id: 'new-folder', - menuActionId: 'new-folder', - label: () => t`New folder`, - keywords: ['create folder'], - icon: FolderPlus, - group: 'commands', - visibility: 'always', - shortcutId: 'new-folder', - // The ⇧⌘N chord is a native-menu accelerator with no web keydown handler. - shortcutDesktopOnly: true, - available: () => true, - dispatch: (ctx) => { - ctx.closePalette(); - ctx.openNewItemDialog('folder'); - }, - }, - { - id: 'open-graph', - label: () => t`Open graph`, - keywords: ['graph panel network'], - icon: Network, - group: 'commands', - visibility: 'always', - available: (ctx) => ctx.activeDocName !== null, - dispatch: (ctx) => { - ctx.closePalette(); - requestDocPanelTab('graph'); - }, - }, - { - id: 'initialize-starter-pack', - label: () => t`Initialize starter pack`, - keywords: ['scaffold', 'seed', 'pack', 'starter'], - icon: Package, - group: 'commands', - visibility: 'always', - available: () => true, - dispatch: (ctx) => { - ctx.closePalette(); - ctx.openSeedDialog(); - }, - }, - // ── Project ─────────────────────────────────────────────────────────────── - { - id: 'new-project', - menuActionId: 'new-project', - label: () => t`New project`, - keywords: ['create new project scaffold'], - icon: Plus, - group: 'project', - visibility: 'always', - available: (ctx) => ctx.bridge !== null, - dispatch: (ctx) => { - ctx.closePalette(); - ctx.openCreateProjectDialog(); - }, - }, - { - id: 'open-folder', - label: () => t`Open folder on disk`, - keywords: ['project'], - icon: FolderOpen, - group: 'project', - visibility: 'always', - shortcutId: 'open-folder', - available: (ctx) => ctx.bridge !== null, - dispatch: (ctx) => { - const bridge = ctx.bridge; - if (!bridge) return; - ctx.runAction(async () => { - const path = await bridge.dialog.openFolder(); - if (!path) return; - await bridge.project.open({ - path, - target: 'new-window', - entryPoint: 'pick-existing', - }); - }); - }, - }, - { - id: 'switch-project', - label: () => t`Switch project`, - keywords: ['switch project navigator projects'], - icon: LayoutGrid, - group: 'project', - visibility: 'always', - shortcutId: 'switch-project', - available: (ctx) => ctx.bridge !== null && !ctx.singleFile, - dispatch: (ctx) => { - const bridge = ctx.bridge; - if (!bridge) return; - ctx.runAction(() => bridge.navigator.open(), t`Failed to open Project Navigator.`); - }, - }, - { - id: 'settings', - label: () => t`Settings`, - keywords: ['preferences config'], - icon: Settings, - group: 'project', - visibility: 'always', - shortcutId: 'settings', - available: (ctx) => !ctx.singleFile, - dispatch: (ctx) => { - ctx.closePalette(); - if (window.location.hash !== SETTINGS_OPEN_HASH) { - window.location.hash = SETTINGS_OPEN_HASH; - } - }, - }, - { - id: 'install-claude-desktop', - label: () => t`Install for Claude Chat & Cowork (Desktop App)`, - keywords: ['claude desktop install cowork'], - icon: Download, - group: 'project', - visibility: 'always', - available: () => SHOW_INSTALL_SKILL, - dispatch: (ctx) => { - ctx.closePalette(); - window.location.hash = '#install-claude-desktop'; - }, - }, - { - id: 'report-bug', - menuActionId: 'report-bug', - label: () => t`Report a bug`, - keywords: ['bug report issue feedback problem'], - icon: Bug, - group: 'project', - visibility: 'always', - // Bundle creation runs over the Electron bridge. Not gated on `singleFile`: - // with no project open the report degrades to the system-wide bundle. - available: (ctx) => ctx.bridge !== null, - dispatch: (ctx) => { - ctx.closePalette(); - ctx.openReportBugDialog(); - }, - }, - // ── File ────────────────────────────────────────────────────────────────── - { - id: 'new-from-template', - ...busDispatch('new-from-template'), - label: () => t`New from template`, - keywords: ['template', 'create', 'new'], - icon: FilePlus2, - group: 'file', - visibility: 'search-only', - available: (ctx) => !ctx.singleFile, - }, - { - id: 'rename', - ...busDispatch('rename'), - label: () => t`Rename`, - keywords: ['rename', 'file', 'folder'], - icon: Pencil, - group: 'file', - visibility: 'search-only', - available: contextualAvailable, - }, - { - id: 'duplicate', - ...busDispatch('duplicate'), - label: () => t`Duplicate`, - keywords: ['duplicate', 'copy', 'file', 'folder'], - icon: Copy, - group: 'file', - visibility: 'search-only', - shortcutId: 'file-tree-duplicate', - available: (ctx) => - ctx.bridge !== null && - (ctx.contextualTargetKind === 'doc' || ctx.contextualTargetKind === 'folder'), - }, - { - id: 'move-to-trash', - ...busDispatch('move-to-trash'), - label: () => t`Move to Trash`, - keywords: ['delete', 'trash', 'remove'], - icon: Trash2, - group: 'file', - visibility: 'search-only', - shortcutId: 'file-tree-delete', - available: contextualAvailable, - }, - { - id: 'reveal-in-finder', - ...busDispatch('reveal-in-finder'), - label: () => t`Reveal in Finder`, - keywords: ['finder', 'reveal', 'show', 'file'], - icon: FolderOpen, - group: 'file', - visibility: 'search-only', - available: contextualAvailable, - }, - { - id: 'copy-full-path', - ...busDispatch('copy-full-path'), - label: () => t`Copy full path`, - keywords: ['copy', 'path', 'absolute', 'full'], - icon: Copy, - group: 'file', - visibility: 'search-only', - available: contextualAvailable, - }, - { - id: 'copy-relative-path', - ...busDispatch('copy-relative-path'), - label: () => t`Copy relative path`, - keywords: ['copy', 'path', 'relative'], - icon: Copy, - group: 'file', - visibility: 'search-only', - available: contextualAvailable, - }, - { - id: 'close-tab', - ...busDispatch('close-active-tab-or-window'), - label: () => t`Close tab`, - keywords: ['close', 'tab', 'window'], - icon: X, - group: 'file', - visibility: 'search-only', - available: (ctx) => ctx.bridge !== null, - }, - { - id: 'new-worktree', - ...busDispatch('new-worktree'), - label: () => t`New worktree`, - keywords: ['worktree', 'branch', 'new'], - icon: GitBranch, - group: 'file', - visibility: 'search-only', - available: (ctx) => ctx.bridge !== null, - }, - { - id: 'switch-worktree', - ...busDispatch('switch-worktree'), - label: () => t`Switch worktree`, - keywords: ['worktree', 'switch', 'branch'], - icon: GitBranch, - group: 'file', - visibility: 'search-only', - available: (ctx) => ctx.bridge !== null, - }, - // ── View ────────────────────────────────────────────────────────────────── - { - id: 'toggle-sidebar', - ...busDispatch('toggle-sidebar'), - label: (ctx) => (ctx.viewMenuState.sidebarVisible ? t`Hide sidebar` : t`Show sidebar`), - keywords: ['sidebar', 'files', 'panel', 'toggle'], - icon: PanelLeft, - group: 'view', - visibility: 'search-only', - shortcutId: 'toggle-files-sidebar', - available: () => true, - }, - { - id: 'toggle-doc-panel', - ...busDispatch('toggle-doc-panel'), - label: (ctx) => - ctx.viewMenuState.docPanelVisible ? t`Hide document panel` : t`Show document panel`, - keywords: ['document', 'panel', 'info', 'toggle'], - icon: PanelRight, - group: 'view', - visibility: 'search-only', - shortcutId: 'toggle-document-panel', - available: () => true, - }, - { - id: 'toggle-terminal', - ...busDispatch('toggle-terminal'), - label: (ctx) => (ctx.viewMenuState.terminalVisible ? t`Hide Terminal` : t`Show Terminal`), - keywords: ['terminal', 'shell', 'console', 'toggle'], - icon: SquareTerminal, - group: 'view', - visibility: 'search-only', - shortcutId: 'toggle-terminal-panel', - available: (ctx) => ctx.bridge !== null, - }, - { - id: 'toggle-show-hidden-files', - ...busDispatch('toggle-show-hidden-files'), - label: () => t`Show hidden files`, - keywords: ['hidden', 'dotfiles', 'files', 'show'], - icon: Eye, - group: 'view', - visibility: 'search-only', - checked: (ctx) => ctx.viewMenuState.showHiddenFiles === true, - available: () => true, - }, - { - id: 'toggle-show-ok-folders', - ...busDispatch('toggle-show-ok-folders'), - label: () => t`Show .ok folders`, - keywords: ['ok', 'folders', 'hidden', 'show'], - icon: Folder, - group: 'view', - visibility: 'search-only', - checked: (ctx) => ctx.viewMenuState.showOkFolders === true, - available: () => true, - }, - { - id: 'toggle-show-only-markdown-files', - ...busDispatch('toggle-show-only-markdown-files'), - label: () => t`Show only markdown files`, - keywords: ['markdown', 'filter', 'files', 'only'], - icon: FileText, - group: 'view', - visibility: 'search-only', - checked: (ctx) => ctx.viewMenuState.showOnlyMarkdownFiles === true, - available: () => true, - }, - { - id: 'toggle-show-skills-section', - ...busDispatch('toggle-show-skills-section'), - label: () => t`Show skills section`, - keywords: ['skills', 'section', 'sidebar', 'show'], - icon: Sparkles, - group: 'view', - visibility: 'search-only', - checked: (ctx) => ctx.viewMenuState.showSkillsSection === true, - available: () => true, - }, - { - id: 'expand-all-tree', - ...busDispatch('expand-all-tree'), - label: () => t`Expand all`, - keywords: ['expand', 'tree', 'folders', 'all'], - icon: UnfoldVertical, - group: 'view', - visibility: 'search-only', - // 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: (ctx) => ctx.viewMenuState.canExpandAll !== false, - }, - { - id: 'collapse-all-tree', - ...busDispatch('collapse-all-tree'), - label: () => t`Collapse all`, - keywords: ['collapse', 'tree', 'folders', 'all'], - icon: FoldVertical, - group: 'view', - visibility: 'search-only', - // Mirror the native menu's smart-hide (visible: canCollapseAll ?? true). - available: (ctx) => ctx.viewMenuState.canCollapseAll !== false, - }, - // ── Terminal ────────────────────────────────────────────────────────────── - { - id: 'new-terminal', - ...busDispatch('new-terminal'), - label: () => t`New Terminal`, - keywords: ['terminal', 'shell', 'new', 'tab'], - icon: SquareTerminal, - group: 'terminal', - visibility: 'search-only', - available: (ctx) => ctx.bridge !== null, - }, - { - id: 'kill-terminal', - ...busDispatch('kill-terminal'), - label: () => t`Kill Terminal`, - keywords: ['terminal', 'kill', 'close', 'session'], - icon: SquareTerminal, - group: 'terminal', - visibility: 'search-only', - available: (ctx) => ctx.bridge !== null && ctx.viewMenuState.terminalLive === true, - }, - // ── Application ─────────────────────────────────────────────────────────── - { - id: 'check-for-updates', - label: () => t`Check for updates`, - keywords: ['update', 'upgrade', 'version', 'check'], - icon: RefreshCw, - group: 'app', - visibility: 'search-only', - available: (ctx) => ctx.bridge !== null, - dispatch: (ctx) => - ctx.runAction(async () => { - await ctx.bridge?.update.checkNow(); - }), - }, - { - id: 'set-up-integrations', - label: () => t`Set up OpenKnowledge integrations`, - keywords: ['integrations', 'mcp', 'setup', 'claude', 'configure'], - icon: Blocks, - group: 'app', - visibility: 'search-only', - available: (ctx) => ctx.bridge !== null, - dispatch: (ctx) => - ctx.runAction(async () => { - await ctx.bridge?.mcpWiring.reconfigure(); - }), - }, - { - id: 'toggle-spell-check', - label: () => t`Check spelling while typing`, - keywords: ['spell', 'spelling', 'check', 'typing'], - icon: SpellCheck, - group: 'app', - visibility: 'search-only', - // App-wide flag owned by the Electron main process (not view-menu-state): - // the invoke reads + toggles it there, so the row carries no checkmark. - available: (ctx) => ctx.bridge !== null, - dispatch: (ctx) => - ctx.runAction(async () => { - await ctx.bridge?.spellcheck.toggle(); - }), - }, - { - id: 'open-github', - label: () => t`OpenKnowledge on GitHub`, - keywords: ['github', 'source', 'repository', 'code'], - icon: GithubIcon, - group: 'app', - visibility: 'search-only', - available: () => true, - dispatch: (ctx) => ctx.openExternalUrl(OPEN_KNOWLEDGE_GITHUB_URL), - }, -]; +function paletteCoreContext(ctx: PaletteCommandContext): CommandContext { + return { + host: ctx.bridge !== null ? 'desktop' : 'web', + activeTargetKind: ctx.contextualTargetKind, + singleFile: ctx.singleFile, + terminalLive: ctx.viewMenuState.terminalLive === true, + canExpandAll: ctx.viewMenuState.canExpandAll !== false, + canCollapseAll: ctx.viewMenuState.canCollapseAll !== false, + hasActiveDoc: ctx.activeDocName !== null, + showInstallSkill: SHOW_INSTALL_SKILL, + }; +} + +function resolvePaletteLabel(cmd: CommandIdentity, ctx: PaletteCommandContext): string { + if (cmd.stateToggle) { + // Palette form: `state ? Hide : Show` (undefined → Show), independent of the + // native menu's default-visible fallback. + const visible = ctx.viewMenuState[cmd.stateToggle.stateField] === true; + const key = visible ? cmd.stateToggle.hideKey : cmd.stateToggle.showKey; + return i18n._(PALETTE_COMMAND_LABELS[key as PaletteLabelKey]); + } + return i18n._(PALETTE_COMMAND_LABELS[cmd.labelKey as PaletteLabelKey]); +} + +function busDispatch(cmd: CommandIdentity): (ctx: PaletteCommandContext) => void { + const action = cmd.menuActionId as OkMenuAction; + return (ctx) => ctx.emitMenuAction(action); +} + +function toPaletteCommand(cmd: CommandIdentity): PaletteCommand { + const presence = cmd.palette; + if (!presence) throw new Error(`command ${cmd.id} has no palette presence`); + const icon = COMMAND_ICONS[cmd.id]; + if (!icon) throw new Error(`command ${cmd.id} has no palette icon`); + const checkField = cmd.checkField; + return { + id: cmd.id, + menuActionId: cmd.menuActionId as OkMenuAction | undefined, + label: (ctx) => resolvePaletteLabel(cmd, ctx), + keywords: cmd.keywords, + icon, + group: presence.group, + visibility: presence.visibility, + shortcutId: cmd.shortcutId as KeyboardShortcutId | undefined, + shortcutDesktopOnly: cmd.shortcutDesktopOnly, + checked: checkField ? (ctx) => ctx.viewMenuState[checkField] === true : undefined, + available: (ctx) => evaluateCommandAvailability(cmd.availability, paletteCoreContext(ctx)), + dispatch: COMMAND_DISPATCH[cmd.id] ?? busDispatch(cmd), + }; +} + +export const PALETTE_COMMANDS: readonly PaletteCommand[] = COMMAND_IDENTITIES.flatMap((cmd) => + cmd.palette ? [toPaletteCommand(cmd)] : [], +); diff --git a/packages/app/src/lib/command-menu-parity.test.ts b/packages/app/src/lib/command-menu-parity.test.ts index f9c4ee5b7..c7b455fc6 100644 --- a/packages/app/src/lib/command-menu-parity.test.ts +++ b/packages/app/src/lib/command-menu-parity.test.ts @@ -18,11 +18,16 @@ import { describe, expect, test } from 'bun:test'; import { readdirSync, readFileSync } from 'node:fs'; import { join, relative } from 'node:path'; +import { COMMAND_IDENTITIES, type MenuPlatform } from '@inkeep/open-knowledge-core'; import { PALETTE_COMMANDS } from '@/components/command-palette-commands'; 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'; +import { + buildMenuTemplate, + MENU_BINDING_IDS, + 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). @@ -177,10 +182,19 @@ interface Leaf { label: string; role?: string; accelerator?: string; + // State-dependent output the registry now drives generically. Captured so the + // state-rendering tests below can pin the Show/Hide variant, smart-hide + // `visible`, and checkbox `type`/`checked`: a condition inversion in that + // generic code would otherwise stay green under the all-enabled sweep snapshot. + visible?: boolean; + itemType?: string; + checked?: boolean; } /** Recurse the template, collecting actionable leaves (skip separators, disabled - * placeholders, and submenu parents — which contribute their children). */ + * placeholders, and submenu parents — which contribute their children). A + * smart-hidden leaf (`visible: false`, still enabled) is retained so its + * `visible` state can be asserted. */ function collectLeaves(items: readonly MenuTemplateItem[], out: Leaf[]): void { for (const item of items) { if (item.type === 'separator') continue; @@ -191,16 +205,19 @@ function collectLeaves(items: readonly MenuTemplateItem[], out: Leaf[]): void { } if (item.enabled === false) continue; // disabled placeholder (e.g. "No recent projects") const accelerator = typeof item.accelerator === 'string' ? item.accelerator : undefined; + const visible = typeof item.visible === 'boolean' ? item.visible : undefined; + const checked = typeof item.checked === 'boolean' ? item.checked : undefined; if (item.role) { out.push({ label: typeof item.label === 'string' ? item.label : '', role: item.role, accelerator, + visible, }); continue; } if (typeof item.label === 'string') { - out.push({ label: item.label, accelerator }); + out.push({ label: item.label, accelerator, visible, itemType: item.type, checked }); } } } @@ -210,12 +227,15 @@ function normalizeLabel(label: string): string { return label.replace(/…$/, '').trim(); } -function collectLeavesForPlatform(platform: NodeJS.Platform): Leaf[] { +function collectLeavesForPlatform( + platform: NodeJS.Platform, + deps: MenuDeps = makeFullDeps(), +): Leaf[] { const original = process.platform; Object.defineProperty(process, 'platform', { value: platform, configurable: true }); try { const leaves: Leaf[] = []; - collectLeaves(buildMenuTemplate(makeFullDeps()), leaves); + collectLeaves(buildMenuTemplate(deps), leaves); return leaves; } finally { Object.defineProperty(process, 'platform', { value: original, configurable: true }); @@ -398,3 +418,210 @@ describe('command-menu parity ratchet', () => { expect(callSites.sort()).toEqual(['lib/local-menu-action-bus.ts']); }); }); + +// ─── Phase 2b: registry drives both surfaces ──────────────────────────────── +// The native menu now renders its command leaves from the same core registry +// (`COMMAND_IDENTITIES`) the palette does. These guard the registry itself, now +// that it is the single declaration point across menu + palette. +describe('command identity registry (Phase 2b)', () => { + const OK_MENU_ACTION_SET = new Set(OK_MENU_ACTIONS); + + test('every registry menuActionId is a real OkMenuAction', () => { + const bad = COMMAND_IDENTITIES.flatMap((cmd) => + cmd.menuActionId && !OK_MENU_ACTION_SET.has(cmd.menuActionId) ? [cmd.id] : [], + ); + expect(bad).toEqual([]); + }); + + test('every palette command without an override dispatch has a menuActionId to emit', () => { + // Bus-dispatched palette rows derive their dispatch from the id; a palette + // command that neither overrides dispatch nor carries a menuActionId would + // have nothing to emit. The known override ids are the dialog / bridge / + // renderer commands. + const OVERRIDES = new Set([ + 'new-file', + 'new-folder', + 'open-graph', + 'initialize-starter-pack', + 'new-project', + 'open-folder', + 'switch-project', + 'settings', + 'install-claude-desktop', + 'report-bug', + 'check-for-updates', + 'set-up-integrations', + 'toggle-spell-check', + 'open-github', + ]); + const missing = COMMAND_IDENTITIES.flatMap((cmd) => + cmd.palette && !OVERRIDES.has(cmd.id) && cmd.menuActionId === undefined ? [cmd.id] : [], + ); + expect(missing).toEqual([]); + }); + + test('every registry shortcutId resolves in the keyboard-shortcut registry', () => { + const bad = COMMAND_IDENTITIES.flatMap((cmd) => { + if (cmd.shortcutId === undefined) return []; + try { + formatShortcut(cmd.shortcutId as KeyboardShortcutId, 'mac'); + return []; + } catch { + return [cmd.id]; + } + }); + expect(bad).toEqual([]); + }); + + // Ratchet B, structural cure: with placement now DECLARED in the registry, a + // command that lists two placements resolving to the same platform is the + // hand-duplication class the "Check for updates" App+Help dupe belonged to. + // Flag it at the declaration level — earlier than the rendered-output dupe + // check above. A deliberate multi-placement earns an allowlist entry. + const DECLARED_MULTI_PLACEMENT = new Set([]); + + test('Ratchet B (declared): no command has two same-platform menu placements', () => { + const resolvesTo = (platform: 'mac' | 'other', p: MenuPlatform): boolean => + p === 'all' || p === platform; + const offenders = COMMAND_IDENTITIES.flatMap((cmd) => { + if (DECLARED_MULTI_PLACEMENT.has(cmd.id)) return []; + const placements = cmd.menu ?? []; + const macCount = placements.filter((p) => resolvesTo('mac', p.platform ?? 'all')).length; + const otherCount = placements.filter((p) => resolvesTo('other', p.platform ?? 'all')).length; + return macCount > 1 || otherCount > 1 ? [cmd.id] : []; + }); + expect(offenders).toEqual([]); + }); + + // The registry-driven menu decouples identity (core) from the desktop binding + // (click / enabled / presence / checkbox). A menu-placed command with no + // binding renders a leaf with no click handler, enabled by default — a silent + // no-op the optional-chained `MENU_BINDINGS[cmd.id]` lookup would not flag. + test('every menu-placed command has a MENU_BINDINGS entry', () => { + const missing = COMMAND_IDENTITIES.flatMap((cmd) => + cmd.menu && cmd.menu.length > 0 && !MENU_BINDING_IDS.has(cmd.id) ? [cmd.id] : [], + ); + expect(missing).toEqual([]); + }); + + test('every MENU_BINDINGS entry maps to a menu-placed command (no stale bindings)', () => { + const menuPlaced = new Set( + COMMAND_IDENTITIES.flatMap((cmd) => (cmd.menu && cmd.menu.length > 0 ? [cmd.id] : [])), + ); + const stale = [...MENU_BINDING_IDS].filter((id) => !menuPlaced.has(id)); + expect(stale).toEqual([]); + }); +}); + +// ─── Menu state-dependent rendering ───────────────────────────────────────── +// The registry drives the Show/Hide toggle label, the smart-hide `visible` +// flag, the checkbox `type`/`checked` state, and presence-gated absence through +// generic code in `buildCommandLeaves` / `menuLeafLabel`. The classification +// sweeps above run a single all-enabled `makeFullDeps` snapshot, so a condition +// inversion in that generic code (a flipped Show/Hide, availability mapped to +// `enabled` instead of `visible`, an inverted `checked`, a dropped presence +// gate) would render a user-visible menu regression while staying green. These +// pin each branch by building the template with the triggering state. +describe('menu state-dependent rendering', () => { + const findLeaf = (leaves: Leaf[], label: string): Leaf | undefined => + leaves.find((leaf) => normalizeLabel(leaf.label) === label); + + test('Show/Hide toggles render the Show variant when the panel is hidden', () => { + const leaves = collectLeavesForPlatform('darwin', { + ...makeFullDeps(), + sidebarVisible: false, + docPanelVisible: false, + terminalVisible: false, + }); + const labels = new Set(leaves.map((leaf) => normalizeLabel(leaf.label))); + expect(labels.has('Show sidebar')).toBe(true); + expect(labels.has('Show document panel')).toBe(true); + expect(labels.has('Show Terminal')).toBe(true); + // A flipped `visible ? hideKey : showKey` would leave the Hide variants here. + expect(labels.has('Hide sidebar')).toBe(false); + expect(labels.has('Hide document panel')).toBe(false); + expect(labels.has('Hide Terminal')).toBe(false); + }); + + test('Show/Hide toggles render the Hide variant when the panel is visible', () => { + const leaves = collectLeavesForPlatform('darwin', { + ...makeFullDeps(), + sidebarVisible: true, + docPanelVisible: true, + terminalVisible: true, + }); + const labels = new Set(leaves.map((leaf) => normalizeLabel(leaf.label))); + expect(labels.has('Hide sidebar')).toBe(true); + expect(labels.has('Show sidebar')).toBe(false); + }); + + test('smart-hide maps availability to `visible` (not `enabled`) for Expand/Collapse all', () => { + // canExpandAll:false → the Expand all leaf renders `visible:false` while + // staying enabled (its click dep is wired). Mapping availability to `enabled` + // instead would drop the leaf from the collected set (disabled-item filter), + // which the `toBeDefined` assertion catches. + const collapsedTree = collectLeavesForPlatform('darwin', { + ...makeFullDeps(), + canExpandAll: false, + canCollapseAll: true, + }); + const expandAll = findLeaf(collapsedTree, 'Expand all'); + expect(expandAll).toBeDefined(); + expect(expandAll?.visible).toBe(false); + expect(findLeaf(collapsedTree, 'Collapse all')?.visible).toBe(true); + + const expandedTree = collectLeavesForPlatform('darwin', { + ...makeFullDeps(), + canExpandAll: true, + canCollapseAll: false, + }); + expect(findLeaf(expandedTree, 'Expand all')?.visible).toBe(true); + expect(findLeaf(expandedTree, 'Collapse all')?.visible).toBe(false); + }); + + test('checkbox items carry `type: checkbox` and track the checked state', () => { + const checkedLeaf = findLeaf( + collectLeavesForPlatform('darwin', { ...makeFullDeps(), showHiddenFilesChecked: true }), + 'Show hidden files', + ); + expect(checkedLeaf?.itemType).toBe('checkbox'); + expect(checkedLeaf?.checked).toBe(true); + + const uncheckedLeaf = findLeaf( + collectLeavesForPlatform('darwin', { ...makeFullDeps(), showHiddenFilesChecked: false }), + 'Show hidden files', + ); + expect(uncheckedLeaf?.itemType).toBe('checkbox'); + expect(uncheckedLeaf?.checked).toBe(false); + }); + + test('presence-gated leaves disappear when their dep is unwired', () => { + // `check-for-updates` is presence-gated on `onCheckForUpdates`; dropping the + // dep removes the leaf entirely (not render it disabled) on both the macOS + // App-menu and the Windows/Linux Help-menu placement. + for (const platform of ['darwin', 'win32'] as const) { + expect( + findLeaf(collectLeavesForPlatform(platform, makeFullDeps()), 'Check for updates'), + ).toBeDefined(); + const withoutDep = collectLeavesForPlatform(platform, { + ...makeFullDeps(), + onCheckForUpdates: undefined, + }); + expect(findLeaf(withoutDep, 'Check for updates')).toBeUndefined(); + } + // `set-up-integrations` is presence-gated on `reconfigureMcpWiring`: unwiring + // it must remove the File-menu leaf, not render a non-functional MCP entry. + expect( + findLeaf( + collectLeavesForPlatform('darwin', makeFullDeps()), + 'Set up OpenKnowledge integrations', + ), + ).toBeDefined(); + expect( + findLeaf( + collectLeavesForPlatform('darwin', { ...makeFullDeps(), reconfigureMcpWiring: undefined }), + 'Set up OpenKnowledge integrations', + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/app/src/lib/menu-label-parity.test.ts b/packages/app/src/lib/menu-label-parity.test.ts index f8b12aa3d..b24f516e3 100644 --- a/packages/app/src/lib/menu-label-parity.test.ts +++ b/packages/app/src/lib/menu-label-parity.test.ts @@ -1,19 +1,29 @@ import { beforeAll, describe, expect, it } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { MENU_LABELS } from '@inkeep/open-knowledge-core'; +import { COMMAND_IDENTITIES, MENU_LABELS } from '@inkeep/open-knowledge-core'; +import { + PALETTE_COMMAND_LABELS, + type PaletteLabelKey, +} from '@/components/command-palette-commands'; +import { i18n } from '@/lib/i18n'; /** - * Parity guard for the file/tree labels that appear in BOTH the native Electron - * menu and the in-app renderer menus. + * Parity guard for the labels that appear in BOTH the native Electron menu and + * the in-app renderer (the Cmd+K palette). * * The native menu (`packages/desktop/src/main/menu.ts`) reads `MENU_LABELS` - * directly. The renderer wraps the SAME strings in Lingui `` / t`` - * macros — which require string literals, so the renderer can't import the - * constants — and those macros compile into this catalog. If a renderer label - * drifts from a shared constant (e.g. a casing change on one surface only), its - * value disappears from the catalog and this test fails, catching a divergence - * the native menu can't observe at runtime (it has no i18n). + * directly (via each command's registry `labelKey`). The palette maps the SAME + * `labelKey` to a Lingui `msg` descriptor in `PALETTE_COMMAND_LABELS` — the + * macro requires a string literal, so the renderer can't import the constants — + * and those descriptors compile into this catalog. This file asserts three + * things, so a drift the native menu can't observe at runtime (it has no i18n) + * turns the suite red: + * 1. every `MENU_LABELS` value is in the compiled catalog (the original guard); + * 2. every registry command's palette `labelKey` (and Show/Hide toggle keys) + * has a descriptor in `PALETTE_COMMAND_LABELS` (completeness); + * 3. every palette descriptor resolves to a string that is in the catalog and, + * where the key is also a `MENU_LABELS` key, equals the menu string. */ function collectStrings(node: unknown, out: Set): void { if (typeof node === 'string') { @@ -44,3 +54,40 @@ describe('shared menu labels stay in sync between the native menu and the render }); } }); + +describe('palette label map covers every registry labelKey (Phase 2b)', () => { + for (const cmd of COMMAND_IDENTITIES) { + if (!cmd.palette) continue; + const keys = cmd.stateToggle + ? [cmd.stateToggle.showKey, cmd.stateToggle.hideKey] + : cmd.labelKey !== undefined + ? [cmd.labelKey] + : []; + for (const key of keys) { + it(`palette command "${cmd.id}" label key "${key}" has a descriptor`, () => { + expect(key in PALETTE_COMMAND_LABELS).toBe(true); + }); + } + } +}); + +describe('every palette descriptor is in the catalog and agrees with MENU_LABELS', () => { + for (const [key, descriptor] of Object.entries(PALETTE_COMMAND_LABELS)) { + const paletteString = i18n._(descriptor); + it(`palette label "${key}" resolves to a catalog string`, () => { + expect(catalogStrings.has(paletteString)).toBe(true); + }); + if (key in MENU_LABELS) { + it(`palette label "${key}" equals MENU_LABELS.${key}`, () => { + expect(paletteString).toBe(MENU_LABELS[key as keyof typeof MENU_LABELS]); + }); + } + } + + it('every palette label key is a MENU_LABELS key (no orphan palette labels)', () => { + const orphans = (Object.keys(PALETTE_COMMAND_LABELS) as PaletteLabelKey[]).filter( + (key) => !(key in MENU_LABELS), + ); + expect(orphans).toEqual([]); + }); +}); diff --git a/packages/core/src/commands/command-identity.test.ts b/packages/core/src/commands/command-identity.test.ts new file mode 100644 index 000000000..8e87da611 --- /dev/null +++ b/packages/core/src/commands/command-identity.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from 'bun:test'; +import { MENU_LABELS } from '../constants/menu-labels.ts'; +import { + COMMAND_IDENTITIES, + type CommandContext, + evaluateCommandAvailability, +} from './command-identity.ts'; + +function ctx(overrides: Partial = {}): CommandContext { + return { + host: 'desktop', + activeTargetKind: 'doc', + singleFile: false, + terminalLive: false, + canExpandAll: true, + canCollapseAll: true, + hasActiveDoc: true, + showInstallSkill: true, + ...overrides, + }; +} + +describe('evaluateCommandAvailability', () => { + test('empty spec is always available', () => { + expect(evaluateCommandAvailability({}, ctx())).toBe(true); + expect(evaluateCommandAvailability({}, ctx({ host: 'web', activeTargetKind: 'none' }))).toBe( + true, + ); + }); + + test('host: desktop hides on web', () => { + expect(evaluateCommandAvailability({ host: 'desktop' }, ctx({ host: 'web' }))).toBe(false); + expect(evaluateCommandAvailability({ host: 'desktop' }, ctx({ host: 'desktop' }))).toBe(true); + expect(evaluateCommandAvailability({ host: 'all' }, ctx({ host: 'web' }))).toBe(true); + }); + + test('requiresTargetKinds gates on the projected kind', () => { + const spec = { requiresTargetKinds: ['doc', 'folder'] as const }; + expect(evaluateCommandAvailability(spec, ctx({ activeTargetKind: 'doc' }))).toBe(true); + expect(evaluateCommandAvailability(spec, ctx({ activeTargetKind: 'folder' }))).toBe(true); + expect(evaluateCommandAvailability(spec, ctx({ activeTargetKind: 'asset' }))).toBe(false); + expect(evaluateCommandAvailability(spec, ctx({ activeTargetKind: 'none' }))).toBe(false); + expect(evaluateCommandAvailability(spec, ctx({ activeTargetKind: 'project' }))).toBe(false); + }); + + // The load-bearing reconciliation: reveal/copy are actionable in the menu's + // project scope but hidden in the palette with no target — one spec, two + // contexts, because the menu projects project-scope to `project` and the + // palette projects no-target to `none`. + test('reveal/copy spec: project-scope actionable, no-target hidden', () => { + const spec = { requiresTargetKinds: ['doc', 'folder', 'asset', 'project'] as const }; + // Menu: project scope → project → available. + expect(evaluateCommandAvailability(spec, ctx({ activeTargetKind: 'project' }))).toBe(true); + // Palette: no target → none → hidden. + expect(evaluateCommandAvailability(spec, ctx({ activeTargetKind: 'none' }))).toBe(false); + }); + + test('singleFileHidden, terminalLive, expand/collapse, activeDoc, installSkill gates', () => { + expect(evaluateCommandAvailability({ singleFileHidden: true }, ctx({ singleFile: true }))).toBe( + false, + ); + expect( + evaluateCommandAvailability({ requiresTerminalLive: true }, ctx({ terminalLive: false })), + ).toBe(false); + expect( + evaluateCommandAvailability({ requiresCanExpandAll: true }, ctx({ canExpandAll: false })), + ).toBe(false); + expect( + evaluateCommandAvailability({ requiresCanCollapseAll: true }, ctx({ canCollapseAll: false })), + ).toBe(false); + expect( + evaluateCommandAvailability({ requiresActiveDoc: true }, ctx({ hasActiveDoc: false })), + ).toBe(false); + expect( + evaluateCommandAvailability({ requiresInstallSkill: true }, ctx({ showInstallSkill: false })), + ).toBe(false); + }); + + // Real commands combine gates (e.g. rename is host:desktop + requiresTargetKinds). + // Pin the multi-gate path so a reorder or short-circuit interaction can't pass. + test('compound gates: host AND target-kind must both pass', () => { + const spec = { host: 'desktop', requiresTargetKinds: ['doc'] } as const; + expect(evaluateCommandAvailability(spec, ctx({ host: 'web', activeTargetKind: 'doc' }))).toBe( + false, + ); + expect( + evaluateCommandAvailability(spec, ctx({ host: 'desktop', activeTargetKind: 'asset' })), + ).toBe(false); + expect( + evaluateCommandAvailability(spec, ctx({ host: 'desktop', activeTargetKind: 'doc' })), + ).toBe(true); + }); +}); + +describe('COMMAND_IDENTITIES registry invariants', () => { + test('command ids are unique', () => { + const ids = COMMAND_IDENTITIES.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test('menuActionIds are unique among commands that declare one', () => { + const actionIds = COMMAND_IDENTITIES.flatMap((c) => (c.menuActionId ? [c.menuActionId] : [])); + expect(new Set(actionIds).size).toBe(actionIds.length); + }); + + test('every palette command has a labelKey that resolves in MENU_LABELS', () => { + for (const cmd of COMMAND_IDENTITIES) { + if (!cmd.palette) continue; + expect(cmd.labelKey).toBeDefined(); + expect(MENU_LABELS[cmd.labelKey as keyof typeof MENU_LABELS]).toBeDefined(); + } + }); + + test('every menu placement labelKey / stateToggle key resolves in MENU_LABELS', () => { + for (const cmd of COMMAND_IDENTITIES) { + if (cmd.stateToggle) { + expect(MENU_LABELS[cmd.stateToggle.showKey]).toBeDefined(); + expect(MENU_LABELS[cmd.stateToggle.hideKey]).toBeDefined(); + } + for (const placement of cmd.menu ?? []) { + if (placement.menuLabelKey) expect(MENU_LABELS[placement.menuLabelKey]).toBeDefined(); + // A menu leaf must resolve to SOME label: an explicit literal, a menu + // key override, or the command's own labelKey. + const resolvable = + placement.menuLabelText !== undefined || + placement.menuLabelKey !== undefined || + cmd.stateToggle !== undefined || + cmd.labelKey !== undefined; + expect({ id: cmd.id, resolvable }).toEqual({ id: cmd.id, resolvable: true }); + } + } + }); + + // Structural cure precondition: no command declares two placements that + // resolve to the same platform (that is how a leaf gets hand-duplicated). The + // palette-side Ratchet B extension asserts this against the rendered menu too. + test('no command has two menu placements for the same platform', () => { + for (const cmd of COMMAND_IDENTITIES) { + const platforms = (cmd.menu ?? []).map((p) => p.platform ?? 'all'); + const macCount = platforms.filter((p) => p === 'all' || p === 'mac').length; + const otherCount = platforms.filter((p) => p === 'all' || p === 'other').length; + expect({ id: cmd.id, macCount: macCount <= 1, otherCount: otherCount <= 1 }).toEqual({ + id: cmd.id, + macCount: true, + otherCount: true, + }); + } + }); +}); diff --git a/packages/core/src/commands/command-identity.ts b/packages/core/src/commands/command-identity.ts new file mode 100644 index 000000000..41e892d04 --- /dev/null +++ b/packages/core/src/commands/command-identity.ts @@ -0,0 +1,633 @@ +import type { MenuLabelKey } from '../constants/menu-labels.ts'; + +/** + * Shared, serializable command identity for OpenKnowledge's global command + * surfaces — the ONE declaration point the native Electron menu + * (`packages/desktop/src/main/menu.ts`) and the Cmd+K palette + * (`packages/app/src/components/command-palette-commands.ts`) both render from. + * + * This module is CORE: browser + node safe, Lingui-free, `tsdown`-built (no + * macro transform). So every field here is PLAIN DATA — no functions, no + * `lucide-react` icons, no Lingui `msg` descriptors. Each surface joins this + * identity with its own presentation (the menu adds plain labels + click deps; + * the palette adds `msg` labels + `lucide` icons + dispatch closures). + * + * `menuActionId` and `shortcutId` are typed as bare strings here because their + * unions (`OkMenuAction`, `KeyboardShortcutId`) live in the desktop/app + * packages, which core cannot import. The app/desktop wrappers narrow them, and + * the parity ratchets (`command-menu-parity.test.ts`) assert every id is real. + */ + +/** Palette render buckets; the palette renders each group under its own heading. */ +export type CommandGroup = 'commands' | 'project' | 'file' | 'view' | 'terminal' | 'app'; + +/** + * Projected active-target kind, shared by both surfaces' availability contexts. + * `project` is the menu's project-scope signal (a window is open on a project, + * no file selected — contentDir is still an actionable target); `none` is the + * palette's "no target" signal. Keeping them distinct lets one availability + * spec drive both surfaces: `reveal-in-finder` / copy-path are actionable in + * project scope (menu) yet hidden with no target (palette), which is exactly + * `requiresTargetKinds` including `project` but not `none`. + */ +export type ContextualTargetKind = 'doc' | 'folder' | 'asset' | 'project' | 'none'; + +/** Host gate. `all` = web + desktop; `desktop` hides on the web host. */ +export type CommandHostScope = 'all' | 'desktop'; + +/** + * Declarative availability. The pure {@link evaluateCommandAvailability} + * evaluates this against a {@link CommandContext}; the menu maps the result to + * `enabled` (or `visible`, for smart-hidden tree commands) and the palette maps + * it to whether the row renders. Every field references DATA only, so this + * predicate stays core-safe: a declarative spec + pure evaluator, never a + * renderer-reaching predicate. + */ +export interface CommandAvailabilitySpec { + /** Host gate; defaults to `all`. */ + readonly host?: CommandHostScope; + /** Active target must project to one of these kinds. */ + readonly requiresTargetKinds?: readonly ContextualTargetKind[]; + /** Hidden in a no-project single-file session. */ + readonly singleFileHidden?: boolean; + /** Requires a live (mounted) terminal session. */ + readonly requiresTerminalLive?: boolean; + /** Requires an expandable tree (smart-hide when everything is expanded). */ + readonly requiresCanExpandAll?: boolean; + /** Requires a collapsible tree (smart-hide when everything is collapsed). */ + readonly requiresCanCollapseAll?: boolean; + /** Requires an active document (e.g. Open graph). */ + readonly requiresActiveDoc?: boolean; + /** Requires the install-skill feature flag (`SHOW_INSTALL_SKILL`). */ + readonly requiresInstallSkill?: boolean; +} + +/** + * The DATA a command needs to decide availability, assembled per render by each + * surface: the menu from the IPC-pushed active-target snapshot + platform; the + * palette from `DocumentContext` + the view-menu-state store. Booleans are + * pre-normalized by the caller (e.g. `canExpandAll = raw !== false`, + * `terminalLive = raw === true`) so the evaluator stays a plain membership test. + */ +export interface CommandContext { + readonly host: 'desktop' | 'web'; + readonly activeTargetKind: ContextualTargetKind; + readonly singleFile: boolean; + readonly terminalLive: boolean; + readonly canExpandAll: boolean; + readonly canCollapseAll: boolean; + readonly hasActiveDoc: boolean; + readonly showInstallSkill: boolean; +} + +/** Pure availability predicate. No side effects, no renderer/desktop concepts. */ +export function evaluateCommandAvailability( + spec: CommandAvailabilitySpec, + ctx: CommandContext, +): boolean { + if (spec.host === 'desktop' && ctx.host !== 'desktop') return false; + if (spec.singleFileHidden && ctx.singleFile) return false; + if (spec.requiresTerminalLive && !ctx.terminalLive) return false; + if (spec.requiresCanExpandAll && !ctx.canExpandAll) return false; + if (spec.requiresCanCollapseAll && !ctx.canCollapseAll) return false; + if (spec.requiresActiveDoc && !ctx.hasActiveDoc) return false; + if (spec.requiresInstallSkill && !ctx.showInstallSkill) return false; + if (spec.requiresTargetKinds && !spec.requiresTargetKinds.includes(ctx.activeTargetKind)) { + return false; + } + return true; +} + +/** Which platform's menu bar a placement targets. `all` = both. */ +export type MenuPlatform = 'all' | 'mac' | 'other'; + +/** + * Named slots in the native menu's declarative scaffolding. `buildMenuTemplate` + * owns the roles / separators / submenu parents / recents around these; each + * command leaf declares which slot it lands in and at what order. A command may + * declare more than one placement (e.g. Settings / Check for updates are + * platform-XOR: one `mac` placement, one `other`), which is how the + * "Check for updates" duplicate becomes data instead of a hand-branch. + */ +export type MenuSection = + | 'app-updates' + | 'app-settings' + | 'app-uninstall' + | 'file-create' + | 'file-project' + | 'file-worktree' + | 'file-item' + | 'file-reveal' + | 'file-copy-path' + | 'file-integrations' + | 'file-settings' + | 'file-close' + | 'edit-spell' + | 'view-panels' + | 'view-visibility' + | 'view-tree' + | 'terminal' + | 'help-install' + | 'help-links' + | 'help-updates'; + +export interface CommandMenuPlacement { + readonly section: MenuSection; + /** Sort key within the section (menu leaves render in ascending order). */ + readonly order: number; + /** Platform gate; defaults to `all`. */ + readonly platform?: MenuPlatform; + /** Electron accelerator, single-sourced here; a parity test asserts it agrees + * with the app's keyboard-shortcut registry (the two share no import). */ + readonly accelerator?: string; + /** Append the native "opens a new surface" ellipsis (…) at render time. */ + readonly ellipsis?: boolean; + /** Render as an Electron checkbox item. */ + readonly checkbox?: boolean; + /** Smart-hide: map availability to `visible` (not `enabled`) — Expand/Collapse all. */ + readonly smartHide?: boolean; + /** + * Menu label key override into `MENU_LABELS`, when the native menu renders a + * different label than the palette (e.g. Copy path's children are "Full path" + * / "Relative path" in the menu, "Copy full path" / "Copy relative path" in + * the palette). Defaults to the command's `labelKey`. + */ + readonly menuLabelKey?: MenuLabelKey; + /** + * Literal native-menu label, for the handful of menu strings that are NOT in + * the shared `MENU_LABELS` parity contract: menu-only leaves (Uninstall, New + * Terminal Window) and two leaves whose native label predates the app's + * sentence-case convention and stays byte-identical here (Report a Bug, + * Install…(desktop app)). Takes precedence over `menuLabelKey`. + */ + readonly menuLabelText?: string; +} + +/** Show/Hide state-toggle labels (single menu row whose label flips on state). */ +export interface CommandStateToggle { + readonly showKey: MenuLabelKey; + readonly hideKey: MenuLabelKey; + /** View-menu-state field driving the label. */ + readonly stateField: 'sidebarVisible' | 'docPanelVisible' | 'terminalVisible'; + /** Menu default when the state is unknown (sidebar/doc-panel start visible → "Hide"). */ + readonly defaultVisible: boolean; +} + +/** View-menu-state checkbox field a command's check indicator reads. */ +export type CommandCheckField = + | 'showHiddenFiles' + | 'showOkFolders' + | 'showOnlyMarkdownFiles' + | 'showSkillsSection'; + +/** Palette presentation hints (serializable subset; icon/label/dispatch are app-side). */ +export interface CommandPalettePresence { + readonly group: CommandGroup; + /** `always` renders on empty open; `search-only` renders only under a matching query. */ + readonly visibility: 'always' | 'search-only'; +} + +export interface CommandIdentity { + /** Stable id; the palette DOM testid is `command-palette-${id}`. */ + readonly id: string; + /** The `OkMenuAction` this command dispatches, when it routes through the menu-action bus. */ + readonly menuActionId?: string; + /** + * Canonical label key (into `MENU_LABELS`) — the palette's label + the menu's + * default. Omitted for menu-only leaves that render a literal `menuLabelText` + * (Uninstall, New Terminal Window) with no palette row. + */ + readonly labelKey?: MenuLabelKey; + /** Extra `matchesCommandQuery` tokens beyond the label. Not localized. */ + readonly keywords: readonly string[]; + /** Keyboard-shortcut id whose accelerator the palette renders (`formatShortcut`). */ + readonly shortcutId?: string; + /** The shortcut only fires via a native-menu accelerator (no web keydown). */ + readonly shortcutDesktopOnly?: boolean; + /** Show/Hide toggle metadata (sidebar / document panel / terminal). */ + readonly stateToggle?: CommandStateToggle; + /** Checkbox check-state field (palette check indicator + menu checked source). */ + readonly checkField?: CommandCheckField; + readonly availability: CommandAvailabilitySpec; + /** Palette presence (omitted for menu-only commands: send-to-ai, uninstall, new-terminal-window). */ + readonly palette?: CommandPalettePresence; + /** Native-menu placement(s) (omitted for palette-only commands: open-graph, initialize-starter-pack). */ + readonly menu?: readonly CommandMenuPlacement[]; +} + +/** + * The command identity registry. Order matches the palette's row order + * within each group (the palette filters by group and preserves array order). + * Menu-only commands are interleaved near their menu neighbors. + */ +export const COMMAND_IDENTITIES: readonly CommandIdentity[] = [ + // ── Commands group (palette) / File-create (menu) ─────────────────────────── + { + id: 'new-file', + menuActionId: 'new-doc', + labelKey: 'newFile', + keywords: ['create file'], + shortcutId: 'new-item', + availability: {}, + palette: { group: 'commands', visibility: 'always' }, + menu: [{ section: 'file-create', order: 0, accelerator: 'CmdOrCtrl+N' }], + }, + { + id: 'new-folder', + menuActionId: 'new-folder', + labelKey: 'newFolder', + keywords: ['create folder'], + shortcutId: 'new-folder', + shortcutDesktopOnly: true, + availability: {}, + palette: { group: 'commands', visibility: 'always' }, + menu: [{ section: 'file-create', order: 1, accelerator: 'CmdOrCtrl+Shift+N' }], + }, + { + id: 'open-graph', + labelKey: 'openGraph', + keywords: ['graph panel network'], + availability: { requiresActiveDoc: true }, + palette: { group: 'commands', visibility: 'always' }, + }, + { + id: 'initialize-starter-pack', + labelKey: 'initializeStarterPack', + keywords: ['scaffold', 'seed', 'pack', 'starter'], + availability: {}, + palette: { group: 'commands', visibility: 'always' }, + }, + // ── Project group (palette) / File-project (menu) ─────────────────────────── + { + id: 'new-project', + menuActionId: 'new-project', + labelKey: 'newProject', + keywords: ['create new project scaffold'], + availability: { host: 'desktop' }, + palette: { group: 'project', visibility: 'always' }, + menu: [{ section: 'file-project', order: 0, ellipsis: true }], + }, + { + id: 'open-folder', + labelKey: 'openFolderOnDisk', + keywords: ['project'], + shortcutId: 'open-folder', + availability: { host: 'desktop' }, + palette: { group: 'project', visibility: 'always' }, + menu: [ + { + section: 'file-project', + order: 2, + accelerator: 'CmdOrCtrl+O', + ellipsis: true, + menuLabelKey: 'openFolder', + }, + ], + }, + { + id: 'switch-project', + labelKey: 'switchProject', + keywords: ['switch project navigator projects'], + shortcutId: 'switch-project', + availability: { host: 'desktop', singleFileHidden: true }, + palette: { group: 'project', visibility: 'always' }, + menu: [{ section: 'file-project', order: 1, accelerator: 'CmdOrCtrl+Shift+P', ellipsis: true }], + }, + { + id: 'settings', + labelKey: 'settings', + keywords: ['preferences config'], + shortcutId: 'settings', + availability: { singleFileHidden: true }, + palette: { group: 'project', visibility: 'always' }, + menu: [ + { + section: 'app-settings', + order: 0, + platform: 'mac', + accelerator: 'CmdOrCtrl+,', + ellipsis: true, + }, + { + section: 'file-settings', + order: 0, + platform: 'other', + accelerator: 'CmdOrCtrl+,', + ellipsis: true, + }, + ], + }, + { + id: 'install-claude-desktop', + labelKey: 'installClaudeDesktop', + keywords: ['claude desktop install cowork'], + availability: { requiresInstallSkill: true }, + palette: { group: 'project', visibility: 'always' }, + menu: [ + { + section: 'help-install', + order: 0, + ellipsis: true, + menuLabelText: 'Install for Claude Chat & Cowork (desktop app)', + }, + ], + }, + { + id: 'report-bug', + menuActionId: 'report-bug', + labelKey: 'reportBug', + keywords: ['bug report issue feedback problem'], + availability: { host: 'desktop' }, + palette: { group: 'project', visibility: 'always' }, + menu: [{ section: 'help-links', order: 1, ellipsis: true, menuLabelText: 'Report a Bug' }], + }, + // ── File group (palette) / File-item + worktree (menu) ────────────────────── + { + id: 'new-from-template', + menuActionId: 'new-from-template', + labelKey: 'newFromTemplate', + keywords: ['template', 'create', 'new'], + availability: { singleFileHidden: true }, + palette: { group: 'file', visibility: 'search-only' }, + menu: [{ section: 'file-create', order: 2, ellipsis: true }], + }, + { + id: 'rename', + menuActionId: 'rename', + labelKey: 'rename', + keywords: ['rename', 'file', 'folder'], + availability: { host: 'desktop', requiresTargetKinds: ['doc', 'folder', 'asset'] }, + palette: { group: 'file', visibility: 'search-only' }, + menu: [{ section: 'file-item', order: 1 }], + }, + { + id: 'duplicate', + menuActionId: 'duplicate', + labelKey: 'duplicate', + keywords: ['duplicate', 'copy', 'file', 'folder'], + shortcutId: 'file-tree-duplicate', + availability: { host: 'desktop', requiresTargetKinds: ['doc', 'folder'] }, + palette: { group: 'file', visibility: 'search-only' }, + menu: [{ section: 'file-item', order: 0, accelerator: 'CmdOrCtrl+D' }], + }, + { + id: 'move-to-trash', + menuActionId: 'move-to-trash', + labelKey: 'moveToTrash', + keywords: ['delete', 'trash', 'remove'], + shortcutId: 'file-tree-delete', + availability: { host: 'desktop', requiresTargetKinds: ['doc', 'folder', 'asset'] }, + palette: { group: 'file', visibility: 'search-only' }, + menu: [{ section: 'file-item', order: 2, accelerator: 'CmdOrCtrl+Delete' }], + }, + { + id: 'reveal-in-finder', + menuActionId: 'reveal-in-finder', + labelKey: 'revealInFinder', + keywords: ['finder', 'reveal', 'show', 'file'], + availability: { host: 'desktop', requiresTargetKinds: ['doc', 'folder', 'asset', 'project'] }, + palette: { group: 'file', visibility: 'search-only' }, + menu: [{ section: 'file-reveal', order: 0 }], + }, + { + // Menu-only "Open with AI" leaf. The palette surfaces send-to-ai as a + // bespoke per-target Open-with-AI group, not a fixed registry row. + id: 'send-to-ai', + menuActionId: 'send-to-ai', + labelKey: 'openWithAi', + keywords: ['ai', 'agent', 'handoff'], + availability: { host: 'desktop', requiresTargetKinds: ['doc', 'folder', 'project'] }, + menu: [{ section: 'file-reveal', order: 1 }], + }, + { + id: 'copy-full-path', + menuActionId: 'copy-full-path', + labelKey: 'copyFullPath', + keywords: ['copy', 'path', 'absolute', 'full'], + availability: { host: 'desktop', requiresTargetKinds: ['doc', 'folder', 'asset', 'project'] }, + palette: { group: 'file', visibility: 'search-only' }, + menu: [{ section: 'file-copy-path', order: 0, menuLabelKey: 'fullPath' }], + }, + { + id: 'copy-relative-path', + menuActionId: 'copy-relative-path', + labelKey: 'copyRelativePath', + keywords: ['copy', 'path', 'relative'], + availability: { host: 'desktop', requiresTargetKinds: ['doc', 'folder', 'asset', 'project'] }, + palette: { group: 'file', visibility: 'search-only' }, + menu: [{ section: 'file-copy-path', order: 1, menuLabelKey: 'relativePath' }], + }, + { + id: 'close-tab', + menuActionId: 'close-active-tab-or-window', + labelKey: 'closeTab', + keywords: ['close', 'tab', 'window'], + availability: { host: 'desktop' }, + palette: { group: 'file', visibility: 'search-only' }, + menu: [{ section: 'file-close', order: 0, platform: 'mac', accelerator: 'CmdOrCtrl+W' }], + }, + { + id: 'new-worktree', + menuActionId: 'new-worktree', + labelKey: 'newWorktree', + keywords: ['worktree', 'branch', 'new'], + availability: { host: 'desktop' }, + palette: { group: 'file', visibility: 'search-only' }, + menu: [{ section: 'file-worktree', order: 0, ellipsis: true }], + }, + { + id: 'switch-worktree', + menuActionId: 'switch-worktree', + labelKey: 'switchWorktree', + keywords: ['worktree', 'switch', 'branch'], + availability: { host: 'desktop' }, + palette: { group: 'file', visibility: 'search-only' }, + menu: [{ section: 'file-worktree', order: 1, ellipsis: true }], + }, + // ── View group (palette) / View-panels + visibility + tree (menu) ─────────── + { + id: 'toggle-sidebar', + menuActionId: 'toggle-sidebar', + labelKey: 'sidebarHide', + keywords: ['sidebar', 'files', 'panel', 'toggle'], + shortcutId: 'toggle-files-sidebar', + stateToggle: { + showKey: 'sidebarShow', + hideKey: 'sidebarHide', + stateField: 'sidebarVisible', + defaultVisible: true, + }, + availability: {}, + palette: { group: 'view', visibility: 'search-only' }, + menu: [{ section: 'view-panels', order: 0, accelerator: 'CmdOrCtrl+Alt+S' }], + }, + { + id: 'toggle-doc-panel', + menuActionId: 'toggle-doc-panel', + labelKey: 'docPanelHide', + keywords: ['document', 'panel', 'info', 'toggle'], + shortcutId: 'toggle-document-panel', + stateToggle: { + showKey: 'docPanelShow', + hideKey: 'docPanelHide', + stateField: 'docPanelVisible', + defaultVisible: true, + }, + availability: {}, + palette: { group: 'view', visibility: 'search-only' }, + menu: [{ section: 'view-panels', order: 1, accelerator: 'CmdOrCtrl+Alt+B' }], + }, + { + id: 'toggle-terminal', + menuActionId: 'toggle-terminal', + labelKey: 'terminalShow', + keywords: ['terminal', 'shell', 'console', 'toggle'], + shortcutId: 'toggle-terminal-panel', + stateToggle: { + showKey: 'terminalShow', + hideKey: 'terminalHide', + stateField: 'terminalVisible', + defaultVisible: false, + }, + availability: { host: 'desktop' }, + palette: { group: 'view', visibility: 'search-only' }, + menu: [{ section: 'view-panels', order: 2, accelerator: 'CmdOrCtrl+J' }], + }, + { + id: 'toggle-show-hidden-files', + menuActionId: 'toggle-show-hidden-files', + labelKey: 'showHiddenFiles', + keywords: ['hidden', 'dotfiles', 'files', 'show'], + checkField: 'showHiddenFiles', + availability: {}, + palette: { group: 'view', visibility: 'search-only' }, + menu: [ + { section: 'view-visibility', order: 0, accelerator: 'CmdOrCtrl+Shift+.', checkbox: true }, + ], + }, + { + id: 'toggle-show-ok-folders', + menuActionId: 'toggle-show-ok-folders', + labelKey: 'showOkFolders', + keywords: ['ok', 'folders', 'hidden', 'show'], + checkField: 'showOkFolders', + availability: {}, + palette: { group: 'view', visibility: 'search-only' }, + menu: [{ section: 'view-visibility', order: 1, checkbox: true }], + }, + { + id: 'toggle-show-only-markdown-files', + menuActionId: 'toggle-show-only-markdown-files', + labelKey: 'showOnlyMarkdownFiles', + keywords: ['markdown', 'filter', 'files', 'only'], + checkField: 'showOnlyMarkdownFiles', + availability: {}, + palette: { group: 'view', visibility: 'search-only' }, + menu: [{ section: 'view-visibility', order: 2, checkbox: true }], + }, + { + id: 'toggle-show-skills-section', + menuActionId: 'toggle-show-skills-section', + labelKey: 'showSkillsSection', + keywords: ['skills', 'section', 'sidebar', 'show'], + checkField: 'showSkillsSection', + availability: {}, + palette: { group: 'view', visibility: 'search-only' }, + menu: [{ section: 'view-visibility', order: 3, checkbox: true }], + }, + { + id: 'expand-all-tree', + menuActionId: 'expand-all-tree', + labelKey: 'expandAll', + keywords: ['expand', 'tree', 'folders', 'all'], + availability: { requiresCanExpandAll: true }, + palette: { group: 'view', visibility: 'search-only' }, + menu: [{ section: 'view-tree', order: 0, smartHide: true }], + }, + { + id: 'collapse-all-tree', + menuActionId: 'collapse-all-tree', + labelKey: 'collapseAll', + keywords: ['collapse', 'tree', 'folders', 'all'], + availability: { requiresCanCollapseAll: true }, + palette: { group: 'view', visibility: 'search-only' }, + menu: [{ section: 'view-tree', order: 1, smartHide: true }], + }, + // ── Terminal group (palette) / Terminal (menu) ────────────────────────────── + { + id: 'new-terminal', + menuActionId: 'new-terminal', + labelKey: 'newTerminal', + keywords: ['terminal', 'shell', 'new', 'tab'], + availability: { host: 'desktop' }, + palette: { group: 'terminal', visibility: 'search-only' }, + menu: [{ section: 'terminal', order: 0 }], + }, + { + // Menu-only leaf; opens a dedicated terminal window in main (no renderer handler). + id: 'new-terminal-window', + keywords: [], + availability: { host: 'desktop' }, + menu: [{ section: 'terminal', order: 1, menuLabelText: 'New Terminal Window' }], + }, + { + id: 'kill-terminal', + menuActionId: 'kill-terminal', + labelKey: 'killTerminal', + keywords: ['terminal', 'kill', 'close', 'session'], + availability: { host: 'desktop', requiresTerminalLive: true }, + palette: { group: 'terminal', visibility: 'search-only' }, + menu: [{ section: 'terminal', order: 2 }], + }, + // ── Application group (palette) / App + Edit + Help (menu) ─────────────────── + { + id: 'check-for-updates', + labelKey: 'checkForUpdates', + keywords: ['update', 'upgrade', 'version', 'check'], + availability: { host: 'desktop' }, + palette: { group: 'app', visibility: 'search-only' }, + menu: [ + { section: 'app-updates', order: 0, platform: 'mac', ellipsis: true }, + { section: 'help-updates', order: 0, platform: 'other', ellipsis: true }, + ], + }, + { + id: 'set-up-integrations', + labelKey: 'setUpIntegrations', + keywords: ['integrations', 'mcp', 'setup', 'claude', 'configure'], + availability: { host: 'desktop' }, + palette: { group: 'app', visibility: 'search-only' }, + menu: [{ section: 'file-integrations', order: 0, ellipsis: true }], + }, + { + id: 'toggle-spell-check', + labelKey: 'checkSpelling', + keywords: ['spell', 'spelling', 'check', 'typing'], + availability: { host: 'desktop' }, + palette: { group: 'app', visibility: 'search-only' }, + menu: [{ section: 'edit-spell', order: 0, checkbox: true }], + }, + { + id: 'open-github', + labelKey: 'openOnGithub', + keywords: ['github', 'source', 'repository', 'code'], + availability: {}, + palette: { group: 'app', visibility: 'search-only' }, + menu: [{ section: 'help-links', order: 0 }], + }, + { + // Menu-only leaf; macOS App menu, presence-gated + rare/destructive. + id: 'uninstall', + keywords: [], + availability: { host: 'desktop' }, + menu: [ + { + section: 'app-uninstall', + order: 0, + platform: 'mac', + ellipsis: true, + menuLabelText: 'Uninstall OpenKnowledge', + }, + ], + }, +]; diff --git a/packages/core/src/constants/menu-labels.ts b/packages/core/src/constants/menu-labels.ts index 8d1ee7e3a..8cd48757b 100644 --- a/packages/core/src/constants/menu-labels.ts +++ b/packages/core/src/constants/menu-labels.ts @@ -1,7 +1,11 @@ /** - * Canonical sentence-case labels for file/tree actions that appear in BOTH the - * native Electron menu bar (`packages/desktop/src/main/menu.ts`) and the in-app - * renderer menus (`FileTree.tsx` / `FileSidebar.tsx`). + * Canonical sentence-case labels the shared command registry + * (`@inkeep/open-knowledge-core/commands`) points its `labelKey` at. Most + * surface in BOTH the native Electron menu bar (`packages/desktop/src/main/menu.ts`) + * and the in-app renderer (`FileTree.tsx` / `FileSidebar.tsx` and the Cmd+K + * palette); a few (e.g. `openGraph`, `initializeStarterPack`, the Show/Hide + * toggle variants) are palette-only, kept here so the label-parity test guards + * them and the registry keeps a single label source. * * Why a shared constant: the same action surfaces twice and the two copies * must read identically. The native menu has no i18n runtime, so it imports @@ -16,8 +20,8 @@ * (`packages/app/scripts/audit-strings/check-casing.ts`). Proper nouns keep * their capitals (Finder, Terminal, AI). Native menu items that open a new * surface append the platform ellipsis (…) per the Apple HIG — that suffix is - * native-only and is added at the menu.ts call site, not stored here (same - * split as `SWITCH_PROJECT_LABEL_WITH_ELLIPSIS` in the desktop package). + * native-only and is added at the menu render site (the command registry's + * per-placement `ellipsis` flag), not stored here. */ export const MENU_LABELS = { newFile: 'New file', @@ -58,7 +62,34 @@ export const MENU_LABELS = { killTerminal: 'Kill Terminal', checkSpelling: 'Check spelling while typing', openOnGithub: 'OpenKnowledge on GitHub', -} as const; + // Additional shared command labels the registry references. Each string is + // rendered identically by the palette, so the parity test stays green. The + // native menu appends the … ellipsis at its call site where the command opens + // a new surface (Switch project…, Settings…). + switchProject: 'Switch project', + settings: 'Settings', + // Open folder's palette row is the descriptive "Open folder on disk"; the + // native File menu renders the terser "Open folder…" via `openFolder` above. + openFolderOnDisk: 'Open folder on disk', + // Palette-only commands (no native-menu leaf); shared here so the label-parity + // test guards them and the registry keeps a single label source. + openGraph: 'Open graph', + initializeStarterPack: 'Initialize starter pack', + // State-aware Show/Hide toggles — both surfaces render both variants; the + // native menu is a single row whose label flips on the pushed view-menu-state. + sidebarShow: 'Show sidebar', + sidebarHide: 'Hide sidebar', + docPanelShow: 'Show document panel', + docPanelHide: 'Hide document panel', + terminalShow: 'Show Terminal', + terminalHide: 'Hide Terminal', + // Palette forms for the two commands whose native-menu leaf renders a + // different literal via the placement's `menuLabelText` ("Report a Bug" keeps + // its capital B; the install leaf renders "…(desktop app)" lowercase), so only + // these palette strings participate in the shared-label parity contract. + reportBug: 'Report a bug', + installClaudeDesktop: 'Install for Claude Chat & Cowork (Desktop App)', +} as const satisfies Record; export type MenuLabelKey = keyof typeof MENU_LABELS; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a40ca50b6..41a4cb215 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -62,6 +62,24 @@ export { PROTOCOL_VERSION } from './protocol-version.ts'; // Re-export VFileMessage for Observer B's error classification (instanceof check // instead of fragile constructor.name string comparison). export { VFileMessage } from 'vfile-message'; +// Shared command identity — the single declaration point the native menu and +// the Cmd+K palette both render from. Plain data + a pure evaluator. +export { + COMMAND_IDENTITIES, + type CommandAvailabilitySpec, + type CommandCheckField, + type CommandContext, + type CommandGroup, + type CommandHostScope, + type CommandIdentity, + type CommandMenuPlacement, + type CommandPalettePresence, + type CommandStateToggle, + type ContextualTargetKind, + evaluateCommandAvailability, + type MenuPlatform, + type MenuSection, +} from './commands/command-identity.ts'; // Headless config writers + UI ConfigBinding. // Browser+node compatible — no Node deps; structural ConfigDocProvider type // keeps `@hocuspocus/provider` out of core's runtime deps. diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts index 91a38e9bf..164905a17 100644 --- a/packages/desktop/src/main/menu.ts +++ b/packages/desktop/src/main/menu.ts @@ -32,14 +32,20 @@ */ import { + COMMAND_IDENTITIES, + type CommandContext, + type CommandIdentity, + type CommandMenuPlacement, + type ContextualTargetKind, + evaluateCommandAvailability, MENU_LABELS, + type MenuSection, 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'; -import { SWITCH_PROJECT_LABEL_WITH_ELLIPSIS } from '../shared/labels.ts'; import { promptForExistingFolder } from './dialog-helpers.ts'; export interface MenuDeps { @@ -331,10 +337,300 @@ export async function installApplicationMenu(deps: MenuDeps): Promise { Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } -/** Exported for unit testing — pure function over deps. */ +/** + * Desktop-side binding for each command identity: how the native menu derives a + * leaf's click / enabled / presence / checkbox state from the injected + * `MenuDeps`. This is the "click handlers stay wired by id" layer — a leaf's + * label, accelerator, placement, and availability all come from the shared + * registry (`@inkeep/open-knowledge-core`), and only this desktop glue lives + * here. + * + * `enabled` is the dep-presence gate ANDed with the registry availability (an + * unwired dep disables the leaf — the unit-test-safe behavior the menu has + * always had; in production every dep is wired, so availability drives it). + * `present` omits the leaf entirely — capability/flag-gated items that render as + * absence, not disabled. Both default to "always". + */ +interface MenuCommandBinding { + click?(deps: MenuDeps): MenuItemConstructorOptions['click']; + enabled?(deps: MenuDeps): boolean; + present?(deps: MenuDeps): boolean; + checked?(deps: MenuDeps): boolean; +} + +const MENU_BINDINGS: Record = { + 'new-file': { click: (d) => () => d.onNewFile?.(), enabled: (d) => d.onNewFile !== undefined }, + 'new-folder': { + click: (d) => () => d.onNewFolder?.(), + enabled: (d) => d.onNewFolder !== undefined, + }, + 'new-from-template': { + click: (d) => () => d.onNewFromTemplate?.(), + enabled: (d) => d.onNewFromTemplate !== undefined, + }, + 'new-project': { + click: (d) => () => d.onNewProject?.(), + enabled: (d) => d.onNewProject !== undefined, + }, + // Switch project / Open folder are always enabled — their deps are required. + 'switch-project': { click: (d) => () => d.openNavigator() }, + 'open-folder': { + click: (d) => async () => { + // Shared with the `ok:dialog:open-folder` IPC handler so both call sites + // agree on dialog options forever — see dialog-helpers. + const picked = await promptForExistingFolder(d.dialog); + if (picked) { + await d.openProject(picked, 'pick-existing'); + } + }, + }, + 'new-worktree': { + click: (d) => () => d.onNewWorktree?.(), + enabled: (d) => d.onNewWorktree !== undefined, + }, + 'switch-worktree': { + click: (d) => () => d.onSwitchWorktree?.(), + enabled: (d) => d.onSwitchWorktree !== undefined, + }, + duplicate: { click: (d) => () => d.onDuplicate?.(), enabled: (d) => d.onDuplicate !== undefined }, + rename: { click: (d) => () => d.onRename?.(), enabled: (d) => d.onRename !== undefined }, + 'move-to-trash': { + click: (d) => () => d.onMoveToTrash?.(), + enabled: (d) => d.onMoveToTrash !== undefined, + }, + 'reveal-in-finder': { + click: (d) => () => d.onRevealInFinder?.(), + enabled: (d) => d.onRevealInFinder !== undefined, + }, + 'send-to-ai': { + click: (d) => () => d.onSendToAi?.(), + enabled: (d) => d.onSendToAi !== undefined, + }, + 'copy-full-path': { + click: (d) => () => d.onCopyFullPath?.(), + enabled: (d) => d.onCopyFullPath !== undefined, + }, + 'copy-relative-path': { + click: (d) => () => d.onCopyRelativePath?.(), + enabled: (d) => d.onCopyRelativePath !== undefined, + }, + // Re-trigger first-launch MCP consent. Presence-gated: non-macOS / non-packaged + // contexts (where MCP wiring no-ops anyway) plumb `undefined` and hide the row. + 'set-up-integrations': { + click: (d) => () => { + void d.reconfigureMcpWiring?.(); + }, + present: (d) => d.reconfigureMcpWiring !== undefined, + }, + settings: { click: (d) => () => d.openSettings?.() }, + 'close-tab': { + click: (d) => () => d.onCloseActiveTabOrWindow?.(), + enabled: (d) => d.onCloseActiveTabOrWindow !== undefined, + }, + // Presence-gated on the booted updater handle; a bare click reference matches + // the runtime shape Electron invokes for a shortcut-less item. + 'check-for-updates': { + click: (d) => d.onCheckForUpdates, + present: (d) => d.onCheckForUpdates !== undefined, + }, + uninstall: { click: (d) => () => d.onUninstall?.(), present: (d) => d.onUninstall !== undefined }, + 'new-terminal': { + click: (d) => () => d.onNewTerminal?.(), + enabled: (d) => d.onNewTerminal !== undefined, + }, + 'new-terminal-window': { + click: (d) => () => d.onNewTerminalWindow?.(), + enabled: (d) => d.onNewTerminalWindow !== undefined, + }, + 'kill-terminal': { + click: (d) => () => d.onKillTerminal?.(), + enabled: (d) => d.onKillTerminal !== undefined, + }, + 'toggle-spell-check': { + click: (d) => () => d.onToggleSpellCheck?.(), + enabled: (d) => d.onToggleSpellCheck !== undefined, + checked: (d) => d.spellCheckEnabled ?? true, + }, + 'toggle-sidebar': { + click: (d) => () => d.onToggleSidebar?.(), + enabled: (d) => d.onToggleSidebar !== undefined, + }, + 'toggle-doc-panel': { + click: (d) => () => d.onToggleDocPanel?.(), + enabled: (d) => d.onToggleDocPanel !== undefined, + }, + 'toggle-terminal': { + click: (d) => () => d.onToggleTerminal?.(), + enabled: (d) => d.onToggleTerminal !== undefined, + }, + 'toggle-show-hidden-files': { + click: (d) => () => d.onToggleShowHiddenFiles?.(), + enabled: (d) => d.onToggleShowHiddenFiles !== undefined, + checked: (d) => d.showHiddenFilesChecked ?? false, + }, + 'toggle-show-ok-folders': { + click: (d) => () => d.onToggleShowOkFolders?.(), + enabled: (d) => d.onToggleShowOkFolders !== undefined, + checked: (d) => d.showOkFoldersChecked ?? false, + }, + 'toggle-show-only-markdown-files': { + click: (d) => () => d.onToggleShowOnlyMarkdownFiles?.(), + enabled: (d) => d.onToggleShowOnlyMarkdownFiles !== undefined, + checked: (d) => d.showOnlyMarkdownFilesChecked ?? false, + }, + 'toggle-show-skills-section': { + click: (d) => () => d.onToggleShowSkillsSection?.(), + enabled: (d) => d.onToggleShowSkillsSection !== undefined, + // Skills section is default-on, so the unwired checkbox reads checked. + checked: (d) => d.showSkillsSectionChecked ?? true, + }, + 'expand-all-tree': { + click: (d) => () => d.onExpandAll?.(), + enabled: (d) => d.onExpandAll !== undefined, + }, + 'collapse-all-tree': { + click: (d) => () => d.onCollapseAll?.(), + enabled: (d) => d.onCollapseAll !== undefined, + }, + 'open-github': { click: (d) => () => d.openExternalUrl(OPEN_KNOWLEDGE_GITHUB_URL) }, + // Report a Bug always renders + is enabled; the click no-ops when unwired. + 'report-bug': { click: (d) => () => d.onReportBug?.() }, + 'install-claude-desktop': { + click: (d) => () => d.openInstallSkillDialog?.(), + present: () => SHOW_INSTALL_SKILL, + }, +}; + +/** + * The command ids that carry a desktop binding. Exported so the parity ratchet + * can assert every menu-placed registry command has one: a command with a `menu` + * placement but no binding would render a leaf with no click handler, enabled by + * default — a silent no-op the optional-chained lookup in `buildCommandLeaves` + * would not catch. + */ +export const MENU_BINDING_IDS: ReadonlySet = new Set(Object.keys(MENU_BINDINGS)); + +/** Project the pushed active-target snapshot onto the shared gating kind. The + * menu maps project scope (and the pre-first-push `undefined`) to `project`: + * contentDir is still an actionable target, so reveal / copy-path stay enabled + * there, while rename / duplicate / trash (which require a real file) do not. */ +function menuTargetKind(target: EditorActiveTargetSnapshot | undefined): ContextualTargetKind { + if (target === undefined || target.kind === null) return 'project'; + return target.kind; +} + +function menuCommandContext(deps: MenuDeps): CommandContext { + return { + host: 'desktop', + activeTargetKind: menuTargetKind(deps.activeTarget), + // The native menu has no single-file concept; those commands never gate here. + singleFile: false, + terminalLive: deps.terminalLive === true, + canExpandAll: deps.canExpandAll ?? true, + canCollapseAll: deps.canCollapseAll ?? true, + // No Open-graph leaf in the menu; the field is palette-only. + hasActiveDoc: false, + showInstallSkill: SHOW_INSTALL_SKILL, + }; +} + +/** Resolve a menu leaf's plain-string label: an explicit literal, the Show/Hide + * toggle variant driven by the pushed view-menu-state, a per-placement key + * override (Copy path's children), or the command's own `labelKey`. */ +function menuLeafLabel( + cmd: CommandIdentity, + placement: CommandMenuPlacement, + deps: MenuDeps, +): string { + if (placement.menuLabelText !== undefined) return placement.menuLabelText; + if (cmd.stateToggle) { + const { stateField, defaultVisible, showKey, hideKey } = cmd.stateToggle; + const visible = deps[stateField] ?? defaultVisible; + return MENU_LABELS[visible ? hideKey : showKey]; + } + const key = placement.menuLabelKey ?? cmd.labelKey; + if (key === undefined) { + throw new Error(`command ${cmd.id} menu leaf has no resolvable label`); + } + return MENU_LABELS[key]; +} + +/** Generate the actionable command leaves for the current platform, grouped by + * their declared menu section. `buildMenuTemplate` slots each group into the + * declarative scaffolding (roles / separators / submenu parents / recents). */ +function buildCommandLeaves( + deps: MenuDeps, + isMac: boolean, +): Map { + const platform = isMac ? 'mac' : 'other'; + const ctx = menuCommandContext(deps); + const staged = new Map>(); + for (const cmd of COMMAND_IDENTITIES) { + if (!cmd.menu) continue; + const binding = MENU_BINDINGS[cmd.id]; + for (const placement of cmd.menu) { + const plat = placement.platform ?? 'all'; + if (plat !== 'all' && plat !== platform) continue; + if (binding?.present && !binding.present(deps)) continue; + const available = evaluateCommandAvailability(cmd.availability, ctx); + const depWired = binding?.enabled ? binding.enabled(deps) : true; + const label = menuLeafLabel(cmd, placement, deps); + const item: MenuItemConstructorOptions = { + label: placement.ellipsis ? `${label}…` : label, + }; + const click = binding?.click?.(deps); + if (click !== undefined) item.click = click; + if (placement.accelerator !== undefined) item.accelerator = placement.accelerator; + if (placement.checkbox === true) { + item.type = 'checkbox'; + item.checked = binding?.checked ? binding.checked(deps) : false; + } + if (placement.smartHide === true) { + // Smart-hide (Expand/Collapse all): availability maps to `visible`, so a + // fully-expanded tree hides the no-op affordance rather than disabling it. + item.visible = available; + item.enabled = depWired; + } else { + item.enabled = available && depWired; + } + const list = staged.get(placement.section) ?? []; + list.push({ order: placement.order, item }); + staged.set(placement.section, list); + } + } + const result = new Map(); + for (const [section, entries] of staged) { + entries.sort((a, b) => a.order - b.order); + result.set( + section, + entries.map((e) => e.item), + ); + } + return result; +} + +/** Append a trailing separator iff the section rendered any leaves (so a + * presence-gated section contributes neither leaf nor stray separator). */ +function withTrailingSep(items: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] { + return items.length > 0 ? [...items, { type: 'separator' as const }] : []; +} + +/** Prepend a leading separator iff the section rendered any leaves. */ +function withLeadingSep(items: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] { + return items.length > 0 ? [{ type: 'separator' as const }, ...items] : []; +} + +/** + * Exported for unit testing — pure function over deps. The actionable command + * leaves are generated from the shared registry (`buildCommandLeaves`); the + * roles, separators, submenu parents (Recent project, Copy path), the dynamic + * recents rows, and the `isMac` platform branches stay declarative here. + */ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { const isMac = process.platform === 'darwin'; const recents = deps.getRecentProjects(); + const leaves = buildCommandLeaves(deps, isMac); + const leafOf = (section: MenuSection): MenuItemConstructorOptions[] => leaves.get(section) ?? []; const recentSubmenu: MenuItemConstructorOptions[] = recents.length === 0 @@ -363,28 +659,14 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] submenu: [ { role: 'about' as const }, { type: 'separator' as const }, - // Apple HIG canonical placement: "Check for updates…" - // immediately under About in the application menu. Hidden - // when the updater handle isn't available (dev mode, boot - // failure) — see MenuDeps.onCheckForUpdates rationale. - ...(deps.onCheckForUpdates - ? ([ - { - label: `${MENU_LABELS.checkForUpdates}\u2026`, - click: deps.onCheckForUpdates, - }, - { type: 'separator' as const }, - ] satisfies MenuItemConstructorOptions[]) - : []), - // Apple HIG places "Settings…" / "Preferences…" immediately - // before the services group. The CmdOrCtrl+, accelerator is - // OS-captured: Electron routes the keypress to this menu - // item before it reaches the renderer's keydown handler. - { - label: 'Settings…', - accelerator: 'CmdOrCtrl+,', - click: () => deps.openSettings?.(), - }, + // Apple HIG: "Check for updates…" under About, then "Settings…". + // Both are platform-conditional placements in the shared registry + // (the App-menu placement is `mac`-only; each has a mirror `other` + // placement in the File / Help menu), so the leaf renders exactly + // once per platform — the previously hand-branched dedupe, now + // expressed as data. + ...withTrailingSep(leafOf('app-updates')), + ...leafOf('app-settings'), { type: 'separator' as const }, { role: 'services' as const }, { type: 'separator' as const }, @@ -392,15 +674,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { role: 'hideOthers' as const }, { role: 'unhide' as const }, { type: 'separator' as const }, - ...(deps.onUninstall - ? ([ - { - label: 'Uninstall OpenKnowledge…', - click: () => deps.onUninstall?.(), - }, - { type: 'separator' as const }, - ] satisfies MenuItemConstructorOptions[]) - : []), + ...withTrailingSep(leafOf('app-uninstall')), { role: 'quit' as const }, ], }, @@ -410,172 +684,36 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'File', submenu: [ - // Creation items head the File menu (New file / New folder / New from - // template), then the project section, then the item-management actions - // (Duplicate / Rename / Move to Trash). Each item-management action is - // enable/disabled per `activeTarget` so a project-scope window doesn't - // surface Rename / Duplicate / Move to Trash with no target; asset scope - // enables Rename / Move to Trash but keeps Duplicate disabled. - { - label: MENU_LABELS.newFile, - accelerator: 'CmdOrCtrl+N', - enabled: deps.onNewFile !== undefined, - click: () => deps.onNewFile?.(), - }, - { - label: MENU_LABELS.newFolder, - accelerator: 'CmdOrCtrl+Shift+N', - enabled: deps.onNewFolder !== undefined, - click: () => deps.onNewFolder?.(), - }, - { - label: `${MENU_LABELS.newFromTemplate}\u2026`, - enabled: deps.onNewFromTemplate !== undefined, - click: () => deps.onNewFromTemplate?.(), - }, + // Creation items head the File menu; then the project section (mirrors + // the in-app ProjectSwitcher order: Recent → New project → Switch → + // Open folder); then worktrees; then the activeTarget-gated + // item-management actions; then reveal / AI / copy-path. Every command + // leaf comes from the registry — the separators, the Recent-project + // submenu, and the Copy-path submenu parent stay declarative here. + ...leafOf('file-create'), { type: 'separator' }, - // Project section — order mirrors the in-app ProjectSwitcher - // (packages/app/src/components/ProjectSwitcher.tsx) so the native menu - // and the sidebar footer read identically. The recents submenu and the - // create / switch / open wiring are unchanged; only position, order, - // and labels moved here. { label: 'Recent project', submenu: recentSubmenu, }, - { - // Scaffolds a brand-new project (opens the create-new-project dialog). - // No `activeTarget` gate — creating a project doesn't depend on scope. - label: `${MENU_LABELS.newProject}\u2026`, - enabled: deps.onNewProject !== undefined, - click: () => deps.onNewProject?.(), - }, - { - label: SWITCH_PROJECT_LABEL_WITH_ELLIPSIS, - // Rebound from Cmd+Shift+N (now New folder) to Cmd+Shift+P per macOS - // HIG for navigation/palette-style commands. - accelerator: 'CmdOrCtrl+Shift+P', - click: () => deps.openNavigator(), - }, - { - label: `${MENU_LABELS.openFolder}\u2026`, - accelerator: 'CmdOrCtrl+O', - click: async () => { - // Shared with the `ok:dialog:open-folder` IPC handler so both call - // sites agree on dialog options forever — see dialog-helpers. - const picked = await promptForExistingFolder(deps.dialog); - if (picked) { - await deps.openProject(picked, 'pick-existing'); - } - }, - }, + ...leafOf('file-project'), { type: 'separator' }, - // Worktree selector (worktree = window). Mirrors the sidebar - // ProjectSwitcher's worktree section — both delegate to the same - // renderer surface. Disabled (not hidden) in the Navigator window and - // in unit-test contexts, where the deps aren't wired. - { - label: `${MENU_LABELS.newWorktree}\u2026`, - enabled: deps.onNewWorktree !== undefined, - click: () => deps.onNewWorktree?.(), - }, - { - label: `${MENU_LABELS.switchWorktree}\u2026`, - enabled: deps.onSwitchWorktree !== undefined, - click: () => deps.onSwitchWorktree?.(), - }, + ...leafOf('file-worktree'), { type: 'separator' }, - { - label: MENU_LABELS.duplicate, - accelerator: 'CmdOrCtrl+D', - enabled: - deps.onDuplicate !== undefined && - deps.activeTarget !== undefined && - (deps.activeTarget.kind === 'doc' || deps.activeTarget.kind === 'folder'), - click: () => deps.onDuplicate?.(), - }, - { - label: MENU_LABELS.rename, - // Project scope (kind === null) has no target to rename \u2192 disabled. - enabled: - deps.onRename !== undefined && - deps.activeTarget !== undefined && - deps.activeTarget.kind !== null, - click: () => deps.onRename?.(), - }, - { - label: MENU_LABELS.moveToTrash, - accelerator: 'CmdOrCtrl+Delete', - enabled: - deps.onMoveToTrash !== undefined && - deps.activeTarget !== undefined && - deps.activeTarget.kind !== null, - click: () => deps.onMoveToTrash?.(), - }, + ...leafOf('file-item'), { type: 'separator' }, - { - label: MENU_LABELS.revealInFinder, - enabled: deps.onRevealInFinder !== undefined, - click: () => deps.onRevealInFinder?.(), - }, - { - label: MENU_LABELS.openWithAi, - enabled: deps.onSendToAi !== undefined && deps.activeTarget?.kind !== 'asset', - click: () => deps.onSendToAi?.(), - }, + ...leafOf('file-reveal'), { label: MENU_LABELS.copyPath, enabled: deps.onCopyFullPath !== undefined || deps.onCopyRelativePath !== undefined, - submenu: [ - { - label: MENU_LABELS.fullPath, - enabled: deps.onCopyFullPath !== undefined, - click: () => deps.onCopyFullPath?.(), - }, - { - label: MENU_LABELS.relativePath, - enabled: deps.onCopyRelativePath !== undefined, - click: () => deps.onCopyRelativePath?.(), - }, - ], + submenu: leafOf('file-copy-path'), }, { type: 'separator' }, - // Re-trigger first-launch MCP consent. The dep is optional so - // non-macOS / non-packaged contexts (where MCP wiring no-ops - // anyway) hide the row. `deps` - // plumbs `undefined` when the runtime has nothing to offer. - ...(deps.reconfigureMcpWiring - ? ([ - { - label: `${MENU_LABELS.setUpIntegrations}\u2026`, - click: () => { - void deps.reconfigureMcpWiring?.(); - }, - }, - { type: 'separator' as const }, - ] satisfies MenuItemConstructorOptions[]) - : []), - // On Windows/Linux Settings… belongs in the File menu (Apple - // HIG only governs macOS; on macOS the Settings entry lives in the - // App menu above). Placed above the trailing close/quit role. - ...(!isMac - ? ([ - { - label: 'Settings…', - accelerator: 'CmdOrCtrl+,', - click: () => deps.openSettings?.(), - }, - { type: 'separator' as const }, - ] satisfies MenuItemConstructorOptions[]) - : []), - isMac - ? { - label: MENU_LABELS.closeTab, - accelerator: 'CmdOrCtrl+W', - enabled: deps.onCloseActiveTabOrWindow !== undefined, - click: () => deps.onCloseActiveTabOrWindow?.(), - } - : { role: 'quit' }, + ...withTrailingSep(leafOf('file-integrations')), + // On Windows/Linux Settings… belongs in the File menu (macOS renders it + // in the App menu above); its registry placement is `other`-only. + ...withTrailingSep(leafOf('file-settings')), + ...(isMac ? leafOf('file-close') : [{ role: 'quit' as const }]), ], }, @@ -590,120 +728,26 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { role: 'paste' }, { role: 'selectAll' }, { type: 'separator' }, - // macOS Edit-menu spell-check toggle. `checked` is derived from the - // persisted app-wide flag at build time; toggling (here or from the - // in-editor context menu) rebuilds the menu so the checkmark stays in - // sync. No accelerator — macOS has no canonical one for this item. - { - label: MENU_LABELS.checkSpelling, - type: 'checkbox', - checked: deps.spellCheckEnabled ?? true, - enabled: deps.onToggleSpellCheck !== undefined, - click: () => deps.onToggleSpellCheck?.(), - }, + ...leafOf('edit-spell'), ], }, { label: 'View', submenu: [ - // Reload / Force Reload ship on every channel — a page-level recovery - // affordance for stable users (parity with Electron apps like Claude - // Code Desktop), not a dev-only tool. Toggle Developer Tools stays - // gated on `showDevToolsMenu` (dev + beta), so stable doesn't expose - // the raw web inspector. + // Reload / Force Reload ship on every channel; Toggle Developer Tools is + // gated on `showDevToolsMenu` (dev + beta) — Electron built-in roles. { role: 'reload' as const }, { role: 'forceReload' as const }, ...(deps.showDevToolsMenu ? ([{ role: 'toggleDevTools' as const }] satisfies MenuItemConstructorOptions[]) : []), { type: 'separator' as const }, - // Sidebar visibility toggle. Apple HIG convention: a single row whose - // label flips between "Show sidebar" / "Hide sidebar" based on the - // current state (Finder's pattern), rather than a checkbox row. ⌥⌘S - // is Apple's canonical accelerator for sidebar toggle — ⌘B is Bold in - // the TipTap editor, and ⌘\ is non-standard. Spelled `CmdOrCtrl+Alt+S` - // (not `Option+Cmd+S`): Electron renders `Alt` as ⌥ and `CmdOrCtrl` as - // ⌘ on macOS, so this is the identical ⌥⌘S here while staying the - // cross-platform-safe form the sibling accelerators use. The accelerator - // is OS-captured: Electron routes the keypress to this menu item before - // it reaches the renderer's keydown handler, so the renderer's - // shadcn sidebar primitive does NOT also bind a window keydown. - { - label: deps.sidebarVisible === false ? 'Show sidebar' : 'Hide sidebar', - accelerator: 'CmdOrCtrl+Alt+S', - enabled: deps.onToggleSidebar !== undefined, - click: () => deps.onToggleSidebar?.(), - }, - { - label: deps.docPanelVisible === false ? 'Show document panel' : 'Hide document panel', - accelerator: 'CmdOrCtrl+Alt+B', - enabled: deps.onToggleDocPanel !== undefined, - click: () => deps.onToggleDocPanel?.(), - }, - { - // Terminal starts hidden, so falsy/undefined reads "Show Terminal". - // ⌘J / Ctrl+J is OS-captured (fires before the renderer), so a - // focused xterm can't swallow it — same model as the sidebar item. - label: deps.terminalVisible ? 'Hide Terminal' : 'Show Terminal', - accelerator: 'CmdOrCtrl+J', - enabled: deps.onToggleTerminal !== undefined, - click: () => deps.onToggleTerminal?.(), - }, + ...leafOf('view-panels'), { type: 'separator' }, - // Sidebar visibility toggles. Checkbox state mirrors the sidebar's - // tree-options popover + empty-space context menu, in the same order. - // Toggling any of them flips the projectLocalBinding flag via the - // renderer's menu-action handler; the resulting CRDT write propagates - // back through merged config so every surface stays in sync. The - // unwired `checked` fallbacks match the renderer's resolved config - // defaults (hidden files + .ok folders + only-markdown off, skills - // section on). - { - label: MENU_LABELS.showHiddenFiles, - accelerator: 'CmdOrCtrl+Shift+.', - type: 'checkbox', - checked: deps.showHiddenFilesChecked ?? false, - enabled: deps.onToggleShowHiddenFiles !== undefined, - click: () => deps.onToggleShowHiddenFiles?.(), - }, - { - label: MENU_LABELS.showOkFolders, - type: 'checkbox', - checked: deps.showOkFoldersChecked ?? false, - enabled: deps.onToggleShowOkFolders !== undefined, - click: () => deps.onToggleShowOkFolders?.(), - }, - { - label: MENU_LABELS.showOnlyMarkdownFiles, - type: 'checkbox', - checked: deps.showOnlyMarkdownFilesChecked ?? false, - enabled: deps.onToggleShowOnlyMarkdownFiles !== undefined, - click: () => deps.onToggleShowOnlyMarkdownFiles?.(), - }, - { - label: MENU_LABELS.showSkillsSection, - type: 'checkbox', - checked: deps.showSkillsSectionChecked ?? true, - enabled: deps.onToggleShowSkillsSection !== undefined, - click: () => deps.onToggleShowSkillsSection?.(), - }, + ...leafOf('view-visibility'), { type: 'separator' }, - // Tree-scoped Expand/Collapse all. Smart-hide via `visible: false` - // (rather than `enabled: false`) so a fully-expanded tree doesn't - // render a useless enabled-with-no-op Expand all affordance. - { - label: MENU_LABELS.expandAll, - visible: deps.canExpandAll ?? true, - enabled: deps.onExpandAll !== undefined, - click: () => deps.onExpandAll?.(), - }, - { - label: MENU_LABELS.collapseAll, - visible: deps.canCollapseAll ?? true, - enabled: deps.onCollapseAll !== undefined, - click: () => deps.onCollapseAll?.(), - }, + ...leafOf('view-tree'), { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, @@ -715,37 +759,8 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { // Top-level Terminal menu (VS Code placement, between View and Window). - // The discoverable home for terminal actions; the View → Show/Hide - // Terminal toggle stays as-is to keep the established ⌘J muscle memory. label: 'Terminal', - submenu: [ - { - // Opens a new terminal tab each click (revealing the dock if hidden; - // never hides). No accelerator here: ⌘J belongs to the View → - // 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: MENU_LABELS.newTerminal, - enabled: deps.onNewTerminal !== undefined, - click: () => deps.onNewTerminal?.(), - }, - { - // Opens a dedicated terminal window (1:1 with VS Code's "New Terminal - // Window"). No accelerator — VS Code ships the command unbound, and - // ⌘J stays the docked Show/Hide Terminal toggle. - label: 'New Terminal Window', - enabled: deps.onNewTerminalWindow !== undefined, - click: () => deps.onNewTerminalWindow?.(), - }, - { - // 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: MENU_LABELS.killTerminal, - enabled: deps.onKillTerminal !== undefined && deps.terminalLive === true, - click: () => deps.onKillTerminal?.(), - }, - ], + submenu: leafOf('terminal'), }, { @@ -765,38 +780,11 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] { label: 'Help', submenu: [ - ...(SHOW_INSTALL_SKILL - ? ([ - { - label: 'Install for Claude Chat & Cowork (desktop app)…', - click: () => deps.openInstallSkillDialog?.(), - }, - { type: 'separator' as const }, - ] satisfies MenuItemConstructorOptions[]) - : []), - { - label: MENU_LABELS.openOnGithub, - click: () => deps.openExternalUrl(OPEN_KNOWLEDGE_GITHUB_URL), - }, - { - label: 'Report a Bug…', - click: () => deps.onReportBug?.(), - }, - // "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: `${MENU_LABELS.checkForUpdates}\u2026`, - click: deps.onCheckForUpdates, - }, - ] satisfies MenuItemConstructorOptions[]) - : []), + ...withTrailingSep(leafOf('help-install')), + ...leafOf('help-links'), + // macOS gets the Apple-HIG App-menu placement above; Windows/Linux have + // no application menu, so Help is the convention there (leading sep). + ...withLeadingSep(leafOf('help-updates')), ], }, ]; diff --git a/packages/desktop/src/shared/labels.ts b/packages/desktop/src/shared/labels.ts deleted file mode 100644 index b18be1d42..000000000 --- a/packages/desktop/src/shared/labels.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * User-facing string constants surfaced from the desktop app's main process. - * - * Inclusion rule: native-OS menu items consumed only by `main/menu.ts`. Other - * surfaces (renderer dropdowns, command palette, CLI) inline their literals. - * - * Keep this file zero-dep — it loads in main, preload, and any test runner. - */ - -/** - * macOS / Windows / Linux native menu bar item for re-summoning the Project - * Navigator (`File → Switch project…`, accelerator `Cmd+Shift+N`). Consumed - * only by `packages/desktop/src/main/menu.ts`. - * - * The trailing `…` is the native-menu convention for "opens a new surface" - * (Apple HIG / Windows HIG / GTK). Renderer-side surfaces (ProjectSwitcher - * dropdown, CommandPalette row) render the bare `Switch project` label — - * `…` is reserved for native menus and truncation indicators only. - */ -export const SWITCH_PROJECT_LABEL_WITH_ELLIPSIS = 'Switch project…' as const;