From 399e3c5f70ef4d74fffd5cb819f9898f7ca9bdab Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 14:38:05 -0700 Subject: [PATCH] feat(f9): preview gains authorship mode + attribution dep + setMode wiring Per-panel mode (default changes); refresh branches to renderAuthorship reading AttributionController.spansFor; webview setMode message; extension.ts reorders the F7 controller after attribution. getMode/setMode test seams. Co-Authored-By: Claude Opus 4.8 --- src/extension.ts | 21 ++++++++------- src/trackChangesPreview.ts | 54 +++++++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 2a2e23e..f4a388e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -70,16 +70,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // still works — PUC-5. const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? ""); - // --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT --- - // Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it - // is constructed regardless of an open folder and its command is always live. - // It reuses the F6 baseline (INV-20) and adds no persistence. - const trackChangesPreviewController = new TrackChangesPreviewController( - diffViewController, - context.extensionUri, - ); - context.subscriptions.push(trackChangesPreviewController); - // --- F8: authoring on ANY document (in-folder, out-of-folder, untitled) --- // The router routes per-document: in-workspace file: → the committable repo // `.threads/` sidecar (CoauthorStore, INV-2); out-of-folder file: + untitled: @@ -99,6 +89,17 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const attributionController = new AttributionController(sidecarRouter, root, versionGuard); context.subscriptions.push(attributionController); + // --- F7: rendered track-changes preview (Feature #21) + F9 authorship mode --- + // Workspace-INDEPENDENT (works on any markdown doc, reuses the F6 baseline, + // INV-20). Constructed AFTER attribution so F9's authorship view can read F3 + // spans (AttributionController.spansFor). + const trackChangesPreviewController = new TrackChangesPreviewController( + diffViewController, + context.extensionUri, + attributionController, + ); + context.subscriptions.push(trackChangesPreviewController); + // --- F4: propose/accept (Feature #12) --- const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); context.subscriptions.push(proposalController); diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index 1320432..be3defe 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -11,7 +11,8 @@ import * as path from "node:path"; import { randomBytes } from "node:crypto"; import * as vscode from "vscode"; import type { DiffViewController } from "./diffViewController"; -import { renderTrackChanges, diffBlocks, type BlockOp } from "./trackChangesModel"; +import type { AttributionController } from "./attributionController"; +import { renderTrackChanges, renderAuthorship, diffBlocks, type BlockOp } from "./trackChangesModel"; const VIEW_TYPE = "cowriting.trackChangesPreview"; const DEBOUNCE_MS = 150; @@ -21,10 +22,13 @@ export class TrackChangesPreviewController implements vscode.Disposable { private readonly panels = new Map(); private readonly lastModel = new Map(); private readonly debounces = new Map(); + /** F9: per-panel view mode — track-changes (default) or authorship. */ + private readonly mode = new Map(); constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, + private readonly attribution: AttributionController, ) { this.disposables.push( vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () => @@ -70,6 +74,18 @@ export class TrackChangesPreviewController implements vscode.Disposable { () => { this.panels.delete(key); this.lastModel.delete(key); + this.mode.delete(key); + }, + null, + this.disposables, + ); + // F9: the webview's header toggle posts the chosen mode back. + panel.webview.onDidReceiveMessage( + (m: { type?: string; mode?: "changes" | "authorship" }) => { + if (m?.type === "setMode" && (m.mode === "changes" || m.mode === "authorship")) { + this.mode.set(key, m.mode); + this.refresh(document); + } }, null, this.disposables, @@ -97,14 +113,30 @@ export class TrackChangesPreviewController implements vscode.Disposable { if (doc) this.refresh(doc); } - /** Recompute the model + post HTML to the panel (no-op if no panel). */ + /** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */ refresh(document: vscode.TextDocument): void { const key = document.uri.toString(); const panel = this.panels.get(key); if (!panel) return; - const baseline = this.diffView.getBaseline(key); - const baselineText = baseline?.text ?? document.getText(); // no baseline → no marks + const mode = this.mode.get(key) ?? "changes"; const current = document.getText(); + if (mode === "authorship") { + // F9: render the current buffer colored by F3 author, baseline-independent. + const spans = this.attribution.spansFor(document); + this.lastModel.set(key, diffBlocks(this.diffView.getBaseline(key)?.text ?? current, current)); + void panel.webview.postMessage({ + type: "render", + mode, + html: renderAuthorship(current, spans), + legend: { + claude: spans.some((s) => s.author === "claude"), + human: spans.some((s) => s.author === "human"), + }, + }); + return; + } + const baseline = this.diffView.getBaseline(key); + const baselineText = baseline?.text ?? current; // no baseline → no marks const ops = diffBlocks(baselineText, current); this.lastModel.set(key, ops); const summary = { @@ -112,11 +144,11 @@ export class TrackChangesPreviewController implements vscode.Disposable { removed: ops.filter((o) => o.kind === "removed").length, changed: ops.filter((o) => o.kind === "changed").length, }; - const epoch = this.epochLabel(baseline); void panel.webview.postMessage({ type: "render", + mode, html: renderTrackChanges(baselineText, current), - epoch, + epoch: this.epochLabel(baseline), summary, }); } @@ -177,6 +209,16 @@ export class TrackChangesPreviewController implements vscode.Disposable { getLastModel(uriString: string): BlockOp[] | undefined { return this.lastModel.get(uriString); } + /** F9: current view mode for a panel (default track-changes). */ + getMode(uriString: string): "changes" | "authorship" { + return this.mode.get(uriString) ?? "changes"; + } + /** F9: set the view mode and re-render (the programmatic twin of the header toggle). */ + setMode(uriString: string, mode: "changes" | "authorship"): void { + this.mode.set(uriString, mode); + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString); + if (doc) this.refresh(doc); + } dispose(): void { for (const t of this.debounces.values()) clearTimeout(t);