@peaske7/readit 0.1.7 → 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 +133 -178
  19. package/src/{cli/index.ts → cli.ts} +211 -107
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +78 -105
  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} +111 -81
  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
@@ -1,7 +1,7 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import type { Comment, CommentFile } from "../types";
4
+ import type { Comment, CommentFile } from "../schema";
5
5
 
6
6
  const FORMAT_VERSION = 1;
7
7
  const HASH_LENGTH = 16;
@@ -9,9 +9,6 @@ const MAX_SELECTION_LENGTH = 1000;
9
9
  const TRUNCATION_MARKER = "\n...\n";
10
10
  const ANCHOR_PREFIX_LENGTH = 200; // chars stored for anchor matching when text is truncated
11
11
 
12
- /**
13
- * Truncate very long selections to first ~500 + ... + last ~500 chars.
14
- */
15
12
  export function truncateSelection(text: string): string {
16
13
  if (text.length <= MAX_SELECTION_LENGTH) {
17
14
  return text;
@@ -27,13 +24,9 @@ export function truncateSelection(text: string): string {
27
24
  * Comments are stored in ~/.readit/comments/{absolute-path-structure}/{filename}.comments.md
28
25
  */
29
26
  export function getCommentPath(sourcePath: string): string {
30
- // Resolve to absolute path
31
27
  const absolute = path.resolve(sourcePath);
32
-
33
28
  // Remove leading slash and drive letter (Windows)
34
29
  const normalized = absolute.replace(/^\//, "").replace(/^[A-Z]:[\\/]/, "");
35
-
36
- // Get filename without extension, add .comments.md
37
30
  const ext = path.extname(normalized);
38
31
  const withoutExt = normalized.slice(0, -ext.length || undefined);
39
32
 
@@ -45,9 +38,6 @@ export function getCommentPath(sourcePath: string): string {
45
38
  );
46
39
  }
47
40
 
48
- /**
49
- * Compute SHA-256 hash of content, returning first 16 characters.
50
- */
51
41
  export function computeHash(content: string): string {
52
42
  return crypto
53
43
  .createHash("sha256")
@@ -56,18 +46,12 @@ export function computeHash(content: string): string {
56
46
  .slice(0, HASH_LENGTH);
57
47
  }
58
48
 
59
- /**
60
- * Get line number (1-indexed) for a character offset in content.
61
- */
62
49
  export function getLineNumber(content: string, offset: number): number {
63
50
  if (offset <= 0 || content.length === 0) return 1;
64
51
  const clampedOffset = Math.min(offset, content.length);
65
52
  return content.slice(0, clampedOffset).split("\n").length;
66
53
  }
67
54
 
68
- /**
69
- * Get line range string for a selection (e.g., "L42" or "L42-45").
70
- */
71
55
  export function getLineHint(
72
56
  content: string,
73
57
  startOffset: number,
@@ -75,12 +59,9 @@ export function getLineHint(
75
59
  ): string {
76
60
  const startLine = getLineNumber(content, startOffset);
77
61
  const endLine = getLineNumber(content, endOffset);
78
- return startLine === endLine ? `L${startLine}` : `L${startLine}-${endLine}`;
62
+ return startLine === endLine ? `L${startLine}` : `L${startLine}-L${endLine}`;
79
63
  }
80
64
 
81
- /**
82
- * Parse a comment file's markdown content into a CommentFile structure.
83
- */
84
65
  export function parseCommentFile(content: string): CommentFile {
85
66
  const result: CommentFile = {
86
67
  source: "",
@@ -93,7 +74,6 @@ export function parseCommentFile(content: string): CommentFile {
93
74
  return result;
94
75
  }
95
76
 
96
- // Parse YAML front matter
97
77
  const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
98
78
  if (frontMatterMatch) {
99
79
  const frontMatter = frontMatterMatch[1];
@@ -105,7 +85,6 @@ export function parseCommentFile(content: string): CommentFile {
105
85
  if (hashMatch) result.hash = hashMatch[1].trim();
106
86
  if (versionMatch) result.version = Number.parseInt(versionMatch[1], 10);
107
87
 
108
- // Validate version compatibility
109
88
  if (result.version > FORMAT_VERSION) {
110
89
  throw new Error(
111
90
  `Comment file requires readit v${result.version} or higher. ` +
@@ -114,7 +93,6 @@ export function parseCommentFile(content: string): CommentFile {
114
93
  }
115
94
  }
116
95
 
117
- // Remove front matter and split by separator
118
96
  const bodyContent = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
119
97
  const blocks = bodyContent.split(/\n---\n/).filter((block) => block.trim());
120
98
 
@@ -128,9 +106,6 @@ export function parseCommentFile(content: string): CommentFile {
128
106
  return result;
129
107
  }
130
108
 
131
- /**
132
- * Parse a single comment block.
133
- */
134
109
  function parseCommentBlock(block: string): Comment | undefined {
135
110
  // Extract metadata from HTML comment: <!-- c:{id}|{lineHint}|{timestamp} -->
136
111
  const metadataMatch = block.match(/<!--\s*c:([^|]+)\|([^|]+)\|([^>]+)\s*-->/);
@@ -144,19 +119,16 @@ function parseCommentBlock(block: string): Comment | undefined {
144
119
  const anchorMatch = block.match(/<!--\s*anchor:(.+?)\s*-->/);
145
120
  const anchorPrefix = anchorMatch ? anchorMatch[1] : undefined;
146
121
 
147
- // Extract selected text from blockquote
148
122
  const blockquoteMatch = block.match(/^>\s*(.+(?:\n>\s*.+)*)$/m);
149
123
  if (!blockquoteMatch) {
150
124
  return undefined;
151
125
  }
152
126
 
153
- // Remove the "> " prefix from each line
154
127
  const selectedText = blockquoteMatch[1]
155
128
  .split("\n")
156
129
  .map((line) => line.replace(/^>\s*/, ""))
157
130
  .join("\n");
158
131
 
159
- // Extract comment body (everything after blockquote)
160
132
  const afterBlockquote = block.slice(
161
133
  block.indexOf(blockquoteMatch[0]) + blockquoteMatch[0].length,
162
134
  );
@@ -175,13 +147,9 @@ function parseCommentBlock(block: string): Comment | undefined {
175
147
  };
176
148
  }
177
149
 
178
- /**
179
- * Serialize a CommentFile structure to markdown content.
180
- */
181
150
  export function serializeComments(file: CommentFile): string {
182
151
  const lines: string[] = [];
183
152
 
184
- // YAML front matter
185
153
  lines.push("---");
186
154
  lines.push(`source: ${file.source}`);
187
155
  lines.push(`hash: ${file.hash}`);
@@ -189,7 +157,6 @@ export function serializeComments(file: CommentFile): string {
189
157
  lines.push("---");
190
158
  lines.push("");
191
159
 
192
- // Comments
193
160
  for (const comment of file.comments) {
194
161
  lines.push(serializeComment(comment));
195
162
  lines.push("");
@@ -200,28 +167,21 @@ export function serializeComments(file: CommentFile): string {
200
167
  return lines.join("\n");
201
168
  }
202
169
 
203
- /**
204
- * Serialize a single comment to markdown block.
205
- */
206
170
  function serializeComment(comment: Comment): string {
207
171
  const lines: string[] = [];
208
172
 
209
- // Metadata as HTML comment
210
173
  const lineHint = comment.lineHint || "L0";
211
174
  lines.push(`<!-- c:${comment.id}|${lineHint}|${comment.createdAt} -->`);
212
175
 
213
- // Anchor prefix for long selections (used for anchor matching when text is truncated)
214
176
  if (comment.anchorPrefix) {
215
177
  lines.push(`<!-- anchor:${comment.anchorPrefix} -->`);
216
178
  }
217
179
 
218
- // Selected text as blockquote
219
180
  const quotedLines = comment.selectedText
220
181
  .split("\n")
221
182
  .map((line) => `> ${line}`);
222
183
  lines.push(...quotedLines);
223
184
 
224
- // Comment body
225
185
  if (comment.comment) {
226
186
  lines.push("");
227
187
  lines.push(comment.comment);
@@ -230,9 +190,6 @@ function serializeComment(comment: Comment): string {
230
190
  return lines.join("\n");
231
191
  }
232
192
 
233
- /**
234
- * Create a new comment with a generated ID and current timestamp.
235
- */
236
193
  export function createComment(
237
194
  selectedText: string,
238
195
  commentText: string,
@@ -255,7 +212,6 @@ export function createComment(
255
212
  startOffset,
256
213
  endOffset,
257
214
  lineHint,
258
- // Store first N chars for anchor matching when text is truncated
259
215
  anchorPrefix: needsTruncation
260
216
  ? selectedText.slice(0, ANCHOR_PREFIX_LENGTH)
261
217
  : undefined,
package/src/lib/export.ts CHANGED
@@ -1,19 +1,12 @@
1
- import type { Comment, Document } from "../types";
1
+ import type { Comment, Document } from "../schema";
2
2
 
3
- export function generatePrompt(comments: Comment[], fileName: string): string {
4
- const prompt = comments
5
- .map((c) => {
6
- return `---\nSelected text: "${c.selectedText}"\nComment: ${c.comment}`;
7
- })
8
- .join("\n\n");
9
-
10
- return `# Review Comments for ${fileName}\n\n${prompt}`;
3
+ export function formatComment(c: Comment): string {
4
+ const line = c.lineHint ? `[${c.lineHint}] ` : "";
5
+ return `${line}"${c.selectedText}"\n${c.comment}`;
11
6
  }
12
7
 
13
- export function generateRawText(comments: Comment[]): string {
14
- return comments
15
- .map((c) => `${c.selectedText}\n\n${c.comment}`)
16
- .join("\n\n---\n\n");
8
+ export function generatePrompt(comments: Comment[], fileName: string): string {
9
+ return `# Review Comments for ${fileName}\n\n${comments.map(formatComment).join("\n\n---\n\n")}`;
17
10
  }
18
11
 
19
12
  export function exportCommentsAsJson(
@@ -27,6 +20,7 @@ export function exportCommentsAsJson(
27
20
  comments: comments.map((c) => ({
28
21
  selectedText: c.selectedText,
29
22
  comment: c.comment,
23
+ lineHint: c.lineHint,
30
24
  createdAt: c.createdAt,
31
25
  })),
32
26
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { findTextPosition } from "./core";
2
+ import { findTextPosition } from "./resolver";
3
3
 
4
4
  describe("findTextPosition", () => {
5
5
  it("finds single occurrence", () => {
@@ -1,9 +1,5 @@
1
- import type { HighlightPositions, HighlightStyle, TextNodeInfo } from "./types";
1
+ import type { HighlightStyle, TextNodeInfo } from "./types";
2
2
 
3
- /**
4
- * Block-level elements that should have newlines between them.
5
- * Used to normalize whitespace in text extraction.
6
- */
7
3
  const BLOCK_ELEMENTS = new Set([
8
4
  "P",
9
5
  "DIV",
@@ -20,9 +16,6 @@ const BLOCK_ELEMENTS = new Set([
20
16
  "BR",
21
17
  ]);
22
18
 
23
- /**
24
- * Find the closest block-level ancestor of a node.
25
- */
26
19
  function findBlockParent(node: Node): Element | null {
27
20
  let parent = node.parentElement;
28
21
  while (parent && !BLOCK_ELEMENTS.has(parent.tagName)) {
@@ -31,10 +24,7 @@ function findBlockParent(node: Node): Element | null {
31
24
  return parent;
32
25
  }
33
26
 
34
- /**
35
- * Calculate text offset from root to a specific node position.
36
- * Accounts for newlines between block elements to match getDOMTextContent.
37
- */
27
+ /** Accounts for newlines between block elements to match getDOMTextContent. */
38
28
  export function getTextOffset(
39
29
  root: Node,
40
30
  targetNode: Node,
@@ -48,13 +38,12 @@ export function getTextOffset(
48
38
  while (node) {
49
39
  const blockParent = findBlockParent(node);
50
40
 
51
- // Add newline when transitioning between different block parents
52
41
  if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
53
42
  if (
54
43
  !lastBlockParent.contains(blockParent) &&
55
44
  !blockParent.contains(lastBlockParent)
56
45
  ) {
57
- offset += 1; // Account for the newline
46
+ offset += 1;
58
47
  }
59
48
  }
60
49
 
@@ -69,10 +58,7 @@ export function getTextOffset(
69
58
  return offset;
70
59
  }
71
60
 
72
- /**
73
- * Extract all text content from a DOM tree.
74
- * Inserts newlines between block-level elements to match browser selection behavior.
75
- */
61
+ /** Inserts newlines between block-level elements to match browser selection behavior. */
76
62
  export function getDOMTextContent(root: Node): string {
77
63
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
78
64
  let text = "";
@@ -82,7 +68,6 @@ export function getDOMTextContent(root: Node): string {
82
68
  while (node) {
83
69
  const blockParent = findBlockParent(node);
84
70
 
85
- // Insert newline when transitioning between different block parents
86
71
  if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
87
72
  // Only add newline if blocks are siblings (not nested)
88
73
  if (
@@ -101,10 +86,6 @@ export function getDOMTextContent(root: Node): string {
101
86
  return text;
102
87
  }
103
88
 
104
- /**
105
- * Collect all text nodes with their cumulative offset ranges.
106
- * Accounts for newlines between block elements to match getDOMTextContent.
107
- */
108
89
  export function collectTextNodes(root: Node): TextNodeInfo[] {
109
90
  const textNodes: TextNodeInfo[] = [];
110
91
  let currentOffset = 0;
@@ -116,14 +97,12 @@ export function collectTextNodes(root: Node): TextNodeInfo[] {
116
97
  while (node) {
117
98
  const blockParent = findBlockParent(node);
118
99
 
119
- // Account for newline when transitioning between different block parents
120
- // (same logic as getDOMTextContent)
121
100
  if (lastBlockParent && blockParent && lastBlockParent !== blockParent) {
122
101
  if (
123
102
  !lastBlockParent.contains(blockParent) &&
124
103
  !blockParent.contains(lastBlockParent)
125
104
  ) {
126
- currentOffset += 1; // Account for the newline
105
+ currentOffset += 1;
127
106
  }
128
107
  }
129
108
 
@@ -141,9 +120,6 @@ export function collectTextNodes(root: Node): TextNodeInfo[] {
141
120
  return textNodes;
142
121
  }
143
122
 
144
- /**
145
- * Extended style configuration for highlight marks with color and bracket mode support.
146
- */
147
123
  export interface ExtendedHighlightStyle extends HighlightStyle {
148
124
  colorIndex?: number;
149
125
  isBracketMode?: boolean;
@@ -164,9 +140,6 @@ interface NodeSegment {
164
140
  order: number;
165
141
  }
166
142
 
167
- /**
168
- * Line threshold for bracket mode (selections spanning this many lines or more)
169
- */
170
143
  const BRACKET_MODE_LINE_THRESHOLD = 5;
171
144
 
172
145
  export function countLinesInRange(
@@ -183,9 +156,6 @@ export function countLinesInRange(
183
156
  // applyHighlightWithStyle adds color indices and bracket mode for saved comments.
184
157
  // Keeping them separate avoids unnecessary complexity in a shared abstraction.
185
158
 
186
- /**
187
- * Apply highlight mark elements to a text range (for pending selections).
188
- */
189
159
  export function applyHighlightToRange(
190
160
  root: HTMLElement,
191
161
  startOffset: number,
@@ -225,7 +195,6 @@ export function applyHighlightToRange(
225
195
  mark.appendChild(fragment);
226
196
  range.insertNode(mark);
227
197
  } catch (err) {
228
- // Skip if fallback also fails, but log for debugging
229
198
  console.warn("[highlight] Failed to apply highlight to range:", err);
230
199
  }
231
200
  }
@@ -380,35 +349,3 @@ export function clearHighlights(
380
349
  }
381
350
  }
382
351
  }
383
-
384
- /**
385
- * Collect highlight positions relative to a container and document.
386
- */
387
- export function collectHighlightPositions(
388
- root: HTMLElement,
389
- containerRect: DOMRect,
390
- scrollY = 0,
391
- ): HighlightPositions {
392
- const positions: Record<string, number> = {};
393
- const documentPositions: Record<string, number> = {};
394
-
395
- // Collect comment highlight positions
396
- const marks = root.querySelectorAll("mark[data-comment-id]");
397
- for (const mark of marks) {
398
- const commentId = mark.getAttribute("data-comment-id");
399
- if (!commentId) continue;
400
-
401
- // Get position relative to container (for margin notes)
402
- const markRect = mark.getBoundingClientRect();
403
- const relativeTop = markRect.top - containerRect.top;
404
-
405
- // Use first occurrence of each comment id
406
- if (!(commentId in positions)) {
407
- positions[commentId] = relativeTop;
408
- // Document-absolute position (for minimap)
409
- documentPositions[commentId] = markRect.top + scrollY;
410
- }
411
- }
412
-
413
- return { positions, documentPositions };
414
- }