md-redline 0.1.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/md-redline +255 -0
  4. package/bin/test-windows.ps1 +70 -0
  5. package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
  6. package/dist/assets/arc-DI2g9LXK.js +1 -0
  7. package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
  8. package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
  9. package/dist/assets/array-DOVTz2Mq.js +1 -0
  10. package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
  11. package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
  12. package/dist/assets/channel-DPCihw7y.js +1 -0
  13. package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
  14. package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
  15. package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
  16. package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
  17. package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
  18. package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
  19. package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
  20. package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
  21. package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
  22. package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
  23. package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
  24. package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
  25. package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
  26. package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
  27. package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
  28. package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
  29. package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
  30. package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
  31. package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
  32. package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
  33. package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
  34. package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
  35. package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
  36. package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
  37. package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
  38. package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
  39. package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
  40. package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
  41. package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
  42. package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
  43. package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
  44. package/dist/assets/clone-CI9aUwHe.js +1 -0
  45. package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
  46. package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
  47. package/dist/assets/dagre-CilMRazv.js +1 -0
  48. package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
  49. package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
  50. package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
  51. package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
  52. package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
  53. package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
  54. package/dist/assets/dist-C_eddq6m.js +1 -0
  55. package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
  56. package/dist/assets/flatten-B8XIuT0x.js +1 -0
  57. package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
  58. package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
  59. package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
  60. package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
  61. package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
  62. package/dist/assets/index-BoggyWS0.css +2 -0
  63. package/dist/assets/index-aLvjHQW4.js +104 -0
  64. package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
  65. package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
  66. package/dist/assets/init-Bft5Ffpj.js +1 -0
  67. package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
  68. package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
  69. package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
  70. package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
  71. package/dist/assets/katex-Uj9wLT16.js +265 -0
  72. package/dist/assets/line-CRxEwpOv.js +1 -0
  73. package/dist/assets/linear-PDPfFByd.js +1 -0
  74. package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
  75. package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
  76. package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
  77. package/dist/assets/ordinal-DIg8h6NI.js +1 -0
  78. package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
  79. package/dist/assets/path-DfRbCp9y.js +1 -0
  80. package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
  81. package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
  82. package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
  83. package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
  84. package/dist/assets/reduce-CV2X8n1a.js +1 -0
  85. package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
  86. package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
  87. package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
  88. package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
  89. package/dist/assets/src-PKe5NtkK.js +1 -0
  90. package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
  91. package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
  92. package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
  93. package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
  94. package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
  95. package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
  96. package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
  97. package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
  98. package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
  99. package/dist/favicon.svg +15 -0
  100. package/dist/index.html +14 -0
  101. package/dist/screenshot.png +0 -0
  102. package/index.html +13 -0
  103. package/package.json +105 -0
  104. package/public/favicon.svg +15 -0
  105. package/public/screenshot.png +0 -0
  106. package/server/index.test.ts +814 -0
  107. package/server/index.ts +736 -0
  108. package/server/preferences.test.ts +126 -0
  109. package/server/preferences.ts +76 -0
  110. package/src/App.tsx +1620 -0
  111. package/src/components/ActionButton.tsx +41 -0
  112. package/src/components/CommandPalette.tsx +191 -0
  113. package/src/components/CommentCard.tsx +556 -0
  114. package/src/components/CommentForm.tsx +285 -0
  115. package/src/components/CommentSidebar.tsx +428 -0
  116. package/src/components/ConfirmDialog.tsx +64 -0
  117. package/src/components/ContextMenu.tsx +220 -0
  118. package/src/components/DragHandles.tsx +48 -0
  119. package/src/components/FileExplorer.tsx +251 -0
  120. package/src/components/FileOpener.tsx +304 -0
  121. package/src/components/IconButton.tsx +32 -0
  122. package/src/components/KeyboardShortcutsPanel.tsx +136 -0
  123. package/src/components/MarkdownViewer.tsx +682 -0
  124. package/src/components/RawView.tsx +798 -0
  125. package/src/components/SearchBar.tsx +129 -0
  126. package/src/components/Separator.tsx +7 -0
  127. package/src/components/SettingsPanel.tsx +813 -0
  128. package/src/components/SplitIconButton.tsx +133 -0
  129. package/src/components/TabBar.tsx +594 -0
  130. package/src/components/TableOfContents.tsx +70 -0
  131. package/src/components/ThemeSelector.tsx +159 -0
  132. package/src/components/Toast.tsx +99 -0
  133. package/src/components/Toolbar.tsx +161 -0
  134. package/src/components/iconButtonVariants.ts +19 -0
  135. package/src/components/rawView.test.ts +291 -0
  136. package/src/contexts/SettingsContext.tsx +120 -0
  137. package/src/hooks/useAuthor.test.ts +58 -0
  138. package/src/hooks/useAuthor.ts +69 -0
  139. package/src/hooks/useAutoResize.ts +20 -0
  140. package/src/hooks/useCommentCardTriggers.ts +20 -0
  141. package/src/hooks/useComments.test.ts +773 -0
  142. package/src/hooks/useComments.ts +332 -0
  143. package/src/hooks/useContextMenu.ts +48 -0
  144. package/src/hooks/useContextMenuItems.ts +392 -0
  145. package/src/hooks/useDiffSnapshot.test.ts +130 -0
  146. package/src/hooks/useDiffSnapshot.ts +67 -0
  147. package/src/hooks/useDragHandles.ts +417 -0
  148. package/src/hooks/useFileWatcher.ts +45 -0
  149. package/src/hooks/useHeadingTracking.ts +84 -0
  150. package/src/hooks/useMermaidRenderer.ts +75 -0
  151. package/src/hooks/useModalState.ts +22 -0
  152. package/src/hooks/usePageVisible.test.ts +69 -0
  153. package/src/hooks/usePageVisible.ts +19 -0
  154. package/src/hooks/usePaneLayout.test.ts +108 -0
  155. package/src/hooks/usePaneLayout.ts +102 -0
  156. package/src/hooks/useRecentFiles.test.ts +103 -0
  157. package/src/hooks/useRecentFiles.ts +99 -0
  158. package/src/hooks/useResizablePanel.test.ts +84 -0
  159. package/src/hooks/useResizablePanel.ts +118 -0
  160. package/src/hooks/useSearch.test.ts +72 -0
  161. package/src/hooks/useSearch.ts +53 -0
  162. package/src/hooks/useSelection.ts +48 -0
  163. package/src/hooks/useSessionPersistence.test.ts +59 -0
  164. package/src/hooks/useSessionPersistence.ts +43 -0
  165. package/src/hooks/useTabs.test.ts +127 -0
  166. package/src/hooks/useTabs.ts +561 -0
  167. package/src/hooks/useThemePersistence.ts +41 -0
  168. package/src/hooks/useToast.ts +27 -0
  169. package/src/index.css +1047 -0
  170. package/src/lib/agent-prompts.test.ts +34 -0
  171. package/src/lib/agent-prompts.ts +68 -0
  172. package/src/lib/comment-editor-state.ts +6 -0
  173. package/src/lib/comment-parser.test.ts +1959 -0
  174. package/src/lib/comment-parser.ts +1021 -0
  175. package/src/lib/diff.test.ts +164 -0
  176. package/src/lib/diff.ts +139 -0
  177. package/src/lib/heading-slugs.test.ts +85 -0
  178. package/src/lib/heading-slugs.ts +44 -0
  179. package/src/lib/http.test.ts +43 -0
  180. package/src/lib/http.ts +29 -0
  181. package/src/lib/mermaid-highlights.test.ts +517 -0
  182. package/src/lib/mermaid-highlights.ts +936 -0
  183. package/src/lib/mermaid-renderer.test.ts +114 -0
  184. package/src/lib/mermaid-renderer.ts +89 -0
  185. package/src/lib/path-utils.test.ts +17 -0
  186. package/src/lib/path-utils.ts +7 -0
  187. package/src/lib/platform.test.ts +58 -0
  188. package/src/lib/platform.ts +14 -0
  189. package/src/lib/preferences-client.test.ts +177 -0
  190. package/src/lib/preferences-client.ts +94 -0
  191. package/src/lib/selection-resolver.test.ts +118 -0
  192. package/src/lib/selection-resolver.ts +37 -0
  193. package/src/lib/settings.test.ts +152 -0
  194. package/src/lib/settings.ts +78 -0
  195. package/src/lib/shortcut-label.tsx +18 -0
  196. package/src/lib/themes.ts +21 -0
  197. package/src/lib/visible-text.test.ts +86 -0
  198. package/src/lib/visible-text.ts +77 -0
  199. package/src/main.tsx +22 -0
  200. package/src/markdown/pipeline.test.ts +82 -0
  201. package/src/markdown/pipeline.ts +33 -0
  202. package/src/types.test.ts +43 -0
  203. package/src/types.ts +46 -0
  204. package/tsconfig.app.json +28 -0
  205. package/tsconfig.json +7 -0
  206. package/tsconfig.node.json +26 -0
  207. package/vite.config.ts +50 -0
