@peaske7/readit 0.1.7 → 0.2.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 (118) hide show
  1. package/README.md +0 -3
  2. package/biome.json +1 -1
  3. package/bun.lock +43 -185
  4. package/docs/perf-baseline.md +75 -0
  5. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  6. package/e2e/perf/add-comment.spec.ts +118 -0
  7. package/e2e/perf/fixtures/generate.ts +331 -0
  8. package/e2e/perf/initial-load.spec.ts +49 -0
  9. package/e2e/perf/perf.setup.ts +23 -0
  10. package/e2e/perf/perf.teardown.ts +9 -0
  11. package/e2e/perf/scroll.spec.ts +39 -0
  12. package/e2e/perf/tab-switch.spec.ts +69 -0
  13. package/e2e/perf/text-selection.spec.ts +119 -0
  14. package/e2e/perf/utils/metrics.ts +286 -0
  15. package/e2e/perf/utils/perf-cli.ts +86 -0
  16. package/package.json +9 -18
  17. package/playwright.config.ts +12 -0
  18. package/src/App.tsx +133 -178
  19. package/src/{cli/index.ts → cli.ts} +211 -107
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +78 -105
  22. package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
  23. package/src/components/Header.tsx +9 -20
  24. package/src/components/InlineEditor.tsx +5 -5
  25. package/src/components/MarginNote.tsx +71 -93
  26. package/src/components/MarginNotes.tsx +7 -34
  27. package/src/components/RawModal.tsx +9 -8
  28. package/src/components/ReanchorConfirm.tsx +2 -2
  29. package/src/components/SettingsModal.tsx +11 -89
  30. package/src/components/TabBar.tsx +4 -4
  31. package/src/components/TableOfContents.tsx +5 -5
  32. package/src/components/comments/CommentInput.tsx +7 -35
  33. package/src/components/comments/CommentListItem.tsx +9 -11
  34. package/src/components/comments/CommentManager.tsx +53 -37
  35. package/src/components/comments/CommentNav.tsx +14 -14
  36. package/src/components/ui/ActionLink.tsx +14 -18
  37. package/src/components/ui/Button.tsx +42 -43
  38. package/src/components/ui/Dialog.tsx +73 -113
  39. package/src/components/ui/DropdownMenu.tsx +113 -69
  40. package/src/components/ui/Text.tsx +30 -37
  41. package/src/contexts/CommentContext.tsx +75 -106
  42. package/src/contexts/LocaleContext.tsx +45 -4
  43. package/src/contexts/PositionsContext.tsx +16 -0
  44. package/src/contexts/SettingsContext.tsx +133 -0
  45. package/src/hooks/useClickOutside.ts +0 -4
  46. package/src/hooks/useCommentNavigation.ts +6 -29
  47. package/src/hooks/useComments.ts +6 -18
  48. package/src/hooks/useDocument.ts +35 -34
  49. package/src/hooks/useHeadings.test.ts +8 -50
  50. package/src/hooks/useHeadings.ts +5 -88
  51. package/src/hooks/useScrollSpy.ts +10 -14
  52. package/src/hooks/useTextSelection.ts +1 -38
  53. package/src/lib/__fixtures__/bench-data.ts +1 -41
  54. package/src/lib/anchor.bench.ts +57 -67
  55. package/src/lib/anchor.test.ts +5 -1
  56. package/src/lib/anchor.ts +13 -93
  57. package/src/lib/comment-storage.test.ts +4 -4
  58. package/src/lib/comment-storage.ts +2 -46
  59. package/src/lib/export.ts +7 -13
  60. package/src/lib/highlight/core.test.ts +1 -1
  61. package/src/lib/highlight/dom.ts +5 -68
  62. package/src/lib/highlight/highlighter.ts +102 -262
  63. package/src/lib/highlight/resolver.ts +112 -0
  64. package/src/lib/highlight/types.ts +0 -35
  65. package/src/lib/highlight/worker.ts +45 -0
  66. package/src/lib/i18n/en.ts +1 -50
  67. package/src/lib/i18n/ja.ts +1 -50
  68. package/src/lib/i18n/types.ts +1 -49
  69. package/src/lib/margin-layout.ts +5 -27
  70. package/src/lib/positions.ts +150 -0
  71. package/src/lib/utils.ts +2 -19
  72. package/src/schema.ts +81 -0
  73. package/src/{server/index.ts → server.ts} +111 -81
  74. package/src/{store/index.ts → store.ts} +14 -46
  75. package/vite.config.ts +8 -0
  76. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  77. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  78. package/src/components/DocumentViewer/index.ts +0 -1
  79. package/src/components/FloatingTOC.tsx +0 -61
  80. package/src/components/ShortcutCapture.tsx +0 -48
  81. package/src/components/ShortcutList.tsx +0 -198
  82. package/src/components/comments/CommentMinimap.tsx +0 -62
  83. package/src/components/ui/ActionBar.tsx +0 -16
  84. package/src/components/ui/SeparatorDot.tsx +0 -9
  85. package/src/contexts/LayoutContext.tsx +0 -88
  86. package/src/hooks/useClipboard.ts +0 -82
  87. package/src/hooks/useEditorScheme.ts +0 -51
  88. package/src/hooks/useFontPreference.ts +0 -59
  89. package/src/hooks/useKeybindings.ts +0 -108
  90. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  91. package/src/hooks/useLayoutMode.ts +0 -44
  92. package/src/hooks/useLocalePreference.ts +0 -42
  93. package/src/hooks/useReanchorMode.ts +0 -33
  94. package/src/hooks/useScrollMetrics.ts +0 -56
  95. package/src/hooks/useThemePreference.ts +0 -66
  96. package/src/lib/comment-storage.bench.ts +0 -63
  97. package/src/lib/context.bench.ts +0 -41
  98. package/src/lib/context.test.ts +0 -224
  99. package/src/lib/context.ts +0 -193
  100. package/src/lib/editor-links.ts +0 -59
  101. package/src/lib/export.bench.ts +0 -35
  102. package/src/lib/highlight/colors.ts +0 -37
  103. package/src/lib/highlight/core.ts +0 -54
  104. package/src/lib/highlight/index.ts +0 -23
  105. package/src/lib/highlight/script-builder.ts +0 -485
  106. package/src/lib/html-processor.test.tsx +0 -170
  107. package/src/lib/html-processor.tsx +0 -95
  108. package/src/lib/i18n/completeness.test.ts +0 -51
  109. package/src/lib/i18n/translations.test.ts +0 -39
  110. package/src/lib/layout-constants.ts +0 -12
  111. package/src/lib/margin-layout.bench.ts +0 -28
  112. package/src/lib/scroll.test.ts +0 -118
  113. package/src/lib/scroll.ts +0 -47
  114. package/src/lib/shortcut-registry.test.ts +0 -173
  115. package/src/lib/shortcut-registry.ts +0 -209
  116. package/src/lib/utils.test.ts +0 -110
  117. package/src/store/index.test.ts +0 -242
  118. package/src/types/index.ts +0 -127
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { basename, dirname, join } from "node:path";
6
- import { findAnchorWithFallback } from "../lib/anchor.js";
6
+ import { findAnchorWithFallback } from "./lib/anchor.js";
7
7
  import {
8
8
  computeHash,
9
9
  createComment,
@@ -12,20 +12,15 @@ import {
12
12
  parseCommentFile,
13
13
  serializeComments,
14
14
  truncateSelection,
15
- } from "../lib/comment-storage.js";
16
- import { getFileType } from "../lib/utils.js";
15
+ } from "./lib/comment-storage.js";
16
+ import { isMarkdownFile } from "./lib/utils.js";
17
17
  import {
18
18
  AnchorConfidences,
19
19
  type Comment,
20
20
  type DocumentSettings,
21
- type DocumentType,
22
- type EditorScheme,
23
- EditorSchemes,
24
21
  FontFamilies,
25
22
  type FontFamily,
26
- } from "../types/index.js";
27
-
28
- // ─── Helpers ─────────────────────────────────────────────────────────
23
+ } from "./schema.js";
29
24
 
