@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.
- package/biome.json +1 -1
- package/bun.lock +86 -72
- package/docs/plans/2026-03-13-client-mode-design.md +86 -0
- package/docs/plans/2026-03-13-client-mode-plan.md +605 -0
- package/package.json +12 -11
- package/src/App.tsx +23 -6
- package/src/cli/index.ts +312 -25
- 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 +35 -10
- 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/lib/utils.ts +11 -0
- package/src/main.tsx +4 -1
- package/src/server/index.ts +263 -103
- 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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
157
|
-
|
|
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 = `${
|
|
188
|
+
const tempPath = `${SETTINGS_PATH}.tmp`;
|
|
166
189
|
await fs.writeFile(tempPath, JSON.stringify(settings, null, 2), "utf-8");
|
|
167
|
-
await fs.rename(tempPath,
|
|
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
|
|
196
|
-
|
|
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
|
|
436
|
+
async function getSettingsRoute(): Promise<Response> {
|
|
385
437
|
try {
|
|
386
|
-
const settings = await
|
|
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
|
|
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
|
-
|
|
407
|
-
|
|
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
|
|
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(
|
|
433
|
-
|
|
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: () =>
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
686
|
-
|
|
687
|
-
|
|
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}`,
|
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
|
});
|