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:hover { background:#2c2c2c; color:#eee; }
|
||||||
.vv-ai-code-btn.ok { background:#264a26; border-color:#356b35; color:#cfe8cf; }
|
.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. */
|
/* 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-code.numbered code { counter-reset:vvln; }
|
||||||
.vv-ai-cl { counter-increment:vvln; }
|
.vv-ai-cl { counter-increment:vvln; }
|
||||||
.vv-ai-cl::before { content:counter(vvln); display:inline-block; width:2.6em; margin-right:.8em;
|
.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 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(); }
|
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
|
// 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
|
// 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.
|
// 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-n">${n} line${n === 1 ? '' : 's'}</span>`;
|
||||||
head += '<span class="vv-ai-code-sp"></span>';
|
head += '<span class="vv-ai-code-sp"></span>';
|
||||||
head += '<button type="button" class="vv-ai-code-btn" data-code-copy>Copy</button>';
|
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
|
// 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.
|
// button that silently does nothing, which is worse than not having one.
|
||||||
if (o.onInsertCode) {
|
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:
|
// 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
|
// 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.
|
// 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]');
|
const cbtn = e.target.closest('[data-code-copy],[data-code-insert]');
|
||||||
if (cbtn) {
|
if (cbtn) {
|
||||||
const card = cbtn.closest('.vv-ai-code');
|
const card = cbtn.closest('.vv-ai-code');
|
||||||
|
|||||||
@@ -2445,6 +2445,9 @@ if (document.getElementById('vv-sched-ai-chat')) {
|
|||||||
// Per-block Insert buttons render only where a page can receive them.
|
// Per-block Insert buttons render only where a page can receive them.
|
||||||
onInsertCode: vvAiInsertCode,
|
onInsertCode: vvAiInsertCode,
|
||||||
onOpenSource: vvAiOpenSource,
|
onOpenSource: vvAiOpenSource,
|
||||||
|
// Diff and per-hunk apply against whatever the editor holds at the moment of the click.
|
||||||
|
getCompareText: vvAiCompareText,
|
||||||
|
onReplaceCode: vvAiReplaceCode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2530,6 +2533,36 @@ function vvAiEditorOpen() {
|
|||||||
return !!ed && ed.style.display !== 'none';
|
return !!ed && ed.style.display !== 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a proposed block is diffed against: the editor exactly as it stands right now, not as it
|
||||||
|
// was when the answer arrived. Read fresh on every draw, so applying one hunk and redrawing shows
|
||||||
|
// the remaining differences rather than a stale picture.
|
||||||
|
function vvAiCompareText() {
|
||||||
|
const ta = document.getElementById('vv-editor-body');
|
||||||
|
return (ta && vvAiEditorOpen()) ? ta.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Takes the whole rewritten file back from a hunk apply. Whole-file rather than a splice because
|
||||||
|
// the component computed the result against this exact text — handing back a range would make
|
||||||
|
// both sides responsible for the arithmetic, and only one of them can be right.
|
||||||
|
function vvAiReplaceCode(text) {
|
||||||
|
const ta = document.getElementById('vv-editor-body');
|
||||||
|
if (!ta || !vvAiEditorOpen()) {
|
||||||
|
if (vvSchedChat) vvSchedChat.note('The editor is not open — nothing was applied.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bracketed immediately, so one Ctrl+Z steps back over the whole hunk rather than unpicking it
|
||||||
|
// a keystroke at a time. Cursor position is kept where it was where that still makes sense.
|
||||||
|
vvUndoCapture(true);
|
||||||
|
const caret = Math.min(ta.selectionStart, text.length);
|
||||||
|
ta.value = text;
|
||||||
|
ta.selectionStart = ta.selectionEnd = caret;
|
||||||
|
vvUndoCapture(true);
|
||||||
|
|
||||||
|
vvSyncHlOverlay();
|
||||||
|
vvEditorCursorMoved();
|
||||||
|
}
|
||||||
|
|
||||||
// Called by the chat component's per-block Insert button, with that block's exact text. This
|
// Called by the chat component's per-block Insert button, with that block's exact text. This
|
||||||
// replaced a whole-answer offer: the offer had to guess which block was meant when an answer
|
// replaced a whole-answer offer: the offer had to guess which block was meant when an answer
|
||||||
// carried several, and it asked after every code answer whether or not anything was wanted.
|
// carried several, and it asked after every code answer whether or not anything was wanted.
|
||||||
|
|||||||
Reference in New Issue
Block a user