@peaske7/readit 0.1.6 → 0.1.8

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 (49) hide show
  1. package/biome.json +1 -1
  2. package/bun.lock +86 -72
  3. package/package.json +12 -11
  4. package/src/App.tsx +36 -16
  5. package/src/cli/index.ts +338 -70
  6. package/src/components/ActionsMenu.tsx +12 -10
  7. package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
  8. package/src/components/DocumentViewer/DocumentViewer.tsx +10 -8
  9. package/src/components/DocumentViewer/InlineCode.tsx +60 -0
  10. package/src/components/FloatingTOC.tsx +4 -2
  11. package/src/components/Header.tsx +3 -1
  12. package/src/components/InlineEditor.tsx +4 -2
  13. package/src/components/MarginNote.tsx +17 -8
  14. package/src/components/RawModal.tsx +9 -7
  15. package/src/components/ReanchorConfirm.tsx +6 -3
  16. package/src/components/SettingsModal.tsx +112 -23
  17. package/src/components/ShortcutCapture.tsx +4 -1
  18. package/src/components/ShortcutList.tsx +50 -9
  19. package/src/components/comments/CommentBadge.tsx +7 -1
  20. package/src/components/comments/CommentInput.tsx +13 -18
  21. package/src/components/comments/CommentListItem.tsx +15 -5
  22. package/src/components/comments/CommentManager.tsx +14 -7
  23. package/src/components/comments/CommentNav.tsx +8 -3
  24. package/src/contexts/CommentContext.tsx +16 -9
  25. package/src/contexts/LayoutContext.tsx +17 -5
  26. package/src/contexts/LocaleContext.tsx +35 -0
  27. package/src/hooks/useClipboard.ts +11 -8
  28. package/src/hooks/useDocument.ts +33 -18
  29. package/src/hooks/useEditorScheme.ts +51 -0
  30. package/src/hooks/useFontPreference.ts +5 -22
  31. package/src/hooks/useKeybindings.ts +6 -18
  32. package/src/hooks/useLocalePreference.ts +42 -0
  33. package/src/index.css +87 -26
  34. package/src/lib/editor-links.ts +59 -0
  35. package/src/lib/highlight/dom.ts +126 -54
  36. package/src/lib/highlight/highlighter.ts +10 -10
  37. package/src/lib/i18n/completeness.test.ts +51 -0
  38. package/src/lib/i18n/en.ts +139 -0
  39. package/src/lib/i18n/index.ts +3 -0
  40. package/src/lib/i18n/ja.ts +141 -0
  41. package/src/lib/i18n/translations.test.ts +39 -0
  42. package/src/lib/i18n/translations.ts +27 -0
  43. package/src/lib/i18n/types.ts +145 -0
  44. package/src/lib/shortcut-registry.ts +1 -1
  45. package/src/main.tsx +4 -1
  46. package/src/server/index.ts +197 -124
  47. package/src/store/index.test.ts +22 -0
  48. package/src/store/index.ts +24 -4
  49. package/src/types/index.ts +12 -0
@@ -19,6 +19,8 @@ import {
19
19
  type Comment,
20
20
  type DocumentSettings,
21
21
  type DocumentType,
22
+ type EditorScheme,
23
+ EditorSchemes,
22
24
  FontFamilies,
23
25
  type FontFamily,
24
26
  } from "../types/index.js";
@@ -30,7 +32,7 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
30
32
  }
31
33
 
32
34
  export interface FileEntry {
33
- content: string;
35
+ content?: string;
34
36
  type: DocumentType;
35
37
  filePath: string;
36
38
  }
@@ -48,17 +50,43 @@ export interface ServerResult {
48
50
  server: { stop(): void };
49
51
  }
50
52
 
