From 8c6e7822b47f32b514f75e47e15401028b334dd7 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Fri, 26 Jun 2026 06:34:47 -0700 Subject: [PATCH] feat(render): pure decorationPlan feeds editor + webview from one diff (#64, INV-49) Co-Authored-By: Claude Sonnet 4.6 --- src/trackChangesModel.ts | 36 ++++++++++++++++++++++++++++++++++++ test/decorationPlan.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 test/decorationPlan.test.ts diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index b792c3b..02dc7ea 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -263,6 +263,42 @@ export function wordEditHunks(currentText: string, rewrittenText: string): EditH return hunks; } +/** F12/#64 (INV-49/52): the editor render of one optimistically-applied proposal. */ +export interface DecorationPlan { + /** buffer ranges of inserted (proposed) text → tinted (INV-52). */ + insertions: { start: number; end: number }[]; + /** struck original text shown as a non-editable hint at a buffer offset (INV-52). */ + deletions: { at: number; text: string }[]; +} + +/** + * F12/#64 (INV-49): compute the editor decoration plan for a proposal whose applied + * text occupies `[anchorStart, anchorStart+replacement.length)` in the buffer. The + * SAME word diff the webview uses (`wordEditHunks`) drives it, so both surfaces show + * the identical diff. A changed run maps to (a) an insertion range over the run's + * applied text and (b) a deletion hint carrying the run's removed original text at + * the run start; a pure insertion has no deletion hint; a pure deletion has only a + * hint. Pure, vscode-free, deterministic. + */ +export function decorationPlan(anchorStart: number, original: string, replacement: string): DecorationPlan { + const insertions: { start: number; end: number }[] = []; + const deletions: { at: number; text: string }[] = []; + // Walk the word diff once, tracking the applied-side offset as we consume parts. + let appliedOffset = anchorStart; + for (const part of diffWordsWithSpace(original, replacement)) { + if (part.added) { + insertions.push({ start: appliedOffset, end: appliedOffset + part.value.length }); + appliedOffset += part.value.length; + } else if (part.removed) { + deletions.push({ at: appliedOffset, text: part.value }); + // removed text is NOT in the applied buffer → appliedOffset does not advance + } else { + appliedOffset += part.value.length; + } + } + return { insertions, deletions }; +} + const isWs = (c: string): boolean => /\s/.test(c); /** diff --git a/test/decorationPlan.test.ts b/test/decorationPlan.test.ts new file mode 100644 index 0000000..c149df9 --- /dev/null +++ b/test/decorationPlan.test.ts @@ -0,0 +1,27 @@ +import { describe, test, expect } from "vitest"; +import { decorationPlan } from "../src/trackChangesModel"; + +describe("decorationPlan (INV-49)", () => { + test("derives insertion ranges + deletion hints from original→replacement", () => { + // anchorStart 10; original 'the brown fox' → 'the red fox' + const plan = decorationPlan(10, "the brown fox", "the red fox"); + // one changed run: 'brown ' → 'red ' (word-level); insertion 'red ' tinted, + // deletion hint 'brown ' shown at the run start. + expect(plan.insertions.length).toBeGreaterThan(0); + expect(plan.deletions.length).toBeGreaterThan(0); + // insertion offsets are in buffer coords (>= anchorStart) and lie within the applied text + for (const ins of plan.insertions) { + expect(ins.start).toBeGreaterThanOrEqual(10); + expect(ins.end).toBeLessThanOrEqual(10 + "the red fox".length); + } + // a deletion hint carries the struck original text at a buffer offset + expect(plan.deletions[0].text).toContain("brown"); + }); + + test("pure insertion (no deletion) yields an insertion range and no deletion hint", () => { + // 'hello' → 'hello world': ' world' is a pure word-level insertion (no removed tokens) + const plan = decorationPlan(0, "hello", "hello world"); + expect(plan.insertions.length).toBe(1); + expect(plan.deletions.length).toBe(0); + }); +});