F10: interactive track-changes review in the markdown preview (#29) #30
+73
-38
@@ -12,7 +12,8 @@ import { randomBytes } from "node:crypto";
|
||||
import * as vscode from "vscode";
|
||||
import type { DiffViewController } from "./diffViewController";
|
||||
import type { AttributionController } from "./attributionController";
|
||||
import { renderTrackChanges, renderAuthorship, diffBlocks, type BlockOp } from "./trackChangesModel";
|
||||
import type { ProposalController } from "./proposalController";
|
||||
import { renderReview, renderPlain, diffBlocks, type BlockOp } from "./trackChangesModel";
|
||||
|
||||
const VIEW_TYPE = "cowriting.trackChangesPreview";
|
||||
const DEBOUNCE_MS = 150;
|
||||
@@ -22,13 +23,16 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
private readonly panels = new Map<string, vscode.WebviewPanel>();
|
||||
private readonly lastModel = new Map<string, BlockOp[]>();
|
||||
private readonly debounces = new Map<string, NodeJS.Timeout>();
|
||||
/** F9: per-panel view mode — track-changes (default) or authorship. */
|
||||
private readonly mode = new Map<string, "changes" | "authorship">();
|
||||
/** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */
|
||||
private readonly mode = new Map<string, "on" | "off">();
|
||||
/** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */
|
||||
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88);
|
||||
|
||||
constructor(
|
||||
private readonly diffView: DiffViewController,
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
private readonly attribution: AttributionController,
|
||||
private readonly proposals: ProposalController,
|
||||
) {
|
||||
this.disposables.push(
|
||||
vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () =>
|
||||
@@ -36,7 +40,13 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
),
|
||||
vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)),
|
||||
this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)),
|
||||
this.proposals.onDidChangeProposals(({ uri }) => {
|
||||
this.refreshByUri(uri);
|
||||
this.updateStatus(uri);
|
||||
}),
|
||||
this.statusItem,
|
||||
);
|
||||
this.statusItem.command = "cowriting.showTrackChangesPreview";
|
||||
}
|
||||
|
||||
private isMarkdown(document: vscode.TextDocument): boolean {
|
||||
@@ -75,22 +85,33 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
this.panels.delete(key);
|
||||
this.lastModel.delete(key);
|
||||
this.mode.delete(key);
|
||||
// A panel is gone: re-show the off-panel indicator if proposals remain.
|
||||
this.updateStatus(key);
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
// F9: the webview's header toggle posts the chosen mode back.
|
||||
// F10: the webview posts the annotations toggle + ✓/✗ proposal decisions back.
|
||||
panel.webview.onDidReceiveMessage(
|
||||
(m: { type?: string; mode?: "changes" | "authorship" }) => {
|
||||
if (m?.type === "setMode" && (m.mode === "changes" || m.mode === "authorship")) {
|
||||
(m: { type?: string; mode?: "on" | "off"; proposalId?: string }) => {
|
||||
if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) {
|
||||
this.mode.set(key, m.mode);
|
||||
this.refresh(document);
|
||||
} else if (m?.type === "accept" && m.proposalId) {
|
||||
void this.proposals
|
||||
.acceptById(this.proposals.keyFor(document), m.proposalId)
|
||||
.then(() => this.refresh(document));
|
||||
} else if (m?.type === "reject" && m.proposalId) {
|
||||
this.proposals.rejectById(this.proposals.keyFor(document), m.proposalId);
|
||||
this.refresh(document);
|
||||
}
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
);
|
||||
this.panels.set(key, panel);
|
||||
// A panel is now open for this doc — the off-panel indicator is redundant.
|
||||
this.statusItem.hide();
|
||||
this.refresh(document);
|
||||
}
|
||||
|
||||
@@ -118,41 +139,49 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
const key = document.uri.toString();
|
||||
const panel = this.panels.get(key);
|
||||
if (!panel) return;
|
||||
const mode = this.mode.get(key) ?? "changes";
|
||||
const mode = this.mode.get(key) ?? "on";
|
||||
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 baselineText = baseline?.text ?? current; // no baseline → no change-marks
|
||||
const ops = diffBlocks(baselineText, current);
|
||||
this.lastModel.set(key, ops);
|
||||
if (mode === "off") {
|
||||
void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current) });
|
||||
return;
|
||||
}
|
||||
const spans = this.attribution.spansFor(document);
|
||||
const proposals = this.proposals.listProposals(document);
|
||||
const summary = {
|
||||
added: ops.filter((o) => o.kind === "added").length,
|
||||
removed: ops.filter((o) => o.kind === "removed").length,
|
||||
changed: ops.filter((o) => o.kind === "changed").length,
|
||||
proposals: proposals.length,
|
||||
};
|
||||
void panel.webview.postMessage({
|
||||
type: "render",
|
||||
mode,
|
||||
html: renderTrackChanges(baselineText, current),
|
||||
html: renderReview(baselineText, current, spans, proposals),
|
||||
epoch: this.epochLabel(baseline),
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
/** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */
|
||||
private updateStatus(uri: string): void {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri);
|
||||
if (!doc) {
|
||||
this.statusItem.hide();
|
||||
return;
|
||||
}
|
||||
const n = this.proposals.listProposals(doc).length;
|
||||
if (n === 0 || this.panels.has(uri)) {
|
||||
this.statusItem.hide();
|
||||
return;
|
||||
}
|
||||
this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`;
|
||||
this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals";
|
||||
this.statusItem.show();
|
||||
}
|
||||
|
||||
private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string {
|
||||
if (!baseline) return "opened (no baseline yet)";
|
||||
const time = new Date(baseline.capturedAt).toLocaleTimeString();
|
||||
@@ -193,13 +222,10 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
</head>
|
||||
<body>
|
||||
<div id="cw-header">
|
||||
<div id="cw-mode" role="group">
|
||||
<button id="cw-mode-changes" class="cw-seg cw-seg-on" data-mode="changes">Track changes</button>
|
||||
<button id="cw-mode-authorship" class="cw-seg" data-mode="authorship">Authorship</button>
|
||||
</div>
|
||||
<span id="cw-epoch">Track changes</span>
|
||||
<label id="cw-toggle"><input type="checkbox" id="cw-annotations" checked /> Annotations</label>
|
||||
<span id="cw-epoch">Review</span>
|
||||
<span id="cw-summary"></span>
|
||||
<span id="cw-legend" hidden></span>
|
||||
<span id="cw-legend"></span>
|
||||
</div>
|
||||
<div id="cw-body"></div>
|
||||
<script nonce="${nonce}" src="${scriptUri}"></script>
|
||||
@@ -214,24 +240,33 @@ export class TrackChangesPreviewController implements vscode.Disposable {
|
||||
getLastModel(uriString: string): BlockOp[] | undefined {
|
||||
return this.lastModel.get(uriString);
|
||||
}
|
||||
/** F7.1 (#22) test seam: the track-changes HTML the panel would post for a doc. */
|
||||
/** F10 test seam: the review HTML the panel would post for a doc (on-state). */
|
||||
renderHtmlFor(uriString: string): string {
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (!doc) return "";
|
||||
const current = doc.getText();
|
||||
const baseline = this.diffView.getBaseline(uriString);
|
||||
return renderTrackChanges(baseline?.text ?? current, current);
|
||||
return renderReview(
|
||||
baseline?.text ?? current,
|
||||
current,
|
||||
this.attribution.spansFor(doc),
|
||||
this.proposals.listProposals(doc),
|
||||
);
|
||||
}
|
||||
/** F9: current view mode for a panel (default track-changes). */
|
||||
getMode(uriString: string): "changes" | "authorship" {
|
||||
return this.mode.get(uriString) ?? "changes";
|
||||
/** F10: current annotations mode for a panel (default on). */
|
||||
getMode(uriString: string): "on" | "off" {
|
||||
return this.mode.get(uriString) ?? "on";
|
||||
}
|
||||
/** F9: set the view mode and re-render (the programmatic twin of the header toggle). */
|
||||
setMode(uriString: string, mode: "changes" | "authorship"): void {
|
||||
/** F10: set the annotations mode and re-render (the programmatic twin of the header toggle). */
|
||||
setMode(uriString: string, mode: "on" | "off"): void {
|
||||
this.mode.set(uriString, mode);
|
||||
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
|
||||
if (doc) this.refresh(doc);
|
||||
}
|
||||
/** F10 test seam (SLICE-4 E2E): the off-panel status-bar indicator text, if shown. */
|
||||
statusText(): string | undefined {
|
||||
return this.statusItem.text || undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const t of this.debounces.values()) clearTimeout(t);
|
||||
|
||||
Reference in New Issue
Block a user