Merge pull request 'F6: diff-view toggle on any file (global storage, untitled in-memory)' (#20) from f6-any-file into main
This commit was merged in pull request #20.
This commit is contained in:
@@ -132,26 +132,32 @@ record per the contract; git push/pull is the transport, no re-homing ever.
|
|||||||
|
|
||||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`.
|
Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`.
|
||||||
|
|
||||||
## F6 — Diff-view toggle (Feature #17)
|
## F6 — Diff-view toggle (Feature #17, #19)
|
||||||
|
|
||||||
**Cmd/Ctrl-Alt-D** (or **Cowriting: Toggle Diff View**) flips a tracked
|
**`Ctrl+Alt+D`** (the same chord on macOS — not `Cmd`; or **Cowriting: Toggle
|
||||||
document into a native `vscode.diff` against a **coauthoring baseline** — the
|
Diff View**) flips the focused document into a native `vscode.diff` against a
|
||||||
readonly baseline on the left, your **live, editable** document on the right
|
**coauthoring baseline** — the readonly baseline on the left, your **live,
|
||||||
(so you keep writing inside the diff; toggling again closes it). The diff
|
editable** document on the right (so you keep writing inside the diff; toggling
|
||||||
answers "what did *I* change?" in one keystroke instead of git archaeology.
|
again closes it). The diff answers "what did *I* change?" in one keystroke
|
||||||
|
instead of git archaeology.
|
||||||
|
|
||||||
|
- **Works on any file (#19):** any document you can edit — a file inside or
|
||||||
|
outside the workspace folder, or an **untitled** scratch buffer. Only a
|
||||||
|
non-text-editor focus warns. (Untitled buffers diff in-memory; saving makes
|
||||||
|
the baseline persist.)
|
||||||
- **Machine-factored baseline (INV-18):** the baseline initializes when a doc
|
- **Machine-factored baseline (INV-18):** the baseline initializes when a doc
|
||||||
is first tracked and **advances automatically at every machine landing**
|
is first seen and **advances automatically at every machine landing** (every
|
||||||
(every successful `applyAgentEdit` seam apply — INV-9). So text Claude landed
|
successful `applyAgentEdit` seam apply — INV-9). So text Claude landed never
|
||||||
never shows as a change; everything the diff shows is operator-authored by
|
shows as a change; everything the diff shows is operator-authored by
|
||||||
construction — no attribution filtering.
|
construction — no attribution filtering.
|
||||||
- **Pin on demand:** **Cowriting: Pin Diff Baseline to Now** resets the
|
- **Pin on demand:** **Cowriting: Pin Diff Baseline to Now** resets the
|
||||||
baseline to the current buffer for a deliberate "review my next pass" epoch;
|
baseline to the current buffer for a deliberate "review my next pass" epoch;
|
||||||
the diff tab title names the epoch (`opened` / `Claude landed` / `pinned`).
|
the diff tab title names the epoch (`opened` / `Claude landed` / `pinned`).
|
||||||
- **Pure view, repo-free (INV-19):** the baseline snapshot lives in VS Code
|
- **Pure view, repo-free (INV-19):** the baseline snapshot lives in VS Code
|
||||||
workspace storage, **never** the repo — `.threads/`, the cross-rung contract
|
**global** extension storage, keyed by a hash of the document URI, **never**
|
||||||
(INV-14..17), and `SCHEMA_VERSION` are untouched. Storage-unavailable
|
the repo — `.threads/`, the cross-rung contract (INV-14..17), and
|
||||||
degrades to in-memory baselines + one warning.
|
`SCHEMA_VERSION` are untouched. Storage-unavailable degrades to in-memory
|
||||||
|
baselines + one warning.
|
||||||
- **No LLM in CI:** host E2E (`test/e2e/suite/diffView.test.ts`) drives the same
|
- **No LLM in CI:** host E2E (`test/e2e/suite/diffView.test.ts`) drives the same
|
||||||
programmatic seam ingress (propose + accept) the F4 suite uses.
|
programmatic seam ingress (propose + accept) the F4 suite uses.
|
||||||
|
|
||||||
|
|||||||
+11
-7
@@ -6,10 +6,11 @@ rest of F6 needs no credentials and no network.
|
|||||||
|
|
||||||
1. `npm run build`, launch the extension (F5 in VS Code opens the committed
|
1. `npm run build`, launch the extension (F5 in VS Code opens the committed
|
||||||
`sandbox/` playground), open `playground.md`.
|
`sandbox/` playground), open `playground.md`.
|
||||||
2. Edit a sentence by hand, then **Cmd/Ctrl-Alt-D** (or run **Cowriting: Toggle
|
2. Edit a sentence by hand, then **`Ctrl+Alt+D`** (same chord on macOS — not
|
||||||
Diff View**). ✅ A diff opens: the readonly baseline on the left, your live
|
`Cmd`; or run **Cowriting: Toggle Diff View**). ✅ A diff opens: the readonly
|
||||||
document on the right; the tab title reads `playground.md — my changes since
|
baseline on the left, your live document on the right; the tab title reads
|
||||||
opened <time>`. Your hand edit shows as a change.
|
`playground.md — my changes since opened <time>`. Your hand edit shows as a
|
||||||
|
change.
|
||||||
3. Keep typing **in the diff's right pane**. ✅ Editing continues in place;
|
3. Keep typing **in the diff's right pane**. ✅ Editing continues in place;
|
||||||
decorations/threads still work. Toggle again. ✅ The diff tab closes and the
|
decorations/threads still work. Toggle again. ✅ The diff tab closes and the
|
||||||
normal editor is back.
|
normal editor is back.
|
||||||
@@ -21,6 +22,9 @@ rest of F6 needs no credentials and no network.
|
|||||||
now); the title reads `pinned <time>`. Type → your changes show against the
|
now); the title reads `pinned <time>`. Type → your changes show against the
|
||||||
pin.
|
pin.
|
||||||
6. Reload the window (Developer: Reload Window). ✅ Toggle: the baseline is the
|
6. Reload the window (Developer: Reload Window). ✅ Toggle: the baseline is the
|
||||||
same as before the reload (persisted in workspace storage, not the repo —
|
same as before the reload (persisted in **global** extension storage, not the
|
||||||
INV-19; `git status` shows nothing new).
|
repo — INV-19; `git status` shows nothing new).
|
||||||
7. Toggle on a file outside the workspace folder. ✅ A warning, no diff.
|
7. **Any file (#19):** open a file from *outside* the `sandbox/` folder, or a
|
||||||
|
new untitled buffer (`Cmd/Ctrl+N`), edit it, and `Ctrl+Alt+D`. ✅ The diff
|
||||||
|
opens there too — untitled buffers show `(unsaved)` in the title and diff
|
||||||
|
in-memory. Toggle with focus on a non-editor (e.g. the terminal) → warning.
|
||||||
|
|||||||
+16
-13
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* BaselineStore — load/save one diff-view baseline JSON per document (F6 §6.3).
|
* BaselineStore — load/save one diff-view baseline JSON per document (F6 §6.3).
|
||||||
* The CoauthorStore shape (src/store.ts): vscode-free (Node fs only, unit-
|
* The CoauthorStore shape (src/store.ts): vscode-free (Node fs only, unit-
|
||||||
* testable), one file per docPath. INV-19: the storage dir is VS Code's
|
* testable), one file per storage key. INV-19: the storage dir is VS Code's
|
||||||
* per-workspace extension storage, NEVER the repo — so the baseline can never
|
* per-extension GLOBAL storage, NEVER the repo — so the baseline can never be
|
||||||
* be committed, merged, or read by another rung, and the sidecar / cross-rung
|
* committed, merged, or read by another rung. F6 works on *any* file, so the
|
||||||
* contract (INV-14..17) are untouched by construction.
|
* key is a hash of the document URI (the controller derives it), not a
|
||||||
|
* workspace-relative path; an untitled buffer has no durable identity and is
|
||||||
|
* never persisted here (in-memory only — see DiffViewController).
|
||||||
*/
|
*/
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
@@ -12,7 +14,8 @@ import * as path from "node:path";
|
|||||||
export type BaselineReason = "opened" | "machine-landing" | "pinned";
|
export type BaselineReason = "opened" | "machine-landing" | "pinned";
|
||||||
|
|
||||||
export interface Baseline {
|
export interface Baseline {
|
||||||
docPath: string;
|
/** the document URI (the identity the key is derived from) — for round-trip/debug. */
|
||||||
|
uri: string;
|
||||||
/** full document text captured at the epoch (from the buffer, not disk — §6.3). */
|
/** full document text captured at the epoch (from the buffer, not disk — §6.3). */
|
||||||
text: string;
|
text: string;
|
||||||
capturedAt: string;
|
capturedAt: string;
|
||||||
@@ -20,23 +23,23 @@ export interface Baseline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BaselineStore {
|
export class BaselineStore {
|
||||||
/** @param storageDir absolute VS Code workspace-storage dir (context.storageUri.fsPath). */
|
/** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */
|
||||||
constructor(private readonly storageDir: string) {}
|
constructor(private readonly storageDir: string) {}
|
||||||
|
|
||||||
/** `<storageDir>/baselines/<repo-relative-docPath>.json` (§6.3). */
|
/** `<storageDir>/baselines/<key>.json` — `key` is a filesystem-safe hash (§6.3). */
|
||||||
baselinePath(docPath: string): string {
|
baselinePath(key: string): string {
|
||||||
return path.join(this.storageDir, "baselines", `${docPath}.json`);
|
return path.join(this.storageDir, "baselines", `${key}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
load(docPath: string): Baseline | null {
|
load(key: string): Baseline | null {
|
||||||
const p = this.baselinePath(docPath);
|
const p = this.baselinePath(key);
|
||||||
if (!fs.existsSync(p)) return null;
|
if (!fs.existsSync(p)) return null;
|
||||||
return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
|
return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */
|
/** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */
|
||||||
save(docPath: string, baseline: Baseline): void {
|
save(key: string, baseline: Baseline): void {
|
||||||
const p = this.baselinePath(docPath);
|
const p = this.baselinePath(key);
|
||||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
fs.writeFileSync(p, JSON.stringify(baseline, null, 2) + "\n", "utf8");
|
fs.writeFileSync(p, JSON.stringify(baseline, null, 2) + "\n", "utf8");
|
||||||
}
|
}
|
||||||
|
|||||||
+85
-79
@@ -1,14 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* DiffViewController — F6 diff-view toggle (spec §6.2/§6.4). Owns the baseline
|
* DiffViewController — F6 diff-view toggle (spec §6.2/§6.4). Owns the baseline
|
||||||
* lifecycle (initialize at first track / advance at every machine landing / pin
|
* lifecycle (initialize at first sight / advance at every machine landing / pin
|
||||||
* on demand), serves the baseline as a readonly `cowriting-baseline:` virtual
|
* on demand), serves the baseline as a readonly `cowriting-baseline:` virtual
|
||||||
* document, and toggles a native vscode.diff (baseline left, the LIVE document
|
* document, and toggles a native vscode.diff (baseline left, the LIVE document
|
||||||
* right). A pure view: never mutates the document, sidecar, or attribution
|
* right). A pure view: never mutates the document, sidecar, or attribution
|
||||||
* state (INV-19). Baselines persist via the vscode-free BaselineStore; if
|
* state (INV-19).
|
||||||
* storage is unavailable the controller degrades to in-memory baselines + one
|
*
|
||||||
* warning (reload survival is lost; the toggle still works) — §6.5 PUC-5.
|
* F6 works on ANY text document, not just workspace files — it needs no
|
||||||
|
* `.threads/` sidecar, only a stable doc identity + a storage home. So it has
|
||||||
|
* its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from
|
||||||
|
* F2's workspace `isTracked`. Persistable docs (`file:`) keep their baseline in
|
||||||
|
* VS Code's per-extension GLOBAL storage keyed by a hash of the document URI;
|
||||||
|
* untitled buffers have no durable identity, so their baseline is in-memory
|
||||||
|
* only (lost on reload) — the same degrade the storage-unavailable path uses.
|
||||||
*/
|
*/
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore";
|
import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore";
|
||||||
|
|
||||||
@@ -16,20 +23,17 @@ export const BASELINE_SCHEME = "cowriting-baseline";
|
|||||||
|
|
||||||
export class DiffViewController implements vscode.Disposable {
|
export class DiffViewController implements vscode.Disposable {
|
||||||
private readonly disposables: vscode.Disposable[] = [];
|
private readonly disposables: vscode.Disposable[] = [];
|
||||||
/** Source of truth for the content provider; mirrors what the store persists. */
|
/** Source of truth for the content provider, keyed by `document.uri.toString()`. */
|
||||||
private readonly baselines = new Map<string, Baseline>();
|
private readonly baselines = new Map<string, Baseline>();
|
||||||
private readonly onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
|
private readonly onDidChangeEmitter = new vscode.EventEmitter<vscode.Uri>();
|
||||||
private storageWarned = false;
|
private storageWarned = false;
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly store: BaselineStore | null) {
|
||||||
private readonly store: BaselineStore | null,
|
|
||||||
private readonly rootDir: string,
|
|
||||||
) {
|
|
||||||
const provider: vscode.TextDocumentContentProvider = {
|
const provider: vscode.TextDocumentContentProvider = {
|
||||||
onDidChange: this.onDidChangeEmitter.event,
|
onDidChange: this.onDidChangeEmitter.event,
|
||||||
provideTextDocumentContent: (uri) => {
|
provideTextDocumentContent: (uri) => {
|
||||||
const docPath = this.docPathFromBaselineUri(uri);
|
// The baseline URI carries the real document URI in its query (see baselineUri).
|
||||||
return this.baselines.get(docPath)?.text ?? "";
|
return this.baselines.get(uri.query)?.text ?? "";
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
@@ -41,37 +45,49 @@ export class DiffViewController implements vscode.Disposable {
|
|||||||
vscode.commands.registerCommand("cowriting.pinDiffBaseline", () =>
|
vscode.commands.registerCommand("cowriting.pinDiffBaseline", () =>
|
||||||
this.pinCommand(vscode.window.activeTextEditor),
|
this.pinCommand(vscode.window.activeTextEditor),
|
||||||
),
|
),
|
||||||
|
// F6 captures a baseline for any diffable doc the moment it is first seen,
|
||||||
|
// independent of the workspace gate (so "opened" is the open-time text).
|
||||||
|
vscode.workspace.onDidOpenTextDocument((d) => this.ensureBaseline(d)),
|
||||||
);
|
);
|
||||||
|
for (const d of vscode.workspace.textDocuments) this.ensureBaseline(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- tracking / uri helpers --------------------------------------------------------
|
// ---- diffability / identity --------------------------------------------------------
|
||||||
|
|
||||||
private isTracked(document: vscode.TextDocument): boolean {
|
/** Any text document F6 can diff: a saved file OR an unsaved buffer. */
|
||||||
return document.uri.scheme === "file" && document.uri.fsPath.startsWith(this.rootDir);
|
private isDiffable(document: vscode.TextDocument): boolean {
|
||||||
|
return document.uri.scheme === "file" || document.uri.scheme === "untitled";
|
||||||
}
|
}
|
||||||
private docPathOf(uri: vscode.Uri): string {
|
/** Only `file:` docs have a durable identity to persist a baseline against. */
|
||||||
return vscode.workspace.asRelativePath(uri, false);
|
private isPersistable(document: vscode.TextDocument): boolean {
|
||||||
|
return document.uri.scheme === "file";
|
||||||
}
|
}
|
||||||
/** The readonly virtual-doc URI whose content the provider serves for this doc. */
|
/** In-memory map key — the full document URI. */
|
||||||
private baselineUri(docPath: string): vscode.Uri {
|
private uriKey(document: vscode.TextDocument): string {
|
||||||
return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: "/" + docPath });
|
return document.uri.toString();
|
||||||
}
|
}
|
||||||
private docPathFromBaselineUri(uri: vscode.Uri): string {
|
/** Filesystem-safe storage key for a persistable doc: sha256 of its URI. */
|
||||||
return uri.path.replace(/^\//, "");
|
private storageKey(uriKey: string): string {
|
||||||
|
return createHash("sha256").update(uriKey).digest("hex");
|
||||||
|
}
|
||||||
|
/** The readonly virtual-doc URI; its query carries the real document URI. */
|
||||||
|
private baselineUri(document: vscode.TextDocument): vscode.Uri {
|
||||||
|
const name = path.basename(document.uri.path) || "untitled";
|
||||||
|
return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: "/" + name, query: this.uriKey(document) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- baseline lifecycle (§6.4) -----------------------------------------------------
|
// ---- baseline lifecycle (§6.4) -----------------------------------------------------
|
||||||
|
|
||||||
/** First sight of a tracked doc: load the stored baseline, else capture `opened`. */
|
/** First sight of a diffable doc: load the stored baseline, else capture `opened`. */
|
||||||
ensureBaseline(document: vscode.TextDocument): void {
|
ensureBaseline(document: vscode.TextDocument): void {
|
||||||
if (!this.isTracked(document)) return;
|
if (!this.isDiffable(document)) return;
|
||||||
const docPath = this.docPathOf(document.uri);
|
const key = this.uriKey(document);
|
||||||
if (this.baselines.has(docPath)) return;
|
if (this.baselines.has(key)) return;
|
||||||
if (this.store) {
|
if (this.store && this.isPersistable(document)) {
|
||||||
try {
|
try {
|
||||||
const stored = this.store.load(docPath);
|
const stored = this.store.load(this.storageKey(key));
|
||||||
if (stored) {
|
if (stored) {
|
||||||
this.baselines.set(docPath, stored);
|
this.baselines.set(key, stored);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -83,35 +99,30 @@ export class DiffViewController implements vscode.Disposable {
|
|||||||
|
|
||||||
/** Machine landing (INV-18): re-capture so landed text never shows as a change. */
|
/** Machine landing (INV-18): re-capture so landed text never shows as a change. */
|
||||||
advance(document: vscode.TextDocument): void {
|
advance(document: vscode.TextDocument): void {
|
||||||
if (!this.isTracked(document)) return;
|
if (!this.isDiffable(document)) return;
|
||||||
this.capture(document, "machine-landing");
|
this.capture(document, "machine-landing");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human pin: baseline := now; the open diff visibly empties (left = right). */
|
/** Human pin: baseline := now; the open diff visibly empties (left = right). */
|
||||||
pin(document: vscode.TextDocument): void {
|
pin(document: vscode.TextDocument): void {
|
||||||
if (!this.isTracked(document)) return;
|
if (!this.isDiffable(document)) return;
|
||||||
this.capture(document, "pinned");
|
this.capture(document, "pinned");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Capture buffer text at this epoch, persist, and refresh any open diff's left side. */
|
/** Capture buffer text at this epoch, persist (if persistable), refresh any open diff. */
|
||||||
private capture(document: vscode.TextDocument, reason: BaselineReason): void {
|
private capture(document: vscode.TextDocument, reason: BaselineReason): void {
|
||||||
const docPath = this.docPathOf(document.uri);
|
const key = this.uriKey(document);
|
||||||
const baseline: Baseline = {
|
const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason };
|
||||||
docPath,
|
this.baselines.set(key, baseline);
|
||||||
text: document.getText(),
|
if (this.store && this.isPersistable(document)) {
|
||||||
capturedAt: new Date().toISOString(),
|
|
||||||
reason,
|
|
||||||
};
|
|
||||||
this.baselines.set(docPath, baseline);
|
|
||||||
if (this.store) {
|
|
||||||
try {
|
try {
|
||||||
this.store.save(docPath, baseline);
|
this.store.save(this.storageKey(key), baseline);
|
||||||
} catch {
|
} catch {
|
||||||
this.warnStorageOnce();
|
this.warnStorageOnce();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// An open diff re-requests the left side when its baseline URI changes.
|
// An open diff re-requests the left side when its baseline URI changes.
|
||||||
this.onDidChangeEmitter.fire(this.baselineUri(docPath));
|
this.onDidChangeEmitter.fire(this.baselineUri(document));
|
||||||
}
|
}
|
||||||
|
|
||||||
private warnStorageOnce(): void {
|
private warnStorageOnce(): void {
|
||||||
@@ -122,44 +133,21 @@ export class DiffViewController implements vscode.Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- commands (toggle implemented in SLICE-3, Task 4) ------------------------------
|
|
||||||
|
|
||||||
private pinCommand(editor: vscode.TextEditor | undefined): void {
|
|
||||||
if (!editor || !this.isTracked(editor.document)) {
|
|
||||||
void vscode.window.showWarningMessage("Cowriting: open a tracked workspace document to pin its diff baseline.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.pin(editor.document);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- test-facing surface (§6.4) ----------------------------------------------------
|
|
||||||
|
|
||||||
getBaseline(docPath: string): { text: string; reason: BaselineReason; capturedAt: string } | undefined {
|
|
||||||
const b = this.baselines.get(docPath);
|
|
||||||
return b ? { text: b.text, reason: b.reason, capturedAt: b.capturedAt } : undefined;
|
|
||||||
}
|
|
||||||
/** Absolute on-disk path of this doc's persisted baseline, or undefined if in-memory. */
|
|
||||||
baselineFilePath(docPath: string): string | undefined {
|
|
||||||
return this.store?.baselinePath(docPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- toggle UX (§6.5 PUC-1) --------------------------------------------------------
|
// ---- toggle UX (§6.5 PUC-1) --------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If this doc's baseline diff is the active/open tab → close it and reveal the
|
* If this doc's baseline diff is open → close it and reveal the normal editor;
|
||||||
* normal editor; if the active editor is a tracked doc with no diff open →
|
* else open vscode.diff (baseline left, the live document right). Warns only
|
||||||
* open vscode.diff (baseline left, the live document right). Untracked → warn,
|
* when there is no active text editor (or a non-diffable scheme).
|
||||||
* no diff.
|
|
||||||
*/
|
*/
|
||||||
private async toggle(editor: vscode.TextEditor | undefined): Promise<void> {
|
private async toggle(editor: vscode.TextEditor | undefined): Promise<void> {
|
||||||
if (!editor || !this.isTracked(editor.document)) {
|
if (!editor || !this.isDiffable(editor.document)) {
|
||||||
void vscode.window.showWarningMessage(
|
void vscode.window.showWarningMessage(
|
||||||
"Cowriting: open a tracked workspace document to toggle its diff view.",
|
"Cowriting: focus a text editor to toggle its diff view.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const document = editor.document;
|
const document = editor.document;
|
||||||
const docPath = this.docPathOf(document.uri);
|
|
||||||
const openTab = this.findDiffTab(document.uri);
|
const openTab = this.findDiffTab(document.uri);
|
||||||
if (openTab) {
|
if (openTab) {
|
||||||
await vscode.window.tabGroups.close(openTab);
|
await vscode.window.tabGroups.close(openTab);
|
||||||
@@ -167,11 +155,13 @@ export class DiffViewController implements vscode.Disposable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.ensureBaseline(document);
|
this.ensureBaseline(document);
|
||||||
const baseline = this.baselines.get(docPath)!;
|
const baseline = this.baselines.get(this.uriKey(document))!;
|
||||||
const title = `${path.basename(docPath)} — my changes since ${this.epochLabel(baseline)}`;
|
const name = path.basename(document.uri.path) || "untitled";
|
||||||
|
const unsaved = this.isPersistable(document) ? "" : " (unsaved)";
|
||||||
|
const title = `${name} — my changes since ${this.epochLabel(baseline)}${unsaved}`;
|
||||||
await vscode.commands.executeCommand(
|
await vscode.commands.executeCommand(
|
||||||
"vscode.diff",
|
"vscode.diff",
|
||||||
this.baselineUri(docPath),
|
this.baselineUri(document),
|
||||||
document.uri,
|
document.uri,
|
||||||
title,
|
title,
|
||||||
{ preview: false },
|
{ preview: false },
|
||||||
@@ -208,12 +198,28 @@ export class DiffViewController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private pinCommand(editor: vscode.TextEditor | undefined): void {
|
||||||
* Test-facing (§6.4): is this doc's baseline diff currently open in any tab
|
if (!editor || !this.isDiffable(editor.document)) {
|
||||||
* group? The diff's `modified` side is the document's own file: URI.
|
void vscode.window.showWarningMessage("Cowriting: focus a text editor to pin its diff baseline.");
|
||||||
*/
|
return;
|
||||||
isDiffOpen(docPath: string): boolean {
|
}
|
||||||
return this.findDiffTab(vscode.Uri.file(path.join(this.rootDir, docPath))) !== undefined;
|
this.pin(editor.document);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- test-facing surface (§6.4) ----------------------------------------------------
|
||||||
|
|
||||||
|
getBaseline(uriString: string): { text: string; reason: BaselineReason; capturedAt: string } | undefined {
|
||||||
|
const b = this.baselines.get(uriString);
|
||||||
|
return b ? { text: b.text, reason: b.reason, capturedAt: b.capturedAt } : undefined;
|
||||||
|
}
|
||||||
|
/** Is this doc's baseline diff currently open in any tab group? */
|
||||||
|
isDiffOpen(uriString: string): boolean {
|
||||||
|
return this.findDiffTab(vscode.Uri.parse(uriString)) !== undefined;
|
||||||
|
}
|
||||||
|
/** Absolute on-disk path of this doc's persisted baseline, or undefined (untitled/in-memory). */
|
||||||
|
baselineFilePath(uriString: string): string | undefined {
|
||||||
|
if (!this.store || vscode.Uri.parse(uriString).scheme !== "file") return undefined;
|
||||||
|
return this.store.baselinePath(this.storageKey(uriString));
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
|||||||
+18
-10
@@ -44,6 +44,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- F6: diff-view toggle (Features #17 + #19) — workspace-INDEPENDENT ---
|
||||||
|
// F6 works on ANY diffable doc (file or untitled), so it is constructed
|
||||||
|
// regardless of whether a folder is open, and its commands are always live
|
||||||
|
// (never stubbed). Baseline lives in VS Code's per-extension GLOBAL storage
|
||||||
|
// (always present), never the repo (INV-19). The machine-landing advance is
|
||||||
|
// wired in the with-root branch below (landings only occur on workspace files
|
||||||
|
// through the seam). The controller self-wires baseline capture on open.
|
||||||
|
const baselineStorageDir = context.globalStorageUri?.fsPath;
|
||||||
|
const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null;
|
||||||
|
const diffViewController = new DiffViewController(baselineStore);
|
||||||
|
context.subscriptions.push(diffViewController);
|
||||||
|
|
||||||
// --- F2: region-anchored threads (Feature #4) ---
|
// --- F2: region-anchored threads (Feature #4) ---
|
||||||
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
if (!root) {
|
if (!root) {
|
||||||
@@ -66,11 +78,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
"cowriting.acceptProposal",
|
"cowriting.acceptProposal",
|
||||||
"cowriting.rejectProposal",
|
"cowriting.rejectProposal",
|
||||||
"cowriting.proposeAgentEdit",
|
"cowriting.proposeAgentEdit",
|
||||||
"cowriting.toggleDiffView",
|
|
||||||
"cowriting.pinDiffBaseline",
|
|
||||||
]) {
|
]) {
|
||||||
context.subscriptions.push(vscode.commands.registerCommand(command, stub));
|
context.subscriptions.push(vscode.commands.registerCommand(command, stub));
|
||||||
}
|
}
|
||||||
|
// F6 (toggleDiffView / pinDiffBaseline) is NOT stubbed here — it works
|
||||||
|
// without a folder (on untitled buffers and out-of-folder files); its real
|
||||||
|
// commands were registered above by DiffViewController.
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const store = new CoauthorStore(root);
|
const store = new CoauthorStore(root);
|
||||||
@@ -88,14 +101,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
const proposalController = new ProposalController(store, attributionController, root, versionGuard);
|
const proposalController = new ProposalController(store, attributionController, root, versionGuard);
|
||||||
context.subscriptions.push(proposalController);
|
context.subscriptions.push(proposalController);
|
||||||
|
|
||||||
// --- F6: diff-view toggle (Feature #17) ---
|
// --- F6 machine-landing wiring (with-root only) ---
|
||||||
// Baseline lives in VS Code workspace storage, never the repo (INV-19).
|
|
||||||
// storageUri can be undefined in odd host states → in-memory fallback (§6.5).
|
|
||||||
const storageDir = context.storageUri?.fsPath;
|
|
||||||
const baselineStore = storageDir ? new BaselineStore(storageDir) : null;
|
|
||||||
const diffViewController = new DiffViewController(baselineStore, root);
|
|
||||||
context.subscriptions.push(diffViewController);
|
|
||||||
// The seam's single machine-landing signal advances the baseline (INV-18).
|
// The seam's single machine-landing signal advances the baseline (INV-18).
|
||||||
|
// The seam fires only on workspace files, so this wiring belongs here; the
|
||||||
|
// DiffViewController itself was constructed above (workspace-independent).
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
|
attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)),
|
||||||
);
|
);
|
||||||
@@ -245,7 +254,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
threadController.renderAll(doc);
|
threadController.renderAll(doc);
|
||||||
attributionController.loadAll(doc);
|
attributionController.loadAll(doc);
|
||||||
proposalController.renderAll(doc);
|
proposalController.renderAll(doc);
|
||||||
diffViewController.ensureBaseline(doc);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
vscode.workspace.textDocuments.forEach(renderIfOpen);
|
||||||
|
|||||||
+25
-17
@@ -13,41 +13,49 @@ afterEach(() => {
|
|||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
function sample(docPath = "notes/chapter-1.md"): Baseline {
|
const KEY = "a1b2c3"; // a stand-in for the controller's sha256(uri) key
|
||||||
return { docPath, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" };
|
|
||||||
|
function sample(uri = "file:///ws/notes/chapter-1.md"): Baseline {
|
||||||
|
return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("BaselineStore", () => {
|
describe("BaselineStore", () => {
|
||||||
it("returns null for a document with no baseline", () => {
|
it("returns null for a key with no baseline", () => {
|
||||||
expect(new BaselineStore(dir).load("notes/chapter-1.md")).toBeNull();
|
expect(new BaselineStore(dir).load(KEY)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("computes the baseline path as baselines/<docPath>.json", () => {
|
it("computes the baseline path as baselines/<key>.json", () => {
|
||||||
const store = new BaselineStore(dir);
|
const store = new BaselineStore(dir);
|
||||||
expect(store.baselinePath("notes/chapter-1.md")).toBe(
|
expect(store.baselinePath(KEY)).toBe(path.join(dir, "baselines", "a1b2c3.json"));
|
||||||
path.join(dir, "baselines", "notes", "chapter-1.md.json"),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("save then load round-trips the baseline (including nested docPaths)", () => {
|
it("save then load round-trips the baseline (including the uri identity)", () => {
|
||||||
const store = new BaselineStore(dir);
|
const store = new BaselineStore(dir);
|
||||||
const b = sample();
|
const b = sample();
|
||||||
store.save(b.docPath, b);
|
store.save(KEY, b);
|
||||||
expect(store.load(b.docPath)).toEqual(b);
|
expect(store.load(KEY)).toEqual(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("overwrites in place: the newest epoch wins, no history kept", () => {
|
it("overwrites in place: the newest epoch wins, no history kept", () => {
|
||||||
const store = new BaselineStore(dir);
|
const store = new BaselineStore(dir);
|
||||||
store.save("d.md", { docPath: "d.md", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" });
|
store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" });
|
||||||
store.save("d.md", { docPath: "d.md", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
|
store.save(KEY, { uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
|
||||||
expect(store.load("d.md")).toEqual({ docPath: "d.md", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
|
expect(store.load(KEY)).toEqual({ uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes pretty JSON with a trailing newline", () => {
|
it("writes pretty JSON with a trailing newline", () => {
|
||||||
const store = new BaselineStore(dir);
|
const store = new BaselineStore(dir);
|
||||||
const b = sample("d.md");
|
const b = sample();
|
||||||
store.save("d.md", b);
|
store.save(KEY, b);
|
||||||
const raw = fs.readFileSync(store.baselinePath("d.md"), "utf8");
|
const raw = fs.readFileSync(store.baselinePath(KEY), "utf8");
|
||||||
expect(raw).toBe(JSON.stringify(b, null, 2) + "\n");
|
expect(raw).toBe(JSON.stringify(b, null, 2) + "\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("distinct keys are independent files (no collision)", () => {
|
||||||
|
const store = new BaselineStore(dir);
|
||||||
|
store.save("k1", sample("file:///a.md"));
|
||||||
|
store.save("k2", sample("file:///b.md"));
|
||||||
|
expect(store.load("k1")!.uri).toBe("file:///a.md");
|
||||||
|
expect(store.load("k2")!.uri).toBe("file:///b.md");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ suite("no-workspace activation (#8)", () => {
|
|||||||
"cowriting.acceptProposal",
|
"cowriting.acceptProposal",
|
||||||
"cowriting.rejectProposal",
|
"cowriting.rejectProposal",
|
||||||
"cowriting.proposeAgentEdit",
|
"cowriting.proposeAgentEdit",
|
||||||
"cowriting.toggleDiffView",
|
|
||||||
"cowriting.pinDiffBaseline",
|
|
||||||
]) {
|
]) {
|
||||||
assert.ok(all.includes(command), `${command} is registered`);
|
assert.ok(all.includes(command), `${command} is registered`);
|
||||||
}
|
}
|
||||||
@@ -39,4 +37,26 @@ suite("no-workspace activation (#8)", () => {
|
|||||||
await vscode.commands.executeCommand("cowriting.editSelection");
|
await vscode.commands.executeCommand("cowriting.editSelection");
|
||||||
await vscode.commands.executeCommand("cowriting.createThread");
|
await vscode.commands.executeCommand("cowriting.createThread");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F6 (#19) is workspace-INDEPENDENT: its commands are real (not stubs) even
|
||||||
|
// with no folder open, and the toggle works on an untitled buffer.
|
||||||
|
test("F6 toggle works with no folder open (untitled buffer)", async () => {
|
||||||
|
const all = await vscode.commands.getCommands(true);
|
||||||
|
assert.ok(all.includes("cowriting.toggleDiffView"), "toggleDiffView registered");
|
||||||
|
assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline registered");
|
||||||
|
const untitled = await vscode.workspace.openTextDocument({ content: "no-folder scratch\n", language: "markdown" });
|
||||||
|
await vscode.window.showTextDocument(untitled);
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
const opened = vscode.window.tabGroups.all.some((g) =>
|
||||||
|
g.tabs.some(
|
||||||
|
(t) =>
|
||||||
|
t.input instanceof vscode.TabInputTextDiff &&
|
||||||
|
t.input.original.scheme === "cowriting-baseline" &&
|
||||||
|
t.input.modified.toString() === untitled.uri.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(opened, "a cowriting-baseline diff opened for the untitled buffer with no folder");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import * as assert from "assert";
|
import * as assert from "assert";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import type { CowritingApi } from "../../../src/extension";
|
import type { CowritingApi } from "../../../src/extension";
|
||||||
|
|
||||||
const WS = process.env.E2E_WORKSPACE!;
|
const WS = process.env.E2E_WORKSPACE!;
|
||||||
const DOC_REL = "docs/diffview.md";
|
const DOC_REL = "docs/diffview.md";
|
||||||
|
const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString();
|
||||||
|
|
||||||
async function openDoc(): Promise<vscode.TextDocument> {
|
async function openDoc(): Promise<vscode.TextDocument> {
|
||||||
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
const uri = vscode.Uri.file(path.join(WS, DOC_REL));
|
||||||
@@ -22,16 +24,17 @@ async function getApi(): Promise<CowritingApi> {
|
|||||||
const settle = () => new Promise((r) => setTimeout(r, 300));
|
const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||||
|
|
||||||
// Order-dependent (F2–F4 pattern): later tests consume earlier state. Owns
|
// Order-dependent (F2–F4 pattern): later tests consume earlier state. Owns
|
||||||
// docs/diffview.md exclusively.
|
// docs/diffview.md exclusively. F6 works on ANY file (#19), so the last two
|
||||||
suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", () => {
|
// tests use an out-of-workspace file and an untitled buffer.
|
||||||
|
suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no LLM)", () => {
|
||||||
const TARGET = "A target sentence Claude will rewrite via the seam.";
|
const TARGET = "A target sentence Claude will rewrite via the seam.";
|
||||||
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
|
const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam.";
|
||||||
|
|
||||||
test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => {
|
test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => {
|
||||||
const doc = await openDoc();
|
const doc = await openDoc();
|
||||||
|
await getApi();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
// renderIfOpen on open already called ensureBaseline; assert it captured.
|
const baseline = api.diffViewController.getBaseline(docUri());
|
||||||
const baseline = api.diffViewController.getBaseline(DOC_REL);
|
|
||||||
assert.ok(baseline, "baseline captured on first sight");
|
assert.ok(baseline, "baseline captured on first sight");
|
||||||
assert.strictEqual(baseline!.reason, "opened");
|
assert.strictEqual(baseline!.reason, "opened");
|
||||||
assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer");
|
assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer");
|
||||||
@@ -39,11 +42,10 @@ suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", ()
|
|||||||
|
|
||||||
test("toggle opens a diff tab (original scheme cowriting-baseline) over the live doc (PUC-1)", async () => {
|
test("toggle opens a diff tab (original scheme cowriting-baseline) over the live doc (PUC-1)", async () => {
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), false, "no diff open yet");
|
assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), false, "no diff open yet");
|
||||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), true, "diff tab open");
|
assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), true, "diff tab open");
|
||||||
// The active tab is a TextDiff whose original is the baseline scheme.
|
|
||||||
const active = vscode.window.tabGroups.activeTabGroup.activeTab;
|
const active = vscode.window.tabGroups.activeTabGroup.activeTab;
|
||||||
assert.ok(active && active.input instanceof vscode.TabInputTextDiff, "active tab is a diff");
|
assert.ok(active && active.input instanceof vscode.TabInputTextDiff, "active tab is a diff");
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
@@ -56,12 +58,12 @@ suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", ()
|
|||||||
test("typing leaves the baseline unchanged while the buffer diverges", async () => {
|
test("typing leaves the baseline unchanged while the buffer diverges", async () => {
|
||||||
const doc = await openDoc();
|
const doc = await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const before = api.diffViewController.getBaseline(DOC_REL)!.text;
|
const before = api.diffViewController.getBaseline(docUri())!.text;
|
||||||
const edit = new vscode.WorkspaceEdit();
|
const edit = new vscode.WorkspaceEdit();
|
||||||
edit.insert(doc.uri, new vscode.Position(0, 0), "OPERATOR ADDED LINE\n");
|
edit.insert(doc.uri, new vscode.Position(0, 0), "OPERATOR ADDED LINE\n");
|
||||||
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied");
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(api.diffViewController.getBaseline(DOC_REL)!.text, before, "baseline unchanged by typing");
|
assert.strictEqual(api.diffViewController.getBaseline(docUri())!.text, before, "baseline unchanged by typing");
|
||||||
assert.notStrictEqual(doc.getText(), before, "buffer diverged");
|
assert.notStrictEqual(doc.getText(), before, "buffer diverged");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,7 +84,7 @@ suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", ()
|
|||||||
assert.ok(id, "propose returns an id");
|
assert.ok(id, "propose returns an id");
|
||||||
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam");
|
||||||
await settle();
|
await settle();
|
||||||
const baseline = api.diffViewController.getBaseline(DOC_REL)!;
|
const baseline = api.diffViewController.getBaseline(docUri())!;
|
||||||
assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing");
|
assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing");
|
||||||
assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)");
|
assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)");
|
||||||
assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too");
|
assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too");
|
||||||
@@ -97,7 +99,7 @@ suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", ()
|
|||||||
assert.ok(await vscode.workspace.applyEdit(edit));
|
assert.ok(await vscode.workspace.applyEdit(edit));
|
||||||
await settle();
|
await settle();
|
||||||
assert.notStrictEqual(
|
assert.notStrictEqual(
|
||||||
api.diffViewController.getBaseline(DOC_REL)!.text,
|
api.diffViewController.getBaseline(docUri())!.text,
|
||||||
doc.getText(),
|
doc.getText(),
|
||||||
"operator changes show against the advanced baseline",
|
"operator changes show against the advanced baseline",
|
||||||
);
|
);
|
||||||
@@ -108,59 +110,82 @@ suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", ()
|
|||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
|
||||||
await settle();
|
await settle();
|
||||||
const baseline = api.diffViewController.getBaseline(DOC_REL)!;
|
const baseline = api.diffViewController.getBaseline(docUri())!;
|
||||||
assert.strictEqual(baseline.reason, "pinned");
|
assert.strictEqual(baseline.reason, "pinned");
|
||||||
assert.strictEqual(baseline.text, doc.getText(), "pinned baseline == current buffer (diff empties)");
|
assert.strictEqual(baseline.text, doc.getText(), "pinned baseline == current buffer (diff empties)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the baseline is persisted on disk under the storage dir with the expected content (PUC-4)", async () => {
|
test("the baseline is persisted in GLOBAL storage, never the repo (PUC-4, INV-19)", async () => {
|
||||||
|
await openDoc();
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
const p = api.diffViewController.baselineFilePath(DOC_REL);
|
const p = api.diffViewController.baselineFilePath(docUri());
|
||||||
assert.ok(p, "storage-backed baseline path is available");
|
assert.ok(p, "storage-backed baseline path is available for a file: doc");
|
||||||
assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`);
|
assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`);
|
||||||
const onDisk = JSON.parse(fs.readFileSync(p!, "utf8"));
|
const onDisk = JSON.parse(fs.readFileSync(p!, "utf8"));
|
||||||
assert.strictEqual(onDisk.docPath, DOC_REL);
|
assert.strictEqual(onDisk.uri, docUri(), "baseline records the document URI");
|
||||||
assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted");
|
assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted");
|
||||||
assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(DOC_REL)!.text, "on-disk == in-memory");
|
assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(docUri())!.text, "on-disk == in-memory");
|
||||||
// INV-19: nothing leaked into the repo's .threads sidecar tree.
|
// INV-19: baseline lives under the extension's storage dir, not the repo.
|
||||||
assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree");
|
assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree");
|
||||||
|
assert.ok(!p!.startsWith(WS + path.sep), "baseline is NOT under the workspace folder");
|
||||||
|
assert.ok(p!.includes(`${path.sep}baselines${path.sep}`), "baseline lives under <globalStorage>/baselines/");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("toggle again closes the diff tab and reveals the normal editor (PUC-1)", async () => {
|
test("toggle again closes the diff tab and reveals the normal editor (PUC-1)", async () => {
|
||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
if (!api.diffViewController.isDiffOpen(DOC_REL)) {
|
if (!api.diffViewController.isDiffOpen(docUri())) {
|
||||||
|
await openDoc();
|
||||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||||
await settle();
|
await settle();
|
||||||
}
|
}
|
||||||
assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), true, "diff open before close");
|
assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), true, "diff open before close");
|
||||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||||
await settle();
|
await settle();
|
||||||
assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), false, "diff tab closed");
|
assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), false, "diff tab closed");
|
||||||
// "Normal editor is back": the file is the active text editor, and no
|
|
||||||
// baseline-diff tab for it remains in any group.
|
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
vscode.window.activeTextEditor?.document.uri.toString(),
|
vscode.window.activeTextEditor?.document.uri.toString(),
|
||||||
vscode.Uri.file(path.join(WS, DOC_REL)).toString(),
|
docUri(),
|
||||||
"the normal editor for the doc is active after closing the diff",
|
"the normal editor for the doc is active after closing the diff",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("toggling on an untracked doc warns and opens no diff (PUC-5)", async () => {
|
test("toggle works on a file OUTSIDE the workspace folder, persisted in global storage (#19)", async () => {
|
||||||
await getApi();
|
const api = await getApi();
|
||||||
const untracked = await vscode.workspace.openTextDocument({ content: "scratch", language: "markdown" });
|
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-outside-"));
|
||||||
await vscode.window.showTextDocument(untracked);
|
const outsidePath = path.join(outsideDir, "outside.md");
|
||||||
|
fs.writeFileSync(outsidePath, "# Outside the workspace\n\nThe operator edits this too.\n", "utf8");
|
||||||
|
const outsideUri = vscode.Uri.file(outsidePath);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(outsideUri);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
await settle();
|
await settle();
|
||||||
|
// Captured on open even though it is NOT under the workspace folder.
|
||||||
|
const baseline = api.diffViewController.getBaseline(outsideUri.toString());
|
||||||
|
assert.ok(baseline, "baseline captured for an out-of-folder file");
|
||||||
|
assert.strictEqual(baseline!.reason, "opened");
|
||||||
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||||
await settle();
|
await settle();
|
||||||
// No baseline-diff tab for an untitled doc anywhere.
|
assert.strictEqual(api.diffViewController.isDiffOpen(outsideUri.toString()), true, "diff opens for the outside file");
|
||||||
const anyDiff = vscode.window.tabGroups.all.some((g) =>
|
const fp = api.diffViewController.baselineFilePath(outsideUri.toString());
|
||||||
g.tabs.some(
|
assert.ok(fp && fs.existsSync(fp), "outside-file baseline persisted in global storage");
|
||||||
(t) =>
|
assert.ok(!fp!.startsWith(WS + path.sep), "not under the workspace folder");
|
||||||
t.input instanceof vscode.TabInputTextDiff &&
|
// close it so it doesn't bleed into the untitled test's active-tab checks
|
||||||
t.input.original.scheme === "cowriting-baseline" &&
|
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||||
t.input.modified.toString() === untracked.uri.toString(),
|
await settle();
|
||||||
),
|
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||||
);
|
});
|
||||||
assert.strictEqual(anyDiff, false, "no diff opened for the untracked doc");
|
|
||||||
|
test("toggle works on an UNTITLED buffer, baseline in-memory only (#19)", async () => {
|
||||||
|
const api = await getApi();
|
||||||
|
const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" });
|
||||||
|
await vscode.window.showTextDocument(untitled);
|
||||||
|
await settle();
|
||||||
|
const key = untitled.uri.toString();
|
||||||
|
assert.strictEqual(untitled.uri.scheme, "untitled", "it really is an untitled buffer");
|
||||||
|
const baseline = api.diffViewController.getBaseline(key);
|
||||||
|
assert.ok(baseline, "untitled buffer got an in-memory baseline");
|
||||||
|
assert.strictEqual(api.diffViewController.baselineFilePath(key), undefined, "untitled is NOT persisted to disk");
|
||||||
|
await vscode.commands.executeCommand("cowriting.toggleDiffView");
|
||||||
|
await settle();
|
||||||
|
assert.strictEqual(api.diffViewController.isDiffOpen(key), true, "diff opens for the untitled buffer");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user