feat: Task 7 — decorateCommitted fills committed author-colored changes in the editor (reverses INV-32)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,9 @@
|
||||
*/
|
||||
import * as vscode from "vscode";
|
||||
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";
|
||||
|
||||
export class EditorProposalController implements vscode.Disposable, vscode.CodeLensProvider {
|
||||
@@ -40,13 +42,29 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
||||
* optimisticApply runs concurrently with the still-in-progress propose loop,
|
||||
* causing "file changed in the meantime" workspace-edit conflicts. */
|
||||
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.insDeco.human, this.insDeco.claude, this.delDeco.human, this.delDeco.claude, this.lensEmitter,
|
||||
vscode.languages.registerCodeLensProvider({ language: "markdown" }, this),
|
||||
this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)),
|
||||
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
|
||||
vscode.commands.registerCommand("cowriting.proposalAcceptMenu", (id?: string) => this.menu("accept", id)),
|
||||
vscode.commands.registerCommand("cowriting.proposalRejectMenu", (id?: string) => this.menu("reject", id)),
|
||||
@@ -68,6 +86,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. */
|
||||
private async onProposalsChanged(uri: string): Promise<void> {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
@@ -116,12 +148,53 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
||||
}
|
||||
}
|
||||
|
||||
/** Overridden in Task 7 to add committed (non-proposal) author-colored changes. */
|
||||
/**
|
||||
* 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".
|
||||
*/
|
||||
private decorateCommitted(
|
||||
_editor: vscode.TextEditor,
|
||||
_ins: Record<"human" | "claude", vscode.Range[]>,
|
||||
_del: Record<"human" | "claude", vscode.DecorationOptions[]>,
|
||||
): void {}
|
||||
editor: vscode.TextEditor,
|
||||
ins: Record<"human" | "claude", vscode.Range[]>,
|
||||
del: Record<"human" | "claude", 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.
|
||||
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)));
|
||||
}
|
||||
for (const d of plan.deletions) {
|
||||
del[author].push({
|
||||
range: new vscode.Range(doc.positionAt(d.at), doc.positionAt(d.at)),
|
||||
renderOptions: { after: { contentText: ` ${d.text} `, textDecoration: "line-through" } },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** CodeLensProvider: a `Accept ▾` / `Reject ▾` pair above each applied block. */
|
||||
provideCodeLenses(document: vscode.TextDocument): vscode.CodeLens[] {
|
||||
@@ -164,6 +237,8 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL
|
||||
dispose(): void {
|
||||
for (const t of this.pendingApply.values()) clearTimeout(t);
|
||||
this.pendingApply.clear();
|
||||
for (const t of this.pendingRender.values()) clearTimeout(t);
|
||||
this.pendingRender.clear();
|
||||
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,
|
||||
// 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);
|
||||
|
||||
// #46 (INV-42): accept every pending proposal on the active doc in one gesture
|
||||
|
||||
@@ -250,6 +250,29 @@ describe("wordDiffByAuthor", () => {
|
||||
|
||||
import { renderPlain } from "../src/trackChangesModel";
|
||||
|
||||
import { 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
|
||||
});
|
||||
|
||||
describe("renderPlain", () => {
|
||||
test("renderPlain renders current buffer as plain markdown (no marks)", () => {
|
||||
const html = renderPlain("# Title\n\nhello");
|
||||
|
||||
Reference in New Issue
Block a user