Diff a proposed script against the open one and take it a hunk at a time

This commit is contained in:
Gmer4Lfe
2026-08-10 22:41:07 -04:00
parent 66f427dd2d
commit aa3d92360e
2 changed files with 252 additions and 0 deletions
+219
View File
@@ -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');
+33
View File
@@ -2445,6 +2445,9 @@ if (document.getElementById('vv-sched-ai-chat')) {
// Per-block Insert buttons render only where a page can receive them.
onInsertCode: vvAiInsertCode,
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';
}
// 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
// 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.