@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.
- package/biome.json +1 -1
- package/bun.lock +86 -72
- package/package.json +12 -11
- package/src/App.tsx +36 -16
- package/src/cli/index.ts +338 -70
- package/src/components/ActionsMenu.tsx +12 -10
- package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
- package/src/components/DocumentViewer/DocumentViewer.tsx +10 -8
- 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 +197 -124
- 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
|
|
|
@@ -471,27 +509,30 @@ function createDocumentStream(
|
|
|
471
509
|
});
|
|
472
510
|
}
|
|
473
511
|
|
|
474
|
-
function createHeartbeat(
|
|
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
|
-
|
|
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: () =>
|
|
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({
|
|
741
|
+
return json({
|
|
742
|
+
files,
|
|
743
|
+
clean: options.clean || false,
|
|
744
|
+
workingDirectory: process.cwd(),
|
|
745
|
+
});
|
|
650
746
|
}
|
|
651
747
|
|
|
652
|
-
//
|
|
653
|
-
if (pathname === "/api/
|
|
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
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
|
785
|
+
// New document — register 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
|
-
|
|
708
|
-
type: "
|
|
797
|
+
sendEvent({
|
|
798
|
+
type: "document-added",
|
|
709
799
|
path: filePath,
|
|
710
800
|
fileName: basename(filePath),
|
|
711
801
|
fileType,
|
|
712
|
-
})
|
|
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
|
|
729
|
-
return errorResponse("Failed to add
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
});
|