@plannotator/ui 0.53.0 → 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 (145) hide show
  1. package/HANDOFF.md +284 -27
  2. package/README.md +84 -14
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +177 -34
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/AnnotationToolstrip.tsx +45 -40
  9. package/components/ApproveDropdown.tsx +7 -9
  10. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  11. package/components/AttachmentsButton.tsx +1 -1
  12. package/components/BlockRenderer.tsx +19 -5
  13. package/components/CodeFilePopout.tsx +140 -5
  14. package/components/CommentPopover.tsx +46 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DiagramBlock.tsx +17 -12
  17. package/components/EditorAnnotationCard.tsx +1 -1
  18. package/components/ExportModal.tsx +77 -7
  19. package/components/HostTitle.tsx +0 -4
  20. package/components/HtmlSurfaceControls.tsx +2 -1
  21. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  22. package/components/ImageAnnotator/index.tsx +24 -4
  23. package/components/ImageLightbox.tsx +658 -120
  24. package/components/ImagePeek.tsx +38 -4
  25. package/components/ImageRegions.tsx +369 -0
  26. package/components/ImageThumbnail.tsx +1 -1
  27. package/components/ImageZoomStage.tsx +796 -0
  28. package/components/InboxAnnouncementDialog.tsx +202 -0
  29. package/components/InlineMarkdown.tsx +152 -24
  30. package/components/KeyboardShortcuts.tsx +23 -0
  31. package/components/LazyBlockBoundary.tsx +100 -0
  32. package/components/ListMarker.tsx +3 -3
  33. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  34. package/components/MentionPicker.tsx +51 -0
  35. package/components/MenuVersionSection.tsx +31 -9
  36. package/components/PlanDecisionMenuExtras.tsx +129 -0
  37. package/components/PlanHeaderMenu.tsx +15 -1
  38. package/components/ProviderIcons.tsx +8 -0
  39. package/components/RefHoverCard.tsx +913 -0
  40. package/components/SendFailureToast.tsx +126 -0
  41. package/components/Settings.tsx +617 -126
  42. package/components/SkillReferenceMenu.tsx +39 -12
  43. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  44. package/components/ThemeProvider.tsx +14 -1
  45. package/components/ThemeTab.tsx +16 -24
  46. package/components/Tooltip.tsx +49 -18
  47. package/components/Viewer.tsx +316 -47
  48. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  49. package/components/ai/NewChatButton.tsx +74 -0
  50. package/components/blocks/CodeBlock.tsx +1 -1
  51. package/components/blocks/HtmlBlock.tsx +7 -1
  52. package/components/blocks/QuestionBlock.tsx +522 -62
  53. package/components/blocks/QuestionContext.tsx +3 -0
  54. package/components/diagram/DiagramComposer.tsx +5 -1
  55. package/components/diagram/DiagramPending.tsx +12 -2
  56. package/components/diagram/DiagramViewer.tsx +19 -3
  57. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  58. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  59. package/components/html-embed/index.ts +3 -0
  60. package/components/html-embed/languages.ts +15 -0
  61. package/components/html-embed/loadGate.ts +107 -0
  62. package/components/html-viewer/HtmlViewer.tsx +69 -6
  63. package/components/html-viewer/bridge-script.asset.js +504 -12
  64. package/components/html-viewer/bridge-script.lite.ts +1 -1
  65. package/components/html-viewer/bridge-script.ts +545 -12
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +265 -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/hooks/pfm/useCodeFilePopout.ts +7 -1
  78. package/hooks/useAIChat.ts +72 -25
  79. package/hooks/useAnnotationDraft.ts +71 -6
  80. package/hooks/useAnnotationHighlighter.ts +57 -3
  81. package/hooks/useCodeAnnotationDraft.ts +113 -17
  82. package/hooks/useCookieHostSetting.ts +96 -0
  83. package/hooks/useDocumentFont.ts +17 -0
  84. package/hooks/useDocumentTextSize.ts +20 -0
  85. package/hooks/useFileBrowser.ts +10 -1
  86. package/hooks/useLinkedDoc.ts +226 -13
  87. package/hooks/useMentionAutocomplete.ts +1 -1
  88. package/hooks/usePackageChangesSetting.ts +90 -0
  89. package/hooks/usePlanLookDialog.ts +67 -0
  90. package/hooks/useSharing.ts +11 -7
  91. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  92. package/hooks/useThemePaletteHalves.ts +37 -0
  93. package/hooks/useUpdateCheck.ts +22 -4
  94. package/hooks/useUpdateToast.ts +1 -1
  95. package/hooks/useVimSelection.ts +9 -0
  96. package/package.json +23 -22
  97. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  98. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  99. package/shortcuts/core.ts +63 -4
  100. package/shortcuts/index.ts +1 -0
  101. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  102. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  103. package/shortcuts/runtime.ts +14 -6
  104. package/styles.css +1 -1
  105. package/theme.css +179 -1
  106. package/themes/plannotator-graphite.css +66 -0
  107. package/types.ts +26 -9
  108. package/utils/agentSwitch.ts +41 -20
  109. package/utils/agentToolSetting.ts +1 -1
  110. package/utils/aiChatFormat.ts +23 -7
  111. package/utils/annotationOwnsHighlight.ts +4 -2
  112. package/utils/asciidocParser.ts +570 -0
  113. package/utils/autoUpdateNotice.ts +24 -0
  114. package/utils/belowTitleSlot.ts +174 -0
  115. package/utils/codeLineScroll.ts +100 -0
  116. package/utils/commentSubmitKey.ts +48 -0
  117. package/utils/cssFontFamily.ts +65 -0
  118. package/utils/decisionSpec.ts +190 -2
  119. package/utils/defaultNotesApp.ts +1 -1
  120. package/utils/diffFonts.ts +4 -1
  121. package/utils/documentFont.ts +68 -0
  122. package/utils/documentTextSize.ts +54 -0
  123. package/utils/fontDetect.ts +63 -0
  124. package/utils/inboxAnnouncement.ts +78 -0
  125. package/utils/lookAndFeelAnnouncement.ts +82 -5
  126. package/utils/markdownExtensions.ts +2 -2
  127. package/utils/mentions.ts +34 -5
  128. package/utils/mermaid.ts +20 -3
  129. package/utils/mermaidTheme.ts +3 -51
  130. package/utils/notion.ts +58 -0
  131. package/utils/parser.ts +28 -1
  132. package/utils/platform.ts +46 -0
  133. package/utils/refCard.ts +82 -0
  134. package/utils/richClipboard.ts +380 -0
  135. package/utils/richClipboardLists.ts +57 -0
  136. package/utils/richCopyPolicy.ts +22 -0
  137. package/utils/scrollAnchor.ts +82 -0
  138. package/utils/sectionRefs.ts +123 -0
  139. package/utils/selectionBoundary.ts +40 -0
  140. package/utils/sendFailure.ts +85 -0
  141. package/utils/sharing.ts +5 -2
  142. package/utils/themeRegistry.ts +10 -0
  143. package/utils/themeTokens.ts +74 -0
  144. package/utils/updateNotice.ts +30 -8
  145. 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> => {
@@ -59,14 +59,18 @@ export interface UseSkillReferenceAutocompleteResult {
59
59
  * active row and dismisses the menu when the user has engaged with it (a row
60
60
  * is active or a query was typed); on a bare-trigger menu with no engagement
61
61
  * it passes through, so Escape still closes the composer in one press.
62
+ * `bareEscapeDismisses` (default off) makes Escape close any open menu first,
63
+ * for a composer where Escape costs more than a press (the Inbox's compose
64
+ * dialog asks whether to keep a draft).
62
65
  */
63
66
  export function useSkillReferenceAutocomplete(options: {
64
67
  text: string;
65
68
  setText: (text: string) => void;
66
69
  textareaRef: React.RefObject<HTMLTextAreaElement | null>;
67
70
  enabled: boolean;
71
+ bareEscapeDismisses?: boolean;
68
72
  }): UseSkillReferenceAutocompleteResult {
69
- const { text, setText, textareaRef, enabled } = options;
73
+ const { text, setText, textareaRef, enabled, bareEscapeDismisses = false } = options;
70
74
  // Seed from the memory cache only when the surface opted in — a disabled
71
75
  // composer must stay inert even after another surface warmed the catalog.
72
76
  const [catalog, setCatalog] = useState<SkillCatalogEntry[]>(() =>
@@ -205,7 +209,7 @@ export function useSkillReferenceAutocomplete(options: {
205
209
  // Only consume Escape when the user engaged with the menu (typed a
206
210
  // query or activated a row). A bare-trigger menu the user ignored
207
211
  // must not cost an extra Escape on the way to closing the composer.
208
- if (boundedActive === null && trigger.query.length === 0) return false;
212
+ if (!bareEscapeDismisses && boundedActive === null && trigger.query.length === 0) return false;
209
213
  e.preventDefault();
210
214
  e.stopPropagation();
211
215
  setActiveIndex(null);
@@ -215,7 +219,7 @@ export function useSkillReferenceAutocomplete(options: {
215
219
  return false;
216
220
  }
217
221
  },
218
- [boundedActive, items.length, open, select, trigger],
222
+ [bareEscapeDismisses, boundedActive, items.length, open, select, trigger],
219
223
  );
220
224
 
221
225
  const humanOnlyReferences = useMemo(
@@ -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.
@@ -317,8 +317,16 @@ export function useVimSelection({
317
317
  return next;
318
318
  }, [containerRef, setState]);
319
319
 
320
+ // Whether vim was on since the last reset. The disabled branch below resets
321
+ // only when vim actually goes from on to off: on a mount where vim was never
322
+ // on there is nothing to reset, and `window.getSelection()` in
323
+ // `clearSelectionWithin` forces a layout on every document open.
324
+ const wasEnabledRef = useRef(enabled);
325
+
320
326
  useEffect(() => {
321
327
  if (!enabled) {
328
+ if (!wasEnabledRef.current) return;
329
+ wasEnabledRef.current = false;
322
330
  setState(createInitialVimSelectionState());
323
331
  setFocused(false);
324
332
  setHelpOpen(false);
@@ -326,6 +334,7 @@ export function useVimSelection({
326
334
  clearSelectionWithin(containerRef.current);
327
335
  return;
328
336
  }
337
+ wasEnabledRef.current = true;
329
338
  if (!focused) return;
330
339
  if (restoringFocusRef.current) {
331
340
  restoringFocusRef.current = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.53.0",
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
  },