agent-canvas 0.2.1 → 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.
- package/README.md +42 -17
- package/bin/agent-canvas.js +7 -7
- package/bin/agent-canvas.ts +48 -37
- package/bin/lib/commands/daemon.ts +46 -43
- package/bin/lib/commands/exec.ts +100 -100
- package/bin/lib/commands/fetch.ts +15 -15
- package/bin/lib/commands/install.ts +36 -36
- package/bin/lib/commands/instructions.ts +51 -51
- package/bin/lib/commands/push.ts +115 -97
- package/bin/lib/commands/review.ts +190 -0
- package/bin/lib/commands/watch.ts +24 -24
- package/bin/lib/config.ts +12 -12
- package/bin/lib/daemon-lifecycle.ts +47 -51
- package/bin/lib/helpers.ts +82 -76
- package/bin/lib/instructions/app-review.md +88 -0
- package/bin/lib/instructions/component-checklist.md +7 -5
- package/bin/lib/instructions/component-custom.md +20 -14
- package/bin/lib/instructions/component-diff.md +4 -3
- package/bin/lib/instructions/component-item.md +18 -8
- package/bin/lib/instructions/component-markdown.md +4 -2
- package/bin/lib/instructions/component-mermaid.md +12 -6
- package/bin/lib/instructions/component-multichoice.md +6 -2
- package/bin/lib/instructions/component-note.md +1 -1
- package/bin/lib/instructions/component-priority.md +1 -1
- package/bin/lib/instructions/component-rangeinput.md +9 -2
- package/bin/lib/instructions/component-secretinput.md +12 -11
- package/bin/lib/instructions/component-svg.md +62 -16
- package/bin/lib/instructions/component-table.md +6 -6
- package/bin/lib/instructions/component-usefeedback.md +8 -8
- package/bin/lib/instructions/component-userinput.md +6 -2
- package/bin/lib/instructions/flow-feature.md +22 -5
- package/bin/lib/instructions/flow-plan.md +5 -5
- package/bin/lib/instructions/flow-runbook.md +28 -25
- package/bin/lib/instructions/overview.md +27 -18
- package/bin/lib/instructions/theming.md +5 -0
- package/daemon/build.ts +171 -152
- package/daemon/client/App.tsx +1205 -1077
- package/daemon/client/CompareView.tsx +478 -458
- package/daemon/client/FileBrowser.tsx +241 -203
- package/daemon/client/FileViewer.tsx +220 -211
- package/daemon/client/OverviewView.tsx +283 -267
- package/daemon/client/ReviewerIdentityDialog.tsx +72 -71
- package/daemon/client/RevisionSelect.tsx +111 -95
- package/daemon/client/SessionSwitcher.tsx +81 -81
- package/daemon/client/ShareDialog.tsx +211 -212
- package/daemon/client/apiSchemas.ts +78 -0
- package/daemon/client/canvas-runtime.ts +1 -1
- package/daemon/client/clientApi.ts +194 -206
- package/daemon/client/components/SecretInput.tsx +138 -138
- package/daemon/client/components/index.ts +2 -2
- package/daemon/client/domDiff.ts +341 -333
- package/daemon/client/errorMessage.ts +3 -0
- package/daemon/client/exportMarkdown.ts +222 -201
- package/daemon/client/lcs.ts +19 -14
- package/daemon/client/shareCrypto.ts +57 -57
- package/daemon/client/unifiedDiff.ts +660 -649
- package/daemon/dist/annotations-runtime.js +1 -1
- package/daemon/dist/client.css +2 -2
- package/daemon/dist/client.js +77 -80
- package/daemon/dist/components.js +59 -59
- package/daemon/dist/preact-compat.js +1 -1
- package/daemon/dist/review.js +361 -0
- package/daemon/dist/runtime.js +1 -1
- package/daemon/langMap.ts +24 -6
- package/daemon/review-widget/anchor.ts +187 -0
- package/daemon/review-widget/client.ts +95 -0
- package/daemon/review-widget/index.tsx +36 -0
- package/daemon/review-widget/markup.ts +71 -0
- package/daemon/review-widget/screenshot.ts +91 -0
- package/daemon/review-widget/source.ts +103 -0
- package/daemon/review-widget/styles.css +359 -0
- package/daemon/review-widget/text-modules.d.ts +4 -0
- package/daemon/review-widget/tsconfig.json +16 -0
- package/daemon/review-widget/widget.tsx +436 -0
- package/daemon/src/components.ts +3 -3
- package/daemon/src/handlers/api.ts +330 -328
- package/daemon/src/handlers/files.ts +112 -106
- package/daemon/src/handlers/static.ts +47 -46
- package/daemon/src/handlers/uploads.ts +61 -56
- package/daemon/src/paths.ts +13 -10
- package/daemon/src/remote-feedback.ts +192 -192
- package/daemon/src/review/markdown.ts +52 -0
- package/daemon/src/review/model.ts +132 -0
- package/daemon/src/review/proxy.ts +177 -0
- package/daemon/src/review/server.ts +199 -0
- package/daemon/src/review/store.ts +151 -0
- package/daemon/src/server.ts +127 -86
- package/daemon/src/share.ts +163 -169
- package/daemon/src/stubFetch.ts +7 -0
- package/daemon/tsconfig.json +19 -19
- package/package.json +47 -47
- package/skills/canvas/SKILL.md +5 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- package/daemon/src/files-api.test.ts +0 -78
- package/daemon/src/remote-feedback.test.ts +0 -165
- package/daemon/src/secrets-api.test.ts +0 -123
- package/daemon/src/share-e2e.test.ts +0 -277
- package/daemon/src/share.test.ts +0 -289
|
@@ -1,494 +1,514 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import { RevisionContext, type RevisionInfo
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
1
|
+
import { useTextAnnotation } from '@fabrika/annotations'
|
|
2
|
+
import { extractContext } from '@fabrika/annotations'
|
|
3
|
+
import { RevisionContext, type RevisionInfo } from '@fabrika/canvas-kernel/client'
|
|
4
|
+
import { useAnnotations } from '@fabrika/canvas-kernel/client'
|
|
5
|
+
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import { runDomDiff } from './domDiff'
|
|
7
|
+
import { RevisionSelect } from './RevisionSelect'
|
|
8
|
+
import { buildUnifiedDom, extractBlockTree, matchBlocks } from './unifiedDiff'
|
|
9
9
|
|
|
10
10
|
interface CompareViewProps {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
initialLeft: number
|
|
12
|
+
initialRight: number
|
|
13
|
+
sessionId: string
|
|
14
|
+
onExit: () => void
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
type CompareMode =
|
|
17
|
+
type CompareMode = 'unified' | 'side-by-side'
|
|
18
18
|
|
|
19
19
|
/** Get union of canvas filenames from two revisions */
|
|
20
20
|
function getCompareFiles(revisions: RevisionInfo[], leftRev: number, rightRev: number): string[] {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
const left = revisions.find(r => r.revision === leftRev)
|
|
22
|
+
const right = revisions.find(r => r.revision === rightRev)
|
|
23
|
+
const files = new Set<string>()
|
|
24
|
+
for (const cf of left?.canvasFiles || []) files.add(cf.filename)
|
|
25
|
+
for (const cf of right?.canvasFiles || []) files.add(cf.filename)
|
|
26
|
+
return [...files].sort()
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function CompareView({ initialLeft, initialRight, sessionId, onExit }: CompareViewProps) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
30
|
+
const [leftRev, setLeftRev] = useState(initialLeft)
|
|
31
|
+
const [rightRev, setRightRev] = useState(initialRight)
|
|
32
|
+
const [mode, setMode] = useState<CompareMode>('unified')
|
|
33
|
+
const [hasChanges, setHasChanges] = useState(true)
|
|
34
|
+
const [diffDone, setDiffDone] = useState(false)
|
|
35
|
+
const { revisions } = useContext(RevisionContext)
|
|
36
|
+
|
|
37
|
+
const compareFiles = useMemo(() => getCompareFiles(revisions, leftRev, rightRev), [revisions, leftRev, rightRev])
|
|
38
|
+
const [selectedFile, setSelectedFile] = useState<string>(() => compareFiles[0] || 'plan.jsx')
|
|
39
|
+
|
|
40
|
+
// Reset selected file when revisions change
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!compareFiles.includes(selectedFile)) {
|
|
43
|
+
setSelectedFile(compareFiles[0] || 'plan.jsx')
|
|
44
|
+
}
|
|
45
|
+
}, [compareFiles, selectedFile])
|
|
46
|
+
|
|
47
|
+
// Check if file exists in each revision
|
|
48
|
+
const leftHasFile = useMemo(() => {
|
|
49
|
+
const rev = revisions.find(r => r.revision === leftRev)
|
|
50
|
+
return rev?.canvasFiles.some(cf => cf.filename === selectedFile) ?? false
|
|
51
|
+
}, [revisions, leftRev, selectedFile])
|
|
52
|
+
const rightHasFile = useMemo(() => {
|
|
53
|
+
const rev = revisions.find(r => r.revision === rightRev)
|
|
54
|
+
return rev?.canvasFiles.some(cf => cf.filename === selectedFile) ?? false
|
|
55
|
+
}, [revisions, rightRev, selectedFile])
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className="flex flex-col h-screen">
|
|
59
|
+
{/* Header */}
|
|
60
|
+
<div className="flex items-center justify-between px-6 py-3 border-b border-border-subtle bg-bg-surface flex-shrink-0">
|
|
61
|
+
<div className="flex items-center gap-3">
|
|
62
|
+
<span className="text-[13px] font-body font-medium text-text-primary">Compare</span>
|
|
63
|
+
<RevisionSelect value={leftRev} onChange={setLeftRev} accent="red" />
|
|
64
|
+
<span className="text-text-tertiary text-[13px]">→</span>
|
|
65
|
+
<RevisionSelect value={rightRev} onChange={setRightRev} accent="green" />
|
|
66
|
+
{diffDone && !hasChanges && (
|
|
67
|
+
<span className="text-[11px] font-body text-text-tertiary bg-bg-elevated px-2 py-0.5 rounded">
|
|
68
|
+
No changes
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
<div className="flex items-center gap-4">
|
|
73
|
+
{/* Mode toggle */}
|
|
74
|
+
<div className="flex items-center bg-bg-elevated rounded-md p-0.5">
|
|
75
|
+
<button
|
|
76
|
+
onClick={() => setMode('unified')}
|
|
77
|
+
className={`text-[11px] font-body px-2.5 py-1 rounded transition-colors ${
|
|
78
|
+
mode === 'unified'
|
|
79
|
+
? 'bg-bg-surface text-text-primary font-medium shadow-sm'
|
|
80
|
+
: 'text-text-tertiary hover:text-text-secondary'
|
|
81
|
+
}`}
|
|
82
|
+
>
|
|
83
|
+
Unified
|
|
84
|
+
</button>
|
|
85
|
+
<button
|
|
86
|
+
onClick={() => setMode('side-by-side')}
|
|
87
|
+
className={`text-[11px] font-body px-2.5 py-1 rounded transition-colors ${
|
|
88
|
+
mode === 'side-by-side'
|
|
89
|
+
? 'bg-bg-surface text-text-primary font-medium shadow-sm'
|
|
90
|
+
: 'text-text-tertiary hover:text-text-secondary'
|
|
91
|
+
}`}
|
|
92
|
+
>
|
|
93
|
+
Side-by-side
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Sync scroll — only in side-by-side mode */}
|
|
98
|
+
{mode === 'side-by-side' && <SyncScrollCheckbox />}
|
|
99
|
+
|
|
100
|
+
<button
|
|
101
|
+
onClick={onExit}
|
|
102
|
+
className="text-[12px] font-body font-medium text-text-secondary hover:text-text-primary px-3 py-1.5 rounded-md hover:bg-bg-elevated transition-colors"
|
|
103
|
+
>
|
|
104
|
+
Exit Compare
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{/* Canvas file selector — only show when multiple files */}
|
|
110
|
+
{compareFiles.length > 1 && (
|
|
111
|
+
<div className="flex items-center gap-1 px-6 py-2 border-b border-border-subtle bg-bg-surface flex-shrink-0 overflow-x-auto">
|
|
112
|
+
{compareFiles.map(filename => {
|
|
113
|
+
const label = filename.replace(/\.jsx$/, '')
|
|
114
|
+
const isSelected = filename === selectedFile
|
|
115
|
+
const inLeft = revisions.find(r => r.revision === leftRev)?.canvasFiles.some(cf => cf.filename === filename)
|
|
116
|
+
const inRight = revisions.find(r => r.revision === rightRev)?.canvasFiles.some(cf => cf.filename === filename)
|
|
117
|
+
const statusLabel = !inLeft ? ' (added)' : !inRight ? ' (removed)' : ''
|
|
118
|
+
return (
|
|
119
|
+
<button
|
|
120
|
+
key={filename}
|
|
121
|
+
onClick={() => setSelectedFile(filename)}
|
|
122
|
+
className={`text-[12px] font-body px-3 py-1 rounded-md transition-colors whitespace-nowrap ${
|
|
123
|
+
isSelected
|
|
124
|
+
? 'bg-bg-elevated text-text-primary font-medium'
|
|
125
|
+
: 'text-text-tertiary hover:text-text-secondary hover:bg-bg-elevated'
|
|
126
|
+
}`}
|
|
127
|
+
>
|
|
128
|
+
{label}
|
|
129
|
+
{statusLabel && (
|
|
130
|
+
<span className={`ml-1 text-[10px] ${!inLeft ? 'text-accent-green' : 'text-accent-red'}`}>
|
|
131
|
+
{statusLabel}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
134
|
+
</button>
|
|
135
|
+
)
|
|
136
|
+
})}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{/* Show placeholder for files that only exist in one revision */}
|
|
141
|
+
{!leftHasFile || !rightHasFile
|
|
142
|
+
? (
|
|
143
|
+
<div className="flex-1 flex items-center justify-center">
|
|
144
|
+
<div className="text-center text-text-tertiary font-body">
|
|
145
|
+
<div className="text-lg mb-2">
|
|
146
|
+
{!leftHasFile ? 'New file' : 'Removed file'}
|
|
147
|
+
</div>
|
|
148
|
+
<div className="text-[13px]">
|
|
149
|
+
{selectedFile.replace(/\.jsx$/, '')} {!leftHasFile ? 'was added' : 'was removed'} in this revision
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
: mode === 'unified'
|
|
155
|
+
? (
|
|
156
|
+
<UnifiedView
|
|
157
|
+
key={`unified-${leftRev}-${rightRev}-${selectedFile}`}
|
|
158
|
+
sessionId={sessionId}
|
|
159
|
+
leftRev={leftRev}
|
|
160
|
+
rightRev={rightRev}
|
|
161
|
+
filename={selectedFile}
|
|
162
|
+
onDiffResult={(done, changes) => {
|
|
163
|
+
setDiffDone(done)
|
|
164
|
+
setHasChanges(changes)
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
)
|
|
168
|
+
: (
|
|
169
|
+
<SideBySideView
|
|
170
|
+
key={`sbs-${leftRev}-${rightRev}-${selectedFile}`}
|
|
171
|
+
sessionId={sessionId}
|
|
172
|
+
leftRev={leftRev}
|
|
173
|
+
rightRev={rightRev}
|
|
174
|
+
filename={selectedFile}
|
|
175
|
+
onDiffResult={(done, changes) => {
|
|
176
|
+
setDiffDone(done)
|
|
177
|
+
setHasChanges(changes)
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
173
183
|
}
|
|
174
184
|
|
|
175
185
|
/* ── Sync scroll checkbox (lifted out to avoid re-render issues) ── */
|
|
176
186
|
|
|
177
187
|
function SyncScrollCheckbox() {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
188
|
+
const [checked, setChecked] = useState(true)
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
document.documentElement.dataset.syncScroll = checked ? '1' : '0'
|
|
191
|
+
return () => {
|
|
192
|
+
delete document.documentElement.dataset.syncScroll
|
|
193
|
+
}
|
|
194
|
+
}, [checked])
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
|
198
|
+
<input
|
|
199
|
+
type="checkbox"
|
|
200
|
+
checked={checked}
|
|
201
|
+
onChange={(e) => setChecked(e.target.checked)}
|
|
202
|
+
className="accent-accent-amber w-3.5 h-3.5"
|
|
203
|
+
/>
|
|
204
|
+
<span className="text-[11px] font-body text-text-secondary">Sync scroll</span>
|
|
205
|
+
</label>
|
|
206
|
+
)
|
|
195
207
|
}
|
|
196
208
|
|
|
197
209
|
/* ── Unified view ── */
|
|
198
210
|
|
|
199
211
|
interface ViewProps {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
212
|
+
sessionId: string
|
|
213
|
+
leftRev: number
|
|
214
|
+
rightRev: number
|
|
215
|
+
filename: string
|
|
216
|
+
onDiffResult: (done: boolean, hasChanges: boolean) => void
|
|
205
217
|
}
|
|
206
218
|
|
|
207
219
|
function UnifiedView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
220
|
+
const oldRef = useRef<HTMLDivElement>(null)
|
|
221
|
+
const newRef = useRef<HTMLDivElement>(null)
|
|
222
|
+
const unifiedRef = useRef<HTMLDivElement>(null)
|
|
223
|
+
const [leftReady, setLeftReady] = useState(false)
|
|
224
|
+
const [rightReady, setRightReady] = useState(false)
|
|
225
|
+
const [done, setDone] = useState(false)
|
|
226
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
227
|
+
|
|
228
|
+
const [slow, setSlow] = useState(false)
|
|
229
|
+
|
|
230
|
+
const { annotations } = useAnnotations()
|
|
231
|
+
const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename))
|
|
232
|
+
const { popovers } = useTextAnnotation({
|
|
233
|
+
containerRef: unifiedRef,
|
|
234
|
+
restoreKey: done,
|
|
235
|
+
restoreAnnotations: canvasAnnotations,
|
|
236
|
+
extractContext: (range) => extractContext(range, unifiedRef.current!),
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// Detect stuck loading
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (done || loadError) return
|
|
242
|
+
const id = setTimeout(() => {
|
|
243
|
+
console.warn('[unified-diff] still loading after 10s', {
|
|
244
|
+
leftReady,
|
|
245
|
+
rightReady,
|
|
246
|
+
oldRef: !!oldRef.current,
|
|
247
|
+
newRef: !!newRef.current,
|
|
248
|
+
})
|
|
249
|
+
setSlow(true)
|
|
250
|
+
}, 10_000)
|
|
251
|
+
return () => clearTimeout(id)
|
|
252
|
+
}, [done, loadError, leftReady, rightReady])
|
|
253
|
+
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!leftReady || !rightReady || done) return
|
|
256
|
+
if (!oldRef.current || !newRef.current) return
|
|
257
|
+
|
|
258
|
+
requestAnimationFrame(() => {
|
|
259
|
+
requestAnimationFrame(() => {
|
|
260
|
+
if (!oldRef.current || !newRef.current || !unifiedRef.current) {
|
|
261
|
+
console.warn('[unified-diff] refs lost between rAF frames', {
|
|
262
|
+
old: !!oldRef.current,
|
|
263
|
+
new: !!newRef.current,
|
|
264
|
+
unified: !!unifiedRef.current,
|
|
265
|
+
})
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const oldTree = extractBlockTree(oldRef.current)
|
|
271
|
+
const newTree = extractBlockTree(newRef.current)
|
|
272
|
+
const matches = matchBlocks(oldTree, newTree)
|
|
273
|
+
const { fragment, hasChanges } = buildUnifiedDom(matches)
|
|
274
|
+
|
|
275
|
+
unifiedRef.current.innerHTML = ''
|
|
276
|
+
unifiedRef.current.appendChild(fragment)
|
|
277
|
+
setDone(true)
|
|
278
|
+
onDiffResult(true, hasChanges)
|
|
279
|
+
} catch (e) {
|
|
280
|
+
console.error('[unified-diff] diff computation failed', e)
|
|
281
|
+
setLoadError(e instanceof Error ? e.message : String(e))
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
}, [leftReady, rightReady, done])
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
289
|
+
{/* Offscreen source panels */}
|
|
290
|
+
{!done && !loadError && (
|
|
291
|
+
<div style={{ position: 'absolute', left: -9999, visibility: 'hidden' as const, pointerEvents: 'none' as const, width: 720 }}>
|
|
292
|
+
<ComparePanelRenderer
|
|
293
|
+
ref={oldRef}
|
|
294
|
+
sessionId={sessionId}
|
|
295
|
+
revision={leftRev}
|
|
296
|
+
filename={filename}
|
|
297
|
+
onReady={() =>
|
|
298
|
+
setLeftReady(true)}
|
|
299
|
+
onError={(msg) => setLoadError(`Rev ${leftRev}: ${msg}`)}
|
|
300
|
+
/>
|
|
301
|
+
<ComparePanelRenderer
|
|
302
|
+
ref={newRef}
|
|
303
|
+
sessionId={sessionId}
|
|
304
|
+
revision={rightRev}
|
|
305
|
+
filename={filename}
|
|
306
|
+
onReady={() => setRightReady(true)}
|
|
307
|
+
onError={(msg) => setLoadError(`Rev ${rightRev}: ${msg}`)}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
)}
|
|
311
|
+
|
|
312
|
+
{/* Visible output */}
|
|
313
|
+
<div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
|
|
314
|
+
{!done && !loadError && (
|
|
315
|
+
<div className="flex flex-col items-center justify-center h-64 text-text-tertiary font-body text-body gap-2">
|
|
316
|
+
<span>Loading...</span>
|
|
317
|
+
{slow && (
|
|
318
|
+
<span className="text-[11px]">
|
|
319
|
+
Taking longer than expected — check Network tab for pending requests
|
|
320
|
+
</span>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
{loadError && (
|
|
325
|
+
<div className="bg-accent-red-muted rounded-lg p-5 mt-8">
|
|
326
|
+
<h3 className="font-body font-semibold text-accent-red mb-2">Failed to load revision</h3>
|
|
327
|
+
<pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{loadError}</pre>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
<div ref={unifiedRef} />
|
|
331
|
+
</div>
|
|
332
|
+
{popovers}
|
|
333
|
+
</div>
|
|
334
|
+
)
|
|
317
335
|
}
|
|
318
336
|
|
|
319
337
|
/* ── Side-by-side view ── */
|
|
320
338
|
|
|
321
339
|
function SideBySideView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
340
|
+
const oldRef = useRef<HTMLDivElement>(null)
|
|
341
|
+
const newRef = useRef<HTMLDivElement>(null)
|
|
342
|
+
const [leftReady, setLeftReady] = useState(false)
|
|
343
|
+
const [rightReady, setRightReady] = useState(false)
|
|
344
|
+
const [done, setDone] = useState(false)
|
|
345
|
+
|
|
346
|
+
const viewRef = useRef<HTMLDivElement>(null)
|
|
347
|
+
const leftPanelRef = useRef<HTMLDivElement>(null)
|
|
348
|
+
const rightPanelRef = useRef<HTMLDivElement>(null)
|
|
349
|
+
const isSyncing = useRef(false)
|
|
350
|
+
|
|
351
|
+
const handleLeftReady = useCallback(() => setLeftReady(true), [])
|
|
352
|
+
const handleRightReady = useCallback(() => setRightReady(true), [])
|
|
353
|
+
|
|
354
|
+
const { annotations } = useAnnotations()
|
|
355
|
+
const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename))
|
|
356
|
+
const { popovers } = useTextAnnotation({
|
|
357
|
+
containerRef: viewRef,
|
|
358
|
+
restoreKey: done,
|
|
359
|
+
restoreAnnotations: canvasAnnotations,
|
|
360
|
+
extractContext: (range) => extractContext(range, viewRef.current!),
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// Run diff
|
|
364
|
+
useEffect(() => {
|
|
365
|
+
if (!leftReady || !rightReady || done) return
|
|
366
|
+
if (!oldRef.current || !newRef.current) return
|
|
367
|
+
|
|
368
|
+
requestAnimationFrame(() => {
|
|
369
|
+
requestAnimationFrame(() => {
|
|
370
|
+
if (!oldRef.current || !newRef.current) return
|
|
371
|
+
const changed = runDomDiff(oldRef.current, newRef.current)
|
|
372
|
+
setDone(true)
|
|
373
|
+
onDiffResult(true, changed)
|
|
374
|
+
})
|
|
375
|
+
})
|
|
376
|
+
}, [leftReady, rightReady, done])
|
|
377
|
+
|
|
378
|
+
// Sync scroll
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
const left = leftPanelRef.current
|
|
381
|
+
const right = rightPanelRef.current
|
|
382
|
+
if (!left || !right) return
|
|
383
|
+
|
|
384
|
+
const handleSync = (source: HTMLDivElement, target: HTMLDivElement) => {
|
|
385
|
+
if (document.documentElement.dataset.syncScroll === '0') return
|
|
386
|
+
if (isSyncing.current) return
|
|
387
|
+
isSyncing.current = true
|
|
388
|
+
const sourceMax = source.scrollHeight - source.clientHeight
|
|
389
|
+
const targetMax = target.scrollHeight - target.clientHeight
|
|
390
|
+
if (sourceMax > 0 && targetMax > 0) {
|
|
391
|
+
target.scrollTop = (source.scrollTop / sourceMax) * targetMax
|
|
392
|
+
}
|
|
393
|
+
requestAnimationFrame(() => {
|
|
394
|
+
isSyncing.current = false
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const onLeft = () => handleSync(left, right)
|
|
399
|
+
const onRight = () => handleSync(right, left)
|
|
400
|
+
left.addEventListener('scroll', onLeft)
|
|
401
|
+
right.addEventListener('scroll', onRight)
|
|
402
|
+
return () => {
|
|
403
|
+
left.removeEventListener('scroll', onLeft)
|
|
404
|
+
right.removeEventListener('scroll', onRight)
|
|
405
|
+
}
|
|
406
|
+
}, [])
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
<div ref={viewRef} className="flex flex-1 min-h-0">
|
|
410
|
+
<div ref={leftPanelRef} className="w-1/2 overflow-y-auto border-r border-border-subtle">
|
|
411
|
+
<div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
|
|
412
|
+
<ComparePanelRenderer
|
|
413
|
+
ref={oldRef}
|
|
414
|
+
sessionId={sessionId}
|
|
415
|
+
revision={leftRev}
|
|
416
|
+
filename={filename}
|
|
417
|
+
onReady={handleLeftReady}
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
<div ref={rightPanelRef} className="w-1/2 overflow-y-auto">
|
|
422
|
+
<div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
|
|
423
|
+
<ComparePanelRenderer
|
|
424
|
+
ref={newRef}
|
|
425
|
+
sessionId={sessionId}
|
|
426
|
+
revision={rightRev}
|
|
427
|
+
filename={filename}
|
|
428
|
+
onReady={handleRightReady}
|
|
429
|
+
/>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
{popovers}
|
|
433
|
+
</div>
|
|
434
|
+
)
|
|
415
435
|
}
|
|
416
436
|
|
|
417
437
|
/* ── Panel renderer ── */
|
|
418
438
|
|
|
419
439
|
interface ComparePanelRendererProps {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
440
|
+
sessionId: string
|
|
441
|
+
revision: number
|
|
442
|
+
filename: string
|
|
443
|
+
onReady: () => void
|
|
444
|
+
onError?: (message: string) => void
|
|
425
445
|
}
|
|
426
446
|
|
|
427
447
|
export const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
))
|
|
448
|
+
({ sessionId, revision, filename, onReady, onError }, ref) => {
|
|
449
|
+
const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null)
|
|
450
|
+
const [error, setError] = useState<string | null>(null)
|
|
451
|
+
const [loading, setLoading] = useState(true)
|
|
452
|
+
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
setLoading(true)
|
|
455
|
+
setError(null)
|
|
456
|
+
const jsName = filename.replace(/\.jsx$/, '.js')
|
|
457
|
+
import(`/api/session/${sessionId}/canvas/${encodeURIComponent(jsName)}?rev=${revision}&t=${Date.now()}`)
|
|
458
|
+
.then((mod) => {
|
|
459
|
+
if (!mod.default) {
|
|
460
|
+
const msg = 'Module has no default export'
|
|
461
|
+
setError(msg)
|
|
462
|
+
setLoading(false)
|
|
463
|
+
onError?.(msg)
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
setPlanComponent(() => mod.default)
|
|
467
|
+
setLoading(false)
|
|
468
|
+
})
|
|
469
|
+
.catch((e) => {
|
|
470
|
+
setError(e.message)
|
|
471
|
+
setLoading(false)
|
|
472
|
+
onError?.(e.message)
|
|
473
|
+
})
|
|
474
|
+
}, [sessionId, revision, filename])
|
|
475
|
+
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
if (!PlanComponent) return
|
|
478
|
+
requestAnimationFrame(() => {
|
|
479
|
+
requestAnimationFrame(() => {
|
|
480
|
+
onReady()
|
|
481
|
+
})
|
|
482
|
+
})
|
|
483
|
+
}, [PlanComponent])
|
|
484
|
+
|
|
485
|
+
const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent])
|
|
486
|
+
|
|
487
|
+
if (loading) {
|
|
488
|
+
return (
|
|
489
|
+
<div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">
|
|
490
|
+
Loading...
|
|
491
|
+
</div>
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (error) {
|
|
496
|
+
return (
|
|
497
|
+
<div className="bg-accent-red-muted rounded-lg p-5">
|
|
498
|
+
<h3 className="font-body font-semibold text-accent-red mb-2">Error</h3>
|
|
499
|
+
<pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{error}</pre>
|
|
500
|
+
</div>
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!PlanComponent) {
|
|
505
|
+
return <div className="text-text-tertiary text-center py-8 font-body text-body">No canvas loaded</div>
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return (
|
|
509
|
+
<div ref={ref}>
|
|
510
|
+
{planElement}
|
|
511
|
+
</div>
|
|
512
|
+
)
|
|
513
|
+
},
|
|
514
|
+
))
|