agent-canvas 0.1.13 → 0.1.15
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/bin/lib/commands/push.ts +26 -10
- package/bun.lock +3 -0
- package/daemon/client/AnnotationProvider.tsx +2 -2
- package/daemon/client/App.tsx +125 -47
- package/daemon/client/CompareView.tsx +161 -31
- package/daemon/client/OverviewView.tsx +326 -0
- package/daemon/client/PlanRenderer.tsx +13 -8
- package/daemon/client/RevisionSelect.tsx +16 -7
- package/daemon/client/components/Mermaid.tsx +3 -2
- package/daemon/client/generateMarkdown.ts +24 -5
- package/daemon/client/runtime.ts +9 -2
- package/daemon/client/useTextAnnotation.tsx +6 -2
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +58 -58
- package/daemon/dist/components.js +23 -23
- package/daemon/dist/runtime.js +1 -1
- package/daemon/src/compiler.ts +100 -0
- package/daemon/src/handlers/api.ts +59 -24
- package/daemon/src/session.ts +140 -41
- package/daemon/src/watcher.ts +42 -30
- package/package.json +4 -3
- package/skills/canvas/SKILL.md +24 -19
- package/skills/canvas/components.md +29 -15
- package/skills/canvas/flows.md +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, { useEffect, useState, useRef, useCallback, useMemo } from "react";
|
|
1
|
+
import React, { useEffect, useState, useRef, useCallback, useMemo, useContext } from "react";
|
|
2
2
|
import { RevisionSelect } from "./RevisionSelect";
|
|
3
|
+
import { RevisionContext, type RevisionInfo, type CanvasFileInfo } from "./App";
|
|
3
4
|
import { runDomDiff } from "./domDiff";
|
|
4
5
|
import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
|
|
5
6
|
import { useAnnotations } from "./AnnotationProvider";
|
|
@@ -15,12 +16,43 @@ interface CompareViewProps {
|
|
|
15
16
|
|
|
16
17
|
type CompareMode = "unified" | "side-by-side";
|
|
17
18
|
|
|
19
|
+
/** Get union of canvas filenames from two revisions */
|
|
20
|
+
function getCompareFiles(revisions: RevisionInfo[], leftRev: number, rightRev: number): string[] {
|
|
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
|
+
}
|
|
28
|
+
|
|
18
29
|
export function CompareView({ initialLeft, initialRight, sessionId, onExit }: CompareViewProps) {
|
|
19
30
|
const [leftRev, setLeftRev] = useState(initialLeft);
|
|
20
31
|
const [rightRev, setRightRev] = useState(initialRight);
|
|
21
32
|
const [mode, setMode] = useState<CompareMode>("unified");
|
|
22
33
|
const [hasChanges, setHasChanges] = useState(true);
|
|
23
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]);
|
|
24
56
|
|
|
25
57
|
return (
|
|
26
58
|
<div className="flex flex-col h-screen">
|
|
@@ -74,20 +106,65 @@ export function CompareView({ initialLeft, initialRight, sessionId, onExit }: Co
|
|
|
74
106
|
</div>
|
|
75
107
|
</div>
|
|
76
108
|
|
|
77
|
-
{
|
|
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
|
+
<div className="flex-1 flex items-center justify-center">
|
|
143
|
+
<div className="text-center text-text-tertiary font-body">
|
|
144
|
+
<div className="text-lg mb-2">
|
|
145
|
+
{!leftHasFile ? "New file" : "Removed file"}
|
|
146
|
+
</div>
|
|
147
|
+
<div className="text-[13px]">
|
|
148
|
+
{selectedFile.replace(/\.jsx$/, "")} {!leftHasFile ? "was added" : "was removed"} in this revision
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
) : mode === "unified" ? (
|
|
78
153
|
<UnifiedView
|
|
79
|
-
key={`unified-${leftRev}-${rightRev}`}
|
|
154
|
+
key={`unified-${leftRev}-${rightRev}-${selectedFile}`}
|
|
80
155
|
sessionId={sessionId}
|
|
81
156
|
leftRev={leftRev}
|
|
82
157
|
rightRev={rightRev}
|
|
158
|
+
filename={selectedFile}
|
|
83
159
|
onDiffResult={(done, changes) => { setDiffDone(done); setHasChanges(changes); }}
|
|
84
160
|
/>
|
|
85
161
|
) : (
|
|
86
162
|
<SideBySideView
|
|
87
|
-
key={`sbs-${leftRev}-${rightRev}`}
|
|
163
|
+
key={`sbs-${leftRev}-${rightRev}-${selectedFile}`}
|
|
88
164
|
sessionId={sessionId}
|
|
89
165
|
leftRev={leftRev}
|
|
90
166
|
rightRev={rightRev}
|
|
167
|
+
filename={selectedFile}
|
|
91
168
|
onDiffResult={(done, changes) => { setDiffDone(done); setHasChanges(changes); }}
|
|
92
169
|
/>
|
|
93
170
|
)}
|
|
@@ -99,7 +176,6 @@ export function CompareView({ initialLeft, initialRight, sessionId, onExit }: Co
|
|
|
99
176
|
|
|
100
177
|
function SyncScrollCheckbox() {
|
|
101
178
|
const [checked, setChecked] = useState(true);
|
|
102
|
-
// Store in a ref accessible to SideBySideView via DOM attribute
|
|
103
179
|
useEffect(() => {
|
|
104
180
|
document.documentElement.dataset.syncScroll = checked ? "1" : "0";
|
|
105
181
|
return () => { delete document.documentElement.dataset.syncScroll; };
|
|
@@ -124,72 +200,113 @@ interface ViewProps {
|
|
|
124
200
|
sessionId: string;
|
|
125
201
|
leftRev: number;
|
|
126
202
|
rightRev: number;
|
|
203
|
+
filename: string;
|
|
127
204
|
onDiffResult: (done: boolean, hasChanges: boolean) => void;
|
|
128
205
|
}
|
|
129
206
|
|
|
130
|
-
function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps) {
|
|
207
|
+
function UnifiedView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
|
|
131
208
|
const oldRef = useRef<HTMLDivElement>(null);
|
|
132
209
|
const newRef = useRef<HTMLDivElement>(null);
|
|
133
210
|
const unifiedRef = useRef<HTMLDivElement>(null);
|
|
134
211
|
const [leftReady, setLeftReady] = useState(false);
|
|
135
212
|
const [rightReady, setRightReady] = useState(false);
|
|
136
213
|
const [done, setDone] = useState(false);
|
|
214
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
215
|
+
|
|
216
|
+
const [slow, setSlow] = useState(false);
|
|
137
217
|
|
|
138
218
|
const { annotations } = useAnnotations();
|
|
139
|
-
const
|
|
219
|
+
const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename));
|
|
140
220
|
const { popovers } = useTextAnnotation({
|
|
141
221
|
containerRef: unifiedRef,
|
|
142
222
|
restoreKey: done,
|
|
143
|
-
restoreAnnotations:
|
|
223
|
+
restoreAnnotations: canvasAnnotations,
|
|
144
224
|
extractContext: (range) => extractContext(range, unifiedRef.current!),
|
|
145
225
|
});
|
|
146
226
|
|
|
227
|
+
// Detect stuck loading
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (done || loadError) return;
|
|
230
|
+
const id = setTimeout(() => {
|
|
231
|
+
console.warn("[unified-diff] still loading after 10s", {
|
|
232
|
+
leftReady, rightReady, oldRef: !!oldRef.current, newRef: !!newRef.current,
|
|
233
|
+
});
|
|
234
|
+
setSlow(true);
|
|
235
|
+
}, 10_000);
|
|
236
|
+
return () => clearTimeout(id);
|
|
237
|
+
}, [done, loadError, leftReady, rightReady]);
|
|
238
|
+
|
|
147
239
|
useEffect(() => {
|
|
148
240
|
if (!leftReady || !rightReady || done) return;
|
|
149
241
|
if (!oldRef.current || !newRef.current) return;
|
|
150
242
|
|
|
151
243
|
requestAnimationFrame(() => {
|
|
152
244
|
requestAnimationFrame(() => {
|
|
153
|
-
if (!oldRef.current || !newRef.current || !unifiedRef.current)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
245
|
+
if (!oldRef.current || !newRef.current || !unifiedRef.current) {
|
|
246
|
+
console.warn("[unified-diff] refs lost between rAF frames", {
|
|
247
|
+
old: !!oldRef.current, new: !!newRef.current, unified: !!unifiedRef.current,
|
|
248
|
+
});
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const oldTree = extractBlockTree(oldRef.current);
|
|
254
|
+
const newTree = extractBlockTree(newRef.current);
|
|
255
|
+
const matches = matchBlocks(oldTree, newTree);
|
|
256
|
+
const { fragment, hasChanges } = buildUnifiedDom(matches);
|
|
257
|
+
|
|
258
|
+
unifiedRef.current.innerHTML = "";
|
|
259
|
+
unifiedRef.current.appendChild(fragment);
|
|
260
|
+
setDone(true);
|
|
261
|
+
onDiffResult(true, hasChanges);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
console.error("[unified-diff] diff computation failed", e);
|
|
264
|
+
setLoadError(e instanceof Error ? e.message : String(e));
|
|
265
|
+
}
|
|
164
266
|
});
|
|
165
267
|
});
|
|
166
268
|
}, [leftReady, rightReady, done]);
|
|
167
269
|
|
|
168
270
|
return (
|
|
169
271
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
170
|
-
{/* Offscreen source panels
|
|
171
|
-
{!done && (
|
|
272
|
+
{/* Offscreen source panels */}
|
|
273
|
+
{!done && !loadError && (
|
|
172
274
|
<div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
|
|
173
275
|
<ComparePanelRenderer
|
|
174
276
|
ref={oldRef}
|
|
175
277
|
sessionId={sessionId}
|
|
176
278
|
revision={leftRev}
|
|
279
|
+
filename={filename}
|
|
177
280
|
onReady={() => setLeftReady(true)}
|
|
281
|
+
onError={(msg) => setLoadError(`Rev ${leftRev}: ${msg}`)}
|
|
178
282
|
/>
|
|
179
283
|
<ComparePanelRenderer
|
|
180
284
|
ref={newRef}
|
|
181
285
|
sessionId={sessionId}
|
|
182
286
|
revision={rightRev}
|
|
287
|
+
filename={filename}
|
|
183
288
|
onReady={() => setRightReady(true)}
|
|
289
|
+
onError={(msg) => setLoadError(`Rev ${rightRev}: ${msg}`)}
|
|
184
290
|
/>
|
|
185
291
|
</div>
|
|
186
292
|
)}
|
|
187
293
|
|
|
188
294
|
{/* Visible output */}
|
|
189
295
|
<div className="max-w-[720px] mx-auto px-6 pt-8 pb-32">
|
|
190
|
-
{!done && (
|
|
191
|
-
<div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">
|
|
192
|
-
Loading
|
|
296
|
+
{!done && !loadError && (
|
|
297
|
+
<div className="flex flex-col items-center justify-center h-64 text-text-tertiary font-body text-body gap-2">
|
|
298
|
+
<span>Loading...</span>
|
|
299
|
+
{slow && (
|
|
300
|
+
<span className="text-[11px]">
|
|
301
|
+
Taking longer than expected — check Network tab for pending requests
|
|
302
|
+
</span>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
{loadError && (
|
|
307
|
+
<div className="bg-accent-red-muted rounded-lg p-5 mt-8">
|
|
308
|
+
<h3 className="font-body font-semibold text-accent-red mb-2">Failed to load revision</h3>
|
|
309
|
+
<pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{loadError}</pre>
|
|
193
310
|
</div>
|
|
194
311
|
)}
|
|
195
312
|
<div ref={unifiedRef} />
|
|
@@ -201,7 +318,7 @@ function UnifiedView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps)
|
|
|
201
318
|
|
|
202
319
|
/* ── Side-by-side view ── */
|
|
203
320
|
|
|
204
|
-
function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProps) {
|
|
321
|
+
function SideBySideView({ sessionId, leftRev, rightRev, filename, onDiffResult }: ViewProps) {
|
|
205
322
|
const oldRef = useRef<HTMLDivElement>(null);
|
|
206
323
|
const newRef = useRef<HTMLDivElement>(null);
|
|
207
324
|
const [leftReady, setLeftReady] = useState(false);
|
|
@@ -217,11 +334,11 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
217
334
|
const handleRightReady = useCallback(() => setRightReady(true), []);
|
|
218
335
|
|
|
219
336
|
const { annotations } = useAnnotations();
|
|
220
|
-
const
|
|
337
|
+
const canvasAnnotations = annotations.filter(a => !a.filePath && (!a.canvasFile || a.canvasFile === filename));
|
|
221
338
|
const { popovers } = useTextAnnotation({
|
|
222
339
|
containerRef: viewRef,
|
|
223
340
|
restoreKey: done,
|
|
224
|
-
restoreAnnotations:
|
|
341
|
+
restoreAnnotations: canvasAnnotations,
|
|
225
342
|
extractContext: (range) => extractContext(range, viewRef.current!),
|
|
226
343
|
});
|
|
227
344
|
|
|
@@ -276,6 +393,7 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
276
393
|
ref={oldRef}
|
|
277
394
|
sessionId={sessionId}
|
|
278
395
|
revision={leftRev}
|
|
396
|
+
filename={filename}
|
|
279
397
|
onReady={handleLeftReady}
|
|
280
398
|
/>
|
|
281
399
|
</div>
|
|
@@ -286,6 +404,7 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
286
404
|
ref={newRef}
|
|
287
405
|
sessionId={sessionId}
|
|
288
406
|
revision={rightRev}
|
|
407
|
+
filename={filename}
|
|
289
408
|
onReady={handleRightReady}
|
|
290
409
|
/>
|
|
291
410
|
</div>
|
|
@@ -300,11 +419,13 @@ function SideBySideView({ sessionId, leftRev, rightRev, onDiffResult }: ViewProp
|
|
|
300
419
|
interface ComparePanelRendererProps {
|
|
301
420
|
sessionId: string;
|
|
302
421
|
revision: number;
|
|
422
|
+
filename: string;
|
|
303
423
|
onReady: () => void;
|
|
424
|
+
onError?: (message: string) => void;
|
|
304
425
|
}
|
|
305
426
|
|
|
306
|
-
const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
|
|
307
|
-
({ sessionId, revision, onReady }, ref) => {
|
|
427
|
+
export const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, ComparePanelRendererProps>(
|
|
428
|
+
({ sessionId, revision, filename, onReady, onError }, ref) => {
|
|
308
429
|
const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
|
|
309
430
|
const [error, setError] = useState<string | null>(null);
|
|
310
431
|
const [loading, setLoading] = useState(true);
|
|
@@ -312,16 +433,25 @@ const ComparePanelRenderer = React.memo(React.forwardRef<HTMLDivElement, Compare
|
|
|
312
433
|
useEffect(() => {
|
|
313
434
|
setLoading(true);
|
|
314
435
|
setError(null);
|
|
315
|
-
|
|
436
|
+
const jsName = filename.replace(/\.jsx$/, ".js");
|
|
437
|
+
import(`/api/session/${sessionId}/canvas/${encodeURIComponent(jsName)}?rev=${revision}&t=${Date.now()}`)
|
|
316
438
|
.then((mod) => {
|
|
439
|
+
if (!mod.default) {
|
|
440
|
+
const msg = "Module has no default export";
|
|
441
|
+
setError(msg);
|
|
442
|
+
setLoading(false);
|
|
443
|
+
onError?.(msg);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
317
446
|
setPlanComponent(() => mod.default);
|
|
318
447
|
setLoading(false);
|
|
319
448
|
})
|
|
320
449
|
.catch((e) => {
|
|
321
450
|
setError(e.message);
|
|
322
451
|
setLoading(false);
|
|
452
|
+
onError?.(e.message);
|
|
323
453
|
});
|
|
324
|
-
}, [sessionId, revision]);
|
|
454
|
+
}, [sessionId, revision, filename]);
|
|
325
455
|
|
|
326
456
|
useEffect(() => {
|
|
327
457
|
if (!PlanComponent) return;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import React, { useState, useContext, useMemo, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import { SessionContext } from "#canvas/runtime";
|
|
3
|
+
import { PlanRenderer } from "./PlanRenderer";
|
|
4
|
+
import { ComparePanelRenderer } from "./CompareView";
|
|
5
|
+
import { ActiveViewContext, RevisionContext, type CanvasFileInfo, type RevisionInfo } from "./App";
|
|
6
|
+
import { extractBlockTree, matchBlocks, buildUnifiedDom } from "./unifiedDiff";
|
|
7
|
+
import { runDomDiff } from "./domDiff";
|
|
8
|
+
|
|
9
|
+
interface OverviewViewProps {
|
|
10
|
+
revision: number;
|
|
11
|
+
responseBanner?: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FileChange {
|
|
15
|
+
filename: string;
|
|
16
|
+
status: "new" | "changed" | "unchanged";
|
|
17
|
+
diffStats?: { added: number; removed: number };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function categorizeChanges(
|
|
21
|
+
currentRev: RevisionInfo | undefined,
|
|
22
|
+
previousRev: RevisionInfo | undefined,
|
|
23
|
+
): FileChange[] {
|
|
24
|
+
if (!currentRev) return [];
|
|
25
|
+
|
|
26
|
+
return currentRev.canvasFiles.map(cf => {
|
|
27
|
+
if (!cf.diffStats) {
|
|
28
|
+
return { filename: cf.filename, status: "new" as const };
|
|
29
|
+
}
|
|
30
|
+
if (cf.diffStats.added === 0 && cf.diffStats.removed === 0) {
|
|
31
|
+
return { filename: cf.filename, status: "unchanged" as const, diffStats: cf.diffStats };
|
|
32
|
+
}
|
|
33
|
+
return { filename: cf.filename, status: "changed" as const, diffStats: cf.diffStats };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getAffectedFiles(changes: FileChange[]): string[] {
|
|
38
|
+
return changes.filter(c => c.status !== "unchanged").map(c => c.filename);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function OverviewView({ revision, responseBanner }: OverviewViewProps) {
|
|
42
|
+
const { setActiveView } = useContext(ActiveViewContext);
|
|
43
|
+
const { revisions } = useContext(RevisionContext);
|
|
44
|
+
|
|
45
|
+
const currentRev = revisions.find(r => r.revision === revision);
|
|
46
|
+
const previousRev = revisions.find(r => r.revision === revision - 1);
|
|
47
|
+
const changes = useMemo(() => categorizeChanges(currentRev, previousRev), [currentRev, previousRev]);
|
|
48
|
+
|
|
49
|
+
const affected = changes.filter(c => c.status !== "unchanged");
|
|
50
|
+
const unchanged = changes.filter(c => c.status === "unchanged");
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="max-w-[720px] mx-auto px-6 pt-12 pb-32">
|
|
54
|
+
{responseBanner}
|
|
55
|
+
|
|
56
|
+
{affected.map(change => (
|
|
57
|
+
<OverviewFileSection
|
|
58
|
+
key={change.filename}
|
|
59
|
+
change={change}
|
|
60
|
+
revision={revision}
|
|
61
|
+
previousRevision={previousRev?.revision}
|
|
62
|
+
onOpenTab={() => setActiveView({ type: "canvas", filename: change.filename })}
|
|
63
|
+
/>
|
|
64
|
+
))}
|
|
65
|
+
|
|
66
|
+
{unchanged.length > 0 && (
|
|
67
|
+
<div className="mt-8 border-t border-border-subtle pt-4">
|
|
68
|
+
<div className="text-[11px] font-body font-medium uppercase tracking-widest text-text-tertiary mb-3">
|
|
69
|
+
Unchanged
|
|
70
|
+
</div>
|
|
71
|
+
{unchanged.map(change => (
|
|
72
|
+
<UnchangedFileRow
|
|
73
|
+
key={change.filename}
|
|
74
|
+
change={change}
|
|
75
|
+
revision={revision}
|
|
76
|
+
onOpenTab={() => setActiveView({ type: "canvas", filename: change.filename })}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type ViewMode = "rendered" | "unified" | "side-by-side";
|
|
86
|
+
|
|
87
|
+
function ViewModeToggle({ mode, onChange, showDiff }: { mode: ViewMode; onChange: (m: ViewMode) => void; showDiff: boolean }) {
|
|
88
|
+
if (!showDiff) return null;
|
|
89
|
+
return (
|
|
90
|
+
<div className="flex items-center bg-bg-elevated rounded-md p-0.5">
|
|
91
|
+
{(["rendered", "unified", "side-by-side"] as ViewMode[]).map(m => (
|
|
92
|
+
<button
|
|
93
|
+
key={m}
|
|
94
|
+
onClick={() => onChange(m)}
|
|
95
|
+
className={`text-[10px] font-body px-2 py-0.5 rounded transition-colors ${
|
|
96
|
+
mode === m
|
|
97
|
+
? "bg-bg-surface text-text-primary font-medium shadow-sm"
|
|
98
|
+
: "text-text-tertiary hover:text-text-secondary"
|
|
99
|
+
}`}
|
|
100
|
+
>
|
|
101
|
+
{m === "rendered" ? "Full" : m === "unified" ? "Unified" : "Split"}
|
|
102
|
+
</button>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function OverviewFileSection({
|
|
109
|
+
change,
|
|
110
|
+
revision,
|
|
111
|
+
previousRevision,
|
|
112
|
+
onOpenTab,
|
|
113
|
+
}: {
|
|
114
|
+
change: FileChange;
|
|
115
|
+
revision: number;
|
|
116
|
+
previousRevision?: number;
|
|
117
|
+
onOpenTab: () => void;
|
|
118
|
+
}) {
|
|
119
|
+
const label = change.filename.replace(/\.jsx$/, "");
|
|
120
|
+
const isNew = change.status === "new";
|
|
121
|
+
const diffStats = change.diffStats;
|
|
122
|
+
const canDiff = change.status === "changed" && previousRevision != null;
|
|
123
|
+
const [viewMode, setViewMode] = useState<ViewMode>("rendered");
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="mb-8">
|
|
127
|
+
{/* File header */}
|
|
128
|
+
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border-subtle">
|
|
129
|
+
<span className={`text-[11px] font-mono px-1.5 py-0.5 rounded ${
|
|
130
|
+
isNew
|
|
131
|
+
? "bg-accent-green/10 text-accent-green border border-accent-green/20"
|
|
132
|
+
: "bg-accent-amber/10 text-accent-amber border border-accent-amber/20"
|
|
133
|
+
}`}>
|
|
134
|
+
{isNew ? "new" : "modified"}
|
|
135
|
+
</span>
|
|
136
|
+
<span className="text-[14px] font-body font-medium text-text-primary">{label}</span>
|
|
137
|
+
{diffStats && (diffStats.added > 0 || diffStats.removed > 0) && (
|
|
138
|
+
<span className="text-[11px] font-mono text-text-tertiary">
|
|
139
|
+
<span className="text-accent-green">+{diffStats.added}</span>
|
|
140
|
+
{" "}
|
|
141
|
+
<span className="text-accent-red">-{diffStats.removed}</span>
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
<ViewModeToggle mode={viewMode} onChange={setViewMode} showDiff={canDiff} />
|
|
145
|
+
<button
|
|
146
|
+
onClick={onOpenTab}
|
|
147
|
+
className={`${canDiff ? "" : "ml-auto"} text-[11px] font-body font-medium text-accent-blue hover:text-accent-blue/80 transition-colors`}
|
|
148
|
+
>
|
|
149
|
+
Open in tab
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Content based on view mode */}
|
|
154
|
+
{viewMode === "rendered" ? (
|
|
155
|
+
<div data-canvas-file={change.filename}>
|
|
156
|
+
<PlanRenderer revision={revision} filename={change.filename} />
|
|
157
|
+
</div>
|
|
158
|
+
) : canDiff && previousRevision != null ? (
|
|
159
|
+
<InlineDiff
|
|
160
|
+
key={`${viewMode}-${revision}-${previousRevision}`}
|
|
161
|
+
filename={change.filename}
|
|
162
|
+
leftRev={previousRevision}
|
|
163
|
+
rightRev={revision}
|
|
164
|
+
mode={viewMode}
|
|
165
|
+
/>
|
|
166
|
+
) : null}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── Inline diff for overview ── */
|
|
172
|
+
|
|
173
|
+
function InlineDiff({
|
|
174
|
+
filename,
|
|
175
|
+
leftRev,
|
|
176
|
+
rightRev,
|
|
177
|
+
mode,
|
|
178
|
+
}: {
|
|
179
|
+
filename: string;
|
|
180
|
+
leftRev: number;
|
|
181
|
+
rightRev: number;
|
|
182
|
+
mode: "unified" | "side-by-side";
|
|
183
|
+
}) {
|
|
184
|
+
const sessionId = useContext(SessionContext);
|
|
185
|
+
|
|
186
|
+
if (mode === "unified") {
|
|
187
|
+
return <InlineUnifiedDiff sessionId={sessionId} filename={filename} leftRev={leftRev} rightRev={rightRev} />;
|
|
188
|
+
}
|
|
189
|
+
return <InlineSideBySideDiff sessionId={sessionId} filename={filename} leftRev={leftRev} rightRev={rightRev} />;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function InlineUnifiedDiff({ sessionId, filename, leftRev, rightRev }: { sessionId: string; filename: string; leftRev: number; rightRev: number }) {
|
|
193
|
+
const oldRef = useRef<HTMLDivElement>(null);
|
|
194
|
+
const newRef = useRef<HTMLDivElement>(null);
|
|
195
|
+
const unifiedRef = useRef<HTMLDivElement>(null);
|
|
196
|
+
const [leftReady, setLeftReady] = useState(false);
|
|
197
|
+
const [rightReady, setRightReady] = useState(false);
|
|
198
|
+
const [done, setDone] = useState(false);
|
|
199
|
+
const [error, setError] = useState<string | null>(null);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!leftReady || !rightReady || done) return;
|
|
203
|
+
if (!oldRef.current || !newRef.current) return;
|
|
204
|
+
|
|
205
|
+
requestAnimationFrame(() => {
|
|
206
|
+
requestAnimationFrame(() => {
|
|
207
|
+
if (!oldRef.current || !newRef.current || !unifiedRef.current) return;
|
|
208
|
+
try {
|
|
209
|
+
const oldTree = extractBlockTree(oldRef.current);
|
|
210
|
+
const newTree = extractBlockTree(newRef.current);
|
|
211
|
+
const matches = matchBlocks(oldTree, newTree);
|
|
212
|
+
const { fragment } = buildUnifiedDom(matches);
|
|
213
|
+
unifiedRef.current.innerHTML = "";
|
|
214
|
+
unifiedRef.current.appendChild(fragment);
|
|
215
|
+
setDone(true);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}, [leftReady, rightReady, done]);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div>
|
|
225
|
+
{/* Offscreen panels for diff computation */}
|
|
226
|
+
{!done && !error && (
|
|
227
|
+
<div style={{ position: "absolute", left: -9999, visibility: "hidden" as const, pointerEvents: "none" as const, width: 720 }}>
|
|
228
|
+
<ComparePanelRenderer ref={oldRef} sessionId={sessionId} revision={leftRev} filename={filename} onReady={() => setLeftReady(true)} onError={(msg) => setError(msg)} />
|
|
229
|
+
<ComparePanelRenderer ref={newRef} sessionId={sessionId} revision={rightRev} filename={filename} onReady={() => setRightReady(true)} onError={(msg) => setError(msg)} />
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
{!done && !error && (
|
|
233
|
+
<div className="flex items-center justify-center h-24 text-text-tertiary font-body text-[13px]">Computing diff...</div>
|
|
234
|
+
)}
|
|
235
|
+
{error && (
|
|
236
|
+
<div className="text-accent-red text-[13px] font-body p-3 bg-accent-red-muted rounded-lg">{error}</div>
|
|
237
|
+
)}
|
|
238
|
+
<div ref={unifiedRef} />
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function InlineSideBySideDiff({ sessionId, filename, leftRev, rightRev }: { sessionId: string; filename: string; leftRev: number; rightRev: number }) {
|
|
244
|
+
const oldRef = useRef<HTMLDivElement>(null);
|
|
245
|
+
const newRef = useRef<HTMLDivElement>(null);
|
|
246
|
+
const [leftReady, setLeftReady] = useState(false);
|
|
247
|
+
const [rightReady, setRightReady] = useState(false);
|
|
248
|
+
const [done, setDone] = useState(false);
|
|
249
|
+
|
|
250
|
+
const handleLeftReady = useCallback(() => setLeftReady(true), []);
|
|
251
|
+
const handleRightReady = useCallback(() => setRightReady(true), []);
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (!leftReady || !rightReady || done) return;
|
|
255
|
+
if (!oldRef.current || !newRef.current) return;
|
|
256
|
+
|
|
257
|
+
requestAnimationFrame(() => {
|
|
258
|
+
requestAnimationFrame(() => {
|
|
259
|
+
if (!oldRef.current || !newRef.current) return;
|
|
260
|
+
runDomDiff(oldRef.current, newRef.current);
|
|
261
|
+
setDone(true);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}, [leftReady, rightReady, done]);
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div className="flex gap-0 border-y border-border-subtle overflow-hidden w-screen relative left-1/2 -translate-x-1/2">
|
|
268
|
+
<div className="w-1/2 border-r border-border-subtle">
|
|
269
|
+
<div className="px-2 py-1 text-[10px] font-body font-medium text-text-tertiary bg-bg-elevated border-b border-border-subtle">
|
|
270
|
+
Rev {leftRev}
|
|
271
|
+
</div>
|
|
272
|
+
<div className="px-6 py-4 max-w-[600px] mx-auto">
|
|
273
|
+
<ComparePanelRenderer ref={oldRef} sessionId={sessionId} revision={leftRev} filename={filename} onReady={handleLeftReady} />
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
<div className="w-1/2">
|
|
277
|
+
<div className="px-2 py-1 text-[10px] font-body font-medium text-text-tertiary bg-bg-elevated border-b border-border-subtle">
|
|
278
|
+
Rev {rightRev}
|
|
279
|
+
</div>
|
|
280
|
+
<div className="px-6 py-4 max-w-[600px] mx-auto">
|
|
281
|
+
<ComparePanelRenderer ref={newRef} sessionId={sessionId} revision={rightRev} filename={filename} onReady={handleRightReady} />
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function UnchangedFileRow({
|
|
289
|
+
change,
|
|
290
|
+
revision,
|
|
291
|
+
onOpenTab,
|
|
292
|
+
}: {
|
|
293
|
+
change: FileChange;
|
|
294
|
+
revision: number;
|
|
295
|
+
onOpenTab: () => void;
|
|
296
|
+
}) {
|
|
297
|
+
const [expanded, setExpanded] = useState(false);
|
|
298
|
+
const label = change.filename.replace(/\.jsx$/, "");
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<div className="mb-2">
|
|
302
|
+
<div className="flex items-center gap-2 py-1.5">
|
|
303
|
+
<button
|
|
304
|
+
onClick={() => setExpanded(!expanded)}
|
|
305
|
+
className="text-text-tertiary hover:text-text-secondary transition-colors"
|
|
306
|
+
>
|
|
307
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" className={`transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}>
|
|
308
|
+
<path d="M3.5 2L6.5 5L3.5 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
309
|
+
</svg>
|
|
310
|
+
</button>
|
|
311
|
+
<span className="text-[13px] font-body text-text-secondary">{label}</span>
|
|
312
|
+
<button
|
|
313
|
+
onClick={onOpenTab}
|
|
314
|
+
className="ml-auto text-[11px] font-body text-text-tertiary hover:text-accent-blue transition-colors"
|
|
315
|
+
>
|
|
316
|
+
Open in tab
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
{expanded && (
|
|
320
|
+
<div className="ml-5 mt-1 mb-3" data-canvas-file={change.filename}>
|
|
321
|
+
<PlanRenderer revision={revision} filename={change.filename} />
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|