agent-canvas 0.2.0 → 0.6.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 (98) hide show
  1. package/README.md +42 -17
  2. package/bin/agent-canvas.js +7 -7
  3. package/bin/agent-canvas.ts +48 -37
  4. package/bin/lib/commands/daemon.ts +46 -43
  5. package/bin/lib/commands/exec.ts +100 -100
  6. package/bin/lib/commands/fetch.ts +15 -15
  7. package/bin/lib/commands/install.ts +36 -36
  8. package/bin/lib/commands/instructions.ts +51 -51
  9. package/bin/lib/commands/push.ts +115 -97
  10. package/bin/lib/commands/review.ts +190 -0
  11. package/bin/lib/commands/watch.ts +24 -24
  12. package/bin/lib/config.ts +12 -12
  13. package/bin/lib/daemon-lifecycle.ts +47 -51
  14. package/bin/lib/helpers.ts +82 -76
  15. package/bin/lib/instructions/app-review.md +88 -0
  16. package/bin/lib/instructions/component-checklist.md +7 -5
  17. package/bin/lib/instructions/component-custom.md +20 -14
  18. package/bin/lib/instructions/component-diff.md +4 -3
  19. package/bin/lib/instructions/component-item.md +18 -8
  20. package/bin/lib/instructions/component-markdown.md +4 -2
  21. package/bin/lib/instructions/component-mermaid.md +12 -6
  22. package/bin/lib/instructions/component-multichoice.md +6 -2
  23. package/bin/lib/instructions/component-note.md +1 -1
  24. package/bin/lib/instructions/component-priority.md +1 -1
  25. package/bin/lib/instructions/component-rangeinput.md +9 -2
  26. package/bin/lib/instructions/component-secretinput.md +12 -11
  27. package/bin/lib/instructions/component-svg.md +62 -16
  28. package/bin/lib/instructions/component-table.md +6 -6
  29. package/bin/lib/instructions/component-usefeedback.md +8 -8
  30. package/bin/lib/instructions/component-userinput.md +6 -2
  31. package/bin/lib/instructions/flow-feature.md +22 -5
  32. package/bin/lib/instructions/flow-plan.md +5 -5
  33. package/bin/lib/instructions/flow-runbook.md +28 -25
  34. package/bin/lib/instructions/overview.md +27 -18
  35. package/bin/lib/instructions/theming.md +5 -0
  36. package/daemon/build.ts +171 -152
  37. package/daemon/client/App.tsx +1205 -1077
  38. package/daemon/client/CompareView.tsx +478 -458
  39. package/daemon/client/FileBrowser.tsx +241 -203
  40. package/daemon/client/FileViewer.tsx +220 -211
  41. package/daemon/client/OverviewView.tsx +283 -267
  42. package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
  43. package/daemon/client/RevisionSelect.tsx +111 -95
  44. package/daemon/client/SessionSwitcher.tsx +81 -81
  45. package/daemon/client/ShareDialog.tsx +211 -212
  46. package/daemon/client/apiSchemas.ts +78 -0
  47. package/daemon/client/canvas-runtime.ts +1 -1
  48. package/daemon/client/clientApi.ts +194 -206
  49. package/daemon/client/components/SecretInput.tsx +138 -138
  50. package/daemon/client/components/index.ts +2 -2
  51. package/daemon/client/domDiff.ts +341 -333
  52. package/daemon/client/errorMessage.ts +3 -0
  53. package/daemon/client/exportMarkdown.ts +222 -201
  54. package/daemon/client/lcs.ts +19 -14
  55. package/daemon/client/shareCrypto.ts +57 -57
  56. package/daemon/client/unifiedDiff.ts +660 -649
  57. package/daemon/dist/annotations-runtime.js +1 -1
  58. package/daemon/dist/client.css +2 -2
  59. package/daemon/dist/client.js +77 -80
  60. package/daemon/dist/components.js +59 -59
  61. package/daemon/dist/preact-compat.js +1 -1
  62. package/daemon/dist/review.js +361 -0
  63. package/daemon/dist/runtime.js +1 -1
  64. package/daemon/langMap.ts +24 -6
  65. package/daemon/review-widget/anchor.ts +187 -0
  66. package/daemon/review-widget/client.ts +95 -0
  67. package/daemon/review-widget/index.tsx +36 -0
  68. package/daemon/review-widget/markup.ts +71 -0
  69. package/daemon/review-widget/screenshot.ts +91 -0
  70. package/daemon/review-widget/source.ts +103 -0
  71. package/daemon/review-widget/styles.css +359 -0
  72. package/daemon/review-widget/text-modules.d.ts +4 -0
  73. package/daemon/review-widget/tsconfig.json +16 -0
  74. package/daemon/review-widget/widget.tsx +436 -0
  75. package/daemon/src/components.ts +3 -3
  76. package/daemon/src/handlers/api.ts +330 -328
  77. package/daemon/src/handlers/files.ts +112 -105
  78. package/daemon/src/handlers/static.ts +47 -46
  79. package/daemon/src/handlers/uploads.ts +61 -56
  80. package/daemon/src/paths.ts +13 -10
  81. package/daemon/src/remote-feedback.ts +192 -192
  82. package/daemon/src/review/markdown.ts +52 -0
  83. package/daemon/src/review/model.ts +132 -0
  84. package/daemon/src/review/proxy.ts +177 -0
  85. package/daemon/src/review/server.ts +199 -0
  86. package/daemon/src/review/store.ts +151 -0
  87. package/daemon/src/server.ts +127 -86
  88. package/daemon/src/share.ts +163 -169
  89. package/daemon/src/stubFetch.ts +7 -0
  90. package/daemon/tsconfig.json +19 -19
  91. package/package.json +47 -47
  92. package/skills/canvas/SKILL.md +9 -1
  93. package/bin/lib/commands/exec.test.ts +0 -27
  94. package/bin/lib/helpers.test.ts +0 -79
  95. package/daemon/src/remote-feedback.test.ts +0 -165
  96. package/daemon/src/secrets-api.test.ts +0 -123
  97. package/daemon/src/share-e2e.test.ts +0 -277
  98. package/daemon/src/share.test.ts +0 -289
