@peaske7/readit 0.1.8 → 0.2.0

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 (118) hide show
  1. package/README.md +0 -3
  2. package/biome.json +1 -1
  3. package/bun.lock +43 -185
  4. package/docs/perf-baseline.md +75 -0
  5. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  6. package/e2e/perf/add-comment.spec.ts +118 -0
  7. package/e2e/perf/fixtures/generate.ts +331 -0
  8. package/e2e/perf/initial-load.spec.ts +49 -0
  9. package/e2e/perf/perf.setup.ts +23 -0
  10. package/e2e/perf/perf.teardown.ts +9 -0
  11. package/e2e/perf/scroll.spec.ts +39 -0
  12. package/e2e/perf/tab-switch.spec.ts +69 -0
  13. package/e2e/perf/text-selection.spec.ts +119 -0
  14. package/e2e/perf/utils/metrics.ts +286 -0
  15. package/e2e/perf/utils/perf-cli.ts +86 -0
  16. package/package.json +9 -18
  17. package/playwright.config.ts +12 -0
  18. package/src/App.tsx +124 -172
  19. package/src/{cli/index.ts → cli.ts} +37 -53
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +77 -106
  22. package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
  23. package/src/components/Header.tsx +9 -20
  24. package/src/components/InlineEditor.tsx +5 -5
  25. package/src/components/MarginNote.tsx +71 -93
  26. package/src/components/MarginNotes.tsx +7 -34
  27. package/src/components/RawModal.tsx +9 -8
  28. package/src/components/ReanchorConfirm.tsx +2 -2
  29. package/src/components/SettingsModal.tsx +11 -89
  30. package/src/components/TabBar.tsx +4 -4
  31. package/src/components/TableOfContents.tsx +5 -5
  32. package/src/components/comments/CommentInput.tsx +7 -35
  33. package/src/components/comments/CommentListItem.tsx +9 -11
  34. package/src/components/comments/CommentManager.tsx +53 -37
  35. package/src/components/comments/CommentNav.tsx +14 -14
  36. package/src/components/ui/ActionLink.tsx +14 -18
  37. package/src/components/ui/Button.tsx +42 -43
  38. package/src/components/ui/Dialog.tsx +73 -113
  39. package/src/components/ui/DropdownMenu.tsx +113 -69
  40. package/src/components/ui/Text.tsx +30 -37
  41. package/src/contexts/CommentContext.tsx +75 -106
  42. package/src/contexts/LocaleContext.tsx +45 -4
  43. package/src/contexts/PositionsContext.tsx +16 -0
  44. package/src/contexts/SettingsContext.tsx +133 -0
  45. package/src/hooks/useClickOutside.ts +0 -4
  46. package/src/hooks/useCommentNavigation.ts +6 -29
  47. package/src/hooks/useComments.ts +6 -18
  48. package/src/hooks/useDocument.ts +35 -34
  49. package/src/hooks/useHeadings.test.ts +8 -50
  50. package/src/hooks/useHeadings.ts +5 -88
  51. package/src/hooks/useScrollSpy.ts +10 -14
  52. package/src/hooks/useTextSelection.ts +1 -38
  53. package/src/lib/__fixtures__/bench-data.ts +1 -41
  54. package/src/lib/anchor.bench.ts +57 -67
  55. package/src/lib/anchor.test.ts +5 -1
  56. package/src/lib/anchor.ts +13 -93
  57. package/src/lib/comment-storage.test.ts +4 -4
  58. package/src/lib/comment-storage.ts +2 -46
  59. package/src/lib/export.ts +7 -13
  60. package/src/lib/highlight/core.test.ts +1 -1
  61. package/src/lib/highlight/dom.ts +5 -68
  62. package/src/lib/highlight/highlighter.ts +102 -262
  63. package/src/lib/highlight/resolver.ts +112 -0
  64. package/src/lib/highlight/types.ts +0 -35
  65. package/src/lib/highlight/worker.ts +45 -0
  66. package/src/lib/i18n/en.ts +1 -50
  67. package/src/lib/i18n/ja.ts +1 -50
  68. package/src/lib/i18n/types.ts +1 -49
  69. package/src/lib/margin-layout.ts +5 -27
  70. package/src/lib/positions.ts +150 -0
  71. package/src/lib/utils.ts +2 -19
  72. package/src/schema.ts +81 -0
  73. package/src/{server/index.ts → server.ts} +74 -74
  74. package/src/{store/index.ts → store.ts} +14 -46
  75. package/vite.config.ts +8 -0
  76. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  77. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  78. package/src/components/DocumentViewer/index.ts +0 -1
  79. package/src/components/FloatingTOC.tsx +0 -61
  80. package/src/components/ShortcutCapture.tsx +0 -48
  81. package/src/components/ShortcutList.tsx +0 -198
  82. package/src/components/comments/CommentMinimap.tsx +0 -62
  83. package/src/components/ui/ActionBar.tsx +0 -16
  84. package/src/components/ui/SeparatorDot.tsx +0 -9
  85. package/src/contexts/LayoutContext.tsx +0 -88
  86. package/src/hooks/useClipboard.ts +0 -82
  87. package/src/hooks/useEditorScheme.ts +0 -51
  88. package/src/hooks/useFontPreference.ts +0 -59
  89. package/src/hooks/useKeybindings.ts +0 -108
  90. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  91. package/src/hooks/useLayoutMode.ts +0 -44
  92. package/src/hooks/useLocalePreference.ts +0 -42
  93. package/src/hooks/useReanchorMode.ts +0 -33
  94. package/src/hooks/useScrollMetrics.ts +0 -56
  95. package/src/hooks/useThemePreference.ts +0 -66
  96. package/src/lib/comment-storage.bench.ts +0 -63
  97. package/src/lib/context.bench.ts +0 -41
  98. package/src/lib/context.test.ts +0 -224
  99. package/src/lib/context.ts +0 -193
  100. package/src/lib/editor-links.ts +0 -59
  101. package/src/lib/export.bench.ts +0 -35
  102. package/src/lib/highlight/colors.ts +0 -37
  103. package/src/lib/highlight/core.ts +0 -54
  104. package/src/lib/highlight/index.ts +0 -23
  105. package/src/lib/highlight/script-builder.ts +0 -485
  106. package/src/lib/html-processor.test.tsx +0 -170
  107. package/src/lib/html-processor.tsx +0 -95
  108. package/src/lib/i18n/completeness.test.ts +0 -51
  109. package/src/lib/i18n/translations.test.ts +0 -39
  110. package/src/lib/layout-constants.ts +0 -12
  111. package/src/lib/margin-layout.bench.ts +0 -28
  112. package/src/lib/scroll.test.ts +0 -118
  113. package/src/lib/scroll.ts +0 -47
  114. package/src/lib/shortcut-registry.test.ts +0 -173
  115. package/src/lib/shortcut-registry.ts +0 -209
  116. package/src/lib/utils.test.ts +0 -110
  117. package/src/store/index.test.ts +0 -242
  118. package/src/types/index.ts +0 -127
