@plannotator/ui 0.53.1 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -0,0 +1,37 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useTheme } from '../components/ThemeProvider';
3
+ import { themesForHalf, type ThemeHalf, type ThemeInfo } from '../utils/themeRegistry';
4
+
5
+ export interface ThemePaletteHalves {
6
+ /** Which half the picker assigns to. */
7
+ half: ThemeHalf;
8
+ setHalf: (half: ThemeHalf) => void;
9
+ /** The palette each half holds now. */
10
+ pair: Record<ThemeHalf, string>;
11
+ /** The palettes the current half can take, from the registry (so a new palette shows by itself). */
12
+ themes: ThemeInfo[];
13
+ nameOf: (id: string) => string;
14
+ /** Assign a palette to a half (ThemeProvider's setHalfTheme). */
15
+ choose: (half: ThemeHalf, id: string) => void;
16
+ }
17
+
18
+ /**
19
+ * The palette picker's state, without its layout: the half being assigned
20
+ * (it follows the mode on screen, so opening Settings in dark mode edits the
21
+ * dark half first), the pair, and the palettes that half can take. ThemeTab
22
+ * draws it for plan review, annotate and code review; the Inbox draws the
23
+ * same choices in its own Settings rows.
24
+ */
25
+ export function useThemePaletteHalves(): ThemePaletteHalves {
26
+ const { lightTheme, darkTheme, setHalfTheme, availableThemes, preferredMode } = useTheme();
27
+ const [half, setHalf] = useState<ThemeHalf>(preferredMode);
28
+ useEffect(() => setHalf(preferredMode), [preferredMode]);
29
+ return {
30
+ half,
31
+ setHalf,
32
+ pair: { light: lightTheme, dark: darkTheme },
33
+ themes: themesForHalf(availableThemes, half),
34
+ nameOf: (id: string) => availableThemes.find(theme => theme.id === id)?.name ?? id,
35
+ choose: setHalfTheme,
36
+ };
37
+ }
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, useMemo } from 'react';
2
2
  import { getItem, setItem } from '../utils/storage';
3
- import type { UpdateAction, UpdateAdvert } from '../utils/updateNotice';
3
+ import type { UpdateAction, UpdateAdvert, UpdateCheckFailingAdvert } from '../utils/updateNotice';
4
4
 
5
5
  declare const __APP_VERSION__: string;
6
6
 
@@ -21,6 +21,11 @@ export interface UpdateInfo {
21
21
  action?: UpdateAction;
22
22
  /** The command for this host, with the remembered install flags. */
23
23
  command?: string;
24
+ /**
25
+ * Checking for updates has failed for hours (no update known): the Options
26
+ * menu says so with `command`. `updateAvailable` is false then.
27
+ */
28
+ checkFailing?: { since: string; error: string };
24
29
  dismiss: () => void;
25
30
  }
26
31
 