@@ -1,222 +1,231 @@
1
- import React, { useEffect, useState, useContext, useCallback, useRef, useMemo } from "react";
2
- import { SessionContext } from "#canvas/runtime";
3
- import type { AnnotationContext } from "#canvas/runtime";
4
- import { useAnnotations } from "@fabrika/canvas-kernel/client";
5
- import { wrapRangeWithMark } from "@fabrika/annotations";
6
- import { LANG_MAP } from "../langMap";
7
- import { generateAnnotationId } from "@fabrika/annotations";
8
- import { useTextAnnotation } from "@fabrika/annotations";
9
- import { FS_AVAILABLE } from "./clientApi";
1
+ import { SessionContext } from '#canvas/runtime'
2
+ import type { AnnotationContext } from '#canvas/runtime'
3
+ import { wrapRangeWithMark } from '@fabrika/annotations'
4
+ import { useTextAnnotation } from '@fabrika/annotations'
5
+ import { filePreviewResponseSchema, useAnnotations } from '@fabrika/canvas-kernel/client'
6
+ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
7
+ import { LANG_MAP } from '../langMap'
8
+ import { FS_AVAILABLE } from './clientApi'
10
9
 
11
10
  /** Walk up from a node to find the parent line div with data-line-num */
12
11
  function findLineDiv(node: Node): HTMLElement | null {
13
- let el: HTMLElement | null = node instanceof HTMLElement ? node : node.parentElement;
14
- while (el) {
15
- if (el.hasAttribute("data-line-num")) return el;
16
- el = el.parentElement;
17
- }
18
- return null;
12
+ let el: HTMLElement | null = node instanceof HTMLElement ? node : node.parentElement
13
+ while (el) {
14
+ if (el.hasAttribute('data-line-num')) return el
15
+ el = el.parentElement
16
+ }
17
+ return null
19
18
  }
