@peaske7/readit 0.2.0 → 0.2.1

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 (173) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +118 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +881 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +218 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/RawModal.svelte +126 -0
  68. package/src/components/ReanchorConfirm.svelte +30 -0
  69. package/src/components/SettingsModal.svelte +220 -0
  70. package/src/components/ShortcutCapture.svelte +82 -0
  71. package/src/components/ShortcutList.svelte +145 -0
  72. package/src/components/TabBar.svelte +52 -0
  73. package/src/components/TableOfContents.svelte +125 -0
  74. package/src/components/ui/ActionLink.svelte +40 -0
  75. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  76. package/src/components/ui/Dialog.svelte +97 -0
  77. package/src/components/ui/DropdownMenu.svelte +85 -0
  78. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  79. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  80. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  81. package/src/env.d.ts +6 -0
  82. package/src/index.css +36 -166
  83. package/src/lib/__fixtures__/bench-data.ts +0 -13
  84. package/src/lib/anchor.bench.ts +1 -12
  85. package/src/lib/anchor.test.ts +0 -8
  86. package/src/lib/anchor.ts +0 -4
  87. package/src/lib/comment-storage.bench.ts +49 -0
  88. package/src/lib/comment-storage.test.ts +41 -33
  89. package/src/lib/comment-storage.ts +21 -18
  90. package/src/lib/export.bench.ts +21 -0
  91. package/src/lib/export.ts +0 -1
  92. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  93. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  94. package/src/lib/highlight/core.test.ts +0 -5
  95. package/src/lib/highlight/dom.ts +52 -216
  96. package/src/lib/highlight/highlight-registry.ts +221 -0
  97. package/src/lib/highlight/highlight.bench.ts +92 -0
  98. package/src/lib/highlight/highlighter.ts +112 -132
  99. package/src/lib/highlight/resolver.ts +5 -79
  100. package/src/lib/highlight/types.ts +0 -5
  101. package/src/lib/html-text.test.ts +162 -0
  102. package/src/lib/html-text.ts +161 -0
  103. package/src/lib/i18n/en.ts +26 -0
  104. package/src/lib/i18n/ja.ts +26 -0
  105. package/src/lib/i18n/types.ts +25 -0
  106. package/src/lib/margin-layout.bench.ts +61 -0
  107. package/src/lib/margin-layout.ts +0 -7
  108. package/src/lib/markdown-renderer.test.ts +154 -0
  109. package/src/lib/markdown-renderer.ts +177 -0
  110. package/src/lib/mermaid-config.ts +38 -0
  111. package/src/lib/mermaid-renderer.ts +162 -0
  112. package/src/lib/mermaid-worker.ts +60 -0
  113. package/src/lib/positions.ts +31 -24
  114. package/src/lib/shortcut-registry.ts +244 -0
  115. package/src/lib/utils.ts +0 -29
  116. package/src/main.ts +16 -0
  117. package/src/schema.ts +16 -5
  118. package/src/server.ts +355 -91
  119. package/src/stores/app.svelte.ts +231 -0
  120. package/src/stores/locale.svelte.ts +46 -0
  121. package/src/stores/settings.svelte.ts +90 -0
  122. package/src/stores/shortcuts.svelte.ts +104 -0
  123. package/src/stores/ui.svelte.ts +12 -0
  124. package/src/template.ts +104 -0
  125. package/src/test-setup.ts +47 -0
  126. package/svelte.config.js +5 -0
  127. package/tsconfig.json +2 -2
  128. package/vite.config.ts +23 -3
  129. package/vscode-readit/.mcp.json +7 -0
  130. package/vscode-readit/.vscodeignore +7 -0
  131. package/vscode-readit/bun.lock +78 -0
  132. package/vscode-readit/icon.svg +10 -0
  133. package/vscode-readit/package.json +110 -0
  134. package/vscode-readit/src/extension.ts +117 -0
  135. package/vscode-readit/src/server-manager.ts +272 -0
  136. package/vscode-readit/src/webview-provider.ts +204 -0
  137. package/vscode-readit/tsconfig.json +20 -0
  138. package/e2e/fixtures/sample.html +0 -13
  139. package/src/App.tsx +0 -368
  140. package/src/components/ActionsMenu.tsx +0 -91
  141. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  142. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  143. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  144. package/src/components/Header.tsx +0 -54
  145. package/src/components/InlineEditor.tsx +0 -74
  146. package/src/components/MarginNote.tsx +0 -185
  147. package/src/components/MarginNotes.tsx +0 -23
  148. package/src/components/RawModal.tsx +0 -144
  149. package/src/components/ReanchorConfirm.tsx +0 -36
  150. package/src/components/SettingsModal.tsx +0 -232
  151. package/src/components/TabBar.tsx +0 -60
  152. package/src/components/TableOfContents.tsx +0 -108
  153. package/src/components/comments/CommentBadge.tsx +0 -49
  154. package/src/components/comments/CommentInput.tsx +0 -86
  155. package/src/components/comments/CommentListItem.tsx +0 -90
  156. package/src/components/comments/CommentManager.tsx +0 -129
  157. package/src/components/comments/CommentNav.tsx +0 -109
  158. package/src/components/ui/ActionLink.tsx +0 -28
  159. package/src/components/ui/Dialog.tsx +0 -116
  160. package/src/components/ui/DropdownMenu.tsx +0 -158
  161. package/src/contexts/CommentContext.tsx +0 -198
  162. package/src/contexts/LocaleContext.tsx +0 -76
  163. package/src/contexts/PositionsContext.tsx +0 -16
  164. package/src/contexts/SettingsContext.tsx +0 -133
  165. package/src/hooks/useClickOutside.ts +0 -31
  166. package/src/hooks/useCommentNavigation.ts +0 -107
  167. package/src/hooks/useComments.ts +0 -311
  168. package/src/hooks/useDocument.ts +0 -157
  169. package/src/hooks/useScrollSpy.ts +0 -77
  170. package/src/hooks/useTextSelection.ts +0 -86
  171. package/src/lib/highlight/worker.ts +0 -45
  172. package/src/main.tsx +0 -13
  173. package/src/store.ts +0 -222
