@plannotator/ui 0.53.1 → 0.54.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 (135) hide show
  1. package/HANDOFF.md +217 -11
  2. package/README.md +65 -10
  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 +19 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CommentPopover.tsx +44 -8
  14. package/components/DecisionControl.tsx +31 -5
  15. package/components/DiagramBlock.tsx +17 -12
  16. package/components/EditorAnnotationCard.tsx +1 -1
  17. package/components/ExportModal.tsx +77 -7
  18. package/components/HostTitle.tsx +0 -4
  19. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  20. package/components/ImageAnnotator/index.tsx +24 -4
  21. package/components/ImageLightbox.tsx +658 -120
  22. package/components/ImagePeek.tsx +38 -4
  23. package/components/ImageRegions.tsx +369 -0
  24. package/components/ImageThumbnail.tsx +1 -1
  25. package/components/ImageZoomStage.tsx +796 -0
  26. package/components/InboxAnnouncementDialog.tsx +202 -0
  27. package/components/InlineMarkdown.tsx +152 -24
  28. package/components/KeyboardShortcuts.tsx +23 -0
  29. package/components/LazyBlockBoundary.tsx +100 -0
  30. package/components/ListMarker.tsx +3 -3
  31. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  32. package/components/MenuVersionSection.tsx +31 -9
  33. package/components/PlanDecisionMenuExtras.tsx +129 -0
  34. package/components/PlanHeaderMenu.tsx +15 -1
  35. package/components/ProviderIcons.tsx +8 -0
  36. package/components/RefHoverCard.tsx +913 -0
  37. package/components/SendFailureToast.tsx +126 -0
  38. package/components/Settings.tsx +617 -126
  39. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  40. package/components/ThemeProvider.tsx +14 -1
  41. package/components/ThemeTab.tsx +16 -24
  42. package/components/Tooltip.tsx +49 -18
  43. package/components/Viewer.tsx +280 -41
  44. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  45. package/components/ai/NewChatButton.tsx +74 -0
  46. package/components/blocks/CodeBlock.tsx +1 -1
  47. package/components/blocks/HtmlBlock.tsx +7 -1
  48. package/components/blocks/QuestionBlock.tsx +522 -62
  49. package/components/diagram/DiagramComposer.tsx +5 -1
  50. package/components/diagram/DiagramPending.tsx +12 -2
  51. package/components/diagram/DiagramViewer.tsx +19 -3
  52. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  53. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  54. package/components/html-embed/index.ts +3 -0
  55. package/components/html-embed/languages.ts +15 -0
  56. package/components/html-embed/loadGate.ts +107 -0
  57. package/components/html-viewer/HtmlViewer.tsx +47 -6
  58. package/components/html-viewer/bridge-script.asset.js +173 -1
  59. package/components/html-viewer/bridge-script.ts +173 -1
  60. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  61. package/components/imageZoom.ts +265 -0
  62. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  63. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  64. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  65. package/components/sidebar/FileBrowser.tsx +16 -2
  66. package/components/sidebar/SidebarTabs.tsx +3 -0
  67. package/config/index.ts +3 -0
  68. package/config/reviewView.ts +41 -1
  69. package/config/settings.ts +155 -4
  70. package/configure.ts +28 -0
  71. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  72. package/hooks/useAIChat.ts +72 -25
  73. package/hooks/useAnnotationDraft.ts +71 -6
  74. package/hooks/useAnnotationHighlighter.ts +57 -3
  75. package/hooks/useCodeAnnotationDraft.ts +113 -17
  76. package/hooks/useCookieHostSetting.ts +96 -0
  77. package/hooks/useDocumentFont.ts +17 -0
  78. package/hooks/useDocumentTextSize.ts +20 -0
  79. package/hooks/useFileBrowser.ts +10 -1
  80. package/hooks/useLinkedDoc.ts +226 -13
  81. package/hooks/usePackageChangesSetting.ts +90 -0
  82. package/hooks/usePlanLookDialog.ts +67 -0
  83. package/hooks/useSharing.ts +11 -7
  84. package/hooks/useThemePaletteHalves.ts +37 -0
  85. package/hooks/useUpdateCheck.ts +22 -4
  86. package/hooks/useUpdateToast.ts +1 -1
  87. package/package.json +23 -22
  88. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  89. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  90. package/shortcuts/core.ts +63 -4
  91. package/shortcuts/index.ts +1 -0
  92. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  93. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  94. package/shortcuts/runtime.ts +4 -3
  95. package/styles.css +1 -1
  96. package/theme.css +179 -1
  97. package/themes/plannotator-graphite.css +66 -0
  98. package/types.ts +26 -9
  99. package/utils/agentSwitch.ts +41 -20
  100. package/utils/agentToolSetting.ts +1 -1
  101. package/utils/aiChatFormat.ts +23 -7
  102. package/utils/annotationOwnsHighlight.ts +4 -2
  103. package/utils/asciidocParser.ts +570 -0
  104. package/utils/autoUpdateNotice.ts +24 -0
  105. package/utils/belowTitleSlot.ts +174 -0
  106. package/utils/codeLineScroll.ts +100 -0
  107. package/utils/commentSubmitKey.ts +48 -0
  108. package/utils/cssFontFamily.ts +65 -0
  109. package/utils/decisionSpec.ts +190 -2
  110. package/utils/defaultNotesApp.ts +1 -1
  111. package/utils/diffFonts.ts +4 -1
  112. package/utils/documentFont.ts +68 -0
  113. package/utils/documentTextSize.ts +54 -0
  114. package/utils/fontDetect.ts +63 -0
  115. package/utils/inboxAnnouncement.ts +78 -0
  116. package/utils/lookAndFeelAnnouncement.ts +82 -5
  117. package/utils/markdownExtensions.ts +2 -2
  118. package/utils/mermaid.ts +20 -3
  119. package/utils/mermaidTheme.ts +3 -51
  120. package/utils/notion.ts +58 -0
  121. package/utils/parser.ts +28 -1
  122. package/utils/platform.ts +46 -0
  123. package/utils/refCard.ts +82 -0
  124. package/utils/richClipboard.ts +380 -0
  125. package/utils/richClipboardLists.ts +57 -0
  126. package/utils/richCopyPolicy.ts +22 -0
  127. package/utils/scrollAnchor.ts +82 -0
  128. package/utils/sectionRefs.ts +123 -0
  129. package/utils/selectionBoundary.ts +40 -0
  130. package/utils/sendFailure.ts +85 -0
  131. package/utils/sharing.ts +5 -2
  132. package/utils/themeRegistry.ts +10 -0
  133. package/utils/themeTokens.ts +74 -0
  134. package/utils/updateNotice.ts +30 -8
  135. package/utils/vscodePanel.ts +9 -0
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
12
- import { Annotation, type ImageAttachment } from '../types';
12
+ import { Annotation, type DocumentRenderAs, type ImageAttachment } from '../types';
13
13
  import {
14
14
  type SharePayload,
15
15
  parseShareHash,
@@ -109,8 +109,10 @@ export function useSharing(
109
109
  resolveRawHtmlForShare?: () => Promise<string | null>,
110
110
  setRawHtml?: (h: string) => void,
111
111
  setShareHtml?: (h: string) => void,
112
- setRenderAs?: (m: 'markdown' | 'html') => void,
112
+ setRenderAs?: (m: DocumentRenderAs) => void,
113
113
  contentRevision = 0,
114
+ /** Render kind of the shared document; `asciidoc` travels as `r: 'asciidoc'`. */
115
+ renderAs?: DocumentRenderAs,
114
116
  ): UseSharingResult {
115
117
  const [isSharedSession, setIsSharedSession] = useState(false);
116
118
  const [isLoadingShared, setIsLoadingShared] = useState(true);
@@ -136,6 +138,7 @@ export function useSharing(
136
138
  pasteApiUrl,
137
139
  rawHtml,
138
140
  contentRevision,
141
+ renderAs,
139
142
  ]);
140
143
  const latestShareRequestContextRef = useRef(shareRequestContext);
141
144
  latestShareRequestContextRef.current = shareRequestContext;
@@ -178,7 +181,7 @@ export function useSharing(
178
181
  setMarkdown('');
179
182
  } else {
180
183
  setMarkdown(payload.p);
181
- setRenderAs?.('markdown');
184
+ setRenderAs?.(payload.r === 'asciidoc' ? 'asciidoc' : 'markdown');
182
185
  setRawHtml?.('');
183
186
  setShareHtml?.('');
184
187
  }
@@ -226,7 +229,7 @@ export function useSharing(
226
229
  setMarkdown('');
227
230
  } else {
228
231
  setMarkdown(payload.p);
229
- setRenderAs?.('markdown');
232
+ setRenderAs?.(payload.r === 'asciidoc' ? 'asciidoc' : 'markdown');
230
233
  setRawHtml?.('');
231
234
  setShareHtml?.('');
232
235
  }
@@ -290,7 +293,7 @@ export function useSharing(
290
293
  const refreshShareUrl = useCallback(async () => {
291
294
  const requestContext = shareRequestContext;
292
295
  try {
293
- const url = await generateShareUrl(markdown, annotations, globalAttachments, shareBaseUrl, rawHtml);
296
+ const url = await generateShareUrl(markdown, annotations, globalAttachments, shareBaseUrl, rawHtml, renderAs);
294
297
  if (latestShareRequestContextRef.current !== requestContext) return;
295
298
  setShareUrl(url ?? '');
296
299
  setShareUrlSize(url ? formatUrlSize(url) : '');
@@ -300,7 +303,7 @@ export function useSharing(
300
303
  setShareUrl('');
301
304
  setShareUrlSize('');
302
305
  }
303
- }, [markdown, annotations, globalAttachments, shareBaseUrl, rawHtml, shareRequestContext]);
306
+ }, [markdown, annotations, globalAttachments, shareBaseUrl, rawHtml, renderAs, shareRequestContext]);
304
307
 
305
308
  // Auto-refresh share URL when dependencies change
306
309
  useEffect(() => {
@@ -372,6 +375,7 @@ export function useSharing(
372
375
  globalAttachments,
373
376
  { pasteApiUrl, shareBaseUrl },
374
377
  htmlForShare,
378
+ renderAs,
375
379
  );
376
380
 
377
381
  if (latestShareRequestContextRef.current !== requestContext) return null;
@@ -398,7 +402,7 @@ export function useSharing(
398
402
  } finally {
399
403
  if (latestShareRequestContextRef.current === requestContext) setIsGeneratingShortUrl(false);
400
404
  }
401
- }, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl, rawHtml, resolveRawHtmlForShare, shareRequestContext]);
405
+ }, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl, rawHtml, resolveRawHtmlForShare, renderAs, shareRequestContext]);
402
406
 
