@peaske7/readit 0.1.8 → 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 (221) 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 -5
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -710
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +130 -0
  12. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  13. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  14. package/e2e/comments.spec.ts +14 -58
  15. package/e2e/document-load.spec.ts +1 -23
  16. package/e2e/export.spec.ts +4 -4
  17. package/e2e/perf/add-comment.spec.ts +116 -0
  18. package/e2e/perf/fixtures/generate.ts +327 -0
  19. package/e2e/perf/initial-load.spec.ts +49 -0
  20. package/e2e/perf/perf.setup.ts +23 -0
  21. package/e2e/perf/perf.teardown.ts +9 -0
  22. package/e2e/perf/screenshot-final.png +0 -0
  23. package/e2e/perf/scroll.spec.ts +39 -0
  24. package/e2e/perf/tab-switch.spec.ts +69 -0
  25. package/e2e/perf/text-selection.spec.ts +119 -0
  26. package/e2e/perf/utils/metrics.ts +350 -0
  27. package/e2e/perf/utils/perf-cli.ts +86 -0
  28. package/e2e/persistence-file.spec.ts +41 -26
  29. package/e2e/utils/selection.ts +17 -73
  30. package/go/cmd/readit/main.go +416 -0
  31. package/go/go.mod +20 -0
  32. package/go/go.sum +41 -0
  33. package/go/internal/server/anchor.go +302 -0
  34. package/go/internal/server/anchor_test.go +111 -0
  35. package/go/internal/server/comments.go +390 -0
  36. package/go/internal/server/documents.go +113 -0
  37. package/go/internal/server/embed.go +17 -0
  38. package/go/internal/server/headings.go +33 -0
  39. package/go/internal/server/headings_test.go +75 -0
  40. package/go/internal/server/htmltext.go +123 -0
  41. package/go/internal/server/markdown.go +157 -0
  42. package/go/internal/server/markdown_bench_test.go +42 -0
  43. package/go/internal/server/markdown_test.go +79 -0
  44. package/go/internal/server/server.go +453 -0
  45. package/go/internal/server/server_bench_test.go +122 -0
  46. package/go/internal/server/settings.go +110 -0
  47. package/go/internal/server/sse.go +140 -0
  48. package/go/internal/server/storage.go +275 -0
  49. package/go/internal/server/storage_test.go +118 -0
  50. package/go/internal/server/template.go +66 -0
  51. package/go/internal/server/types.go +101 -0
  52. package/go/internal/server/watcher.go +74 -0
  53. package/index.html +4 -14
  54. package/nvim-readit/lua/readit/health.lua +64 -0
  55. package/nvim-readit/lua/readit/init.lua +463 -0
  56. package/nvim-readit/plugin/readit.lua +19 -0
  57. package/package.json +24 -41
  58. package/playwright.config.ts +12 -0
  59. package/shell/_readit +158 -0
  60. package/shell/readit.zsh +87 -0
  61. package/src/App.svelte +881 -0
  62. package/src/{cli/index.ts → cli.ts} +216 -70
  63. package/src/components/ActionsMenu.svelte +95 -0
  64. package/src/components/CommentBadge.svelte +67 -0
  65. package/src/components/CommentErrorBanner.svelte +33 -0
  66. package/src/components/CommentInput.svelte +75 -0
  67. package/src/components/CommentListItem.svelte +95 -0
  68. package/src/components/CommentManager.svelte +129 -0
  69. package/src/components/CommentNav.svelte +109 -0
  70. package/src/components/DocumentViewer.svelte +218 -0
  71. package/src/components/FloatingComment.svelte +107 -0
  72. package/src/components/Header.svelte +76 -0
  73. package/src/components/InlineEditor.svelte +72 -0
  74. package/src/components/MarginNote.svelte +167 -0
  75. package/src/components/MarginNotesContainer.svelte +33 -0
  76. package/src/components/RawModal.svelte +126 -0
  77. package/src/components/ReanchorConfirm.svelte +30 -0
  78. package/src/components/SettingsModal.svelte +220 -0
  79. package/src/components/ShortcutCapture.svelte +82 -0
  80. package/src/components/ShortcutList.svelte +145 -0
  81. package/src/components/TabBar.svelte +52 -0
  82. package/src/components/TableOfContents.svelte +125 -0
  83. package/src/components/ui/ActionLink.svelte +40 -0
  84. package/src/components/ui/Button.svelte +53 -0
  85. package/src/components/ui/Dialog.svelte +97 -0
  86. package/src/components/ui/DropdownMenu.svelte +85 -0
  87. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  88. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  89. package/src/components/ui/Text.svelte +42 -0
  90. package/src/env.d.ts +6 -0
  91. package/src/index.css +36 -166
  92. package/src/lib/__fixtures__/bench-data.ts +1 -54
  93. package/src/lib/anchor.bench.ts +47 -68
  94. package/src/lib/anchor.test.ts +5 -9
  95. package/src/lib/anchor.ts +9 -93
  96. package/src/lib/comment-storage.bench.ts +6 -20
  97. package/src/lib/comment-storage.test.ts +45 -37
  98. package/src/lib/comment-storage.ts +23 -64
  99. package/src/lib/export.bench.ts +9 -23
  100. package/src/lib/export.ts +7 -14
  101. package/src/lib/headings.test.ts +103 -0
  102. package/src/lib/headings.ts +44 -0
  103. package/src/lib/highlight/core.test.ts +1 -6
  104. package/src/lib/highlight/dom.ts +53 -280
  105. package/src/lib/highlight/highlight-registry.ts +221 -0
  106. package/src/lib/highlight/highlight.bench.ts +92 -0
  107. package/src/lib/highlight/highlighter.ts +122 -302
  108. package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
  109. package/src/lib/highlight/types.ts +0 -40
  110. package/src/lib/html-text.test.ts +162 -0
  111. package/src/lib/html-text.ts +161 -0
  112. package/src/lib/i18n/en.ts +13 -36
  113. package/src/lib/i18n/ja.ts +14 -37
  114. package/src/lib/i18n/types.ts +13 -36
  115. package/src/lib/margin-layout.bench.ts +48 -15
  116. package/src/lib/margin-layout.ts +2 -31
  117. package/src/lib/markdown-renderer.test.ts +154 -0
  118. package/src/lib/markdown-renderer.ts +177 -0
  119. package/src/lib/mermaid-config.ts +38 -0
  120. package/src/lib/mermaid-renderer.ts +162 -0
  121. package/src/lib/mermaid-worker.ts +60 -0
  122. package/src/lib/positions.ts +157 -0
  123. package/src/lib/shortcut-registry.ts +138 -103
  124. package/src/lib/utils.ts +2 -48
  125. package/src/main.ts +16 -0
  126. package/src/schema.ts +92 -0
  127. package/src/{server/index.ts → server.ts} +427 -163
  128. package/src/stores/app.svelte.ts +231 -0
  129. package/src/stores/locale.svelte.ts +46 -0
  130. package/src/stores/settings.svelte.ts +90 -0
  131. package/src/stores/shortcuts.svelte.ts +104 -0
  132. package/src/stores/ui.svelte.ts +12 -0
  133. package/src/template.ts +104 -0
  134. package/src/test-setup.ts +47 -0
  135. package/svelte.config.js +5 -0
  136. package/tsconfig.json +2 -2
  137. package/vite.config.ts +31 -3
  138. package/vscode-readit/.mcp.json +7 -0
  139. package/vscode-readit/.vscodeignore +7 -0
  140. package/vscode-readit/bun.lock +78 -0
  141. package/vscode-readit/icon.svg +10 -0
  142. package/vscode-readit/package.json +110 -0
  143. package/vscode-readit/src/extension.ts +117 -0
  144. package/vscode-readit/src/server-manager.ts +272 -0
  145. package/vscode-readit/src/webview-provider.ts +204 -0
  146. package/vscode-readit/tsconfig.json +20 -0
  147. package/e2e/fixtures/sample.html +0 -13
  148. package/src/App.tsx +0 -416
  149. package/src/components/ActionsMenu.tsx +0 -112
  150. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  151. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
  152. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  153. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  154. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
  155. package/src/components/DocumentViewer/index.ts +0 -1
  156. package/src/components/FloatingTOC.tsx +0 -61
  157. package/src/components/Header.tsx +0 -65
  158. package/src/components/InlineEditor.tsx +0 -74
  159. package/src/components/MarginNote.tsx +0 -207
  160. package/src/components/MarginNotes.tsx +0 -50
  161. package/src/components/RawModal.tsx +0 -143
  162. package/src/components/ReanchorConfirm.tsx +0 -36
  163. package/src/components/SettingsModal.tsx +0 -310
  164. package/src/components/ShortcutCapture.tsx +0 -48
  165. package/src/components/ShortcutList.tsx +0 -198
  166. package/src/components/TabBar.tsx +0 -60
  167. package/src/components/TableOfContents.tsx +0 -108
  168. package/src/components/comments/CommentBadge.tsx +0 -49
  169. package/src/components/comments/CommentInput.tsx +0 -114
  170. package/src/components/comments/CommentListItem.tsx +0 -92
  171. package/src/components/comments/CommentManager.tsx +0 -113
  172. package/src/components/comments/CommentMinimap.tsx +0 -62
  173. package/src/components/comments/CommentNav.tsx +0 -109
  174. package/src/components/ui/ActionBar.tsx +0 -16
  175. package/src/components/ui/ActionLink.tsx +0 -32
  176. package/src/components/ui/Button.tsx +0 -55
  177. package/src/components/ui/Dialog.tsx +0 -156
  178. package/src/components/ui/DropdownMenu.tsx +0 -114
  179. package/src/components/ui/SeparatorDot.tsx +0 -9
  180. package/src/components/ui/Text.tsx +0 -54
  181. package/src/contexts/CommentContext.tsx +0 -229
  182. package/src/contexts/LayoutContext.tsx +0 -88
  183. package/src/contexts/LocaleContext.tsx +0 -35
  184. package/src/hooks/useClickOutside.ts +0 -35
  185. package/src/hooks/useClipboard.ts +0 -82
  186. package/src/hooks/useCommentNavigation.ts +0 -130
  187. package/src/hooks/useComments.ts +0 -323
  188. package/src/hooks/useDocument.ts +0 -156
  189. package/src/hooks/useEditorScheme.ts +0 -51
  190. package/src/hooks/useFontPreference.ts +0 -59
  191. package/src/hooks/useHeadings.test.ts +0 -159
  192. package/src/hooks/useHeadings.ts +0 -129
  193. package/src/hooks/useKeybindings.ts +0 -108
  194. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  195. package/src/hooks/useLayoutMode.ts +0 -44
  196. package/src/hooks/useLocalePreference.ts +0 -42
  197. package/src/hooks/useReanchorMode.ts +0 -33
  198. package/src/hooks/useScrollMetrics.ts +0 -56
  199. package/src/hooks/useScrollSpy.ts +0 -81
  200. package/src/hooks/useTextSelection.ts +0 -123
  201. package/src/hooks/useThemePreference.ts +0 -66
  202. package/src/lib/context.bench.ts +0 -41
  203. package/src/lib/context.test.ts +0 -224
  204. package/src/lib/context.ts +0 -193
  205. package/src/lib/editor-links.ts +0 -59
  206. package/src/lib/highlight/colors.ts +0 -37
  207. package/src/lib/highlight/index.ts +0 -23
  208. package/src/lib/highlight/script-builder.ts +0 -485
  209. package/src/lib/html-processor.test.tsx +0 -170
  210. package/src/lib/html-processor.tsx +0 -95
  211. package/src/lib/i18n/completeness.test.ts +0 -51
  212. package/src/lib/i18n/translations.test.ts +0 -39
  213. package/src/lib/layout-constants.ts +0 -12
  214. package/src/lib/scroll.test.ts +0 -118
  215. package/src/lib/scroll.ts +0 -47
  216. package/src/lib/shortcut-registry.test.ts +0 -173
  217. package/src/lib/utils.test.ts +0 -110
  218. package/src/main.tsx +0 -13
  219. package/src/store/index.test.ts +0 -242
  220. package/src/store/index.ts +0 -254
  221. package/src/types/index.ts +0 -127