@@ -1,31 +0,0 @@
1
- import { type RefObject, useEffect } from "react";
2
-
3
- export function useClickOutside(
4
- ref: RefObject<HTMLElement | null>,
5
- onClose: () => void,
6
- active: boolean,
7
- ): void {
8
- useEffect(() => {
9
- if (!active) return;
10
-
11
- const handleClickOutside = (e: MouseEvent) => {
12
- if (ref.current && !ref.current.contains(e.target as Node)) {
13
- onClose();
14
- }
15
- };
16
-
17
- const handleEscape = (e: KeyboardEvent) => {
18
- if (e.key === "Escape") {
19
- onClose();
20
- }
21
- };
22
-
23
- document.addEventListener("mousedown", handleClickOutside);
24
- document.addEventListener("keydown", handleEscape);
25
-
26
- return () => {
27
- document.removeEventListener("mousedown", handleClickOutside);
28
- document.removeEventListener("keydown", handleEscape);
29
- };
30
- }, [ref, onClose, active]);
31
- }
@@ -1,107 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
2
- import type { Comment } from "../schema";
3
- import { uiStore } from "../store";
4
-
5
- interface UseCommentNavigationResult {
6
- currentIndex: number;
7
- setHoveredCommentId: (id: string | undefined) => void;
8
- navigateToComment: (commentId: string) => void;
9
- navigatePrevious: () => void;
10
- navigateNext: () => void;
11
- }
12
-
13
- export function useCommentNavigation(
14
- sortedComments: Comment[],
15
- ): UseCommentNavigationResult {
16
- const [currentIndex, setCurrentIndex] = useState(0);
17
- const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
18
- undefined,
19
- );
20
-
21
- // Keep a ref to sortedComments so navigation callbacks stay stable
22
- const sortedRef = useRef(sortedComments);
23
- sortedRef.current = sortedComments;
24
-
25
- useEffect(() => {
26
- return () => clearTimeout(hoverTimeoutRef.current);
27
- }, []);
28
-
29
- // Clamp index when comments are removed (derived during render, no effect needed)
30
- const clampedIndex =
31
- sortedComments.length === 0
32
- ? 0
33
- : Math.min(currentIndex, sortedComments.length - 1);
34
- if (clampedIndex !== currentIndex) {
35
- setCurrentIndex(clampedIndex);
36
- }
37
-
38
- const updateFocusedMarks = useCallback((commentId: string | undefined) => {
39
- const marks = window.document.querySelectorAll("mark[data-comment-id]");
40
- for (const mark of marks) {
41
- const id = mark.getAttribute("data-comment-id");
42
- if (id === commentId) {
43
- mark.setAttribute("data-focused", "true");
44
- } else {
45
- mark.removeAttribute("data-focused");
46
- }
47
- }
48
- }, []);
49
-
50
- const setHoveredCommentId = useCallback(
51
- (id: string | undefined) => {
52
- uiStore.setState({ hoveredCommentId: id });
53
- updateFocusedMarks(id);
54
- },
55
- [updateFocusedMarks],
56
- );
57
-
58
- const navigateToComment = useCallback(
59
- (commentId: string) => {
60
- const selector = `mark[data-comment-id="${commentId}"]`;
61
-
62
- const scrollAndHighlight = (element: Element) => {
63
- element.scrollIntoView({ behavior: "smooth", block: "center" });
64
- setHoveredCommentId(commentId);
65
- clearTimeout(hoverTimeoutRef.current);
66
- hoverTimeoutRef.current = setTimeout(
67
- () => setHoveredCommentId(undefined),
68
- 1500,
69
- );
70
- };
71
-
72
- const highlight = document.querySelector(selector);
73
- if (highlight) {
74
- scrollAndHighlight(highlight);
75
- }
76
- },
77
- [setHoveredCommentId],
78
- );
79
-
80
- const navigatePrevious = useCallback(() => {
81
- const sc = sortedRef.current;
82
- if (sc.length === 0) return;
83
- setCurrentIndex((prev) => {
84
- const newIndex = prev === 0 ? sc.length - 1 : prev - 1;
85
- navigateToComment(sc[newIndex].id);
86
- return newIndex;
87
- });
88
- }, [navigateToComment]);
89
-
90
- const navigateNext = useCallback(() => {
91
- const sc = sortedRef.current;
92
- if (sc.length === 0) return;
93
- setCurrentIndex((prev) => {
94
- const newIndex = prev === sc.length - 1 ? 0 : prev + 1;
95
- navigateToComment(sc[newIndex].id);
96
- return newIndex;
97
- });
98
- }, [navigateToComment]);
99
-
100
- return {
101
- currentIndex: clampedIndex,
102
- setHoveredCommentId,
103
- navigateToComment,
104
- navigatePrevious,
105
- navigateNext,
106
- };
107
- }
@@ -1,311 +0,0 @@
1
- import { useCallback, useEffect, useRef } from "react";
2
- import { AnchorConfidences, type Comment } from "../schema";
3
- import { appStore, useAppStore } from "../store";
4
-
5
- interface UseCommentsOptions {
6
- clean?: boolean;
7
- }
8
-
9
- interface UseCommentsResult {
10
- comments: Comment[];
11
- error?: string;
12
- addComment: (
13
- selectedText: string,
14
- comment: string,
15
- startOffset: number,
16
- endOffset: number,
17
- ) => void;
18
- deleteComment: (id: string) => void;
19
- deleteAll: () => void;
20
- editComment: (id: string, newText: string) => void;
21
- reanchorComment: (
22
- id: string,
23
- selectedText: string,
24
- startOffset: number,
25
- endOffset: number,
26
- ) => void;
27
- }
28
-
29
- export function useComments(
30
- filePath: string | null,
31
- options: UseCommentsOptions = {},
32
- ): UseCommentsResult {
33
- const { clean = false } = options;
34
-
35
- const comments = useAppStore(
36
- (s) => s.documents.get(filePath ?? "")?.comments ?? [],
37
- );
38
- const error = useAppStore(
39
- (s) => s.documents.get(filePath ?? "")?.commentsError ?? undefined,
40
- );
41
-
42
- const pendingOperations = useRef<Map<string, Comment[]>>(new Map());
43
-
44
- // Capture filePath at call time so callbacks stay stable across renders
45
- const filePathRef = useRef(filePath);
46
- filePathRef.current = filePath;
47
-
48
- const executeMutation = useCallback(
49
- async <T>({
50
- operationId,
51
- optimisticUpdate,
52
- apiCall,
53
- onSuccess,
54
- errorMessage,
55
- }: {
56
- operationId: string;
57
- optimisticUpdate: (prev: Comment[]) => Comment[];
58
- apiCall: () => Promise<T>;
59
- onSuccess?: (result: T, prev: Comment[]) => Comment[];
60
- errorMessage: string;
61
- }) => {
62
- const fp = filePathRef.current;
63
- if (!fp) return;
64
-
65
- const currentDocState = appStore.getState().documents.get(fp);
66
- const previousComments = [...(currentDocState?.comments ?? [])];
67
- pendingOperations.current.set(operationId, previousComments);
68
-
69
- appStore.getState().setComments(optimisticUpdate(previousComments), fp);
70
- appStore.getState().setCommentsError(null, fp);
71
-
72
- try {
73
- const result = await apiCall();
74
-
75
- if (onSuccess) {
76
- const current = appStore.getState().documents.get(fp)?.comments ?? [];
77
- appStore.getState().setComments(onSuccess(result, current), fp);
78
- }
79
- } catch (err) {
80
- console.error(`${errorMessage}:`, err);
81
- appStore
82
- .getState()
83
- .setCommentsError(
84
- err instanceof Error ? err.message : errorMessage,
85
- fp,
86
- );
87
-
88
- const rollback = pendingOperations.current.get(operationId);
89
- if (rollback) {
90
- appStore.getState().setComments(rollback, fp);
91
- }
92
- } finally {
93
- pendingOperations.current.delete(operationId);
94
- }
95
- },
96
- [],
97
- );
98
-
99
- const pathQuery = useCallback((base: string) => {
100
- const fp = filePathRef.current;
101
- if (!fp) return base;
102
- return `${base}?path=${encodeURIComponent(fp)}`;
103
- }, []);
104
-
105
- useEffect(() => {
106
- if (!filePath) return;
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
-
112
- const loadComments = async () => {
113
- appStore.getState().setCommentsError(null, filePath);
114
- const query = `?path=${encodeURIComponent(filePath)}`;
115
-
116
- try {
117
- if (clean) {
118
- await fetch(`/api/comments${query}`, { method: "DELETE" });
119
- appStore.getState().setComments([], filePath);
120
- return;
121
- }
122
-
123
- const response = await fetch(`/api/comments${query}`);
124
- if (!response.ok) {
125
- throw new Error(`Failed to load comments: ${response.statusText}`);
126
- }
127
-
128
- const data = await response.json();
129
- appStore.getState().setComments(data.comments || [], filePath);
130
- } catch (err) {
131
- console.error("Failed to load comments:", err);
132
- appStore
133
- .getState()
134
- .setCommentsError(
135
- err instanceof Error ? err.message : "Failed to load comments",
136
- filePath,
137
- );
138
- appStore.getState().setComments([], filePath);
139
- }
140
- };
141
-
142
- loadComments();
143
- }, [filePath, clean]);
144
-
145
- const addComment = useCallback(
146
- (
147
- selectedText: string,
148
- commentText: string,
149
- startOffset: number,
150
- endOffset: number,
151
- ) => {
152
- const tempId = `temp-${crypto.randomUUID()}`;
153
- const optimisticComment: Comment = {
154
- id: tempId,
155
- selectedText,
156
- comment: commentText.trim(),
157
- createdAt: new Date().toISOString(),
158
- startOffset,
159
- endOffset,
160
- };
161
-
162
- executeMutation({
163
- operationId: tempId,
164
- optimisticUpdate: (prev) => [...prev, optimisticComment],
165
- apiCall: async () => {
166
- const response = await fetch(pathQuery("/api/comments"), {
167
- method: "POST",
168
- headers: { "Content-Type": "application/json" },
169
- body: JSON.stringify({
170
- selectedText,
171
- comment: commentText.trim(),
172
- startOffset,
173
- endOffset,
174
- }),
175
- });
176
-
177
- if (!response.ok) {
178
- throw new Error(`Failed to add comment: ${response.statusText}`);
179
- }
180
-
181
- return response.json();
182
- },
183
- onSuccess: (data, prev) =>
184
- prev.map((c) => (c.id === tempId ? data.comment : c)),
185
- errorMessage: "Failed to add comment",
186
- });
187
- },
188
- [executeMutation, pathQuery],
189
- );
190
-
191
- const deleteComment = useCallback(
192
- (id: string) => {
193
- executeMutation({
194
- operationId: `delete-${id}`,
195
- optimisticUpdate: (prev) => prev.filter((c) => c.id !== id),
196
- apiCall: async () => {
197
- const response = await fetch(pathQuery(`/api/comments/${id}`), {
198
- method: "DELETE",
199
- });
200
-
201
- if (!response.ok) {
202
- throw new Error(`Failed to delete comment: ${response.statusText}`);
203
- }
204
- },
205
- errorMessage: "Failed to delete comment",
206
- });
207
- },
208
- [executeMutation, pathQuery],
209
- );
210
-
211
- const deleteAll = useCallback(() => {
212
- executeMutation({
213
- operationId: "delete-all",
214
- optimisticUpdate: () => [],
215
- apiCall: async () => {
216
- const response = await fetch(pathQuery("/api/comments"), {
217
- method: "DELETE",
218
- });
219
- if (!response.ok) {
220
- throw new Error(
221
- `Failed to delete all comments: ${response.statusText}`,
222
- );
223
- }
224
- },
225
- errorMessage: "Failed to delete all comments",
226
- });
227
- }, [executeMutation, pathQuery]);
228
-
229
- const editComment = useCallback(
230
- (id: string, newText: string) => {
231
- const trimmed = newText.trim();
232
- if (!trimmed) return;
233
-
234
- executeMutation({
235
- operationId: `edit-${id}`,
236
- optimisticUpdate: (prev) =>
237
- prev.map((c) => (c.id === id ? { ...c, comment: trimmed } : c)),
238
- apiCall: async () => {
239
- const response = await fetch(pathQuery(`/api/comments/${id}`), {
240
- method: "PUT",
241
- headers: { "Content-Type": "application/json" },
242
- body: JSON.stringify({ comment: trimmed }),
243
- });
244
-
245
- if (!response.ok) {
246
- throw new Error(`Failed to update comment: ${response.statusText}`);
247
- }
248
- },
249
- errorMessage: "Failed to edit comment",
250
- });
251
- },
252
- [executeMutation, pathQuery],
253
- );
254
-
255
- const reanchorComment = useCallback(
256
- (
257
- id: string,
258
- selectedText: string,
259
- startOffset: number,
260
- endOffset: number,
261
- ) => {
262
- executeMutation({
263
- operationId: `reanchor-${id}`,
264
- optimisticUpdate: (prev) =>
265
- prev.map((c) =>
266
- c.id === id
267
- ? {
268
- ...c,
269
- selectedText,
270
- startOffset,
271
- endOffset,
272
- anchorConfidence: AnchorConfidences.EXACT,
273
- }
274
- : c,
275
- ),
276
- apiCall: async () => {
277
- const response = await fetch(
278
- pathQuery(`/api/comments/${id}/reanchor`),
279
- {
280
- method: "PUT",
281
- headers: { "Content-Type": "application/json" },
282
- body: JSON.stringify({ selectedText, startOffset, endOffset }),
283
- },
284
- );
285
-
286
- if (!response.ok) {
287
- throw new Error(
288
- `Failed to re-anchor comment: ${response.statusText}`,
289
- );
290
- }
291
-
292
- return response.json();
293
- },
294
- onSuccess: (data, prev) =>
295
- prev.map((c) => (c.id === id ? data.comment : c)),
296
- errorMessage: "Failed to re-anchor comment",
297
- });
298
- },
299
- [executeMutation, pathQuery],
300
- );
301
-
302
- return {
303
- comments,
304
- error,
305
- addComment,
306
- deleteComment,
307
- deleteAll,
308
- editComment,
309
- reanchorComment,
310
- };
311
- }
@@ -1,157 +0,0 @@
1
- import { useCallback, useEffect, useState } from "react";
2
- import { toast } from "sonner";
3
- import type { Document } from "../schema";
4
- import { appStore, useAppStore } from "../store";
5
-
6
- interface UseDocumentResult {
7
- document: Document | null;
8
- error: string | null;
9
- isInitialized: boolean;
10
- reload: () => Promise<void>;
11
- }
12
-
13
- interface DocListItem {
14
- path: string;
15
- fileName: string;
16
- }
17
-
18
- export function useDocument(): UseDocumentResult {
19
- const [error, setError] = useState<string | null>(null);
20
- const [isInitialized, setIsInitialized] = useState(false);
21
-
22
- const activeDocumentPath = useAppStore((s) => s.activeDocumentPath);
23
-
24
- const document = useAppStore((s) => {
25
- const ds = s.getActiveDocumentState();
26
- if (!ds?.document.content) return null;
27
- return ds.document;
28
- });
29
-
30
- useEffect(() => {
31
- async function init() {
32
- try {
33
- const res = await fetch("/api/documents");
34
- if (!res.ok) throw new Error(`Server error: ${res.status}`);
35
- const data = await res.json();
36
-
37
- const clean = data.clean || false;
38
- if (data.workingDirectory) {
39
- appStore.getState().setWorkingDirectory(data.workingDirectory);
40
- }
41
- data.files.forEach((file: DocListItem, index: number) => {
42
- appStore.getState().openDocument(
43
- {
44
- content: "",
45
- filePath: file.path,
46
- fileName: file.fileName,
47
- clean,
48
- },
49
- { active: index === 0 },
50
- );
51
- });
52
- } catch (err) {
53
- setError(
54
- err instanceof Error ? err.message : "Failed to load documents",
55
- );
56
- } finally {
57
- setIsInitialized(true);
58
- }
59
- }
60
- init();
61
- }, []);
62
-
63
- useEffect(() => {
64
- if (!activeDocumentPath) return;
65
- const state = appStore.getState().documents.get(activeDocumentPath);
66
- if (!state || state.document.content) return;
67
-
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) => {
95
- setError(
96
- err instanceof Error ? err.message : "Failed to load document",
97
- );
98
- },
99
- );
100
- }, [activeDocumentPath]);
101
-
102
- // SSE: register new documents without stealing focus; reload already-loaded docs on updates
103
- useEffect(() => {
104
- const eventSource = new EventSource("/api/document/stream");
105
- eventSource.onmessage = async (e) => {
106
- try {
107
- const data = JSON.parse(e.data);
108
- if (data.type === "document-added" && data.path) {
109
- appStore.getState().openDocument(
110
- {
111
- content: "",
112
- filePath: data.path,
113
- fileName: data.fileName,
114
- clean: false,
115
- },
116
- { active: false },
117
- );
118
- return;
119
- }
120
- if (data.type === "document-updated" && data.path) {
121
- const state = appStore.getState().documents.get(data.path);
122
- if (!state?.document.content) return;
123
-
124
- const res = await fetch(
125
- `/api/document?path=${encodeURIComponent(data.path)}`,
126
- );
127
- if (res.ok) {
128
- const doc = await res.json();
129
- appStore.getState().updateDocumentContent(doc.content, data.path);
130
- }
131
- }
132
- } catch {
133
- // Ignore non-JSON messages ("connected", "ping")
134
- }
135
- };
136
- return () => eventSource.close();
137
- }, []);
138
-
139
- const reload = useCallback(async () => {
140
- if (!activeDocumentPath) return;
141
- try {
142
- const res = await fetch(
143
- `/api/document?path=${encodeURIComponent(activeDocumentPath)}`,
144
- );
145
- if (!res.ok) throw new Error(`Server error: ${res.status}`);
146
- const data = await res.json();
147
- appStore
148
- .getState()
149
- .updateDocumentContent(data.content, activeDocumentPath);
150
- toast.success("Document reloaded");
151
- } catch (err) {
152
- toast.error(err instanceof Error ? err.message : "Failed to reload");
153
- }
154
- }, [activeDocumentPath]);
155
-
156
- return { document, error, isInitialized, reload };
157
- }
@@ -1,77 +0,0 @@
1
- import { useEffect, useRef, useState } from "react";
2
-
3
- export function useScrollSpy(
4
- headingIds: string[],
5
- enabled = true,
6
- ): string | null {
7
- const [activeId, setActiveId] = useState<string | null>(null);
8
- const hasSetInitialRef = useRef(false);
9
-
10
- useEffect(() => {
11
- if (!enabled || headingIds.length === 0) {
12
- if (headingIds.length === 0) {
13
- setActiveId(null);
14
- hasSetInitialRef.current = false;
15
- }
16
- return;
17
- }
18
-
19
- const visibleHeadings = new Map<string, number>();
20
-
21
- const observer = new IntersectionObserver(
22
- (entries) => {
23
- for (const entry of entries) {
24
- const id = entry.target.id;
25
-
26
- if (entry.isIntersecting) {
27
- visibleHeadings.set(id, entry.boundingClientRect.top);
28
- } else {
29
- visibleHeadings.delete(id);
30
- }
31
- }
32
-
33
- if (visibleHeadings.size > 0) {
34
- let closestId: string | null = null;
35
- let closestDistance = Number.POSITIVE_INFINITY;
36
-
37
- for (const [id, top] of visibleHeadings) {
38
- const distance = Math.abs(top);
39
- if (distance < closestDistance) {
40
- closestDistance = distance;
41
- closestId = id;
42
- }
43
- }
44
-
45
- if (closestId) {
46
- setActiveId(closestId);
47
- }
48
- }
49
- },
50
- {
51
- // Observe when headings are in the top 30% of viewport
52
- rootMargin: "-10% 0px -70% 0px",
53
- threshold: 0,
54
- },
55
- );
56
-
57
- // Set initial active heading BEFORE starting observer
58
- // to prevent flash when observer fires first
59
- if (!hasSetInitialRef.current) {
60
- setActiveId(headingIds[0]);
61
- hasSetInitialRef.current = true;
62
- }
63
-
64
- for (const id of headingIds) {
65
- const element = document.getElementById(id);
66
- if (element) {
67
- observer.observe(element);
68
- }
69
- }
70
-
71
- return () => {
72
- observer.disconnect();
73
- };
74
- }, [headingIds, enabled]);
75
-
76
- return activeId;
77
- }