30
25
  function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
31
26
  return err instanceof Error && "code" in err;
@@ -33,7 +28,6 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
33
28
 
34
29
  export interface FileEntry {
35
30
  content?: string;
36
- type: DocumentType;
37
31
  filePath: string;
38
32
  }
39
33
 
@@ -194,12 +188,6 @@ function isValidFontFamily(value: unknown): value is FontFamily {
194
188
  return value === FontFamilies.SERIF || value === FontFamilies.SANS_SERIF;
195
189
  }
196
190
 
197
- function isValidEditorScheme(value: unknown): value is EditorScheme {
198
- return Object.values(EditorSchemes).includes(value as EditorScheme);
199
- }
200
-
201
- // ─── PID file helpers ───────────────────────────────────────────────
202
-
203
191
  export const SERVER_INFO_PATH = path.join(
204
192
  os.homedir(),
205
193
  ".readit",
@@ -225,8 +213,6 @@ export async function removeServerInfo(): Promise<void> {
225
213
  }
226
214
  }
227
215
 
228
- // ─── Response helpers ───────────────────────────────────────────────
229
-
230
216
  function json(data: unknown, status = 200): Response {
231
217
  return Response.json(data, { status });
232
218
  }
@@ -235,15 +221,11 @@ function errorResponse(message: string, status: number): Response {
235
221
  return Response.json({ error: message }, { status });
236
222
  }
