@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
@@ -0,0 +1,133 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ use,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ } from "react";
10
+ import { toast } from "sonner";
11
+ import {
12
+ FontFamilies,
13
+ type FontFamily,
14
+ type ThemeMode,
15
+ ThemeModes,
16
+ } from "../schema";
17
+
18
+ const THEME_STORAGE_KEY = "readit:theme";
19
+ const DARK_MQ = "(prefers-color-scheme: dark)";
20
+
21
+ function getStoredTheme(): ThemeMode {
22
+ try {
23
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
24
+ if (
25
+ stored === ThemeModes.LIGHT ||
26
+ stored === ThemeModes.DARK ||
27
+ stored === ThemeModes.SYSTEM
28
+ ) {
29
+ return stored;
30
+ }
31
+ } catch {
32
+ // localStorage may be unavailable
33
+ }
34
+ return ThemeModes.SYSTEM;
35
+ }
36
+
37
+ function applyTheme(mode: ThemeMode): void {
38
+ const isDark =
39
+ mode === ThemeModes.DARK ||
40
+ (mode === ThemeModes.SYSTEM && window.matchMedia(DARK_MQ).matches);
41
+
42
+ document.documentElement.classList.toggle("dark", isDark);
43
+ }
44
+
45
+ interface SettingsContextValue {
46
+ fontFamily: FontFamily;
47
+ setFontFamily: (font: FontFamily) => Promise<void>;
48
+ themeMode: ThemeMode;
49
+ setThemeMode: (mode: ThemeMode) => void;
50
+ }
51
+
52
+ export const SettingsContext = createContext<SettingsContextValue | null>(null);
53
+
54
+ export function useSettings(): SettingsContextValue {
55
+ const value = use(SettingsContext);
56
+ if (!value) {
57
+ throw new Error("useSettings must be used within a SettingsProvider");
58
+ }
59
+ return value;
60
+ }
61
+
62
+ export function SettingsProvider({ children }: { children: ReactNode }) {
63
+ const [fontFamily, setFontFamilyState] = useState<FontFamily>(
64
+ FontFamilies.SERIF,
65
+ );
66
+
67
+ useEffect(() => {
68
+ const fetchSettings = async () => {
69
+ try {
70
+ const response = await fetch("/api/settings");
71
+ if (response.ok) {
72
+ const settings = await response.json();
73
+ setFontFamilyState(settings.fontFamily || FontFamilies.SERIF);
74
+ }
75
+ } catch (err) {
76
+ console.error("Failed to fetch settings:", err);
77
+ }
78
+ };
79
+
80
+ fetchSettings();
81
+ }, []);
82
+
83
+ const setFontFamily = useCallback(async (font: FontFamily) => {
84
+ setFontFamilyState(font);
85
+
86
+ try {
87
+ const response = await fetch("/api/settings", {
88
+ method: "PUT",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({ fontFamily: font }),
91
+ });
92
+
93
+ if (!response.ok) {
94
+ throw new Error("Failed to save settings");
95
+ }
96
+ } catch (err) {
97
+ console.error("Failed to save font preference:", err);
98
+ toast.error("Failed to save font preference");
99
+ }
100
+ }, []);
101
+
102
+ const [themeMode, setThemeModeState] = useState<ThemeMode>(getStoredTheme);
103
+
104
+ useEffect(() => {
105
+ applyTheme(themeMode);
106
+ }, [themeMode]);
107
+
108
+ useEffect(() => {
109
+ if (themeMode !== ThemeModes.SYSTEM) return;
110
+
111
+ const mq = window.matchMedia(DARK_MQ);
112
+ const handler = () => applyTheme(ThemeModes.SYSTEM);
113
+
114
+ mq.addEventListener("change", handler);
115
+ return () => mq.removeEventListener("change", handler);
116
+ }, [themeMode]);
117
+
118
+ const setThemeMode = useCallback((mode: ThemeMode) => {
119
+ setThemeModeState(mode);
120
+ try {
121
+ localStorage.setItem(THEME_STORAGE_KEY, mode);
122
+ } catch {
123
+ // localStorage may be unavailable
124
+ }
125
+ }, []);
126
+
127
+ const value = useMemo<SettingsContextValue>(
128
+ () => ({ fontFamily, setFontFamily, themeMode, setThemeMode }),
129
+ [fontFamily, setFontFamily, themeMode, setThemeMode],
130
+ );
131
+
132
+ return <SettingsContext value={value}>{children}</SettingsContext>;
133
+ }
@@ -1,9 +1,5 @@
1
1
  import { type RefObject, useEffect } from "react";
