Merge: author-colored track changes — Phase 2 (editor pane)
Brings author-colored track changes inline into the main editor (reverses INV-32): four per-author decoration types, decorateCommitted decorating all committed changes-since-baseline by author (human green / Claude blue + struck hints), standalone deletions neutral, proposals recolored Claude, overlap stacking. Subagent-driven execution of Tasks 6-9; opus whole-branch review + fix. typecheck + 265 unit + build + E2E green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,14 @@ del { text-decoration: line-through; opacity: 0.7; }
|
|||||||
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
|
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
|
||||||
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
|
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
|
||||||
|
|
||||||
|
/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B},
|
||||||
|
both marks must show. cw-ins-* sets text-decoration:none which would otherwise
|
||||||
|
suppress the parent del's strikethrough. `inherit` restores the parent's
|
||||||
|
line-through while the child's border-bottom underline is unaffected.
|
||||||
|
The engine currently emits SIBLING ins/del (never nested) so this rule is a
|
||||||
|
forward defensive guarantee only. See task-8-report.md for details. */
|
||||||
|
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; }
|
||||||
|
|
||||||
/* whole-block ops: an added block is an insertion (its author runs are emitted as
|
/* whole-block ops: an added block is an insertion (its author runs are emitted as
|
||||||
cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral
|
cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral
|
||||||
struck (adjacency heuristic fallback) */
|
struck (adjacency heuristic fallback) */
|
||||||
|
|||||||
+128
-29
@@ -7,24 +7,34 @@
|
|||||||
* for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens
|
* for deletions (INV-52, decorationPlan/INV-49), and (3) provides a CodeLens
|
||||||
* `Accept ▾ / Reject ▾` above each block whose ▾ opens a QuickPick (this / all).
|
* `Accept ▾ / Reject ▾` above each block whose ▾ opens a QuickPick (this / all).
|
||||||
* Owns no proposal STATE — it is a view over ProposalController (which stays the
|
* Owns no proposal STATE — it is a view over ProposalController (which stays the
|
||||||
* pure F4 owner). A document with no pending proposals shows nothing (INV-32's
|
* pure F4 owner). Phase 2 decorates ALL committed changes-since-baseline by author
|
||||||
* spirit when nothing is pending).
|
* (human green / Claude blue + struck hints for deletions), plus pending proposals;
|
||||||
|
* a pinned baseline suppresses committed marks entirely (pin→clean, INV-48).
|
||||||
*/
|
*/
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import type { ProposalController } from "./proposalController";
|
import type { ProposalController } from "./proposalController";
|
||||||
import { decorationPlan } from "./trackChangesModel";
|
import type { DiffViewController } from "./diffViewController";
|
||||||
|
import type { AttributionController } from "./attributionController";
|
||||||
|
import { decorationPlan, wordEditHunks, authorAt } from "./trackChangesModel";
|
||||||
import { isAuthorable } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
|
|
||||||
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
|
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
private readonly insertionDeco = vscode.window.createTextEditorDecorationType({
|
private readonly insDeco: Record<"human" | "claude", vscode.TextEditorDecorationType> = {
|
||||||
backgroundColor: new vscode.ThemeColor("diffEditor.insertedTextBackground"),
|
human: vscode.window.createTextEditorDecorationType({
|
||||||
});
|
backgroundColor: "rgba(63,185,80,0.14)", borderColor: "#3fb950",
|
||||||
private readonly deletionDeco = vscode.window.createTextEditorDecorationType({
|
border: "0 0 2px 0", borderStyle: "solid",
|
||||||
// a non-editable struck-red hint injected AFTER the insertion (INV-52)
|
}),
|
||||||
after: { color: new vscode.ThemeColor("gitDecoration.deletedResourceForeground") },
|
claude: vscode.window.createTextEditorDecorationType({
|
||||||
textDecoration: "none",
|
backgroundColor: "rgba(88,166,255,0.15)", borderColor: "#58a6ff",
|
||||||
});
|
border: "0 0 2px 0", borderStyle: "solid",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
private readonly delDeco: Record<"human" | "claude" | "none", vscode.TextEditorDecorationType> = {
|
||||||
|
human: vscode.window.createTextEditorDecorationType({ after: { color: "#f85149" } }),
|
||||||
|
claude: vscode.window.createTextEditorDecorationType({ after: { color: "#bc8cff" } }),
|
||||||
|
none: vscode.window.createTextEditorDecorationType({ after: { color: new vscode.ThemeColor("descriptionForeground") } }),
|
||||||
|
};
|
||||||
private readonly lensEmitter = new vscode.EventEmitter<void>();
|
private readonly lensEmitter = new vscode.EventEmitter<void>();
|
||||||
readonly onDidChangeCodeLenses = this.lensEmitter.event;
|
readonly onDidChangeCodeLenses = this.lensEmitter.event;
|
||||||
/** Pending debounce timers — one per URI — coalesce rapid-fire propose events
|
/** Pending debounce timers — one per URI — coalesce rapid-fire propose events
|
||||||
@@ -34,13 +44,29 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
|||||||
* optimisticApply runs concurrently with the still-in-progress propose loop,
|
* optimisticApply runs concurrently with the still-in-progress propose loop,
|
||||||
* causing "file changed in the meantime" workspace-edit conflicts. */
|
* causing "file changed in the meantime" workspace-edit conflicts. */
|
||||||
private readonly pendingApply = new Map<string, ReturnType<typeof setTimeout>>();
|
private readonly pendingApply = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
/** Debounce timers for committed-change re-render (one per URI, 50 ms). */
|
||||||
|
private readonly pendingRender = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
constructor(private readonly proposals: ProposalController) {
|
constructor(
|
||||||
|
private readonly proposals: ProposalController,
|
||||||
|
private readonly diffView: DiffViewController,
|
||||||
|
private readonly attribution: AttributionController,
|
||||||
|
) {
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
this.insertionDeco, this.deletionDeco, this.lensEmitter,
|
this.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.delDeco.none, this.lensEmitter,
|
||||||
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
|
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
|
||||||
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
|
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
|
||||||
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
|
vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)),
|
||||||
|
// Refresh committed decorations when the baseline advances (pin / machine-landing / open).
|
||||||
|
this.diffView.onDidChangeBaseline(({ uri }) => {
|
||||||
|
const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri);
|
||||||
|
if (ed) this.renderEditor(ed);
|
||||||
|
}),
|
||||||
|
// Refresh committed decorations (debounced) when the active doc changes — attribution spans shift.
|
||||||
|
vscode.workspace.onDidChangeTextDocument((e) => {
|
||||||
|
const ed = vscode.window.activeTextEditor;
|
||||||
|
if (ed && e.document === ed.document) this.scheduleRender(ed);
|
||||||
|
}),
|
||||||
// the four QuickPick-backed menu commands
|
// the four QuickPick-backed menu commands
|
||||||
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
|
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
|
||||||
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
|
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
|
||||||
@@ -62,6 +88,20 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Debounce a committed-change re-render for `editor` (50 ms — lets rapid typing settle). */
|
||||||
|
private scheduleRender(editor: vscode.TextEditor): void {
|
||||||
|
const uri = editor.document.uri.toString();
|
||||||
|
const prev = this.pendingRender.get(uri);
|
||||||
|
if (prev !== undefined) clearTimeout(prev);
|
||||||
|
this.pendingRender.set(
|
||||||
|
uri,
|
||||||
|
setTimeout(() => {
|
||||||
|
this.pendingRender.delete(uri);
|
||||||
|
this.renderEditor(editor);
|
||||||
|
}, 50),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */
|
/** Apply any not-yet-applied proposals on the doc, then re-decorate + refresh lenses. */
|
||||||
private async onProposalsChanged(uri: string): Promise<void> {
|
private async onProposalsChanged(uri: string): Promise<void> {
|
||||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||||
@@ -80,29 +120,86 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
|||||||
/** Decorate the editor for every applied proposal on its document (INV-52). */
|
/** Decorate the editor for every applied proposal on its document (INV-52). */
|
||||||
private renderEditor(editor: vscode.TextEditor): void {
|
private renderEditor(editor: vscode.TextEditor): void {
|
||||||
const doc = editor.document;
|
const doc = editor.document;
|
||||||
if (doc.languageId !== "markdown") {
|
const clearAll = () => {
|
||||||
editor.setDecorations(this.insertionDeco, []);
|
for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], []);
|
||||||
editor.setDecorations(this.deletionDeco, []);
|
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], []);
|
||||||
return;
|
};
|
||||||
}
|
if (doc.languageId !== "markdown") return clearAll();
|
||||||
const key = this.proposals.keyFor(doc);
|
const key = this.proposals.keyFor(doc);
|
||||||
const insertions: vscode.Range[] = [];
|
const ins: Record<"human" | "claude", vscode.Range[]> = { human: [], claude: [] };
|
||||||
const deletions: vscode.DecorationOptions[] = [];
|
const del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]> = { human: [], claude: [], none: [] };
|
||||||
|
// Pending proposals — always Claude (INV: proposals are agent-authored).
|
||||||
for (const v of this.proposals.listProposals(doc)) {
|
for (const v of this.proposals.listProposals(doc)) {
|
||||||
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
|
if (v.anchorStart === null || v.original === undefined || !this.proposals.isApplied(key, v.id)) continue;
|
||||||
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
|
const plan = decorationPlan(v.anchorStart, v.original, v.replacement);
|
||||||
for (const ins of plan.insertions) {
|
for (const i of plan.insertions) ins.claude.push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
||||||
insertions.push(new vscode.Range(doc.positionAt(ins.start), doc.positionAt(ins.end)));
|
for (const d of plan.deletions) {
|
||||||
}
|
del.claude.push({
|
||||||
for (const del of plan.deletions) {
|
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||||
deletions.push({
|
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||||
range: new vscode.Range(doc.positionAt(del.at), doc.positionAt(del.at)),
|
});
|
||||||
renderOptions: { after: { contentText: ` ${del.text} `, textDecoration: "line-through" } },
|
}
|
||||||
|
}
|
||||||
|
// Committed changes-since-baseline are added by decorateCommitted (pushes into ins/del[author]).
|
||||||
|
this.decorateCommitted(editor, ins, del);
|
||||||
|
for (const a of ["human", "claude"] as const) editor.setDecorations(this.insDeco[a], ins[a]);
|
||||||
|
for (const a of ["human", "claude", "none"] as const) editor.setDecorations(this.delDeco[a], del[a]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorates committed (non-proposal) changes-since-baseline by author (Task 7 / reverse INV-32).
|
||||||
|
* Diffs current buffer against the F6 baseline; attributes each changed run to its author;
|
||||||
|
* skips any hunk overlapping an applied pending proposal (the proposal loop owns those).
|
||||||
|
* pin→clean: returns without marking when baseline.reason === "pinned".
|
||||||
|
* Adjacency heuristic: a deletion in a hunk that has insertions inherits the hunk author;
|
||||||
|
* a standalone deletion (hunk with no insertions) renders neutral ("none") — matching the
|
||||||
|
* preview's cw-del-none treatment.
|
||||||
|
*/
|
||||||
|
private decorateCommitted(
|
||||||
|
editor: vscode.TextEditor,
|
||||||
|
ins: Record<"human" | "claude", vscode.Range[]>,
|
||||||
|
del: Record<"human" | "claude" | "none", vscode.DecorationOptions[]>,
|
||||||
|
): void {
|
||||||
|
const doc = editor.document;
|
||||||
|
const baseline = this.diffView.getBaseline(doc.uri.toString());
|
||||||
|
// No baseline yet, or baseline was pinned (pin→clean: no committed marks, INV-48).
|
||||||
|
if (!baseline || baseline.reason === "pinned") return;
|
||||||
|
const current = doc.getText();
|
||||||
|
// Nothing changed — short-circuit before the diff.
|
||||||
|
if (current === baseline.text) return;
|
||||||
|
const spans = this.attribution.spansFor(doc);
|
||||||
|
const key = this.proposals.keyFor(doc);
|
||||||
|
// Build the set of current-buffer ranges owned by applied pending proposals so we
|
||||||
|
// can skip hunks that fall inside them — the proposal loop already decorates those.
|
||||||
|
const appliedRanges: { start: number; end: number }[] = [];
|
||||||
|
for (const v of this.proposals.listProposals(doc)) {
|
||||||
|
if (v.anchorStart !== null && v.original !== undefined && this.proposals.isApplied(key, v.id)) {
|
||||||
|
appliedRanges.push({ start: v.anchorStart, end: v.anchorStart + v.replacement.length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// wordEditHunks(current, baseline.text): start/end are in current coords; replacement is baseline text.
|
||||||
|
const hunks = wordEditHunks(current, baseline.text);
|
||||||
|
for (const h of hunks) {
|
||||||
|
// Skip hunks whose current-buffer range overlaps an applied pending proposal.
|
||||||
|
// The skip is whole-hunk and therefore conservative: a committed edit word-merged
|
||||||
|
// into a proposal's hunk is skipped entirely — rare at word granularity.
|
||||||
|
if (appliedRanges.some((r) => h.start < r.end && h.end > r.start)) continue;
|
||||||
|
const author = authorAt(h.start, spans) ?? "human";
|
||||||
|
// h.replacement = baseline text for this span; current.slice(h.start, h.end) = applied text.
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||||
|
for (const i of plan.insertions) {
|
||||||
|
ins[author].push(new vscode.Range(doc.positionAt(i.start), doc.positionAt(i.end)));
|
||||||
|
}
|
||||||
|
// Adjacency heuristic: a deletion paired with an insertion in THIS hunk inherits the
|
||||||
|
// insertion author; a standalone deletion (no insertion in the hunk) is neutral.
|
||||||
|
const delAuthor = plan.insertions.length > 0 ? author : "none";
|
||||||
|
for (const d of plan.deletions) {
|
||||||
|
del[delAuthor].push({
|
||||||
|
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||||
|
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
editor.setDecorations(this.insertionDeco, insertions);
|
|
||||||
editor.setDecorations(this.deletionDeco, deletions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
|
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
|
||||||
@@ -146,6 +243,8 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
|||||||
dispose(): void {
|
dispose(): void {
|
||||||
for (const t of this.pendingApply.values()) clearTimeout(t);
|
for (const t of this.pendingApply.values()) clearTimeout(t);
|
||||||
this.pendingApply.clear();
|
this.pendingApply.clear();
|
||||||
|
for (const t of this.pendingRender.values()) clearTimeout(t);
|
||||||
|
this.pendingRender.clear();
|
||||||
for (const d of this.disposables) d.dispose();
|
for (const d of this.disposables) d.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -119,7 +119,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
|
|
||||||
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
|
// --- F12 (#64): the editor surface — optimistic-apply proposals into the buffer,
|
||||||
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
|
// decorate the diff, and provide Accept ▾/Reject ▾ CodeLens (INV-48/49/52/53). ---
|
||||||
const editorProposalController = new EditorProposalController(proposalController);
|
const editorProposalController = new EditorProposalController(proposalController, diffViewController, attributionController);
|
||||||
context.subscriptions.push(editorProposalController);
|
context.subscriptions.push(editorProposalController);
|
||||||
|
|
||||||
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
||||||
|
|||||||
@@ -642,15 +642,6 @@ function isCloseSentinel(m: string): boolean {
|
|||||||
return m === SENT.claude.close || m === SENT.human.close;
|
return m === SENT.claude.close || m === SENT.human.close;
|
||||||
}
|
}
|
||||||
|
|
||||||
function authorBadge(authors: Set<AuthorKind>): { cls: string; label: string } | null {
|
|
||||||
if (authors.size === 0) return null;
|
|
||||||
if (authors.size > 1) return { cls: "cw-mixed", label: "mixed" };
|
|
||||||
const only = [...authors][0];
|
|
||||||
return only === "claude"
|
|
||||||
? { cls: "cw-by-claude", label: "Claude" }
|
|
||||||
: { cls: "cw-by-human", label: "You" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Markdown emphasis / code delimiters whose RUNS must never be split by an
|
// Markdown emphasis / code delimiters whose RUNS must never be split by an
|
||||||
// injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks
|
// injected sentinel — a sentinel between two run chars (e.g. `*|*`) breaks
|
||||||
// markdown-it's delimiter pairing (#33 CASE1).
|
// markdown-it's delimiter pairing (#33 CASE1).
|
||||||
@@ -729,7 +720,7 @@ const ALL_SENTINELS = new RegExp(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #33: token-aware replacement of the rendered author sentinels with `cw-by-*`
|
* #33: token-aware replacement of the rendered author sentinels with `cw-ins-*`
|
||||||
* spans. A naive global string-replace (the old approach) could emit a span that
|
* spans. A naive global string-replace (the old approach) could emit a span that
|
||||||
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
|
* CROSSES an element boundary — `<span><strong>bo</span>ld</strong>` — when a
|
||||||
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
|
* boundary fell inside an emphasis run (CASE3). This walks the rendered HTML and
|
||||||
|
|||||||
@@ -246,9 +246,60 @@ describe("wordDiffByAuthor", () => {
|
|||||||
expect(html).not.toContain("cw-del-human");
|
expect(html).not.toContain("cw-del-human");
|
||||||
expect(html).not.toContain("cw-del-claude");
|
expect(html).not.toContain("cw-del-claude");
|
||||||
});
|
});
|
||||||
|
test("emits SIBLING ins/del elements, never nested — CSS overlap rule in preview.css is a defensive forward guarantee", () => {
|
||||||
|
// The engine assigns the cluster's insertion author to all deletions in the same
|
||||||
|
// cluster (adjacency heuristic), so <del> and <ins> always carry the SAME author
|
||||||
|
// as siblings. Cross-author nesting (<del class="cw-del-X"><ins class="cw-ins-Y">)
|
||||||
|
// is never produced today. The CSS rule `.cw-del-claude .cw-ins-human { text-decoration: inherit }`
|
||||||
|
// guards against a future regression where such nesting appears.
|
||||||
|
const spans: AuthorSpan[] = [{ start: 0, end: 100, author: "human" }];
|
||||||
|
const html = wordDiffByAuthor("old text", "new text", 0, spans);
|
||||||
|
expect(html).toContain('<del class="cw-del-human">');
|
||||||
|
expect(html).toContain('<ins class="cw-ins-human">');
|
||||||
|
// Structural check: no <del> immediately wraps an <ins> (sibling, not nested).
|
||||||
|
// /<del[^>]*>[^<]*<ins/ would match a nested case but not a sibling case
|
||||||
|
// because [^<]* stops before the closing </del> tag.
|
||||||
|
expect(html).not.toMatch(/<del[^>]*>[^<]*<ins/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import { renderPlain } from "../src/trackChangesModel";
|
import { renderPlain, wordEditHunks, decorationPlan } from "../src/trackChangesModel";
|
||||||
|
|
||||||
|
test("committed change: wordEditHunks(current, baseline) → start/end index current; replacement is baseline text", () => {
|
||||||
|
const baseline = "the light mode";
|
||||||
|
const current = "the dark mode";
|
||||||
|
const [h] = wordEditHunks(current, baseline);
|
||||||
|
expect(current.slice(h.start, h.end)).toContain("dark"); // applied (current) side
|
||||||
|
expect(h.replacement).toContain("light"); // baseline (deleted) side
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||||
|
expect(plan.insertions.length).toBeGreaterThan(0);
|
||||||
|
expect(plan.deletions.some((d) => d.text.includes("light"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("committed change with NO shared prefix: current offsets + deleted text both correct", () => {
|
||||||
|
const baseline = "alpha tail";
|
||||||
|
const current = "beta tail";
|
||||||
|
const [h] = wordEditHunks(current, baseline);
|
||||||
|
expect(h.start).toBe(0);
|
||||||
|
expect(current.slice(h.start, h.end)).toContain("beta"); // applied
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, current.slice(h.start, h.end));
|
||||||
|
expect(plan.deletions.some((d) => d.text.includes("alpha"))).toBe(true); // deletion not lost
|
||||||
|
});
|
||||||
|
|
||||||
|
test("standalone committed deletion → plan has no insertions (controller routes del to neutral)", () => {
|
||||||
|
// current = "keep word" (baseline had "this " between "keep " and "word" — standalone deletion)
|
||||||
|
const [h] = wordEditHunks("keep word", "keep this word");
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, "keep word".slice(h.start, h.end));
|
||||||
|
expect(plan.insertions.length).toBe(0);
|
||||||
|
expect(plan.deletions.some((d) => d.text.includes("this"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("paired committed replace → plan has insertions (deletion inherits author, not neutral)", () => {
|
||||||
|
// current = "the dark mode", baseline = "the light mode" — replacement, so paired
|
||||||
|
const [h] = wordEditHunks("the dark mode", "the light mode");
|
||||||
|
const plan = decorationPlan(h.start, h.replacement, "the dark mode".slice(h.start, h.end));
|
||||||
|
expect(plan.insertions.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
describe("renderPlain", () => {
|
describe("renderPlain", () => {
|
||||||
test("renderPlain renders current buffer as plain markdown (no marks)", () => {
|
test("renderPlain renders current buffer as plain markdown (no marks)", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user