package/src/App.tsx CHANGED
@@ -1,28 +1,26 @@
1
- import { use, useCallback, useEffect, useRef } from "react";
2
- import { Toaster } from "sonner";
1
+ import { useCallback, useEffect, useMemo, useRef } from "react";
2
+ import { Toaster, toast } from "sonner";
3
3
  import { CommentInput } from "./components/comments/CommentInput";
4
- import { CommentMinimap } from "./components/comments/CommentMinimap";
5
4
  import { CommentNav } from "./components/comments/CommentNav";
6
- import { DocumentViewer } from "./components/DocumentViewer";
7
- import { FloatingTOC } from "./components/FloatingTOC";
5
+ import { DocumentViewer } from "./components/DocumentViewer/DocumentViewer";
8
6
  import { Header } from "./components/Header";
9
7
  import { MarginNotes } from "./components/MarginNotes";
10
8
  import { ReanchorConfirm } from "./components/ReanchorConfirm";
11
9
  import { TabBar } from "./components/TabBar";
12
10
  import { TableOfContents } from "./components/TableOfContents";
13
- import { textVariants } from "./components/ui/Text";
14
- import { CommentContext, CommentProvider } from "./contexts/CommentContext";
15
- import { LayoutContext, LayoutProvider } from "./contexts/LayoutContext";
11
+ import {
12
+ CommentProvider,
13
+ useCommentActions,
14
+ useCommentData,
15
+ } from "./contexts/CommentContext";
16
16
  import { useLocale } from "./contexts/LocaleContext";