@@ -0,0 +1,798 @@
1
+ import {
2
+ useRef,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useImperativeHandle,
6
+ forwardRef,
7
+ useCallback,
8
+ useMemo,
9
+ useState,
10
+ } from 'react';
11
+ import { unified } from 'unified';
12
+ import remarkParse from 'remark-parse';
13
+ import remarkFrontmatter from 'remark-frontmatter';
14
+ import remarkGfm from 'remark-gfm';
15
+ import { highlightSearchMatches } from './MarkdownViewer';
16
+ import { COMMENT_MARKER_RE, parseComments } from '../lib/comment-parser';
17
+ import { uniqueSlugs } from '../lib/heading-slugs';
18
+ import { computeDiff, type DiffLine } from '../lib/diff';
19
+ import { SplitIconButton } from './SplitIconButton';
20
+
21
+ // Markdown syntax highlighting patterns (order matters — first match wins per region)
22
+ interface SyntaxRule {
23
+ pattern: RegExp;
24
+ className: string;
25
+ }
26
+
27
+ const SYNTAX_RULES: SyntaxRule[] = [
28
+ // Comment markers — highest priority
29
+ { pattern: COMMENT_MARKER_RE, className: 'raw-comment-marker' },
30
+ // Fenced code blocks (``` or ~~~)
31
+ { pattern: /^(`{3,}|~{3,}).*$(?:\n[\s\S]*?)?^(\1)/gm, className: 'raw-code-block' },
32
+ // Inline code
33
+ { pattern: /`[^`\n]+`/g, className: 'raw-inline-code' },
34
+ // Headings
35
+ { pattern: /^#{1,6}\s.*$/gm, className: 'raw-heading' },
36
+ // Bold
37
+ { pattern: /\*\*[^*]+\*\*/g, className: 'raw-bold' },
38
+ // Italic (but not inside bold)
39
+ { pattern: /(?<!\*)\*[^*\n]+\*(?!\*)/g, className: 'raw-italic' },
40
+ // Links [text](url)
41
+ { pattern: /\[([^\]]+)\]\([^)]+\)/g, className: 'raw-link' },
42
+ // Blockquotes
43
+ { pattern: /^>\s.*$/gm, className: 'raw-blockquote' },
44
+ // List markers
45
+ { pattern: /^(\s*)([-*+]|\d+\.)\s/gm, className: 'raw-list-marker' },
46
+ // Horizontal rules (3+ of the same character)
47
+ { pattern: /^(-{3,}|\*{3,}|_{3,})\s*$/gm, className: 'raw-hr' },
48
+ // Table pipes
49
+ { pattern: /^\|.*\|$/gm, className: 'raw-table' },
50
+ // Frontmatter — no `m` flag so ^ only matches start of string
51
+ { pattern: /^---\n[\s\S]*?\n---/g, className: 'raw-frontmatter' },
52
+ // HTML comments (non-@comment ones)
53
+ { pattern: /<!--(?! @comment)[\s\S]*?-->/g, className: 'raw-html-comment' },
54
+ ];
55
+
56
+ export interface RawViewHandle {
57
+ scrollToComment: (commentId: string) => void;
58
+ scrollToHeading: (headingId: string) => void;
59
+ }
60
+
61
+ interface Props {
62
+ rawMarkdown: string;
63
+ searchQuery?: string;
64
+ searchActiveIndex?: number;
65
+ onSearchCount?: (count: number) => void;
66
+ activeCommentId: string | null;
67
+ diffSnapshot?: string | null;
68
+ diffEnabled?: boolean;
69
+ onDiffToggle?: () => void;
70
+ onClearSnapshot?: () => void;
71
+ }
72
+
73
+ interface DisplayRow {
74
+ type: 'same' | 'added' | 'removed';
75
+ html: string;
76
+ lineNo: number | undefined;
77
+ sourceLineIndex?: number;
78
+ }
79
+
80
+ type Region = { start: number; end: number; className: string; id?: string };
81
+ type MarkdownAstNode = {
82
+ type: string;
83
+ depth?: number;
84
+ value?: string;
85
+ alt?: string;
86
+ children?: MarkdownAstNode[];
87
+ position?: {
88
+ start?: {
89
+ line?: number;
90
+ };
91
+ };
92
+ };
93
+
94
+ export interface RawHeading {
95
+ id: string;
96
+ text: string;
97
+ level: number;
98
+ lineIndex: number;
99
+ }
100
+
101
+ const rawHeadingProcessor = unified()
102
+ .use(remarkParse)
103
+ .use(remarkFrontmatter, ['yaml', 'toml'])
104
+ .use(remarkGfm);
105
+
106
+ function extractNodeText(node: MarkdownAstNode): string {
107
+ if (node.type === 'text' || node.type === 'inlineCode' || node.type === 'html') {
108
+ return node.value ?? '';
109
+ }
110
+ if (node.type === 'image') {
111
+ return node.alt ?? '';
112
+ }
113
+ if (node.type === 'break') {
114
+ return ' ';
115
+ }
116
+ return node.children?.map(extractNodeText).join('') ?? '';
117
+ }
118
+
119
+ export function extractRawHeadings(rawMarkdown: string): RawHeading[] {
120
+ COMMENT_MARKER_RE.lastIndex = 0;
121
+ const cleanRaw = rawMarkdown.replace(COMMENT_MARKER_RE, '');
122
+ const tree = rawHeadingProcessor.parse(cleanRaw) as MarkdownAstNode;
123
+ const headings: Array<{ text: string; level: number; lineIndex: number }> = [];
124
+
125
+ const visit = (node: MarkdownAstNode) => {
126
+ if (node.type === 'heading') {
127
+ const line = node.position?.start?.line;
128
+ if (line != null) {
129
+ headings.push({
130
+ text: extractNodeText(node).trim(),
131
+ level: node.depth ?? 1,
132
+ lineIndex: Math.max(0, line - 1),
133
+ });
134
+ }
135
+ }
136
+ node.children?.forEach(visit);
137
+ };
138
+
139
+ visit(tree);
140
+
141
+ const ids = uniqueSlugs(headings.map((heading) => heading.text));
142
+ return headings.map((heading, index) => ({
143
+ ...heading,
144
+ id: ids[index],
145
+ }));
146
+ }
147
+
148
+ /**
149
+ * Build highlighted HTML from raw markdown text.
150
+ * Returns the full HTML string with <span> wrappers for syntax highlighting.
151
+ */
152
+ export function buildHighlightedHtml(raw: string): string {
153
+ // Step 1: Collect comment marker matches first (they have absolute priority)
154
+ const commentRegions: Region[] = [];
155
+ COMMENT_MARKER_RE.lastIndex = 0;
156
+ let cm: RegExpExecArray | null;
157
+ while ((cm = COMMENT_MARKER_RE.exec(raw)) !== null) {
158
+ const region: Region = {
159
+ start: cm.index,
160
+ end: cm.index + cm[0].length,
161
+ className: 'raw-comment-marker',
162
+ };
163
+ try {
164
+ const jsonStr = cm[0].replace(/^<!-- @comment/, '').replace(/ -->$/, '');
165
+ const parsed = JSON.parse(jsonStr);
166
+ if (parsed.id) region.id = parsed.id;
167
+ } catch {
168
+ /* ignore parse errors */
169
+ }
170
+ commentRegions.push(region);
171
+ }
172
+
173
+ // Step 2: Collect other syntax matches, skipping any that overlap comment markers
174
+ const otherRegions: Region[] = [];
175
+ for (const rule of SYNTAX_RULES) {
176
+ if (rule.className === 'raw-comment-marker') continue;
177
+ rule.pattern.lastIndex = 0;
178
+ let m: RegExpExecArray | null;
179
+ while ((m = rule.pattern.exec(raw)) !== null) {
180
+ const start = m.index;
181
+ const end = m.index + m[0].length;
182
+ // Skip if this region overlaps any comment marker
183
+ const overlapsComment = commentRegions.some((c) => start < c.end && end > c.start);
184
+ if (!overlapsComment) {
185
+ otherRegions.push({ start, end, className: rule.className });
186
+ }
187
+ }
188
+ }
189
+
190
+ // Step 3: Merge and sort all regions
191
+ const allRegions = [...commentRegions, ...otherRegions];
192
+ allRegions.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start));
193
+
194
+ // Step 4: Remove overlapping regions among non-comment rules (first match wins)
195
+ const filtered: Region[] = [];
196
+ let lastEnd = 0;
197
+ for (const r of allRegions) {
198
+ if (r.start >= lastEnd) {
199
+ filtered.push(r);
200
+ lastEnd = r.end;
201
+ }
202
+ }
203
+
204
+ // Build HTML string
205
+ const parts: string[] = [];
206
+ let cursor = 0;
207
+
208
+ for (const r of filtered) {
209
+ if (r.start > cursor) {
210
+ parts.push(escapeHtml(raw.slice(cursor, r.start)));
211
+ }
212
+ const idAttr = r.id ? ` data-comment-id="${escapeAttr(r.id)}"` : '';
213
+ parts.push(
214
+ `<span class="${r.className}"${idAttr}>${escapeHtml(raw.slice(r.start, r.end))}</span>`,
215
+ );
216
+ cursor = r.end;
217
+ }
218
+
219
+ if (cursor < raw.length) {
220
+ parts.push(escapeHtml(raw.slice(cursor)));
221
+ }
222
+
223
+ return parts.join('');
224
+ }
225
+
226
+ /**
227
+ * Split highlighted HTML into per-line segments matching the source line count.
228
+ * Handles spans that cross line boundaries by closing/reopening tags.
229
+ */
230
+ export function splitHighlightedHtml(raw: string, fullHtml: string): string[] {
231
+ const lines = raw.split('\n');
232
+ const result: string[] = [];
233
+ let current = '';
234
+ const openTags: string[] = [];
235
+ let i = 0;
236
+
237
+ while (i < fullHtml.length) {
238
+ if (fullHtml[i] === '\n') {
239
+ for (let t = openTags.length - 1; t >= 0; t--) {
240
+ current += '</span>';
241
+ }
242
+ result.push(current);
243
+ current = '';
244
+ for (const tag of openTags) {
245
+ current += tag;
246
+ }
247
+ i++;
248
+ } else if (fullHtml[i] === '<') {
249
+ const closeMatch = fullHtml.slice(i).match(/^<\/span>/);
250
+ if (closeMatch) {
251
+ current += closeMatch[0];
252
+ openTags.pop();
253
+ i += closeMatch[0].length;
254
+ } else {
255
+ const openMatch = fullHtml.slice(i).match(/^<span[^>]*>/);
256
+ if (openMatch) {
257
+ current += openMatch[0];
258
+ openTags.push(openMatch[0]);
259
+ i += openMatch[0].length;
260
+ } else {
261
+ current += fullHtml[i];
262
+ i++;
263
+ }
264
+ }
265
+ } else {
266
+ current += fullHtml[i];
267
+ i++;
268
+ }
269
+ }
270
+ for (let t = openTags.length - 1; t >= 0; t--) {
271
+ current += '</span>';
272
+ }
273
+ result.push(current);
274
+
275
+ while (result.length < lines.length) result.push('');
276
+ return result;
277
+ }
278
+
279
+ export function escapeHtml(s: string): string {
280
+ return s
281
+ .replace(/&/g, '&amp;')
282
+ .replace(/</g, '&lt;')
283
+ .replace(/>/g, '&gt;')
284
+ .replace(/"/g, '&quot;');
285
+ }
286
+
287
+ function escapeAttr(s: string): string {
288
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
289
+ }
290
+
291
+ export const RawView = forwardRef<RawViewHandle, Props>(function RawView(
292
+ {
293
+ rawMarkdown,
294
+ searchQuery,
295
+ searchActiveIndex,
296
+ onSearchCount,
297
+ activeCommentId,
298
+ diffSnapshot,
299
+ diffEnabled,
300
+ onDiffToggle,
301
+ onClearSnapshot,
302
+ },
303
+ ref,
304
+ ) {
305
+ const containerRef = useRef<HTMLDivElement>(null);
306
+ const tableRef = useRef<HTMLDivElement>(null);
307
+ const flashTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
308
+ const [copyFeedback, setCopyFeedback] = useState(false);
309
+ const [showComments, setShowComments] = useState(true);
310
+ const [activeDiffChunk, setActiveDiffChunk] = useState(0);
311
+
312
+ // Hide comment markers when entering diff mode so user sees only diffs
313
+ useEffect(() => {
314
+ if (diffEnabled) setShowComments(false);
315
+ }, [diffEnabled]);
316
+
317
+ // Clean up flash timer on unmount
318
+ useEffect(() => {
319
+ return () => {
320
+ if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
321
+ };
322
+ }, []);
323
+
324
+ const highlightedHtml = useMemo(() => buildHighlightedHtml(rawMarkdown), [rawMarkdown]);
325
+ const rawHeadings = useMemo(() => extractRawHeadings(rawMarkdown), [rawMarkdown]);
326
+ const headingIdsByLine = useMemo(
327
+ () => new Map(rawHeadings.map((heading) => [heading.lineIndex, heading.id])),
328
+ [rawHeadings],
329
+ );
330
+
331
+ const lineHtmls = useMemo(
332
+ () => splitHighlightedHtml(rawMarkdown, highlightedHtml),
333
+ [rawMarkdown, highlightedHtml],
334
+ );
335
+
336
+ // Diff computation — compare clean markdown (without comment markers) so that
337
+ // marker additions/removals don't appear as content changes
338
+ const diffLines = useMemo<DiffLine[] | null>(() => {
339
+ if (!diffEnabled || !diffSnapshot) return null;
340
+ const { cleanMarkdown: oldClean } = parseComments(diffSnapshot);
341
+ const { cleanMarkdown: newClean } = parseComments(rawMarkdown);
342
+ return computeDiff(oldClean, newClean);
343
+ }, [diffEnabled, diffSnapshot, rawMarkdown]);
344
+
345
+ const oldHighlightedHtml = useMemo(
346
+ () => (diffEnabled && diffSnapshot ? buildHighlightedHtml(diffSnapshot) : ''),
347
+ [diffEnabled, diffSnapshot],
348
+ );
349
+
350
+ const oldLineHtmls = useMemo(() => {
351
+ if (!diffEnabled || !diffSnapshot) return [];
352
+ return splitHighlightedHtml(diffSnapshot, oldHighlightedHtml);
353
+ }, [diffEnabled, diffSnapshot, oldHighlightedHtml]);
354
+
355
+ const displayRows = useMemo<DisplayRow[]>(() => {
356
+ if (!diffLines) {
357
+ return lineHtmls.map((html, i) => ({
358
+ type: 'same' as const,
359
+ html,
360
+ lineNo: i + 1,
361
+ sourceLineIndex: i,
362
+ }));
363
+ }
364
+ return diffLines.map((dl) => {
365
+ if (dl.type === 'removed') {
366
+ const oldIdx = (dl.oldLineNo ?? 1) - 1;
367
+ return {
368
+ type: 'removed' as const,
369
+ html: oldLineHtmls[oldIdx] ?? '',
370
+ lineNo: dl.oldLineNo,
371
+ sourceLineIndex: undefined,
372
+ };
373
+ }
374
+ const newIdx = (dl.newLineNo ?? 1) - 1;
375
+ return {
376
+ type: dl.type,
377
+ html: lineHtmls[newIdx] ?? '',
378
+ lineNo: dl.newLineNo,
379
+ sourceLineIndex: newIdx,
380
+ };
381
+ });
382
+ }, [diffLines, lineHtmls, oldLineHtmls]);
383
+
384
+ // Diff chunks: contiguous groups of changed (added/removed) rows
385
+ const diffChunks = useMemo(() => {
386
+ if (!diffLines) return [];
387
+ const chunks: { startRow: number; endRow: number }[] = [];
388
+ let inChunk = false;
389
+ let start = 0;
390
+ for (let i = 0; i < displayRows.length; i++) {
391
+ const changed = displayRows[i].type !== 'same';
392
+ if (changed && !inChunk) {
393
+ inChunk = true;
394
+ start = i;
395
+ } else if (!changed && inChunk) {
396
+ inChunk = false;
397
+ chunks.push({ startRow: start, endRow: i - 1 });
398
+ }
399
+ }
400
+ if (inChunk) chunks.push({ startRow: start, endRow: displayRows.length - 1 });
401
+ return chunks;
402
+ }, [diffLines, displayRows]);
403
+
404
+ // Reset active chunk when diff changes
405
+ useEffect(() => {
406
+ setActiveDiffChunk(0);
407
+ }, [diffChunks.length]);
408
+
409
+ const scrollToDiffChunk = useCallback(
410
+ (index: number) => {
411
+ const chunk = diffChunks[index];
412
+ if (!chunk || !tableRef.current) return;
413
+ const scrollParent =
414
+ containerRef.current?.querySelector('.overflow-y-auto') ??
415
+ containerRef.current?.closest('.overflow-y-auto');
416
+ if (!scrollParent) return;
417
+ const rows = tableRef.current.querySelectorAll('.raw-line');
418
+ const targetRow = rows[chunk.startRow];
419
+ if (!targetRow) return;
420
+ const rowRect = targetRow.getBoundingClientRect();
421
+ const parentRect = scrollParent.getBoundingClientRect();
422
+ scrollParent.scrollTo({
423
+ top: scrollParent.scrollTop + rowRect.top - parentRect.top - 40,
424
+ behavior: 'smooth',
425
+ });
426
+ },
427
+ [diffChunks],
428
+ );
429
+
430
+ const handleDiffPrev = useCallback(() => {
431
+ const next = activeDiffChunk > 0 ? activeDiffChunk - 1 : diffChunks.length - 1;
432
+ setActiveDiffChunk(next);
433
+ scrollToDiffChunk(next);
434
+ }, [activeDiffChunk, diffChunks.length, scrollToDiffChunk]);
435
+
436
+ const handleDiffNext = useCallback(() => {
437
+ const next = activeDiffChunk < diffChunks.length - 1 ? activeDiffChunk + 1 : 0;
438
+ setActiveDiffChunk(next);
439
+ scrollToDiffChunk(next);
440
+ }, [activeDiffChunk, diffChunks.length, scrollToDiffChunk]);
441
+
442
+ // Set innerHTML for each line cell and apply search highlights
443
+ useLayoutEffect(() => {
444
+ if (!tableRef.current) return;
445
+ const codeCells = tableRef.current.querySelectorAll<HTMLElement>('.raw-line-content');
446
+ codeCells.forEach((cell, i) => {
447
+ cell.innerHTML = displayRows[i]?.html || '';
448
+ });
449
+
450
+ // Apply search highlights across all content cells (skip removed lines).
451
+ if (searchQuery) {
452
+ const counts: number[] = [];
453
+ codeCells.forEach((cell, i) => {
454
+ if (displayRows[i]?.type === 'removed') {
455
+ counts.push(0);
456
+ return;
457
+ }
458
+ const count = highlightSearchMatches(cell, searchQuery, -1);
459
+ counts.push(count);
460
+ });
461
+ const totalCount = counts.reduce((a, b) => a + b, 0);
462
+
463
+ let cumulative = 0;
464
+ codeCells.forEach((cell, i) => {
465
+ if (displayRows[i]?.type === 'removed') return;
466
+ cell.innerHTML = displayRows[i]?.html || '';
467
+ const activeGlobal = searchActiveIndex ?? 0;
468
+ const localActive =
469
+ activeGlobal >= cumulative && activeGlobal < cumulative + counts[i]
470
+ ? activeGlobal - cumulative
471
+ : -1;
472
+ highlightSearchMatches(cell, searchQuery, localActive);
473
+ cumulative += counts[i];
474
+ });
475
+ onSearchCount?.(totalCount);
476
+ } else {
477
+ onSearchCount?.(0);
478
+ }
479
+ }, [displayRows, searchQuery, searchActiveIndex, onSearchCount]);
480
+
481
+ // Highlight active comment marker (only in current content, not removed lines)
482
+ useLayoutEffect(() => {
483
+ if (!tableRef.current) return;
484
+
485
+ tableRef.current.querySelectorAll('.raw-comment-marker-active').forEach((el) => {
486
+ el.classList.remove('raw-comment-marker-active');
487
+ });
488
+
489
+ if (activeCommentId) {
490
+ const markers = tableRef.current.querySelectorAll(
491
+ `[data-comment-id="${CSS.escape(activeCommentId)}"]`,
492
+ );
493
+ for (const marker of markers) {
494
+ if (!marker.closest('.raw-line-diff-removed')) {
495
+ marker.classList.add('raw-comment-marker-active');
496
+ break;
497
+ }
498
+ }
499
+ }
500
+ }, [activeCommentId, displayRows]);
501
+
502
+ /** Find the scrollable container (descendant or ancestor). */
503
+ const getScrollParent = useCallback((): Element | null => {
504
+ if (!containerRef.current) return null;
505
+ // The scroll container is a descendant (flex-1 overflow-y-auto) in pinned toolbar layout
506
+ return (
507
+ containerRef.current.querySelector('.overflow-y-auto') ??
508
+ containerRef.current.closest('.overflow-y-auto')
509
+ );
510
+ }, []);
511
+
512
+ const scrollToComment = useCallback(
513
+ (commentId: string) => {
514
+ if (!tableRef.current || !containerRef.current) return;
515
+
516
+ // Re-enable comment markers if hidden so the marker is visible
517
+ setShowComments(true);
518
+
519
+ // Defer scroll to next frame so display:none is removed first
520
+ requestAnimationFrame(() => {
521
+ if (!tableRef.current) return;
522
+ const marker = tableRef.current.querySelector(
523
+ `[data-comment-id="${CSS.escape(commentId)}"]`,
524
+ );
525
+ if (!marker) return;
526
+
527
+ const scrollParent = getScrollParent();
528
+ if (scrollParent) {
529
+ const markerRect = marker.getBoundingClientRect();
530
+ const parentRect = scrollParent.getBoundingClientRect();
531
+ const offset = markerRect.top - parentRect.top - parentRect.height / 3;
532
+ scrollParent.scrollTop += offset;
533
+ }
534
+
535
+ // Clear previous flash animation
536
+ if (flashTimerRef.current) {
537
+ clearTimeout(flashTimerRef.current);
538
+ tableRef.current?.querySelectorAll('.raw-comment-marker-flash').forEach((el) => {
539
+ el.classList.remove('raw-comment-marker-flash');
540
+ });
541
+ }
542
+ marker.classList.add('raw-comment-marker-flash');
543
+ flashTimerRef.current = setTimeout(
544
+ () => marker.classList.remove('raw-comment-marker-flash'),
545
+ 1500,
546
+ );
547
+ });
548
+ },
549
+ [getScrollParent],
550
+ );
551
+
552
+ const scrollToHeading = useCallback(
553
+ (headingId: string) => {
554
+ if (!tableRef.current) return;
555
+ const headingLine = tableRef.current.querySelector(
556
+ `.raw-line[data-heading-id="${CSS.escape(headingId)}"]`,
557
+ );
558
+ if (!headingLine) return;
559
+
560
+ const scrollParent = getScrollParent();
561
+ if (scrollParent) {
562
+ const lineRect = headingLine.getBoundingClientRect();
563
+ const parentRect = scrollParent.getBoundingClientRect();
564
+ scrollParent.scrollTo({
565
+ top: scrollParent.scrollTop + lineRect.top - parentRect.top,
566
+ behavior: 'smooth',
567
+ });
568
+ return;
569
+ }
570
+
571
+ headingLine.scrollIntoView({ behavior: 'smooth', block: 'start' });
572
+ },
573
+ [getScrollParent],
574
+ );
575
+
576
+ useImperativeHandle(ref, () => ({ scrollToComment, scrollToHeading }), [
577
+ scrollToComment,
578
+ scrollToHeading,
579
+ ]);
580
+
581
+ const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
582
+ useEffect(() => {
583
+ return () => {
584
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
585
+ };
586
+ }, []);
587
+
588
+ const showCopyFeedback = useCallback(() => {
589
+ setCopyFeedback(true);
590
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
591
+ copyTimerRef.current = setTimeout(() => setCopyFeedback(false), 2000);
592
+ }, []);
593
+
594
+ const handleCopy = useCallback(() => {
595
+ navigator.clipboard.writeText(rawMarkdown).then(showCopyFeedback, () => {});
596
+ }, [rawMarkdown, showCopyFeedback]);
597
+
598
+ const handleCopyWithoutComments = useCallback(() => {
599
+ COMMENT_MARKER_RE.lastIndex = 0;
600
+ const clean = rawMarkdown.replace(COMMENT_MARKER_RE, '');
601
+ navigator.clipboard.writeText(clean).then(showCopyFeedback, () => {});
602
+ }, [rawMarkdown, showCopyFeedback]);
603
+
604
+ const hasChanges = diffLines ? diffLines.some((l) => l.type !== 'same') : true;
605
+ const hasDiffSnapshot = diffSnapshot != null;
606
+
607
+ const toggleClass = (active: boolean) =>
608
+ `p-1 rounded transition-colors ${
609
+ active
610
+ ? 'text-primary-text bg-primary-bg'
611
+ : 'text-content-muted hover:text-content-secondary hover:bg-tint'
612
+ }`;
613
+
614
+ const actionClass =
615
+ 'text-[11px] rounded px-2 py-0.5 transition-colors text-content-secondary hover:text-content hover:bg-tint';
616
+
617
+ const toolbar = (
618
+ <div className="raw-toolbar">
619
+ <div className="raw-toolbar-left">
620
+ <button
621
+ className={toggleClass(showComments)}
622
+ onClick={() => setShowComments((v) => !v)}
623
+ title={showComments ? 'Hide comment markers' : 'Show comment markers'}
624
+ >
625
+ <svg
626
+ className="w-3.5 h-3.5"
627
+ fill="none"
628
+ viewBox="0 0 24 24"
629
+ stroke="currentColor"
630
+ strokeWidth={2}
631
+ >
632
+ <path
633
+ strokeLinecap="round"
634
+ strokeLinejoin="round"
635
+ d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
636
+ />
637
+ </svg>
638
+ </button>
639
+ {hasDiffSnapshot && onDiffToggle && (
640
+ <button
641
+ className={`${toggleClass(!!diffEnabled)} flex items-center gap-1`}
642
+ onClick={onDiffToggle}
643
+ title={diffEnabled ? 'Hide diff overlay' : 'Show diff since snapshot'}
644
+ >
645
+ <svg
646
+ className="w-3.5 h-3.5"
647
+ fill="none"
648
+ viewBox="0 0 24 24"
649
+ stroke="currentColor"
650
+ strokeWidth={2}
651
+ >
652
+ <path
653
+ strokeLinecap="round"
654
+ strokeLinejoin="round"
655
+ d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
656
+ />
657
+ </svg>
658
+ {diffChunks.length > 0 && (
659
+ <span className="text-[10px] tabular-nums">{diffChunks.length}</span>
660
+ )}
661
+ </button>
662
+ )}
663
+ {diffEnabled && diffChunks.length > 0 && (
664
+ <div className="flex items-center gap-0.5 ml-1">
665
+ <button
666
+ className="p-0.5 rounded text-content-muted hover:text-content-secondary hover:bg-tint transition-colors"
667
+ onClick={handleDiffPrev}
668
+ title="Previous change"
669
+ >
670
+ <svg
671
+ className="w-3 h-3"
672
+ fill="none"
673
+ viewBox="0 0 24 24"
674
+ stroke="currentColor"
675
+ strokeWidth={2.5}
676
+ >
677
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
678
+ </svg>
679
+ </button>
680
+ <button
681
+ className="p-0.5 rounded text-content-muted hover:text-content-secondary hover:bg-tint transition-colors"
682
+ onClick={handleDiffNext}
683
+ title="Next change"
684
+ >
685
+ <svg
686
+ className="w-3 h-3"
687
+ fill="none"
688
+ viewBox="0 0 24 24"
689
+ stroke="currentColor"
690
+ strokeWidth={2.5}
691
+ >
692
+ <path
693
+ strokeLinecap="round"
694
+ strokeLinejoin="round"
695
+ d="M19.5 8.25l-7.5 7.5-7.5-7.5"
696
+ />
697
+ </svg>
698
+ </button>
699
+ </div>
700
+ )}
701
+ {hasDiffSnapshot && onClearSnapshot && (
702
+ <button className={actionClass} onClick={onClearSnapshot} title="Clear diff snapshot">
703
+ Clear snapshot
704
+ </button>
705
+ )}
706
+ </div>
707
+ <div className="raw-toolbar-right">
708
+ <SplitIconButton
709
+ icon={
710
+ copyFeedback ? (
711
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
712
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
713
+ </svg>
714
+ ) : (
715
+ <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
716
+ <path
717
+ strokeLinecap="round"
718
+ strokeLinejoin="round"
719
+ d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
720
+ />
721
+ </svg>
722
+ )
723
+ }
724
+ onClick={handleCopy}
725
+ title={copyFeedback ? 'Copied!' : 'Copy document'}
726
+ chevronTitle="Copy options"
727
+ menu={[{ label: 'Copy without comments', onClick: handleCopyWithoutComments }]}
728
+ />
729
+ </div>
730
+ </div>
731
+ );
732
+
733
+ const containerClass = `raw-view flex flex-col h-full${showComments ? '' : ' raw-view-comments-hidden'}`;
734
+
735
+ if (diffEnabled && diffLines && !hasChanges) {
736
+ return (
737
+ <div ref={containerRef} className={containerClass}>
738
+ {toolbar}
739
+ <div className="flex flex-col items-center justify-center flex-1 text-content-muted px-6">
740
+ <svg
741
+ className="w-12 h-12 mb-3 text-content-faint"
742
+ fill="none"
743
+ viewBox="0 0 24 24"
744
+ stroke="currentColor"
745
+ strokeWidth={1.5}
746
+ >
747
+ <path
748
+ strokeLinecap="round"
749
+ strokeLinejoin="round"
750
+ d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
751
+ />
752
+ </svg>
753
+ <p className="text-sm font-medium text-content-secondary mb-1">No changes yet</p>
754
+ <p className="text-xs text-center leading-relaxed max-w-xs">
755
+ This view updates automatically when the file is modified.
756
+ <br />
757
+ Hand off to an agent and changes will appear here.
758
+ </p>
759
+ </div>
760
+ </div>
761
+ );
762
+ }
763
+
764
+ return (
765
+ <div ref={containerRef} className={containerClass}>
766
+ {toolbar}
767
+
768
+ <div className="flex-1 overflow-y-auto px-8 pt-4 pb-[50vh] lg:px-12 xl:px-16">
769
+ <div className="max-w-3xl mx-auto">
770
+ <div ref={tableRef} className="raw-view-table">
771
+ {displayRows.map((row, i) => {
772
+ const diffClass =
773
+ row.type === 'added'
774
+ ? 'raw-line-diff-added'
775
+ : row.type === 'removed'
776
+ ? 'raw-line-diff-removed'
777
+ : '';
778
+ return (
779
+ <div
780
+ key={i}
781
+ className={`raw-line ${diffClass}`}
782
+ data-heading-id={
783
+ row.sourceLineIndex != null
784
+ ? headingIdsByLine.get(row.sourceLineIndex)
785
+ : undefined
786
+ }
787
+ >
788
+ <span className="raw-line-number">{row.lineNo}</span>
789
+ <span className="raw-line-content" />
790
+ </div>
791
+ );
792
+ })}
793
+ </div>
794
+ </div>
795
+ </div>
796
+ </div>
797
+ );
798
+ });