@@ -35,13 +40,13 @@ function isDismissedVersion(latestVersion: string): boolean {
35
40
  /**
36
41
  * The Options menu's update state, from the server's advert (`updateAvailable`
37
42
  * on /api/plan and /api/diff). The browser never asks GitHub: the machine
38
- * checks once a day (packages/shared/update-check.ts) and the server passes
43
+ * checks every few minutes (packages/shared/update-check.ts) and the server passes
39
44
  * the answer on, or nothing when update checks are off, in a dev build, or on
40
45
  * a surface with no server.
41
46
  *
42
47
  * `?preview-update=0.29.0` still simulates an update, for screenshots.
43
48
  */
44
- export function useUpdateCheck(advert?: UpdateAdvert | null): UpdateInfo | null {
49
+ export function useUpdateCheck(advert?: UpdateAdvert | null, failing?: UpdateCheckFailingAdvert | null): UpdateInfo | null {
45
50
  const preview = useMemo(() => {
46
51
  if (typeof window === 'undefined') return null;
47
52
  const version = new URLSearchParams(window.location.search).get('preview-update');
@@ -56,7 +61,20 @@ export function useUpdateCheck(advert?: UpdateAdvert | null): UpdateInfo | null
56
61
  setDismissedVersion(latestVersion);
57
62
  }, [latestVersion]);
58
63
 
59
- if (!latestVersion) return null;
64
+ if (!latestVersion) {
65
+ if (!failing) return null;
66
+ const current = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0';
67
+ return {
68
+ currentVersion: current,
69
+ latestVersion: current,
70
+ updateAvailable: false,
71
+ dismissed: true,
72
+ releaseUrl: 'https://github.com/backnotprop/plannotator/releases',
73
+ command: failing.command,
74
+ checkFailing: { since: failing.since, error: failing.error },
75
+ dismiss,
76
+ };
77
+ }
60
78
  const currentVersion = advert?.current ?? (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0');
61
79
  return {
62
80
  currentVersion,
@@ -21,7 +21,7 @@ function isTypingSomewhere(): boolean {
21
21
 
22
22
  /**
23
23
  * The review windows' "a new version is out" toast (design §4.5): from the
24
- * server's advert only, at most once per version and once every three days
24
+ * server's advert only, at most once per version and once a day
25
25
  * (claimUpdateToast), queued behind the first-run dialogs (`blocked`) and
26
26
  * never while a composer or dialog has focus. A blocked attempt is not
27
27
  * consumed: it looks again, and gives up for this load after two minutes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.53.1",
3
+ "version": "0.55.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,6 +12,7 @@
12
12
  "./components/ImageAnnotator": "./components/ImageAnnotator/index.tsx",
13
13
  "./components/html-viewer": "./components/html-viewer/index.ts",
14
14
  "./components/diagram": "./components/diagram/index.ts",
15
+ "./components/diagramLanguages": "./components/diagramLanguages.ts",
15
16
  "./components/diagram/*": "./components/diagram/*.tsx",
16
17
  "./components/html-viewer/bridge-script": "./components/html-viewer/bridge-script.ts",
17
18
  "./components/html-viewer/bridge-script.asset.js": "./components/html-viewer/bridge-script.asset.js",
@@ -28,7 +29,8 @@
28
29
  "./theme-modes": "./components/themeModes.tsx",
29
30
  "./types": "./types.ts",
30
31
  "./theme": "./theme.css",
31
- "./styles.css": "./styles.css"
32
+ "./styles.css": "./styles.css",
33
+ "./contents-rail.css": "./contents-rail.css"
32
34
  },
33
35
  "files": [
34
36
  "components",
@@ -50,6 +52,7 @@
50
52
  "theme.css",
51
53
  "print.css",
52
54
  "styles.css",
55
+ "contents-rail.css",
53
56
  "plannotator.webp",
54
57
  "HANDOFF.md",
55
58
  "!**/*.test.ts",
@@ -57,42 +60,42 @@
57
60
  "!test-setup"
58
61
  ],
59
62
  "dependencies": {
60
- "@base-ui/react": "^1.7.0",
63
+ "@base-ui/react": "^1.9.0",
61
64
  "@codemirror/autocomplete": "^6.20.3",
62
- "@codemirror/commands": "^6.11.0",
65
+ "@codemirror/commands": "^6.11.1",
63
66
  "@codemirror/lang-javascript": "^6.2.5",
64
67
  "@codemirror/lang-json": "^6.0.2",
65
68
  "@codemirror/lang-markdown": "^6.5.2",
66
69
  "@codemirror/lang-python": "^6.2.1",
67
70
  "@codemirror/lang-yaml": "^6.1.3",
68
- "@codemirror/language": "^6.12.4",
69
- "@codemirror/legacy-modes": "^6.5.3",
71
+ "@codemirror/language": "^6.13.1",
72
+ "@codemirror/legacy-modes": "^6.5.5",
70
73
  "@codemirror/merge": "^6.12.2",
71
74
  "@codemirror/search": "^6.7.2",
72
- "@codemirror/state": "^6.7.2",
73
- "@codemirror/view": "^6.43.10",
75
+ "@codemirror/state": "^6.7.6",
76
+ "@codemirror/view": "^6.43.14",
74
77
  "@fontsource-variable/geist-mono": "5.3.0",
75
78
  "@fontsource-variable/inter": "^5.3.0",
76
- "@lezer/common": "^1.5.2",
77
- "@lezer/highlight": "^1.2.3",
78
- "@pierre/diffs": "1.3.6",
79
+ "@lezer/common": "^1.5.3",
80
+ "@lezer/highlight": "^1.2.5",
81
+ "@pierre/diffs": "1.5.2",
79
82
  "@plannotator/atomic-editor": "^0.9.0",
80
- "@plannotator/core": "0.25.13",
83
+ "@plannotator/core": "0.25.14",
81
84
  "@plannotator/markdown-editor": "^0.5.0",
82
85
  "@plannotator/web-highlighter": "^0.8.1",
83
86
  "@tanstack/react-table": "^8.21.3",
84
- "@viz-js/viz": "3.30.0",
87
+ "@viz-js/viz": "3.31.0",
85
88
  "class-variance-authority": "^0.7.1",
86
89
  "clsx": "^2.1.1",
87
90
  "diff": "^8.0.4",
88
- "dompurify": "^3.4.14",
89
- "katex": "^0.16.47",
90
- "lucide-react": "^1.38.0",
91
+ "dompurify": "^3.4.17",
92
+ "katex": "^0.19.0",
93
+ "lucide-react": "^1.59.0",
91
94
  "marked": "^17.0.6",
92
- "mermaid": "12.0.0",
93
- "motion": "^12.38.0",
95
+ "mermaid": "12.2.0",
96
+ "motion": "^14.4.0",
94
97
  "perfect-freehand": "^1.2.2",
95
- "tailwind-merge": "^3.6.0",
98
+ "tailwind-merge": "^3.7.0",
96
99
  "unique-username-generator": "^1.5.1"
97
100
  },
98
101
  "peerDependencies": {
@@ -104,10 +107,10 @@
104
107
  "@happy-dom/global-registrator": "^20.12.0",
105
108
  "@tailwindcss/vite": "^4.3.3",
106
109
  "@types/bun": "^1.2.0",
107
- "@types/react": "^19.2.18",
108
- "@types/react-dom": "^19.2.5",
109
- "react": "^19.2.8",
110
- "react-dom": "^19.2.8",
110
+ "@types/react": "^19.3.0",
111
+ "@types/react-dom": "^19.3.0",
112
+ "react": "^19.3.1",
113
+ "react-dom": "^19.3.1",
111
114
  "tailwindcss": "^4.1.18",
112
115
  "typescript": "~5.8.2",
113
116
  "vite": "^6.2.0"
@@ -8,6 +8,7 @@ export const reviewAnnotationToolbarShortcuts = defineShortcutScope({
8
8
  submitComment: {
9
9
  description: 'Submit comment',
10
10
  bindings: ['Mod+Enter'],
11
+ hint: 'With Settings → General → “Enter saves a comment” on, Enter saves too and Shift+Enter adds a new line.',
11
12
  section: 'Annotations',
12
13
  displayOrder: 10,
13
14
  },
@@ -0,0 +1,82 @@
1
+ import { defineShortcutScope } from '../core';
2
+ import { createShortcutScopeHook } from '../runtime';
3
+
4
+ /**
5
+ * Moving around the repository in code review (approved design record
6
+ * "review-explorer", step 3). VS Code's own default keys per system, so a
7
+ * reviewer's hands already know them. None of them takes a key code review
8
+ * used before; ⌘P / Ctrl+P is the browser's Print, replaced here in code
9
+ * review only.
10
+ */
11
+ export const reviewExplorerShortcuts = defineShortcutScope({
12
+ id: 'review-explorer',
13
+ title: 'Go to',
14
+ shortcuts: {
15
+ goToFile: {
16
+ description: 'Go to File',
17
+ bindings: ['Mod+P'],
18
+ section: 'Go to',
19
+ hint: 'Fuzzy search over every file of the repository at the revision the diff reads. Changed files are marked and win ties; with nothing typed, the files you opened last.',
20
+ preventDefault: true,
21
+ displayOrder: 10,
22
+ },
23
+ goToDefinition: {
24
+ description: 'Go to Definition',
25
+ bindings: ['F12', 'Mod+F12'],
26
+ section: 'Go to',
27
+ hint: 'The symbol under the pointer (or the last one you clicked). One clear definition opens its file at the line; several open the references panel. Mod+F12 is for browsers that keep F12 for their developer tools. Needs the local checkout.',
28
+ preventDefault: true,
29
+ displayOrder: 20,
30
+ },
31
+ goToReferences: {
32
+ description: 'Go to References',
33
+ bindings: ['Shift+F12'],
34
+ section: 'Go to',
35
+ hint: 'Opens the references panel for the symbol under the pointer. A result opens its file at the line.',
36
+ preventDefault: true,
37
+ displayOrder: 30,
38
+ },
39
+ navigateBack: {
40
+ description: 'Go back',
41
+ bindings: ['Ctrl+-'],
42
+ platformBindings: {
43
+ mac: ['Ctrl+-'],
44
+ windows: ['Alt+ArrowLeft'],
45
+ linux: ['Ctrl+Alt+-'],
46
+ },
47
+ section: 'Go to',
48
+ hint: 'Back to where the last jump (Go to File, Go to Definition, a reference) started. Stays inside the review.',
49
+ preventDefault: true,
50
+ displayOrder: 40,
51
+ },
52
+ navigateForward: {
53
+ description: 'Go forward',
54
+ bindings: ['Ctrl+Shift+-'],
55
+ platformBindings: {
56
+ mac: ['Ctrl+Shift+-'],
57
+ windows: ['Alt+ArrowRight'],
58
+ linux: ['Ctrl+Shift+-'],
59
+ },
60
+ section: 'Go to',
61
+ displayOrder: 50,
62
+ preventDefault: true,
63
+ },
64
+ nextChange: {
65
+ description: 'Next change',
66
+ bindings: ['Alt+F5'],
67
+ section: 'Go to',
68
+ hint: 'The next run of changed lines in the open file; past the last one, the next file with changes.',
69
+ preventDefault: true,
70
+ displayOrder: 60,
71
+ },
72
+ previousChange: {
73
+ description: 'Previous change',
74
+ bindings: ['Shift+Alt+F5'],
75
+ section: 'Go to',
76
+ preventDefault: true,
77
+ displayOrder: 70,
78
+ },
79
+ },
80
+ });
81
+
82
+ export const useReviewExplorerShortcuts = createShortcutScopeHook(reviewExplorerShortcuts);
package/shortcuts/core.ts CHANGED
@@ -1,11 +1,21 @@
1
- import { isMac } from '../utils/platform';
1
+ import { isMac, isWindows } from '../utils/platform';
2
2
 
3
3
  export type ShortcutPlatform = 'mac' | 'non-mac' | 'cross-platform';
4
4
 
5
+ /** The three desktop systems a binding can differ on (VS Code's split). */
6
+ export type ShortcutOS = 'mac' | 'windows' | 'linux';
7
+
5
8
  export interface ShortcutDefinition {
6
9
  description: string;
7
10
  /** Alternative bindings for the same action, shown as “or” in the shortcuts UI. */
8
11
  bindings: string[];
12
+ /**
13
+ * Per-system bindings that replace `bindings` on that system (VS Code's
14
+ * defaults differ by OS, e.g. Back is ⌃- on macOS, Alt+← on Windows and
15
+ * Ctrl+Alt+- on Linux). `bindings` stays the fallback and what tools that
16
+ * know nothing of systems read.
17
+ */
18
+ platformBindings?: Partial<Record<ShortcutOS, string[]>>;
9
19
  section: string;
10
20
  hint?: string;
11
21
  preventDefault?: boolean;
@@ -41,6 +51,9 @@ export interface ShortcutSurface {
41
51
 
42
52
  const NAMED_TOKENS = new Set([
43
53
  'Mod',
54
+ // The physical Control key on every system (⌃ on macOS), unlike `Mod`,
55
+ // which is ⌘ on macOS. For VS Code's macOS bindings such as ⌃-.
56
+ 'Ctrl',
44
57
  'Shift',
45
58
  'Alt',
46
59
  'Enter',
@@ -69,13 +82,15 @@ const NAMED_TOKENS = new Set([
69
82
  '}',
70
83
  '?',
71
84
  '$',
85
+ // Matched by its key code, so Shift (which turns it into `_`) still reads as it.
86
+ '-',
72
87
  ]);
73
88
 
74
89
  for (let n = 1; n <= 12; n += 1) {
75
90
  NAMED_TOKENS.add(`F${n}`);
76
91
  }
77
92
 
78
- const MODIFIER_TOKENS = new Set(['Mod', 'Shift', 'Alt']);
93
+ const MODIFIER_TOKENS = new Set(['Mod', 'Ctrl', 'Shift', 'Alt']);
79
94
  const SHIFTED_LITERAL_TOKENS = new Set(['{', '}', '?', '$']);
80
95
  type ShortcutKeyEvent = Pick<
81
96
  KeyboardEvent,
@@ -184,7 +199,11 @@ export function validateShortcutRegistry(registry: ShortcutRegistry): string[] {
184
199
  errors.push(`Shortcut ${id} must define at least one binding.`);
185
200
  }
186
201
 
187
- for (const binding of shortcut.bindings) {
202
+ const allBindings = [
203
+ ...shortcut.bindings,
204
+ ...Object.values(shortcut.platformBindings ?? {}).flat(),
205
+ ];
206
+ for (const binding of allBindings) {
188
207
  if (!binding.trim()) {
189
208
  errors.push(`Shortcut ${id} contains an empty binding.`);
190
209
  continue;
@@ -264,9 +283,36 @@ export function getShortcutPlatform(): Exclude<ShortcutPlatform, 'cross-platform
264
283
  return isMac ? 'mac' : 'non-mac';
265
284
  }
266
285
 
286
+ export function getShortcutOS(): ShortcutOS {
287
+ return isMac ? 'mac' : isWindows ? 'windows' : 'linux';
288
+ }
289
+
290
+ /** The bindings that apply on a system: its own when it has some, else `bindings`. */
291
+ export function resolveShortcutBindings(
292
+ shortcut: Pick<ShortcutDefinition, 'bindings' | 'platformBindings'>,
293
+ os: ShortcutOS = getShortcutOS(),
294
+ ): string[] {
295
+ return shortcut.platformBindings?.[os] ?? shortcut.bindings;
296
+ }
297
+
298
+ const OS_LABELS: Record<ShortcutOS, string> = { mac: 'macOS', windows: 'Windows', linux: 'Linux' };
299
+
300
+ /**
301
+ * A shortcut's bindings as text for a reference that serves every system (the
302
+ * docs): `Cmd+P or Ctrl+P` style text, or one entry per system when they
303
+ * differ ("Ctrl+- (macOS) · Alt+ArrowLeft (Windows) · Ctrl+Alt+- (Linux)").
304
+ */
305
+ export function formatShortcutDefinitionText(shortcut: Pick<ShortcutDefinition, 'bindings' | 'platformBindings'>): string {
306
+ if (!shortcut.platformBindings) return formatShortcutBindingsText(shortcut.bindings);
307
+ return (['mac', 'windows', 'linux'] as const)
308
+ .map((os) => `${formatShortcutBindingsText(resolveShortcutBindings(shortcut, os), os === 'mac' ? 'mac' : 'non-mac')} (${OS_LABELS[os]})`)
309
+ .join(' · ');
310
+ }
311
+
267
312
  function formatKeycapToken(token: string, platform: Exclude<ShortcutPlatform, 'cross-platform'>): string {
268
313
  if (platform === 'mac') {
269
314
  if (token === 'Mod') return '⌘';
315
+ if (token === 'Ctrl') return '⌃';
270
316
  if (token === 'Alt') return '⌥';
271
317
  if (token === 'Shift') return '⇧';
272
318
  if (token === 'Enter') return '⏎';
@@ -293,6 +339,7 @@ function formatTextToken(token: string, platform: ShortcutPlatform): string {
293
339
  return platform === 'mac' ? 'Option' : 'Alt';
294
340
  }
295
341
 
342
+ if (token === 'Ctrl') return 'Ctrl';
296
343
  if (token === 'Escape') return 'Escape';
297
344
  if (token === 'hold') return 'hold';
298
345
  return token;
@@ -359,6 +406,10 @@ function matchesKeyToken(event: ShortcutKeyEvent, token: string): boolean {
359
406
  return event.key === ' ' || event.key === 'Spacebar' || event.code === 'Space';
360
407
  }
361
408
 
409
+ if (token === '-') {
410
+ return event.code === 'Minus' || event.key === '-' || event.key === '_';
411
+ }
412
+
362
413
  if (token === 'A-Z') {
363
414
  return /^[A-Z]$/.test(key);
364
415
  }
@@ -393,6 +444,7 @@ export function matchesShortcutBinding(event: ShortcutKeyEvent, binding: string)
393
444
  if (tokens.length === 0) return false;
394
445
 
395
446
  const requiresMod = tokens.includes('Mod');
447
+ const requiresCtrl = tokens.includes('Ctrl');
396
448
  const requiresShift = tokens.includes('Shift');
397
449
  const requiresAlt = tokens.includes('Alt');
398
450
  const keyTokens = tokens.filter(token => !MODIFIER_TOKENS.has(token));
@@ -403,7 +455,14 @@ export function matchesShortcutBinding(event: ShortcutKeyEvent, binding: string)
403
455
  || (!requiresShift && keyToken === 'A-Z' && event.shiftKey)
404
456
  || (!requiresShift && SHIFTED_LITERAL_TOKENS.has(keyToken) && event.key === keyToken);
405
457
 
406
- if (requiresMod !== (event.metaKey || event.ctrlKey)) return false;
458
+ // `Mod` is ⌘ or Ctrl (as before); `Ctrl` is the physical Control key only.
459
+ if (requiresMod) {
460
+ if (!(event.metaKey || event.ctrlKey)) return false;
461
+ } else if (requiresCtrl) {
462
+ if (!event.ctrlKey || event.metaKey) return false;
463
+ } else if (event.metaKey || event.ctrlKey) {
464
+ return false;
465
+ }
407
466
  if (!shiftMatches) return false;
408
467
  if (requiresAlt !== event.altKey) return false;
409
468
 
@@ -35,3 +35,4 @@ export { reviewAiShortcuts, useReviewAiShortcuts } from './code-review/ai.shortc
35
35
  export { reviewSuggestionModalShortcuts, useReviewSuggestionModalShortcuts } from './code-review/suggestionModal.shortcuts';
36
36
  export { reviewTourDialogShortcuts, useReviewTourDialogShortcuts } from './code-review/tourDialog.shortcuts';
37
37
  export { reviewMarkdownPreviewShortcuts, useReviewMarkdownPreviewShortcuts } from './code-review/markdownPreview.shortcuts';
38
+ export { reviewExplorerShortcuts, useReviewExplorerShortcuts } from './code-review/explorer.shortcuts';
@@ -9,7 +9,7 @@ export const annotationPanelShortcuts = defineShortcutScope({
9
9
  description: 'Save annotation edit',
10
10
  bindings: ['Mod+Enter'],
11
11
  section: 'Annotations',
12
- hint: 'Available while editing an annotation in the side panel.',
12
+ hint: 'Available while editing an annotation in the side panel. With Settings → General → “Enter saves a comment” on, Enter saves too.',
13
13
  displayOrder: 50,
14
14
  },
15
15
  cancelEdit: {
@@ -7,6 +7,7 @@ export const commentPopoverShortcuts = defineShortcutScope({
7
7
  submit: {
8
8
  description: 'Submit comment',
9
9
  bindings: ['Mod+Enter'],
10
+ hint: 'With Settings → General → “Enter saves a comment” on, Enter saves too and Shift+Enter adds a new line.',
10
11
  section: 'Annotations',
11
12
  displayOrder: 30,
12
13
  },
@@ -5,6 +5,7 @@ import {
5
5
  matchesShortcutBinding,
6
6
  matchesShortcutBindingGroup,
7
7
  parseDoubleTapBinding,
8
+ resolveShortcutBindings,
8
9
  } from './core';
9
10
 
10
11
  type ShortcutActionId<TScope extends ShortcutScopeDefinition<any>> = keyof TScope['shortcuts'] & string;
@@ -86,7 +87,7 @@ export function dispatchShortcutEvent<TScope extends ShortcutScopeDefinition<any
86
87
  for (const [actionId, shortcut] of getShortcutEntries(scope)) {
87
88
  const handler = handlers[actionId];
88
89
  if (!handler) continue;
89
- if (!shortcut.bindings.some(binding => matchesShortcutBinding(event, binding))) continue;
90
+ if (!resolveShortcutBindings(shortcut).some(binding => matchesShortcutBinding(event, binding))) continue;
90
91
 
91
92
  const { when, handle } = normalizeShortcutHandler(handler);
92
93
  if (when && !when(event)) continue;
@@ -189,7 +190,7 @@ export function useShortcutScope<TScope extends ShortcutScopeDefinition<any>>({
189
190
  const { when } = normalizeShortcutHandler(handler);
190
191
  if (when && !when(keyboardEvent)) continue;
191
192
 
192
- for (const binding of shortcut.bindings) {
193
+ for (const binding of resolveShortcutBindings(shortcut)) {
193
194
  const groups = binding.trim().split(/\s+/).filter(Boolean);
194
195
  const doubleTapKey = parseDoubleTapBinding(binding);
195
196
  if (
@@ -275,7 +276,7 @@ export function useDoubleTapShortcuts<TScope extends ShortcutScopeDefinition<any
275
276
  // Pre-parse which actions have double-tap bindings
276
277
  const doubleTapActions: Array<{ actionId: ShortcutActionId<TScope>; keyName: string; preventDefault: boolean }> = [];
277
278
  for (const [actionId, shortcut] of getShortcutEntries(scope)) {
278
- for (const binding of shortcut.bindings) {
279
+ for (const binding of resolveShortcutBindings(shortcut)) {
279
280
  const keyName = parseDoubleTapBinding(binding);
280
281
  if (keyName) {
281
282
  doubleTapActions.push({ actionId, keyName, preventDefault: shortcut.preventDefault === true });