diff --git a/AGENTS.md b/AGENTS.md index 4ad9984d..3676ea94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,13 @@ plannotator/ │ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.) │ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views │ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser +│ │ ├── shortcuts/ # Keyboard shortcut registry (see Keyboard Shortcuts section below) +│ │ │ ├── core.ts # Engine: parser, formatter, dispatcher, validator +│ │ │ ├── runtime.ts # Engine: useShortcutScope, useDoubleTapShortcuts hooks +│ │ │ ├── index.ts # Barrel — re-exports engine + scopes from both subfolders +│ │ │ ├── plan-review/ # Scopes for plan-editor surfaces (annotationToolbar, annotationPanel, commentPopover, imageAnnotator, inputMethod, viewer) +│ │ │ └── code-review/ # Scopes for review-editor surfaces (ai, allFilesDiff, annotationToolbar, fileTree, prComments, suggestionModal, tourDialog) +│ │ ├── shortcuts.test.ts # Registry unit tests (parser, dispatcher, validator) │ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts │ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts │ │ └── types.ts @@ -60,9 +67,12 @@ plannotator/ │ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only) │ │ ├── draft.ts # Annotation draft persistence (node:fs only) │ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName) -│ ├── editor/ # Plan review App.tsx +│ ├── editor/ # Plan review app +│ │ ├── App.tsx # Main plan review app +│ │ └── shortcuts.ts # planReviewSurface + annotateSurface — composes plan-review scopes into per-surface registries │ └── review-editor/ # Code review UI │ ├── App.tsx # Main review app +│ ├── shortcuts.ts # codeReviewSurface — composes code-review scopes into the review registry │ ├── components/ # DiffViewer, FileTree, ReviewSidebar │ ├── dock/ # Dockview center panel infrastructure │ ├── demoData.ts # Demo diff for standalone mode @@ -380,6 +390,20 @@ interface Block { Text highlighting uses `web-highlighter` library. Code blocks use manual `` wrapping (web-highlighter can't select inside `
`).
 
+## Keyboard Shortcuts
+
+**Location:** `packages/ui/shortcuts/` (engine + scope data), `packages/editor/shortcuts.ts` and `packages/review-editor/shortcuts.ts` (per-app surfaces).
+
+The shortcut system has three layers:
+
+1. **Engine** (`packages/ui/shortcuts/{core,runtime}.ts`) — parser for declarative bindings (`Mod+Enter`, `Alt Alt` double-tap, `Alt hold`), dispatcher, platform-aware formatter (mac glyphs vs. `Ctrl`), validator, and the `useShortcutScope` / `useDoubleTapShortcuts` React hooks. Truly shared — both apps use it as-is.
+2. **Scopes** — `defineShortcutScope({ id, title, shortcuts: { actionId: { bindings, description, section, ... } } })`. One scope per UI surface (annotation toolbar, comment popover, file tree, etc.). Lives in `packages/ui/shortcuts/{plan-review,code-review}/` — **the subfolder names which app's UI the scope serves**. Components/Apps wire handlers to a scope via `useShortcutScope({ scope, handlers: { actionId: () => ... } })`.
+3. **Surfaces** (`packages/editor/shortcuts.ts`, `packages/review-editor/shortcuts.ts`) — each app composes its scopes into a `ShortcutSurface` (`planReviewSurface`, `annotateSurface`, `codeReviewSurface`). Surfaces feed both the in-app help modal and the marketing site's auto-generated docs page.
+
+**Convention for adding new shortcuts:** define the action in the relevant scope file under the right subfolder (`plan-review/` or `code-review/`), declare the binding(s) and description, then wire a handler at the call site with `useShortcutScope`. The marketing docs page picks it up automatically at next build. Unit tests in `packages/ui/shortcuts.test.ts` enforce normalized binding tokens (`Mod`, `Shift`, `Alt`, `A-Z`, `1-0`, named keys, `F1`–`F12`) and unique scope ids.
+
+**Marketing docs auto-generation:** `apps/marketing/src/lib/shortcutReference.ts` reads the three surfaces and `apps/marketing/src/components/ShortcutReference.astro` renders them as tables. The `/docs/reference/keyboard-shortcuts` page is special-cased in `apps/marketing/src/pages/docs/[...slug].astro` to render the component instead of the markdown body.
+
 ## URL Sharing
 
 **Location:** `packages/ui/utils/sharing.ts`, `packages/ui/hooks/useSharing.ts`
@@ -482,6 +506,8 @@ Running only `build:opencode` will copy stale HTML files.
 
 `apps/marketing/` is the plannotator.ai website — landing page, documentation, and blog. Built with Astro 5 (static output, zero client JS except a theme toggle island). Docs are markdown files in `src/content/docs/`, blog posts in `src/content/blog/`, both using Astro content collections. Tailwind CSS v4 via `@tailwindcss/vite`. Deploys to S3/CloudFront via GitHub Actions on push to main.
 
+The `/docs/reference/keyboard-shortcuts` page is auto-generated from the shortcut registry at build time — see the Keyboard Shortcuts section above. Editing the markdown body has no effect; update the scope files instead.
+
 ## Test plugin locally
 
 ```
diff --git a/apps/marketing/src/components/ShortcutReference.astro b/apps/marketing/src/components/ShortcutReference.astro
new file mode 100644
index 00000000..83663b3d
--- /dev/null
+++ b/apps/marketing/src/components/ShortcutReference.astro
@@ -0,0 +1,37 @@
+---
+import { shortcutReferenceSurfaces } from '../lib/shortcutReference';
+import { formatShortcutBindingsText } from '../../../../packages/ui/shortcuts';
+---
+
+

Keyboard shortcuts available across the Plannotator review and annotation UIs.

