@peaske7/readit 0.1.5 → 0.1.7

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 (52) hide show
  1. package/biome.json +1 -1
  2. package/bun.lock +86 -72
  3. package/docs/plans/2026-03-13-client-mode-design.md +86 -0
  4. package/docs/plans/2026-03-13-client-mode-plan.md +605 -0
  5. package/package.json +12 -11
  6. package/src/App.tsx +23 -6
  7. package/src/cli/index.ts +312 -25
  8. package/src/components/ActionsMenu.tsx +12 -10
  9. package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
  10. package/src/components/DocumentViewer/DocumentViewer.tsx +6 -6
  11. package/src/components/DocumentViewer/InlineCode.tsx +60 -0
  12. package/src/components/FloatingTOC.tsx +4 -2
  13. package/src/components/Header.tsx +3 -1
  14. package/src/components/InlineEditor.tsx +4 -2
  15. package/src/components/MarginNote.tsx +17 -8
  16. package/src/components/RawModal.tsx +9 -7
  17. package/src/components/ReanchorConfirm.tsx +6 -3
  18. package/src/components/SettingsModal.tsx +112 -23
  19. package/src/components/ShortcutCapture.tsx +4 -1
  20. package/src/components/ShortcutList.tsx +50 -9
  21. package/src/components/comments/CommentBadge.tsx +7 -1
  22. package/src/components/comments/CommentInput.tsx +13 -18
  23. package/src/components/comments/CommentListItem.tsx +15 -5
  24. package/src/components/comments/CommentManager.tsx +14 -7
  25. package/src/components/comments/CommentNav.tsx +8 -3
  26. package/src/contexts/CommentContext.tsx +16 -9
  27. package/src/contexts/LayoutContext.tsx +17 -5
  28. package/src/contexts/LocaleContext.tsx +35 -0
  29. package/src/hooks/useClipboard.ts +11 -8
  30. package/src/hooks/useDocument.ts +35 -10
  31. package/src/hooks/useEditorScheme.ts +51 -0
  32. package/src/hooks/useFontPreference.ts +5 -22
  33. package/src/hooks/useKeybindings.ts +6 -18
  34. package/src/hooks/useLocalePreference.ts +42 -0
  35. package/src/index.css +87 -26
  36. package/src/lib/editor-links.ts +59 -0
  37. package/src/lib/highlight/dom.ts +126 -54
  38. package/src/lib/highlight/highlighter.ts +10 -10
  39. package/src/lib/i18n/completeness.test.ts +51 -0
  40. package/src/lib/i18n/en.ts +139 -0
  41. package/src/lib/i18n/index.ts +3 -0
  42. package/src/lib/i18n/ja.ts +141 -0
  43. package/src/lib/i18n/translations.test.ts +39 -0
  44. package/src/lib/i18n/translations.ts +27 -0
  45. package/src/lib/i18n/types.ts +145 -0
  46. package/src/lib/shortcut-registry.ts +1 -1
  47. package/src/lib/utils.ts +11 -0
  48. package/src/main.tsx +4 -1
  49. package/src/server/index.ts +263 -103
  50. package/src/store/index.test.ts +22 -0
  51. package/src/store/index.ts +24 -4
  52. package/src/types/index.ts +12 -0
@@ -13,11 +13,14 @@ import {
13
13
  serializeComments,
14
14
  truncateSelection,
15
15
  } from "../lib/comment-storage.js";
16
+ import { getFileType } from "../lib/utils.js";
16
17
  import {
17
18
  AnchorConfidences,
18
19
  type Comment,
19
20
  type DocumentSettings,
20
21
  type DocumentType,
22
+ type EditorScheme,
23
+ EditorSchemes,
21
24
  FontFamilies,
22
25
  type FontFamily,
23
26
  } from "../types/index.js";
@@ -29,7 +32,7 @@ function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
29
32
  }
30
33
 
31
34
  export interface FileEntry {
32
- content: string;
35
+ content?: string;
33
36
  type: DocumentType;
34
37
  filePath: string;
35
38
  }
@@ -47,17 +50,43 @@ export interface ServerResult {
47
50
  server: { stop(): void };
48
51
  }