17
- import { useClipboard } from "./hooks/useClipboard";
17
+ import { PositionsProvider, usePositions } from "./contexts/PositionsContext";
18
+ import { SettingsProvider } from "./contexts/SettingsContext";
18
19
  import { useDocument } from "./hooks/useDocument";
19
20
  import { useHeadings } from "./hooks/useHeadings";
20
- import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
21
- import { useScrollMetrics } from "./hooks/useScrollMetrics";
22
21
  import { useScrollSpy } from "./hooks/useScrollSpy";
23
22
  import { useTextSelection } from "./hooks/useTextSelection";
24
- import { calculateScrollTarget, getElementTopInDocument } from "./lib/scroll";
25
- import { ShortcutActions } from "./lib/shortcut-registry";
23
+ import { exportCommentsAsJson, generatePrompt } from "./lib/export";
26
24
  import { cn } from "./lib/utils";
27
25
  import { appStore, useAppStore } from "./store";
28
26
 
@@ -33,7 +31,7 @@ const TOASTER_OPTIONS = {
33
31
  classNames: {
34
32
  toast: cn(
35
33
  "backdrop-blur-sm bg-white/90 dark:bg-zinc-900/90 border border-zinc-100 dark:border-zinc-800 px-3 py-2 shadow-sm rounded-md",
36
- textVariants({ variant: "caption" }),
34
+ "text-xs text-zinc-500 dark:text-zinc-400",
37
35
  ),
38
36
  },
39
37
  };