@@ -1,88 +0,0 @@
1
- import { createContext, type ReactNode, use, useMemo } from "react";
2
- import { useEditorScheme } from "../hooks/useEditorScheme";
3
- import { useFontPreference } from "../hooks/useFontPreference";
4
- import { useKeybindings } from "../hooks/useKeybindings";
5
- import { useLayoutMode } from "../hooks/useLayoutMode";
6
- import { useThemePreference } from "../hooks/useThemePreference";
7
- import type { ShortcutDefinition } from "../lib/shortcut-registry";
8
- import type {
9
- EditorScheme,
10
- FontFamily,
11
- ShortcutBinding,
12
- ThemeMode,
13
- } from "../types";
14
-
15
- interface LayoutContextValue {
16
- isFullscreen: boolean;
17
- toggleLayoutMode: () => void;
18
- fontFamily: FontFamily;
19
- setFontFamily: (font: FontFamily) => Promise<void>;
20
- editorScheme: EditorScheme;
21
- setEditorScheme: (scheme: EditorScheme) => Promise<void>;
22
- themeMode: ThemeMode;
23
- setThemeMode: (mode: ThemeMode) => void;
24
- shortcuts: ShortcutDefinition[];
25
- updateBinding: (id: string, binding: ShortcutBinding) => Promise<void>;
26
- toggleShortcutEnabled: (id: string) => Promise<void>;
27
- resetShortcutsToDefaults: () => Promise<void>;
28
- }
29
-
30
- export const LayoutContext = createContext<LayoutContextValue | null>(null);
31
-
32
- export function useLayoutContext(): LayoutContextValue {
33
- const value = use(LayoutContext);
34
- if (!value) {
35
- throw new Error("useLayoutContext must be used within a LayoutProvider");
36
- }
37
- return value;
38
- }
39
-
40
- interface LayoutProviderProps {
41
- children: ReactNode;
42
- }
43
-
44
- export function LayoutProvider({ children }: LayoutProviderProps) {
45
- const { isFullscreen, toggleLayoutMode } = useLayoutMode();
46
- const { fontFamily, setFontFamily } = useFontPreference();
47
- const { editorScheme, setEditorScheme } = useEditorScheme();
48
- const { themeMode, setThemeMode } = useThemePreference();
49
- const {
50
- shortcuts,
51
- updateBinding,
52
- toggleEnabled: toggleShortcutEnabled,
53
- resetToDefaults: resetShortcutsToDefaults,
54
- } = useKeybindings();
55
-
56
- const value = useMemo<LayoutContextValue>(
57
- () => ({
58
- isFullscreen,
59
- toggleLayoutMode,
60
- fontFamily,
61
- setFontFamily,
62
- editorScheme,
63
- setEditorScheme,
64
- themeMode,
65
- setThemeMode,
66
- shortcuts,
67
- updateBinding,
68
- toggleShortcutEnabled,
69
- resetShortcutsToDefaults,
70
- }),
71
- [
72
- isFullscreen,
73
- toggleLayoutMode,
74
- fontFamily,
75
- setFontFamily,
76
- editorScheme,
77
- setEditorScheme,
78
- themeMode,
79
- setThemeMode,
80
- shortcuts,
81
- updateBinding,
82
- toggleShortcutEnabled,
83
- resetShortcutsToDefaults,
84
- ],
85
- );
86
-
87
- return <LayoutContext value={value}>{children}</LayoutContext>;
88
- }
@@ -1,35 +0,0 @@
1
- import { createContext, type ReactNode, use, useMemo } from "react";
2
- import { useLocalePreference } from "../hooks/useLocalePreference";
3
- import { createT, type Locale, type TranslationKey } from "../lib/i18n";
4
-
5
- interface LocaleContextValue {
6
- locale: Locale;
7
- setLocale: (locale: Locale) => void;
8
- t: (key: TranslationKey, params?: Record<string, string | number>) => string;
9
- }
10
-
11
- const LocaleContext = createContext<LocaleContextValue | null>(null);
12
-
13
- export function useLocale(): LocaleContextValue {
14
- const value = use(LocaleContext);
15
- if (!value) {
16
- throw new Error("useLocale must be used within a LocaleProvider");
17
- }
18
- return value;
19
- }
20
-
21
- interface LocaleProviderProps {
22
- children: ReactNode;
23
- }
24
-
25
- export function LocaleProvider({ children }: LocaleProviderProps) {
26
- const { locale, setLocale } = useLocalePreference();
27
- const t = useMemo(() => createT(locale), [locale]);
28
-
29
- const value = useMemo<LocaleContextValue>(
30
- () => ({ locale, setLocale, t }),
31
- [locale, setLocale, t],
32
- );
33
-
34
- return <LocaleContext value={value}>{children}</LocaleContext>;
35
- }
@@ -1,35 +0,0 @@
1
- import { type RefObject, useEffect } from "react";
2
-
3
- /**
4
- * Close a dropdown/popover when clicking outside or pressing Escape.
5
- * Only attaches listeners when `active` is true.
6
- */
7
- export function useClickOutside(
8
- ref: RefObject<HTMLElement | null>,
9
- onClose: () => void,
10
- active: boolean,
11
- ): void {
12
- useEffect(() => {
13
- if (!active) return;
14
-
15
- const handleClickOutside = (e: MouseEvent) => {
16
- if (ref.current && !ref.current.contains(e.target as Node)) {
17
- onClose();
18
- }
19
- };
20
-
21
- const handleEscape = (e: KeyboardEvent) => {
22
- if (e.key === "Escape") {
23
- onClose();
24
- }
25
- };
26
-
27
- document.addEventListener("mousedown", handleClickOutside);
28
- document.addEventListener("keydown", handleEscape);
29
-
30
- return () => {
31
- document.removeEventListener("mousedown", handleClickOutside);
32
- document.removeEventListener("keydown", handleEscape);
33
- };
34
- }, [ref, onClose, active]);
35
- }
@@ -1,82 +0,0 @@
1
- import { useCallback } from "react";
2
- import { toast } from "sonner";
3
- import { extractContext, formatForLLM } from "../lib/context";
4
- import {
5
- exportCommentsAsJson,
6
- generatePrompt,
7
- generateRawText,
8
- } from "../lib/export";
9
- import type { TranslationKey } from "../lib/i18n";
10
- import { truncate } from "../lib/utils";
11
- import type { Comment, Document, Selection } from "../types";
12
-
13
- interface UseClipboardParams {
14
- comments: Comment[];
15
- document: Document | undefined;
16
- selection: Selection | undefined;
17
- clearSelection: () => void;
18
- t: (key: TranslationKey, params?: Record<string, string | number>) => string;
19
- }
20
-
21
- export function useClipboard({
22
- comments,
23
- document,
24
- selection,
25
- clearSelection,
26
- t,
27
- }: UseClipboardParams) {
28
- // Export handlers
29
- const copyAll = useCallback(() => {
30
- if (!document) return;
31
- const prompt = generatePrompt(comments, document.fileName);
32
- navigator.clipboard.writeText(prompt);
33
- toast.success(t("toast.copiedAllComments"));
34
- }, [comments, document, t]);
35
-
36
- const copyAllRaw = useCallback(() => {
37
- if (!document) return;
38
- const raw = generateRawText(comments);
39
- navigator.clipboard.writeText(raw);
40
- toast.success(t("toast.copiedAllRaw"));
41
- }, [comments, document, t]);
42
-
43
- const exportJson = useCallback(() => {
44
- if (!document) return;
45
- exportCommentsAsJson(comments, document);
46
- }, [comments, document]);
47
-
48
- // Selection copy handlers
49
- const copySelectionRaw = useCallback(() => {
50
- if (!selection) return;
51
-
52
- navigator.clipboard.writeText(selection.text);
53
- toast.success(t("toast.copied", { text: truncate(selection.text) }));
54
- clearSelection();
55
- }, [selection, clearSelection, t]);
56
-
57
- const copySelectionForLLM = useCallback(() => {
58
- if (!selection || !document) return;
59
-
60
- const context = extractContext({
61
- content: document.content,
62
- startOffset: selection.startOffset,
63
- endOffset: selection.endOffset,
64
- });
65
- const formatted = formatForLLM({
66
- context,
67
- fileName: document.fileName,
68
- });
69
-
70
- navigator.clipboard.writeText(formatted);
71
- toast.success(t("toast.copiedForLLM", { text: truncate(selection.text) }));
72
- clearSelection();
73
- }, [selection, document, clearSelection, t]);
74
-
75
- return {
76
- copyAll,
77
- copyAllRaw,
78
- exportJson,
79
- copySelectionRaw,
80
- copySelectionForLLM,
81
- };
82
- }
@@ -1,130 +0,0 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
2
- import { appStore, useAppStore } from "../store";
3
- import type { Comment } from "../types";
4
-
5
- interface UseCommentNavigationResult {
6
- currentIndex: number;
7
- hoveredCommentId: string | undefined;
8
- setHoveredCommentId: (id: string | undefined) => void;
9
- navigateToComment: (commentId: string) => void;
10
- navigatePrevious: () => void;
11
- navigateNext: () => void;
12
- }
13
-
14
- /**
15
- * Manage comment navigation with cycling, keyboard shortcuts, and scroll-to-comment.
16
- * Handles Alt+↑/↓ keyboard navigation.
17
- */
18
- export function useCommentNavigation(
19
- sortedComments: Comment[],
20
- ): UseCommentNavigationResult {
21
- const [currentIndex, setCurrentIndex] = useState(0);
22
- const hoveredCommentId = useAppStore(
23
- (s) => s.getActiveDocumentState()?.hoveredCommentId,
24
- );
25
- const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
26
- undefined,
27
- );
28
-
29
- // Keep a ref to sortedComments so navigation callbacks stay stable
30
- const sortedRef = useRef(sortedComments);
31
- sortedRef.current = sortedComments;
32
-
33
- // Cleanup hover timeout on unmount
34
- useEffect(() => {
35
- return () => clearTimeout(hoverTimeoutRef.current);
36
- }, []);
37
-
38
- // Clamp index when comments are removed (derived during render, no effect needed)
39
- const clampedIndex =
40
- sortedComments.length === 0
41
- ? 0
42
- : Math.min(currentIndex, sortedComments.length - 1);
43
- if (clampedIndex !== currentIndex) {
44
- setCurrentIndex(clampedIndex);
45
- }
46
-
47
- // Update DOM data-focused attributes imperatively
48
- const updateFocusedMarks = useCallback((commentId: string | undefined) => {
49
- const marks = window.document.querySelectorAll("mark[data-comment-id]");
50
- for (const mark of marks) {
51
- const id = mark.getAttribute("data-comment-id");
52
- if (id === commentId) {
53
- mark.setAttribute("data-focused", "true");
54
- } else {
55
- mark.removeAttribute("data-focused");
56
- }
57
- }
58
- }, []);
59
-
60
- const setHoveredCommentId = useCallback(
61
- (id: string | undefined) => {
62
- appStore.getState().setHoveredCommentId(id);
63
- updateFocusedMarks(id);
64
- },
65
- [updateFocusedMarks],
66
- );
67
-
68
- // Navigate to a comment by scrolling its highlight into view
69
- const navigateToComment = useCallback(
70
- (commentId: string) => {
71
- const selector = `mark[data-comment-id="${commentId}"]`;
72
-
73
- const scrollAndHighlight = (element: Element) => {
74
- element.scrollIntoView({ behavior: "smooth", block: "center" });
75
- setHoveredCommentId(commentId);
76
- clearTimeout(hoverTimeoutRef.current);
77
- hoverTimeoutRef.current = setTimeout(
78
- () => setHoveredCommentId(undefined),
79
- 1500,
80
- );
81
- };
82
-
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);
95
- }
96
- },
97
- [setHoveredCommentId],
98
- );
99
-
100
- // Navigate to previous comment (cycles to last when at first)
101
- const navigatePrevious = useCallback(() => {
102
- const sc = sortedRef.current;
103
- if (sc.length === 0) return;
104
- setCurrentIndex((prev) => {
105
- const newIndex = prev === 0 ? sc.length - 1 : prev - 1;
106
- navigateToComment(sc[newIndex].id);
107
- return newIndex;
108
- });
109
- }, [navigateToComment]);
110
-
111
- // Navigate to next comment (cycles to first when at last)
112
- const navigateNext = useCallback(() => {
113
- const sc = sortedRef.current;
114
- if (sc.length === 0) return;
115
- setCurrentIndex((prev) => {
116
- const newIndex = prev === sc.length - 1 ? 0 : prev + 1;
117
- navigateToComment(sc[newIndex].id);
118
- return newIndex;
119
- });
120
- }, [navigateToComment]);
121
-
122
- return {
123
- currentIndex: clampedIndex,
124
- hoveredCommentId,
125
- setHoveredCommentId,
126
- navigateToComment,
127
- navigatePrevious,
128
- navigateNext,
129
- };
130
- }
@@ -1,323 +0,0 @@
1
- import { useCallback, useEffect, useRef } from "react";
2
- import { appStore, useAppStore } from "../store";
3
- import { AnchorConfidences, type Comment } from "../types";
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
- /**
30
- * Hook for managing comments with optimistic updates.
31
- * State lives in the Zustand store; this hook coordinates API mutations.
32
- */
33
- export function useComments(
34
- filePath: string | null,
35
- options: UseCommentsOptions = {},
36
- ): UseCommentsResult {
37
- const { clean = false } = options;
38
-
39
- // Read comments and error from the store
40
- const comments = useAppStore(
41
- (s) => s.documents.get(filePath ?? "")?.comments ?? [],
42
- );
43
- const error = useAppStore(
44
- (s) => s.documents.get(filePath ?? "")?.commentsError ?? undefined,
45
- );
46
-
47
- // Track pending operations for rollback on error
48
- const pendingOperations = useRef<Map<string, Comment[]>>(new Map());
49
-
50
- // Capture filePath at call time for stable closures
51
- const filePathRef = useRef(filePath);
52
- filePathRef.current = filePath;
53
-
54
- /**
55
- * Execute an optimistic mutation with automatic rollback on error.
56
- */
57
- const executeMutation = useCallback(
58
- async <T>({
59
- operationId,
60
- optimisticUpdate,
61
- apiCall,
62
- onSuccess,
63
- errorMessage,
64
- }: {
65
- operationId: string;
66
- optimisticUpdate: (prev: Comment[]) => Comment[];
67
- apiCall: () => Promise<T>;
68
- onSuccess?: (result: T, prev: Comment[]) => Comment[];
69
- errorMessage: string;
70
- }) => {
71
- const fp = filePathRef.current;
72
- if (!fp) return;
73
-
74
- // Read current comments from store for rollback
75
- const currentDocState = appStore.getState().documents.get(fp);
76
- const previousComments = [...(currentDocState?.comments ?? [])];
77
- pendingOperations.current.set(operationId, previousComments);
78
-
79
- // Apply optimistic update
80
- appStore.getState().setComments(optimisticUpdate(previousComments), fp);
81
- appStore.getState().setCommentsError(null, fp);
82
-
83
- try {
84
- const result = await apiCall();
85
-
86
- // Apply server response transformation if provided
87
- if (onSuccess) {
88
- const current = appStore.getState().documents.get(fp)?.comments ?? [];
89
- appStore.getState().setComments(onSuccess(result, current), fp);
90
- }
91
- } catch (err) {
92
- console.error(`${errorMessage}:`, err);
93
- appStore
94
- .getState()
95
- .setCommentsError(
96
- err instanceof Error ? err.message : errorMessage,
97
- fp,
98
- );
99
-
100
- // Rollback on error
101
- const rollback = pendingOperations.current.get(operationId);
102
- if (rollback) {
103
- appStore.getState().setComments(rollback, fp);
104
- }
105
- } finally {
106
- pendingOperations.current.delete(operationId);
107
- }
108
- },
109
- [],
110
- );
111
-
112
- // Build path-scoped API URL
113
- const pathQuery = useCallback((base: string) => {
114
- const fp = filePathRef.current;
115
- if (!fp) return base;
116
- return `${base}?path=${encodeURIComponent(fp)}`;
117
- }, []);
118
-
119
- // Load comments from API
120
- useEffect(() => {
121
- if (!filePath) return;
122
-
123
- const loadComments = async () => {
124
- appStore.getState().setCommentsError(null, filePath);
125
- const query = `?path=${encodeURIComponent(filePath)}`;
126
-
127
- try {
128
- // If clean flag is set, clear comments first
129
- if (clean) {
130
- await fetch(`/api/comments${query}`, { method: "DELETE" });
131
- appStore.getState().setComments([], filePath);
132
- return;
133
- }
134
-
135
- const response = await fetch(`/api/comments${query}`);
136
- if (!response.ok) {
137
- throw new Error(`Failed to load comments: ${response.statusText}`);
138
- }
139
-
140
- const data = await response.json();
141
- appStore.getState().setComments(data.comments || [], filePath);
142
- } catch (err) {
143
- console.error("Failed to load comments:", err);
144
- appStore
145
- .getState()
146
- .setCommentsError(
147
- err instanceof Error ? err.message : "Failed to load comments",
148
- filePath,
149
- );
150
- appStore.getState().setComments([], filePath);
151
- }
152
- };
153
-
154
- loadComments();
155
- }, [filePath, clean]);
156
-
157
- const addComment = useCallback(
158
- (
159
- selectedText: string,
160
- commentText: string,
161
- startOffset: number,
162
- endOffset: number,
163
- ) => {
164
- const tempId = `temp-${crypto.randomUUID()}`;
165
- const optimisticComment: Comment = {
166
- id: tempId,
167
- selectedText,
168
- comment: commentText.trim(),
169
- createdAt: new Date().toISOString(),
170
- startOffset,
171
- endOffset,
172
- };
173
-
174
- executeMutation({
175
- operationId: tempId,
176
- optimisticUpdate: (prev) => [...prev, optimisticComment],
177
- apiCall: async () => {
178
- const response = await fetch(pathQuery("/api/comments"), {
179
- method: "POST",
180
- headers: { "Content-Type": "application/json" },
181
- body: JSON.stringify({
182
- selectedText,
183
- comment: commentText.trim(),
184
- startOffset,
185
- endOffset,
186
- }),
187
- });
188
-
189
- if (!response.ok) {
190
- throw new Error(`Failed to add comment: ${response.statusText}`);
191
- }
192
-
193
- return response.json();
194
- },
195
- onSuccess: (data, prev) =>
196
- prev.map((c) => (c.id === tempId ? data.comment : c)),
197
- errorMessage: "Failed to add comment",
198
- });
199
- },
200
- [executeMutation, pathQuery],
201
- );
202
-
203
- const deleteComment = useCallback(
204
- (id: string) => {
205
- executeMutation({
206
- operationId: `delete-${id}`,
207
- optimisticUpdate: (prev) => prev.filter((c) => c.id !== id),
208
- apiCall: async () => {
209
- const response = await fetch(pathQuery(`/api/comments/${id}`), {
210
- method: "DELETE",
211
- });
212
-
213
- if (!response.ok) {
214
- throw new Error(`Failed to delete comment: ${response.statusText}`);
215
- }
216
- },
217
- errorMessage: "Failed to delete comment",
218
- });
219
- },
220
- [executeMutation, pathQuery],
221
- );
222
-
223
- const deleteAll = useCallback(() => {
224
- executeMutation({
225
- operationId: "delete-all",
226
- optimisticUpdate: () => [],
227
- apiCall: async () => {
228
- const response = await fetch(pathQuery("/api/comments"), {
229
- method: "DELETE",
230
- });
231
- if (!response.ok) {
232
- throw new Error(
233
- `Failed to delete all comments: ${response.statusText}`,
234
- );
235
- }
236
- },
237
- errorMessage: "Failed to delete all comments",
238
- });
239
- }, [executeMutation, pathQuery]);
240
-
241
- const editComment = useCallback(
242
- (id: string, newText: string) => {
243
- const trimmed = newText.trim();
244
- if (!trimmed) return;
245
-
246
- executeMutation({
247
- operationId: `edit-${id}`,
248
- optimisticUpdate: (prev) =>
249
- prev.map((c) => (c.id === id ? { ...c, comment: trimmed } : c)),
250
- apiCall: async () => {
251
- const response = await fetch(pathQuery(`/api/comments/${id}`), {
252
- method: "PUT",
253
- headers: { "Content-Type": "application/json" },
254
- body: JSON.stringify({ comment: trimmed }),
255
- });
256
-
257
- if (!response.ok) {
258
- throw new Error(`Failed to update comment: ${response.statusText}`);
259
- }
260
- },
261
- errorMessage: "Failed to edit comment",
262
- });
263
- },
264
- [executeMutation, pathQuery],
265
- );
266
-
267
- const reanchorComment = useCallback(
268
- (
269
- id: string,
270
- selectedText: string,
271
- startOffset: number,
272
- endOffset: number,
273
- ) => {
274
- executeMutation({
275
- operationId: `reanchor-${id}`,
276
- optimisticUpdate: (prev) =>
277
- prev.map((c) =>
278
- c.id === id
279
- ? {
280
- ...c,
281
- selectedText,
282
- startOffset,
283
- endOffset,
284
- anchorConfidence: AnchorConfidences.EXACT,
285
- }
286
- : c,
287
- ),
288
- apiCall: async () => {
289
- const response = await fetch(
290
- pathQuery(`/api/comments/${id}/reanchor`),
291
- {
292
- method: "PUT",
293
- headers: { "Content-Type": "application/json" },
294
- body: JSON.stringify({ selectedText, startOffset, endOffset }),
295
- },
296
- );
297
-
298
- if (!response.ok) {
299
- throw new Error(
300
- `Failed to re-anchor comment: ${response.statusText}`,
301
- );
302
- }
303
-
304
- return response.json();
305
- },
306
- onSuccess: (data, prev) =>
307
- prev.map((c) => (c.id === id ? data.comment : c)),
308
- errorMessage: "Failed to re-anchor comment",
309
- });
310
- },
311
- [executeMutation, pathQuery],
312
- );
313
-
314
- return {
315
- comments,
316
- error,
317
- addComment,
318
- deleteComment,
319
- deleteAll,
320
- editComment,
321
- reanchorComment,
322
- };
323
- }