@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,276 @@
1
+ /**
2
+ * The image lightbox's zoom math, kept pure so it can be tested without a
3
+ * layout engine. A viewport is a translate plus a scale on the image, where
4
+ * scale 1 is the image's own pixels (Actual size) and the translate is the
5
+ * image's top-left corner inside the stage, in CSS pixels.
6
+ *
7
+ * The rules: the smallest scale is Fit (never above 1, so a small image is
8
+ * not blown up), the largest is 8 (800%); an axis on which the image is
9
+ * smaller than the stage is centred, and an axis on which it is larger is
10
+ * kept covering the stage, so the image can never be panned out of sight.
11
+ */
12
+
13
+ export interface ZoomSize {
14
+ readonly width: number;
15
+ readonly height: number;
16
+ }
17
+
18
+ export interface ZoomViewport {
19
+ readonly x: number;
20
+ readonly y: number;
21
+ readonly scale: number;
22
+ }
23
+
24
+ export interface ZoomLimits {
25
+ readonly min: number;
26
+ readonly max: number;
27
+ }
28
+
29
+ /** The share of the stage Fit may fill on each axis (1 = edge to edge). */
30
+ export interface ZoomInset {
31
+ readonly x: number;
32
+ readonly y: number;
33
+ }
34
+
35
+ /** 800%: an image pixel is then 8 screen pixels, enough to read any screenshot. */
36
+ export const LIGHTBOX_ZOOM_MAX = 8;
37
+ /** Wheel zoom: factor = exp(-deltaY * sensitivity), with deltaY capped so a
38
+ * mouse notch (100 px) is about 1.28x, close to the diagram canvas's step,
39
+ * while a trackpad pinch (a few px per event) stays smooth. */
40
+ export const WHEEL_ZOOM_SENSITIVITY = 0.01;
41
+ export const WHEEL_ZOOM_DELTA_CAP = 25;
42
+ /** A plain-wheel gesture keeps the meaning it started with (zoom or scroll)
43
+ * until the wheel rests this long, so wheeling in past the screen's edge
44
+ * does not flip from zooming to scrolling halfway through. */
45
+ export const WHEEL_LATCH_MS = 250;
46
+ /** A press that travels less than this is a tap (a click), not a pan. */
47
+ export const TAP_SLOP_MOUSE_PX = 4;
48
+ export const TAP_SLOP_TOUCH_PX = 10;
49
+ /** Two taps this close in time and space are a double tap (or double click). */
50
+ export const DOUBLE_TAP_MS = 300;
51
+ export const DOUBLE_TAP_SLOP_PX = 24;
52
+ /** A double click at Fit zooms to twice the Fit scale. */
53
+ export const DOUBLE_TAP_ZOOM = 2;
54
+
55
+ /**
56
+ * Which way Fit fits: the whole image (`contain`, the default), or its width
57
+ * (a tall screenshot read from the top) or height (a panorama read from the
58
+ * left). The smallest scale is always `contain`, so a reader can zoom out to
59
+ * the whole image from either.
60
+ */
61
+ export type FitAxis = 'contain' | 'width' | 'height';
62
+
63
+ const FULL: ZoomInset = { x: 1, y: 1 };
64
+
65
+ function valid(size: ZoomSize | null | undefined): size is ZoomSize {
66
+ return !!size && size.width > 0 && size.height > 0;
67
+ }
68
+
69
+ export function clampNumber(value: number, min: number, max: number): number {
70
+ return Math.min(max, Math.max(min, value));
71
+ }
72
+
73
+ /** The scale at which the whole image (or its width, or its height) fits the stage, never above 1. */
74
+ export function fitScale(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL, axis: FitAxis = 'contain'): number {
75
+ if (!valid(image) || !valid(stage)) return 1;
76
+ const across = (stage.width * inset.x) / image.width;
77
+ const down = (stage.height * inset.y) / image.height;
78
+ if (axis === 'width') return Math.min(1, across);
79
+ if (axis === 'height') return Math.min(1, down);
80
+ return Math.min(1, across, down);
81
+ }
82
+
83
+ export function zoomLimits(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL): ZoomLimits {
84
+ const min = fitScale(image, stage, inset);
85
+ return { min, max: Math.max(min, LIGHTBOX_ZOOM_MAX) };
86
+ }
87
+
88
+ function placeAxis(position: number, content: number, room: number): number {
89
+ if (content <= room) return (room - content) / 2;
90
+ return clampNumber(position, room - content, 0);
91
+ }
92
+
93
+ /** The scale within the limits, and the image centred or covering the stage on each axis. */
94
+ export function clampViewport(view: ZoomViewport, image: ZoomSize, stage: ZoomSize, limits: ZoomLimits): ZoomViewport {
95
+ const scale = clampNumber(view.scale, limits.min, limits.max);
96
+ return {
97
+ scale,
98
+ x: placeAxis(view.x, image.width * scale, stage.width),
99
+ y: placeAxis(view.y, image.height * scale, stage.height),
100
+ };
101
+ }
102
+
103
+ /** Fit: the whole image, centred (or its width from the top, or its height from the left). */
104
+ export function fitViewport(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL, axis: FitAxis = 'contain'): ZoomViewport {
105
+ const limits = zoomLimits(image, stage, inset);
106
+ return clampViewport({ x: 0, y: 0, scale: fitScale(image, stage, inset, axis) }, image, stage, limits);
107
+ }
108
+
109
+ /**
110
+ * Actual size: the image's own pixels. An axis on which it is larger than the
111
+ * stage starts at its top or left edge (a tall screenshot is read from the
112
+ * top); an axis on which it is smaller is centred.
113
+ */
114
+ export function actualViewport(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL): ZoomViewport {
115
+ const limits = zoomLimits(image, stage, inset);
116
+ return clampViewport({ x: 0, y: 0, scale: 1 }, image, stage, limits);
117
+ }
118
+
119
+ /** Zoom by `factor` about a point of the stage, so the pixel under it stays under it. */
120
+ export function zoomAt(
121
+ view: ZoomViewport,
122
+ factor: number,
123
+ px: number,
124
+ py: number,
125
+ image: ZoomSize,
126
+ stage: ZoomSize,
127
+ limits: ZoomLimits,
128
+ ): ZoomViewport {
129
+ const scale = clampNumber(view.scale * factor, limits.min, limits.max);
130
+ const ratio = scale / view.scale;
131
+ return clampViewport({ x: px - (px - view.x) * ratio, y: py - (py - view.y) * ratio, scale }, image, stage, limits);
132
+ }
133
+
134
+ /** Move the image by a pointer's travel (or a scroll's, negated by the caller). */
135
+ export function panBy(view: ZoomViewport, dx: number, dy: number, image: ZoomSize, stage: ZoomSize, limits: ZoomLimits): ZoomViewport {
136
+ return clampViewport({ x: view.x + dx, y: view.y + dy, scale: view.scale }, image, stage, limits);
137
+ }
138
+
139
+ /** Whether the image is larger than the stage on either axis (so there is something to pan). */
140
+ export function overflows(view: ZoomViewport, image: ZoomSize, stage: ZoomSize): boolean {
141
+ return image.width * view.scale > stage.width + 0.5 || image.height * view.scale > stage.height + 0.5;
142
+ }
143
+
144
+ /** Whether a viewport is at the Fit scale (`limits.min`, or a given Fit scale). */
145
+ export function atFit(view: ZoomViewport, limits: ZoomLimits | number): boolean {
146
+ const fit = typeof limits === 'number' ? limits : limits.min;
147
+ return Math.abs(view.scale - fit) < 1e-3;
148
+ }
149
+
150
+ /**
151
+ * Whether the part of an image on screen (`visibleBox`, its own pixels) can
152
+ * still pan sideways: `1` shows more of its right (the finger moving left),
153
+ * `-1` more of its left. A swipe that way pans first, and steps once the
154
+ * image is at its edge (approval "inbox-image-swipe").
155
+ */
156
+ export function canPanToward(box: Pick<NaturalBox, 'x' | 'w'> | null, naturalWidth: number, dir: 1 | -1): boolean {
157
+ if (!box || !(naturalWidth > 0)) return false;
158
+ return dir > 0 ? box.x + box.w < naturalWidth - 1 : box.x > 1;
159
+ }
160
+
161
+ /** The share of a stage that leaves `padding` CSS pixels on every side (at least a tenth). */
162
+ export function paddingInset(stage: ZoomSize, padding: number): ZoomInset {
163
+ if (!valid(stage) || padding <= 0) return FULL;
164
+ return { x: Math.max(0.1, (stage.width - 2 * padding) / stage.width), y: Math.max(0.1, (stage.height - 2 * padding) / stage.height) };
165
+ }
166
+
167
+ /**
168
+ * A wheel event's travel in CSS pixels. Lines (Firefox's mouse wheel) count
169
+ * 16 px and pages the stage's height. Shift turns a vertical wheel sideways,
170
+ * as it does on a page (macOS already reports it as deltaX).
171
+ */
172
+ export function wheelTravel(
173
+ event: { deltaX: number; deltaY: number; deltaMode: number; shiftKey: boolean },
174
+ pageHeight: number,
175
+ ): { dx: number; dy: number } {
176
+ const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? pageHeight : 1;
177
+ let dx = event.deltaX * unit;
178
+ let dy = event.deltaY * unit;
179
+ if (event.shiftKey && dx === 0) {
180
+ dx = dy;
181
+ dy = 0;
182
+ }
183
+ return { dx, dy };
184
+ }
185
+
186
+ /** The zoom factor for a wheel's vertical travel: up (negative) zooms in. */
187
+ export function wheelZoomFactor(dy: number): number {
188
+ return Math.exp(-clampNumber(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP) * WHEEL_ZOOM_SENSITIVITY);
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Region boxes (#2100): a box drawn on the image is kept in the image's own
193
+ // (natural) pixels, so it is the same box at every zoom and pan, and the
194
+ // agent can crop exactly that spot. These convert between the stage (CSS
195
+ // pixels, relative to the stage's top-left) and the image.
196
+ // ---------------------------------------------------------------------------
197
+
198
+ /** A rectangle in the image's own pixels (integers once made by `naturalBoxFromPoints`). */
199
+ export interface NaturalBox {
200
+ readonly x: number;
201
+ readonly y: number;
202
+ readonly w: number;
203
+ readonly h: number;
204
+ }
205
+
206
+ /** A rectangle in stage pixels. */
207
+ export interface StageRect {
208
+ readonly left: number;
209
+ readonly top: number;
210
+ readonly width: number;
211
+ readonly height: number;
212
+ }
213
+
214
+ /** A stage point (CSS pixels from the stage's top-left) in the image's own pixels. Not clamped. */
215
+ export function stageToNatural(view: ZoomViewport, px: number, py: number): { x: number; y: number } {
216
+ return { x: (px - view.x) / view.scale, y: (py - view.y) / view.scale };
217
+ }
218
+
219
+ /**
220
+ * The box two stage points span (a drag's start and end, in either
221
+ * direction), in the image's own pixels: clamped to the image, widened to
222
+ * whole pixels (floor the near edges, ceil the far ones), so the box always
223
+ * covers what the reviewer drew. Null when nothing of it is on the image.
224
+ */
225
+ export function naturalBoxFromPoints(
226
+ view: ZoomViewport,
227
+ a: { x: number; y: number },
228
+ b: { x: number; y: number },
229
+ image: ZoomSize,
230
+ ): NaturalBox | null {
231
+ const p = stageToNatural(view, a.x, a.y);
232
+ const q = stageToNatural(view, b.x, b.y);
233
+ const x0 = clampNumber(Math.floor(Math.min(p.x, q.x)), 0, image.width);
234
+ const y0 = clampNumber(Math.floor(Math.min(p.y, q.y)), 0, image.height);
235
+ const x1 = clampNumber(Math.ceil(Math.max(p.x, q.x)), 0, image.width);
236
+ const y1 = clampNumber(Math.ceil(Math.max(p.y, q.y)), 0, image.height);
237
+ if (x1 - x0 < 1 || y1 - y0 < 1) return null;
238
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
239
+ }
240
+
241
+ /** Where a natural-pixel box is on the stage at a viewport. */
242
+ export function naturalBoxToStage(view: ZoomViewport, box: NaturalBox): StageRect {
243
+ return { left: view.x + box.x * view.scale, top: view.y + box.y * view.scale, width: box.w * view.scale, height: box.h * view.scale };
244
+ }
245
+
246
+ /**
247
+ * The part of the image the stage shows now, in the image's own pixels:
248
+ * what Enter comments on in Region mode (the keyboard's way to a box: frame
249
+ * the spot with Actual size and the scroll keys, then press Enter). Null when
250
+ * nothing of the image is on the stage.
251
+ */
252
+ export function visibleNaturalBox(view: ZoomViewport, image: ZoomSize, stage: ZoomSize): NaturalBox | null {
253
+ return naturalBoxFromPoints(view, { x: 0, y: 0 }, { x: stage.width, y: stage.height }, image);
254
+ }
255
+
256
+ /** The share of the stage a box fills when the viewer zooms to it. */
257
+ export const ZOOM_TO_BOX_SHARE = 0.5;
258
+
259
+ /**
260
+ * The viewport that shows a box large and centred: it fills `share` of the
261
+ * stage on its tighter axis, within the zoom limits (a box larger than that
262
+ * share at Fit stays at Fit), then the usual clamp keeps the image covering
263
+ * the stage, so a box at an edge sits as near the centre as the image allows.
264
+ */
265
+ export function viewportForBox(
266
+ box: NaturalBox,
267
+ image: ZoomSize,
268
+ stage: ZoomSize,
269
+ limits: ZoomLimits,
270
+ share = ZOOM_TO_BOX_SHARE,
271
+ ): ZoomViewport {
272
+ const scale = clampNumber(Math.min((stage.width * share) / Math.max(1, box.w), (stage.height * share) / Math.max(1, box.h)), limits.min, limits.max);
273
+ const cx = box.x + box.w / 2;
274
+ const cy = box.y + box.h / 2;
275
+ return clampViewport({ scale, x: stage.width / 2 - cx * scale, y: stage.height / 2 - cy * scale }, image, stage, limits);
276
+ }
@@ -573,7 +573,7 @@ const DiffBlockRenderer: React.FC<DiffBlockRendererProps> = ({
573
573
  switch (block.type) {
574
574
  case "unchanged":
575
575
  return (
576
- <div className={`plan-diff-unchanged ${unchangedClassName ?? DEFAULT_UNCHANGED_CLASS}`}>
576
+ <div className={`plan-diff-unchanged ${unchangedClassName ?? DEFAULT_UNCHANGED_CLASS}`} data-doc-text="">
577
577
  <MarkdownChunk content={block.content} />
578
578
  </div>
579
579
  );
@@ -36,6 +36,12 @@ interface PlanDiffViewerProps {
36
36
  selectedAnnotationId?: string | null;
37
37
  mode?: EditorMode;
38
38
  onOpenVscodeDiff?: (baseVersion: number) => Promise<{ ok?: boolean; error?: string }>;
39
+ /**
40
+ * Grow the diff card to the full height the parent gives it, like Viewer's
41
+ * `fillHeight`, so a short diff's card still runs to the bottom of the view.
42
+ * Default false: the card sizes to its content.
43
+ */
44
+ fillHeight?: boolean;
39
45
  }
40
46
 
41
47
  const defaultOpenVscodeDiff = async (
@@ -77,6 +83,7 @@ export const PlanDiffViewer: React.FC<PlanDiffViewerProps> = ({
77
83
  selectedAnnotationId,
78
84
  mode,
79
85
  onOpenVscodeDiff,
86
+ fillHeight = false,
80
87
  }) => {
81
88
  const [vscodeDiffLoading, setVscodeDiffLoading] = useState(false);
82
89
  const [vscodeDiffError, setVscodeDiffError] = useState<string | null>(null);
@@ -102,8 +109,12 @@ export const PlanDiffViewer: React.FC<PlanDiffViewerProps> = ({
102
109
  };
103
110
 
104
111
  return (
105
- <div className="relative z-50 w-full" style={maxWidth ? { maxWidth } : { maxWidth: 832 }}>
106
- <article className="w-full bg-card border border-border/50 rounded-xl shadow-xl p-5 md:p-8 lg:p-10 xl:p-12 relative">
112
+ <div
113
+ className={`relative z-50 w-full${fillHeight ? ' flex flex-col print:block' : ''}`}
114
+ data-viewer-fill-height={fillHeight ? '' : undefined}
115
+ style={maxWidth ? { maxWidth } : { maxWidth: 832 }}
116
+ >
117
+ <article className={`w-full ${fillHeight ? 'flex-1 ' : ''}bg-card border border-border/50 rounded-xl shadow-xl p-5 md:p-8 lg:p-10 xl:p-12 relative`} data-doc-text-root="">
107
118
  {/* Top-left: repo info + diff badge — matches Viewer layout (flex-col) so badge doesn't jump position */}
108
119
  <div className="absolute top-3 left-3 md:top-4 md:left-5 flex flex-col items-start gap-1 text-[9px] text-muted-foreground/50 font-mono">
109
120
  {repoInfo && (
@@ -67,7 +67,7 @@ export const PlanRawDiffView: React.FC<PlanRawDiffViewProps> = ({ blocks }) => {
67
67
  }, [blocks]);
68
68
 
69
69
  return (
70
- <div className="font-mono text-[13px] leading-relaxed bg-muted/30 rounded-lg border border-border/30 overflow-x-auto">
70
+ <div className="font-mono text-[13px] leading-relaxed bg-muted/30 rounded-lg border border-border/30 overflow-x-auto" data-doc-text="">
71
71
  <div>
72
72
  {lines.map((line, index) => (
73
73
  <div
@@ -10,6 +10,7 @@ import { Search, X } from "lucide-react";
10
10
  import type { VaultNode } from "../../types";
11
11
  import type { DirState } from "../../hooks/useFileBrowser";
12
12
  import { CountBadge } from "./CountBadge";
13
+ import { HostTitle } from "../HostTitle";
13
14
  import { ObsidianIconRaw } from "../icons/ObsidianIcons";
14
15
  import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types";
15
16
  import { normalizeBrowserPath } from "@plannotator/core/browser-paths";
@@ -282,7 +283,9 @@ const TreeNode: React.FC<{
282
283
  <svg className="w-3 h-3 flex-shrink-0 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
283
284
  <path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
284
285
  </svg>
285
- <span className="truncate">{node.name}</span>
286
+ <HostTitle title={node.name} cut>
287
+ <span className="truncate">{node.name}</span>
288
+ </HostTitle>
286
289
  <div className="ml-auto flex flex-shrink-0 items-center gap-1.5 text-[10px]">
287
290
  {(aggregateChange.additions > 0 || aggregateChange.deletions > 0) && (
288
291
  <>
@@ -360,7 +363,9 @@ const TreeNode: React.FC<{
360
363
  <svg className="w-3 h-3 flex-shrink-0 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
361
364
  <path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
362
365
  </svg>
363
- <span className={`truncate flex-1 min-w-0 ${isDeleted ? "line-through" : ""}`}>{displayName}</span>
366
+ <HostTitle title={node.name} cut>
367
+ <span className={`truncate flex-1 min-w-0 ${isDeleted ? "line-through" : ""}`}>{displayName}</span>
368
+ </HostTitle>
364
369
  <div className="ml-auto flex flex-shrink-0 items-center gap-1.5 text-[10px]">
365
370
  {isActive && selectionPending && (
366
371
  <span className="text-primary">Opening…</span>
@@ -455,10 +460,19 @@ const DirSection: React.FC<{
455
460
  selectionPending={selectionPending}
456
461
  />
457
462
  ))}
463
+ {dir.truncated && <FileLimitNote limit={dir.fileLimit} />}
458
464
  </div>
459
465
  );
460
466
  };
461
467
 
468
+ /** The folder holds more supported files than the server lists; say so once, quietly, at the foot of its tree. */
469
+ export const FileLimitNote: React.FC<{ limit?: number }> = ({ limit }) => (
470
+ <div data-file-limit-note className="px-2 pt-2 pb-1 text-[10px] leading-snug text-muted-foreground">
471
+ {limit ? `Showing the first ${limit.toLocaleString("en-US")} files.` : "Showing some of the files."}{" "}
472
+ Raise <code className="font-mono">PLANNOTATOR_FILE_BROWSER_MAX_FILES</code> to see more.
473
+ </div>
474
+ );
475
+
462
476
  export const FileBrowser: React.FC<FileBrowserProps> = ({
463
477
  dirs,
464
478
  expandedFolders,
@@ -24,6 +24,7 @@ interface SidebarTabsProps {
24
24
  hasFileAnnotations?: boolean;
25
25
  hasMessageAnnotations?: boolean;
26
26
  className?: string;
27
+ style?: React.CSSProperties;
27
28
  }
28
29
 
29
30
  export const SidebarTabs: React.FC<SidebarTabsProps> = ({
@@ -40,11 +41,13 @@ export const SidebarTabs: React.FC<SidebarTabsProps> = ({
40
41
  hasFileAnnotations,
41
42
  hasMessageAnnotations,
42
43
  className,
44
+ style,
43
45
  }) => {
44
46
  return (
45
47
  <div
46
48
  data-sidebar-tabs="true"
47
49
  className={`flex flex-col gap-1 pt-3 pl-0.5 flex-shrink-0 ${className ?? ""}`}
50
+ style={style}
48
51
  >
49
52
  {showAgentTerminalTab && onToggleAgentTerminal && (
50
53
  <button
package/config/index.ts CHANGED
@@ -4,9 +4,12 @@ export { useConfigValue } from './useConfig';
4
4
  export {
5
5
  setReviewPanelView,
6
6
  setReviewDefaultDiffType,
7
+ getProviderReviewDefaultDiffType,
8
+ setProviderReviewDefaultDiffType,
7
9
  getPersistedReviewPanelView,
8
10
  setReviewAutoViewed,
9
11
  needsAutoViewedNotice,
10
12
  markAutoViewedNoticeSeen,
11
13
  type ReviewDefaultDiffType,
14
+ type VcsReviewSettingsDescriptor,
12
15
  } from './reviewView';
@@ -2,6 +2,20 @@ import { configStore } from './configStore';
2
2
  import { SETTINGS } from './settings';
3
3
  import { storage } from '../utils/storage';
4
4
 
5
+ export interface ReviewSettingsDiffOption {
6
+ id: string;
7
+ label: string;
8
+ description: string;
9
+ }
10
+
11
+ /** The default-diff choices Settings → Review offers for one VCS. */
12
+ export interface VcsReviewSettingsDescriptor {
13
+ id: string;
14
+ label: string;
15
+ defaultDiffType: string;
16
+ diffOptions: ReviewSettingsDiffOption[];
17
+ }
18
+
5
19
  /**
6
20
  * The ONLY writers for the coupled setting pair (reviewPanelView,
7
21
  * defaultDiffType).
@@ -81,6 +95,32 @@ export function setReviewDefaultDiffType(
81
95
  }
82
96
  }
83
97
 
98
+ /**
99
+ * The saved default diff for a VCS other than Git (today: Jujutsu), read from
100
+ * `reviewDefaults[id]`. Git keeps its original `defaultDiffType` setting and
101
+ * its coupled setter above; a value this VCS does not offer reads as its own
102
+ * default, never as a Git mode.
103
+ */
104
+ export function getProviderReviewDefaultDiffType(
105
+ descriptor: VcsReviewSettingsDescriptor,
106
+ store: PanelViewConfigStore = configStore,
107
+ ): string {
108
+ const configured = store.get('reviewDefaults')[descriptor.id]?.defaultDiffType;
109
+ return configured && descriptor.diffOptions.some((option) => option.id === configured)
110
+ ? configured
111
+ : descriptor.defaultDiffType;
112
+ }
113
+
114
+ export function setProviderReviewDefaultDiffType(
115
+ providerId: string,
116
+ value: string,
117
+ store: PanelViewConfigStore = configStore,
118
+ ): void {
119
+ store.set('reviewDefaults', {
120
+ ...store.get('reviewDefaults'),
121
+ [providerId]: { defaultDiffType: value },
122
+ });
123
+ }
84
124
 
85
125
  /**
86
126
  * One-time gate for the auto-mark-viewed notice — the toast that fires the
@@ -90,7 +130,7 @@ export function setReviewDefaultDiffType(
90
130
  * meaningful revision can re-show it.
91
131
  *
92
132
  * It lives beside the setting rather than in the review app because BOTH
93
- * writers of the setting (Settings > Git here, and the file-list gear in the
133
+ * writers of the setting (Settings → Review here, and the file-list gear in the
94
134
  * review app) must consume the gate: someone who found the switch has
95
135
  * demonstrably discovered the feature and must never be told about it.
96
136
  */