@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.
- package/biome.json +1 -1
- package/bun.lock +86 -72
- package/package.json +12 -11
- package/src/App.tsx +23 -6
- package/src/cli/index.ts +167 -19
- package/src/components/ActionsMenu.tsx +12 -10
- package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
- package/src/components/DocumentViewer/DocumentViewer.tsx +6 -6
- package/src/components/DocumentViewer/InlineCode.tsx +60 -0
- package/src/components/FloatingTOC.tsx +4 -2
- package/src/components/Header.tsx +3 -1
- package/src/components/InlineEditor.tsx +4 -2
- package/src/components/MarginNote.tsx +17 -8
- package/src/components/RawModal.tsx +9 -7
- package/src/components/ReanchorConfirm.tsx +6 -3
- package/src/components/SettingsModal.tsx +112 -23
- package/src/components/ShortcutCapture.tsx +4 -1
- package/src/components/ShortcutList.tsx +50 -9
- package/src/components/comments/CommentBadge.tsx +7 -1
- package/src/components/comments/CommentInput.tsx +13 -18
- package/src/components/comments/CommentListItem.tsx +15 -5
- package/src/components/comments/CommentManager.tsx +14 -7
- package/src/components/comments/CommentNav.tsx +8 -3
- package/src/contexts/CommentContext.tsx +16 -9
- package/src/contexts/LayoutContext.tsx +17 -5
- package/src/contexts/LocaleContext.tsx +35 -0
- package/src/hooks/useClipboard.ts +11 -8
- package/src/hooks/useDocument.ts +33 -18
- package/src/hooks/useEditorScheme.ts +51 -0
- package/src/hooks/useFontPreference.ts +5 -22
- package/src/hooks/useKeybindings.ts +6 -18
- package/src/hooks/useLocalePreference.ts +42 -0
- package/src/index.css +87 -26
- package/src/lib/editor-links.ts +59 -0
- package/src/lib/highlight/dom.ts +126 -54
- package/src/lib/highlight/highlighter.ts +10 -10
- package/src/lib/i18n/completeness.test.ts +51 -0
- package/src/lib/i18n/en.ts +139 -0
- package/src/lib/i18n/index.ts +3 -0
- package/src/lib/i18n/ja.ts +141 -0
- package/src/lib/i18n/translations.test.ts +39 -0
- package/src/lib/i18n/translations.ts +27 -0
- package/src/lib/i18n/types.ts +145 -0
- package/src/lib/shortcut-registry.ts +1 -1
- package/src/main.tsx +4 -1
- package/src/server/index.ts +160 -117
- package/src/store/index.test.ts +22 -0
- package/src/store/index.ts +24 -4
- package/src/types/index.ts +12 -0
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
158
|
-
|
|
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 = `${
|
|
188
|
+
const tempPath = `${SETTINGS_PATH}.tmp`;
|
|
167
189
|
await fs.writeFile(tempPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
168
|
-
await fs.rename(tempPath,
|
|
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
|
|
224
|
-
|
|
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
|
|
436
|
+
async function getSettingsRoute(): Promise<Response> {
|
|
413
437
|
try {
|
|
414
|
-
const settings = await
|
|
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
|
|
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
|
-
|
|
435
|
-
|
|
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
|
|
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(
|
|
461
|
-
|
|
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: () =>
|
|
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({
|
|
711
|
+
return json({
|
|
712
|
+
files,
|
|
713
|
+
clean: options.clean || false,
|
|
714
|
+
workingDirectory: process.cwd(),
|
|
715
|
+
});
|
|
650
716
|
}
|
|
651
717
|
|
|
652
|
-
//
|
|
653
|
-
if (pathname === "/api/
|
|
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
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
|
755
|
+
// New document — register 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
|
-
|
|
708
|
-
type: "
|
|
767
|
+
sendEvent({
|
|
768
|
+
type: "document-added",
|
|
709
769
|
path: filePath,
|
|
710
770
|
fileName: basename(filePath),
|
|
711
771
|
fileType,
|
|
712
|
-
})
|
|
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
|
|
729
|
-
return errorResponse("Failed to add
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/store/index.test.ts
CHANGED
|
@@ -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", () => {
|
package/src/store/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
136
|
+
activeDocumentPath: nextActive,
|
|
117
137
|
documentOrder: [...prev.documentOrder, doc.filePath],
|
|
118
138
|
};
|
|
119
139
|
});
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
}
|