53
+ interface ResolvedCommentsCacheEntry {
54
+ commentMtimeMs: number;
55
+ sourceHash: string;
56
+ comments: Comment[];
57
+ }
58
+
59
+ const resolvedCommentsCache = new Map<string, ResolvedCommentsCacheEntry>();
60
+
61
+ function invalidateResolvedComments(filePath: string): void {
62
+ resolvedCommentsCache.delete(filePath);
63
+ }
64
+
65
+ async function canonicalPath(filePath: string): Promise<string> {
66
+ return fs.realpath(path.resolve(filePath));
67
+ }
68
+
51
69
  async function readCommentsFromFile(
52
70
  filePath: string,
53
71
  sourceContent: string,
54
72
  ): Promise<Comment[]> {
55
73
  const commentPath = getCommentPath(filePath);
74
+ const sourceHash = computeHash(sourceContent);
56
75
 
57
76
  try {
77
+ const stats = await fs.stat(commentPath);
78
+ const cached = resolvedCommentsCache.get(filePath);
79
+ if (
80
+ cached &&
81
+ cached.sourceHash === sourceHash &&
82
+ cached.commentMtimeMs === stats.mtimeMs
83
+ ) {
84
+ return cached.comments;
85
+ }
86
+
58
87
  const content = await fs.readFile(commentPath, "utf-8");
59
88
  const file = parseCommentFile(content);
60
-
61
- return file.comments.map((comment) => {
89
+ const resolvedComments = file.comments.map((comment) => {
62
90
  const textForMatching = comment.anchorPrefix || comment.selectedText;
63
91
  const anchor = findAnchorWithFallback({
64
92
  source: sourceContent,
@@ -81,8 +109,17 @@ async function readCommentsFromFile(
81
109
  anchorConfidence: AnchorConfidences.UNRESOLVED,
82
110
  };
83
111
  });
112
+
113
+ resolvedCommentsCache.set(filePath, {
114
+ sourceHash,
115
+ commentMtimeMs: stats.mtimeMs,
116
+ comments: resolvedComments,
117
+ });
118
+
119
+ return resolvedComments;
84
120
  } catch (err) {
85
121
  if (isErrnoException(err) && err.code === "ENOENT") {
122
+ invalidateResolvedComments(filePath);
86
123
  return [];
87
124
  }
88
125
  throw err;
@@ -110,6 +147,7 @@ async function writeCommentsToFile(
110
147
  const tempPath = `${commentPath}.tmp`;
111
148
  await fs.writeFile(tempPath, content, "utf-8");
112
149
  await fs.rename(tempPath, commentPath);
150
+ invalidateResolvedComments(filePath);
113
151
  }
114
152
 
115
153
  async function deleteCommentFile(filePath: string): Promise<void> {
@@ -121,30 +159,19 @@ async function deleteCommentFile(filePath: string): Promise<void> {
121
159
  throw err;
122
160
  }
123
161
  }
162
+ invalidateResolvedComments(filePath);
124
163
  }
125
164
 
126
- function getSettingsPath(sourcePath: string): string {
127
- const absolute = path.resolve(sourcePath);
128
- const normalized = absolute.replace(/^\//, "").replace(/^[A-Z]:[\\/]/, "");
129
- return path.join(
130
- os.homedir(),
131
- ".readit",
132
- "settings",
133
- `${normalized}.settings.json`,
134
- );
135
- }
165
+ const SETTINGS_PATH = path.join(os.homedir(), ".readit", "settings.json");
136
166
 
137
167
  const DEFAULT_SETTINGS: DocumentSettings = {
138
168
  version: 1,
139
169
  fontFamily: FontFamilies.SERIF,
140
170
  };
141
171
 
142
- async function readSettingsFromFile(
143
- filePath: string,
144
- ): Promise<DocumentSettings> {
145
- const settingsPath = getSettingsPath(filePath);
172
+ async function readSettings(): Promise<DocumentSettings> {
146
173
  try {
147
- const content = await fs.readFile(settingsPath, "utf-8");
174
+ const content = await fs.readFile(SETTINGS_PATH, "utf-8");
148
175
  return JSON.parse(content) as DocumentSettings;
149
176
  } catch (err) {
150
177
  if (isErrnoException(err) && err.code === "ENOENT") {
@@ -154,24 +181,23 @@ async function readSettingsFromFile(
154
181
  }
155
182
  }
156
183
 
157
- async function writeSettingsToFile(
158
- filePath: string,
159
- settings: DocumentSettings,
160
- ): Promise<void> {
161
- const settingsPath = getSettingsPath(filePath);
162
- const settingsDir = dirname(settingsPath);
163
-
184
+ async function writeSettings(settings: DocumentSettings): Promise<void> {
185
+ const settingsDir = dirname(SETTINGS_PATH);
164
186
  await fs.mkdir(settingsDir, { recursive: true });
165
187
 
166
- const tempPath = `${settingsPath}.tmp`;
188
+ const tempPath = `${SETTINGS_PATH}.tmp`;
167
189
  await fs.writeFile(tempPath, JSON.stringify(settings, null, 2), "utf-8");
168
- await fs.rename(tempPath, settingsPath);
190
+ await fs.rename(tempPath, SETTINGS_PATH);
169
191
  }
170
192
 
171
193
  function isValidFontFamily(value: unknown): value is FontFamily {
172
194
  return value === FontFamilies.SERIF || value === FontFamilies.SANS_SERIF;
173
195
  }
174
196
 
197
+ function isValidEditorScheme(value: unknown): value is EditorScheme {
198
+ return Object.values(EditorSchemes).includes(value as EditorScheme);
199
+ }
200
+
175
201
  // ─── PID file helpers ───────────────────────────────────────────────
176
202
 
177
203
  export const SERVER_INFO_PATH = path.join(
@@ -213,17 +239,15 @@ function errorResponse(message: string, status: number): Response {
213
239
 
214
240
  interface RouteContext {
215
241
  filePath: string;
216
- getCurrentContent: () => string;
242
+ getCurrentContent: () => Promise<string>;
217
243
  }
218
244
 
219
245
  // ─── Route handlers ─────────────────────────────────────────────────
220
246
 
221
247
  async function getComments(ctx: RouteContext): Promise<Response> {
222
248
  try {
223
- const comments = await readCommentsFromFile(
224
- ctx.filePath,
225
- ctx.getCurrentContent(),
226
- );
249
+ const currentContent = await ctx.getCurrentContent();
250
+ const comments = await readCommentsFromFile(ctx.filePath, currentContent);
227
251
  return json({ comments });
228
252
  } catch (err) {
229
253
  console.error("Failed to read comments:", err);
@@ -249,7 +273,7 @@ async function addComment(ctx: RouteContext, req: Request): Promise<Response> {
249
273
  return errorResponse("Missing required fields", 400);
250
274
  }
251
275
 
252
- const currentContent = ctx.getCurrentContent();
276
+ const currentContent = await ctx.getCurrentContent();
253
277
  const newComment = createComment(
254
278
  selectedText,
255
279
  commentText,
@@ -285,7 +309,7 @@ async function updateComment(
285
309
  return errorResponse("Missing comment text", 400);
286
310
  }
287
311
 
288
- const currentContent = ctx.getCurrentContent();
312
+ const currentContent = await ctx.getCurrentContent();
289
313
  const existingComments = await readCommentsFromFile(
290
314
  ctx.filePath,
291
315
  currentContent,
@@ -311,7 +335,7 @@ async function updateComment(
311
335
 
312
336
  async function deleteComment(ctx: RouteContext, id: string): Promise<Response> {
313
337
  try {
314
- const currentContent = ctx.getCurrentContent();
338
+ const currentContent = await ctx.getCurrentContent();
315
339
  const existingComments = await readCommentsFromFile(
316
340
  ctx.filePath,
317
341
  currentContent,
@@ -371,7 +395,7 @@ async function reanchorComment(
371
395
  return errorResponse("Missing required fields", 400);
372
396
  }
373
397
 
374
- const currentContent = ctx.getCurrentContent();
398
+ const currentContent = await ctx.getCurrentContent();
375
399
  const existingComments = await readCommentsFromFile(
376
400
  ctx.filePath,
377
401
  currentContent,
@@ -409,9 +433,9 @@ async function reanchorComment(
409
433
  }
410
434
  }
411
435
 
412
- async function getSettings(ctx: RouteContext): Promise<Response> {
436
+ async function getSettingsRoute(): Promise<Response> {
413
437
  try {
414
- const settings = await readSettingsFromFile(ctx.filePath);
438
+ const settings = await readSettings();
415
439
  return json(settings);
416
440
  } catch (err) {
417
441
  console.error("Failed to read settings:", err);
@@ -419,27 +443,28 @@ async function getSettings(ctx: RouteContext): Promise<Response> {
419
443
  }
420
444
  }
421
445
 
422
- async function updateSettings(
423
- ctx: RouteContext,
424
- req: Request,
425
- ): Promise<Response> {
446
+ async function updateSettingsRoute(req: Request): Promise<Response> {
426
447
  try {
427
448
  const body = await req.json();
428
- const { fontFamily, keybindings } = body;
449
+ const { fontFamily, editorScheme, keybindings } = body;
429
450
 
430
451
  if (fontFamily !== undefined && !isValidFontFamily(fontFamily)) {
431
452
  return errorResponse("Invalid font family", 400);
432
453
  }
433
454
 
434
- // Read current settings and merge
435
- const current = await readSettingsFromFile(ctx.filePath);
455
+ if (editorScheme !== undefined && !isValidEditorScheme(editorScheme)) {
456
+ return errorResponse("Invalid editor scheme", 400);
457
+ }
458
+
459
+ const current = await readSettings();
436
460
  const settings: DocumentSettings = {
437
461
  ...current,
438
462
  ...(fontFamily !== undefined && { fontFamily }),
463
+ ...(editorScheme !== undefined && { editorScheme }),
439
464
  ...(keybindings !== undefined && { keybindings }),
440
465
  };
441
466
 
442
- await writeSettingsToFile(ctx.filePath, settings);
467
+ await writeSettings(settings);
443
468
  return json(settings);
444
469
  } catch (err) {
445
470
  console.error("Failed to save settings:", err);
@@ -452,13 +477,26 @@ async function updateSettings(
452
477
  function createDocumentStream(
453
478
  sseClients: Set<ReadableStreamDefaultController>,
454
479
  ): Response {
480
+ let pingInterval: ReturnType<typeof setInterval>;
481
+ let captured: ReadableStreamDefaultController;
482
+
455
483
  const stream = new ReadableStream({
456
484
  start(controller) {
485
+ captured = controller;
457
486
  controller.enqueue("data: connected\n\n");
458
487
  sseClients.add(controller);
488
+ pingInterval = setInterval(() => {
489
+ try {
490
+ controller.enqueue("data: ping\n\n");
491
+ } catch {
492
+ clearInterval(pingInterval);
493
+ sseClients.delete(controller);
494
+ }
495
+ }, 5000);
459
496
  },
460
- cancel(controller) {
461
- sseClients.delete(controller);
497
+ cancel() {
498
+ clearInterval(pingInterval);
499
+ sseClients.delete(captured);
462
500
  },
463
501
  });
464
502
 
@@ -471,27 +509,30 @@ function createDocumentStream(
471
509
  });
472
510
  }
473
511
 
474
- function createHeartbeat(isDev: boolean): Response {
512
+ function createHeartbeat(
513
+ onOpen: (controller: ReadableStreamDefaultController) => void,
514
+ onClose: (controller: ReadableStreamDefaultController) => void,
515
+ ): Response {
475
516
  let interval: ReturnType<typeof setInterval>;
517
+ let captured: ReadableStreamDefaultController;
476
518
 
477
519
  const stream = new ReadableStream({
478
520
  start(controller) {
521
+ captured = controller;
479
522
  controller.enqueue("data: connected\n\n");
523
+ onOpen(controller);
480
524
  interval = setInterval(() => {
481
525
  try {
482
526
  controller.enqueue("data: ping\n\n");
483
527
  } catch {
484
528
  clearInterval(interval);
529
+ onClose(controller);
485
530
  }
486
531
  }, 5000);
487
532
  },
488
533
  cancel() {
489
534
  clearInterval(interval);
490
- if (isDev) return;
491
- setTimeout(() => {
492
- console.log("\nBrowser disconnected, shutting down...");
493
- process.exit(0);
494
- }, 100);
535
+ onClose(captured);
495
536
  },
496
537
  });
497
538
 
@@ -536,7 +577,8 @@ function extractCommentId(pathname: string): string | undefined {
536
577
  // ─── Multi-file state ───────────────────────────────────────────────
537
578
 
538
579
  interface FileState {
539
- content: string;
580
+ content: string | null;
581
+ isLoaded: boolean;
540
582
  type: DocumentType;
541
583
  debounceTimer: ReturnType<typeof setTimeout> | null;
542
584
  }
@@ -556,7 +598,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
556
598
 
557
599
  for (const entry of options.files) {
558
600
  fileMap.set(entry.filePath, {
559
- content: entry.content,
601
+ content: entry.content ?? null,
602
+ isLoaded: entry.content !== undefined,
560
603
  type: entry.type,
561
604
  debounceTimer: null,
562
605
  });
@@ -565,6 +608,60 @@ function createServer(options: ServerOptions): ServerWithWatchers {
565
608
 
566
609
  const defaultPath = fileOrder[0];
567
610
  const sseClients = new Set<ReadableStreamDefaultController>();
611
+ const heartbeatClients = new Set<ReadableStreamDefaultController>();
612
+ let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
613
+
614
+ function sendEvent(event: unknown): void {
615
+ const message = `data: ${JSON.stringify(event)}\n\n`;
616
+ for (const controller of sseClients) {
617
+ try {
618
+ controller.enqueue(message);
619
+ } catch {
620
+ sseClients.delete(controller);
621
+ }
622
+ }
623
+ }
624
+
625
+ function clearShutdownTimer(): void {
626
+ if (!shutdownTimer) return;
627
+ clearTimeout(shutdownTimer);
628
+ shutdownTimer = null;
629
+ }
630
+
631
+ function onHeartbeatOpen(controller: ReadableStreamDefaultController): void {
632
+ heartbeatClients.add(controller);
633
+ clearShutdownTimer();
634
+ }
635
+
636
+ function onHeartbeatClose(controller: ReadableStreamDefaultController): void {
637
+ heartbeatClients.delete(controller);
638
+ if (isDev || heartbeatClients.size > 0 || shutdownTimer) return;
639
+
640
+ shutdownTimer = setTimeout(() => {
641
+ if (heartbeatClients.size > 0) {
642
+ clearShutdownTimer();
643
+ return;
644
+ }
645
+ console.log("\nBrowser disconnected, shutting down...");
646
+ process.exit(0);
647
+ }, 1500);
648
+ }
649
+
650
+ async function ensureFileContent(filePath: string): Promise<string> {
651
+ const state = fileMap.get(filePath);
652
+ if (!state) {
653
+ throw new Error(`File not found: ${filePath}`);
654
+ }
655
+
656
+ if (state.isLoaded && state.content !== null) {
657
+ return state.content;
658
+ }
659
+
660
+ const content = await fs.readFile(filePath, "utf-8");
661
+ state.content = content;
662
+ state.isLoaded = true;
663
+ return content;
664
+ }
568
665
 
569
666
  // Resolve the target file from ?path= query param, falling back to first file
570
667
  function resolveContext(url: URL): RouteContext | null {
@@ -573,7 +670,7 @@ function createServer(options: ServerOptions): ServerWithWatchers {
573
670
  if (!state) return null;
574
671
  return {
575
672
  filePath: requestedPath,
576
- getCurrentContent: () => state.content,
673
+ getCurrentContent: () => ensureFileContent(requestedPath),
577
674
  };
578
675
  }
579
676
 
@@ -600,18 +697,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
600
697
  state.debounceTimer = setTimeout(async () => {
601
698
  try {
602
699
  const newContent = await fs.readFile(targetPath, "utf-8");
603
- if (newContent !== state.content) {
700
+ if (!state.isLoaded || newContent !== state.content) {
604
701
  state.content = newContent;
702
+ state.isLoaded = true;
703
+ invalidateResolvedComments(targetPath);
605
704
  console.log(`File changed: ${basename(targetPath)}`);
606
-
607
- const message = `data: ${JSON.stringify({ type: "update", path: targetPath })}\n\n`;
608
- for (const controller of sseClients) {
609
- try {
610
- controller.enqueue(message);
611
- } catch {
612
- sseClients.delete(controller);
613
- }
614
- }
705
+ sendEvent({ type: "document-updated", path: targetPath });
615
706
  }
616
707
  } catch (err) {
617
708
  console.error(`Failed to read updated file ${targetPath}:`, err);
@@ -628,6 +719,7 @@ function createServer(options: ServerOptions): ServerWithWatchers {
628
719
  const server = Bun.serve({
629
720
  port: options.port,
630
721
  hostname: options.host,
722
+ idleTimeout: 255, // max value (seconds) — SSE streams stay open long
631
723
 
632
724
  async fetch(req) {
633
725
  const url = new URL(req.url);
@@ -646,11 +738,15 @@ function createServer(options: ServerOptions): ServerWithWatchers {
646
738
  type: state.type,
647
739
  };
648
740
  });
649
- return json({ files, clean: options.clean || false });
741
+ return json({
742
+ files,
743
+ clean: options.clean || false,
744
+ workingDirectory: process.cwd(),
745
+ });
650
746
  }
651
747
 
652
- // Hot-add or refresh a file
653
- if (pathname === "/api/files" && method === "POST") {
748
+ // Register a document for this session without forcing focus
749
+ if (pathname === "/api/documents" && method === "POST") {
654
750
  try {
655
751
  const { path: requestedPath } = await req.json();
656
752
 
@@ -658,7 +754,15 @@ function createServer(options: ServerOptions): ServerWithWatchers {
658
754
  return errorResponse("Missing 'path' field", 400);
659
755
  }
660
756
 
661
- const filePath = path.resolve(requestedPath);
757
+ let filePath: string;
758
+ try {
759
+ filePath = await canonicalPath(requestedPath);
760
+ } catch (err) {
761
+ if (isErrnoException(err) && err.code === "ENOENT") {
762
+ return errorResponse(`File not found: ${requestedPath}`, 404);
763
+ }
764
+ throw err;
765
+ }
662
766
  const fileType = getFileType(filePath);
663
767
 
664
768
  if (!fileType) {
@@ -668,65 +772,45 @@ function createServer(options: ServerOptions): ServerWithWatchers {
668
772
  );
669
773
  }
670
774
 
671
- let content: string;
672
- try {
673
- content = await fs.readFile(filePath, "utf-8");
674
- } catch (err) {
675
- if (isErrnoException(err) && err.code === "ENOENT") {
676
- return errorResponse(`File not found: ${filePath}`, 404);
677
- }
678
- throw err;
679
- }
680
-
681
775
  const existingState = fileMap.get(filePath);
682
776
 
683
777
  if (existingState) {
684
- // File already loaded — refresh content
685
- existingState.content = content;
686
- const message = `data: ${JSON.stringify({ type: "update", path: filePath })}\n\n`;
687
- for (const controller of sseClients) {
688
- try {
689
- controller.enqueue(message);
690
- } catch {
691
- sseClients.delete(controller);
692
- }
693
- }
778
+ return json({
779
+ path: filePath,
780
+ fileName: basename(filePath),
781
+ type: fileType,
782
+ status: "present",
783
+ });
694
784
  } else {
695
- // New fileadd to server
785
+ // New documentregister metadata only, load content on demand
696
786
  fileMap.set(filePath, {
697
- content,
787
+ content: null,
788
+ isLoaded: false,
698
789
  type: fileType,
699
790
  debounceTimer: null,
700
791
  });
701
792
  fileOrder.push(filePath);
702
793
 
703
- // Set up file watcher for the new file
704
794
  const watcher = watchFile(filePath);
705
795
  if (watcher) watchers.push(watcher);
706
796
 
707
- const message = `data: ${JSON.stringify({
708
- type: "file-added",
797
+ sendEvent({
798
+ type: "document-added",
709
799
  path: filePath,
710
800
  fileName: basename(filePath),
711
801
  fileType,
712
- })}\n\n`;
713
- for (const controller of sseClients) {
714
- try {
715
- controller.enqueue(message);
716
- } catch {
717
- sseClients.delete(controller);
718
- }
719
- }
802
+ });
720
803
  }
721
804
 
722
805
  return json({
723
806
  path: filePath,
724
807
  fileName: basename(filePath),
725
808
  type: fileType,
809
+ status: "added",
726
810
  });
727
811
  } catch (err) {
728
- console.error("Failed to add file:", err);
729
- return errorResponse("Failed to add file", 500);
812
+ console.error("Failed to add document:", err);
813
+ return errorResponse("Failed to add document", 500);
730
814
  }
731
815
  }
732
816
 
@@ -735,8 +819,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
735
819
  const ctxOrRes = requireContext(url);
736
820
  if (ctxOrRes instanceof Response) return ctxOrRes;
737
821
  const state = fileMap.get(ctxOrRes.filePath)!;
822
+ const content = await ctxOrRes.getCurrentContent();
738
823
  return json({
739
- content: state.content,
824
+ content,
740
825
  type: state.type,
741
826
  filePath: ctxOrRes.filePath,
742
827
  fileName: basename(ctxOrRes.filePath),
@@ -753,7 +838,7 @@ function createServer(options: ServerOptions): ServerWithWatchers {
753
838
  }
754
839
 
755
840
  if (pathname === "/api/heartbeat" && method === "GET") {
756
- return createHeartbeat(isDev);
841
+ return createHeartbeat(onHeartbeatOpen, onHeartbeatClose);
757
842
  }
758
843
 
759
844
  // Comments routes
@@ -798,30 +883,18 @@ function createServer(options: ServerOptions): ServerWithWatchers {
798
883
  }
799
884
  }
800
885
 
801
- // Settings routes
886
+ // Settings routes (global, not per-document)
802
887
  if (pathname === "/api/settings" && method === "GET") {
803
- const ctxOrRes = requireContext(url);
804
- if (ctxOrRes instanceof Response) return ctxOrRes;
805
- return getSettings(ctxOrRes);
888
+ return getSettingsRoute();
806
889
  }
807
890
 
808
891
  if (pathname === "/api/settings" && method === "PUT") {
809
- const ctxOrRes = requireContext(url);
810
- if (ctxOrRes instanceof Response) return ctxOrRes;
811
- return updateSettings(ctxOrRes, req);
892
+ return updateSettingsRoute(req);
812
893
  }
813
894
 
814
895
  // ── Static / SPA serving ────────────────────────────────
815
896
 
816
- if (isDev && pathname === "/") {
817
- return Response.redirect("http://localhost:5173", 302);
818
- }
819
-
820
- if (!isDev) {
821
- return serveStaticFile(distPath, pathname);
822
- }
823
-
824
- return new Response("Not Found", { status: 404 });
897
+ return serveStaticFile(distPath, pathname);
825
898
  },
826
899
  });
827
900
 
@@ -64,6 +64,28 @@ describe("AppStore", () => {
64
64
  "/test/file.html",
65
65
  ]);
66
66
  });
67
+
68
+ it("adds document without activating when active is false", () => {
69
+ store.getState().openDocument(mockDoc);
70
+ store.getState().openDocument(mockDoc2, { active: false });
71
+ expect(store.getState().activeDocumentPath).toBe("/test/file.md");
72
+ expect(store.getState().documentOrder).toEqual([
73
+ "/test/file.md",
74
+ "/test/file.html",
75
+ ]);
76
+ });
77
+
78
+ it("updates existing document without stealing focus when active is false", () => {
79
+ store.getState().openDocument(mockDoc);
80
+ store.getState().openDocument(mockDoc2);
81
+ store
82
+ .getState()
83
+ .openDocument({ ...mockDoc, content: "# Updated" }, { active: false });
84
+ expect(store.getState().activeDocumentPath).toBe("/test/file.html");
85
+ expect(
86
+ store.getState().documents.get("/test/file.md")!.document.content,
87
+ ).toBe("# Updated");
88
+ });
67
89
  });
68
90
 
69
91
  describe("closeDocument", () => {
@@ -23,9 +23,11 @@ export interface AppStore {
23
23
  documents: Map<string, DocumentState>;
24
24
  activeDocumentPath: string | null;
25
25
  documentOrder: string[];
26
+ workingDirectory: string | null;
26
27
 
27
28
  // Global actions
28
- openDocument: (doc: Document) => void;
29
+ setWorkingDirectory: (dir: string) => void;
30
+ openDocument: (doc: Document, opts?: { active?: boolean }) => void;
29
31
  closeDocument: (filePath: string) => void;
30
32
  setActiveDocument: (filePath: string) => void;
31
33
 
@@ -103,17 +105,35 @@ export function createAppStore() {
103
105
  documents: new Map(),
104
106
  activeDocumentPath: null,
105
107
  documentOrder: [],
108
+ workingDirectory: null,
106
109
 
107
- openDocument: (doc) => {
110
+ setWorkingDirectory: (dir) => set({ workingDirectory: dir }),
111
+
112
+ openDocument: (doc, opts) => {
108
113
  set((prev) => {
114
+ const active = opts?.active ?? true;
115
+ const nextActive =
116
+ active || !prev.activeDocumentPath
117
+ ? doc.filePath
118
+ : prev.activeDocumentPath;
119
+
109
120
  if (prev.documents.has(doc.filePath)) {
110
- return { activeDocumentPath: doc.filePath };
121
+ const newDocs = new Map(prev.documents);
122
+ const prevDoc = newDocs.get(doc.filePath)!;
123
+ newDocs.set(doc.filePath, {
124
+ ...prevDoc,
125
+ document: { ...prevDoc.document, ...doc },
126
+ });
127
+ return {
128
+ documents: newDocs,
129
+ activeDocumentPath: nextActive,
130
+ };
111
131
  }
112
132
  const newDocs = new Map(prev.documents);
113
133
  newDocs.set(doc.filePath, createInitialDocumentState(doc));
114
134
  return {
115
135
  documents: newDocs,
116
- activeDocumentPath: doc.filePath,
136
+ activeDocumentPath: nextActive,
117
137
  documentOrder: [...prev.documentOrder, doc.filePath],
118
138
  };
119
139
  });