Diff a proposed script against the open one and take it a hunk at a time
This commit is contained in:
@@ -206,6 +206,27 @@ function vv_ai_chat_assets(): void {
|
||||
.vv-ai-code-btn:hover { background:#2c2c2c; color:#eee; }
|
||||
.vv-ai-code-btn.ok { background:#264a26; border-color:#356b35; color:#cfe8cf; }
|
||||
/* Generated content, so it stays out of textContent — Copy and Insert return the code alone. */
|
||||
/* ── Diff view ──────────────────────────────────────────────────────────────
|
||||
Replaces the code body rather than sitting beside it: on a 15" panel the point of a diff is to
|
||||
be the thing you are reading, and showing both doubles the height to say the same thing twice. */
|
||||
.vv-ai-code.diffing pre { display:none; }
|
||||
.vv-ai-diff { font-family:monospace; font-size:11.5px; line-height:1.5; }
|
||||
.vv-ai-hunk { border-top:1px solid #222; }
|
||||
.vv-ai-hunk-h { display:flex; align-items:center; gap:8px; padding:3px 8px; background:#161616;
|
||||
font-size:10px; color:#666; font-family:inherit; }
|
||||
.vv-ai-hunk-n { color:#4a4a4a; }
|
||||
.vv-ai-dl { display:flex; padding:0 8px; white-space:pre-wrap; word-break:break-word; }
|
||||
/* The marker column is what makes a diff readable without colour — which matters for the
|
||||
colour-blind case and for a screen being read from across a room. */
|
||||
.vv-ai-dm { display:inline-block; width:1.2em; flex:0 0 auto; color:#3f3f3f; user-select:none; }
|
||||
.vv-ai-dl.ctx { color:#6a6a6a; }
|
||||
.vv-ai-dl.add { background:#12240f; color:#9fd68a; }
|
||||
.vv-ai-dl.del { background:#2a1212; color:#d68a8a; }
|
||||
.vv-ai-dl.add .vv-ai-dm { color:#5f9f4f; }
|
||||
.vv-ai-dl.del .vv-ai-dm { color:#9f5f5f; }
|
||||
.vv-ai-diff-warn { padding:6px 9px; font-size:10.5px; color:#d8b070; background:#241d10;
|
||||
border-bottom:1px solid #222; font-family:inherit; }
|
||||
|
||||
.vv-ai-code.numbered code { counter-reset:vvln; }
|
||||
.vv-ai-cl { counter-increment:vvln; }
|
||||
.vv-ai-cl::before { content:counter(vvln); display:inline-block; width:2.6em; margin-right:.8em;
|
||||
@@ -511,6 +532,168 @@ vv_ai_profiles_script();
|
||||
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
|
||||
function clearEmpty() { const e = chatEl().querySelector('.vv-ai-empty'); if (e) e.remove(); }
|
||||
|
||||
// ── Diff ─────────────────────────────────────────────────────────────
|
||||
// Computed here from the two full texts, never asked of the model. A model asked for a unified
|
||||
// diff produces a plausible-looking one with wrong line numbers and dropped context often
|
||||
// enough to be useless, and it already returns the whole modified script — so the reliable
|
||||
// move is to diff what it gave against what is open and show the difference ourselves.
|
||||
|
||||
// Guard against a pathological pair freezing the tab. 4M cells is roughly 2000x2000 lines,
|
||||
// far past any script in this repo; beyond it the block is simply offered as a block.
|
||||
const DIFF_CELL_MAX = 4000000;
|
||||
|
||||
function diffLines(A, B) {
|
||||
const n = A.length, m = B.length;
|
||||
|
||||
// Common prefix and suffix are stripped before the matrix is built. Edits cluster in the
|
||||
// middle of a file, so this usually turns a 300x300 table into something trivial.
|
||||
let s = 0; while (s < n && s < m && A[s] === B[s]) s++;
|
||||
let e = 0; while (e < n - s && e < m - s && A[n - 1 - e] === B[m - 1 - e]) e++;
|
||||
|
||||
const a = A.slice(s, n - e), b = B.slice(s, m - e);
|
||||
if (a.length * b.length > DIFF_CELL_MAX) return null;
|
||||
|
||||
// Longest common subsequence, filled from the end so the walk forward below is greedy and
|
||||
// produces the conventional "deletions before insertions" ordering.
|
||||
const R = a.length, C = b.length;
|
||||
const dp = [];
|
||||
for (let i = 0; i <= R; i++) dp.push(new Uint32Array(C + 1));
|
||||
for (let i = R - 1; i >= 0; i--) {
|
||||
for (let j = C - 1; j >= 0; j--) {
|
||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const ops = [];
|
||||
for (let k = 0; k < s; k++) ops.push({ t: ' ', text: A[k] });
|
||||
let i = 0, j = 0;
|
||||
while (i < R && j < C) {
|
||||
if (a[i] === b[j]) { ops.push({ t: ' ', text: a[i] }); i++; j++; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: '-', text: a[i] }); i++; }
|
||||
else { ops.push({ t: '+', text: b[j] }); j++; }
|
||||
}
|
||||
while (i < R) ops.push({ t: '-', text: a[i++] });
|
||||
while (j < C) ops.push({ t: '+', text: b[j++] });
|
||||
for (let k = 0; k < e; k++) ops.push({ t: ' ', text: A[n - e + k] });
|
||||
return ops;
|
||||
}
|
||||
|
||||
// Changed lines, grouped with context. Runs closer together than twice the context merge into
|
||||
// one hunk rather than showing the same lines as trailing context and then leading context.
|
||||
const DIFF_CTX = 3;
|
||||
function diffHunks(ops, ctx) {
|
||||
ctx = ctx === undefined ? DIFF_CTX : ctx;
|
||||
const changed = [];
|
||||
ops.forEach((o, k) => { if (o.t !== ' ') changed.push(k); });
|
||||
if (!changed.length) return [];
|
||||
|
||||
const hunks = [];
|
||||
let from = changed[0], to = changed[0];
|
||||
for (let x = 1; x < changed.length; x++) {
|
||||
if (changed[x] - to <= ctx * 2) { to = changed[x]; continue; }
|
||||
hunks.push({ from, to });
|
||||
from = to = changed[x];
|
||||
}
|
||||
hunks.push({ from, to });
|
||||
|
||||
return hunks.map(h => ({
|
||||
from: Math.max(0, h.from - ctx),
|
||||
to: Math.min(ops.length - 1, h.to + ctx),
|
||||
adds: ops.slice(h.from, h.to + 1).filter(o => o.t === '+').length,
|
||||
dels: ops.slice(h.from, h.to + 1).filter(o => o.t === '-').length,
|
||||
}));
|
||||
}
|
||||
|
||||
// Rebuilds the whole file with only the selected hunks taken. Outside a selected hunk the old
|
||||
// side wins (keep '-', drop '+'); inside it the new side does. That means applying one hunk
|
||||
// cannot disturb a line the operator has not agreed to change — which is the entire point of
|
||||
// doing this per hunk instead of replacing the file.
|
||||
function applySelected(ops, hunks, selected) {
|
||||
const inSel = new Array(ops.length).fill(false);
|
||||
selected.forEach(hi => {
|
||||
const h = hunks[hi];
|
||||
if (h) for (let k = h.from; k <= h.to; k++) inSel[k] = true;
|
||||
});
|
||||
const out = [];
|
||||
ops.forEach((o, k) => {
|
||||
if (o.t === ' ') out.push(o.text);
|
||||
else if (o.t === '-') { if (!inSel[k]) out.push(o.text); }
|
||||
else { if (inSel[k]) out.push(o.text); }
|
||||
});
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// Share of the smaller file that survives unchanged. A rewrite scores near zero and is offered
|
||||
// as a block, because rendering a whole new script as one enormous all-added hunk is noise
|
||||
// dressed as review.
|
||||
const DIFF_MIN_SIM = 0.30;
|
||||
function diffSimilarity(ops, A, B) {
|
||||
const same = ops.reduce((n, o) => n + (o.t === ' ' ? 1 : 0), 0);
|
||||
const base = Math.max(1, Math.min(A.length, B.length));
|
||||
return same / base;
|
||||
}
|
||||
|
||||
// Held per card rather than per answer. Nothing is cached across a render: every draw
|
||||
// recomputes against the editor as it stands, so applying a hunk makes that hunk disappear
|
||||
// from the view because it genuinely is no longer a difference.
|
||||
const diffState = new WeakMap();
|
||||
|
||||
function drawDiff(card) {
|
||||
const box = card.querySelector('[data-diff-body]');
|
||||
if (!box) return;
|
||||
|
||||
const code = (card.querySelector('code') || {}).textContent || '';
|
||||
const cur = String((o.getCompareText && o.getCompareText()) || '');
|
||||
|
||||
if (!cur.trim()) {
|
||||
box.innerHTML = `<div class="vv-ai-none">nothing open to compare against</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const A = cur.split('\n'), B = code.split('\n');
|
||||
const ops = diffLines(A, B);
|
||||
if (!ops) {
|
||||
box.innerHTML = `<div class="vv-ai-none">too large to diff — use Insert instead</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const hunks = diffHunks(ops);
|
||||
if (!hunks.length) {
|
||||
box.innerHTML = `<div class="vv-ai-none">identical to what is open — nothing to apply</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const sim = diffSimilarity(ops, A, B);
|
||||
diffState.set(card, { ops, hunks });
|
||||
|
||||
// Below the threshold this is a different file, not an edit of this one. Said plainly rather
|
||||
// than rendered as one vast all-added hunk, which looks like review but reads as noise.
|
||||
const warn = sim < DIFF_MIN_SIM
|
||||
? `<div class="vv-ai-diff-warn">Only ${Math.round(sim * 100)}% of the open file survives —
|
||||
this looks like a replacement rather than an edit. Check before applying.</div>`
|
||||
: '';
|
||||
|
||||
box.innerHTML = warn + hunks.map((h, hi) => {
|
||||
const rows = [];
|
||||
for (let k = h.from; k <= h.to; k++) {
|
||||
const op = ops[k];
|
||||
const cls = op.t === '+' ? 'add' : op.t === '-' ? 'del' : 'ctx';
|
||||
rows.push(`<div class="vv-ai-dl ${cls}"><span class="vv-ai-dm">${op.t}</span>`
|
||||
+ `<span>${esc(op.text)}</span></div>`);
|
||||
}
|
||||
return `<div class="vv-ai-hunk">
|
||||
<div class="vv-ai-hunk-h">
|
||||
<span>hunk ${hi + 1} of ${hunks.length}</span>
|
||||
<span class="vv-ai-hunk-n">+${h.adds} −${h.dels}</span>
|
||||
<span class="vv-ai-code-sp"></span>
|
||||
<button type="button" class="vv-ai-code-btn" data-hunk="${hi}">Apply</button>
|
||||
</div>
|
||||
${rows.join('')}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Long blocks get numbered, because "the error is on line 40" is unusable against a wall of
|
||||
// unnumbered text. The number is CSS generated content on a per-line span, which keeps it out
|
||||
// of textContent — so Copy and Insert return the code and never the gutter.
|
||||
@@ -531,6 +714,12 @@ vv_ai_profiles_script();
|
||||
head += `<span class="vv-ai-code-n">${n} line${n === 1 ? '' : 's'}</span>`;
|
||||
head += '<span class="vv-ai-code-sp"></span>';
|
||||
head += '<button type="button" class="vv-ai-code-btn" data-code-copy>Copy</button>';
|
||||
// Offered wherever there is something to compare against. Whether the open file is actually
|
||||
// related to this block is decided on click, from the text as it stands then — the editor
|
||||
// may have been switched or edited since the answer arrived.
|
||||
if (o.getCompareText && o.onReplaceCode) {
|
||||
head += '<button type="button" class="vv-ai-code-btn" data-code-diff>Diff</button>';
|
||||
}
|
||||
// Only offered where the page can actually receive it. Without a target this would be a
|
||||
// button that silently does nothing, which is worse than not having one.
|
||||
if (o.onInsertCode) {
|
||||
@@ -796,6 +985,36 @@ vv_ai_profiles_script();
|
||||
// Code-card buttons. The text is read from the DOM rather than carried in a data attribute:
|
||||
// a script full of quotes and backslashes interpolated into an attribute is an escaping bug
|
||||
// waiting to happen, and textContent already holds exactly what the model wrote.
|
||||
// Apply one hunk. The editor is rewritten whole — the page owns splicing it in with undo —
|
||||
// and then the diff is redrawn against the new content, so what was just applied vanishes
|
||||
// from the list because it is no longer a difference.
|
||||
const hb = e.target.closest('[data-hunk]');
|
||||
if (hb) {
|
||||
const card = hb.closest('.vv-ai-code');
|
||||
const st = diffState.get(card);
|
||||
if (!st) return;
|
||||
const next = applySelected(st.ops, st.hunks, new Set([Number(hb.dataset.hunk)]));
|
||||
if (o.onReplaceCode) o.onReplaceCode(next);
|
||||
drawDiff(card);
|
||||
return;
|
||||
}
|
||||
|
||||
const dbtn = e.target.closest('[data-code-diff]');
|
||||
if (dbtn) {
|
||||
const card = dbtn.closest('.vv-ai-code');
|
||||
const open = card.classList.toggle('diffing');
|
||||
dbtn.classList.toggle('ok', open);
|
||||
dbtn.textContent = open ? 'Code' : 'Diff';
|
||||
let box = card.querySelector('[data-diff-body]');
|
||||
if (!box) {
|
||||
card.insertAdjacentHTML('beforeend', '<div class="vv-ai-diff" data-diff-body></div>');
|
||||
box = card.querySelector('[data-diff-body]');
|
||||
}
|
||||
box.hidden = !open;
|
||||
if (open) drawDiff(card);
|
||||
return;
|
||||
}
|
||||
|
||||
const cbtn = e.target.closest('[data-code-copy],[data-code-insert]');
|
||||
if (cbtn) {
|
||||
const card = cbtn.closest('.vv-ai-code');
|
||||
|
||||
Reference in New Issue
Block a user