@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.
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 +36 -16
  5. package/src/cli/index.ts +338 -70
  6. package/src/components/ActionsMenu.tsx +12 -10
  7. package/src/components/DocumentViewer/CodeBlock.tsx +131 -54
  8. package/src/components/DocumentViewer/DocumentViewer.tsx +10 -8
  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 +197 -124
  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
package/src/App.tsx CHANGED
@@ -13,6 +13,7 @@ import { TableOfContents } from "./components/TableOfContents";
13
13
  import { textVariants } from "./components/ui/Text";
14
14
  import { CommentContext, CommentProvider } from "./contexts/CommentContext";
15
15
  import { LayoutContext, LayoutProvider } from "./contexts/LayoutContext";
16
+ import { useLocale } from "./contexts/LocaleContext";
16
17
  import { useClipboard } from "./hooks/useClipboard";
17
18
  import { useDocument } from "./hooks/useDocument";
18
19
  import { useHeadings } from "./hooks/useHeadings";
@@ -37,7 +38,13 @@ const TOASTER_OPTIONS = {
37
38
  },
38
39
  };
39
40
 
40
- function AppContent() {
41
+ interface AppContentProps {
42
+ document: NonNullable<ReturnType<typeof useDocument>["document"]>;
43
+ reload: ReturnType<typeof useDocument>["reload"];
44
+ }
45
+
46
+ function AppContent({ document, reload }: AppContentProps) {
47
+ const { t } = useLocale();
41
48
  const {
42
49
  comments,
43
50
  sortedComments,
@@ -51,8 +58,6 @@ function AppContent() {
51
58
  navigateNext,
52
59
  } = use(CommentContext)!;
53
60
 
54
- const { document, reload } = useDocument();
55
-
56
61
  const {
57
62
  selection,
58
63
  highlightPositions,
@@ -74,6 +79,7 @@ function AppContent() {
74
79
  document: document ?? undefined,
75
80
  selection: selection ?? undefined,
76
81
  clearSelection,
82
+ t,
77
83
  });
78
84
 
79
85
  const { shortcuts, isFullscreen } = use(LayoutContext)!;
@@ -137,11 +143,6 @@ function AppContent() {
137
143
  }
138
144
  }, []);
139
145
 
140
- useEffect(() => {
141
- const eventSource = new EventSource("/api/heartbeat");
142
- return () => eventSource.close();
143
- }, []);
144
-
145
146
  // Scroll save/restore for tab switching
146
147
  const setScrollY = useAppStore((s) => s.setScrollY);
147
148
  const savedScrollY = useAppStore(
@@ -275,6 +276,7 @@ function AppContent() {
275
276
  />
276
277
  ) : (
277
278
  <CommentInput
279
+ key={selection.text}
278
280
  selectedText={selection.text}
279
281
  onSubmit={handleAddComment}
280
282
  onCancel={clearSelection}
@@ -302,7 +304,7 @@ function AppContent() {
302
304
  <CommentNav />
303
305
 
304
306
  <footer className="py-4 text-center text-sm text-zinc-400 dark:text-zinc-500">
305
- Made with ❤️ by Jay and Claude
307
+ {t("app.footer")}
306
308
  </footer>
307
309
  </div>
308
310
  );
@@ -333,11 +335,17 @@ function useTabKeyboardShortcuts() {
333
335
  }
334
336
 
335
337
  function App() {
336
- const { document, error, isInitialized } = useDocument();
338
+ const { t } = useLocale();
339
+ const { document, error, isInitialized, reload } = useDocument();
337
340
  const documentOrder = useAppStore((s) => s.documentOrder);
338
341
 
339
342
  useTabKeyboardShortcuts();
340
343
 
344
+ useEffect(() => {
345
+ const eventSource = new EventSource("/api/heartbeat");
346
+ return () => eventSource.close();
347
+ }, []);
348
+
341
349
  if (error) {
342
350
  return (
343
351
  <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
@@ -349,7 +357,9 @@ function App() {
349
357
  if (!isInitialized) {
350
358
  return (
351
359
  <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
352
- <div className="text-zinc-500 dark:text-zinc-400">Loading...</div>
360
+ <div className="text-zinc-500 dark:text-zinc-400">
361
+ {t("app.loading")}
362
+ </div>
353
363
  </div>
354
364
  );
355
365
  }
@@ -358,9 +368,17 @@ function App() {
358
368
  return (
359
369
  <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex flex-col">
360
370
  <TabBar />
361
- <div className="flex-1 flex items-center justify-center">
371
+ <div className="flex-1 flex flex-col items-center justify-center gap-3">
362
372
  <p className="text-zinc-400 dark:text-zinc-500 text-sm">
363
- No documents open.
373
+ {t("app.noDocuments")}
374
+ </p>
375
+ <p className="text-zinc-400 dark:text-zinc-500 text-xs">
376
+ {t("app.noDocumentsHintPrefix")}
377
+ {t("app.noDocumentsHintPrefix") && " "}
378
+ <code className="bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded text-xs">
379
+ readit open &lt;file.md&gt;
380
+ </code>{" "}
381
+ {t("app.noDocumentsHintSuffix")}
364
382
  </p>
365
383
  </div>
366
384
  </div>
@@ -370,7 +388,9 @@ function App() {
370
388
  if (!document) {
371
389
  return (
372
390
  <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
373
- <div className="text-zinc-500 dark:text-zinc-400">Loading...</div>
391
+ <div className="text-zinc-500 dark:text-zinc-400">
392
+ {t("app.loading")}
393
+ </div>
374
394
  </div>
375
395
  );
376
396
  }
@@ -378,7 +398,7 @@ function App() {
378
398
  return (
379
399
  <>
380
400
  <TabBar />
381
- <LayoutProvider filePath={document.filePath}>
401
+ <LayoutProvider>
382
402
  <CommentProvider
383
403
  filePath={document.filePath}
384
404
  clean={document.clean}
@@ -386,7 +406,7 @@ function App() {
386
406
  fileName={document.fileName}
387
407
  documentType={document.type}
388
408
  >
389
- <AppContent />
409
+ <AppContent document={document} reload={reload} />
390
410
  </CommentProvider>
391
411
  </LayoutProvider>
392
412
  </>
package/src/cli/index.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  lstatSync,
6
6
  readdirSync,
7
7
  readFileSync,
8
+ realpathSync,
8
9
  statSync,
9
10
  } from "node:fs";
10
11
  import * as fs from "node:fs/promises";
@@ -33,17 +34,113 @@ interface ServerInfo {
33
34
  pid: number;
34
35
  }
35
36
 
36
- async function discoverServer(): Promise<ServerInfo | null> {
37
- const serverInfoPath = join(os.homedir(), ".readit", "server.json");
37
+ interface ServerTarget {
38
+ kind: "existing" | "started";
39
+ port: number;
40
+ url: string;
41
+ server?: { stop(): void };
42
+ }
43
+
44
+ const READIT_DIR = join(os.homedir(), ".readit");
45
+ const SERVER_INFO_PATH = join(READIT_DIR, "server.json");
46
+ const SERVER_LOCK_PATH = join(READIT_DIR, "server.lock");
47
+ const SERVER_LOCK_MAX_AGE_MS = 30_000;
48
+ const SERVER_LOCK_TIMEOUT_MS = 10_000;
49
+ const SERVER_LOCK_WAIT_MS = 100;
50
+
51
+ function isAlive(pid: number): boolean {
52
+ try {
53
+ process.kill(pid, 0);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function getErrnoCode(err: unknown): string | undefined {
61
+ return err instanceof Error && "code" in err
62
+ ? (err as NodeJS.ErrnoException).code
63
+ : undefined;
64
+ }
65
+
66
+ function sleep(ms: number): Promise<void> {
67
+ return new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+
70
+ async function clearStaleServerLock(): Promise<void> {
71
+ try {
72
+ const [stats, content] = await Promise.all([
73
+ fs.stat(SERVER_LOCK_PATH),
74
+ fs.readFile(SERVER_LOCK_PATH, "utf-8").catch(() => ""),
75
+ ]);
76
+
77
+ const age = Date.now() - stats.mtimeMs;
78
+ let pid: number | undefined;
79
+
80
+ if (content) {
81
+ try {
82
+ const lock = JSON.parse(content) as { pid?: number };
83
+ pid = lock.pid;
84
+ } catch {
85
+ // Ignore malformed lock files and fall back to age-based cleanup.
86
+ }
87
+ }
88
+
89
+ if (age > SERVER_LOCK_MAX_AGE_MS || (pid !== undefined && !isAlive(pid))) {
90
+ await fs.unlink(SERVER_LOCK_PATH).catch(() => {});
91
+ }
92
+ } catch (err) {
93
+ if (getErrnoCode(err) !== "ENOENT") throw err;
94
+ }
95
+ }
96
+
97
+ async function withServerLock<T>(run: () => Promise<T>): Promise<T> {
98
+ await fs.mkdir(READIT_DIR, { recursive: true });
99
+ const start = Date.now();
100
+
101
+ while (true) {
102
+ let handle: fs.FileHandle | undefined;
103
+
104
+ try {
105
+ handle = await fs.open(SERVER_LOCK_PATH, "wx");
106
+ await handle.writeFile(
107
+ JSON.stringify({ pid: process.pid, createdAt: Date.now() }),
108
+ "utf-8",
109
+ );
110
+
111
+ try {
112
+ return await run();
113
+ } finally {
114
+ await handle.close().catch(() => {});
115
+ await fs.unlink(SERVER_LOCK_PATH).catch(() => {});
116
+ }
117
+ } catch (err) {
118
+ if (handle) {
119
+ await handle.close().catch(() => {});
120
+ }
121
+
122
+ if (getErrnoCode(err) !== "EEXIST") {
123
+ throw err;
124
+ }
125
+
126
+ await clearStaleServerLock();
127
+
128
+ if (Date.now() - start >= SERVER_LOCK_TIMEOUT_MS) {
129
+ throw new Error("Timed out waiting for readit server lock");
130
+ }
131
+
132
+ await sleep(SERVER_LOCK_WAIT_MS);
133
+ }
134
+ }
135
+ }
38
136
 
137
+ async function discoverServer(): Promise<ServerInfo | null> {
39
138
  try {
40
- const content = readFileSync(serverInfoPath, "utf-8");
139
+ const content = readFileSync(SERVER_INFO_PATH, "utf-8");
41
140
  const info: ServerInfo = JSON.parse(content);
42
141
 
43
142
  // Verify the process is alive
44
- try {
45
- process.kill(info.pid, 0);
46
- } catch {
143
+ if (!isAlive(info.pid)) {
47
144
  return null;
48
145
  }
49
146
 
@@ -61,6 +158,65 @@ async function discoverServer(): Promise<ServerInfo | null> {
61
158
  }
62
159
  }
63
160
 
161
+ async function attachFiles(
162
+ server: ServerInfo,
163
+ files: { path: string; type: DocumentType }[],
164
+ ): Promise<void> {
165
+ for (const file of files) {
166
+ try {
167
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/documents`, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ path: file.path }),
171
+ });
172
+
173
+ if (!res.ok) {
174
+ const data = await res.json();
175
+ console.error(`error: failed to add ${file.path}: ${data.error}`);
176
+ process.exit(1);
177
+ }
178
+
179
+ const data = await res.json();
180
+ if (data.status === "added") {
181
+ console.log(`Added: ${data.fileName} (${data.type})`);
182
+ } else {
183
+ console.log(`Present: ${data.fileName} (${data.type})`);
184
+ }
185
+ } catch (err) {
186
+ console.error(
187
+ "error: failed to connect to server:",
188
+ err instanceof Error ? err.message : err,
189
+ );
190
+ process.exit(1);
191
+ }
192
+ }
193
+ }
194
+
195
+ async function getServerTarget(
196
+ files: FileEntry[],
197
+ port: number,
198
+ host: string,
199
+ ): Promise<ServerTarget> {
200
+ return withServerLock(async () => {
201
+ const server = await discoverServer();
202
+ if (server) {
203
+ return {
204
+ kind: "existing",
205
+ port: server.port,
206
+ url: `http://127.0.0.1:${server.port}`,
207
+ };
208
+ }
209
+
210
+ const started = await startServer({ files, port, host });
211
+ return {
212
+ kind: "started",
213
+ port: started.port,
214
+ url: started.url,
215
+ server: started.server,
216
+ };
217
+ });
218
+ }
219
+
64
220
  /**
65
221
  * Recursively find all .comments.md files in a directory.
66
222
  */
@@ -116,7 +272,6 @@ function findReviewableFiles(dir: string): FileEntry[] {
116
272
  const type = getFileType(entry);
117
273
  if (type) {
118
274
  results.push({
119
- content: readFileSync(fullPath, "utf-8"),
120
275
  type,
121
276
  filePath: fullPath,
122
277
  });
@@ -145,13 +300,15 @@ function resolveFiles(args: string[]): FileEntry[] {
145
300
  const files: FileEntry[] = [];
146
301
 
147
302
  for (const arg of args) {
148
- const filePath = resolve(process.cwd(), arg);
303
+ const inputPath = resolve(process.cwd(), arg);
149
304
 
150
- if (!existsSync(filePath)) {
151
- console.error(`error: not found: ${filePath}`);
305
+ if (!existsSync(inputPath)) {
306
+ console.error(`error: not found: ${inputPath}`);
152
307
  process.exit(1);
153
308
  }
154
309
 
310
+ const filePath = realpathSync(inputPath);
311
+
155
312
  const stat = statSync(filePath);
156
313
 
157
314
  if (stat.isDirectory()) {
@@ -175,7 +332,6 @@ function resolveFiles(args: string[]): FileEntry[] {
175
332
 
176
333
  seen.add(filePath);
177
334
  files.push({
178
- content: readFileSync(filePath, "utf-8"),
179
335
  type,
180
336
  filePath,
181
337
  });
@@ -185,6 +341,114 @@ function resolveFiles(args: string[]): FileEntry[] {
185
341
  return files;
186
342
  }
187
343
 
344
+ // ─── Onboarding ──────────────────────────────────────────────────────
345
+
346
+ const SETTINGS_PATH = join(os.homedir(), ".readit", "settings.json");
347
+
348
+ function isOnboarded(): boolean {
349
+ try {
350
+ const content = readFileSync(SETTINGS_PATH, "utf-8");
351
+ const settings = JSON.parse(content);
352
+ return settings.onboarded === true;
353
+ } catch {
354
+ return false;
355
+ }
356
+ }
357
+
358
+ async function markOnboarded(): Promise<void> {
359
+ let settings: Record<string, unknown> = {};
360
+ try {
361
+ const content = readFileSync(SETTINGS_PATH, "utf-8");
362
+ settings = JSON.parse(content);
363
+ } catch {
364
+ // No existing settings
365
+ }
366
+ settings.onboarded = true;
367
+ const dir = join(os.homedir(), ".readit");
368
+ await fs.mkdir(dir, { recursive: true });
369
+ await fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8");
370
+ }
371
+
372
+ const WELCOME_CONTENT = `# Welcome to readit
373
+
374
+ A simple tool for reviewing markdown with inline comments.
375
+
376
+ ---
377
+
378
+ ## How It Works
379
+
380
+ readit follows a simple loop: **read → comment → extract**.
381
+
382
+ ### 1. Read
383
+
384
+ You're already doing this. Open any markdown file with \`readit <file.md>\` and it renders in your browser with a clean reading experience.
385
+
386
+ ### 2. Comment
387
+
388
+ Select any text to add a comment. Try it now — **select this sentence** and type your first comment.
389
+
390
+ Your comments appear as margin notes next to the highlighted text, just like reviewing a document in Google Docs. Add as many as you need.
391
+
392
+ ### 3. Extract
393
+
394
+ When you're done reviewing, click the menu in the top-right and choose **Copy as Prompt**. This exports all your comments in a format ready for Claude, ChatGPT, or any AI assistant.
395
+
396
+ You can also export as JSON if you prefer structured data.
397
+
398
+ ---
399
+
400
+ ## Everything is Plain Markdown
401
+
402
+ Your comments are saved as \`.comments.md\` files in \`~/.readit/comments/\`. No database, no lock-in — just readable markdown files you can version control, search, or edit by hand.
403
+
404
+ Each comment file looks something like this:
405
+
406
+ \`\`\`markdown
407
+ ## Comment 1
408
+ **Selected:** "select this sentence"
409
+ **Comment:** This is my first comment!
410
+ **Created:** 2024-01-15T10:30:00Z
411
+ \`\`\`
412
+
413
+ ---
414
+
415
+ ## Navigating Comments
416
+
417
+ Once you have multiple comments, use the navigation bar at the bottom of the screen to jump between them. You can also use keyboard shortcuts:
418
+
419
+ | Shortcut | Action |
420
+ |----------|--------|
421
+ | \`Alt + ↑\` | Previous comment |
422
+ | \`Alt + ↓\` | Next comment |
423
+ | \`⌘ + C\` | Copy selected text (raw) |
424
+ | \`⌘ + Shift + C\` | Copy selected text with context (for AI) |
425
+
426
+ ---
427
+
428
+ ## Quick Start
429
+
430
+ \`\`\`bash
431
+ # Review a markdown file
432
+ readit document.md
433
+
434
+ # Use a custom port
435
+ readit document.md --port 3000
436
+
437
+ # Start fresh (clear existing comments)
438
+ readit document.md --clean
439
+ \`\`\`
440
+
441
+ ---
442
+
443
+ ## Try It Now
444
+
445
+ Go ahead and add a few comments to this document. When you're done, export them and see the output. That's the entire workflow — simple, transparent, and designed for reviewing AI-generated content.
446
+ `;
447
+
448
+ const WELCOME_PATH = join(os.homedir(), ".readit", "welcome.md");
449
+
450
+ // ─── Program ─────────────────────────────────────────────────────────
451
+
188
452
  program
189
453
  .name("readit")
190
454
  .description("Review Markdown and HTML documents with inline comments")
@@ -273,9 +537,9 @@ program
273
537
  }
274
538
  });
275
539
 
276
- // Main review command (default) — accepts one or more files/directories
540
+ // Main review command (default) — accepts zero or more files/directories
277
541
  program
278
- .argument("<files...>", "Markdown or HTML files/directories to review")
542
+ .argument("[files...]", "Markdown or HTML files/directories to review")
279
543
  .option("-p, --port <number>", "Port to run server on", "4567")
280
544
  .option("--host <address>", "Host address to bind to", "127.0.0.1")
281
545
  .option("--no-open", "Don't automatically open browser")
@@ -290,11 +554,27 @@ program
290
554
  clean: boolean;
291
555
  },
292
556
  ) => {
293
- const files = resolveFiles(fileArgs);
557
+ let files: FileEntry[];
294
558
 
295
- if (files.length === 0) {
296
- console.error("error: no reviewable files found");
297
- process.exit(1);
559
+ if (fileArgs.length === 0) {
560
+ if (isOnboarded()) {
561
+ files = [];
562
+ } else {
563
+ files = [
564
+ {
565
+ content: WELCOME_CONTENT,
566
+ type: "markdown" as DocumentType,
567
+ filePath: WELCOME_PATH,
568
+ },
569
+ ];
570
+ }
571
+ } else {
572
+ files = resolveFiles(fileArgs);
573
+
574
+ if (files.length === 0) {
575
+ console.error("error: no reviewable files found");
576
+ process.exit(1);
577
+ }
298
578
  }
299
579
 
300
580
  const preferredPort = Number.parseInt(options.port, 10);
@@ -316,9 +596,21 @@ program
316
596
  clean: options.clean,
317
597
  });
318
598
 
319
- const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
599
+ if (files.length === 0) {
600
+ console.log(`
601
+ readit - Document Review Tool
320
602
 
321
- console.log(`
603
+ URL: ${url}
604
+
605
+ No files specified. Add files with:
606
+ readit open <file.md>
607
+
608
+ Server running. Press Ctrl+C to stop.
609
+ `);
610
+ } else {
611
+ const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
612
+
613
+ console.log(`
322
614
  readit - Document Review Tool
323
615
 
324
616
  ${files.length === 1 ? "File:" : "Files:"}
@@ -328,11 +620,17 @@ ${fileList.join("\n")}
328
620
  Server running. Close browser tab to stop.
329
621
  Press Ctrl+C to force stop.
330
622
  `);
623
+ }
331
624
 
332
625
  if (options.open) {
333
626
  open(url);
334
627
  }
335
628
 
629
+ // Mark onboarding complete on first server start
630
+ if (fileArgs.length === 0) {
631
+ await markOnboarded();
632
+ }
633
+
336
634
  // Graceful shutdown on Ctrl+C
337
635
  process.on("SIGINT", async () => {
338
636
  console.log("\n\nShutting down...");
@@ -362,13 +660,15 @@ program
362
660
  // Resolve and validate files
363
661
  const resolvedFiles: { path: string; type: DocumentType }[] = [];
364
662
  for (const arg of fileArgs) {
365
- const filePath = resolve(process.cwd(), arg);
663
+ const inputPath = resolve(process.cwd(), arg);
366
664
 
367
- if (!existsSync(filePath)) {
368
- console.error(`error: not found: ${filePath}`);
665
+ if (!existsSync(inputPath)) {
666
+ console.error(`error: not found: ${inputPath}`);
369
667
  process.exit(1);
370
668
  }
371
669
 
670
+ const filePath = realpathSync(inputPath);
671
+
372
672
  const type = getFileType(filePath);
373
673
  if (!type) {
374
674
  console.error(
@@ -380,59 +680,27 @@ program
380
680
  resolvedFiles.push({ path: filePath, type });
381
681
  }
382
682
 
383
- // Try to find running server
384
- const server = await discoverServer();
385
-
386
- if (server) {
387
- // Send files to running server
388
- for (const file of resolvedFiles) {
389
- try {
390
- const res = await fetch(
391
- `http://127.0.0.1:${server.port}/api/files`,
392
- {
393
- method: "POST",
394
- headers: { "Content-Type": "application/json" },
395
- body: JSON.stringify({ path: file.path }),
396
- },
397
- );
398
-
399
- if (!res.ok) {
400
- const data = await res.json();
401
- console.error(`error: failed to add ${file.path}: ${data.error}`);
402
- process.exit(1);
403
- }
404
-
405
- const data = await res.json();
406
- console.log(`Added: ${data.fileName} (${data.type})`);
407
- } catch (err) {
408
- console.error(
409
- "error: failed to connect to server:",
410
- err instanceof Error ? err.message : err,
411
- );
412
- process.exit(1);
413
- }
414
- }
415
-
416
- console.log(`\nServer: http://127.0.0.1:${server.port}`);
417
- return;
418
- }
419
-
420
- // No running server — start one
421
- console.log("No running server found, starting new one...\n");
422
-
423
683
  const files = resolvedFiles.map((f) => ({
424
- content: readFileSync(f.path, "utf-8"),
425
684
  type: f.type,
426
685
  filePath: f.path,
427
686
  }));
428
687
 
429
688
  const preferredPort = Number.parseInt(options.port, 10);
430
689
  try {
431
- const { url, server: newServer } = await startServer({
690
+ const target = await getServerTarget(
432
691
  files,
433
- port: preferredPort,
434
- host: options.host,
435
- });
692
+ preferredPort,
693
+ options.host,
694
+ );
695
+
696
+ if (target.kind === "existing") {
697
+ await attachFiles(
698
+ { port: target.port, pid: process.pid },
699
+ resolvedFiles,
700
+ );
701
+ console.log(`\nServer: ${target.url}`);
702
+ return;
703
+ }
436
704
 
437
705
  const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
438
706
  console.log(`
@@ -440,17 +708,17 @@ readit - Document Review Tool
440
708
 
441
709
  ${files.length === 1 ? "File:" : "Files:"}
442
710
  ${fileList.join("\n")}
443
- URL: ${url}
711
+ URL: ${target.url}
444
712
 
445
713
  Server running. Close browser tab to stop.
446
714
  Press Ctrl+C to force stop.
447
715
  `);
448
716
 
449
- open(url);
717
+ open(target.url);
450
718
 
451
719
  process.on("SIGINT", async () => {
452
720
  console.log("\n\nShutting down...");
453
- newServer.stop();
721
+ target.server?.stop();
454
722
  await removeServerInfo();
455
723
  process.exit(0);
456
724
  });