403
407
  // Import annotations from a teammate's share URL (supports both hash-based and short /p/<id> URLs)
404
408
  const importFromShareUrl = useCallback(async (url: string): Promise<ImportResult> => {
@@ -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.54.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",
@@ -57,42 +58,42 @@
57
58
  "!test-setup"
58
59
  ],
59
60
  "dependencies": {
60
- "@base-ui/react": "^1.7.0",
61
+ "@base-ui/react": "^1.9.0",
61
62
  "@codemirror/autocomplete": "^6.20.3",
62
- "@codemirror/commands": "^6.11.0",
63
+ "@codemirror/commands": "^6.11.1",
63
64
  "@codemirror/lang-javascript": "^6.2.5",
64
65
  "@codemirror/lang-json": "^6.0.2",
65
66
  "@codemirror/lang-markdown": "^6.5.2",
66
67
  "@codemirror/lang-python": "^6.2.1",
67
68
  "@codemirror/lang-yaml": "^6.1.3",
68
- "@codemirror/language": "^6.12.4",
69
- "@codemirror/legacy-modes": "^6.5.3",
69
+ "@codemirror/language": "^6.13.1",
70
+ "@codemirror/legacy-modes": "^6.5.5",
70
71
  "@codemirror/merge": "^6.12.2",
71
72
  "@codemirror/search": "^6.7.2",
72
- "@codemirror/state": "^6.7.2",
73
- "@codemirror/view": "^6.43.10",
73
+ "@codemirror/state": "^6.7.6",
74
+ "@codemirror/view": "^6.43.14",
74
75
  "@fontsource-variable/geist-mono": "5.3.0",
75
76
  "@fontsource-variable/inter": "^5.3.0",
76
- "@lezer/common": "^1.5.2",
77
- "@lezer/highlight": "^1.2.3",
78
- "@pierre/diffs": "1.3.6",
77
+ "@lezer/common": "^1.5.3",
78
+ "@lezer/highlight": "^1.2.5",
79
+ "@pierre/diffs": "1.5.2",
79
80
  "@plannotator/atomic-editor": "^0.9.0",
80
- "@plannotator/core": "0.25.13",
81
+ "@plannotator/core": "0.25.14",
81
82
  "@plannotator/markdown-editor": "^0.5.0",
82
83
  "@plannotator/web-highlighter": "^0.8.1",
83
84
  "@tanstack/react-table": "^8.21.3",
84
- "@viz-js/viz": "3.30.0",
85
+ "@viz-js/viz": "3.31.0",
85
86
  "class-variance-authority": "^0.7.1",
86
87
  "clsx": "^2.1.1",
87
88
  "diff": "^8.0.4",
88
- "dompurify": "^3.4.14",
89
- "katex": "^0.16.47",
90
- "lucide-react": "^1.38.0",
89
+ "dompurify": "^3.4.17",
90
+ "katex": "^0.18.11",
91
+ "lucide-react": "^1.59.0",
91
92
  "marked": "^17.0.6",
92
- "mermaid": "12.0.0",
93
- "motion": "^12.38.0",
93
+ "mermaid": "12.2.0",
94
+ "motion": "^14.4.0",
94
95
  "perfect-freehand": "^1.2.2",
95
- "tailwind-merge": "^3.6.0",
96
+ "tailwind-merge": "^3.7.0",
96
97
  "unique-username-generator": "^1.5.1"
97
98
  },
98
99
  "peerDependencies": {
@@ -104,10 +105,10 @@
104
105
  "@happy-dom/global-registrator": "^20.12.0",
105
106
  "@tailwindcss/vite": "^4.3.3",
106
107
  "@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",
108
+ "@types/react": "^19.3.0",
109
+ "@types/react-dom": "^19.3.0",
110
+ "react": "^19.3.1",
111
+ "react-dom": "^19.3.1",
111
112
  "tailwindcss": "^4.1.18",
112
113
  "typescript": "~5.8.2",
113
114
  "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 });