237
223
 
238
- // ─── Route context ──────────────────────────────────────────────────
239
-
240
224
  interface RouteContext {
241
225
  filePath: string;
242
226
  getCurrentContent: () => Promise<string>;
243
227
  }
244
228
 
245
- // ─── Route handlers ─────────────────────────────────────────────────
246
-
247
229
  async function getComments(ctx: RouteContext): Promise<Response> {
248
230
  try {
249
231
  const currentContent = await ctx.getCurrentContent();
@@ -446,22 +428,16 @@ async function getSettingsRoute(): Promise<Response> {
446
428
  async function updateSettingsRoute(req: Request): Promise<Response> {
447
429
  try {
448
430
  const body = await req.json();
449
- const { fontFamily, editorScheme, keybindings } = body;
431
+ const { fontFamily } = body;
450
432
 
451
433
  if (fontFamily !== undefined && !isValidFontFamily(fontFamily)) {
452
434
  return errorResponse("Invalid font family", 400);
453
435
  }
454
436
 
455
- if (editorScheme !== undefined && !isValidEditorScheme(editorScheme)) {
456
- return errorResponse("Invalid editor scheme", 400);
457
- }
458
-
459
437
  const current = await readSettings();
460
438
  const settings: DocumentSettings = {
461
439
  ...current,
462
440
  ...(fontFamily !== undefined && { fontFamily }),
463
- ...(editorScheme !== undefined && { editorScheme }),
464
- ...(keybindings !== undefined && { keybindings }),
465
441
  };
466
442
 
467
443
  await writeSettings(settings);
@@ -472,8 +448,6 @@ async function updateSettingsRoute(req: Request): Promise<Response> {
472
448
  }
473
449
  }
474
450
 
475
- // ─── SSE helpers ────────────────────────────────────────────────────
476
-
477
451
  function createDocumentStream(
478
452
  sseClients: Set<ReadableStreamDefaultController>,
479
453
  ): Response {
@@ -509,27 +483,30 @@ function createDocumentStream(
509
483
  });
510
484
  }
511
485
 
512
- function createHeartbeat(isDev: boolean): Response {
486
+ function createHeartbeat(
487
+ onOpen: (controller: ReadableStreamDefaultController) => void,
488
+ onClose: (controller: ReadableStreamDefaultController) => void,
489
+ ): Response {
513
490
  let interval: ReturnType<typeof setInterval>;
491
+ let captured: ReadableStreamDefaultController;
514
492
 
515
493
  const stream = new ReadableStream({
516
494
  start(controller) {
495
+ captured = controller;
517
496
  controller.enqueue("data: connected\n\n");
497
+ onOpen(controller);
518
498
  interval = setInterval(() => {
519
499
  try {
520
500
  controller.enqueue("data: ping\n\n");
521
501
  } catch {
522
502
  clearInterval(interval);
503
+ onClose(controller);
523
504
  }
524
505
  }, 5000);
525
506
  },
526
507
  cancel() {
527
508
  clearInterval(interval);
528
- if (isDev) return;
529
- setTimeout(() => {
530
- console.log("\nBrowser disconnected, shutting down...");
531
- process.exit(0);
532
- }, 100);
509
+ onClose(captured);
533
510
  },
534
511
  });
535
512
 
@@ -542,8 +519,6 @@ function createHeartbeat(isDev: boolean): Response {
542
519
  });
543
520
  }
544
521
 
545
- // ─── Static file serving ────────────────────────────────────────────
546
-
547
522
  async function serveStaticFile(
548
523
  distPath: string,
549
524
  pathname: string,
@@ -564,40 +539,85 @@ async function serveStaticFile(
564
539
  return new Response("Not Found", { status: 404 });
565
540
  }
566
541
 
567
- // ─── Extract route param ────────────────────────────────────────────
542
+ const VITE_DEV_PORT = 24678;
543
+ const VITE_DEV_ORIGIN = `http://127.0.0.1:${VITE_DEV_PORT}`;
544
+
545
+ async function proxyToVite(
546
+ req: Request,
547
+ pathname: string,
548
+ search: string,
549
+ ): Promise<Response> {
550
+ const target = `${VITE_DEV_ORIGIN}${pathname}${search}`;
551
+ try {
552
+ return await fetch(
553
+ new Request(target, {
554
+ method: req.method,
555
+ headers: req.headers,
556
+ body: req.body,
557
+ redirect: "manual",
558
+ }),
559
+ );
560
+ } catch {
561
+ return new Response("Vite dev server not available", { status: 502 });
562
+ }
563
+ }
564
+
565
+ async function isViteReady(): Promise<boolean> {
566
+ try {
567
+ const res = await fetch(`${VITE_DEV_ORIGIN}/`);
568
+ return res.ok;
569
+ } catch {
570
+ return false;
571
+ }
572
+ }
573
+
574
+ async function spawnViteDev(): Promise<() => void> {
575
+ // If Vite is already running (e.g. after bun --watch restart), reuse it
576
+ if (await isViteReady()) {
577
+ return () => {};
578
+ }
579
+
580
+ const child = Bun.spawn(
581
+ ["bunx", "vite", "--port", String(VITE_DEV_PORT), "--strictPort"],
582
+ { stdout: "ignore", stderr: "inherit" },
583
+ );
584
+
585
+ const maxWaitMs = 10_000;
586
+ const start = Date.now();
587
+ while (Date.now() - start < maxWaitMs) {
588
+ if (await isViteReady()) break;
589
+ await new Promise((r) => setTimeout(r, 200));
590
+ }
591
+
592
+ return () => {
593
+ child.kill();
594
+ };
595
+ }
568
596
 
569
597
  function extractCommentId(pathname: string): string | undefined {
570
598
  const match = pathname.match(/^\/api\/comments\/([^/]+)/);
571
599
  return match?.[1];
572
600
  }
573
601
 
574
- // ─── Multi-file state ───────────────────────────────────────────────
575
-
576
602
  interface FileState {
577
603
  content: string | null;
578
604
  isLoaded: boolean;
579
- type: DocumentType;
580
605
  debounceTimer: ReturnType<typeof setTimeout> | null;
581
606
  }
582
607
 
583
- // ─── Server creation ────────────────────────────────────────────────
584
-
585
608
  interface ServerWithWatchers {
586
609
  server: ReturnType<typeof Bun.serve>;
587
610
  watchers: FSWatcher[];
588
611
  }
589
612
 
590
613
  function createServer(options: ServerOptions): ServerWithWatchers {
591
- // Map of absolute path → mutable file state
592
614
  const fileMap = new Map<string, FileState>();
593
- // Ordered list of file paths (insertion order for tab display)
594
615
  const fileOrder: string[] = [];
595
616
 
596
617
  for (const entry of options.files) {
597
618
  fileMap.set(entry.filePath, {
598
619
  content: entry.content ?? null,
599
620
  isLoaded: entry.content !== undefined,
600
- type: entry.type,
601
621
  debounceTimer: null,
602
622
  });
603
623
  fileOrder.push(entry.filePath);
@@ -605,6 +625,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
605
625
 
606
626
  const defaultPath = fileOrder[0];
607
627
  const sseClients = new Set<ReadableStreamDefaultController>();
628
+ const heartbeatClients = new Set<ReadableStreamDefaultController>();
629
+ let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
608
630
 
609
631
  function sendEvent(event: unknown): void {
610
632
  const message = `data: ${JSON.stringify(event)}\n\n`;
@@ -617,6 +639,31 @@ function createServer(options: ServerOptions): ServerWithWatchers {
617
639
  }
618
640
  }
619
641
 
642
+ function clearShutdownTimer(): void {
643
+ if (!shutdownTimer) return;
644
+ clearTimeout(shutdownTimer);
645
+ shutdownTimer = null;
646
+ }
647
+
648
+ function onHeartbeatOpen(controller: ReadableStreamDefaultController): void {
649
+ heartbeatClients.add(controller);
650
+ clearShutdownTimer();
651
+ }
652
+
653
+ function onHeartbeatClose(controller: ReadableStreamDefaultController): void {
654
+ heartbeatClients.delete(controller);
655
+ if (isDev || heartbeatClients.size > 0 || shutdownTimer) return;
656
+
657
+ shutdownTimer = setTimeout(() => {
658
+ if (heartbeatClients.size > 0) {
659
+ clearShutdownTimer();
660
+ return;
661
+ }
662
+ console.log("\nBrowser disconnected, shutting down...");
663
+ process.exit(0);
664
+ }, 1500);
665
+ }
666
+
620
667
  async function ensureFileContent(filePath: string): Promise<string> {
621
668
  const state = fileMap.get(filePath);
622
669
  if (!state) {
@@ -633,7 +680,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
633
680
  return content;
634
681
  }
635
682
 
636
- // Resolve the target file from ?path= query param, falling back to first file
637
683
  function resolveContext(url: URL): RouteContext | null {
638
684
  const requestedPath = url.searchParams.get("path") ?? defaultPath;
639
685
  const state = fileMap.get(requestedPath);
@@ -696,18 +742,11 @@ function createServer(options: ServerOptions): ServerWithWatchers {
696
742
  const { pathname } = url;
697
743
  const method = req.method;
698
744
 
699
- // ── API routes ──────────────────────────────────────────
700
-
701
- // Document list (multi-file)
702
745
  if (pathname === "/api/documents" && method === "GET") {
703
- const files = fileOrder.map((fp) => {
704
- const state = fileMap.get(fp)!;
705
- return {
706
- path: fp,
707
- fileName: basename(fp),
708
- type: state.type,
709
- };
710
- });
746
+ const files = fileOrder.map((fp) => ({
747
+ path: fp,
748
+ fileName: basename(fp),
749
+ }));
711
750
  return json({
712
751
  files,
713
752
  clean: options.clean || false,
@@ -715,7 +754,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
715
754
  });
716
755
  }
717
756
 
718
- // Register a document for this session without forcing focus
719
757
  if (pathname === "/api/documents" && method === "POST") {
720
758
  try {
721
759
  const { path: requestedPath } = await req.json();
@@ -733,11 +771,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
733
771
  }
734
772
  throw err;
735
773
  }
736
- const fileType = getFileType(filePath);
737
-
738
- if (!fileType) {
774
+ if (!isMarkdownFile(filePath)) {
739
775
  return errorResponse(
740
- `Unsupported file type: ${filePath} (expected .md, .markdown, .html, or .htm)`,
776
+ `Unsupported file type: ${filePath} (expected .md or .markdown)`,
741
777
  400,
742
778
  );
743
779
  }
@@ -748,15 +784,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
748
784
  return json({
749
785
  path: filePath,
750
786
  fileName: basename(filePath),
751
- type: fileType,
752
787
  status: "present",
753
788
  });
754
789
  } else {
755
- // New document — register metadata only, load content on demand
756
790
  fileMap.set(filePath, {
757
791
  content: null,
758
792
  isLoaded: false,
759
- type: fileType,
760
793
  debounceTimer: null,
761
794
  });
762
795
  fileOrder.push(filePath);
@@ -768,14 +801,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
768
801
  type: "document-added",
769
802
  path: filePath,
770
803
  fileName: basename(filePath),
771
- fileType,
772
804
  });
773
805
  }
774
806
 
775
807
  return json({
776
808
  path: filePath,
777
809
  fileName: basename(filePath),
778
- type: fileType,
779
810
  status: "added",
780
811
  });
781
812
  } catch (err) {
@@ -784,15 +815,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
784
815
  }
785
816
  }
786
817
 
787
- // Single document (backward compat + path-aware)
788
818
  if (pathname === "/api/document" && method === "GET") {
789
819
  const ctxOrRes = requireContext(url);
790
820
  if (ctxOrRes instanceof Response) return ctxOrRes;
791
- const state = fileMap.get(ctxOrRes.filePath)!;
792
821
  const content = await ctxOrRes.getCurrentContent();
793
822
  return json({
794
823
  content,
795
- type: state.type,
796
824
  filePath: ctxOrRes.filePath,
797
825
  fileName: basename(ctxOrRes.filePath),
798
826
  clean: options.clean || false,
@@ -808,10 +836,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
808
836
  }
809
837
 
810
838
  if (pathname === "/api/heartbeat" && method === "GET") {
811
- return createHeartbeat(isDev);
839
+ return createHeartbeat(onHeartbeatOpen, onHeartbeatClose);
812
840
  }
813
841
 
814
- // Comments routes
815
842
  if (pathname === "/api/comments" && method === "GET") {
816
843
  const ctxOrRes = requireContext(url);
817
844
  if (ctxOrRes instanceof Response) return ctxOrRes;
@@ -836,7 +863,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
836
863
  return clearComments(ctxOrRes);
837
864
  }
838
865
 
839
- // Parameterized comment routes
840
866
  const commentId = extractCommentId(pathname);
841
867
  if (commentId) {
842
868
  const ctxOrRes = requireContext(url);
@@ -853,7 +879,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
853
879
  }
854
880
  }
855
881
 
856
- // Settings routes (global, not per-document)
857
882
  if (pathname === "/api/settings" && method === "GET") {
858
883
  return getSettingsRoute();
859
884
  }
@@ -862,8 +887,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
862
887
  return updateSettingsRoute(req);
863
888
  }
864
889
 
865
- // ── Static / SPA serving ────────────────────────────────
866
-
890
+ if (isDev) {
891
+ return proxyToVite(req, pathname, url.search);
892
+ }
867
893
  return serveStaticFile(distPath, pathname);
868
894
  },
869
895
  });
@@ -879,8 +905,6 @@ function createServer(options: ServerOptions): ServerWithWatchers {
879
905
  return { server, watchers };
880
906
  }
881
907
 
882
- // ─── Port fallback + start ──────────────────────────────────────────
883
-
884
908
  export async function startServer(
885
909
  options: ServerOptions,
886
910
  ): Promise<ServerResult> {
@@ -893,9 +917,15 @@ export async function startServer(
893
917
  const displayHost =
894
918
  options.host === "0.0.0.0" ? "localhost" : options.host;
895
919
 
920
+ let stopVite: (() => void) | undefined;
921
+ if (process.env.NODE_ENV === "development") {
922
+ stopVite = await spawnViteDev();
923
+ }
924
+
896
925
  const originalStop = server.stop.bind(server);
897
926
  const wrappedServer = {
898
927
  stop() {
928
+ stopVite?.();
899
929
  for (const w of watchers) w.close();
900
930
  originalStop();
901
931
  },
@@ -1,7 +1,5 @@
1
1
  import { createStore, useStore } from "zustand";
2
- import type { Comment, Document, Selection } from "../types";
3
-
4
- // ─── Types ───────────────────────────────────────────────────────────
2
+ import type { Comment, Document, Selection } from "./schema";
5
3
 
6
4
  export interface DocumentState {
7
5
  document: Document;
@@ -11,41 +9,27 @@ export interface DocumentState {
11
9
  selection: Selection | null;
12
10
  pendingSelectionTop: number | undefined;
13
11
  pendingCommentText: string;
14
- highlightPositions: Record<string, number>;
15
- documentPositions: Record<string, number>;
16
12
  scrollY: number;
17
- hoveredCommentId: string | undefined;
18
13
  reanchorTarget: { commentId: string } | null;
19
14
  }
20
15
 
21
16
  export interface AppStore {
22
- // Multi-document state
23
17
  documents: Map<string, DocumentState>;
24
18
  activeDocumentPath: string | null;
25
19
  documentOrder: string[];
26
20
  workingDirectory: string | null;
27
21
 
28
- // Global actions
29
22
  setWorkingDirectory: (dir: string) => void;
30
23
  openDocument: (doc: Document, opts?: { active?: boolean }) => void;
31
24
  closeDocument: (filePath: string) => void;
32
25
  setActiveDocument: (filePath: string) => void;
33
26
 
34
- // Per-document setters (default to active doc)
27
+ // Setters default to active doc when filePath omitted
35
28
  setComments: (comments: Comment[], filePath?: string) => void;
36
29
  setCommentsError: (error: string | null, filePath?: string) => void;
37
30
  setSelection: (selection: Selection | null, filePath?: string) => void;
38
31
  setPendingSelectionTop: (top: number | undefined, filePath?: string) => void;
39
- setHighlightPositions: (
40
- positions: Record<string, number>,
41
- filePath?: string,
42
- ) => void;
43
- setDocumentPositions: (
44
- positions: Record<string, number>,
45
- filePath?: string,
46
- ) => void;
47
32
  setScrollY: (y: number, filePath?: string) => void;
48
- setHoveredCommentId: (id: string | undefined, filePath?: string) => void;
49
33
  setReanchorTarget: (
50
34
  target: { commentId: string } | null,
51
35
  filePath?: string,
@@ -53,12 +37,9 @@ export interface AppStore {
53
37
  setPendingCommentText: (text: string, filePath?: string) => void;
54
38
  updateDocumentContent: (content: string, filePath?: string) => void;
55
39
 
56
- // Helpers
57
40
  getActiveDocumentState: () => DocumentState | undefined;
58
41
  }
59
42
 
60
- // ─── Helpers ─────────────────────────────────────────────────────────
61
-
62
43
  function createInitialDocumentState(doc: Document): DocumentState {
63
44
  return {
64
45
  document: doc,
@@ -68,10 +49,7 @@ function createInitialDocumentState(doc: Document): DocumentState {
68
49
  selection: null,
69
50
  pendingSelectionTop: undefined,
70
51
  pendingCommentText: "",
71
- highlightPositions: {},
72
- documentPositions: {},
73
52
  scrollY: 0,
74
- hoveredCommentId: undefined,
75
53
  reanchorTarget: null,
76
54
  };
77
55
  }
@@ -80,8 +58,6 @@ function sortComments(comments: Comment[]): Comment[] {
80
58
  return [...comments].sort((a, b) => a.startOffset - b.startOffset);
81
59
  }
82
60
 
83
- // ─── Store Factory ───────────────────────────────────────────────────
84
-
85
61
  export function createAppStore() {
86
62
  return createStore<AppStore>((set, get) => {
87
63
  const resolveFilePath = (filePath?: string): string | null =>
@@ -192,30 +168,12 @@ export function createAppStore() {
192
168
  updateDocState(path, () => ({ pendingSelectionTop: top }));
193
169
  },
194
170
 
195
- setHighlightPositions: (positions, filePath?) => {
196
- const path = resolveFilePath(filePath);
197
- if (!path) return;
198
- updateDocState(path, () => ({ highlightPositions: positions }));
199
- },
200
-
201
- setDocumentPositions: (positions, filePath?) => {
202
- const path = resolveFilePath(filePath);
203
- if (!path) return;
204
- updateDocState(path, () => ({ documentPositions: positions }));
205
- },
206
-
207
171
  setScrollY: (y, filePath?) => {
208
172
  const path = resolveFilePath(filePath);
209
173
  if (!path) return;
210
174
  updateDocState(path, () => ({ scrollY: y }));
211
175
  },
212
176
 
213
- setHoveredCommentId: (id, filePath?) => {
214
- const path = resolveFilePath(filePath);
215
- if (!path) return;
216
- updateDocState(path, () => ({ hoveredCommentId: id }));
217
- },
218
-
219
177
  setReanchorTarget: (target, filePath?) => {
220
178
  const path = resolveFilePath(filePath);
221
179
  if (!path) return;
@@ -245,10 +203,20 @@ export function createAppStore() {
245
203
  });
246
204
  }
247
205
 
248
- // ─── Singleton + React Hook ─────────────────────────────────────────
249
-
250
206
  export const appStore = createAppStore();
251
207
 
252
208
  export function useAppStore<T>(selector: (state: AppStore) => T): T {
253
209
  return useStore(appStore, selector);
254
210
  }
211
+
212
+ interface UIState {
213
+ hoveredCommentId: string | undefined;
214
+ }
215
+
216
+ export const uiStore = createStore<UIState>(() => ({
217
+ hoveredCommentId: undefined,
218
+ }));
219
+
220
+ export function useUI<T>(selector: (s: UIState) => T): T {
221
+ return useStore(uiStore, selector);
222
+ }
package/vite.config.ts CHANGED
@@ -5,6 +5,14 @@ import { defineConfig } from "vite";
5
5
  export default defineConfig({
6
6
  plugins: [tailwindcss(), react()],
7
7
  server: {
8
+ port: 24678,
9
+ strictPort: true,
10
+ host: "127.0.0.1",
11
+ hmr: {
12
+ host: "127.0.0.1",
13
+ port: 24678,
14
+ clientPort: 24678,
15
+ },
8
16
  proxy: {
9
17
  "/api": {
10
18
  target: "http://localhost:4567",