20
19
 
21
20
  /** Build AnnotationContext from a text selection Range inside file content */
22
21
  function buildFileContext(range: Range): AnnotationContext {
23
- const snippet = range.toString().trim();
24
- const startLineDiv = findLineDiv(range.startContainer);
25
- const endLineDiv = findLineDiv(range.endContainer);
26
- const lineStart = startLineDiv ? parseInt(startLineDiv.getAttribute("data-line-num")!, 10) : undefined;
27
- const lineEnd = endLineDiv ? parseInt(endLineDiv.getAttribute("data-line-num")!, 10) : undefined;
28
-
29
- let before = "";
30
- let after = "";
31
- if (snippet.length < 30 && startLineDiv && startLineDiv === endLineDiv) {
32
- const code = startLineDiv.querySelector("code");
33
- if (code) {
34
- const lineText = code.textContent || "";
35
- const idx = lineText.indexOf(snippet);
36
- if (idx >= 0) {
37
- before = lineText.slice(0, idx).trimStart();
38
- after = lineText.slice(idx + snippet.length).trimEnd();
39
- }
40
- }
41
- }
42
-
43
- return { before, after, hierarchy: [], lineStart, lineEnd };
22
+ const snippet = range.toString().trim()
23
+ const startLineDiv = findLineDiv(range.startContainer)
24
+ const endLineDiv = findLineDiv(range.endContainer)
25
+ const lineStart = startLineDiv ? parseInt(startLineDiv.getAttribute('data-line-num')!, 10) : undefined
26
+ const lineEnd = endLineDiv ? parseInt(endLineDiv.getAttribute('data-line-num')!, 10) : undefined
27
+
28
+ let before = ''
29
+ let after = ''
30
+ if (snippet.length < 30 && startLineDiv && startLineDiv === endLineDiv) {
31
+ const code = startLineDiv.querySelector('code')
32
+ if (code) {
33
+ const lineText = code.textContent || ''
34
+ const idx = lineText.indexOf(snippet)
35
+ if (idx >= 0) {
36
+ before = lineText.slice(0, idx).trimStart()
37
+ after = lineText.slice(idx + snippet.length).trimEnd()
38
+ }
39
+ }
40
+ }
41
+
42
+ return { before, after, hierarchy: [], lineStart, lineEnd }
43
+ }
44
+
45
+ function HighlightedLine({ html }: { html: string }) {
46
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: highlight.js escapes the file text and adds only its own span markup.
47
+ return <code className="py-px" dangerouslySetInnerHTML={{ __html: html }} />
44
48
  }
45
49
 
46
50
  interface FileViewerProps {
47
- path: string;
51
+ path: string
48
52
  }
49
53
 