2
2
 
3
- /**
4
- * Close a dropdown/popover when clicking outside or pressing Escape.
5
- * Only attaches listeners when `active` is true.
6
- */
7
3
  export function useClickOutside(
8
4
  ref: RefObject<HTMLElement | null>,
9
5
  onClose: () => void,
@@ -1,27 +1,19 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { appStore, useAppStore } from "../store";
3
- import type { Comment } from "../types";
2
+ import type { Comment } from "../schema";
3
+ import { uiStore } from "../store";
4
4
 
5
5
  interface UseCommentNavigationResult {
6
6
  currentIndex: number;
7
- hoveredCommentId: string | undefined;
8
7
  setHoveredCommentId: (id: string | undefined) => void;
9
8
  navigateToComment: (commentId: string) => void;
10
9
  navigatePrevious: () => void;
11
10
  navigateNext: () => void;
12
11
  }
13
12
 
14
- /**
15
- * Manage comment navigation with cycling, keyboard shortcuts, and scroll-to-comment.
16
- * Handles Alt+↑/↓ keyboard navigation.
17
- */
18
13
  export function useCommentNavigation(
19
14
  sortedComments: Comment[],
20
15
  ): UseCommentNavigationResult {
21
16
  const [currentIndex, setCurrentIndex] = useState(0);
22
- const hoveredCommentId = useAppStore(
23
- (s) => s.getActiveDocumentState()?.hoveredCommentId,
24
- );
25
17
  const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
26
18
  undefined,
27
19
  );
@@ -30,7 +22,6 @@ export function useCommentNavigation(
30
22
  const sortedRef = useRef(sortedComments);
31
23
  sortedRef.current = sortedComments;
32
24
 
33
- // Cleanup hover timeout on unmount
34
25
  useEffect(() => {
35
26
  return () => clearTimeout(hoverTimeoutRef.current);
36
27
  }, []);
@@ -44,7 +35,6 @@ export function useCommentNavigation(
44
35
  setCurrentIndex(clampedIndex);
45
36
  }
46
37
 
47
- // Update DOM data-focused attributes imperatively
48
38
  const updateFocusedMarks = useCallback((commentId: string | undefined) => {
49
39
  const marks = window.document.querySelectorAll("mark[data-comment-id]");
50
40
  for (const mark of marks) {
@@ -59,13 +49,12 @@ export function useCommentNavigation(
59
49
 
60
50
  const setHoveredCommentId = useCallback(
61
51
  (id: string | undefined) => {
62
- appStore.getState().setHoveredCommentId(id);
52
+ uiStore.setState({ hoveredCommentId: id });
63
53
  updateFocusedMarks(id);
64
54
  },
65
55
  [updateFocusedMarks],
66
56
  );
67
57
 
68
- // Navigate to a comment by scrolling its highlight into view
69
58
  const navigateToComment = useCallback(
70
59
  (commentId: string) => {
71
60
  const selector = `mark[data-comment-id="${commentId}"]`;
@@ -80,24 +69,14 @@ export function useCommentNavigation(
80
69
  );
81
70
  };
82
71
 
83
- // Try main document first (for markdown)
84
- const mainHighlight = document.querySelector(selector);
85
- if (mainHighlight) {
86
- scrollAndHighlight(mainHighlight);
87
- return;
88
- }
89
-
90
- // Try inside iframe (for HTML content)
91
- const iframe = document.querySelector("iframe");
92
- const iframeHighlight = iframe?.contentDocument?.querySelector(selector);
93
- if (iframeHighlight) {
94
- scrollAndHighlight(iframeHighlight);
72
+ const highlight = document.querySelector(selector);
73
+ if (highlight) {
74
+ scrollAndHighlight(highlight);
95
75
  }
96
76
  },
97
77
  [setHoveredCommentId],
98
78
  );
99
79
 
100
- // Navigate to previous comment (cycles to last when at first)
101
80
  const navigatePrevious = useCallback(() => {
102
81
  const sc = sortedRef.current;
103
82
  if (sc.length === 0) return;
@@ -108,7 +87,6 @@ export function useCommentNavigation(
108
87
  });
109
88
  }, [navigateToComment]);
110
89
 
111
- // Navigate to next comment (cycles to first when at last)
112
90
  const navigateNext = useCallback(() => {
113
91
  const sc = sortedRef.current;
114
92
  if (sc.length === 0) return;
@@ -121,7 +99,6 @@ export function useCommentNavigation(
121
99
 
122
100
  return {
123
101
  currentIndex: clampedIndex,
124
- hoveredCommentId,
125
102
  setHoveredCommentId,
126
103
  navigateToComment,
127
104
  navigatePrevious,
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
+ import { AnchorConfidences, type Comment } from "../schema";
2
3
  import { appStore, useAppStore } from "../store";
3
- import { AnchorConfidences, type Comment } from "../types";
4
4
 
5
5
  interface UseCommentsOptions {
6
6
  clean?: boolean;
@@ -26,17 +26,12 @@ interface UseCommentsResult {
26
26
  ) => void;
27
27
  }
28
28
 
29
- /**
30
- * Hook for managing comments with optimistic updates.
31
- * State lives in the Zustand store; this hook coordinates API mutations.
32
- */
33
29
  export function useComments(
34
30
  filePath: string | null,
35
31
  options: UseCommentsOptions = {},
36
32
  ): UseCommentsResult {
37
33
  const { clean = false } = options;
38
34
 
39
- // Read comments and error from the store
40
35
  const comments = useAppStore(
41
36
  (s) => s.documents.get(filePath ?? "")?.comments ?? [],
42
37
  );
@@ -44,16 +39,12 @@ export function useComments(
44
39
  (s) => s.documents.get(filePath ?? "")?.commentsError ?? undefined,
45
40
  );
46
41
 
47
- // Track pending operations for rollback on error
48
42
  const pendingOperations = useRef<Map<string, Comment[]>>(new Map());
49
43
 
50
- // Capture filePath at call time for stable closures
44
+ // Capture filePath at call time so callbacks stay stable across renders
51
45
  const filePathRef = useRef(filePath);
52
46
  filePathRef.current = filePath;
53
47
 
54
- /**
55
- * Execute an optimistic mutation with automatic rollback on error.
56
- */
57
48
  const executeMutation = useCallback(
58
49
  async <T>({
59
50
  operationId,
@@ -71,19 +62,16 @@ export function useComments(
71
62
  const fp = filePathRef.current;
72
63
  if (!fp) return;
73
64
 
74
- // Read current comments from store for rollback
75
65
  const currentDocState = appStore.getState().documents.get(fp);
76
66
  const previousComments = [...(currentDocState?.comments ?? [])];
77
67
  pendingOperations.current.set(operationId, previousComments);
78
68
 
79
- // Apply optimistic update
80
69
  appStore.getState().setComments(optimisticUpdate(previousComments), fp);
81
70
  appStore.getState().setCommentsError(null, fp);
82
71
 
83
72
  try {
84
73
  const result = await apiCall();
85
74
 
86
- // Apply server response transformation if provided
87
75
  if (onSuccess) {
88
76
  const current = appStore.getState().documents.get(fp)?.comments ?? [];
89
77
  appStore.getState().setComments(onSuccess(result, current), fp);
@@ -97,7 +85,6 @@ export function useComments(
97
85
  fp,
98
86
  );
99
87
 
100
- // Rollback on error
101
88
  const rollback = pendingOperations.current.get(operationId);
102
89
  if (rollback) {
103
90
  appStore.getState().setComments(rollback, fp);
@@ -109,23 +96,24 @@ export function useComments(
109
96
  [],
110
97
  );
111
98
 
112
- // Build path-scoped API URL
113
99
  const pathQuery = useCallback((base: string) => {
114
100
  const fp = filePathRef.current;
115
101
  if (!fp) return base;
116
102
  return `${base}?path=${encodeURIComponent(fp)}`;
117
103
  }, []);
118
104
 
119
- // Load comments from API
120
105
  useEffect(() => {
121
106
  if (!filePath) return;
122
107
 
108
+ // Skip fetch if comments were already pre-fetched by useDocument (parallel loading)
109
+ const existing = appStore.getState().documents.get(filePath);
110
+ if (!clean && existing && existing.comments.length > 0) return;
111
+
123
112
  const loadComments = async () => {
124
113
  appStore.getState().setCommentsError(null, filePath);
125
114
  const query = `?path=${encodeURIComponent(filePath)}`;
126
115
 
127
116
  try {
128
- // If clean flag is set, clear comments first
129
117
  if (clean) {
130
118
  await fetch(`/api/comments${query}`, { method: "DELETE" });
131
119
  appStore.getState().setComments([], filePath);
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
  import { toast } from "sonner";
3
+ import type { Document } from "../schema";
3
4
  import { appStore, useAppStore } from "../store";
4
- import type { Document } from "../types";
5
5
 
6
6
  interface UseDocumentResult {
7
7
  document: Document | null;
@@ -13,30 +13,20 @@ interface UseDocumentResult {
13
13
  interface DocListItem {
14
14
  path: string;
15
15
  fileName: string;
16
- type: Document["type"];
17
16
  }
18
17
 
19
- /**
20
- * Manage multi-document loading, lazy content fetching, and live reloading.
21
- *
22
- * On mount: fetches the document list from `/api/documents` and opens all
23
- * files in the store. Content is loaded lazily when a tab becomes active.
24
- * SSE events trigger content updates for already-loaded documents.
25
- */
26
18
  export function useDocument(): UseDocumentResult {
27
19
  const [error, setError] = useState<string | null>(null);
28
20
  const [isInitialized, setIsInitialized] = useState(false);
29
21
 
30
22
  const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
31
23
 
32
- // Active document — null until content is loaded
33
24
  const document = useAppStore((s) => {
34
25
  const ds = s.getActiveDocumentState();
35
- if (!ds || !ds.document.content) return null;
26
+ if (!ds?.document.content) return null;
36
27
  return ds.document;
37
28
  });
38
29
 
39
- // Fetch document list on mount, populate store
40
30
  useEffect(() => {
41
31
  async function init() {
42
32
  try {
@@ -51,8 +41,7 @@ export function useDocument(): UseDocumentResult {
51
41
  data.files.forEach((file: DocListItem, index: number) => {
52
42
  appStore.getState().openDocument(
53
43
  {
54
- content: "", // Content loaded lazily on tab activation
55
- type: file.type,
44
+ content: "",
56
45
  filePath: file.path,
57
46
  fileName: file.fileName,
58
47
  clean,
@@ -71,32 +60,46 @@ export function useDocument(): UseDocumentResult {
71
60
  init();
72
61
  }, []);
73
62
 
74
- // Load content when active document changes and has no content yet
75
63
  useEffect(() => {
76
64
  if (!activeDocumentPath) return;
77
65
  const state = appStore.getState().documents.get(activeDocumentPath);
78
66
  if (!state || state.document.content) return;
79
67
 
80
- async function loadContent() {
81
- try {
82
- const res = await fetch(
83
- `/api/document?path=${encodeURIComponent(activeDocumentPath!)}`,
84
- );
85
- if (!res.ok) throw new Error(`Server error: ${res.status}`);
86
- const data = await res.json();
87
- appStore
88
- .getState()
89
- .updateDocumentContent(data.content, activeDocumentPath!);
90
- } catch (err) {
68
+ const path = activeDocumentPath;
69
+ const query = `?path=${encodeURIComponent(path)}`;
70
+ const isClean = state.document.clean;
71
+
72
+ // Fetch document content and comments in parallel so highlights
73
+ // can apply immediately when CommentProvider mounts.
74
+ const docFetch = fetch(`/api/document${query}`).then((r) => {
75
+ if (!r.ok) throw new Error(`Server error: ${r.status}`);
76
+ return r.json();
77
+ });
78
+
79
+ const commentsFetch = isClean
80
+ ? fetch(`/api/comments${query}`, { method: "DELETE" }).then(
81
+ () => [] as unknown[],
82
+ )
83
+ : fetch(`/api/comments${query}`)
84
+ .then((r) => (r.ok ? r.json() : { comments: [] }))
85
+ .then((d) => d.comments || []);
86
+
87
+ Promise.all([docFetch, commentsFetch]).then(
88
+ ([docData, comments]) => {
89
+ // Set comments BEFORE content: content triggers CommentProvider mount,
90
+ // so comments must already be in the store to avoid a wasted empty render.
91
+ appStore.getState().setComments(comments, path);
92
+ appStore.getState().updateDocumentContent(docData.content, path);
93
+ },
94
+ (err) => {
91
95
  setError(
92
96
  err instanceof Error ? err.message : "Failed to load document",
93
97
  );
94
- }
95
- }
96
- loadContent();
98
+ },
99
+ );
97
100
  }, [activeDocumentPath]);
98
101
 
99
- // SSE: register new documents without stealing focus; reload loaded docs on updates
102
+ // SSE: register new documents without stealing focus; reload already-loaded docs on updates
100
103
  useEffect(() => {
101
104
  const eventSource = new EventSource("/api/document/stream");
102
105
  eventSource.onmessage = async (e) => {
@@ -105,8 +108,7 @@ export function useDocument(): UseDocumentResult {
105
108
  if (data.type === "document-added" && data.path) {
106
109
  appStore.getState().openDocument(
107
110
  {
108
- content: "", // Lazy-loaded when tab activated
109
- type: data.fileType,
111
+ content: "",
110
112
  filePath: data.path,
111
113
  fileName: data.fileName,
112
114
  clean: false,
@@ -116,9 +118,8 @@ export function useDocument(): UseDocumentResult {
116
118
  return;
117
119
  }
118
120
  if (data.type === "document-updated" && data.path) {
119
- // Only reload if content was previously loaded
120
121
  const state = appStore.getState().documents.get(data.path);
121
- if (!state || !state.document.content) return;
122
+ if (!state?.document.content) return;
122
123
 
123
124
  const res = await fetch(
124
125
  `/api/document?path=${encodeURIComponent(data.path)}`,
@@ -2,13 +2,13 @@ import { renderHook } from "@testing-library/react";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { useHeadings } from "./useHeadings";
4
4
 
5
- describe("useHeadings - markdown", () => {
5
+ describe("useHeadings", () => {
6
6
  it("extracts basic headings", () => {
7
7
  const content = `# Heading 1
8
8
  ## Heading 2
9
9
  ### Heading 3`;
10
10
 
11
- const { result } = renderHook(() => useHeadings(content, "markdown"));
11
+ const { result } = renderHook(() => useHeadings(content));
12
12
 
13
13
  expect(result.current).toEqual([
14
14
  { id: "heading-1", text: "Heading 1", level: 1 },
@@ -22,7 +22,7 @@ describe("useHeadings - markdown", () => {
22
22
  ## Section
23
23
  ## Section`;
24
24
 
25
- const { result } = renderHook(() => useHeadings(content, "markdown"));
25
+ const { result } = renderHook(() => useHeadings(content));
26
26
 
27
27
  expect(result.current).toEqual([
28
28
  { id: "section", text: "Section", level: 2 },
@@ -41,7 +41,7 @@ echo "hello"
41
41
 
42
42
  ## Another Real Heading`;
43
43
 
44
- const { result } = renderHook(() => useHeadings(content, "markdown"));
44
+ const { result } = renderHook(() => useHeadings(content));
45
45
 
46
46
  expect(result.current).toEqual([
47
47
  { id: "real-heading", text: "Real Heading", level: 1 },
@@ -60,7 +60,7 @@ def foo():
60
60
 
61
61
  ## Another Real Heading`;
62
62
 
63
- const { result } = renderHook(() => useHeadings(content, "markdown"));
63
+ const { result } = renderHook(() => useHeadings(content));
64
64
 
65
65
  expect(result.current).toEqual([
66
66
  { id: "real-heading", text: "Real Heading", level: 1 },
@@ -83,7 +83,7 @@ def foo():
83
83
 
84
84
  ## Results`;
85
85
 
86
- const { result } = renderHook(() => useHeadings(content, "markdown"));
86
+ const { result } = renderHook(() => useHeadings(content));
87
87
 
88
88
  expect(result.current).toEqual([
89
89
  { id: "introduction", text: "Introduction", level: 1 },
@@ -102,7 +102,7 @@ npx readit document.md --port 3000
102
102
 
103
103
  ## Usage`;
104
104
 
105
- const { result } = renderHook(() => useHeadings(content, "markdown"));
105
+ const { result } = renderHook(() => useHeadings(content));
106
106
 
107
107
  expect(result.current).toEqual([
108
108
  { id: "setup", text: "Setup", level: 1 },
@@ -111,49 +111,7 @@ npx readit document.md --port 3000
111
111
  });
112
112
 
113
113
  it("returns empty array for null content", () => {
114
- const { result } = renderHook(() => useHeadings(null, "markdown"));
114
+ const { result } = renderHook(() => useHeadings(null));
115
115
  expect(result.current).toEqual([]);
116
116
  });
117
-
118
- it("returns empty array for null type", () => {
119
- const { result } = renderHook(() => useHeadings("# Heading", null));
120
- expect(result.current).toEqual([]);
121
- });
122
- });
123
-
124
- describe("useHeadings - html", () => {
125
- it("extracts basic headings", () => {
126
- const content = `<h1>Heading 1</h1>
127
- <h2>Heading 2</h2>
128
- <h3>Heading 3</h3>`;
129
-
130
- const { result } = renderHook(() => useHeadings(content, "html"));
131
-
132
- expect(result.current).toEqual([
133
- { id: "heading-1", text: "Heading 1", level: 1 },
134
- { id: "heading-2", text: "Heading 2", level: 2 },
135
- { id: "heading-3", text: "Heading 3", level: 3 },
136
- ]);
137
- });
138
-
139
- it("uses existing id attribute", () => {
140
- const content = `<h1 id="custom-id">Heading 1</h1>`;
141
-
142
- const { result } = renderHook(() => useHeadings(content, "html"));
143
-
144
- expect(result.current).toEqual([
145
- { id: "custom-id", text: "Heading 1", level: 1 },
146
- ]);
147
- });
148
-
149
- it("decodes HTML entities", () => {
150
- const content = `<h1>Hello &amp; World</h1>`;
151
-
152
- const { result } = renderHook(() => useHeadings(content, "html"));
153
-
154
- // Note: & is stripped, leaving "Hello World" → "hello-world" (hyphens collapsed)
155
- expect(result.current).toEqual([
156
- { id: "hello-world", text: "Hello & World", level: 1 },
157
- ]);
158
- });
159
117
  });