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.
- 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 -105
- 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 +9 -1
- package/bin/lib/commands/exec.test.ts +0 -27
- package/bin/lib/helpers.test.ts +0 -79
- 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
package/daemon/client/domDiff.ts
CHANGED
|
@@ -7,50 +7,50 @@
|
|
|
7
7
|
* 3. Inject highlight <span> elements into the DOM
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { simpleLCS } from
|
|
10
|
+
import { simpleLCS } from './lcs'
|
|
11
11
|
|
|
12
12
|
interface WordEntry {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
text: string
|
|
14
|
+
node: Text
|
|
15
|
+
offset: number // character offset within the Text node
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Walk all text nodes, tokenize into words+whitespace, record originating node+offset. */
|
|
19
19
|
export function extractWords(container: HTMLElement): { words: string[]; map: WordEntry[] } {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
20
|
+
const words: string[] = []
|
|
21
|
+
const map: WordEntry[] = []
|
|
22
|
+
|
|
23
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
|
24
|
+
acceptNode(node) {
|
|
25
|
+
const parent = node.parentElement
|
|
26
|
+
if (!parent) return NodeFilter.FILTER_REJECT
|
|
27
|
+
// Skip SVG, select-none, and data-no-diff elements
|
|
28
|
+
if (parent.closest('svg, .select-none, [data-no-diff]')) return NodeFilter.FILTER_REJECT
|
|
29
|
+
return NodeFilter.FILTER_ACCEPT
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
let textNode: Text | null
|
|
34
|
+
while ((textNode = walker.nextNode() as Text | null)) {
|
|
35
|
+
const content = textNode.nodeValue || ''
|
|
36
|
+
// Tokenize: split on whitespace boundaries, keeping whitespace as tokens
|
|
37
|
+
const tokens = content.match(/\S+|\s+/g)
|
|
38
|
+
if (!tokens) continue
|
|
39
|
+
|
|
40
|
+
let offset = 0
|
|
41
|
+
for (const token of tokens) {
|
|
42
|
+
words.push(token)
|
|
43
|
+
map.push({ text: token, node: textNode, offset })
|
|
44
|
+
offset += token.length
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { words, map }
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
interface DiffOp {
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
type: 'same' | 'added' | 'removed'
|
|
53
|
+
index: number // index in the original words array
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -59,51 +59,51 @@ interface DiffOp {
|
|
|
59
59
|
* For large inputs (>5k tokens), falls back to chunked diffing.
|
|
60
60
|
*/
|
|
61
61
|
export function computeWordDiff(
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
oldWords: string[],
|
|
63
|
+
newWords: string[],
|
|
64
64
|
): { oldOps: DiffOp[]; newOps: DiffOp[] } {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
if (oldWords.length > 5000 || newWords.length > 5000) {
|
|
66
|
+
return chunkedDiff(oldWords, newWords)
|
|
67
|
+
}
|
|
68
|
+
return directDiff(oldWords, newWords)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function directDiff(
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
oldWords: string[],
|
|
73
|
+
newWords: string[],
|
|
74
74
|
): { oldOps: DiffOp[]; newOps: DiffOp[] } {
|
|
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
|
-
|
|
75
|
+
const lcs = computeLCS(oldWords, newWords)
|
|
76
|
+
|
|
77
|
+
const oldOps: DiffOp[] = []
|
|
78
|
+
const newOps: DiffOp[] = []
|
|
79
|
+
|
|
80
|
+
let oi = 0,
|
|
81
|
+
ni = 0,
|
|
82
|
+
li = 0
|
|
83
|
+
|
|
84
|
+
while (oi < oldWords.length || ni < newWords.length) {
|
|
85
|
+
if (
|
|
86
|
+
li < lcs.length
|
|
87
|
+
&& oi < oldWords.length
|
|
88
|
+
&& ni < newWords.length
|
|
89
|
+
&& oldWords[oi] === lcs[li]
|
|
90
|
+
&& newWords[ni] === lcs[li]
|
|
91
|
+
) {
|
|
92
|
+
oldOps.push({ type: 'same', index: oi })
|
|
93
|
+
newOps.push({ type: 'same', index: ni })
|
|
94
|
+
oi++
|
|
95
|
+
ni++
|
|
96
|
+
li++
|
|
97
|
+
} else if (oi < oldWords.length && (li >= lcs.length || oldWords[oi] !== lcs[li])) {
|
|
98
|
+
oldOps.push({ type: 'removed', index: oi })
|
|
99
|
+
oi++
|
|
100
|
+
} else if (ni < newWords.length) {
|
|
101
|
+
newOps.push({ type: 'added', index: ni })
|
|
102
|
+
ni++
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { oldOps, newOps }
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
@@ -111,132 +111,140 @@ function directDiff(
|
|
|
111
111
|
* Recursively divides the problem, using two-row DP to find the split point.
|
|
112
112
|
*/
|
|
113
113
|
function computeLCS(a: string[], b: string[]): string[] {
|
|
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
|
-
|
|
114
|
+
const m = a.length, n = b.length
|
|
115
|
+
if (m === 0 || n === 0) return []
|
|
116
|
+
if (m === 1) return b.includes(a[0]) ? [a[0]] : []
|
|
117
|
+
if (n === 1) return a.includes(b[0]) ? [b[0]] : []
|
|
118
|
+
|
|
119
|
+
// For small inputs, use the simple DP approach
|
|
120
|
+
if (m * n <= 1_000_000) {
|
|
121
|
+
return simpleLCS(a, b)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Hirschberg divide-and-conquer
|
|
125
|
+
const mid = Math.floor(m / 2)
|
|
126
|
+
const aFirst = a.slice(0, mid)
|
|
127
|
+
const aSecond = a.slice(mid)
|
|
128
|
+
|
|
129
|
+
const scoreL = lcsLengthRow(aFirst, b)
|
|
130
|
+
const scoreR = lcsLengthRow([...aSecond].reverse(), [...b].reverse())
|
|
131
|
+
|
|
132
|
+
// Find optimal split in b
|
|
133
|
+
let best = 0, bestK = 0
|
|
134
|
+
for (let k = 0; k <= n; k++) {
|
|
135
|
+
const score = scoreL[k] + scoreR[n - k]
|
|
136
|
+
if (score > best) {
|
|
137
|
+
best = score
|
|
138
|
+
bestK = k
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const bFirst = b.slice(0, bestK)
|
|
143
|
+
const bSecond = b.slice(bestK)
|
|
144
|
+
|
|
145
|
+
return [...computeLCS(aFirst, bFirst), ...computeLCS(aSecond, bSecond)]
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
/** Two-row DP: returns last row of LCS length table. O(n) space. */
|
|
146
149
|
function lcsLengthRow(a: string[], b: string[]): number[] {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
150
|
+
const n = b.length
|
|
151
|
+
let prev = new Array(n + 1).fill(0)
|
|
152
|
+
let curr = new Array(n + 1).fill(0)
|
|
153
|
+
for (let i = 0; i < a.length; i++) {
|
|
154
|
+
for (let j = 1; j <= n; j++) {
|
|
155
|
+
curr[j] = a[i] === b[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], curr[j - 1])
|
|
156
|
+
}
|
|
157
|
+
;[prev, curr] = [curr, prev]
|
|
158
|
+
curr.fill(0)
|
|
159
|
+
}
|
|
160
|
+
return prev
|
|
158
161
|
}
|
|
159
162
|
|
|
160
|
-
|
|
161
163
|
/**
|
|
162
164
|
* Chunked fallback for very large plans.
|
|
163
165
|
* Splits words into fixed-size chunks, aligns chunks via LCS,
|
|
164
166
|
* then runs word-level diff within each aligned chunk pair.
|
|
165
167
|
*/
|
|
166
168
|
function chunkedDiff(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
+
oldWords: string[],
|
|
170
|
+
newWords: string[],
|
|
169
171
|
): { oldOps: DiffOp[]; newOps: DiffOp[] } {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
172
|
+
const CHUNK_SIZE = 200
|
|
173
|
+
|
|
174
|
+
const splitIntoChunks = (words: string[]) => {
|
|
175
|
+
const chunks: { start: number; end: number; text: string }[] = []
|
|
176
|
+
for (let i = 0; i < words.length; i += CHUNK_SIZE) {
|
|
177
|
+
const end = Math.min(i + CHUNK_SIZE, words.length)
|
|
178
|
+
chunks.push({ start: i, end, text: words.slice(i, end).join('') })
|
|
179
|
+
}
|
|
180
|
+
return chunks
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const oldChunks = splitIntoChunks(oldWords)
|
|
184
|
+
const newChunks = splitIntoChunks(newWords)
|
|
185
|
+
|
|
186
|
+
const oldTexts = oldChunks.map((c) => c.text)
|
|
187
|
+
const newTexts = newChunks.map((c) => c.text)
|
|
188
|
+
const chunkLCS = computeLCS(oldTexts, newTexts)
|
|
189
|
+
|
|
190
|
+
const oldOps: DiffOp[] = []
|
|
191
|
+
const newOps: DiffOp[] = []
|
|
192
|
+
|
|
193
|
+
let oci = 0, nci = 0, cli = 0
|
|
194
|
+
|
|
195
|
+
while (oci < oldChunks.length || nci < newChunks.length) {
|
|
196
|
+
if (
|
|
197
|
+
cli < chunkLCS.length
|
|
198
|
+
&& oci < oldChunks.length
|
|
199
|
+
&& nci < newChunks.length
|
|
200
|
+
&& oldTexts[oci] === chunkLCS[cli]
|
|
201
|
+
&& newTexts[nci] === chunkLCS[cli]
|
|
202
|
+
) {
|
|
203
|
+
// Identical chunks
|
|
204
|
+
for (let i = oldChunks[oci].start; i < oldChunks[oci].end; i++) {
|
|
205
|
+
oldOps.push({ type: 'same', index: i })
|
|
206
|
+
}
|
|
207
|
+
for (let i = newChunks[nci].start; i < newChunks[nci].end; i++) {
|
|
208
|
+
newOps.push({ type: 'same', index: i })
|
|
209
|
+
}
|
|
210
|
+
oci++
|
|
211
|
+
nci++
|
|
212
|
+
cli++
|
|
213
|
+
} else if (oci < oldChunks.length && (cli >= chunkLCS.length || oldTexts[oci] !== chunkLCS[cli])) {
|
|
214
|
+
if (
|
|
215
|
+
nci < newChunks.length
|
|
216
|
+
&& (cli >= chunkLCS.length || newTexts[nci] !== chunkLCS[cli])
|
|
217
|
+
) {
|
|
218
|
+
// Both sides changed — word-level diff within these chunks
|
|
219
|
+
const sub = directDiff(
|
|
220
|
+
oldWords.slice(oldChunks[oci].start, oldChunks[oci].end),
|
|
221
|
+
newWords.slice(newChunks[nci].start, newChunks[nci].end),
|
|
222
|
+
)
|
|
223
|
+
for (const op of sub.oldOps) {
|
|
224
|
+
oldOps.push({ type: op.type, index: op.index + oldChunks[oci].start })
|
|
225
|
+
}
|
|
226
|
+
for (const op of sub.newOps) {
|
|
227
|
+
newOps.push({ type: op.type, index: op.index + newChunks[nci].start })
|
|
228
|
+
}
|
|
229
|
+
oci++
|
|
230
|
+
nci++
|
|
231
|
+
} else {
|
|
232
|
+
// Only old side — removed
|
|
233
|
+
for (let i = oldChunks[oci].start; i < oldChunks[oci].end; i++) {
|
|
234
|
+
oldOps.push({ type: 'removed', index: i })
|
|
235
|
+
}
|
|
236
|
+
oci++
|
|
237
|
+
}
|
|
238
|
+
} else if (nci < newChunks.length) {
|
|
239
|
+
// Only new side — added
|
|
240
|
+
for (let i = newChunks[nci].start; i < newChunks[nci].end; i++) {
|
|
241
|
+
newOps.push({ type: 'added', index: i })
|
|
242
|
+
}
|
|
243
|
+
nci++
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { oldOps, newOps }
|
|
240
248
|
}
|
|
241
249
|
|
|
242
250
|
/**
|
|
@@ -247,70 +255,70 @@ function chunkedDiff(
|
|
|
247
255
|
* removes the text node and breaks subsequent tokens from the same node.
|
|
248
256
|
*/
|
|
249
257
|
function applyHighlightsByNode(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
258
|
+
ops: DiffOp[],
|
|
259
|
+
map: WordEntry[],
|
|
260
|
+
cssClass: string,
|
|
253
261
|
): void {
|
|
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
|
-
|
|
262
|
+
// Collect changed indices grouped by source text node
|
|
263
|
+
const nodeGroups = new Map<Text, { offset: number; length: number }[]>()
|
|
264
|
+
|
|
265
|
+
for (const op of ops) {
|
|
266
|
+
if (op.type === 'same') continue
|
|
267
|
+
const entry = map[op.index]
|
|
268
|
+
if (!entry || !entry.node.parentNode) continue
|
|
269
|
+
|
|
270
|
+
let group = nodeGroups.get(entry.node)
|
|
271
|
+
if (!group) {
|
|
272
|
+
group = []
|
|
273
|
+
nodeGroups.set(entry.node, group)
|
|
274
|
+
}
|
|
275
|
+
group.push({ offset: entry.offset, length: entry.text.length })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// For each text node, rebuild it with highlight spans
|
|
279
|
+
for (const [textNode, ranges] of nodeGroups) {
|
|
280
|
+
if (!textNode.parentNode) continue
|
|
281
|
+
const content = textNode.nodeValue || ''
|
|
282
|
+
|
|
283
|
+
// Sort ranges by offset
|
|
284
|
+
ranges.sort((a, b) => a.offset - b.offset)
|
|
285
|
+
|
|
286
|
+
// Merge overlapping/adjacent ranges
|
|
287
|
+
const merged: { offset: number; length: number }[] = []
|
|
288
|
+
for (const r of ranges) {
|
|
289
|
+
const last = merged[merged.length - 1]
|
|
290
|
+
if (last && r.offset <= last.offset + last.length) {
|
|
291
|
+
last.length = Math.max(last.length, r.offset + r.length - last.offset)
|
|
292
|
+
} else {
|
|
293
|
+
merged.push({ ...r })
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Build replacement nodes: alternating text and highlighted spans
|
|
298
|
+
const parent = textNode.parentNode
|
|
299
|
+
const frag = document.createDocumentFragment()
|
|
300
|
+
let pos = 0
|
|
301
|
+
|
|
302
|
+
for (const r of merged) {
|
|
303
|
+
// Text before this highlight
|
|
304
|
+
if (r.offset > pos) {
|
|
305
|
+
frag.appendChild(document.createTextNode(content.slice(pos, r.offset)))
|
|
306
|
+
}
|
|
307
|
+
// Highlighted span
|
|
308
|
+
const span = document.createElement('span')
|
|
309
|
+
span.className = cssClass
|
|
310
|
+
span.textContent = content.slice(r.offset, r.offset + r.length)
|
|
311
|
+
frag.appendChild(span)
|
|
312
|
+
pos = r.offset + r.length
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Remaining text after last highlight
|
|
316
|
+
if (pos < content.length) {
|
|
317
|
+
frag.appendChild(document.createTextNode(content.slice(pos)))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
parent.replaceChild(frag, textNode)
|
|
321
|
+
}
|
|
314
322
|
}
|
|
315
323
|
|
|
316
324
|
/**
|
|
@@ -318,24 +326,23 @@ function applyHighlightsByNode(
|
|
|
318
326
|
* Returns true if there were any changes.
|
|
319
327
|
*/
|
|
320
328
|
export function runDomDiff(
|
|
321
|
-
|
|
322
|
-
|
|
329
|
+
oldContainer: HTMLElement,
|
|
330
|
+
newContainer: HTMLElement,
|
|
323
331
|
): boolean {
|
|
324
|
-
|
|
325
|
-
|
|
332
|
+
// Handle mermaid blocks separately via data-md-source comparison
|
|
333
|
+
const mermaidChanged = diffMermaidBlocks(oldContainer, newContainer)
|
|
326
334
|
|
|
327
|
-
|
|
328
|
-
|
|
335
|
+
const oldExtract = extractWords(oldContainer)
|
|
336
|
+
const newExtract = extractWords(newContainer)
|
|
329
337
|
|
|
330
|
-
|
|
338
|
+
const { oldOps, newOps } = computeWordDiff(oldExtract.words, newExtract.words)
|
|
331
339
|
|
|
332
|
-
|
|
333
|
-
oldOps.some((op) => op.type === "removed") || newOps.some((op) => op.type === "added");
|
|
340
|
+
const hasWordChanges = oldOps.some((op) => op.type === 'removed') || newOps.some((op) => op.type === 'added')
|
|
334
341
|
|
|
335
|
-
|
|
336
|
-
|
|
342
|
+
applyHighlightsByNode(oldOps, oldExtract.map, 'diff-removed')
|
|
343
|
+
applyHighlightsByNode(newOps, newExtract.map, 'diff-added')
|
|
337
344
|
|
|
338
|
-
|
|
345
|
+
return hasWordChanges || mermaidChanged
|
|
339
346
|
}
|
|
340
347
|
|
|
341
348
|
/**
|
|
@@ -343,74 +350,75 @@ export function runDomDiff(
|
|
|
343
350
|
* Highlights changed mermaid blocks with a border and appends a source diff.
|
|
344
351
|
*/
|
|
345
352
|
function diffMermaidBlocks(oldContainer: HTMLElement, newContainer: HTMLElement): boolean {
|
|
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
|
-
|
|
415
|
-
|
|
353
|
+
const oldMermaids = Array.from(oldContainer.querySelectorAll<HTMLElement>('[data-md="mermaid"]'))
|
|
354
|
+
const newMermaids = Array.from(newContainer.querySelectorAll<HTMLElement>('[data-md="mermaid"]'))
|
|
355
|
+
let changed = false
|
|
356
|
+
|
|
357
|
+
// Match by position (simple 1:1 pairing)
|
|
358
|
+
const count = Math.max(oldMermaids.length, newMermaids.length)
|
|
359
|
+
for (let i = 0; i < count; i++) {
|
|
360
|
+
const oldEl = oldMermaids[i]
|
|
361
|
+
const newEl = newMermaids[i]
|
|
362
|
+
|
|
363
|
+
if (!oldEl && newEl) {
|
|
364
|
+
// Added
|
|
365
|
+
newEl.classList.add('diff-block-added')
|
|
366
|
+
newEl.style.position = 'relative'
|
|
367
|
+
changed = true
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
if (oldEl && !newEl) {
|
|
371
|
+
// Removed
|
|
372
|
+
oldEl.classList.add('diff-block-removed')
|
|
373
|
+
oldEl.style.position = 'relative'
|
|
374
|
+
changed = true
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
if (!oldEl || !newEl) continue
|
|
378
|
+
|
|
379
|
+
const oldSource = (oldEl.getAttribute('data-md-source') || '').trim()
|
|
380
|
+
const newSource = (newEl.getAttribute('data-md-source') || '').trim()
|
|
381
|
+
|
|
382
|
+
if (oldSource === newSource) continue
|
|
383
|
+
changed = true
|
|
384
|
+
|
|
385
|
+
// Highlight containers
|
|
386
|
+
oldEl.style.borderLeft = '3px solid var(--color-accent-red)'
|
|
387
|
+
oldEl.style.paddingLeft = '8px'
|
|
388
|
+
newEl.style.borderLeft = '3px solid var(--color-accent-green)'
|
|
389
|
+
newEl.style.paddingLeft = '8px'
|
|
390
|
+
|
|
391
|
+
// Append source diffs
|
|
392
|
+
const oldLines = oldSource.split('\n')
|
|
393
|
+
const newLines = newSource.split('\n')
|
|
394
|
+
const lcs = simpleLCS(oldLines, newLines)
|
|
395
|
+
|
|
396
|
+
const buildDiffEl = (lines: string[], lcsArr: string[], side: 'old' | 'new') => {
|
|
397
|
+
const container = document.createElement('div')
|
|
398
|
+
container.className = 'mt-2 bg-bg-code rounded-md overflow-hidden text-tiny font-mono'
|
|
399
|
+
container.setAttribute('data-no-diff', 'true')
|
|
400
|
+
|
|
401
|
+
let li = 0, si = 0
|
|
402
|
+
while (si < lines.length) {
|
|
403
|
+
const row = document.createElement('div')
|
|
404
|
+
row.className = 'px-3 py-0.5 whitespace-pre-wrap'
|
|
405
|
+
if (li < lcsArr.length && lines[si] === lcsArr[li]) {
|
|
406
|
+
row.textContent = ' ' + lines[si]
|
|
407
|
+
si++
|
|
408
|
+
li++
|
|
409
|
+
} else {
|
|
410
|
+
row.classList.add(side === 'old' ? 'diff-removed' : 'diff-added')
|
|
411
|
+
row.textContent = (side === 'old' ? '- ' : '+ ') + lines[si]
|
|
412
|
+
si++
|
|
413
|
+
}
|
|
414
|
+
container.appendChild(row)
|
|
415
|
+
}
|
|
416
|
+
return container
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
oldEl.appendChild(buildDiffEl(oldLines, lcs, 'old'))
|
|
420
|
+
newEl.appendChild(buildDiffEl(newLines, lcs, 'new'))
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return changed
|
|
416
424
|
}
|