+ +{shortcutReferenceSurfaces.map((surface) => ( + +

{surface.title}

+

{surface.description}

+ + {surface.sections.map((section) => ( + +

{section.title}

+ + + + + + + + + + {section.shortcuts.map((shortcut) => ( + + + + + + ))} + +
ShortcutActionNotes
{formatShortcutBindingsText(shortcut.bindings)}{shortcut.description}{shortcut.hint ?? '—'}
+
+ ))} +
+))} diff --git a/apps/marketing/src/content/docs/reference/keyboard-shortcuts.md b/apps/marketing/src/content/docs/reference/keyboard-shortcuts.md index 91d2ac93..df6e3d9f 100644 --- a/apps/marketing/src/content/docs/reference/keyboard-shortcuts.md +++ b/apps/marketing/src/content/docs/reference/keyboard-shortcuts.md @@ -6,22 +6,14 @@ sidebar: section: "Reference" --- -Keyboard shortcuts available in the Plannotator plan review, code review, and annotation UIs. + -| Shortcut | Context | Action | -|----------|---------|--------| -| `Cmd/Ctrl+Enter` | Plan review (no annotations) | Approve plan | -| `Cmd/Ctrl+Enter` | Plan review (with annotations) | Send feedback | -| `Cmd/Ctrl+Enter` | Code review | Send feedback / Approve | -| `Cmd/Ctrl+Enter` | Annotate mode | Send annotations | -| `Cmd/Ctrl+S` | Any mode (with API) | Quick save to default notes app | -| `Escape` | Annotation toolbar | Close toolbar | - -## Notes - -- `Cmd/Ctrl+Enter` is blocked when a modal or dialog is open (export, import, confirm dialogs, image annotator) -- `Cmd/Ctrl+Enter` is blocked when typing in an input or textarea -- `Cmd/Ctrl+S` opens the Export modal if no default notes app is configured -- `Escape` in the annotation toolbar closes it without creating an annotation +This page is generated from the shared shortcut registry at build time. diff --git a/apps/marketing/src/lib/shortcutReference.ts b/apps/marketing/src/lib/shortcutReference.ts new file mode 100644 index 00000000..80663451 --- /dev/null +++ b/apps/marketing/src/lib/shortcutReference.ts @@ -0,0 +1,21 @@ +import { planReviewSurface, annotateSurface } from '../../../../packages/editor/shortcuts'; +import { codeReviewSurface } from '../../../../packages/review-editor/shortcuts'; +import { listRegistryShortcutSections } from '../../../../packages/ui/shortcuts'; +import type { ShortcutSurface } from '../../../../packages/ui/shortcuts'; + +const slugify = (value: string) => value.toLowerCase().replace(/\s+/g, '-'); + +const allSurfaces: ShortcutSurface[] = [planReviewSurface, annotateSurface, codeReviewSurface]; + +export const shortcutReferenceSurfaces = allSurfaces.map((surface) => ({ + ...surface, + sections: listRegistryShortcutSections(surface.registry).map((section) => ({ + ...section, + slug: `${surface.slug}-${slugify(section.title)}`, + })), +})); + +export const shortcutReferenceHeadings = shortcutReferenceSurfaces.flatMap((surface) => [ + { depth: 2 as const, slug: surface.slug, text: surface.title }, + ...surface.sections.map((section) => ({ depth: 3 as const, slug: section.slug, text: section.title })), +]); diff --git a/apps/marketing/src/pages/docs/[...slug].astro b/apps/marketing/src/pages/docs/[...slug].astro index bc80668e..ac9e9034 100644 --- a/apps/marketing/src/pages/docs/[...slug].astro +++ b/apps/marketing/src/pages/docs/[...slug].astro @@ -1,6 +1,8 @@ --- import { getCollection, render } from 'astro:content'; import Docs from '../../layouts/Docs.astro'; +import ShortcutReference from '../../components/ShortcutReference.astro'; +import { shortcutReferenceHeadings } from '../../lib/shortcutReference'; export async function getStaticPaths() { const docs = await getCollection('docs'); @@ -11,7 +13,12 @@ export async function getStaticPaths() { } const { doc } = Astro.props; -const { Content, headings } = await render(doc); +const isShortcutReference = doc.id === 'reference/keyboard-shortcuts'; +const rendered = isShortcutReference ? null : await render(doc); +const Content = rendered?.Content; +const headings = isShortcutReference + ? shortcutReferenceHeadings + : rendered?.headings ?? []; --- - + {isShortcutReference ? : Content && } diff --git a/packages/editor/package.json b/packages/editor/package.json index c81bca99..600d4ea5 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -4,7 +4,8 @@ "type": "module", "exports": { ".": "./App.tsx", - "./styles": "./index.css" + "./styles": "./index.css", + "./shortcuts": "./shortcuts.ts" }, "dependencies": { "@plannotator/shared": "workspace:*", diff --git a/packages/editor/shortcuts.ts b/packages/editor/shortcuts.ts new file mode 100644 index 00000000..4b9347ae --- /dev/null +++ b/packages/editor/shortcuts.ts @@ -0,0 +1,109 @@ +import { + annotationPanelShortcuts, + annotationToolbarShortcuts, + commentPopoverShortcuts, + createShortcutRegistry, + createShortcutScopeHook, + defineShortcutScope, + imageAnnotatorShortcuts, + inputMethodShortcuts, + viewerShortcuts, + type ShortcutSurface, +} from '@plannotator/ui/shortcuts'; + +export const planEditorShortcuts = defineShortcutScope({ + id: 'plan-editor', + title: 'Plan Editor', + shortcuts: { + submitPlan: { + description: 'Approve / Send feedback', + bindings: ['Mod+Enter'], + section: 'Actions', + hint: 'Approves when there are no annotations and sends feedback when there are.', + displayOrder: 10, + }, + submitAnnotations: { + description: 'Send annotations', + bindings: ['Mod+Enter'], + section: 'Actions', + displayOrder: 10, + }, + quickSave: { + description: 'Save to notes app', + bindings: ['Mod+S'], + section: 'Actions', + hint: 'Opens Export if no default notes app is configured.', + displayOrder: 20, + }, + exitPlanDiff: { + description: 'Close diff view', + bindings: ['Escape'], + section: 'Actions', + hint: 'Available while plan diff is open.', + displayOrder: 30, + }, + printPlan: { + description: 'Print', + bindings: ['Mod+P'], + section: 'Actions', + hint: 'Opens the browser print dialog for the current document.', + displayOrder: 40, + }, + }, +}); + +export const usePlanEditorShortcuts = createShortcutScopeHook(planEditorShortcuts); + +const planReviewEditorSettingsShortcuts = defineShortcutScope({ + id: 'plan-review-editor-settings', + title: 'Plan Editor', + shortcuts: { + submitPlan: planEditorShortcuts.shortcuts.submitPlan, + quickSave: planEditorShortcuts.shortcuts.quickSave, + exitPlanDiff: planEditorShortcuts.shortcuts.exitPlanDiff, + printPlan: planEditorShortcuts.shortcuts.printPlan, + }, +}); + +const annotateEditorSettingsShortcuts = defineShortcutScope({ + id: 'annotate-editor-settings', + title: 'Annotate Editor', + shortcuts: { + submitAnnotations: planEditorShortcuts.shortcuts.submitAnnotations, + quickSave: planEditorShortcuts.shortcuts.quickSave, + printPlan: planEditorShortcuts.shortcuts.printPlan, + }, +}); + +const sharedPlanSurfaceShortcuts = [ + inputMethodShortcuts, + annotationToolbarShortcuts, + viewerShortcuts, + commentPopoverShortcuts, + annotationPanelShortcuts, + imageAnnotatorShortcuts, +] as const; + +export const planReviewSettingsShortcutRegistry = createShortcutRegistry([ + planReviewEditorSettingsShortcuts, + ...sharedPlanSurfaceShortcuts, +] as const); + +export const annotateSettingsShortcutRegistry = createShortcutRegistry([ + annotateEditorSettingsShortcuts, + ...sharedPlanSurfaceShortcuts, +] as const); + +export const planReviewSurface: ShortcutSurface = { + slug: 'plan-review', + title: 'Plan review', + description: 'Shortcuts surfaced by the plan review UI.', + registry: planReviewSettingsShortcutRegistry, +}; + +export const annotateSurface: ShortcutSurface = { + slug: 'annotate-mode', + title: 'Annotate mode', + description: 'Shortcuts surfaced by the standalone annotation UI.', + registry: annotateSettingsShortcutRegistry, +}; diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index ffee62d5..220915fa 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -691,11 +691,6 @@ const ReviewApp: React.FC = () => { clearSearch(); } } - // Cmd/Ctrl+Shift+C to copy diff - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') { - e.preventDefault(); - handleCopyDiff(); - } // Cmd/Ctrl+B to toggle file tree if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !isTypingTarget(e.target)) { e.preventDefault(); diff --git a/packages/review-editor/package.json b/packages/review-editor/package.json index cc80941d..e249d2b3 100644 --- a/packages/review-editor/package.json +++ b/packages/review-editor/package.json @@ -4,7 +4,8 @@ "type": "module", "exports": { ".": "./App.tsx", - "./styles": "./index.css" + "./styles": "./index.css", + "./shortcuts": "./shortcuts.ts" }, "dependencies": { "@pierre/diffs": "^1.1.12", diff --git a/packages/review-editor/shortcuts.ts b/packages/review-editor/shortcuts.ts new file mode 100644 index 00000000..4b7aa437 --- /dev/null +++ b/packages/review-editor/shortcuts.ts @@ -0,0 +1,113 @@ +import { + createDoubleTapShortcutsHook, + createShortcutRegistry, + createShortcutScopeHook, + defineShortcutScope, + reviewAiShortcuts, + reviewAllFilesDiffShortcuts, + reviewAnnotationToolbarShortcuts, + reviewFileTreeShortcuts, + reviewPrCommentsShortcuts, + reviewSuggestionModalShortcuts, + reviewTourDialogShortcuts, + type ShortcutSurface, +} from '@plannotator/ui/shortcuts'; + +export const reviewEditorShortcuts = defineShortcutScope({ + id: 'review-editor', + title: 'Review Editor', + shortcuts: { + submit: { + description: 'Approve / Send feedback', + bindings: ['Mod+Enter'], + section: 'Actions', + displayOrder: 10, + }, + focusSearch: { + description: 'Focus search', + bindings: ['Mod+F'], + section: 'Search', + hint: 'Available when the file tree search bar is shown.', + displayOrder: 10, + }, + nextSearchMatch: { + description: 'Next search result', + bindings: ['Enter', 'F3'], + section: 'Search', + displayOrder: 20, + }, + prevSearchMatch: { + description: 'Previous search result', + bindings: ['Shift+Enter', 'Shift+F3'], + section: 'Search', + displayOrder: 30, + }, + clearSearch: { + description: 'Clear search / close panel', + bindings: ['Escape'], + section: 'Search', + displayOrder: 40, + }, + toggleDestination: { + description: 'Toggle review destination', + bindings: ['Alt Alt'], + section: 'Actions', + hint: 'Double-tap to switch between platform and agent in PR review mode.', + displayOrder: 30, + }, + toggleFileTree: { + description: 'Toggle file tree', + bindings: ['Mod+B'], + section: 'Layout', + displayOrder: 10, + }, + toggleSidebar: { + description: 'Toggle review sidebar', + bindings: ['Mod+.'], + section: 'Layout', + displayOrder: 20, + }, + toggleTour: { + description: 'Toggle demo tour dialog', + bindings: ['Mod+Shift+T'], + section: 'Layout', + hint: 'Available in dev builds only.', + displayOrder: 30, + }, + toggleViewed: { + description: 'Toggle file viewed', + bindings: ['V'], + section: 'File Actions', + hint: 'Marks the active diff file as viewed (and auto-collapses in all-files view).', + displayOrder: 10, + }, + stageFile: { + description: 'Stage current file', + bindings: ['A'], + section: 'File Actions', + hint: 'Available when staging is supported (not in PR review mode).', + displayOrder: 20, + }, + }, +}); + +export const useReviewEditorShortcuts = createShortcutScopeHook(reviewEditorShortcuts); +export const useReviewEditorDoubleTap = createDoubleTapShortcutsHook(reviewEditorShortcuts); + +export const reviewSettingsShortcutRegistry = createShortcutRegistry([ + reviewEditorShortcuts, + reviewFileTreeShortcuts, + reviewAllFilesDiffShortcuts, + reviewAnnotationToolbarShortcuts, + reviewSuggestionModalShortcuts, + reviewAiShortcuts, + reviewPrCommentsShortcuts, + reviewTourDialogShortcuts, +] as const); + +export const codeReviewSurface: ShortcutSurface = { + slug: 'code-review', + title: 'Code review', + description: 'Shortcuts surfaced by the code review UI.', + registry: reviewSettingsShortcutRegistry, +}; diff --git a/packages/ui/components/KeyboardShortcuts.tsx b/packages/ui/components/KeyboardShortcuts.tsx index a8bc0c75..d3ab79bb 100644 --- a/packages/ui/components/KeyboardShortcuts.tsx +++ b/packages/ui/components/KeyboardShortcuts.tsx @@ -116,7 +116,6 @@ const reviewShortcuts: ShortcutSection[] = [ shortcuts: [ { keys: [modKey, enter], desc: 'Approve / Send feedback' }, { keys: [altKey, altKey], desc: 'Toggle destination', hint: 'Double-tap to switch between GitHub and Agent in PR review mode' }, - { keys: [modKey, '⇧', 'C'], desc: 'Toggle comment mode' }, { keys: [modKey, 'B'], desc: 'Toggle file tree' }, { keys: [modKey, '.'], desc: 'Toggle sidebar' }, { keys: ['Esc'], desc: 'Collapse sidebar' }, diff --git a/packages/ui/package.json b/packages/ui/package.json index b9613a5c..14d6dce9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -9,6 +9,7 @@ "./components/plan-diff/*": "./components/plan-diff/*.tsx", "./utils/*": "./utils/*.ts", "./hooks/*": "./hooks/*.ts", + "./shortcuts": "./shortcuts/index.ts", "./config": "./config/index.ts", "./types": "./types.ts", "./theme": "./theme.css" diff --git a/packages/ui/shortcuts.test.ts b/packages/ui/shortcuts.test.ts new file mode 100644 index 00000000..5dddb198 --- /dev/null +++ b/packages/ui/shortcuts.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'bun:test'; +import { annotateSettingsShortcutRegistry, planEditorShortcuts, planReviewSettingsShortcutRegistry } from '../editor/shortcuts'; +import { reviewSettingsShortcutRegistry } from '../review-editor/shortcuts'; +import { + createShortcutRegistry, + defineShortcutScope, + dispatchShortcutEvent, + formatShortcutBindingText, + formatShortcutBindingTokens, + getShortcut, + listRegistryShortcutSections, + matchesKeyName, + matchesShortcutBinding, + parseDoubleTapBinding, + validateShortcutRegistry, +} from './shortcuts'; + +describe('shortcuts', () => { + it('formats bindings for docs and keycaps', () => { + expect(formatShortcutBindingText('Mod+Enter')).toBe('Cmd/Ctrl+Enter'); + expect(formatShortcutBindingText('Alt hold')).toBe('Hold Alt'); + expect(formatShortcutBindingText('Alt Alt')).toBe('Double-tap Alt'); + expect(formatShortcutBindingText('Alt Alt', 'mac')).toBe('Double-tap Option'); + expect(formatShortcutBindingTokens('Mod+Enter', 'mac')).toEqual(['⌘', '⏎']); + expect(formatShortcutBindingTokens('Mod+Enter', 'non-mac')).toEqual(['Ctrl', '↵']); + expect(formatShortcutBindingTokens('Alt Alt', 'mac')).toEqual(['⌥', '×2']); + expect(formatShortcutBindingTokens('Alt Alt', 'non-mac')).toEqual(['Alt', '×2']); + }); + + it('validates duplicate scope ids and non-normalized tokens', () => { + const duplicateScope = defineShortcutScope({ + id: 'dup', + title: 'Duplicate', + shortcuts: { + submit: { + description: 'Submit', + bindings: ['Mod+Enter'], + section: 'Actions', + }, + }, + }); + + const badScope = defineShortcutScope({ + id: 'bad', + title: 'Bad', + shortcuts: { + broken: { + description: 'Broken', + bindings: ['Cmd+Enter'], + section: 'Actions', + }, + missingCopy: { + description: '', + bindings: ['Mod+C'], + section: '', + }, + }, + }); + + const errors = validateShortcutRegistry([duplicateScope, duplicateScope, badScope]); + + expect(errors).toContain('Duplicate shortcut scope id: dup'); + expect(errors.some(error => error.includes('Cmd'))).toBe(true); + expect(errors).toContain('Shortcut bad.missingCopy is missing a section.'); + expect(errors).toContain('Shortcut bad.missingCopy is missing a description.'); + expect(() => createShortcutRegistry([duplicateScope, duplicateScope])).toThrow(); + }); + + it('lists plan review, annotate, and review sections from assembled registries', () => { + const planReviewSections = listRegistryShortcutSections(planReviewSettingsShortcutRegistry); + const annotateSections = listRegistryShortcutSections(annotateSettingsShortcutRegistry); + const reviewSections = listRegistryShortcutSections(reviewSettingsShortcutRegistry); + + expect(planReviewSections.map(section => section.title)).toEqual([ + 'Actions', + 'Input Method', + 'Annotations', + 'Image Annotator', + ]); + + expect(annotateSections.map(section => section.title)).toEqual([ + 'Actions', + 'Input Method', + 'Annotations', + 'Image Annotator', + ]); + + expect(getShortcut(planReviewSettingsShortcutRegistry, 'plan-review-editor-settings', 'submitPlan')?.description).toBe('Approve / Send feedback'); + expect(getShortcut(planReviewSettingsShortcutRegistry, 'plan-review-editor-settings', 'submitAnnotations')).toBeUndefined(); + expect(getShortcut(annotateSettingsShortcutRegistry, 'annotate-editor-settings', 'submitAnnotations')?.description).toBe('Send annotations'); + expect(getShortcut(annotateSettingsShortcutRegistry, 'annotate-editor-settings', 'submitPlan')).toBeUndefined(); + + expect(reviewSections.map(section => section.title)).toEqual([ + 'Actions', + 'Search', + 'Layout', + 'File Actions', + 'File Navigation', + 'All-Files View', + 'Annotations', + 'Suggestion Editor', + 'AI Assistant', + 'PR Comments', + 'Tour', + ]); + }); + + it('matches normalized runtime bindings', () => { + const submitEvent = { key: 'Enter', ctrlKey: true, metaKey: false, shiftKey: false, altKey: false, code: 'Enter' } as KeyboardEvent; + const reverseSearchEvent = { key: 'F3', ctrlKey: false, metaKey: false, shiftKey: true, altKey: false, code: 'F3' } as KeyboardEvent; + const typeEvent = { key: 'A', ctrlKey: false, metaKey: false, shiftKey: true, altKey: false, code: 'KeyA' } as KeyboardEvent; + const quickLabelEvent = { key: '3', ctrlKey: false, metaKey: false, shiftKey: false, altKey: true, code: 'Digit3' } as KeyboardEvent; + const macOptionQuickLabelEvent = { key: '£', ctrlKey: false, metaKey: false, shiftKey: false, altKey: true, code: 'Digit3' } as KeyboardEvent; + const wrongEvent = { key: 'Enter', ctrlKey: false, metaKey: false, shiftKey: false, altKey: true, code: 'Enter' } as KeyboardEvent; + + expect(matchesShortcutBinding(submitEvent, 'Mod+Enter')).toBe(true); + expect(matchesShortcutBinding(reverseSearchEvent, 'Shift+F3')).toBe(true); + expect(matchesShortcutBinding(typeEvent, 'A-Z')).toBe(true); + expect(matchesShortcutBinding(quickLabelEvent, 'Alt+1-0')).toBe(true); + expect(matchesShortcutBinding(macOptionQuickLabelEvent, 'Alt+1-0')).toBe(true); + expect(matchesShortcutBinding(wrongEvent, 'Mod+Enter')).toBe(false); + }); + + it('dispatches matching registry actions', () => { + const calls: string[] = []; + const event = { key: 'Enter', ctrlKey: true, metaKey: false, shiftKey: false, altKey: false } as KeyboardEvent; + + const handled = dispatchShortcutEvent(planReviewSettingsShortcutRegistry[0], { + submitPlan: () => calls.push('submitPlan'), + quickSave: () => calls.push('quickSave'), + }, event); + + expect(handled).toBe(true); + expect(calls).toEqual(['submitPlan']); + }); + + it('can dispatch annotate submit after plan submit declines the same binding', () => { + const calls: string[] = []; + const event = { + key: 'Enter', + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + preventDefault: () => calls.push('preventDefault'), + } as unknown as KeyboardEvent; + + const handled = dispatchShortcutEvent(planEditorShortcuts, { + submitPlan: { + when: () => false, + handle: () => calls.push('submitPlan'), + }, + submitAnnotations: { + when: () => true, + handle: () => { + event.preventDefault(); + calls.push('submitAnnotations'); + }, + }, + }, event); + + expect(handled).toBe(true); + expect(calls).toEqual(['preventDefault', 'submitAnnotations']); + }); + + it('supports guarded handlers and continues after a failed guard', () => { + const guardedScope = defineShortcutScope({ + id: 'guarded', + title: 'Guarded', + shortcuts: { + primary: { + description: 'Primary', + bindings: ['Enter'], + section: 'Actions', + preventDefault: true, + }, + fallback: { + description: 'Fallback', + bindings: ['Enter'], + section: 'Actions', + preventDefault: true, + }, + }, + }); + + const calls: string[] = []; + const event = { + key: 'Enter', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + preventDefault: () => calls.push('preventDefault'), + } as unknown as KeyboardEvent; + + const handled = dispatchShortcutEvent(guardedScope, { + primary: { + when: () => false, + handle: () => calls.push('primary'), + }, + fallback: { + when: () => true, + handle: () => calls.push('fallback'), + }, + }, event); + + expect(handled).toBe(true); + expect(calls).toEqual(['preventDefault', 'fallback']); + }); + + it('parses double-tap bindings', () => { + expect(parseDoubleTapBinding('Alt Alt')).toBe('Alt'); + expect(parseDoubleTapBinding('Shift Shift')).toBe('Shift'); + expect(parseDoubleTapBinding('Alt hold')).toBeNull(); + expect(parseDoubleTapBinding('Mod+Enter')).toBeNull(); + expect(parseDoubleTapBinding('Alt Shift')).toBeNull(); // different keys + expect(parseDoubleTapBinding('Alt+Shift Alt+Shift')).toBeNull(); // multi-key groups + }); + + it('matches key names for sequential binding support', () => { + const altEvent = { key: 'Alt' } as KeyboardEvent; + const shiftEvent = { key: 'Shift' } as KeyboardEvent; + const metaEvent = { key: 'Meta' } as KeyboardEvent; + const ctrlEvent = { key: 'Control' } as KeyboardEvent; + + expect(matchesKeyName(altEvent, 'Alt')).toBe(true); + expect(matchesKeyName(altEvent, 'Shift')).toBe(false); + expect(matchesKeyName(shiftEvent, 'Shift')).toBe(true); + expect(matchesKeyName(metaEvent, 'Mod')).toBe(true); + expect(matchesKeyName(ctrlEvent, 'Mod')).toBe(true); + }); + + it('does not handle or prevent default when a guard fails', () => { + const guardedScope = defineShortcutScope({ + id: 'guarded-skip', + title: 'Guarded Skip', + shortcuts: { + save: { + description: 'Save', + bindings: ['Mod+S'], + section: 'Actions', + preventDefault: true, + }, + }, + }); + + let preventDefaultCalls = 0; + const event = { + key: 's', + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + preventDefault: () => { + preventDefaultCalls += 1; + }, + } as unknown as KeyboardEvent; + + const handled = dispatchShortcutEvent(guardedScope, { + save: { + when: () => false, + handle: () => { + throw new Error('should not run'); + }, + }, + }, event); + + expect(handled).toBe(false); + expect(preventDefaultCalls).toBe(0); + }); +}); diff --git a/packages/ui/shortcuts/code-review/ai.shortcuts.ts b/packages/ui/shortcuts/code-review/ai.shortcuts.ts new file mode 100644 index 00000000..e72d2b6d --- /dev/null +++ b/packages/ui/shortcuts/code-review/ai.shortcuts.ts @@ -0,0 +1,25 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const reviewAiShortcuts = defineShortcutScope({ + id: 'review-ai', + title: 'AI Assistant', + shortcuts: { + submit: { + description: 'Send message', + bindings: ['Mod+Enter'], + section: 'AI Assistant', + hint: 'Available in the AI tab and the Ask AI inline input.', + displayOrder: 10, + }, + cancel: { + description: 'Cancel inline AI input', + bindings: ['Escape'], + section: 'AI Assistant', + hint: 'Available in the Ask AI inline input.', + displayOrder: 20, + }, + }, +}); + +export const useReviewAiShortcuts = createShortcutScopeHook(reviewAiShortcuts); diff --git a/packages/ui/shortcuts/code-review/allFilesDiff.shortcuts.ts b/packages/ui/shortcuts/code-review/allFilesDiff.shortcuts.ts new file mode 100644 index 00000000..fe04d5d3 --- /dev/null +++ b/packages/ui/shortcuts/code-review/allFilesDiff.shortcuts.ts @@ -0,0 +1,46 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const reviewAllFilesDiffShortcuts = defineShortcutScope({ + id: 'review-all-files-diff', + title: 'All-Files Diff View', + shortcuts: { + toggleCollapse: { + description: 'Collapse / expand current file', + bindings: ['X'], + section: 'All-Files View', + hint: 'Available when the all-files diff panel is active.', + displayOrder: 10, + }, + undoCollapse: { + description: 'Undo last collapse', + bindings: ['Z'], + section: 'All-Files View', + hint: 'Reopens the most recently auto-collapsed file (auto-collapse fires when toggling viewed).', + displayOrder: 20, + }, + addFileComment: { + description: 'Add file-scoped comment', + bindings: ['C'], + section: 'All-Files View', + hint: 'Opens the file-scoped comment popover for the active file.', + displayOrder: 30, + }, + nextFile: { + description: 'Scroll to next expanded file', + bindings: [']'], + section: 'All-Files View', + hint: 'All-files panel only.', + displayOrder: 40, + }, + prevFile: { + description: 'Scroll to previous expanded file', + bindings: ['['], + section: 'All-Files View', + hint: 'All-files panel only.', + displayOrder: 50, + }, + }, +}); + +export const useReviewAllFilesDiffShortcuts = createShortcutScopeHook(reviewAllFilesDiffShortcuts); diff --git a/packages/ui/shortcuts/code-review/annotationToolbar.shortcuts.ts b/packages/ui/shortcuts/code-review/annotationToolbar.shortcuts.ts new file mode 100644 index 00000000..0a87a4da --- /dev/null +++ b/packages/ui/shortcuts/code-review/annotationToolbar.shortcuts.ts @@ -0,0 +1,30 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const reviewAnnotationToolbarShortcuts = defineShortcutScope({ + id: 'review-annotation-toolbar', + title: 'Review Annotation Toolbar', + shortcuts: { + submitComment: { + description: 'Submit comment', + bindings: ['Mod+Enter'], + section: 'Annotations', + displayOrder: 10, + }, + indentSuggestedCode: { + description: 'Indent suggested code', + bindings: ['Tab'], + section: 'Annotations', + displayOrder: 20, + }, + cancel: { + description: 'Close comment editor', + bindings: ['Escape'], + section: 'Annotations', + hint: 'Available while the review comment editor is open.', + displayOrder: 30, + }, + }, +}); + +export const useReviewAnnotationToolbarShortcuts = createShortcutScopeHook(reviewAnnotationToolbarShortcuts); diff --git a/packages/ui/shortcuts/code-review/fileTree.shortcuts.ts b/packages/ui/shortcuts/code-review/fileTree.shortcuts.ts new file mode 100644 index 00000000..1a257583 --- /dev/null +++ b/packages/ui/shortcuts/code-review/fileTree.shortcuts.ts @@ -0,0 +1,35 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const reviewFileTreeShortcuts = defineShortcutScope({ + id: 'review-file-tree', + title: 'File Navigation', + shortcuts: { + nextFile: { + description: 'Next file', + bindings: ['J', 'ArrowDown'], + section: 'File Navigation', + displayOrder: 10, + }, + prevFile: { + description: 'Previous file', + bindings: ['K', 'ArrowUp'], + section: 'File Navigation', + displayOrder: 20, + }, + firstFile: { + description: 'First file', + bindings: ['Home'], + section: 'File Navigation', + displayOrder: 30, + }, + lastFile: { + description: 'Last file', + bindings: ['End'], + section: 'File Navigation', + displayOrder: 40, + }, + }, +}); + +export const useReviewFileTreeShortcuts = createShortcutScopeHook(reviewFileTreeShortcuts); diff --git a/packages/ui/shortcuts/code-review/prComments.shortcuts.ts b/packages/ui/shortcuts/code-review/prComments.shortcuts.ts new file mode 100644 index 00000000..2cd45e10 --- /dev/null +++ b/packages/ui/shortcuts/code-review/prComments.shortcuts.ts @@ -0,0 +1,18 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const reviewPrCommentsShortcuts = defineShortcutScope({ + id: 'review-pr-comments', + title: 'PR Comments', + shortcuts: { + focusSearch: { + description: 'Focus PR comments search', + bindings: ['Mod+Shift+F'], + section: 'PR Comments', + hint: 'Available while the PR Comments tab is open in the review sidebar.', + displayOrder: 10, + }, + }, +}); + +export const useReviewPrCommentsShortcuts = createShortcutScopeHook(reviewPrCommentsShortcuts); diff --git a/packages/ui/shortcuts/code-review/suggestionModal.shortcuts.ts b/packages/ui/shortcuts/code-review/suggestionModal.shortcuts.ts new file mode 100644 index 00000000..91a326ad --- /dev/null +++ b/packages/ui/shortcuts/code-review/suggestionModal.shortcuts.ts @@ -0,0 +1,25 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const reviewSuggestionModalShortcuts = defineShortcutScope({ + id: 'review-suggestion-modal', + title: 'Suggestion Editor', + shortcuts: { + indent: { + description: 'Indent (insert spaces)', + bindings: ['Tab'], + section: 'Suggestion Editor', + hint: 'Available in the expanded suggestion editor.', + displayOrder: 10, + }, + close: { + description: 'Close suggestion editor', + bindings: ['Escape'], + section: 'Suggestion Editor', + hint: 'Closes the modal; suggestion text is preserved on the underlying comment.', + displayOrder: 20, + }, + }, +}); + +export const useReviewSuggestionModalShortcuts = createShortcutScopeHook(reviewSuggestionModalShortcuts); diff --git a/packages/ui/shortcuts/code-review/tourDialog.shortcuts.ts b/packages/ui/shortcuts/code-review/tourDialog.shortcuts.ts new file mode 100644 index 00000000..2e62f840 --- /dev/null +++ b/packages/ui/shortcuts/code-review/tourDialog.shortcuts.ts @@ -0,0 +1,18 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const reviewTourDialogShortcuts = defineShortcutScope({ + id: 'review-tour-dialog', + title: 'Tour Dialog', + shortcuts: { + close: { + description: 'Close tour', + bindings: ['Escape'], + section: 'Tour', + hint: 'Available while the code tour dialog is open.', + displayOrder: 10, + }, + }, +}); + +export const useReviewTourDialogShortcuts = createShortcutScopeHook(reviewTourDialogShortcuts); diff --git a/packages/ui/shortcuts/core.ts b/packages/ui/shortcuts/core.ts new file mode 100644 index 00000000..0e6850de --- /dev/null +++ b/packages/ui/shortcuts/core.ts @@ -0,0 +1,399 @@ +import { isMac } from '../utils/platform'; + +export type ShortcutPlatform = 'mac' | 'non-mac' | 'cross-platform'; + +export interface ShortcutDefinition { + description: string; + /** Alternative bindings for the same action, shown as “or” in the shortcuts UI. */ + bindings: string[]; + section: string; + hint?: string; + preventDefault?: boolean; + /** Stable help-menu ordering within a section, without relying on object key order. */ + displayOrder?: number; +} + +export interface ShortcutScopeDefinition { + id: string; + title: string; + shortcuts: Record; +} + +export interface ShortcutEntry extends ShortcutDefinition { + scopeId: string; + scopeTitle: string; + actionId: string; +} + +export interface ShortcutSection { + title: string; + shortcuts: ShortcutEntry[]; +} + +export type ShortcutRegistry = readonly ShortcutScopeDefinition[]; + +export interface ShortcutSurface { + slug: string; + title: string; + description: string; + registry: ShortcutRegistry; +} + +const NAMED_TOKENS = new Set([ + 'Mod', + 'Shift', + 'Alt', + 'Enter', + 'Escape', + 'Tab', + // TODO(migration): `matchesKeyToken` does not currently match `Space` — + // pressing Spacebar produces `event.key === ' '` (length 1), which the + // matcher uppercases to `' '` and then compares to the literal `'Space'`, + // always failing. Add a special case in `matchesKeyToken` (e.g. + // `if (token === 'Space') return event.key === ' ' || event.code === 'Space'`) + // before any scope binds Space. + 'Space', + 'Backspace', + 'Delete', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'A-Z', + '1-0', + 'hold', + // Punctuation keys used as shortcut targets. Add new ones as needed; we + // whitelist explicitly so typos like `Cmd` instead of `Mod` keep failing + // validation. + '.', + '[', + ']', +]); + +for (let n = 1; n <= 12; n += 1) { + NAMED_TOKENS.add(`F${n}`); +} + +const MODIFIER_TOKENS = new Set(['Mod', 'Shift', 'Alt']); + +export function defineShortcutScope(scope: ShortcutScopeDefinition): ShortcutScopeDefinition { + return scope; +} + +function isSingleLetter(token: string): boolean { + return /^[A-Z]$/.test(token); +} + +function isSingleDigit(token: string): boolean { + return /^[0-9]$/.test(token); +} + +function getBindingTokens(binding: string): string[] { + return binding.trim().split(/[+\s]+/).filter(Boolean); +} + +function getBindingGroups(binding: string): string[][] { + return binding.trim().split(/\s+/).filter(Boolean).map(group => group.split('+').filter(Boolean)); +} + +/** + * Parse a double-tap binding like `"Alt Alt"` and return the key name. + * Returns null for non-double-tap bindings. + */ +export function parseDoubleTapBinding(binding: string): string | null { + const groups = getBindingGroups(binding); + if (groups.length !== 2) return null; + if (groups[0].length !== 1 || groups[1].length !== 1) return null; + if (groups[0][0] !== groups[1][0]) return null; + if (groups[1][0] === 'hold') return null; + return groups[0][0]; +} + +/** + * Check if a KeyboardEvent matches a named key token (for sequential/stateful matching). + * Unlike `matchesShortcutBinding`, this matches a single key identity without modifier checks. + */ +export function matchesKeyName(event: KeyboardEvent, keyName: string): boolean { + if (keyName === 'Alt') return event.key === 'Alt'; + if (keyName === 'Shift') return event.key === 'Shift'; + if (keyName === 'Mod') return event.key === 'Meta' || event.key === 'Control'; + return matchesKeyToken(event, keyName); +} + +function isNormalizedToken(token: string): boolean { + return NAMED_TOKENS.has(token) || isSingleLetter(token) || isSingleDigit(token); +} + +function normalizeShortcutEntry( + scope: ShortcutScopeDefinition, + actionId: string, + shortcut: ShortcutDefinition, +): ShortcutEntry { + return { + ...shortcut, + scopeId: scope.id, + scopeTitle: scope.title, + actionId, + }; +} + +export function listScopeShortcuts( + scope: ShortcutScopeDefinition, + options?: { actionIds?: readonly string[] }, +): ShortcutEntry[] { + const allowedActionIds = options?.actionIds ? new Set(options.actionIds) : null; + const shortcuts: ShortcutEntry[] = []; + + for (const [actionId, shortcut] of Object.entries(scope.shortcuts)) { + if (allowedActionIds && !allowedActionIds.has(actionId)) continue; + + shortcuts.push(normalizeShortcutEntry(scope, actionId, shortcut)); + } + + return shortcuts; +} + +export function validateShortcutRegistry(registry: ShortcutRegistry): string[] { + const errors: string[] = []; + const scopeIds = new Set(); + + for (const scope of registry) { + if (scopeIds.has(scope.id)) { + errors.push(`Duplicate shortcut scope id: ${scope.id}`); + } + scopeIds.add(scope.id); + + for (const [actionId, shortcut] of Object.entries(scope.shortcuts)) { + const id = `${scope.id}.${actionId}`; + + if (!shortcut.section.trim()) { + errors.push(`Shortcut ${id} is missing a section.`); + } + + if (!shortcut.description.trim()) { + errors.push(`Shortcut ${id} is missing a description.`); + } + + if (shortcut.bindings.length === 0) { + errors.push(`Shortcut ${id} must define at least one binding.`); + } + + for (const binding of shortcut.bindings) { + if (!binding.trim()) { + errors.push(`Shortcut ${id} contains an empty binding.`); + continue; + } + + for (const token of getBindingTokens(binding)) { + if (!isNormalizedToken(token)) { + errors.push(`Shortcut ${id} uses non-normalized token \`${token}\` in binding \`${binding}\`.`); + } + } + } + } + } + + return errors; +} + +export function createShortcutRegistry(registry: TRegistry): TRegistry { + const errors = validateShortcutRegistry(registry); + if (errors.length > 0) { + throw new Error(`Invalid shortcut registry:\n- ${errors.join('\n- ')}`); + } + return registry; +} + +export function mergeShortcutRegistries(...registries: ShortcutRegistry[]): ShortcutRegistry { + return createShortcutRegistry(registries.flat()); +} + +export function getShortcutScope(registry: ShortcutRegistry, scopeId: string): ShortcutScopeDefinition | undefined { + return registry.find(scope => scope.id === scopeId); +} + +export function getShortcut( + registry: ShortcutRegistry, + scopeId: string, + actionId: string, +): ShortcutEntry | undefined { + const scope = getShortcutScope(registry, scopeId); + const shortcut = scope?.shortcuts[actionId]; + return scope && shortcut ? normalizeShortcutEntry(scope, actionId, shortcut) : undefined; +} + +export function listRegistryShortcuts(registry: ShortcutRegistry): ShortcutEntry[] { + const shortcuts: ShortcutEntry[] = []; + + for (const scope of registry) { + shortcuts.push(...listScopeShortcuts(scope)); + } + + return shortcuts; +} + +export function listShortcutSections(shortcuts: readonly ShortcutEntry[]): ShortcutSection[] { + const sections = new Map(); + + for (const shortcut of shortcuts) { + const existing = sections.get(shortcut.section); + if (existing) { + existing.push(shortcut); + } else { + sections.set(shortcut.section, [shortcut]); + } + } + + return Array.from(sections.entries()).map(([title, sectionShortcuts]) => ({ + title, + shortcuts: [...sectionShortcuts].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0) || a.description.localeCompare(b.description)), + })); +} + +export function listRegistryShortcutSections(registry: ShortcutRegistry): ShortcutSection[] { + return listShortcutSections(listRegistryShortcuts(registry)); +} + +export function getShortcutPlatform(): Exclude { + return isMac ? 'mac' : 'non-mac'; +} + +function formatKeycapToken(token: string, platform: Exclude): string { + if (platform === 'mac') { + if (token === 'Mod') return '⌘'; + if (token === 'Alt') return '⌥'; + if (token === 'Shift') return '⇧'; + if (token === 'Enter') return '⏎'; + if (token === 'Escape') return 'Esc'; + } + + if (platform === 'non-mac') { + if (token === 'Mod') return 'Ctrl'; + if (token === 'Enter') return '↵'; + if (token === 'Escape') return 'Esc'; + } + + return token; +} + +function formatTextToken(token: string, platform: ShortcutPlatform): string { + if (token === 'Mod') { + if (platform === 'mac') return 'Cmd'; + if (platform === 'non-mac') return 'Ctrl'; + return 'Cmd/Ctrl'; + } + + if (token === 'Alt') { + return platform === 'mac' ? 'Option' : 'Alt'; + } + + if (token === 'Escape') return 'Escape'; + if (token === 'hold') return 'hold'; + return token; +} + +export function formatShortcutBindingTokens( + binding: string, + platform: Exclude = getShortcutPlatform(), +): string[] { + const doubleTapKey = parseDoubleTapBinding(binding); + if (doubleTapKey) { + return [formatKeycapToken(doubleTapKey, platform), '×2']; + } + + return getBindingTokens(binding).map(token => formatKeycapToken(token, platform)); +} + +export function formatShortcutBindingText( + binding: string, + platform: ShortcutPlatform = 'cross-platform', +): string { + const groups = getBindingGroups(binding); + + if (groups.length === 2 && groups[1].length === 1 && groups[1][0] === 'hold' && groups[0].length === 1) { + return `Hold ${formatTextToken(groups[0][0], platform)}`; + } + + const doubleTapKey = parseDoubleTapBinding(binding); + if (doubleTapKey) { + return `Double-tap ${formatTextToken(doubleTapKey, platform)}`; + } + + return groups + .map(group => group.map(token => formatTextToken(token, platform)).join('+')) + .join(' then '); +} + +export function formatShortcutBindingsText( + bindings: string[], + platform: ShortcutPlatform = 'cross-platform', +): string { + return bindings.map(binding => formatShortcutBindingText(binding, platform)).join(' or '); +} + +function getDigitCode(event: KeyboardEvent): string | null { + const code = typeof event.code === 'string' ? event.code : ''; + const match = code.match(/^Digit([0-9])$/); + return match ? match[1] : null; +} + +export function getShortcutDigit(event: KeyboardEvent): number | null { + const parsed = Number.parseInt(event.key, 10); + if (!Number.isNaN(parsed)) return parsed; + + const digitCode = getDigitCode(event); + return digitCode === null ? null : Number.parseInt(digitCode, 10); +} + +function matchesKeyToken(event: KeyboardEvent, token: string): boolean { + const key = event.key.length === 1 ? event.key.toUpperCase() : event.key; + const shortcutDigit = getShortcutDigit(event); + + if (token === 'A-Z') { + return /^[A-Z]$/.test(key); + } + + if (token === '1-0') { + return /^[0-9]$/.test(key) || shortcutDigit !== null; + } + + if (isSingleLetter(token)) { + return key === token; + } + + if (isSingleDigit(token)) { + return key === token || String(shortcutDigit ?? '') === token; + } + + return key === token; +} + +export function matchesShortcutBinding(event: KeyboardEvent, binding: string): boolean { + if (binding.includes(' ') || binding.includes('hold')) { + return false; + } + + const tokens = binding.split('+').filter(Boolean); + if (tokens.length === 0) return false; + + const requiresMod = tokens.includes('Mod'); + const requiresShift = tokens.includes('Shift'); + const requiresAlt = tokens.includes('Alt'); + const keyTokens = tokens.filter(token => !MODIFIER_TOKENS.has(token)); + if (keyTokens.length !== 1) return false; + + const keyToken = keyTokens[0]; + const shiftMatches = requiresShift === event.shiftKey || (!requiresShift && keyToken === 'A-Z' && event.shiftKey); + + if (requiresMod !== (event.metaKey || event.ctrlKey)) return false; + if (!shiftMatches) return false; + if (requiresAlt !== event.altKey) return false; + + return matchesKeyToken(event, keyToken); +} + +export function getMatchingShortcutBindingIndex(event: KeyboardEvent, bindings: string[]): number { + return bindings.findIndex(binding => matchesShortcutBinding(event, binding)); +} diff --git a/packages/ui/shortcuts/index.ts b/packages/ui/shortcuts/index.ts new file mode 100644 index 00000000..a196f9fc --- /dev/null +++ b/packages/ui/shortcuts/index.ts @@ -0,0 +1,19 @@ +export * from './core'; +export * from './runtime'; + +// plan-review scopes +export { annotationToolbarShortcuts, useAnnotationToolbarShortcuts } from './plan-review/annotationToolbar.shortcuts'; +export { annotationPanelShortcuts, useAnnotationPanelShortcuts } from './plan-review/annotationPanel.shortcuts'; +export { commentPopoverShortcuts } from './plan-review/commentPopover.shortcuts'; +export { imageAnnotatorShortcuts, useImageAnnotatorShortcuts } from './plan-review/imageAnnotator.shortcuts'; +export { inputMethodShortcuts } from './plan-review/inputMethod.shortcuts'; +export { viewerShortcuts, useViewerShortcuts } from './plan-review/viewer.shortcuts'; + +// code-review scopes +export { reviewAnnotationToolbarShortcuts, useReviewAnnotationToolbarShortcuts } from './code-review/annotationToolbar.shortcuts'; +export { reviewFileTreeShortcuts, useReviewFileTreeShortcuts } from './code-review/fileTree.shortcuts'; +export { reviewPrCommentsShortcuts, useReviewPrCommentsShortcuts } from './code-review/prComments.shortcuts'; +export { reviewAllFilesDiffShortcuts, useReviewAllFilesDiffShortcuts } from './code-review/allFilesDiff.shortcuts'; +export { reviewAiShortcuts, useReviewAiShortcuts } from './code-review/ai.shortcuts'; +export { reviewSuggestionModalShortcuts, useReviewSuggestionModalShortcuts } from './code-review/suggestionModal.shortcuts'; +export { reviewTourDialogShortcuts, useReviewTourDialogShortcuts } from './code-review/tourDialog.shortcuts'; diff --git a/packages/ui/shortcuts/plan-review/annotationPanel.shortcuts.ts b/packages/ui/shortcuts/plan-review/annotationPanel.shortcuts.ts new file mode 100644 index 00000000..e65983ac --- /dev/null +++ b/packages/ui/shortcuts/plan-review/annotationPanel.shortcuts.ts @@ -0,0 +1,25 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const annotationPanelShortcuts = defineShortcutScope({ + id: 'annotation-panel', + title: 'Annotation Panel', + shortcuts: { + saveEdit: { + description: 'Save annotation edit', + bindings: ['Mod+Enter'], + section: 'Annotations', + hint: 'Available while editing an annotation in the side panel.', + displayOrder: 50, + }, + cancelEdit: { + description: 'Cancel annotation edit', + bindings: ['Escape'], + section: 'Annotations', + hint: 'Available while editing an annotation in the side panel.', + displayOrder: 60, + }, + }, +}); + +export const useAnnotationPanelShortcuts = createShortcutScopeHook(annotationPanelShortcuts); diff --git a/packages/ui/shortcuts/plan-review/annotationToolbar.shortcuts.ts b/packages/ui/shortcuts/plan-review/annotationToolbar.shortcuts.ts new file mode 100644 index 00000000..4caf5352 --- /dev/null +++ b/packages/ui/shortcuts/plan-review/annotationToolbar.shortcuts.ts @@ -0,0 +1,39 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const annotationToolbarShortcuts = defineShortcutScope({ + id: 'annotation-toolbar', + title: 'Annotation Toolbar', + shortcuts: { + typeToComment: { + description: 'Start comment', + bindings: ['A-Z'], + section: 'Annotations', + hint: 'Typing a letter opens the comment editor with that character.', + displayOrder: 10, + }, + applyQuickLabel: { + description: 'Apply toolbar label', + bindings: ['Alt+1-0'], + section: 'Annotations', + hint: 'Applies the matching preset label while the annotation toolbar is open.', + displayOrder: 20, + }, + applyQuickLabelFromPicker: { + description: 'Apply picker label', + bindings: ['1-0'], + section: 'Annotations', + hint: 'Available while the quick label picker is open.', + displayOrder: 30, + }, + close: { + description: 'Close toolbar', + bindings: ['Escape'], + section: 'Annotations', + hint: 'Available while the annotation toolbar is open.', + displayOrder: 40, + }, + }, +}); + +export const useAnnotationToolbarShortcuts = createShortcutScopeHook(annotationToolbarShortcuts); diff --git a/packages/ui/shortcuts/plan-review/commentPopover.shortcuts.ts b/packages/ui/shortcuts/plan-review/commentPopover.shortcuts.ts new file mode 100644 index 00000000..5717a9ac --- /dev/null +++ b/packages/ui/shortcuts/plan-review/commentPopover.shortcuts.ts @@ -0,0 +1,21 @@ +import { defineShortcutScope } from '../core'; + +export const commentPopoverShortcuts = defineShortcutScope({ + id: 'comment-popover', + title: 'Comment Editor', + shortcuts: { + submit: { + description: 'Submit comment', + bindings: ['Mod+Enter'], + section: 'Annotations', + displayOrder: 30, + }, + cancel: { + description: 'Close comment', + bindings: ['Escape'], + section: 'Annotations', + hint: 'Available while the comment editor is open.', + displayOrder: 40, + }, + }, +}); diff --git a/packages/ui/shortcuts/plan-review/imageAnnotator.shortcuts.ts b/packages/ui/shortcuts/plan-review/imageAnnotator.shortcuts.ts new file mode 100644 index 00000000..a791d97d --- /dev/null +++ b/packages/ui/shortcuts/plan-review/imageAnnotator.shortcuts.ts @@ -0,0 +1,42 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const imageAnnotatorShortcuts = defineShortcutScope({ + id: 'image-annotator', + title: 'Image Annotator', + shortcuts: { + penTool: { + description: 'Pen tool', + bindings: ['1'], + section: 'Image Annotator', + displayOrder: 10, + }, + arrowTool: { + description: 'Arrow tool', + bindings: ['2'], + section: 'Image Annotator', + displayOrder: 20, + }, + circleTool: { + description: 'Circle tool', + bindings: ['3'], + section: 'Image Annotator', + displayOrder: 30, + }, + undo: { + description: 'Undo', + bindings: ['Mod+Z'], + section: 'Image Annotator', + displayOrder: 40, + }, + save: { + description: 'Save and close annotator', + bindings: ['Enter', 'Escape'], + section: 'Image Annotator', + hint: 'When the image name field is focused, Escape blurs it first and Enter confirms the name; both close the annotator otherwise.', + displayOrder: 50, + }, + }, +}); + +export const useImageAnnotatorShortcuts = createShortcutScopeHook(imageAnnotatorShortcuts); diff --git a/packages/ui/shortcuts/plan-review/inputMethod.shortcuts.ts b/packages/ui/shortcuts/plan-review/inputMethod.shortcuts.ts new file mode 100644 index 00000000..bb435836 --- /dev/null +++ b/packages/ui/shortcuts/plan-review/inputMethod.shortcuts.ts @@ -0,0 +1,30 @@ +import { defineShortcutScope } from '../core'; + +// Heads-up for future migrators: these bindings (`Alt hold`, `Alt Alt`) are +// declarative metadata for the help modal and marketing docs. They do NOT +// dispatch through `useShortcutScope`. `Alt Alt` works via +// `useDoubleTapShortcuts`; `Alt hold` has no shared hook yet — it's wired +// today by the bespoke `useInputMethodSwitch` hook in +// `packages/editor/hooks/`. See the comment block above +// `useDoubleTapShortcuts` in `packages/ui/shortcuts/runtime.ts`. + +export const inputMethodShortcuts = defineShortcutScope({ + id: 'input-method', + title: 'Input Method', + shortcuts: { + temporarySwitch: { + description: 'Temporarily switch input method', + bindings: ['Alt hold'], + section: 'Input Method', + hint: 'Hold Alt to switch between Select and Pinpoint, then release to revert.', + displayOrder: 10, + }, + toggleSwitch: { + description: 'Toggle input method', + bindings: ['Alt Alt'], + section: 'Input Method', + hint: 'Double-tap Alt to switch between Select and Pinpoint until you toggle again.', + displayOrder: 20, + }, + }, +}); diff --git a/packages/ui/shortcuts/plan-review/viewer.shortcuts.ts b/packages/ui/shortcuts/plan-review/viewer.shortcuts.ts new file mode 100644 index 00000000..b5558bf6 --- /dev/null +++ b/packages/ui/shortcuts/plan-review/viewer.shortcuts.ts @@ -0,0 +1,24 @@ +import { defineShortcutScope } from '../core'; +import { createShortcutScopeHook } from '../runtime'; + +export const viewerShortcuts = defineShortcutScope({ + id: 'viewer', + title: 'Viewer', + shortcuts: { + copySelection: { + description: 'Copy selected text', + bindings: ['Mod+C'], + section: 'Annotations', + displayOrder: 25, + }, + closeLightbox: { + description: 'Close image lightbox', + bindings: ['Escape'], + section: 'Annotations', + hint: 'Available while an image is open in the lightbox.', + displayOrder: 70, + }, + }, +}); + +export const useViewerShortcuts = createShortcutScopeHook(viewerShortcuts); diff --git a/packages/ui/shortcuts/runtime.ts b/packages/ui/shortcuts/runtime.ts new file mode 100644 index 00000000..47ced48c --- /dev/null +++ b/packages/ui/shortcuts/runtime.ts @@ -0,0 +1,215 @@ +import { useEffect, useRef } from 'react'; +import type { ShortcutDefinition, ShortcutScopeDefinition } from './core'; +import { matchesKeyName, matchesShortcutBinding, parseDoubleTapBinding } from './core'; + +type ShortcutActionId> = keyof TScope['shortcuts'] & string; + +export interface ShortcutHandlerConfig { + when?: (event: KeyboardEvent) => boolean; + handle: (event: KeyboardEvent) => void; +} + +export type ShortcutHandler = ((event: KeyboardEvent) => void) | ShortcutHandlerConfig; + +export type ShortcutHandlers> = Partial< + Record, ShortcutHandler> +>; + +type ShortcutEventTarget = 'window' | 'document' | EventTarget | null; + +export interface UseShortcutScopeOptions> { + scope: TScope; + handlers: ShortcutHandlers; + target?: ShortcutEventTarget; + stopOnMatch?: boolean; +} + +function normalizeShortcutHandler(handler: ShortcutHandler): ShortcutHandlerConfig { + if (typeof handler === 'function') { + return { handle: handler }; + } + + return handler; +} + +// TODO(migration): no cross-scope arbitration. When two scopes bind the +// same key (e.g. `Escape` in both an outer editor scope and an inner +// dialog scope), both `useShortcutScope` listeners fire on a single +// keypress. Add `if (event.defaultPrevented) return false;` at the top +// of this function once shortcut definitions consistently set +// `preventDefault: true` (or once we flip the default). Until then, +// callers must guard with `when` to prevent double-handling. +export function dispatchShortcutEvent>( + scope: TScope, + handlers: ShortcutHandlers, + event: KeyboardEvent, + options?: { stopOnMatch?: boolean }, +): boolean { + const stopOnMatch = options?.stopOnMatch ?? true; + let handled = false; + + for (const [actionId, shortcut] of Object.entries(scope.shortcuts) as Array<[ + ShortcutActionId, + ShortcutDefinition, + ]>) { + const handler = handlers[actionId]; + if (!handler) continue; + if (!shortcut.bindings.some(binding => matchesShortcutBinding(event, binding))) continue; + + const { when, handle } = normalizeShortcutHandler(handler); + if (when && !when(event)) continue; + + if (shortcut.preventDefault) { + event.preventDefault(); + } + + handle(event); + handled = true; + + if (stopOnMatch) { + return true; + } + } + + return handled; +} + +function getEventTarget(target: ShortcutEventTarget): EventTarget | null { + if (target === 'window') { + return typeof window === 'undefined' ? null : window; + } + if (target === 'document') { + return typeof document === 'undefined' ? null : document; + } + return target; +} + +export function useShortcutScope>({ + scope, + handlers, + target = 'window', + stopOnMatch = true, +}: UseShortcutScopeOptions) { + const handlersRef = useRef(handlers); + + useEffect(() => { + handlersRef.current = handlers; + }, [handlers]); + + useEffect(() => { + const eventTarget = getEventTarget(target); + if (!eventTarget || !('addEventListener' in eventTarget)) return; + + const handleKeyDown = (event: Event) => { + dispatchShortcutEvent(scope, handlersRef.current, event as KeyboardEvent, { stopOnMatch }); + }; + + eventTarget.addEventListener('keydown', handleKeyDown as EventListener); + return () => { + eventTarget.removeEventListener('keydown', handleKeyDown as EventListener); + }; + }, [scope, stopOnMatch, target]); +} + +export function createShortcutScopeHook>(scope: TScope) { + return function useScopedShortcutScope(options: Omit, 'scope'>) { + useShortcutScope({ scope, ...options }); + }; +} + +// --- Multi-press shortcut support --- +// +// `useShortcutScope` only dispatches single-press bindings — anything with +// whitespace (e.g. `"Alt Alt"`) or the `hold` token (e.g. `"Alt hold"`) +// short-circuits in `matchesShortcutBinding`. Multi-press bindings need a +// dedicated hook that knows their semantics: +// +// - Double-tap → `useDoubleTapShortcuts` below. +// - Hold → no shared hook yet; wire by hand in the consuming +// component until one is built. The `Alt hold` binding in +// `inputMethodShortcuts` is currently driven by the +// bespoke `useInputMethodSwitch` hook in the plan editor. +// +// TODO: when the App.tsx migration starts touching hold semantics, add a +// `useHoldShortcuts` here paired with a `parseHoldBinding` in core.ts so the +// registry-driven path actually fires. + +// --- Double-tap shortcut support --- + +export type DoubleTapHandlers> = Partial< + Record, ShortcutHandler> +>; + +export interface UseDoubleTapShortcutsOptions> { + scope: TScope; + handlers: DoubleTapHandlers; + /** Max gap between two key releases to count as a double-tap (default: 300ms). */ + window?: number; +} + +/** + * Hook for handling double-tap shortcuts (bindings like `"Alt Alt"`). + * + * Double-tap is detected on keyup: if the same key is released twice + * within `window` ms, the handler fires. Regular (keydown) bindings + * in the same scope are ignored — use `useShortcutScope` for those. + */ +export function useDoubleTapShortcuts>({ + scope, + handlers, + window: tapWindow = 300, +}: UseDoubleTapShortcutsOptions) { + const handlersRef = useRef(handlers); + useEffect(() => { handlersRef.current = handlers; }, [handlers]); + + useEffect(() => { + // Pre-parse which actions have double-tap bindings + const doubleTapActions: Array<{ actionId: ShortcutActionId; keyName: string }> = []; + for (const [actionId, shortcut] of Object.entries(scope.shortcuts) as Array<[ + ShortcutActionId, + ShortcutDefinition, + ]>) { + for (const binding of shortcut.bindings) { + const keyName = parseDoubleTapBinding(binding); + if (keyName) { + doubleTapActions.push({ actionId, keyName }); + } + } + } + + if (doubleTapActions.length === 0) return; + + // Track last keyup timestamp per key + const lastKeyUp = new Map(); + + const handleKeyUp = (event: KeyboardEvent) => { + for (const { actionId, keyName } of doubleTapActions) { + if (!matchesKeyName(event, keyName)) continue; + + const handler = handlersRef.current[actionId]; + if (!handler) continue; + + const { when, handle } = normalizeShortcutHandler(handler); + if (when && !when(event)) continue; + + const now = Date.now(); + const prev = lastKeyUp.get(keyName) ?? 0; + if (now - prev < tapWindow) { + handle(event); + lastKeyUp.set(keyName, 0); // reset so triple-tap doesn't re-fire + } else { + lastKeyUp.set(keyName, now); + } + } + }; + + window.addEventListener('keyup', handleKeyUp); + return () => window.removeEventListener('keyup', handleKeyUp); + }, [scope, tapWindow]); +} + +export function createDoubleTapShortcutsHook>(scope: TScope) { + return function useScopedDoubleTap(options: Omit, 'scope'>) { + useDoubleTapShortcuts({ scope, ...options }); + }; +}