@@ -41,98 +39,50 @@ const TOASTER_OPTIONS = {
41
39
  interface AppContentProps {
42
40
  document: NonNullable<ReturnType<typeof useDocument>["document"]>;
43
41
  reload: ReturnType<typeof useDocument>["reload"];
42
+ isActive: boolean;
44
43
  }
45
44
 
46
- function AppContent({ document, reload }: AppContentProps) {
45
+ function AppContent({ document, reload, isActive }: AppContentProps) {
47
46
  const { t } = useLocale();
48
- const {
49
- comments,
50
- sortedComments,
51
- addComment,
52
- reanchorComment,
53
- reanchorTarget,
54
- cancelReanchor,
55
- hoveredCommentId,
56
- setHoveredCommentId,
57
- navigatePrevious,
58
- navigateNext,
59
- } = use(CommentContext)!;
47
+ const { comments, sortedComments, reanchorTarget } = useCommentData();
48
+ const { addComment, reanchorComment, cancelReanchor, setHoveredCommentId } =
49
+ useCommentActions();
60
50
 
61
- const {
62
- selection,
63
- highlightPositions,
64
- documentPositions,
65
- pendingSelectionTop,
66
- onTextSelect,
67
- onPositionsChange,
68
- clearSelection,
69
- } = useTextSelection();
70
-
71
- const {
72
- copyAll,
73
- copyAllRaw,
74
- exportJson,
75
- copySelectionRaw,
76
- copySelectionForLLM,
77
- } = useClipboard({
78
- comments,
79
- document: document ?? undefined,
80
- selection: selection ?? undefined,
81
- clearSelection,
82
- t,
83
- });
84
-
85
- const { shortcuts, isFullscreen } = use(LayoutContext)!;
86
-
87
- useKeyboardShortcuts(shortcuts, {
88
- [ShortcutActions.COPY_ALL]: copyAll,
89
- [ShortcutActions.COPY_ALL_RAW]: copyAllRaw,
90
- [ShortcutActions.NAVIGATE_NEXT]: navigateNext,
91
- [ShortcutActions.NAVIGATE_PREVIOUS]: navigatePrevious,
92
- [ShortcutActions.COPY_SELECTION_RAW]: copySelectionRaw,
93
- [ShortcutActions.COPY_SELECTION_LLM]: copySelectionForLLM,
94
- [ShortcutActions.CLEAR_SELECTION]: clearSelection,
95
- });
96
-
97
- const scrollMetrics = useScrollMetrics();
98
-
99
- const headings = useHeadings(
100
- document?.content ?? null,
101
- document?.type ?? null,
102
- );
103
- const activeHeadingId = useScrollSpy(headings.map((h) => h.id));
104
-
105
- const scrollToHeading = useCallback(
106
- (id: string) => {
107
- let elementRect: DOMRect | undefined;
108
- let iframeTopOffset: number | undefined;
109
-
110
- if (document?.type === "html") {
111
- const iframe = window.document.querySelector("iframe");
112
- const el = iframe?.contentDocument?.getElementById(id);
113
- if (!el || !iframe) return;
114
- elementRect = el.getBoundingClientRect();
115
- iframeTopOffset = iframe.getBoundingClientRect().top;
116
- } else {
117
- elementRect = window.document
118
- .getElementById(id)
119
- ?.getBoundingClientRect();
120
- }
121
- if (!elementRect) return;
51
+ const { selection, pendingSelectionTop, onTextSelect, clearSelection } =
52
+ useTextSelection();
122
53
 
123
- const elementTop = getElementTopInDocument({
124
- elementRect,
125
- scrollY: window.scrollY,
126
- iframeTopOffset,
127
- });
128
- const scrollTarget = calculateScrollTarget({
129
- elementTop,
130
- viewportHeight: window.innerHeight,
131
- });
132
- window.scrollTo({ top: scrollTarget, behavior: "smooth" });
133
- },
134
- [document?.type],
135
- );
54
+ const pos = usePositions();
55
+
56
+ useEffect(() => {
57
+ pos.setIds(sortedComments.map((c) => c.id));
58
+ }, [pos, sortedComments]);
59
+ useEffect(() => {
60
+ pos.setPending(selection ? pendingSelectionTop : undefined);
61
+ }, [pos, selection, pendingSelectionTop]);
62
+
63
+ const copyAll = useCallback(() => {
64
+ if (!document) return;
65
+ navigator.clipboard.writeText(generatePrompt(comments, document.fileName));
66
+ toast.success(t("toast.copiedAllComments"));
67
+ }, [comments, document, t]);
68
+
69
+ const exportJson = useCallback(() => {
70
+ if (!document) return;
71
+ exportCommentsAsJson(comments, document);
72
+ }, [comments, document]);
73
+
74
+ const headings = useHeadings(document?.content ?? null);
75
+ const headingIds = useMemo(() => headings.map((h) => h.id), [headings]);
76
+ const activeHeadingId = useScrollSpy(headingIds, isActive);
77
+
78
+ const scrollToHeading = useCallback((id: string) => {
79
+ const rect = window.document.getElementById(id)?.getBoundingClientRect();
80
+ if (!rect) return;
81
+
82
+ const elementTop = window.scrollY + rect.top;
83
+ const scrollTarget = Math.max(0, elementTop - window.innerHeight * 0.25);
84
+ window.scrollTo({ top: scrollTarget, behavior: "smooth" });
85
+ }, []);
136
86
 
137
87
  const handleHighlightClick = useCallback((commentId: string) => {
138
88
  const marginNote = window.document.querySelector(
@@ -143,30 +93,31 @@ function AppContent({ document, reload }: AppContentProps) {
143
93
  }
144
94
  }, []);
145
95
 
146
- // Scroll save/restore for tab switching
96
+ // Scroll save/restore for tab switching (visibility-based, not mount-based)
147
97
  const setScrollY = useAppStore((s) => s.setScrollY);
148
98
  const savedScrollY = useAppStore(
149
- (s) => s.getActiveDocumentState()?.scrollY ?? 0,
99
+ (s) => s.documents.get(document.filePath)?.scrollY ?? 0,
150
100
  );
151
- const scrollRestored = useRef(false);
101
+ const prevActiveRef = useRef(isActive);
152
102
 
153
- // Save scroll position on unmount
154
103
  useEffect(() => {
155
- return () => {
156
- setScrollY(window.scrollY);
157
- };
158
- }, [setScrollY]);
104
+ const wasActive = prevActiveRef.current;
105
+ prevActiveRef.current = isActive;
159
106
 
160
- // Restore scroll position on mount (after highlights paint)
161
- useEffect(() => {
162
- if (savedScrollY === 0 || scrollRestored.current) return;
163
- scrollRestored.current = true;
164
- requestAnimationFrame(() => {
107
+ if (wasActive && !isActive) {
108
+ // Tab becoming hidden — save scroll position
109
+ setScrollY(window.scrollY, document.filePath);
110
+ }
111
+
112
+ if (!wasActive && isActive) {
113
+ // Tab becoming visible — restore scroll after layout recalc
165
114
  requestAnimationFrame(() => {
166
- window.scrollTo(0, savedScrollY);
115
+ requestAnimationFrame(() => {
116
+ window.scrollTo(0, savedScrollY);
117
+ });
167
118
  });
168
- });
169
- }, [savedScrollY]);
119
+ }
120
+ }, [isActive, savedScrollY, setScrollY, document.filePath]);
170
121
 
171
122
  const handleAddComment = useCallback(
172
123
  (commentText: string) => {
@@ -213,23 +164,15 @@ function AppContent({ document, reload }: AppContentProps) {
213
164
 
214
165
  return (
215
166
  <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex flex-col">
216
- <Toaster
217
- position="bottom-right"
218
- icons={TOASTER_ICONS}
219
- toastOptions={TOASTER_OPTIONS}
220
- />
221
167
  <Header
222
168
  fileName={document.fileName}
223
169
  onCopyAll={copyAll}
224
- onCopyAllRaw={copyAllRaw}
225
170
  onExportJson={exportJson}
226
171
  onReload={reload}
227
172
  />
228
173
 
229
- <div
230
- className={`flex-1 flex gap-4 w-full ${!isFullscreen ? "max-w-7xl mx-auto" : ""} ${hoveredCommentId ? "has-comment-focus" : ""}`}
231
- >
232
- {!isFullscreen && headings.length > 0 && (
174
+ <div className="flex-1 flex gap-4 w-full max-w-7xl mx-auto">
175
+ {headings.length > 0 && (
233
176
  <aside className="w-48 flex-shrink-0 py-6 pl-6 hidden xl:block">
234
177
  <div className="sticky top-64 max-h-[calc(100vh-17rem)] overflow-y-auto">
235
178
  <TableOfContents
@@ -240,23 +183,14 @@ function AppContent({ document, reload }: AppContentProps) {
240
183
  </div>
241
184
  </aside>
242
185
  )}
243
- {isFullscreen && (
244
- <FloatingTOC
245
- headings={headings}
246
- activeId={activeHeadingId}
247
- onHeadingClick={scrollToHeading}
248
- />
249
- )}
250
186
 
251
187
  <div className="flex-1 px-6 py-6">
252
188
  <DocumentViewer
253
189
  content={document.content}
254
- type={document.type}
255
190
  comments={comments}
256
191
  headings={headings}
257
- pendingSelection={selection ?? undefined}
192
+ isActive={isActive}
258
193
  onTextSelect={onTextSelect}
259
- onHighlightPositionsChange={onPositionsChange}
260
194
  onHighlightHover={setHoveredCommentId}
261
195
  onHighlightClick={handleHighlightClick}
262
196
  />
@@ -280,27 +214,15 @@ function AppContent({ document, reload }: AppContentProps) {
280
214
  selectedText={selection.text}
281
215
  onSubmit={handleAddComment}
282
216
  onCancel={clearSelection}
283
- onCopyRaw={copySelectionRaw}
284
- onCopyForLLM={copySelectionForLLM}
285
217
  />
286
218
  )}
287
219
  </div>
288
220
  )}
289
221
 
290
- <MarginNotes
291
- sortedComments={sortedComments}
292
- highlightPositions={highlightPositions}
293
- pendingSelectionTop={selection ? pendingSelectionTop : undefined}
294
- />
222
+ <MarginNotes sortedComments={sortedComments} />
295
223
  </div>
296
224
  </div>
297
225
 
298
- <CommentMinimap
299
- documentPositions={documentPositions}
300
- documentHeight={scrollMetrics.documentHeight}
301
- viewportHeight={scrollMetrics.viewportHeight}
302
- />
303
-
304
226
  <CommentNav />
305
227
 
306
228
  <footer className="py-4 text-center text-sm text-zinc-400 dark:text-zinc-500">
@@ -336,8 +258,10 @@ function useTabKeyboardShortcuts() {
336
258
 
337
259
  function App() {
338
260
  const { t } = useLocale();
339
- const { document, error, isInitialized, reload } = useDocument();
261
+ const { error, isInitialized, reload } = useDocument();
340
262
  const documentOrder = useAppStore((s) => s.documentOrder);
263
+ const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
264
+ const documents = useAppStore((s) => s.documents);
341
265
 
342
266
  useTabKeyboardShortcuts();
343
267
 
@@ -385,30 +309,58 @@ function App() {
385
309
  );
386
310
  }
387
311
 
388
- if (!document) {
389
- return (
390
- <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
391
- <div className="text-zinc-500 dark:text-zinc-400">
392
- {t("app.loading")}
393
- </div>
394
- </div>
395
- );
396
- }
397
-
398
312
  return (
399
313
  <>
400
314
  <TabBar />
401
- <LayoutProvider>
402
- <CommentProvider
403
- filePath={document.filePath}
404
- clean={document.clean}
405
- documentContent={document.content}
406
- fileName={document.fileName}
407
- documentType={document.type}
408
- >
409
- <AppContent document={document} reload={reload} />
410
- </CommentProvider>
411
- </LayoutProvider>
315
+ <Toaster
316
+ position="bottom-right"
317
+ icons={TOASTER_ICONS}
318
+ toastOptions={TOASTER_OPTIONS}
319
+ />
320
+ <SettingsProvider>
321
+ {documentOrder.map((filePath) => {
322
+ const docState = documents.get(filePath);
323
+ const isActive = filePath === activeDocumentPath;
324
+ const hasContent = !!docState?.document.content;
325
+
326
+ // Don't mount inactive tabs that haven't loaded content yet
327
+ if (!hasContent && !isActive) return null;
328
+
329
+ // Active tab without content — show loading placeholder
330
+ if (!hasContent) {
331
+ return (
332
+ <div
333
+ key={filePath}
334
+ className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center"
335
+ >
336
+ <div className="text-zinc-500 dark:text-zinc-400">
337
+ {t("app.loading")}
338
+ </div>
339
+ </div>
340
+ );
341
+ }
342
+
343
+ return (
344
+ <div
345
+ key={filePath}
346
+ style={isActive ? undefined : { display: "none" }}
347
+ >
348
+ <PositionsProvider>
349
+ <CommentProvider
350
+ filePath={filePath}
351
+ clean={docState.document.clean}
352
+ >
353
+ <AppContent
354
+ document={docState.document}
355
+ reload={reload}
356
+ isActive={isActive}
357
+ />
358
+ </CommentProvider>
359
+ </PositionsProvider>
360
+ </div>
361
+ );
362
+ })}
363
+ </SettingsProvider>
412
364
  </>
413
365
  );
414
366
  }
@@ -13,11 +13,10 @@ import * as os from "node:os";
13
13
  import { join, resolve } from "node:path";
14
14
  import { Command } from "commander";
15
15
  import open from "open";
16
- import { getCommentPath, parseCommentFile } from "../lib/comment-storage.js";
17
- import { getFileType } from "../lib/utils.js";
18
- import type { FileEntry } from "../server/index.js";
19
- import { removeServerInfo, startServer } from "../server/index.js";
20
- import type { DocumentType } from "../types/index.js";
16
+ import { getCommentPath, parseCommentFile } from "./lib/comment-storage.js";
17
+ import { isMarkdownFile } from "./lib/utils.js";
18
+ import type { FileEntry } from "./server.js";
19
+ import { removeServerInfo, startServer } from "./server.js";
21
20
 
22
21
  const program = new Command();
23
22
 
@@ -160,7 +159,7 @@ async function discoverServer(): Promise<ServerInfo | null> {
160
159
 
161
160
  async function attachFiles(
162
161
  server: ServerInfo,
163
- files: { path: string; type: DocumentType }[],
162
+ files: { path: string }[],
164
163
  ): Promise<void> {
165
164
  for (const file of files) {
166
165
  try {
@@ -178,9 +177,9 @@ async function attachFiles(
178
177
 
179
178
  const data = await res.json();
180
179
  if (data.status === "added") {
181
- console.log(`Added: ${data.fileName} (${data.type})`);
180
+ console.log(`Added: ${data.fileName}`);
182
181
  } else {
183
- console.log(`Present: ${data.fileName} (${data.type})`);
182
+ console.log(`Present: ${data.fileName}`);
184
183
  }
185
184
  } catch (err) {
186
185
  console.error(
@@ -217,9 +216,6 @@ async function getServerTarget(
217
216
  });
218
217
  }
219
218
 
220
- /**
221
- * Recursively find all .comments.md files in a directory.
222
- */
223
219
  function findCommentFiles(dir: string): string[] {
224
220
  const results: string[] = [];
225
221
 
@@ -250,9 +246,6 @@ function findCommentFiles(dir: string): string[] {
250
246
  return results;
251
247
  }
252
248
 
253
- /**
254
- * Recursively find reviewable files (.md, .markdown, .html, .htm) in a directory.
255
- */
256
249
  function findReviewableFiles(dir: string): FileEntry[] {
257
250
  const results: FileEntry[] = [];
258
251
 
@@ -268,14 +261,8 @@ function findReviewableFiles(dir: string): FileEntry[] {
268
261
  if (lstat.isSymbolicLink()) continue;
269
262
  if (lstat.isDirectory()) {
270
263
  results.push(...findReviewableFiles(fullPath));
271
- } else {
272
- const type = getFileType(entry);
273
- if (type) {
274
- results.push({
275
- type,
276
- filePath: fullPath,
277
- });
278
- }
264
+ } else if (isMarkdownFile(entry)) {
265
+ results.push({ filePath: fullPath });
279
266
  }
280
267
  } catch (err) {
281
268
  if (isPermissionError(err)) {
@@ -292,9 +279,6 @@ function findReviewableFiles(dir: string): FileEntry[] {
292
279
  return results;
293
280
  }
294
281
 
295
- /**
296
- * Resolve CLI arguments into a deduplicated list of FileEntry objects.
297
- */
298
282
  function resolveFiles(args: string[]): FileEntry[] {
299
283
  const seen = new Set<string>();
300
284
  const files: FileEntry[] = [];
@@ -322,27 +306,21 @@ function resolveFiles(args: string[]): FileEntry[] {
322
306
  } else {
323
307
  if (seen.has(filePath)) continue;
324
308
 
325
- const type = getFileType(filePath);
326
- if (!type) {
309
+ if (!isMarkdownFile(filePath)) {
327
310
  console.error(
328
- `error: unsupported file type: ${arg} (expected .md, .markdown, .html, or .htm)`,
311
+ `error: unsupported file type: ${arg} (expected .md or .markdown)`,
329
312
  );
330
313
  process.exit(1);
331
314
  }
332
315
 
333
316
  seen.add(filePath);
334
- files.push({
335
- type,
336
- filePath,
337
- });
317
+ files.push({ filePath });
338
318
  }
339
319
  }
340
320
 
341
321
  return files;
342
322
  }
343
323
 
344
- // ─── Onboarding ──────────────────────────────────────────────────────
345
-
346
324
  const SETTINGS_PATH = join(os.homedir(), ".readit", "settings.json");
347
325
 
348
326
  function isOnboarded(): boolean {
@@ -447,14 +425,11 @@ Go ahead and add a few comments to this document. When you're done, export them
447
425
 
448
426
  const WELCOME_PATH = join(os.homedir(), ".readit", "welcome.md");
449
427
 
450
- // ─── Program ─────────────────────────────────────────────────────────
451
-
452
428
  program
453
429
  .name("readit")
454
- .description("Review Markdown and HTML documents with inline comments")
430
+ .description("Review Markdown documents with inline comments")
455
431
  .version("0.1.3");
456
432
 
457
- // List command: show all commented files
458
433
  program
459
434
  .command("list")
460
435
  .description("List all files with comments")
@@ -493,7 +468,6 @@ program
493
468
  }
494
469
  });
495
470
 
496
- // Show command: display comments for a file
497
471
  program
498
472
  .command("show <file>")
499
473
  .description("Show comments for a file")
@@ -537,9 +511,8 @@ program
537
511
  }
538
512
  });
539
513
 
540
- // Main review command (default) — accepts zero or more files/directories
541
514
  program
542
- .argument("[files...]", "Markdown or HTML files/directories to review")
515
+ .argument("[files...]", "Markdown files/directories to review")
543
516
  .option("-p, --port <number>", "Port to run server on", "4567")
544
517
  .option("--host <address>", "Host address to bind to", "127.0.0.1")
545
518
  .option("--no-open", "Don't automatically open browser")
@@ -563,7 +536,6 @@ program
563
536
  files = [
564
537
  {
565
538
  content: WELCOME_CONTENT,
566
- type: "markdown" as DocumentType,
567
539
  filePath: WELCOME_PATH,
568
540
  },
569
541
  ];
@@ -588,6 +560,17 @@ program
588
560
  process.exit(1);
589
561
  }
590
562
 
563
+ // Snapshot previous session before startServer() overwrites server.json
564
+ let previousPort: number | undefined;
565
+ try {
566
+ const info = JSON.parse(readFileSync(SERVER_INFO_PATH, "utf-8"));
567
+ if (!isAlive(info.pid)) {
568
+ previousPort = info.port;
569
+ }
570
+ } catch {
571
+ // No previous session — will open browser normally
572
+ }
573
+
591
574
  try {
592
575
  const { url, server } = await startServer({
593
576
  files,
@@ -608,7 +591,7 @@ readit - Document Review Tool
608
591
  Server running. Press Ctrl+C to stop.
609
592
  `);
610
593
  } else {
611
- const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
594
+ const fileList = files.map((f) => ` ${f.filePath}`);
612
595
 
613
596
  console.log(`
614
597
  readit - Document Review Tool
@@ -622,7 +605,11 @@ ${fileList.join("\n")}
622
605
  `);
623
606
  }
624
607
 
625
- if (options.open) {
608
+ const browserLikelyOpen =
609
+ previousPort === preferredPort ||
610
+ process.env.NODE_ENV === "development";
611
+
612
+ if (options.open && !browserLikelyOpen) {
626
613
  open(url);
627
614
  }
628
615
 
@@ -648,17 +635,16 @@ ${fileList.join("\n")}
648
635
  },
649
636
  );
650
637
 
651
- // Open command: add files to running server or start new one
652
638
  program
653
639
  .command("open")
654
- .argument("<files...>", "Markdown or HTML files to add to running server")
640
+ .argument("<files...>", "Markdown files to add to running server")
655
641
  .description("Add files to a running readit server, or start a new one")
656
642
  .option("-p, --port <number>", "Port for new server (if starting)", "4567")
657
643
  .option("--host <address>", "Host for new server (if starting)", "127.0.0.1")
658
644
  .action(
659
645
  async (fileArgs: string[], options: { port: string; host: string }) => {
660
646
  // Resolve and validate files
661
- const resolvedFiles: { path: string; type: DocumentType }[] = [];
647
+ const resolvedFiles: { path: string }[] = [];
662
648
  for (const arg of fileArgs) {
663
649
  const inputPath = resolve(process.cwd(), arg);
664
650
 
@@ -669,19 +655,17 @@ program
669
655
 
670
656
  const filePath = realpathSync(inputPath);
671
657
 
672
- const type = getFileType(filePath);
673
- if (!type) {
658
+ if (!isMarkdownFile(filePath)) {
674
659
  console.error(
675
- `error: unsupported file type: ${arg} (expected .md, .markdown, .html, or .htm)`,
660
+ `error: unsupported file type: ${arg} (expected .md or .markdown)`,
676
661
  );
677
662
  process.exit(1);
678
663
  }
679
664
 
680
- resolvedFiles.push({ path: filePath, type });
665
+ resolvedFiles.push({ path: filePath });
681
666
  }
682
667
 
683
668
  const files = resolvedFiles.map((f) => ({
684
- type: f.type,
685
669
  filePath: f.path,
686
670
  }));
687
671
 
@@ -702,7 +686,7 @@ program
702
686
  return;
703
687
  }
704
688
 
705
- const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
689
+ const fileList = files.map((f) => ` ${f.filePath}`);
706
690
  console.log(`
707
691
  readit - Document Review Tool
708
692