@peaske7/readit 0.1.6 → 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 (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 +23 -6
  5. package/src/cli/index.ts +167 -19
  6. package/src/components/ActionsMenu.tsx +12 -10
  7. package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
  8. package/src/components/DocumentViewer/DocumentViewer.tsx +6 -6
  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 +160 -117
  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
 
@@ -536,7 +574,8 @@ function extractCommentId(pathname: string): string | undefined {
536
574
  // ─── Multi-file state ───────────────────────────────────────────────
537
575
 
538
576
  interface FileState {
539
- content: string;
577
+ content: string | null;
578
+ isLoaded: boolean;
540
579
  type: DocumentType;
541
580
  debounceTimer: ReturnType<typeof setTimeout> | null;
542
581
  }
@@ -556,7 +595,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
556
595
 
557
596
  for (const entry of options.files) {
558
597
  fileMap.set(entry.filePath, {
559
- content: entry.content,
598
+ content: entry.content ?? null,
599
+ isLoaded: entry.content !== undefined,
560
600
  type: entry.type,
561
601
  debounceTimer: null,
562
602
  });
@@ -566,6 +606,33 @@ function createServer(options: ServerOptions): ServerWithWatchers {
566
606
  const defaultPath = fileOrder[0];
567
607
  const sseClients = new Set<ReadableStreamDefaultController>();
568
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
+
569
636
  // Resolve the target file from ?path= query param, falling back to first file
570
637
  function resolveContext(url: URL): RouteContext | null {
571
638
  const requestedPath = url.searchParams.get("path") ?? defaultPath;
@@ -573,7 +640,7 @@ function createServer(options: ServerOptions): ServerWithWatchers {
573
640
  if (!state) return null;
574
641
  return {
575
642
  filePath: requestedPath,
576
- getCurrentContent: () => state.content,
643
+ getCurrentContent: () => ensureFileContent(requestedPath),
577
644
  };
578
645
  }
579
646
 
@@ -600,18 +667,12 @@ function createServer(options: ServerOptions): ServerWithWatchers {
600
667
  state.debounceTimer = setTimeout(async () => {
601
668
  try {
602
669
  const newContent = await fs.readFile(targetPath, "utf-8");
603
- if (newContent !== state.content) {
670
+ if (!state.isLoaded || newContent !== state.content) {
604
671
  state.content = newContent;
672
+ state.isLoaded = true;
673
+ invalidateResolvedComments(targetPath);
605
674
  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
- }
675
+ sendEvent({ type: "document-updated", path: targetPath });
615
676
  }
616
677
  } catch (err) {
617
678
  console.error(`Failed to read updated file ${targetPath}:`, err);
@@ -628,6 +689,7 @@ function createServer(options: ServerOptions): ServerWithWatchers {
628
689
  const server = Bun.serve({
629
690
  port: options.port,
630
691
  hostname: options.host,
692
+ idleTimeout: 255, // max value (seconds) — SSE streams stay open long
631
693
 
632
694
  async fetch(req) {
633
695
  const url = new URL(req.url);
@@ -646,11 +708,15 @@ function createServer(options: ServerOptions): ServerWithWatchers {
646
708
  type: state.type,
647
709
  };
648
710
  });
649
- return json({ files, clean: options.clean || false });
711
+ return json({
712
+ files,
713
+ clean: options.clean || false,
714
+ workingDirectory: process.cwd(),
715
+ });
650
716
  }
651
717
 
652
- // Hot-add or refresh a file
653
- if (pathname === "/api/files" && method === "POST") {
718
+ // Register a document for this session without forcing focus
719
+ if (pathname === "/api/documents" && method === "POST") {
654
720
  try {
655
721
  const { path: requestedPath } = await req.json();
656
722
 
@@ -658,7 +724,15 @@ function createServer(options: ServerOptions): ServerWithWatchers {
658
724
  return errorResponse("Missing 'path' field", 400);
659
725
  }
660
726
 
661
- const filePath = path.resolve(requestedPath);
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
+ }
662
736
  const fileType = getFileType(filePath);
663
737
 
664
738
  if (!fileType) {
@@ -668,65 +742,45 @@ function createServer(options: ServerOptions): ServerWithWatchers {
668
742
  );
669
743
  }
670
744
 
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
745
  const existingState = fileMap.get(filePath);
682
746
 
683
747
  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
- }
748
+ return json({
749
+ path: filePath,
750
+ fileName: basename(filePath),
751
+ type: fileType,
752
+ status: "present",
753
+ });
694
754
  } else {
695
- // New fileadd to server
755
+ // New documentregister metadata only, load content on demand
696
756
  fileMap.set(filePath, {
697
- content,
757
+ content: null,
758
+ isLoaded: false,
698
759
  type: fileType,
699
760
  debounceTimer: null,
700
761
  });
701
762
  fileOrder.push(filePath);
702
763
 
703
- // Set up file watcher for the new file
704
764
  const watcher = watchFile(filePath);
705
765
  if (watcher) watchers.push(watcher);
706
766
 
707
- const message = `data: ${JSON.stringify({
708
- type: "file-added",
767
+ sendEvent({
768
+ type: "document-added",
709
769
  path: filePath,
710
770
  fileName: basename(filePath),
711
771
  fileType,
712
- })}\n\n`;
713
- for (const controller of sseClients) {
714
- try {
715
- controller.enqueue(message);
716
- } catch {
717
- sseClients.delete(controller);
718
- }
719
- }
772
+ });
720
773
  }
721
774
 
722
775
  return json({
723
776
  path: filePath,
724
777
  fileName: basename(filePath),
725
778
  type: fileType,
779
+ status: "added",
726
780
  });
727
781
  } catch (err) {
728
- console.error("Failed to add file:", err);
729
- return errorResponse("Failed to add file", 500);
782
+ console.error("Failed to add document:", err);
783
+ return errorResponse("Failed to add document", 500);
730
784
  }
731
785
  }
732
786
 
@@ -735,8 +789,9 @@ function createServer(options: ServerOptions): ServerWithWatchers {
735
789
  const ctxOrRes = requireContext(url);
736
790
  if (ctxOrRes instanceof Response) return ctxOrRes;
737
791
  const state = fileMap.get(ctxOrRes.filePath)!;
792
+ const content = await ctxOrRes.getCurrentContent();
738
793
  return json({
739
- content: state.content,
794
+ content,
740
795
  type: state.type,
741
796
  filePath: ctxOrRes.filePath,
742
797
  fileName: basename(ctxOrRes.filePath),
@@ -798,30 +853,18 @@ function createServer(options: ServerOptions): ServerWithWatchers {
798
853
  }
799
854
  }
800
855
 
801
- // Settings routes
856
+ // Settings routes (global, not per-document)
802
857
  if (pathname === "/api/settings" && method === "GET") {
803
- const ctxOrRes = requireContext(url);
804
- if (ctxOrRes instanceof Response) return ctxOrRes;
805
- return getSettings(ctxOrRes);
858
+ return getSettingsRoute();
806
859
  }
807
860
 
808
861
  if (pathname === "/api/settings" && method === "PUT") {
809
- const ctxOrRes = requireContext(url);
810
- if (ctxOrRes instanceof Response) return ctxOrRes;
811
- return updateSettings(ctxOrRes, req);
862
+ return updateSettingsRoute(req);
812
863
  }
813
864
 
814
865
  // ── Static / SPA serving ────────────────────────────────
815
866
 
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 });
867
+ return serveStaticFile(distPath, pathname);
825
868
  },
826
869
  });
827
870
 
@@ -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
  });
@@ -67,6 +67,16 @@ export interface Document {
67
67
  clean: boolean;
68
68
  }
69
69
 
70
+ // Editor scheme options - const object pattern per style guide 6.3
71
+ export const EditorSchemes = {
72
+ NONE: "none",
73
+ VSCODE: "vscode",
74
+ VSCODE_INSIDERS: "vscode-insiders",
75
+ CURSOR: "cursor",
76
+ } as const;
77
+
78
+ export type EditorScheme = (typeof EditorSchemes)[keyof typeof EditorSchemes];
79
+
70
80
  // Font family options - const object pattern per style guide 6.3
71
81
  export const FontFamilies = {
72
82
  SERIF: "serif",
@@ -111,5 +121,7 @@ export interface KeybindingOverride {
111
121
  export interface DocumentSettings {
112
122
  version: number;
113
123
  fontFamily: FontFamily;
124
+ editorScheme?: EditorScheme;
114
125
  keybindings?: KeybindingOverride[];
126
+ onboarded?: boolean;
115
127
  }