49
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
+
50
69
  async function readCommentsFromFile(
51
70
  filePath: string,
52
71
  sourceContent: string,
53
72
  ): Promise<Comment[]> {
54
73
  const commentPath = getCommentPath(filePath);
74
+ const sourceHash = computeHash(sourceContent);
55
75
 
56
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
+
57
87
  const content = await fs.readFile(commentPath, "utf-8");
58
88
  const file = parseCommentFile(content);
59
-
60
- return file.comments.map((comment) => {
89
+ const resolvedComments = file.comments.map((comment) => {
61
90
  const textForMatching = comment.anchorPrefix || comment.selectedText;
62
91
  const anchor = findAnchorWithFallback({
63
92
  source: sourceContent,
@@ -80,8 +109,17 @@ async function readCommentsFromFile(
80
109
  anchorConfidence: AnchorConfidences.UNRESOLVED,
81
110
  };
82
111
  });
112
+
113
+ resolvedCommentsCache.set(filePath, {
114
+ sourceHash,
115
+ commentMtimeMs: stats.mtimeMs,
116
+ comments: resolvedComments,
117
+ });
118
+
119
+ return resolvedComments;
83
120
  } catch (err) {
84
121
  if (isErrnoException(err) && err.code === "ENOENT") {
122
+ invalidateResolvedComments(filePath);
85
123
  return [];
86
124
  }
87
125
  throw err;
@@ -109,6 +147,7 @@ async function writeCommentsToFile(
109
147
  const tempPath = `${commentPath}.tmp`;
110
148
  await fs.writeFile(tempPath, content, "utf-8");
111
149
  await fs.rename(tempPath, commentPath);
150
+ invalidateResolvedComments(filePath);
112
151
  }
113
152
 
114
153
  async function deleteCommentFile(filePath: string): Promise<void> {
@@ -120,30 +159,19 @@ async function deleteCommentFile(filePath: string): Promise<void> {
120
159
  throw err;
121
160
  }
122
161
  }
162
+ invalidateResolvedComments(filePath);
123
163
  }
124
164
 
125
- function getSettingsPath(sourcePath: string): string {
126
- const absolute = path.resolve(sourcePath);
127
- const normalized = absolute.replace(/^\//, "").replace(/^[A-Z]:[\\/]/, "");
128
- return path.join(
129
- os.homedir(),
130
- ".readit",
131
- "settings",
132
- `${normalized}.settings.json`,
133
- );
134
- }
165
+ const SETTINGS_PATH = path.join(os.homedir(), ".readit", "settings.json");
135
166
 
136
167
  const DEFAULT_SETTINGS: DocumentSettings = {
137
168
  version: 1,
138
169
  fontFamily: FontFamilies.SERIF,
139
170
  };
140
171
 
141
- async function readSettingsFromFile(
142
- filePath: string,
143
- ): Promise<DocumentSettings> {
144
- const settingsPath = getSettingsPath(filePath);
172
+ async function readSettings(): Promise<DocumentSettings> {
145
173
  try {
146
- const content = await fs.readFile(settingsPath, "utf-8");
174
+ const content = await fs.readFile(SETTINGS_PATH, "utf-8");
147
175
  return JSON.parse(content) as DocumentSettings;
148
176
  } catch (err) {
149
177
  if (isErrnoException(err) && err.code === "ENOENT") {
@@ -153,24 +181,50 @@ async function readSettingsFromFile(
153
181
  }
154
182
  }
155
183
 
156
- async function writeSettingsToFile(
157
- filePath: string,
158
- settings: DocumentSettings,
159
- ): Promise<void> {
160
- const settingsPath = getSettingsPath(filePath);
161
- const settingsDir = dirname(settingsPath);
162
-
184
+ async function writeSettings(settings: DocumentSettings): Promise<void> {
185
+ const settingsDir = dirname(SETTINGS_PATH);
163
186
  await fs.mkdir(settingsDir, { recursive: true });
164
187
 
165
- const tempPath = `${settingsPath}.tmp`;
188
+ const tempPath = `${SETTINGS_PATH}.tmp`;
166
189
  await fs.writeFile(tempPath, JSON.stringify(settings, null, 2), "utf-8");
167
- await fs.rename(tempPath, settingsPath);
190
+ await fs.rename(tempPath, SETTINGS_PATH);
168
191
  }
169
192
 
170
193
  function isValidFontFamily(value: unknown): value is FontFamily {
171
194
  return value === FontFamilies.SERIF || value === FontFamilies.SANS_SERIF;
172
195
  }
173
196
 
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
+ export const SERVER_INFO_PATH = path.join(
204
+ os.homedir(),
205
+ ".readit",
206
+ "server.json",
207
+ );
208
+
209
+ async function writeServerInfo(port: number): Promise<void> {
210
+ await fs.mkdir(path.dirname(SERVER_INFO_PATH), { recursive: true });
211
+ await fs.writeFile(
212
+ SERVER_INFO_PATH,
213
+ JSON.stringify({ port, pid: process.pid }),
214
+ "utf-8",
215
+ );
216
+ }
217
+
218
+ export async function removeServerInfo(): Promise<void> {
219
+ try {
220
+ await fs.unlink(SERVER_INFO_PATH);
221
+ } catch (err) {
222
+ if (!isErrnoException(err) || err.code !== "ENOENT") {
223
+ console.error("Failed to remove server info:", err);
224
+ }
225
+ }
226
+ }
227
+
174
228
  // ─── Response helpers ───────────────────────────────────────────────
175
229
 
176
230
  function json(data: unknown, status = 200): Response {
@@ -185,17 +239,15 @@ function errorResponse(message: string, status: number): Response {
185
239
 
186
240
  interface RouteContext {
187
241
  filePath: string;
188
- getCurrentContent: () => string;
242
+ getCurrentContent: () => Promise<string>;
189
243
  }
190
244
 
191
245
  // ─── Route handlers ─────────────────────────────────────────────────
192
246
 
193
247
  async function getComments(ctx: RouteContext): Promise<Response> {
194
248
  try {
195
- const comments = await readCommentsFromFile(
196
- ctx.filePath,
197
- ctx.getCurrentContent(),
198
- );
249
+ const currentContent = await ctx.getCurrentContent();
250
+ const comments = await readCommentsFromFile(ctx.filePath, currentContent);
199
251
  return json({ comments });
200
252
  } catch (err) {
201
253
  console.error("Failed to read comments:", err);
@@ -221,7 +273,7 @@ async function addComment(ctx: RouteContext, req: Request): Promise<Response> {
221
273
  return errorResponse("Missing required fields", 400);
222
274
  }
223
275
 
224
- const currentContent = ctx.getCurrentContent();
276
+ const currentContent = await ctx.getCurrentContent();
225
277
  const newComment = createComment(
226
278
  selectedText,
227
279
  commentText,
@@ -257,7 +309,7 @@ async function updateComment(
257
309
  return errorResponse("Missing comment text", 400);
258
310
  }
259
311
 
260
- const currentContent = ctx.getCurrentContent();
312
+ const currentContent = await ctx.getCurrentContent();
261
313
  const existingComments = await readCommentsFromFile(
262
314
  ctx.filePath,
263
315
  currentContent,
@@ -283,7 +335,7 @@ async function updateComment(
283
335
 
284
336
  async function deleteComment(ctx: RouteContext, id: string): Promise<Response> {
285
337
  try {
286
- const currentContent = ctx.getCurrentContent();
338
+ const currentContent = await ctx.getCurrentContent();
287
339
  const existingComments = await readCommentsFromFile(
288
340
  ctx.filePath,
289
341
  currentContent,
@@ -343,7 +395,7 @@ async function reanchorComment(
343
395
  return errorResponse("Missing required fields", 400);
344
396
  }
345
397
 
346
- const currentContent = ctx.getCurrentContent();
398
+ const currentContent = await ctx.getCurrentContent();
347
399
  const existingComments = await readCommentsFromFile(
348
400
  ctx.filePath,
349
401
  currentContent,
@@ -381,9 +433,9 @@ async function reanchorComment(
381
433
  }
382
434
  }
383
435
 
384
- async function getSettings(ctx: RouteContext): Promise<Response> {
436
+ async function getSettingsRoute(): Promise<Response> {
385
437
  try {
386
- const settings = await readSettingsFromFile(ctx.filePath);
438
+ const settings = await readSettings();
387
439
  return json(settings);
388
440
  } catch (err) {
389
441
  console.error("Failed to read settings:", err);
@@ -391,27 +443,28 @@ async function getSettings(ctx: RouteContext): Promise<Response> {
391
443
  }
392
444
  }
393
445
 
394
- async function updateSettings(
395
- ctx: RouteContext,
396
- req: Request,
397
- ): Promise<Response> {
446
+ async function updateSettingsRoute(req: Request): Promise<Response> {
398
447
  try {
399
448
  const body = await req.json();
400
- const { fontFamily, keybindings } = body;
449
+ const { fontFamily, editorScheme, keybindings } = body;
401
450
 
402
451
  if (fontFamily !== undefined && !isValidFontFamily(fontFamily)) {
403
452
  return errorResponse("Invalid font family", 400);
404
453
  }
405
454
 
406
- // Read current settings and merge
407
- 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();
408
460
  const settings: DocumentSettings = {
409
461
  ...current,
410
462
  ...(fontFamily !== undefined && { fontFamily }),
463
+ ...(editorScheme !== undefined && { editorScheme }),
411
464
  ...(keybindings !== undefined && { keybindings }),
412
465
  };
413
466
 
414
- await writeSettingsToFile(ctx.filePath, settings);
467
+ await writeSettings(settings);
415
468
  return json(settings);
416
469
  } catch (err) {
417
470
  console.error("Failed to save settings:", err);
@@ -424,13 +477,26 @@ async function updateSettings(
424
477
  function createDocumentStream(
425
478
  sseClients: Set<ReadableStreamDefaultController>,
426
479
  ): Response {
480
+ let pingInterval: ReturnType<typeof setInterval>;
481
+ let captured: ReadableStreamDefaultController;
482
+
427
483
  const stream = new ReadableStream({
428
484
  start(controller) {
485
+ captured = controller;
429
486
  controller.enqueue("data: connected\n\n");
430
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);
431
496
  },
432
- cancel(controller) {
433
- sseClients.delete(controller);
497
+ cancel() {
498
+ clearInterval(pingInterval);
499
+ sseClients.delete(captured);
434
500
  },
435
501
  });
436
502
 
@@ -508,7 +574,8 @@ function extractCommentId(pathname: string): string | undefined {
508
574
  // ─── Multi-file state ───────────────────────────────────────────────
509
575
 
510
576
  interface FileState {
511
- content: string;
577
+ content: string | null;
578
+ isLoaded: boolean;
512
579
  type: DocumentType;
513
580
  debounceTimer: ReturnType<typeof setTimeout> | null;
514
581
  }
@@ -528,7 +595,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
528
595
 
529
596
  for (const entry of options.files) {
530
597
  fileMap.set(entry.filePath, {
531
- content: entry.content,
598
+ content: entry.content ?? null,
599
+ isLoaded: entry.content !== undefined,
532
600
  type: entry.type,
533
601
  debounceTimer: null,
534
602
  });
@@ -538,6 +606,33 @@ function createServer(options: ServerOptions): ServerWithWatchers {
538
606
  const defaultPath = fileOrder[0];
539
607
  const sseClients = new Set<ReadableStreamDefaultController>();
540
608
 
609
+ function sendEvent(event: unknown): void {
610
+ const message = `data: ${JSON.stringify(event)}\n\n`;
611
+ for (const controller of sseClients) {
612
+ try {
613
+ controller.enqueue(message);
614
+ } catch {
615
+ sseClients.delete(controller);
616
+ }
617
+ }
618
+ }
619
+
620
+ async function ensureFileContent(filePath: string): Promise<string> {
621
+ const state = fileMap.get(filePath);
622
+ if (!state) {
623
+ throw new Error(`File not found: ${filePath}`);
624
+ }
625
+
626
+ if (state.isLoaded && state.content !== null) {
627
+ return state.content;
628
+ }
629
+
630
+ const content = await fs.readFile(filePath, "utf-8");
631
+ state.content = content;
632
+ state.isLoaded = true;
633
+ return content;
634
+ }
635
+
541
636
  // Resolve the target file from ?path= query param, falling back to first file
542
637
  function resolveContext(url: URL): RouteContext | null {
543
638
  const requestedPath = url.searchParams.get("path") ?? defaultPath;
@@ -545,7 +640,7 @@ function createServer(options: ServerOptions): ServerWithWatchers {
545
640
  if (!state) return null;
546
641
  return {
547
642
  filePath: requestedPath,
548
- getCurrentContent: () => state.content,
643
+ getCurrentContent: () => ensureFileContent(requestedPath),
549
644
  };
550
645
  }
551
646
 
@@ -560,9 +655,41 @@ function createServer(options: ServerOptions): ServerWithWatchers {
560
655
  const isDev = process.env.NODE_ENV === "development";
561
656
  const distPath = import.meta.dir;
562
657
 
658
+ function watchFile(targetPath: string): FSWatcher | null {
659
+ try {
660
+ const watcher = watch(targetPath, async (eventType) => {
661
+ if (eventType !== "change") return;
662
+
663
+ const state = fileMap.get(targetPath);
664
+ if (!state) return;
665
+
666
+ if (state.debounceTimer) clearTimeout(state.debounceTimer);
667
+ state.debounceTimer = setTimeout(async () => {
668
+ try {
669
+ const newContent = await fs.readFile(targetPath, "utf-8");
670
+ if (!state.isLoaded || newContent !== state.content) {
671
+ state.content = newContent;
672
+ state.isLoaded = true;
673
+ invalidateResolvedComments(targetPath);
674
+ console.log(`File changed: ${basename(targetPath)}`);
675
+ sendEvent({ type: "document-updated", path: targetPath });
676
+ }
677
+ } catch (err) {
678
+ console.error(`Failed to read updated file ${targetPath}:`, err);
679
+ }
680
+ }, 100);
681
+ });
682
+ return watcher;
683
+ } catch (err) {
684
+ console.warn(`File watching not available for ${targetPath}:`, err);
685
+ return null;
686
+ }
687
+ }
688
+
563
689
  const server = Bun.serve({
564
690
  port: options.port,
565
691
  hostname: options.host,
692
+ idleTimeout: 255, // max value (seconds) — SSE streams stay open long
566
693
 
567
694
  async fetch(req) {
568
695
  const url = new URL(req.url);
@@ -581,7 +708,80 @@ function createServer(options: ServerOptions): ServerWithWatchers {
581
708
  type: state.type,
582
709
  };
583
710
  });
584
- return json({ files, clean: options.clean || false });
711
+ return json({
712
+ files,
713
+ clean: options.clean || false,
714
+ workingDirectory: process.cwd(),
715
+ });
716
+ }
717
+
718
+ // Register a document for this session without forcing focus
719
+ if (pathname === "/api/documents" && method === "POST") {
720
+ try {
721
+ const { path: requestedPath } = await req.json();
722
+
723
+ if (!requestedPath || typeof requestedPath !== "string") {
724
+ return errorResponse("Missing 'path' field", 400);
725
+ }
726
+
727
+ let filePath: string;
728
+ try {
729
+ filePath = await canonicalPath(requestedPath);
730
+ } catch (err) {
731
+ if (isErrnoException(err) && err.code === "ENOENT") {
732
+ return errorResponse(`File not found: ${requestedPath}`, 404);
733
+ }
734
+ throw err;
735
+ }
736
+ const fileType = getFileType(filePath);
737
+
738
+ if (!fileType) {
739
+ return errorResponse(
740
+ `Unsupported file type: ${filePath} (expected .md, .markdown, .html, or .htm)`,
741
+ 400,
742
+ );
743
+ }
744
+
745
+ const existingState = fileMap.get(filePath);
746
+
747
+ if (existingState) {
748
+ return json({
749
+ path: filePath,
750
+ fileName: basename(filePath),
751
+ type: fileType,
752
+ status: "present",
753
+ });
754
+ } else {
755
+ // New document — register metadata only, load content on demand
756
+ fileMap.set(filePath, {
757
+ content: null,
758
+ isLoaded: false,
759
+ type: fileType,
760
+ debounceTimer: null,
761
+ });
762
+ fileOrder.push(filePath);
763
+
764
+ const watcher = watchFile(filePath);
765
+ if (watcher) watchers.push(watcher);
766
+
767
+ sendEvent({
768
+ type: "document-added",
769
+ path: filePath,
770
+ fileName: basename(filePath),
771
+ fileType,
772
+ });
773
+ }
774
+
775
+ return json({
776
+ path: filePath,
777
+ fileName: basename(filePath),
778
+ type: fileType,
779
+ status: "added",
780
+ });
781
+ } catch (err) {
782
+ console.error("Failed to add document:", err);
783
+ return errorResponse("Failed to add document", 500);
784
+ }
585
785
  }
586
786
 
587
787
  // Single document (backward compat + path-aware)
@@ -589,8 +789,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
589
789
  const ctxOrRes = requireContext(url);
590
790
  if (ctxOrRes instanceof Response) return ctxOrRes;
591
791
  const state = fileMap.get(ctxOrRes.filePath)!;
792
+ const content = await ctxOrRes.getCurrentContent();
592
793
  return json({
593
- content: state.content,
794
+ content,
594
795
  type: state.type,
595
796
  filePath: ctxOrRes.filePath,
596
797
  fileName: basename(ctxOrRes.filePath),
@@ -652,70 +853,27 @@ function createServer(options: ServerOptions): ServerWithWatchers {
652
853
  }
653
854
  }
654
855
 
655
- // Settings routes
856
+ // Settings routes (global, not per-document)
656
857
  if (pathname === "/api/settings" && method === "GET") {
657
- const ctxOrRes = requireContext(url);
658
- if (ctxOrRes instanceof Response) return ctxOrRes;
659
- return getSettings(ctxOrRes);
858
+ return getSettingsRoute();
660
859
  }
661
860
 
662
861
  if (pathname === "/api/settings" && method === "PUT") {
663
- const ctxOrRes = requireContext(url);
664
- if (ctxOrRes instanceof Response) return ctxOrRes;
665
- return updateSettings(ctxOrRes, req);
862
+ return updateSettingsRoute(req);
666
863
  }
667
864
 
668
865
  // ── Static / SPA serving ────────────────────────────────
669
866
 
670
- if (isDev && pathname === "/") {
671
- return Response.redirect("http://localhost:5173", 302);
672
- }
673
-
674
- if (!isDev) {
675
- return serveStaticFile(distPath, pathname);
676
- }
677
-
678
- return new Response("Not Found", { status: 404 });
867
+ return serveStaticFile(distPath, pathname);
679
868
  },
680
869
  });
681
870
 
682
871
  // Set up per-file watchers after Bun.serve() succeeds to avoid
683
872
  // leaking FSWatcher handles if the server fails to bind.
684
873
  const watchers: FSWatcher[] = [];
685
- for (const filePath of fileOrder) {
686
- try {
687
- const watcher = watch(filePath, async (eventType) => {
688
- if (eventType !== "change") return;
689
-
690
- const state = fileMap.get(filePath);
691
- if (!state) return;
692
-
693
- if (state.debounceTimer) clearTimeout(state.debounceTimer);
694
- state.debounceTimer = setTimeout(async () => {
695
- try {
696
- const newContent = await fs.readFile(filePath, "utf-8");
697
- if (newContent !== state.content) {
698
- state.content = newContent;
699
- console.log(`File changed: ${basename(filePath)}`);
700
-
701
- const message = `data: ${JSON.stringify({ type: "update", path: filePath })}\n\n`;
702
- for (const controller of sseClients) {
703
- try {
704
- controller.enqueue(message);
705
- } catch {
706
- sseClients.delete(controller);
707
- }
708
- }
709
- }
710
- } catch (err) {
711
- console.error(`Failed to read updated file ${filePath}:`, err);
712
- }
713
- }, 100);
714
- });
715
- watchers.push(watcher);
716
- } catch (err) {
717
- console.warn(`File watching not available for ${filePath}:`, err);
718
- }
874
+ for (const fp of fileOrder) {
875
+ const watcher = watchFile(fp);
876
+ if (watcher) watchers.push(watcher);
719
877
  }
720
878
 
721
879
  return { server, watchers };
@@ -745,6 +903,8 @@ export async function startServer(
745
903
 
746
904
  const actualPort = server.port ?? port;
747
905
 
906
+ await writeServerInfo(actualPort);
907
+
748
908
  return {
749
909
  port: actualPort,
750
910
  url: `http://${displayHost}:${actualPort}`,
@@ -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
  });