@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
@@ -7,12 +7,18 @@
7
7
 
8
8
  import { useState, useEffect, useCallback, useRef } from 'react';
9
9
  import type { CodeAnnotation, Annotation, CommentAnnotation } from '../types';
10
- import { getDraftTransport } from './useAnnotationDraft';
10
+ import { getDraftTransport, type DraftTransport } from './useAnnotationDraft';
11
11
 
12
12
  const DEBOUNCE_MS = 500;
13
13
  /** Bound on one read of a switched-onto target's draft (#1590). */
14
14
  export const TARGET_LOAD_TIMEOUT_MS = 5000;
15
15
 
16
+ /** A target-bound transport refuses to write into a different review. The host
17
+ * must refresh/adopt a target before saving can resume; retrying cannot fix it. */
18
+ export class DraftTargetChangedError extends Error {
19
+ constructor() { super('Review draft target changed'); }
20
+ }
21
+
16
22
  function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
17
23
  return new Promise<T>((resolve, reject) => {
18
24
  const timer = setTimeout(() => reject(new Error('draft load timed out')), ms);
@@ -41,8 +47,8 @@ interface DraftData {
41
47
  autoViewSuppressed?: string[];
42
48
  draftGeneration?: number;
43
49
  /**
44
- * Set by the review server (never by the client) when a PR draft is served
45
- * through the PR's target key for a patch different from the one it was
50
+ * Set by the review server (never by the client) when a draft is served
51
+ * through its target key for a patch different from the one it was
46
52
  * saved on (#1590). The host re-checks line comments' anchors on restore.
47
53
  */
48
54
  patchChanged?: boolean;
@@ -65,6 +71,8 @@ function formatTimeAgo(ts: number): string {
65
71
  }
66
72
 
67
73
  interface UseCodeAnnotationDraftOptions {
74
+ /** Optional target-bound transport; the configured host transport is the default. */
75
+ transport?: DraftTransport;
68
76
  annotations: CodeAnnotation[];
69
77
  descriptionAnnotations?: Annotation[];
70
78
  commentAnnotations?: CommentAnnotation[];
@@ -101,6 +109,10 @@ export interface CodeDraftTargetState {
101
109
  }
102
110
 
103
111
  interface UseCodeAnnotationDraftResult {
112
+ /** Flush pending edits, then hold autosave until adoptDraftTarget resumes it. */
113
+ flushBeforeSwitch: () => Promise<void>;
114
+ /** Keep unsaved annotations in memory and offer an in-place refresh. */
115
+ draftTargetChanged: boolean;
104
116
  draftBanner: { count: number; viewedCount: number; timeAgo: string } | null;
105
117
  /** `patchChanged` is true when the server served the draft for a patch other
106
118
  * than the one it was saved on (the host re-checks line anchors). */
@@ -114,10 +126,11 @@ interface UseCodeAnnotationDraftResult {
114
126
  * that one load settles (success, failure, or a newer switch) autosave does
115
127
  * not write under the new target, so the switch cannot overwrite a draft
116
128
  * it has not read yet; nothing else ever waits. */
117
- adoptDraftTarget: (state: CodeDraftTargetState | undefined) => void;
129
+ adoptDraftTarget: (state: CodeDraftTargetState | undefined, transport?: DraftTransport) => void;
118
130
  }
119
131
 
120
132
  export function useCodeAnnotationDraft({
133
+ transport: transportOverride,
121
134
  annotations,
122
135
  descriptionAnnotations = [],
123
136
  commentAnnotations = [],
@@ -129,6 +142,14 @@ export function useCodeAnnotationDraft({
129
142
  targetLoadTimeoutMs = TARGET_LOAD_TIMEOUT_MS,
130
143
  persistViewedFiles = true,
131
144
  }: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
145
+ const transport = transportOverride ?? getDraftTransport();
146
+ const transportRef = useRef(transport);
147
+ transportRef.current = transport;
148
+ const pendingSaveRef = useRef<(() => Promise<void>) | null>(null);
149
+ const savingRef = useRef<Promise<void> | null>(null);
150
+ const switchPausedRef = useRef(false);
151
+ const [draftTargetChanged, setDraftTargetChanged] = useState(false);
152
+ const targetChangedRef = useRef(false);
132
153
  const [draftBanner, setDraftBanner] = useState<{ count: number; viewedCount: number; timeAgo: string } | null>(null);
133
154
  const draftDataRef = useRef<DraftData | null>(null);
134
155
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -147,6 +168,14 @@ export function useCodeAnnotationDraft({
147
168
  // would overwrite a draft it has not merged yet) and remembers that it did,
148
169
  // so settling — success, failure, or supersede — always resumes saving.
149
170
  const switchSeqRef = useRef(0);
171
+ const reportTargetChanged = (error: unknown, seq: number): boolean => {
172
+ if (!(error instanceof DraftTargetChangedError)) return false;
173
+ if (seq === switchSeqRef.current) {
174
+ targetChangedRef.current = true;
175
+ setDraftTargetChanged(true);
176
+ }
177
+ return true;
178
+ };
150
179
  const awaitingTargetLoadRef = useRef(false);
151
180
  const skippedWhileAwaitingRef = useRef(false);
152
181
  // The switched-onto target's draft could not be read (both attempts failed
@@ -182,15 +211,19 @@ export function useCodeAnnotationDraft({
182
211
  // Load draft on mount
183
212
  useEffect(() => {
184
213
  if (!isApiMode) return;
214
+ const seq = switchSeqRef.current;
215
+ let cancelled = false;
185
216
 
186
- getDraftTransport().load()
217
+ transportRef.current.load()
187
218
  .then(({ data, generation }) => {
219
+ if (cancelled || seq !== switchSeqRef.current) return null;
188
220
  if (generation !== null) {
189
221
  draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
190
222
  }
191
223
  return data as DraftData | null;
192
224
  })
193
225
  .then((data: DraftData | null) => {
226
+ if (cancelled || seq !== switchSeqRef.current) return;
194
227
  // Keep even a viewed-only draft while independent progress support is
195
228
  // unresolved; a failed/unsupported load may need to offer it later.
196
229
  draftDataRef.current = data;
@@ -212,9 +245,13 @@ export function useCodeAnnotationDraft({
212
245
  }
213
246
  hasMountedRef.current = true;
214
247
  })
215
- .catch(() => {
216
- hasMountedRef.current = true;
248
+ .catch((error) => {
249
+ if (!cancelled && seq === switchSeqRef.current) {
250
+ reportTargetChanged(error, seq);
251
+ hasMountedRef.current = true;
252
+ }
217
253
  });
254
+ return () => { cancelled = true; };
218
255
  }, [isApiMode]);
219
256
 
220
257
  useEffect(() => {
@@ -248,7 +285,9 @@ export function useCodeAnnotationDraft({
248
285
 
249
286
  if (timerRef.current) clearTimeout(timerRef.current);
250
287
 
251
- timerRef.current = setTimeout(() => {
288
+ const seq = switchSeqRef.current;
289
+ const persist = async () => {
290
+ if (targetChangedRef.current) return;
252
291
  // The switched-onto target's draft is still loading: writing now would
253
292
  // overwrite it unread. Settling the load re-runs this effect.
254
293
  if (awaitingTargetLoadRef.current) {
@@ -268,7 +307,7 @@ export function useCodeAnnotationDraft({
268
307
  // tombstone so it can't resurface on refresh and a late save can't revive
269
308
  // it. Mirrors useAnnotationDraft.persistNow — routed through the draft
270
309
  // transport seam so a host backend tombstones its own stored draft too.
271
- getDraftTransport().remove(draftGeneration, { keepalive: false }).catch(() => {});
310
+ await transport.remove(draftGeneration, { keepalive: false });
272
311
  return;
273
312
  }
274
313
 
@@ -284,18 +323,48 @@ export function useCodeAnnotationDraft({
284
323
  ts: Date.now(),
285
324
  };
286
325
 
287
- getDraftTransport().save(payload, { keepalive: false }).catch(() => {});
326
+ await transport.save(payload, { keepalive: false });
327
+ };
328
+ const save = async () => {
329
+ try { await persist(); } catch (error) {
330
+ reportTargetChanged(error, seq);
331
+ throw error;
332
+ }
333
+ };
334
+ pendingSaveRef.current = save;
335
+ timerRef.current = setTimeout(() => {
336
+ if (switchPausedRef.current) return;
337
+ pendingSaveRef.current = null;
338
+ savingRef.current = save();
339
+ savingRef.current.catch(() => {});
288
340
  }, DEBOUNCE_MS);
289
341
 
290
342
  return () => {
291
343
  if (timerRef.current) clearTimeout(timerRef.current);
344
+ pendingSaveRef.current = null;
292
345
  };
293
- }, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge, persistViewedFiles]);
346
+ }, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge, persistViewedFiles, transport]);
347
+
348
+ const flushBeforeSwitch = useCallback(async () => {
349
+ switchPausedRef.current = true;
350
+ if (timerRef.current) clearTimeout(timerRef.current);
351
+ try {
352
+ // Persistence is best-effort: a rejected earlier write must neither
353
+ // block navigation nor skip a newer edit still waiting on debounce.
354
+ await savingRef.current?.catch(() => {});
355
+ const pending = pendingSaveRef.current;
356
+ pendingSaveRef.current = null;
357
+ if (pending) await pending().catch(() => {});
358
+ } finally {
359
+ savingRef.current = null;
360
+ }
361
+ }, []);
294
362
 
295
363
  const restoreDraft = useCallback(() => {
296
364
  // Cancel any pending autosave so it can't fire with pre-restore state and
297
365
  // overwrite what we're about to restore.
298
366
  if (timerRef.current) clearTimeout(timerRef.current);
367
+ pendingSaveRef.current = null;
299
368
  const data = draftDataRef.current;
300
369
  setDraftBanner(null);
301
370
  draftDataRef.current = null;
@@ -315,19 +384,43 @@ export function useCodeAnnotationDraft({
315
384
  // Cancel any pending autosave so a late save can't revive the draft the user
316
385
  // just dismissed.
317
386
  if (timerRef.current) clearTimeout(timerRef.current);
387
+ pendingSaveRef.current = null;
318
388
  const deletedGeneration = draftGenerationRef.current + 1;
319
389
  draftGenerationRef.current = deletedGeneration;
320
390
  setDraftBanner(null);
321
391
  draftDataRef.current = null;
322
- getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
392
+ const seq = switchSeqRef.current;
393
+ transportRef.current.remove(deletedGeneration, { keepalive: false }).catch(error => {
394
+ reportTargetChanged(error, seq);
395
+ });
323
396
  }, []);
324
397
 
325
398
  // Unmount supersedes any in-flight target read, so a retry never fires
326
399
  // after the page (or a test host) is gone.
327
400
  useEffect(() => () => { switchSeqRef.current += 1; }, []);
328
401
 
329
- const adoptDraftTarget = useCallback((state: CodeDraftTargetState | undefined) => {
402
+ const adoptDraftTarget = useCallback((state: CodeDraftTargetState | undefined, nextTransport?: DraftTransport) => {
330
403
  if (!isApiMode) return;
404
+ // undefined also resumes a FAILED switch, which has not repaired a
405
+ // target mismatch. Only an applied server response may clear the notice.
406
+ if (state) {
407
+ targetChangedRef.current = false;
408
+ setDraftTargetChanged(false);
409
+ }
410
+ if (switchPausedRef.current) {
411
+ switchPausedRef.current = false;
412
+ setSaveNudge(n => n + 1);
413
+ }
414
+ // A failed/superseded request did not adopt another target. Its current
415
+ // draft may still be loading (or unreadable); retain that write protection.
416
+ if (!state) return;
417
+ // An applied switch supersedes the page-load read (its seq guard drops
418
+ // the result), so it must open autosave itself. A failed/superseded
419
+ // switch must not: the page-load read may still be in flight, and
420
+ // saving before it settles could overwrite a draft the banner has not
421
+ // offered yet.
422
+ hasMountedRef.current = true;
423
+ const targetTransport = nextTransport ?? transportRef.current;
331
424
  // Every switch starts clean: a previous switch's load (if still in
332
425
  // flight) is superseded, an unreadable-target block is lifted, and any
333
426
  // write either of them held back resumes (under the new target).
@@ -341,7 +434,6 @@ export function useCodeAnnotationDraft({
341
434
  awaitingTargetLoadRef.current = false;
342
435
  targetUnreadableRef.current = false;
343
436
  retryTargetLoadRef.current = null;
344
- if (!state) { resumeHeldWrite(); return; }
345
437
  const floor = readDraftGeneration(state.draftGeneration);
346
438
  if (floor !== null) draftGenerationRef.current = Math.max(draftGenerationRef.current, floor);
347
439
  if (!state.found) { resumeHeldWrite(); return; }
@@ -381,7 +473,7 @@ export function useCodeAnnotationDraft({
381
473
  awaitingTargetLoadRef.current = true;
382
474
  const attempt = (remaining: number): void => {
383
475
  if (switchSeqRef.current !== seq) return; // superseded or unmounted
384
- withTimeout(getDraftTransport().load(), loadTimeoutRef.current)
476
+ withTimeout(targetTransport.load(), loadTimeoutRef.current)
385
477
  .then(({ data, generation }) => {
386
478
  if (switchSeqRef.current !== seq) return; // a newer switch owns the keys now
387
479
  // A debounced save armed before the read settled captured the
@@ -396,8 +488,12 @@ export function useCodeAnnotationDraft({
396
488
  targetUnreadableRef.current = false;
397
489
  resumeHeldWrite();
398
490
  })
399
- .catch(() => {
491
+ .catch((error) => {
400
492
  if (switchSeqRef.current !== seq) return;
493
+ if (reportTargetChanged(error, seq)) {
494
+ awaitingTargetLoadRef.current = false;
495
+ return;
496
+ }
401
497
  if (remaining > 0) { attempt(remaining - 1); return; }
402
498
  // Still unreadable: never overwrite it blind. Autosave keeps
403
499
  // running for the session and retries this read on its next
@@ -412,5 +508,5 @@ export function useCodeAnnotationDraft({
412
508
  read();
413
509
  }, [isApiMode]);
414
510
 
415
- return { draftBanner, restoreDraft, getDraftGeneration, dismissDraft, adoptDraftTarget };
511
+ return { draftBanner, draftTargetChanged, restoreDraft, getDraftGeneration, dismissDraft, adoptDraftTarget, flushBeforeSwitch };
416
512
  }
@@ -0,0 +1,96 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ /**
4
+ * "Open Plannotator at plannotator.localhost" (#2184) for Settings → General: on
5
+ * writes `urlHost: "plannotator.localhost"` to config.json, off removes it.
6
+ * The server sends it as `serverConfig.cookieHost` only for a local session
7
+ * (never remote mode or `--tailscale`), and writes it with POST /api/config.
8
+ * Deliberately not a settings-registry entry: the registry is bundled into
9
+ * the portable guides.show viewer, whose pinned build must not change.
10
+ */
11
+ export interface CookieHostSetting {
12
+ on: boolean;
13
+ /** PLANNOTATOR_URL_HOST is set (its value; "" when set empty): the switch is locked. */
14
+ env?: string;
15
+ /** config.json holds another `urlHost` (a tailnet name, `auto`, an IP): locked, never replaced. */
16
+ urlHost?: string;
17
+ /** The last save failed: the reason, shown under the switch. */
18
+ error?: string;
19
+ onChange: (next: boolean) => void;
20
+ }
21
+
22
+ /**
23
+ * The cookie switch's one sub line: what it does, or why the switch is
24
+ * locked. `inbox` adds that the Inbox's notifications need allowing once at
25
+ * the new address.
26
+ */
27
+ export function cookieHostDescription(
28
+ setting: Pick<CookieHostSetting, 'on' | 'env' | 'urlHost'>,
29
+ options: { inbox?: boolean } = {},
30
+ ): string {
31
+ if (setting.env !== undefined) {
32
+ return setting.env.trim() === ''
33
+ ? 'Set by PLANNOTATOR_URL_HOST in your environment (empty).'
34
+ : `Set by PLANNOTATOR_URL_HOST=${setting.env.trim()} in your environment.`;
35
+ }
36
+ if (setting.urlHost !== undefined) return `config.json sets urlHost to ${setting.urlHost}; this switch never replaces it.`;
37
+ const notifications = options.inbox ? ' Notifications need allowing once there.' : '';
38
+ return `Keeps Plannotator's cookies away from your other localhost apps. Starts from the next session; this browser's preferences start fresh once.${notifications}`;
39
+ }
40
+
41
+ interface CookieHostState {
42
+ on: boolean;
43
+ env?: string;
44
+ urlHost?: string;
45
+ }
46
+
47
+ function parseState(value: unknown): CookieHostState | undefined {
48
+ if (!value || typeof value !== 'object') return undefined;
49
+ const v = value as Record<string, unknown>;
50
+ if (typeof v.on !== 'boolean') return undefined;
51
+ return {
52
+ on: v.on,
53
+ ...(typeof v.env === 'string' && { env: v.env }),
54
+ ...(typeof v.urlHost === 'string' && { urlHost: v.urlHost }),
55
+ };
56
+ }
57
+
58
+ export function useCookieHostSetting(): {
59
+ setting: CookieHostSetting | undefined;
60
+ adopt: (serverConfig: Record<string, unknown> | undefined) => void;
61
+ } {
62
+ const [state, setState] = useState<CookieHostState | undefined>();
63
+ const [error, setError] = useState<string | undefined>();
64
+
65
+ const adopt = useCallback((serverConfig: Record<string, unknown> | undefined) => {
66
+ setState(parseState(serverConfig?.cookieHost));
67
+ }, []);
68
+
69
+ const onChange = useCallback((next: boolean) => {
70
+ setError(undefined);
71
+ setState((current) => (current ? { ...current, on: next } : current));
72
+ void fetch('/api/config', {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({ cookieHost: next }),
76
+ })
77
+ .then(async (res) => {
78
+ const body = (await res.json().catch(() => ({}))) as { error?: unknown; cookieHost?: unknown };
79
+ const saved = parseState(body.cookieHost);
80
+ if (saved) setState(saved);
81
+ if (!res.ok) {
82
+ if (!saved) setState((current) => (current ? { ...current, on: !next } : current));
83
+ setError(typeof body.error === 'string' ? body.error : 'The setting could not be saved.');
84
+ }
85
+ })
86
+ .catch(() => {
87
+ setState((current) => (current ? { ...current, on: !next } : current));
88
+ setError('The setting could not be saved.');
89
+ });
90
+ }, []);
91
+
92
+ return {
93
+ setting: state ? { ...state, ...(error && { error }), onChange } : undefined,
94
+ adopt,
95
+ };
96
+ }
@@ -0,0 +1,17 @@
1
+ import { useLayoutEffect } from 'react';
2
+ import { useConfigValue } from '../config';
3
+ import { applyDocumentFont } from '../utils/documentFont';
4
+
5
+ /**
6
+ * Apply the `documentFontFamily` setting (Settings → Display → Document font)
7
+ * to the document element for as long as the calling component is mounted. A
8
+ * layout effect, so a remembered font is on the page before its first paint.
9
+ * On unmount the theme's font is put back.
10
+ */
11
+ export function useDocumentFont(): void {
12
+ const family = useConfigValue('documentFontFamily');
13
+ useLayoutEffect(() => {
14
+ applyDocumentFont(document.documentElement, family);
15
+ }, [family]);
16
+ useLayoutEffect(() => () => applyDocumentFont(document.documentElement, ''), []);
17
+ }
@@ -0,0 +1,20 @@
1
+ import { useLayoutEffect } from 'react';
2
+ import { useConfigValue } from '../config';
3
+ import { applyDocumentTextSize, DEFAULT_DOCUMENT_TEXT_SIZE } from '../utils/documentTextSize';
4
+
5
+ /**
6
+ * Apply the `documentTextSize` setting (Settings → Display → Text size) to the
7
+ * document element for as long as the calling component is mounted. A layout
8
+ * effect, so a remembered size is on the page before its first paint. On
9
+ * unmount the default is put back.
10
+ */
11
+ export function useDocumentTextSize(): void {
12
+ const size = useConfigValue('documentTextSize');
13
+ useLayoutEffect(() => {
14
+ applyDocumentTextSize(document.documentElement, size);
15
+ }, [size]);
16
+ useLayoutEffect(
17
+ () => () => applyDocumentTextSize(document.documentElement, DEFAULT_DOCUMENT_TEXT_SIZE),
18
+ [],
19
+ );
20
+ }
@@ -22,6 +22,13 @@ export interface DirState {
22
22
  hasLoadedTree?: boolean;
23
23
  /** When true, fetches via /api/reference/obsidian/files and opens docs via /api/reference/obsidian/doc */
24
24
  isVault?: boolean;
25
+ /**
26
+ * The server left supported files out because the folder holds more than
27
+ * its cap (PLANNOTATOR_FILE_BROWSER_MAX_FILES). `fileLimit` is that cap.
28
+ * Absent from a backend that does not cap.
29
+ */
30
+ truncated?: boolean;
31
+ fileLimit?: number;
25
32
  }
26
33
 
27
34
  export interface UseFileBrowserReturn {
@@ -89,7 +96,7 @@ function remapWorkspaceStatusForDir(
89
96
  * tree from its own transport instead.
90
97
  */
91
98
  export interface FileTreeBackend {
92
- /** Load a directory tree. Resolves to the same shape the /api/reference/files endpoint returns: a Response whose JSON is { tree, workspaceStatus?, error? }. */
99
+ /** Load a directory tree. Resolves to the same shape the /api/reference/files endpoint returns: a Response whose JSON is { tree, workspaceStatus?, truncated?, fileLimit?, error? }. */
93
100
  loadTree(dirPath: string): Promise<Response>;
94
101
  /** Load an Obsidian vault tree. Resolves to a Response whose JSON is { tree, error? }. */
95
102
  loadVaultTree(vaultPath: string): Promise<Response>;
@@ -244,6 +251,8 @@ export function useFileBrowser(): UseFileBrowserReturn {
244
251
  isLoading: false,
245
252
  hasLoadedTree: true,
246
253
  error: null,
254
+ truncated: data.truncated === true,
255
+ fileLimit: typeof data.fileLimit === "number" && data.fileLimit > 0 ? data.fileLimit : undefined,
247
256
  }
248
257
  : d
249
258
  )
@@ -0,0 +1,77 @@
1
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
+ import { activeHeadingBinding, bindHeadings, type HeadingBinding } from '../utils/headingScrollSpy';
3
+
4
+ /**
5
+ * The block id of the heading being read in `scrollViewport`, by the contents
6
+ * rail's own scroll math (utils/headingScrollSpy): cached heading tops, read
7
+ * on every scroll frame, the last heading at the document's end. For a
8
+ * Contents list on the rail's surface (`DocumentContents`), so the two never
9
+ * disagree. Plan review's list keeps `useActiveSection`.
10
+ *
11
+ * Reads at once on mount (a sheet opened mid-document marks the section being
12
+ * read on its first paint), and re-binds when the document's elements change
13
+ * (an edit can replace the headings and keep their count) or resize. Before
14
+ * any heading is drawn the answer stays what it was.
15
+ */
16
+ export function useHeadingScrollSpy(
17
+ scrollViewport: HTMLElement | null,
18
+ blockIds: readonly string[],
19
+ ): string | null {
20
+ const idsKey = blockIds.join('\n');
21
+ // Stable while the ids are the same, whatever array the caller passes.
22
+ const ids = useMemo(() => (idsKey === '' ? [] : idsKey.split('\n')), [idsKey]);
23
+ const [activeIndex, setActiveIndex] = useState<number | null>(null);
24
+ const bindingsRef = useRef<readonly HeadingBinding[]>([]);
25
+
26
+ const refresh = useCallback(() => {
27
+ if (scrollViewport === null) return;
28
+ const binding = activeHeadingBinding(bindingsRef.current, scrollViewport);
29
+ if (binding !== null) setActiveIndex(binding.itemIndex);
30
+ }, [scrollViewport]);
31
+
32
+ useLayoutEffect(() => {
33
+ setActiveIndex(null);
34
+ bindingsRef.current = [];
35
+ if (scrollViewport === null || ids.length === 0) return;
36
+ const rebind = () => {
37
+ bindingsRef.current = bindHeadings(scrollViewport, ids);
38
+ refresh();
39
+ };
40
+ rebind();
41
+
42
+ let scrollFrame: number | null = null;
43
+ const onScroll = () => {
44
+ if (scrollFrame !== null) return;
45
+ scrollFrame = requestAnimationFrame(() => {
46
+ scrollFrame = null;
47
+ refresh();
48
+ });
49
+ };
50
+ let bindFrame: number | null = null;
51
+ const scheduleRebind = () => {
52
+ if (bindFrame !== null) cancelAnimationFrame(bindFrame);
53
+ bindFrame = requestAnimationFrame(() => {
54
+ bindFrame = null;
55
+ rebind();
56
+ });
57
+ };
58
+ scrollViewport.addEventListener('scroll', onScroll, { passive: true });
59
+ window.addEventListener('resize', scheduleRebind);
60
+ const resizeObserver =
61
+ typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleRebind);
62
+ resizeObserver?.observe(scrollViewport);
63
+ const mutationObserver =
64
+ typeof MutationObserver === 'undefined' ? null : new MutationObserver(scheduleRebind);
65
+ mutationObserver?.observe(scrollViewport, { childList: true, subtree: true });
66
+ return () => {
67
+ if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
68
+ if (bindFrame !== null) cancelAnimationFrame(bindFrame);
69
+ scrollViewport.removeEventListener('scroll', onScroll);
70
+ window.removeEventListener('resize', scheduleRebind);
71
+ resizeObserver?.disconnect();
72
+ mutationObserver?.disconnect();
73
+ };
74
+ }, [ids, refresh, scrollViewport]);
75
+
76
+ return activeIndex === null ? null : (ids[activeIndex] ?? null);
77
+ }
@@ -0,0 +1,94 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ /** How long the pointer rests on an anchor before the preview is fetched. */
4
+ export const HOVER_PREVIEW_SHOW_DELAY_MS = 150;
5
+ /** How long a preview stays after the pointer leaves, so it can move into the preview. */
6
+ export const HOVER_PREVIEW_HIDE_DELAY_MS = 200;
7
+
8
+ export interface HoverPreview<T> {
9
+ /** The loaded preview while it is open, else null. */
10
+ preview: T | null;
11
+ /** Attach to the anchor element; the preview positions against it. */
12
+ anchorRef: React.MutableRefObject<HTMLElement | null>;
13
+ /** Pointer or focus arrives on the anchor: load after the show delay. */
14
+ onEnter: () => void;
15
+ /** Pointer or focus leaves the anchor: close after the hide delay. */
16
+ onLeave: () => void;
17
+ /** Spread onto the preview so moving into it keeps it open. */
18
+ previewHandlers: { onMouseEnter: () => void; onMouseLeave: () => void };
19
+ /** Close at once (a click, a scroll). */
20
+ dismiss: () => void;
21
+ }
22
+
23
+ /**
24
+ * Dwell-then-load hover for a floating preview: the timing shared by the
25
+ * code-file link preview (InlineMarkdown) and Call Flow's source locations.
26
+ * Nothing loads before the pointer rests for `HOVER_PREVIEW_SHOW_DELAY_MS`; a
27
+ * load that resolves after the pointer left is dropped. `load` resolving
28
+ * null means "nothing to show".
29
+ */
30
+ export function useHoverPreview<T>(load: () => Promise<T | null>, enabled = true): HoverPreview<T> {
31
+ const [preview, setPreview] = useState<T | null>(null);
32
+ const previewRef = useRef(preview);
33
+ previewRef.current = preview;
34
+ const anchorRef = useRef<HTMLElement | null>(null);
35
+ const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
36
+ const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
37
+ // Bumped on every leave so a load still in flight cannot open a stale preview.
38
+ const generationRef = useRef(0);
39
+ const loadRef = useRef(load);
40
+ loadRef.current = load;
41
+
42
+ const cancelShow = useCallback(() => {
43
+ if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; }
44
+ }, []);
45
+ const cancelHide = useCallback(() => {
46
+ if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; }
47
+ }, []);
48
+
49
+ const scheduleHide = useCallback(() => {
50
+ cancelHide();
51
+ hideTimerRef.current = setTimeout(() => setPreview(null), HOVER_PREVIEW_HIDE_DELAY_MS);
52
+ }, [cancelHide]);
53
+
54
+ const onEnter = useCallback(() => {
55
+ if (!enabled) return;
56
+ cancelHide();
57
+ if (previewRef.current || showTimerRef.current) return;
58
+ const generation = generationRef.current;
59
+ showTimerRef.current = setTimeout(async () => {
60
+ showTimerRef.current = null;
61
+ try {
62
+ const data = await loadRef.current();
63
+ if (data != null && generation === generationRef.current) setPreview(data);
64
+ } catch {}
65
+ }, HOVER_PREVIEW_SHOW_DELAY_MS);
66
+ }, [enabled, cancelHide]);
67
+
68
+ const onLeave = useCallback(() => {
69
+ cancelShow();
70
+ generationRef.current += 1;
71
+ scheduleHide();
72
+ }, [cancelShow, scheduleHide]);
73
+
74
+ const dismiss = useCallback(() => {
75
+ cancelShow();
76
+ cancelHide();
77
+ generationRef.current += 1;
78
+ setPreview(null);
79
+ }, [cancelShow, cancelHide]);
80
+
81
+ useEffect(() => () => {
82
+ cancelShow();
83
+ cancelHide();
84
+ }, [cancelShow, cancelHide]);
85
+
86
+ return {
87
+ preview,
88
+ anchorRef,
89
+ onEnter,
90
+ onLeave,
91
+ previewHandlers: { onMouseEnter: cancelHide, onMouseLeave: scheduleHide },
92
+ dismiss,
93
+ };
94
+ }