50
54
  export function FileViewer({ path }: FileViewerProps) {
51
- if (!FS_AVAILABLE) {
52
- return (
53
- <div className="max-w-[720px] mx-auto px-6 pt-12 pb-32">
54
- <div className="p-4 rounded-lg border border-border-subtle bg-bg-surface">
55
- <div className="text-[11px] uppercase tracking-widest text-text-tertiary font-body mb-2">
56
- File not available
57
- </div>
58
- <p className="text-[13px] text-text-secondary font-body leading-relaxed">
59
- Opening <code className="px-1 py-0.5 rounded bg-bg-elevated text-text-primary text-[11px]">{path}</code> requires
60
- filesystem access, which isn't available in shared view. If the
61
- canvas author wanted this file visible, they should embed it as
62
- a <code className="px-1 py-0.5 rounded bg-bg-elevated text-text-primary text-[11px]">&lt;FilePreview&gt;</code> in the canvas.
63
- </p>
64
- </div>
65
- </div>
66
- );
67
- }
68
- const sessionId = useContext(SessionContext);
69
- const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
70
- const [content, setContent] = useState<string | null>(null);
71
- const [language, setLanguage] = useState("text");
72
- const [loading, setLoading] = useState(true);
73
- const [error, setError] = useState<string | null>(null);
74
- const contentRef = useRef<HTMLPreElement>(null);
75
-
76
- // Line gutter selection state
77
- const [lineSelectStart, setLineSelectStart] = useState<number | null>(null);
78
- const [lineSelectEnd, setLineSelectEnd] = useState<number | null>(null);
79
- const lineSelectStartRef = useRef<number | null>(null);
80
- const lineSelectEndRef = useRef<number | null>(null);
81
- const linesRef = useRef<string[]>([]);
82
-
83
- const fileAnns = annotations.filter((a) => a.filePath === path);
84
-
85
- const scrollContainer = contentRef.current?.closest("#plan-scroll-container") as HTMLElement | null;
86
-
87
- const { popovers, openCreatePopover } = useTextAnnotation({
88
- containerRef: contentRef,
89
- restoreKey: `${content}:${path}`,
90
- restoreAnnotations: fileAnns,
91
- extractContext: buildFileContext,
92
- filePath: path,
93
- scrollContainer,
94
- });
95
-
96
- useEffect(() => {
97
- setLoading(true);
98
- setError(null);
99
- fetch(`/api/file?session=${sessionId}&path=${encodeURIComponent(path)}`)
100
- .then((r) => r.json())
101
- .then((data: any) => {
102
- if (data.error) { setError(data.error); setLoading(false); }
103
- else { setContent(data.content); setLanguage(data.language || "text"); setLoading(false); }
104
- })
105
- .catch(() => { setError("Failed to fetch file"); setLoading(false); });
106
- }, [sessionId, path]);
107
-
108
- // Line gutter: mousedown starts line selection
109
- const handleGutterMouseDown = useCallback((e: React.MouseEvent, lineNum: number) => {
110
- e.preventDefault(); // Prevent text selection
111
- lineSelectStartRef.current = lineNum;
112
- lineSelectEndRef.current = lineNum;
113
- setLineSelectStart(lineNum);
114
- setLineSelectEnd(lineNum);
115
- }, []);
116
-
117
- // Line gutter: mousemove + mouseup for drag selection
118
- useEffect(() => {
119
- const handleMouseMoveForLines = (e: MouseEvent) => {
120
- if (lineSelectStartRef.current === null) return;
121
- const lineDiv = (e.target as HTMLElement).closest("[data-line-num]") as HTMLElement;
122
- if (lineDiv) {
123
- const num = parseInt(lineDiv.getAttribute("data-line-num")!, 10);
124
- lineSelectEndRef.current = num;
125
- setLineSelectEnd(num);
126
- }
127
- };
128
-
129
- const handleMouseUpForLines = () => {
130
- const start = lineSelectStartRef.current;
131
- if (start === null) return;
132
- lineSelectStartRef.current = null;
133
- const end = lineSelectEndRef.current ?? start;
134
- const startLine = Math.min(start, end);
135
- const endLine = Math.max(start, end);
136
- const currentLines = linesRef.current;
137
- const snippet = currentLines.slice(startLine - 1, endLine).join("\n");
138
- if (!snippet.trim()) {
139
- setLineSelectStart(null);
140
- setLineSelectEnd(null);
141
- return;
142
- }
143
-
144
- const tempId = `__pending_${Date.now()}`;
145
- const ctx: AnnotationContext = { before: "", after: "", hierarchy: [], lineStart: startLine, lineEnd: endLine };
146
-
147
- // Wrap code content of selected lines with marks
148
- if (contentRef.current) {
149
- const lineDivs = contentRef.current.querySelectorAll("[data-line-num]");
150
- const firstDiv = lineDivs[startLine - 1] as HTMLElement;
151
- const lastDiv = lineDivs[endLine - 1] as HTMLElement;
152
- if (firstDiv && lastDiv) {
153
- const firstCode = firstDiv.querySelector("code");
154
- const lastCode = lastDiv.querySelector("code");
155
- if (firstCode && lastCode) {
156
- try {
157
- const range = document.createRange();
158
- range.setStartBefore(firstCode.firstChild || firstCode);
159
- range.setEndAfter(lastCode.lastChild || lastCode);
160
- wrapRangeWithMark(range, tempId);
161
- } catch {}
162
- }
163
- }
164
- const anchorEl = lineDivs[endLine - 1] as HTMLElement;
165
- if (anchorEl) {
166
- openCreatePopover(anchorEl, tempId, snippet, ctx);
167
- }
168
- }
169
- setLineSelectStart(null);
170
- setLineSelectEnd(null);
171
- };
172
-
173
- document.addEventListener("mousemove", handleMouseMoveForLines);
174
- document.addEventListener("mouseup", handleMouseUpForLines);
175
- return () => {
176
- document.removeEventListener("mousemove", handleMouseMoveForLines);
177
- document.removeEventListener("mouseup", handleMouseUpForLines);
178
- };
179
- }, []);
180
-
181
- // Highlight content
182
- const highlightedLines = useMemo(() => {
183
- if (!content) return null;
184
- const hljs = (window as any).hljs;
185
- if (!hljs) return null;
186
- const ext = path.split(".").pop() || "";
187
- const lang = LANG_MAP[ext];
188
- try {
189
- const result = lang ? hljs.highlight(content, { language: lang }) : hljs.highlightAuto(content);
190
- return result.value.split("\n");
191
- } catch { return null; }
192
- }, [content, path]);
193
-
194
- if (loading) return <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-[13px]">Loading {path}...</div>;
195
- if (error) return <div className="p-8 text-accent-red font-body text-[13px]">{error}</div>;
196
- if (!content) return null;
197
-
198
- const lines = content.split("\n");
199
- linesRef.current = lines;
200
-
201
- // Computed selected line range for highlighting during drag
202
- const selectedLineRange = useMemo(() => {
203
- if (lineSelectStart === null) return null;
204
- const end = lineSelectEnd ?? lineSelectStart;
205
- return { start: Math.min(lineSelectStart, end), end: Math.max(lineSelectStart, end) };
206
- }, [lineSelectStart, lineSelectEnd]);
207
-
208
- const addWholeFileWithPopup = () => {
209
- const preview = content!.split("\n").slice(0, 3).join("\n") + (lines.length > 3 ? "\n..." : "");
210
- const id = generateAnnotationId();
211
- addAnnotationWithId(id, preview, "", path);
212
- // Focus the new annotation's note in sidebar by activating it
213
- setActiveAnnotationId(id);
214
- };
215
-
216
- return (
217
- <div>
218
- {/* Code */}
219
- <pre ref={contentRef} className="text-code font-mono text-text-code bg-bg-base select-text cursor-text py-3 hljs">
55
+ if (!FS_AVAILABLE) {
56
+ return (
57
+ <div className="max-w-[720px] mx-auto px-6 pt-12 pb-32">
58
+ <div className="p-4 rounded-lg border border-border-subtle bg-bg-surface">
59
+ <div className="text-[11px] uppercase tracking-widest text-text-tertiary font-body mb-2">
60
+ File not available
61
+ </div>
62
+ <p className="text-[13px] text-text-secondary font-body leading-relaxed">
63
+ Opening <code className="px-1 py-0.5 rounded bg-bg-elevated text-text-primary text-[11px]">{path}</code>{' '}
64
+ requires filesystem access, which isn't available in shared view. If the canvas author wanted this file visible, they should embed it as a{' '}
65
+ <code className="px-1 py-0.5 rounded bg-bg-elevated text-text-primary text-[11px]">&lt;FilePreview&gt;</code> in the canvas.
66
+ </p>
67
+ </div>
68
+ </div>
69
+ )
70
+ }
71
+ return <LocalFileViewer path={path} />
72
+ }
73
+
74
+ function LocalFileViewer({ path }: FileViewerProps) {
75
+ const sessionId = useContext(SessionContext)
76
+ const { annotations } = useAnnotations()
77
+ const [content, setContent] = useState<string | null>(null)
78
+ const [loading, setLoading] = useState(true)
79
+ const [error, setError] = useState<string | null>(null)
80
+ const contentRef = useRef<HTMLPreElement>(null)
81
+
82
+ // Line gutter selection state
83
+ const [lineSelectStart, setLineSelectStart] = useState<number | null>(null)
84
+ const [lineSelectEnd, setLineSelectEnd] = useState<number | null>(null)
85
+ const lineSelectStartRef = useRef<number | null>(null)
86
+ const lineSelectEndRef = useRef<number | null>(null)
87
+ const linesRef = useRef<string[]>([])
88
+
89
+ const fileAnns = annotations.filter((a) => a.filePath === path)
90
+
91
+ const scrollContainer = contentRef.current?.closest('#plan-scroll-container') as HTMLElement | null
92
+
93
+ const { popovers, openCreatePopover } = useTextAnnotation({
94
+ containerRef: contentRef,
95
+ restoreKey: `${content}:${path}`,
96
+ restoreAnnotations: fileAnns,
97
+ extractContext: buildFileContext,
98
+ filePath: path,
99
+ scrollContainer,
100
+ })
101
+
102
+ useEffect(() => {
103
+ setLoading(true)
104
+ setError(null)
105
+ fetch(`/api/file?session=${sessionId}&path=${encodeURIComponent(path)}`)
106
+ .then((r) => r.json())
107
+ .then((raw) => {
108
+ const data = filePreviewResponseSchema.parse(raw)
109
+ if ('error' in data) {
110
+ setError(data.error)
111
+ setLoading(false)
112
+ } else {
113
+ setContent(data.content)
114
+ setLoading(false)
115
+ }
116
+ })
117
+ .catch(() => {
118
+ setError('Failed to fetch file')
119
+ setLoading(false)
120
+ })
121
+ }, [sessionId, path])
122
+
123
+ // Line gutter: mousedown starts line selection
124
+ const handleGutterMouseDown = useCallback((e: React.MouseEvent, lineNum: number) => {
125
+ e.preventDefault() // Prevent text selection
126
+ lineSelectStartRef.current = lineNum
127
+ lineSelectEndRef.current = lineNum
128
+ setLineSelectStart(lineNum)
129
+ setLineSelectEnd(lineNum)
130
+ }, [])
131
+
132
+ // Line gutter: mousemove + mouseup for drag selection
133
+ useEffect(() => {
134
+ const handleMouseMoveForLines = (e: MouseEvent) => {
135
+ if (lineSelectStartRef.current === null) return
136
+ const lineDiv = (e.target as HTMLElement).closest('[data-line-num]') as HTMLElement
137
+ if (lineDiv) {
138
+ const num = parseInt(lineDiv.getAttribute('data-line-num')!, 10)
139
+ lineSelectEndRef.current = num
140
+ setLineSelectEnd(num)
141
+ }
142
+ }
143
+
144
+ const handleMouseUpForLines = () => {
145
+ const start = lineSelectStartRef.current
146
+ if (start === null) return
147
+ lineSelectStartRef.current = null
148
+ const end = lineSelectEndRef.current ?? start
149
+ const startLine = Math.min(start, end)
150
+ const endLine = Math.max(start, end)
151
+ const currentLines = linesRef.current
152
+ const snippet = currentLines.slice(startLine - 1, endLine).join('\n')
153
+ if (!snippet.trim()) {
154
+ setLineSelectStart(null)
155
+ setLineSelectEnd(null)
156
+ return
157
+ }
158
+
159
+ const tempId = `__pending_${Date.now()}`
160
+ const ctx: AnnotationContext = { before: '', after: '', hierarchy: [], lineStart: startLine, lineEnd: endLine }
161
+
162
+ // Wrap code content of selected lines with marks
163
+ if (contentRef.current) {
164
+ const lineDivs = contentRef.current.querySelectorAll('[data-line-num]')
165
+ const firstDiv = lineDivs[startLine - 1] as HTMLElement
166
+ const lastDiv = lineDivs[endLine - 1] as HTMLElement
167
+ if (firstDiv && lastDiv) {
168
+ const firstCode = firstDiv.querySelector('code')
169
+ const lastCode = lastDiv.querySelector('code')
170
+ if (firstCode && lastCode) {
171
+ try {
172
+ const range = document.createRange()
173
+ range.setStartBefore(firstCode.firstChild || firstCode)
174
+ range.setEndAfter(lastCode.lastChild || lastCode)
175
+ wrapRangeWithMark(range, tempId)
176
+ } catch {}
177
+ }
178
+ }
179
+ const anchorEl = lineDivs[endLine - 1] as HTMLElement
180
+ if (anchorEl) {
181
+ openCreatePopover(anchorEl, tempId, snippet, ctx)
182
+ }
183
+ }
184
+ setLineSelectStart(null)
185
+ setLineSelectEnd(null)
186
+ }
187
+
188
+ document.addEventListener('mousemove', handleMouseMoveForLines)
189
+ document.addEventListener('mouseup', handleMouseUpForLines)
190
+ return () => {
191
+ document.removeEventListener('mousemove', handleMouseMoveForLines)
192
+ document.removeEventListener('mouseup', handleMouseUpForLines)
193
+ }
194
+ }, [])
195
+
196
+ // Highlight content
197
+ const highlightedLines = useMemo(() => {
198
+ if (!content) return null
199
+ const hljs = window.hljs
200
+ if (!hljs) return null
201
+ const ext = path.split('.').pop() || ''
202
+ const lang = LANG_MAP[ext]
203
+ try {
204
+ const result = lang ? hljs.highlight(content, { language: lang }) : hljs.highlightAuto(content)
205
+ return result.value.split('\n')
206
+ } catch {
207
+ return null
208
+ }
209
+ }, [content, path])
210
+
211
+ // Computed selected line range for highlighting during drag
212
+ const selectedLineRange = useMemo(() => {
213
+ if (lineSelectStart === null) return null
214
+ const end = lineSelectEnd ?? lineSelectStart
215
+ return { start: Math.min(lineSelectStart, end), end: Math.max(lineSelectStart, end) }
216
+ }, [lineSelectStart, lineSelectEnd])
217
+
218
+ if (loading) return <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-[13px]">Loading {path}...</div>
219
+ if (error) return <div className="p-8 text-accent-red font-body text-[13px]">{error}</div>
220
+ if (!content) return null
221
+
222
+ const lines = content.split('\n')
223
+ linesRef.current = lines
224
+
225
+ return (
226
+ <div>
227
+ {/* Code */}
228
+ <pre ref={contentRef} className="text-code font-mono text-text-code bg-bg-base select-text cursor-text py-3 hljs">
220
229
  {lines.map((line, i) => {
221
230
  const lineNum = i + 1;
222
231
  const isSelected = selectedLineRange && lineNum >= selectedLineRange.start && lineNum <= selectedLineRange.end;
@@ -227,16 +236,16 @@ export function FileViewer({ path }: FileViewerProps) {
227
236
  onMouseDown={(e) => handleGutterMouseDown(e, lineNum)}
228
237
  >{lineNum}</span>
229
238
  {highlightedLines ? (
230
- <code className="py-px" dangerouslySetInnerHTML={{ __html: highlightedLines[i] || " " }} />
239
+ <HighlightedLine html={highlightedLines[i] || " "} />
231
240
  ) : (
232
241
  <code className="py-px">{line || " "}</code>
233
242
  )}
234
243
  </div>
235
244
  );
236
245
  })}
237
- </pre>
246
+ </pre>
238
247
 
239
- {popovers}
240
- </div>
241
- );
248
+ {popovers}
249
+ </div>
250
+ )
242
251
  }