F8: out-of-workspace authoring — hybrid sidecar persistence (#25) #26
@@ -196,6 +196,33 @@ the manual smoke, not the sealed-sandbox E2E.
|
|||||||
Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`.
|
Design: `vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md`.
|
||||||
Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md).
|
Live smoke: [`docs/MANUAL-SMOKE-F7.md`](docs/MANUAL-SMOKE-F7.md).
|
||||||
|
|
||||||
|
## F8 — Out-of-workspace authoring (Feature #25)
|
||||||
|
|
||||||
|
"Ask Claude to Edit Selection" (and F2 threads / F3 attribution / F4
|
||||||
|
propose-accept) now work on **any** document the editor shows — saved in the
|
||||||
|
workspace folder, saved outside it, or untitled — matching the already-universal
|
||||||
|
F6 diff and F7 preview. Authoring is no longer gated to in-workspace files, and
|
||||||
|
the commands are live even with **no folder open**.
|
||||||
|
|
||||||
|
Persistence is **hybrid** (one `SidecarStore` abstraction, routed per-document):
|
||||||
|
|
||||||
|
- an **in-workspace** file keeps its committable `.threads/<repo-rel>.json`
|
||||||
|
sidecar, **byte-for-byte unchanged** (INV-2) — the only home the F5 cross-rung
|
||||||
|
contract ever sees;
|
||||||
|
- an **out-of-workspace** file or **untitled** buffer stores its coauthoring
|
||||||
|
artifact in VS Code **global storage** keyed by `sha256(uri)` (the same home
|
||||||
|
and key F6's baseline uses, INV-19/24); untitled buffers are **in-memory
|
||||||
|
only** (lost on reload/save).
|
||||||
|
|
||||||
|
A global-storage artifact is **not a committed file**, so it is **never
|
||||||
|
cross-rung-shareable** (INV-25), and renaming/moving the file **orphans** its
|
||||||
|
artifact (the key is the URI hash) — both stated as design contract, not
|
||||||
|
discovered later. Routing leaves the in-workspace path untouched, so rollback is
|
||||||
|
a plain PR revert with zero data migration.
|
||||||
|
|
||||||
|
Design: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`.
|
||||||
|
Live smoke: [`docs/MANUAL-SMOKE-F8.md`](docs/MANUAL-SMOKE-F8.md).
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
- `npm run watch` — rebuild on change.
|
- `npm run watch` — rebuild on change.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Manual smoke — F8 out-of-workspace authoring
|
||||||
|
|
||||||
|
Confirms "Ask Claude to Edit Selection" (+ threads / attribution) work on a file
|
||||||
|
**outside** the workspace folder and on an **untitled** buffer, while in-workspace
|
||||||
|
authoring is unchanged. One live turn hits the SDK (the only LLM step).
|
||||||
|
|
||||||
|
Spec: `vscode-cowriting-plugin-content/specs/coauthoring-out-of-workspace.md`.
|
||||||
|
|
||||||
|
## Prereqs
|
||||||
|
- Build: `npm run build`.
|
||||||
|
- Launch the Extension Development Host (F5 in VS Code) with this repo's `sandbox/`
|
||||||
|
opened as the workspace folder.
|
||||||
|
|
||||||
|
## A. Out-of-folder file (PUC-1)
|
||||||
|
1. Open a markdown file from a DIFFERENT directory (outside `sandbox/`) — e.g. a
|
||||||
|
sibling repo, or `File > Open` a scratch file under `/tmp`.
|
||||||
|
2. Select a sentence → Command Palette → **Cowriting: Ask Claude to Edit Selection**
|
||||||
|
→ type an instruction → wait for the proposal (amber range + ✓/✗).
|
||||||
|
3. Accept (✓). Expect: the text is replaced and shows the Claude attribution tint.
|
||||||
|
4. Add a coauthoring thread on a selection (**Add Coauthoring Thread on Selection**).
|
||||||
|
5. Close and reopen the file (or revert). Expect: the thread + attribution are
|
||||||
|
restored. There is **no** `.threads/` folder beside the file — its state lives
|
||||||
|
in VS Code global storage (`<globalStorage>/sidecars/<hash>.json`).
|
||||||
|
|
||||||
|
## B. Untitled buffer (PUC-2)
|
||||||
|
1. `File > New File` (don't save) → type a few sentences.
|
||||||
|
2. Select → **Ask Claude to Edit Selection** → instruct → accept. Expect: it works
|
||||||
|
exactly like A, in-session.
|
||||||
|
3. Reload the window (Developer: Reload Window). Expect: the untitled buffer's
|
||||||
|
coauthoring state is **gone** (in-memory only — documented limitation, §6.7).
|
||||||
|
|
||||||
|
## C. In-workspace unchanged (PUC-3)
|
||||||
|
1. Open a file UNDER `sandbox/`. Repeat the propose→accept→thread loop.
|
||||||
|
2. Expect: a committable `sandbox/.threads/<path>.json` sidecar is written, exactly
|
||||||
|
as before F8 (byte-for-byte, INV-2).
|
||||||
|
|
||||||
|
## D. Read-only view declines (PUC-5)
|
||||||
|
1. Open a Git diff / Output view, select text, run **Ask Claude to Edit Selection**.
|
||||||
|
2. Expect: a warning that this kind of document can't be edited (not "select some
|
||||||
|
text").
|
||||||
|
|
||||||
|
## E. Folder-less (optional)
|
||||||
|
1. Launch the EDH with **no folder** open. Open an untitled buffer or `File > Open`
|
||||||
|
any file. Repeat A/B. Expect: authoring works (every doc routes to global
|
||||||
|
storage); the commands are not stubbed.
|
||||||
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -112,12 +112,12 @@
|
|||||||
"editor/context": [
|
"editor/context": [
|
||||||
{
|
{
|
||||||
"command": "cowriting.editSelection",
|
"command": "cowriting.editSelection",
|
||||||
"when": "editorHasSelection && resourceScheme == file",
|
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
|
||||||
"group": "1_cowriting@1"
|
"group": "1_cowriting@1"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "cowriting.createThread",
|
"command": "cowriting.createThread",
|
||||||
"when": "editorHasSelection && resourceScheme == file",
|
"when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)",
|
||||||
"group": "1_cowriting@2"
|
"group": "1_cowriting@2"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
*/
|
*/
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { CoauthorStore } from "./store";
|
import { SidecarRouter, docIdentity } from "./sidecarRouter";
|
||||||
import { newId, type AttributionRecord, type Provenance } from "./model";
|
import { newId, type AttributionRecord, type Provenance } from "./model";
|
||||||
import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
|
import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
|
||||||
import { gitUserEmail } from "./identity";
|
import { gitUserEmail } from "./identity";
|
||||||
import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
|
import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
|
||||||
import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
|
import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
|
||||||
import type { VersionGuard } from "./versionGuard";
|
import type { VersionGuard } from "./versionGuard";
|
||||||
import { isUnderRoot } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
|
|
||||||
/** Test-facing snapshot of live attribution state for a document. */
|
/** Test-facing snapshot of live attribution state for a document. */
|
||||||
export interface RenderedSpan {
|
export interface RenderedSpan {
|
||||||
@@ -74,8 +74,8 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event;
|
readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly store: CoauthorStore,
|
private readonly store: SidecarRouter,
|
||||||
private readonly rootDir: string,
|
private readonly rootDir: string | undefined,
|
||||||
private readonly guard: VersionGuard,
|
private readonly guard: VersionGuard,
|
||||||
) {
|
) {
|
||||||
this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output, this.applyEmitter);
|
this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output, this.applyEmitter);
|
||||||
@@ -88,14 +88,15 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isTracked(document: vscode.TextDocument): boolean {
|
private isTracked(document: vscode.TextDocument): boolean {
|
||||||
return document.uri.scheme === "file" && isUnderRoot(document.uri.fsPath, this.rootDir);
|
return isAuthorable(document.uri.scheme);
|
||||||
}
|
}
|
||||||
private docPathOf(uri: vscode.Uri): string {
|
/** The single document key (F8): repo-relative path in-workspace, URI string otherwise. */
|
||||||
return vscode.workspace.asRelativePath(uri, false);
|
private keyOf(document: vscode.TextDocument): string {
|
||||||
|
return this.store.keyOf(docIdentity(document));
|
||||||
}
|
}
|
||||||
private currentAuthor(): Provenance {
|
private currentAuthor(): Provenance {
|
||||||
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
|
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
|
||||||
const email = gitUserEmail(this.rootDir);
|
const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined;
|
||||||
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
|
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
|
||||||
}
|
}
|
||||||
private state(docPath: string): DocAttribution {
|
private state(docPath: string): DocAttribution {
|
||||||
@@ -112,7 +113,7 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
/** Load the sidecar and re-resolve every attribution (live span | orphan). */
|
/** Load the sidecar and re-resolve every attribution (live span | orphan). */
|
||||||
loadAll(document: vscode.TextDocument): void {
|
loadAll(document: vscode.TextDocument): void {
|
||||||
if (!this.isTracked(document)) return;
|
if (!this.isTracked(document)) return;
|
||||||
const docPath = this.docPathOf(document.uri);
|
const docPath = this.keyOf(document);
|
||||||
const s = this.state(docPath);
|
const s = this.state(docPath);
|
||||||
const artifact = this.store.load(docPath);
|
const artifact = this.store.load(docPath);
|
||||||
s.spans = [];
|
s.spans = [];
|
||||||
@@ -143,7 +144,7 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
handleExternalSidecarChange(uri: vscode.Uri): void {
|
handleExternalSidecarChange(uri: vscode.Uri): void {
|
||||||
for (const s of this.docs.values()) {
|
for (const s of this.docs.values()) {
|
||||||
if (this.store.sidecarPath(s.docPath) === uri.fsPath) {
|
if (this.store.sidecarPath(s.docPath) === uri.fsPath) {
|
||||||
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === s.docPath);
|
const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === s.docPath);
|
||||||
if (doc) this.loadAll(doc);
|
if (doc) this.loadAll(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +154,7 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
|
|
||||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||||
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
if (!this.isTracked(e.document) || e.contentChanges.length === 0) return;
|
||||||
const docPath = this.docPathOf(e.document.uri);
|
const docPath = this.keyOf(e.document);
|
||||||
if (!e.document.isDirty && this.matchesDisk(e.document)) {
|
if (!e.document.isDirty && this.matchesDisk(e.document)) {
|
||||||
// Disk sync (revert / external reload): buffer now equals the file on
|
// Disk sync (revert / external reload): buffer now equals the file on
|
||||||
// disk — re-resolve, never attribute (PUC-4). A real edit can also
|
// disk — re-resolve, never attribute (PUC-4). A real edit can also
|
||||||
@@ -248,7 +249,7 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!this.isTracked(document)) return false;
|
if (!this.isTracked(document)) return false;
|
||||||
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
|
if (opts?.expectedVersion !== undefined && document.version !== opts.expectedVersion) return false;
|
||||||
const docPath = this.docPathOf(document.uri);
|
const docPath = this.keyOf(document);
|
||||||
const startOffset = document.offsetAt(range.start);
|
const startOffset = document.offsetAt(range.start);
|
||||||
const endOffset = document.offsetAt(range.end);
|
const endOffset = document.offsetAt(range.end);
|
||||||
const oldText = document.getText(range);
|
const oldText = document.getText(range);
|
||||||
@@ -298,8 +299,8 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
|
|
||||||
private onDidSave(document: vscode.TextDocument): void {
|
private onDidSave(document: vscode.TextDocument): void {
|
||||||
if (!this.isTracked(document)) return;
|
if (!this.isTracked(document)) return;
|
||||||
if (this.guard.isReadOnly(this.docPathOf(document.uri))) return;
|
if (this.guard.isReadOnly(this.keyOf(document))) return;
|
||||||
const docPath = this.docPathOf(document.uri);
|
const docPath = this.keyOf(document);
|
||||||
const s = this.docs.get(docPath);
|
const s = this.docs.get(docPath);
|
||||||
// Allow save when hadAttributions is true even if spans/orphans are now empty:
|
// Allow save when hadAttributions is true even if spans/orphans are now empty:
|
||||||
// that means the user deliberately deleted all attributed text, and we must
|
// that means the user deliberately deleted all attributed text, and we must
|
||||||
@@ -351,7 +352,7 @@ export class AttributionController implements vscode.Disposable {
|
|||||||
|
|
||||||
private render(document: vscode.TextDocument): void {
|
private render(document: vscode.TextDocument): void {
|
||||||
if (!this.isTracked(document)) return;
|
if (!this.isTracked(document)) return;
|
||||||
const s = this.docs.get(this.docPathOf(document.uri));
|
const s = this.docs.get(this.keyOf(document));
|
||||||
const spans = this.visible && s ? s.spans : [];
|
const spans = this.visible && s ? s.spans : [];
|
||||||
const toRange = (sp: LiveSpan) =>
|
const toRange = (sp: LiveSpan) =>
|
||||||
new vscode.Range(document.positionAt(sp.start), document.positionAt(sp.end));
|
new vscode.Range(document.positionAt(sp.start), document.positionAt(sp.end));
|
||||||
|
|||||||
+37
-48
@@ -7,9 +7,11 @@ import { ProposalController } from "./proposalController";
|
|||||||
import { buildFingerprint } from "./anchorer";
|
import { buildFingerprint } from "./anchorer";
|
||||||
import { VersionGuard } from "./versionGuard";
|
import { VersionGuard } from "./versionGuard";
|
||||||
import { BaselineStore } from "./baselineStore";
|
import { BaselineStore } from "./baselineStore";
|
||||||
|
import { GlobalSidecarStore } from "./globalSidecarStore";
|
||||||
|
import { SidecarRouter } from "./sidecarRouter";
|
||||||
import { DiffViewController } from "./diffViewController";
|
import { DiffViewController } from "./diffViewController";
|
||||||
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
import { TrackChangesPreviewController } from "./trackChangesPreview";
|
||||||
import { isUnderRoot, selectionRejection } from "./workspacePath";
|
import { isAuthorable, selectionRejection } from "./workspacePath";
|
||||||
|
|
||||||
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
const CHANNEL_NAME = "Cowriting (Cline SDK)";
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ export interface CowritingApi {
|
|||||||
versionGuard: VersionGuard;
|
versionGuard: VersionGuard;
|
||||||
diffViewController: DiffViewController;
|
diffViewController: DiffViewController;
|
||||||
trackChangesPreviewController: TrackChangesPreviewController;
|
trackChangesPreviewController: TrackChangesPreviewController;
|
||||||
|
sidecarRouter: SidecarRouter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
|
||||||
@@ -59,6 +62,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
const diffViewController = new DiffViewController(baselineStore);
|
const diffViewController = new DiffViewController(baselineStore);
|
||||||
context.subscriptions.push(diffViewController);
|
context.subscriptions.push(diffViewController);
|
||||||
|
|
||||||
|
// F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage
|
||||||
|
// home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed
|
||||||
|
// workspace-independently; the router falls back to it for any non-in-folder
|
||||||
|
// doc. When globalStorageUri is unavailable, file writes throw (authoring
|
||||||
|
// degrades to errors-on-write, the F6-equivalent edge) but untitled in-memory
|
||||||
|
// still works — PUC-5.
|
||||||
|
const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? "");
|
||||||
|
|
||||||
// --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT ---
|
// --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT ---
|
||||||
// Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it
|
// 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.
|
// is constructed regardless of an open folder and its command is always live.
|
||||||
@@ -69,64 +80,42 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
);
|
);
|
||||||
context.subscriptions.push(trackChangesPreviewController);
|
context.subscriptions.push(trackChangesPreviewController);
|
||||||
|
|
||||||
// --- F2: region-anchored threads (Feature #4) ---
|
// --- 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:
|
||||||
|
// → the global-storage sidecar (GlobalSidecarStore). Constructed even with NO
|
||||||
|
// folder open (everything then routes global) — the F6 #19 precedent, now
|
||||||
|
// extended to authoring (F2 threads, F3 attribution, F4 propose/accept).
|
||||||
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
if (!root) {
|
const coauthorStore = root ? new CoauthorStore(root) : null;
|
||||||
// No folder open → nothing to anchor against, but every contributed
|
const sidecarRouter = new SidecarRouter(coauthorStore, globalSidecarStore, root);
|
||||||
// command must still exist: leave them unregistered and the palette
|
|
||||||
// errors with "command not found" (#8). Register warning stubs instead;
|
|
||||||
// opening a folder reloads the window, re-running activate with a root.
|
|
||||||
const stub = () =>
|
|
||||||
void vscode.window.showWarningMessage(
|
|
||||||
"Cowriting: open a folder first — coauthoring anchors threads and attribution to workspace files.",
|
|
||||||
);
|
|
||||||
for (const command of [
|
|
||||||
"cowriting.createThread",
|
|
||||||
"cowriting.reply",
|
|
||||||
"cowriting.resolveThread",
|
|
||||||
"cowriting.reopenThread",
|
|
||||||
"cowriting.editSelection",
|
|
||||||
"cowriting.toggleAttribution",
|
|
||||||
"cowriting.applyAgentEdit",
|
|
||||||
"cowriting.acceptProposal",
|
|
||||||
"cowriting.rejectProposal",
|
|
||||||
"cowriting.proposeAgentEdit",
|
|
||||||
]) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
const store = new CoauthorStore(root);
|
|
||||||
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
|
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
|
||||||
// warning per doc across the three co-owning controllers.
|
// warning per doc across the three co-owning controllers.
|
||||||
const versionGuard = new VersionGuard(store);
|
const versionGuard = new VersionGuard(sidecarRouter);
|
||||||
const threadController = new ThreadController(store, root, versionGuard);
|
const threadController = new ThreadController(sidecarRouter, root, versionGuard);
|
||||||
context.subscriptions.push(threadController);
|
context.subscriptions.push(threadController);
|
||||||
|
|
||||||
// --- F3: live attribution (Feature #6) ---
|
// --- F3: live attribution (Feature #6) ---
|
||||||
const attributionController = new AttributionController(store, root, versionGuard);
|
const attributionController = new AttributionController(sidecarRouter, root, versionGuard);
|
||||||
context.subscriptions.push(attributionController);
|
context.subscriptions.push(attributionController);
|
||||||
|
|
||||||
// --- F4: propose/accept (Feature #12) ---
|
// --- F4: propose/accept (Feature #12) ---
|
||||||
const proposalController = new ProposalController(store, attributionController, root, versionGuard);
|
const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard);
|
||||||
context.subscriptions.push(proposalController);
|
context.subscriptions.push(proposalController);
|
||||||
|
|
||||||
// --- F6 machine-landing wiring (with-root only) ---
|
// --- F6 machine-landing wiring — now for ANY authorable doc ---
|
||||||
// The seam's single machine-landing signal advances the baseline (INV-18).
|
// The seam's single machine-landing signal advances the F6 baseline (INV-18);
|
||||||
// The seam fires only on workspace files, so this wiring belongs here; the
|
// the seam can now fire on out-of-folder files too, so wire it unconditionally.
|
||||||
// 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)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// One SHARED sidecar watcher for both controllers; self-writes are
|
// One SHARED sidecar watcher for both controllers; self-writes are suppressed
|
||||||
// suppressed centrally in the store (the sidecar is co-owned).
|
// centrally in the repo store (only repo `.threads/` sidecars are watched —
|
||||||
|
// global artifacts live outside the workspace). Harmless when no folder is open.
|
||||||
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
|
const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json");
|
||||||
const onSidecar = (uri: vscode.Uri) => {
|
const onSidecar = (uri: vscode.Uri) => {
|
||||||
if (store.consumeSelfWrite(uri.fsPath)) return;
|
if (sidecarRouter.consumeSelfWrite(uri.fsPath)) return;
|
||||||
threadController.handleExternalSidecarChange(uri);
|
threadController.handleExternalSidecarChange(uri);
|
||||||
attributionController.handleExternalSidecarChange(uri);
|
attributionController.handleExternalSidecarChange(uri);
|
||||||
proposalController.handleExternalSidecarChange(uri);
|
proposalController.handleExternalSidecarChange(uri);
|
||||||
@@ -188,15 +177,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.commands.registerCommand("cowriting.editSelection", async () => {
|
vscode.commands.registerCommand("cowriting.editSelection", async () => {
|
||||||
const editor = vscode.window.activeTextEditor;
|
const editor = vscode.window.activeTextEditor;
|
||||||
// Each failure names its real reason (no editor / no selection / unsaved /
|
// F8: authoring works on any file: or untitled: doc (the router decides
|
||||||
// outside the workspace folder) — not always "select some text" (#bug:
|
// where its artifact is stored). Each failure still names its real reason
|
||||||
// an out-of-workspace file was rejected with the no-selection message).
|
// (no editor / no selection / a non-{file,untitled} read-only view) — not
|
||||||
|
// always "select some text" (#24's per-condition messaging).
|
||||||
const reason = selectionRejection({
|
const reason = selectionRejection({
|
||||||
hasEditor: !!editor,
|
hasEditor: !!editor,
|
||||||
selectionEmpty: editor?.selection.isEmpty ?? true,
|
selectionEmpty: editor?.selection.isEmpty ?? true,
|
||||||
scheme: editor?.document.uri.scheme ?? "",
|
scheme: editor?.document.uri.scheme ?? "",
|
||||||
fsPath: editor?.document.uri.fsPath ?? "",
|
|
||||||
root,
|
|
||||||
});
|
});
|
||||||
if (reason) {
|
if (reason) {
|
||||||
void vscode.window.showWarningMessage(reason);
|
void vscode.window.showWarningMessage(reason);
|
||||||
@@ -269,7 +257,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
|
|
||||||
// Render threads + attributions for already-open editors, and on future opens.
|
// Render threads + attributions for already-open editors, and on future opens.
|
||||||
const renderIfOpen = (doc: vscode.TextDocument) => {
|
const renderIfOpen = (doc: vscode.TextDocument) => {
|
||||||
if (doc.uri.scheme === "file" && isUnderRoot(doc.uri.fsPath, root)) {
|
if (isAuthorable(doc.uri.scheme)) {
|
||||||
threadController.renderAll(doc);
|
threadController.renderAll(doc);
|
||||||
attributionController.loadAll(doc);
|
attributionController.loadAll(doc);
|
||||||
proposalController.renderAll(doc);
|
proposalController.renderAll(doc);
|
||||||
@@ -285,6 +273,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
|
|||||||
versionGuard,
|
versionGuard,
|
||||||
diffViewController,
|
diffViewController,
|
||||||
trackChangesPreviewController,
|
trackChangesPreviewController,
|
||||||
|
sidecarRouter,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* GlobalSidecarStore — the out-of-workspace/untitled authoring sidecar (F8 spec
|
||||||
|
* §6.2/§6.4), mirroring BaselineStore (src/baselineStore.ts): vscode-free (Node
|
||||||
|
* fs/crypto only), one `Artifact` JSON per document in VS Code's per-extension
|
||||||
|
* GLOBAL storage, NEVER the repo (INV-19/24). The key passed in is the DOCUMENT
|
||||||
|
* KEY (the URI string for the docs this store serves):
|
||||||
|
* - `file:` URI → disk at `<dir>/sidecars/<sha256(uri)>.json`.
|
||||||
|
* - `untitled:` → an in-memory Map only (no durable identity — the F6
|
||||||
|
* degrade); lost on reload, and never read by mergeArtifacts (INV-25).
|
||||||
|
* `consumeSelfWrite` is a no-op (these artifacts are outside the `.threads/`
|
||||||
|
* watcher, so there is no self-write storm to suppress).
|
||||||
|
*/
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import {
|
||||||
|
SCHEMA_VERSION,
|
||||||
|
emptyArtifact,
|
||||||
|
isNewerMajor,
|
||||||
|
serializeArtifact,
|
||||||
|
type Artifact,
|
||||||
|
} from "./model";
|
||||||
|
import type { SidecarStore } from "./sidecarStore";
|
||||||
|
|
||||||
|
export class GlobalSidecarStore implements SidecarStore {
|
||||||
|
/** untitled keys live here only — no durable identity to persist against. */
|
||||||
|
private readonly memory = new Map<string, Artifact>();
|
||||||
|
|
||||||
|
/** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */
|
||||||
|
constructor(private readonly storageDir: string) {}
|
||||||
|
|
||||||
|
private isUntitled(key: string): boolean {
|
||||||
|
return key.startsWith("untitled:");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filesystem-safe storage key for a persistable (file:) doc: sha256 of its URI. */
|
||||||
|
private storageKey(key: string): string {
|
||||||
|
return createHash("sha256").update(key).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disk path for a `file:` key, or undefined for an in-memory untitled key. */
|
||||||
|
sidecarPath(key: string): string | undefined {
|
||||||
|
if (this.isUntitled(key)) return undefined;
|
||||||
|
return path.join(this.storageDir, "sidecars", `${this.storageKey(key)}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
load(key: string): Artifact | null {
|
||||||
|
if (this.isUntitled(key)) return this.memory.get(key) ?? null;
|
||||||
|
const p = this.sidecarPath(key)!;
|
||||||
|
if (!fs.existsSync(p)) return null;
|
||||||
|
return JSON.parse(fs.readFileSync(p, "utf8")) as Artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(key: string, artifact: Artifact): void {
|
||||||
|
if (this.isUntitled(key)) {
|
||||||
|
this.memory.set(key, artifact);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = this.sidecarPath(key)!;
|
||||||
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
|
fs.writeFileSync(p, serializeArtifact(artifact), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** See CoauthorStore.update — same INV-16 throw + anchor prune, no self-write mark. */
|
||||||
|
update(key: string, mutate: (artifact: Artifact) => void): Artifact {
|
||||||
|
const artifact = this.load(key) ?? emptyArtifact(key);
|
||||||
|
if (isNewerMajor(artifact)) {
|
||||||
|
throw new Error(
|
||||||
|
`refusing to write ${key}: sidecar schemaVersion ${artifact.schemaVersion} > supported ${SCHEMA_VERSION} (INV-16)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
mutate(artifact);
|
||||||
|
const referenced = new Set<string>([
|
||||||
|
...artifact.threads.map((t) => t.anchorId),
|
||||||
|
...artifact.attributions.map((a) => a.anchorId),
|
||||||
|
...artifact.proposals.map((p) => p.anchorId),
|
||||||
|
]);
|
||||||
|
for (const id of Object.keys(artifact.anchors)) {
|
||||||
|
if (!referenced.has(id)) delete artifact.anchors[id];
|
||||||
|
}
|
||||||
|
this.save(key, artifact);
|
||||||
|
return artifact;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-op: global artifacts are outside the `.threads/` watcher. */
|
||||||
|
consumeSelfWrite(_fsPath: string): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-14
@@ -10,13 +10,13 @@
|
|||||||
* Claude-attributed with zero new attribution code.
|
* Claude-attributed with zero new attribution code.
|
||||||
*/
|
*/
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { CoauthorStore } from "./store";
|
import { SidecarRouter, docIdentity } from "./sidecarRouter";
|
||||||
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
|
import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Provenance } from "./model";
|
||||||
import { resolve, shift, type OffsetRange } from "./anchorer";
|
import { resolve, shift, type OffsetRange } from "./anchorer";
|
||||||
import { addProposal, proposalBody, removeProposal } from "./proposalModel";
|
import { addProposal, proposalBody, removeProposal } from "./proposalModel";
|
||||||
import type { AttributionController } from "./attributionController";
|
import type { AttributionController } from "./attributionController";
|
||||||
import type { VersionGuard } from "./versionGuard";
|
import type { VersionGuard } from "./versionGuard";
|
||||||
import { isUnderRoot } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
|
|
||||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||||
export interface RenderedProposal {
|
export interface RenderedProposal {
|
||||||
@@ -54,9 +54,9 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89);
|
private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 89);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly store: CoauthorStore,
|
private readonly store: SidecarRouter,
|
||||||
private readonly attribution: AttributionController,
|
private readonly attribution: AttributionController,
|
||||||
private readonly rootDir: string,
|
private readonly rootDir: string | undefined,
|
||||||
private readonly guard: VersionGuard,
|
private readonly guard: VersionGuard,
|
||||||
) {
|
) {
|
||||||
// No commentingRangeProvider: humans never open proposal threads by hand —
|
// No commentingRangeProvider: humans never open proposal threads by hand —
|
||||||
@@ -71,13 +71,14 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isTracked(document: vscode.TextDocument): boolean {
|
private isTracked(document: vscode.TextDocument): boolean {
|
||||||
return document.uri.scheme === "file" && isUnderRoot(document.uri.fsPath, this.rootDir);
|
return isAuthorable(document.uri.scheme);
|
||||||
}
|
}
|
||||||
private docPathOf(uri: vscode.Uri): string {
|
/** The single document key (F8): repo-relative path in-workspace, URI string otherwise. */
|
||||||
return vscode.workspace.asRelativePath(uri, false);
|
private keyOf(document: vscode.TextDocument): string {
|
||||||
|
return this.store.keyOf(docIdentity(document));
|
||||||
}
|
}
|
||||||
private ensureState(document: vscode.TextDocument): DocState {
|
private ensureState(document: vscode.TextDocument): DocState {
|
||||||
const docPath = this.docPathOf(document.uri);
|
const docPath = this.keyOf(document);
|
||||||
let state = this.docs.get(docPath);
|
let state = this.docs.get(docPath);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = {
|
state = {
|
||||||
@@ -108,8 +109,8 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
opts?: { turnId?: string; instruction?: string },
|
opts?: { turnId?: string; instruction?: string },
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
if (!this.isTracked(document)) return undefined;
|
if (!this.isTracked(document)) return undefined;
|
||||||
if (this.guard.isReadOnly(this.docPathOf(document.uri))) return undefined;
|
if (this.guard.isReadOnly(this.keyOf(document))) return undefined;
|
||||||
const docPath = this.docPathOf(document.uri);
|
const docPath = this.keyOf(document);
|
||||||
let proposalId: string | undefined;
|
let proposalId: string | undefined;
|
||||||
this.store.update(docPath, (a) => {
|
this.store.update(docPath, (a) => {
|
||||||
proposalId = addProposal(a, fp, replacement, author, opts).proposalId;
|
proposalId = addProposal(a, fp, replacement, author, opts).proposalId;
|
||||||
@@ -185,7 +186,7 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
/** Load + (re)render every pending proposal at its resolved anchor (or flagged). */
|
/** Load + (re)render every pending proposal at its resolved anchor (or flagged). */
|
||||||
renderAll(document: vscode.TextDocument): void {
|
renderAll(document: vscode.TextDocument): void {
|
||||||
if (!this.isTracked(document)) return;
|
if (!this.isTracked(document)) return;
|
||||||
const docPath = this.docPathOf(document.uri);
|
const docPath = this.keyOf(document);
|
||||||
const state = this.ensureState(document);
|
const state = this.ensureState(document);
|
||||||
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
||||||
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
|
for (const vsThread of state.vsThreads.values()) vsThread.dispose();
|
||||||
@@ -212,14 +213,14 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
handleExternalSidecarChange(uri: vscode.Uri): void {
|
handleExternalSidecarChange(uri: vscode.Uri): void {
|
||||||
for (const state of this.docs.values()) {
|
for (const state of this.docs.values()) {
|
||||||
if (this.store.sidecarPath(state.docPath) === uri.fsPath) {
|
if (this.store.sidecarPath(state.docPath) === uri.fsPath) {
|
||||||
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
|
const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath);
|
||||||
if (doc) this.renderAll(doc);
|
if (doc) this.renderAll(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||||
const state = this.docs.get(this.docPathOf(e.document.uri));
|
const state = this.docs.get(this.keyOf(e.document));
|
||||||
if (!state || state.live.size === 0) return;
|
if (!state || state.live.size === 0) return;
|
||||||
for (const change of e.contentChanges) {
|
for (const change of e.contentChanges) {
|
||||||
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
|
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
|
||||||
@@ -298,7 +299,7 @@ export class ProposalController implements vscode.Disposable {
|
|||||||
// ---- lookups ----------------------------------------------------------------------------
|
// ---- lookups ----------------------------------------------------------------------------
|
||||||
|
|
||||||
private openDoc(state: DocState): vscode.TextDocument | undefined {
|
private openDoc(state: DocState): vscode.TextDocument | undefined {
|
||||||
return vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
|
return vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath);
|
||||||
}
|
}
|
||||||
private byThread(vsThread: vscode.CommentThread): { state: DocState; proposal: Proposal } | undefined {
|
private byThread(vsThread: vscode.CommentThread): { state: DocState; proposal: Proposal } | undefined {
|
||||||
for (const state of this.docs.values()) {
|
for (const state of this.docs.values()) {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* SidecarRouter — the per-document persistence façade the three authoring
|
||||||
|
* controllers depend on (F8 spec §6.2/§6.4). Implements SidecarStore and OWNS
|
||||||
|
* the routing: it computes the single document key (keyOf) and dispatches
|
||||||
|
* load/save/update/consumeSelfWrite/sidecarPath to the right implementation:
|
||||||
|
* - an in-workspace `file:` doc → CoauthorStore, keyed by its repo-relative
|
||||||
|
* path → `.threads/<path>.json` (committable, INV-2; the only home F5's
|
||||||
|
* cross-rung ever sees).
|
||||||
|
* - an out-of-workspace `file:` or `untitled:` doc → GlobalSidecarStore, keyed
|
||||||
|
* by its URI string → global storage (INV-19/24/25; untitled in-memory).
|
||||||
|
* The membership predicate is `#24`'s isUnderRoot — here a ROUTING input, never
|
||||||
|
* an authoring gate (that became isAuthorable). vscode-free (takes the extracted
|
||||||
|
* identity, not a vscode.TextDocument) so it unit-tests.
|
||||||
|
*/
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { isUnderRoot } from "./workspacePath";
|
||||||
|
import type { Artifact } from "./model";
|
||||||
|
import type { SidecarStore } from "./sidecarStore";
|
||||||
|
|
||||||
|
/** The minimal document identity the router routes on (extracted from a TextDocument). */
|
||||||
|
export interface DocIdentity {
|
||||||
|
/** `document.uri.toString()` — the URI string. */
|
||||||
|
uri: string;
|
||||||
|
/** `document.uri.fsPath`. */
|
||||||
|
fsPath: string;
|
||||||
|
/** `document.uri.scheme`. */
|
||||||
|
scheme: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A SidecarStore that can also report a sidecar's on-disk path (CoauthorStore, GlobalSidecarStore). */
|
||||||
|
type LocatableStore = SidecarStore & { sidecarPath(key: string): string | undefined };
|
||||||
|
|
||||||
|
/** Extract the routing identity from anything URI-shaped (a real vscode.TextDocument fits). */
|
||||||
|
export function docIdentity(doc: { uri: { toString(): string; fsPath: string; scheme: string } }): DocIdentity {
|
||||||
|
return { uri: doc.uri.toString(), fsPath: doc.uri.fsPath, scheme: doc.uri.scheme };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SidecarRouter implements SidecarStore {
|
||||||
|
constructor(
|
||||||
|
private readonly repo: LocatableStore | null,
|
||||||
|
private readonly global: LocatableStore,
|
||||||
|
private readonly root: string | undefined,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single document key: an in-workspace `file:` doc → its repo-relative
|
||||||
|
* path (cross-rung-meaningful, the existing sidecar key, INV-2); everything
|
||||||
|
* else → its URI string (machine-local, self-consistent — INV-25).
|
||||||
|
*/
|
||||||
|
keyOf(id: DocIdentity): string {
|
||||||
|
if (id.scheme === "file" && this.root !== undefined && isUnderRoot(id.fsPath, this.root)) {
|
||||||
|
return path.relative(this.root, id.fsPath);
|
||||||
|
}
|
||||||
|
return id.uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A key is a global (URI-string) key iff it carries a URI scheme prefix. */
|
||||||
|
private isGlobalKey(key: string): boolean {
|
||||||
|
return key.startsWith("file://") || key.startsWith("untitled:");
|
||||||
|
}
|
||||||
|
private storeFor(key: string): LocatableStore {
|
||||||
|
if (this.isGlobalKey(key) || this.repo === null) return this.global;
|
||||||
|
return this.repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
load(key: string): Artifact | null {
|
||||||
|
return this.storeFor(key).load(key);
|
||||||
|
}
|
||||||
|
save(key: string, artifact: Artifact): void {
|
||||||
|
this.storeFor(key).save(key, artifact);
|
||||||
|
}
|
||||||
|
update(key: string, mutate: (artifact: Artifact) => void): Artifact {
|
||||||
|
return this.storeFor(key).update(key, mutate);
|
||||||
|
}
|
||||||
|
/** Only the repo store self-writes (the `.threads/` watcher); global is a no-op. */
|
||||||
|
consumeSelfWrite(fsPath: string): boolean {
|
||||||
|
return this.repo?.consumeSelfWrite(fsPath) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sidecar's on-disk path: `.threads/<key>.json` for a repo key, the hashed
|
||||||
|
* global path for an out-of-folder `file:` key, undefined for untitled. Used by
|
||||||
|
* the controllers' external-change handler (only repo sidecars are watched) and
|
||||||
|
* by the E2E to assert the storage home.
|
||||||
|
*/
|
||||||
|
sidecarPath(key: string): string | undefined {
|
||||||
|
return this.storeFor(key).sidecarPath(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* SidecarStore — the storage surface the three authoring controllers depend on
|
||||||
|
* (F8 spec §6.2/§6.4). One per-document `Artifact` keyed by a string `key` (the
|
||||||
|
* document key — repo-relative path in-workspace, URI string otherwise; the
|
||||||
|
* router's keyOf is the single source). Two implementations:
|
||||||
|
* - CoauthorStore (src/store.ts) — the committable repo `.threads/` sidecar,
|
||||||
|
* keyed by repo-relative path (INV-2, byte-for-byte unchanged; conforms
|
||||||
|
* structurally — not modified for F8).
|
||||||
|
* - GlobalSidecarStore (src/globalSidecarStore.ts) — out-of-workspace `file:`
|
||||||
|
* and `untitled:` docs, in VS Code GLOBAL storage keyed by sha256(uri)
|
||||||
|
* (INV-19/24/25; untitled in-memory only).
|
||||||
|
* SidecarRouter (src/sidecarRouter.ts) implements this AND adds keyOf/sidecarPath;
|
||||||
|
* the controllers receive the router.
|
||||||
|
*/
|
||||||
|
import type { Artifact } from "./model";
|
||||||
|
|
||||||
|
export interface SidecarStore {
|
||||||
|
/** Load the artifact for `key`, or null if none persisted. */
|
||||||
|
load(key: string): Artifact | null;
|
||||||
|
/** Overwrite the artifact for `key` (newest wins, no history). */
|
||||||
|
save(key: string, artifact: Artifact): void;
|
||||||
|
/**
|
||||||
|
* Read-modify-write: load (or empty), apply `mutate`, prune anchors referenced
|
||||||
|
* by no thread/attribution/proposal, persist, return the result. MUST be
|
||||||
|
* synchronous (the F3 co-ownership contract). Throws on a newer-major sidecar
|
||||||
|
* (INV-16 backstop).
|
||||||
|
*/
|
||||||
|
update(key: string, mutate: (artifact: Artifact) => void): Artifact;
|
||||||
|
/**
|
||||||
|
* Decrement-and-report whether `fsPath` is a sidecar this store just wrote
|
||||||
|
* (repo sidecars only; the global store is outside the `.threads/` watcher so
|
||||||
|
* its impl is a no-op returning false).
|
||||||
|
*/
|
||||||
|
consumeSelfWrite(fsPath: string): boolean;
|
||||||
|
}
|
||||||
+16
-19
@@ -8,13 +8,13 @@
|
|||||||
* orphaned thread (INV-1).
|
* orphaned thread (INV-1).
|
||||||
*/
|
*/
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { CoauthorStore } from "./store";
|
import { SidecarRouter, docIdentity } from "./sidecarRouter";
|
||||||
import { emptyArtifact, type Artifact, type Provenance } from "./model";
|
import { emptyArtifact, type Artifact, type Provenance } from "./model";
|
||||||
import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer";
|
import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer";
|
||||||
import { gitUserEmail } from "./identity";
|
import { gitUserEmail } from "./identity";
|
||||||
import { addThread, appendMessage, setStatus } from "./threadModel";
|
import { addThread, appendMessage, setStatus } from "./threadModel";
|
||||||
import type { VersionGuard } from "./versionGuard";
|
import type { VersionGuard } from "./versionGuard";
|
||||||
import { isUnderRoot } from "./workspacePath";
|
import { isAuthorable } from "./workspacePath";
|
||||||
|
|
||||||
/** Test-facing snapshot of what is currently rendered for a document. */
|
/** Test-facing snapshot of what is currently rendered for a document. */
|
||||||
export interface RenderedThread {
|
export interface RenderedThread {
|
||||||
@@ -42,14 +42,14 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
private readonly docs = new Map<string, DocState>(); // keyed by docPath
|
private readonly docs = new Map<string, DocState>(); // keyed by docPath
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly store: CoauthorStore,
|
private readonly store: SidecarRouter,
|
||||||
private readonly rootDir: string,
|
private readonly rootDir: string | undefined,
|
||||||
private readonly guard: VersionGuard,
|
private readonly guard: VersionGuard,
|
||||||
) {
|
) {
|
||||||
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
|
||||||
this.controller.commentingRangeProvider = {
|
this.controller.commentingRangeProvider = {
|
||||||
provideCommentingRanges: (document) => {
|
provideCommentingRanges: (document) => {
|
||||||
if (!this.isInRoot(document.uri)) return [];
|
if (!isAuthorable(document.uri.scheme)) return [];
|
||||||
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
|
return [new vscode.Range(0, 0, Math.max(0, document.lineCount - 1), 0)];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -69,17 +69,14 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isInRoot(uri: vscode.Uri): boolean {
|
/** The single document key (F8): repo-relative path in-workspace, URI string otherwise. */
|
||||||
return uri.scheme === "file" && isUnderRoot(uri.fsPath, this.rootDir);
|
private keyOf(document: vscode.TextDocument): string {
|
||||||
}
|
return this.store.keyOf(docIdentity(document));
|
||||||
|
|
||||||
private docPathOf(uri: vscode.Uri): string {
|
|
||||||
return vscode.workspace.asRelativePath(uri, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private currentAuthor(): Provenance {
|
private currentAuthor(): Provenance {
|
||||||
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
|
const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
|
||||||
const email = gitUserEmail(this.rootDir);
|
const email = this.rootDir !== undefined ? gitUserEmail(this.rootDir) : undefined;
|
||||||
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
|
return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +90,7 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private ensureState(document: vscode.TextDocument): DocState {
|
private ensureState(document: vscode.TextDocument): DocState {
|
||||||
const docPath = this.docPathOf(document.uri);
|
const docPath = this.keyOf(document);
|
||||||
let state = this.docs.get(docPath);
|
let state = this.docs.get(docPath);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
state = {
|
state = {
|
||||||
@@ -113,8 +110,8 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
|
|
||||||
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
|
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
|
||||||
const editor = vscode.window.activeTextEditor;
|
const editor = vscode.window.activeTextEditor;
|
||||||
if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined;
|
if (!editor || editor.selection.isEmpty || !isAuthorable(editor.document.uri.scheme)) return undefined;
|
||||||
if (this.guard.isReadOnly(this.docPathOf(editor.document.uri))) return undefined;
|
if (this.guard.isReadOnly(this.keyOf(editor.document))) return undefined;
|
||||||
const document = editor.document;
|
const document = editor.document;
|
||||||
const state = this.ensureState(document);
|
const state = this.ensureState(document);
|
||||||
const offsets: OffsetRange = {
|
const offsets: OffsetRange = {
|
||||||
@@ -155,7 +152,7 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
|
|
||||||
/** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */
|
/** Load + (re)render every thread for a document at its resolved anchor (or orphaned). */
|
||||||
renderAll(document: vscode.TextDocument): void {
|
renderAll(document: vscode.TextDocument): void {
|
||||||
const docPath = this.docPathOf(document.uri);
|
const docPath = this.keyOf(document);
|
||||||
const state = this.ensureState(document);
|
const state = this.ensureState(document);
|
||||||
// fresh artifact from disk (reload / external change)
|
// fresh artifact from disk (reload / external change)
|
||||||
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
state.artifact = this.store.load(docPath) ?? emptyArtifact(docPath);
|
||||||
@@ -182,14 +179,14 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
const p = uri.fsPath;
|
const p = uri.fsPath;
|
||||||
for (const state of this.docs.values()) {
|
for (const state of this.docs.values()) {
|
||||||
if (this.store.sidecarPath(state.docPath) === p) {
|
if (this.store.sidecarPath(state.docPath) === p) {
|
||||||
const doc = vscode.workspace.textDocuments.find((d) => this.docPathOf(d.uri) === state.docPath);
|
const doc = vscode.workspace.textDocuments.find((d) => this.keyOf(d) === state.docPath);
|
||||||
if (doc) this.renderAll(doc);
|
if (doc) this.renderAll(doc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
private onDidChange(e: vscode.TextDocumentChangeEvent): void {
|
||||||
const state = this.docs.get(this.docPathOf(e.document.uri));
|
const state = this.docs.get(this.keyOf(e.document));
|
||||||
if (!state || state.live.size === 0) return;
|
if (!state || state.live.size === 0) return;
|
||||||
for (const change of e.contentChanges) {
|
for (const change of e.contentChanges) {
|
||||||
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
|
const edit = { start: change.rangeOffset, end: change.rangeOffset + change.rangeLength, newLength: change.text.length };
|
||||||
@@ -205,7 +202,7 @@ export class ThreadController implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onDidSave(document: vscode.TextDocument): void {
|
private onDidSave(document: vscode.TextDocument): void {
|
||||||
const state = this.docs.get(this.docPathOf(document.uri));
|
const state = this.docs.get(this.keyOf(document));
|
||||||
if (!state || state.live.size === 0) return;
|
if (!state || state.live.size === 0) return;
|
||||||
if (this.guard.isReadOnly(state.docPath)) return;
|
if (this.guard.isReadOnly(state.docPath)) return;
|
||||||
const text = document.getText();
|
const text = document.getText();
|
||||||
|
|||||||
+2
-2
@@ -8,12 +8,12 @@
|
|||||||
*/
|
*/
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { SCHEMA_VERSION, isNewerMajor } from "./model";
|
import { SCHEMA_VERSION, isNewerMajor } from "./model";
|
||||||
import type { CoauthorStore } from "./store";
|
import type { SidecarStore } from "./sidecarStore";
|
||||||
|
|
||||||
export class VersionGuard {
|
export class VersionGuard {
|
||||||
private readonly warned = new Set<string>();
|
private readonly warned = new Set<string>();
|
||||||
|
|
||||||
constructor(private readonly store: CoauthorStore) {}
|
constructor(private readonly store: SidecarStore) {}
|
||||||
|
|
||||||
/** True → skip the write path (and warn once per doc). */
|
/** True → skip the write path (and warn once per doc). */
|
||||||
isReadOnly(docPath: string): boolean {
|
isReadOnly(docPath: string): boolean {
|
||||||
|
|||||||
+19
-13
@@ -20,6 +20,17 @@ export function isUnderRoot(fsPath: string, root: string): boolean {
|
|||||||
return fsPath === root || fsPath.startsWith(root + path.sep);
|
return fsPath === root || fsPath.startsWith(root + path.sep);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents Cowriting can author on: a saved file OR an unsaved buffer (F8 §6.2).
|
||||||
|
* F8 widened membership from "saved file under the workspace folder" to any
|
||||||
|
* `file:`/`untitled:` doc — the SidecarRouter then routes where its artifact is
|
||||||
|
* stored (repo `.threads/` in-workspace, global storage otherwise). `isUnderRoot`
|
||||||
|
* (above) is retained as that ROUTING input, no longer an authoring gate.
|
||||||
|
*/
|
||||||
|
export function isAuthorable(scheme: string): boolean {
|
||||||
|
return scheme === "file" || scheme === "untitled";
|
||||||
|
}
|
||||||
|
|
||||||
export interface SelectionContext {
|
export interface SelectionContext {
|
||||||
/** Is there an active text editor at all? */
|
/** Is there an active text editor at all? */
|
||||||
hasEditor: boolean;
|
hasEditor: boolean;
|
||||||
@@ -27,17 +38,15 @@ export interface SelectionContext {
|
|||||||
selectionEmpty: boolean;
|
selectionEmpty: boolean;
|
||||||
/** The active document's URI scheme (`file`, `untitled`, …). */
|
/** The active document's URI scheme (`file`, `untitled`, …). */
|
||||||
scheme: string;
|
scheme: string;
|
||||||
/** The active document's filesystem path (empty if none). */
|
|
||||||
fsPath: string;
|
|
||||||
/** The workspace folder Cowriting is anchored to. */
|
|
||||||
root: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Why a selection can't be sent to Claude — or `null` if it can. Each condition
|
* Why a selection can't be sent to Claude — or `null` if it can. F8 widened the
|
||||||
* gets its OWN message so the user learns the real reason instead of always
|
* membership: any `file:` or `untitled:` document is authorable (in-workspace
|
||||||
* being told to "select some text" (the reported bug: an out-of-workspace file
|
* files persist to the repo `.threads/` sidecar, out-of-workspace/untitled to
|
||||||
* was rejected with the no-selection message even though text was selected).
|
* global storage — the router decides). Only a non-{file,untitled} scheme (a
|
||||||
|
* read-only `git:`/`output:` view), a missing editor, or an empty selection is
|
||||||
|
* refused — each with its OWN message (#24's per-condition messaging).
|
||||||
*/
|
*/
|
||||||
export function selectionRejection(ctx: SelectionContext): string | null {
|
export function selectionRejection(ctx: SelectionContext): string | null {
|
||||||
if (!ctx.hasEditor) {
|
if (!ctx.hasEditor) {
|
||||||
@@ -46,11 +55,8 @@ export function selectionRejection(ctx: SelectionContext): string | null {
|
|||||||
if (ctx.selectionEmpty) {
|
if (ctx.selectionEmpty) {
|
||||||
return "Cowriting: select some text to send to Claude first.";
|
return "Cowriting: select some text to send to Claude first.";
|
||||||
}
|
}
|
||||||
if (ctx.scheme !== "file") {
|
if (!isAuthorable(ctx.scheme)) {
|
||||||
return "Cowriting: save this document to a file first — Cowriting anchors edits to a saved workspace file.";
|
return "Cowriting: this kind of document can't be edited — Cowriting authors on a file or an untitled buffer, not a read-only view.";
|
||||||
}
|
|
||||||
if (!isUnderRoot(ctx.fsPath, ctx.root)) {
|
|
||||||
return "Cowriting: this file is outside your workspace folder — open a file inside it to ask Claude to edit (Cowriting anchors edits and attribution to files in the workspace).";
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
import * as assert from "assert";
|
import * as assert from "assert";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
|
import type { CowritingApi } from "../../../src/extension";
|
||||||
|
|
||||||
// Regression for #8: with NO workspace folder open, activate() used to return
|
// F8: with NO workspace folder open, authoring used to be stubbed (#8 registered
|
||||||
// before registering the F2/F3 commands, so the palette errored with
|
// warning stubs and activate() returned undefined). F8 makes the authoring
|
||||||
// "command 'cowriting.editSelection' not found". The fix registers warning
|
// commands REAL folder-less (the F6 #19 precedent), routing every doc to global
|
||||||
// stubs instead. This suite runs in a second EDH pass launched WITHOUT a
|
// storage — so activate() returns a real API and propose→accept works on an
|
||||||
// folder (see runTest.ts).
|
// untitled buffer with no folder. This suite runs in a second EDH pass launched
|
||||||
|
// WITHOUT a folder (see runTest.ts).
|
||||||
|
|
||||||
suite("no-workspace activation (#8)", () => {
|
suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => {
|
||||||
test("EDH really has no workspace folder", () => {
|
test("EDH really has no workspace folder", () => {
|
||||||
assert.strictEqual(vscode.workspace.workspaceFolders, undefined);
|
assert.strictEqual(vscode.workspace.workspaceFolders, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("all contributed coauthoring commands are registered as warning stubs", async () => {
|
test("activate returns a real API even with no workspace folder (F8)", async () => {
|
||||||
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||||
const api = await ext.activate();
|
const api = (await ext.activate()) as CowritingApi;
|
||||||
assert.strictEqual(api, undefined, "no-root activation returns no API");
|
assert.ok(api?.proposalController, "no-folder activation returns the authoring API (F8)");
|
||||||
|
assert.ok(api?.sidecarRouter, "router exposed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("all contributed coauthoring commands are registered (real, not stubs)", async () => {
|
||||||
const all = await vscode.commands.getCommands(true);
|
const all = await vscode.commands.getCommands(true);
|
||||||
for (const command of [
|
for (const command of [
|
||||||
"cowriting.createThread",
|
"cowriting.createThread",
|
||||||
@@ -33,9 +39,32 @@ suite("no-workspace activation (#8)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("invoking a stub does not throw (shows a warning instead)", async () => {
|
test("authoring works folder-less: propose→accept on an untitled buffer routes to global storage (F8)", async () => {
|
||||||
await vscode.commands.executeCommand("cowriting.editSelection");
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||||
await vscode.commands.executeCommand("cowriting.createThread");
|
const api = (await ext.activate()) as CowritingApi;
|
||||||
|
const untitled = await vscode.workspace.openTextDocument({
|
||||||
|
content: "Edit this scratch sentence please.\n",
|
||||||
|
language: "markdown",
|
||||||
|
});
|
||||||
|
await vscode.window.showTextDocument(untitled);
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
const key = untitled.uri.toString();
|
||||||
|
const target = "Edit this scratch sentence please.";
|
||||||
|
const start = untitled.getText().indexOf(target);
|
||||||
|
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||||
|
uri: key,
|
||||||
|
start,
|
||||||
|
end: start + target.length,
|
||||||
|
newText: "REPLACED scratch sentence.",
|
||||||
|
model: "sonnet",
|
||||||
|
sessionId: "e2e-nf",
|
||||||
|
turnId: "turn-nf",
|
||||||
|
});
|
||||||
|
assert.ok(id, "propose returns an id for an untitled buffer with no folder");
|
||||||
|
assert.ok(await api.proposalController.acceptById(key, id!), "accept applies");
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
assert.ok(untitled.getText().includes("REPLACED scratch sentence."), "replacement landed in the untitled buffer");
|
||||||
|
assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)");
|
||||||
});
|
});
|
||||||
|
|
||||||
// F6 (#19) is workspace-INDEPENDENT: its commands are real (not stubs) even
|
// F6 (#19) is workspace-INDEPENDENT: its commands are real (not stubs) even
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import * as assert from "assert";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import type { CowritingApi } from "../../../src/extension";
|
||||||
|
|
||||||
|
const WS = process.env.E2E_WORKSPACE!;
|
||||||
|
const settle = () => new Promise((r) => setTimeout(r, 300));
|
||||||
|
|
||||||
|
async function getApi(): Promise<CowritingApi> {
|
||||||
|
const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
|
||||||
|
const api = (await ext.activate()) as CowritingApi;
|
||||||
|
assert.ok(api?.proposalController && api?.sidecarRouter, "extension exports the authoring API + router");
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
async function proposeViaCommand(uri: string, text: string, target: string, newText: string, turnId: string): Promise<string> {
|
||||||
|
const start = text.indexOf(target);
|
||||||
|
assert.ok(start >= 0, `text contains "${target}"`);
|
||||||
|
const id = await vscode.commands.executeCommand<string>("cowriting.proposeAgentEdit", {
|
||||||
|
uri, start, end: start + target.length, newText, model: "sonnet", sessionId: "e2e-oow", turnId,
|
||||||
|
});
|
||||||
|
assert.ok(id, "propose returns the proposal id");
|
||||||
|
return id!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// F8 host E2E (no LLM): authoring on a file OUTSIDE the workspace folder and on
|
||||||
|
// an untitled buffer, plus an in-workspace byte-for-byte regression. Runs in the
|
||||||
|
// WITH-workspace EDH pass (a folder is open; the out-of-folder file lives in a
|
||||||
|
// temp dir outside it — exactly like diffView's out-of-folder test).
|
||||||
|
suite("F8 out-of-workspace authoring (host E2E — programmatic seam, no LLM)", () => {
|
||||||
|
test("out-of-folder file: propose→accept lands Claude-attributed; persisted in GLOBAL storage, no .threads (PUC-1, INV-9/24/25)", async () => {
|
||||||
|
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-oow-"));
|
||||||
|
const outsidePath = path.join(outsideDir, "sibling.md");
|
||||||
|
const TARGET = "The sibling-repo sentence Claude edits.";
|
||||||
|
fs.writeFileSync(outsidePath, `# Sibling\n\n${TARGET}\n`, "utf8");
|
||||||
|
const uri = vscode.Uri.file(outsidePath);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
await settle();
|
||||||
|
const api = await getApi();
|
||||||
|
const key = uri.toString();
|
||||||
|
|
||||||
|
const id = await proposeViaCommand(key, doc.getText(), TARGET, "The sibling sentence CLAUDE REWROTE.", "turn-oow1");
|
||||||
|
await settle();
|
||||||
|
// routed to global storage, never the repo
|
||||||
|
const sidecar = api.sidecarRouter.sidecarPath(key)!;
|
||||||
|
assert.ok(sidecar && fs.existsSync(sidecar), `global sidecar exists at ${sidecar}`);
|
||||||
|
assert.ok(sidecar.includes(`${path.sep}sidecars${path.sep}`), "lives under <globalStorage>/sidecars/");
|
||||||
|
assert.ok(!sidecar.includes(`${path.sep}.threads${path.sep}`), "NOT in a .threads/ tree (INV-25)");
|
||||||
|
assert.ok(!fs.existsSync(path.join(outsideDir, ".threads")), "no .threads/ written beside the out-of-folder file");
|
||||||
|
|
||||||
|
assert.ok(await api.proposalController.acceptById(key, id), "accept applies via the seam");
|
||||||
|
await settle();
|
||||||
|
assert.ok(doc.getText().includes("The sibling sentence CLAUDE REWROTE."), "replacement landed");
|
||||||
|
const agent = api.attributionController.getSpans(key).find((s) => s.turnId === "turn-oow1");
|
||||||
|
assert.ok(agent && agent.authorKind === "agent", "accepted text is Claude-attributed");
|
||||||
|
assert.strictEqual(api.proposalController.getRendered(key).length, 0, "proposal gone (INV-13)");
|
||||||
|
|
||||||
|
// a thread can be opened on the out-of-folder doc
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
const editor = vscode.window.activeTextEditor!;
|
||||||
|
editor.selection = new vscode.Selection(doc.positionAt(0), doc.positionAt(8));
|
||||||
|
const threadId = await api.threadController.createThreadOnSelection("thread on a sibling file");
|
||||||
|
assert.ok(threadId, "a thread opens on the out-of-folder doc");
|
||||||
|
|
||||||
|
// reload-restore from the GLOBAL sidecar (re-anchor content-based)
|
||||||
|
fs.writeFileSync(outsidePath, "PREPENDED\n\n" + doc.getText(), "utf8");
|
||||||
|
const reloaded = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(reloaded);
|
||||||
|
await vscode.commands.executeCommand("workbench.action.files.revert");
|
||||||
|
await settle();
|
||||||
|
api.threadController.renderAll(reloaded);
|
||||||
|
assert.strictEqual(api.threadController.getRendered(key).length, 1, "thread restored from the global sidecar after reload");
|
||||||
|
|
||||||
|
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("untitled buffer: propose→accept works in-session, state in-memory only (PUC-2)", async () => {
|
||||||
|
const TARGET = "The untitled draft sentence.";
|
||||||
|
const untitled = await vscode.workspace.openTextDocument({ content: `${TARGET}\n`, language: "markdown" });
|
||||||
|
await vscode.window.showTextDocument(untitled);
|
||||||
|
await settle();
|
||||||
|
const api = await getApi();
|
||||||
|
const key = untitled.uri.toString();
|
||||||
|
assert.strictEqual(untitled.uri.scheme, "untitled", "really untitled");
|
||||||
|
|
||||||
|
const id = await proposeViaCommand(key, untitled.getText(), TARGET, "The untitled draft, CLAUDE-EDITED.", "turn-oow2");
|
||||||
|
await settle();
|
||||||
|
assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)");
|
||||||
|
assert.ok(await api.proposalController.acceptById(key, id), "accept applies");
|
||||||
|
await settle();
|
||||||
|
assert.ok(untitled.getText().includes("The untitled draft, CLAUDE-EDITED."), "replacement landed in the untitled buffer");
|
||||||
|
const agent = api.attributionController.getSpans(key).find((s) => s.turnId === "turn-oow2");
|
||||||
|
assert.ok(agent && agent.authorKind === "agent", "untitled accepted text is Claude-attributed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("in-workspace regression: artifact still lands at <root>/.threads/<repo-rel>.json with the repo-relative document.path (INV-2)", async () => {
|
||||||
|
const DOC_REL = "docs/f8regression.md";
|
||||||
|
const TARGET = "An in-folder sentence for the F8 regression.";
|
||||||
|
const abs = path.join(WS, DOC_REL);
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
fs.writeFileSync(abs, `# F8 regression\n\n${TARGET}\n`, "utf8");
|
||||||
|
const uri = vscode.Uri.file(abs);
|
||||||
|
const doc = await vscode.workspace.openTextDocument(uri);
|
||||||
|
await vscode.window.showTextDocument(doc);
|
||||||
|
await settle();
|
||||||
|
const api = await getApi();
|
||||||
|
|
||||||
|
// the in-workspace key is the repo-relative path, not the URI string
|
||||||
|
assert.strictEqual(
|
||||||
|
api.sidecarRouter.keyOf({ uri: uri.toString(), fsPath: abs, scheme: "file" }),
|
||||||
|
DOC_REL,
|
||||||
|
"in-folder key is the repo-relative path",
|
||||||
|
);
|
||||||
|
const id = await proposeViaCommand(uri.toString(), doc.getText(), TARGET, "An in-folder sentence, EDITED.", "turn-oow3");
|
||||||
|
await settle();
|
||||||
|
assert.ok(await api.proposalController.acceptById(DOC_REL, id), "accept by the repo-relative key");
|
||||||
|
await settle();
|
||||||
|
const sidecar = api.sidecarRouter.sidecarPath(DOC_REL)!;
|
||||||
|
assert.strictEqual(sidecar, path.join(WS, ".threads", "docs", "f8regression.md.json"), "committable .threads/ path unchanged (INV-2)");
|
||||||
|
assert.ok(fs.existsSync(sidecar), "sidecar written in the repo");
|
||||||
|
const onDisk = JSON.parse(fs.readFileSync(sidecar, "utf8"));
|
||||||
|
assert.strictEqual(onDisk.document.path, DOC_REL, "document.path is the repo-relative path (byte-for-byte)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { GlobalSidecarStore } from "../src/globalSidecarStore";
|
||||||
|
import { emptyArtifact, SCHEMA_VERSION, type Artifact } from "../src/model";
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), "global-sidecar-"));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const FILE_URI = "file:///abs/notes/chapter-1.md";
|
||||||
|
const hash = (uri: string) => createHash("sha256").update(uri).digest("hex");
|
||||||
|
|
||||||
|
function withThread(uri: string): Artifact {
|
||||||
|
const a = emptyArtifact(uri);
|
||||||
|
a.anchors["an_1"] = { fingerprint: { text: "hi", before: "", after: "", lineHint: 0 } };
|
||||||
|
a.threads.push({ id: "t_1", anchorId: "an_1", status: "open", messages: [] });
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GlobalSidecarStore — file: URIs persist to <dir>/sidecars/<sha256(uri)>.json", () => {
|
||||||
|
it("returns null for a key with no sidecar", () => {
|
||||||
|
expect(new GlobalSidecarStore(dir).load(FILE_URI)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("save then load round-trips the artifact at the hashed path", () => {
|
||||||
|
const store = new GlobalSidecarStore(dir);
|
||||||
|
const a = withThread(FILE_URI);
|
||||||
|
store.save(FILE_URI, a);
|
||||||
|
const onDisk = path.join(dir, "sidecars", `${hash(FILE_URI)}.json`);
|
||||||
|
expect(fs.existsSync(onDisk)).toBe(true);
|
||||||
|
expect(store.load(FILE_URI)).toEqual(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sidecarPath returns the hashed disk path for a file: URI", () => {
|
||||||
|
const store = new GlobalSidecarStore(dir);
|
||||||
|
expect(store.sidecarPath(FILE_URI)).toBe(path.join(dir, "sidecars", `${hash(FILE_URI)}.json`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites in place (newest wins, no history)", () => {
|
||||||
|
const store = new GlobalSidecarStore(dir);
|
||||||
|
store.save(FILE_URI, withThread(FILE_URI));
|
||||||
|
const empty = emptyArtifact(FILE_URI);
|
||||||
|
store.save(FILE_URI, empty);
|
||||||
|
expect(store.load(FILE_URI)).toEqual(empty);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumeSelfWrite is a no-op returning false (outside the .threads watcher)", () => {
|
||||||
|
const store = new GlobalSidecarStore(dir);
|
||||||
|
store.save(FILE_URI, withThread(FILE_URI));
|
||||||
|
expect(store.consumeSelfWrite(store.sidecarPath(FILE_URI)!)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update applies the mutation, prunes unreferenced anchors, and persists", () => {
|
||||||
|
const store = new GlobalSidecarStore(dir);
|
||||||
|
const result = store.update(FILE_URI, (a) => {
|
||||||
|
a.anchors["orphan"] = { fingerprint: { text: "x", before: "", after: "", lineHint: 0 } };
|
||||||
|
a.anchors["kept"] = { fingerprint: { text: "y", before: "", after: "", lineHint: 0 } };
|
||||||
|
a.threads.push({ id: "t", anchorId: "kept", status: "open", messages: [] });
|
||||||
|
});
|
||||||
|
expect(Object.keys(result.anchors)).toEqual(["kept"]); // "orphan" pruned
|
||||||
|
expect(store.load(FILE_URI)!.threads.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update throws on a newer-major sidecar (INV-16 backstop)", () => {
|
||||||
|
const store = new GlobalSidecarStore(dir);
|
||||||
|
const future = { ...emptyArtifact(FILE_URI), schemaVersion: SCHEMA_VERSION + 1 };
|
||||||
|
store.save(FILE_URI, future as Artifact);
|
||||||
|
expect(() => store.update(FILE_URI, () => {})).toThrow(/INV-16/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GlobalSidecarStore — untitled: keys are in-memory only (no disk, lost on reload)", () => {
|
||||||
|
const UNTITLED = "untitled:Untitled-1";
|
||||||
|
|
||||||
|
it("save then load round-trips within the same instance (session)", () => {
|
||||||
|
const store = new GlobalSidecarStore(dir);
|
||||||
|
const a = emptyArtifact(UNTITLED);
|
||||||
|
a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] });
|
||||||
|
a.anchors["an"] = { fingerprint: { text: "z", before: "", after: "", lineHint: 0 } };
|
||||||
|
store.save(UNTITLED, a);
|
||||||
|
expect(store.load(UNTITLED)).toEqual(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes NO disk file for an untitled key", () => {
|
||||||
|
const store = new GlobalSidecarStore(dir);
|
||||||
|
store.save(UNTITLED, emptyArtifact(UNTITLED));
|
||||||
|
expect(store.sidecarPath(UNTITLED)).toBeUndefined();
|
||||||
|
expect(fs.existsSync(path.join(dir, "sidecars"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a fresh instance (simulated reload) has no untitled state", () => {
|
||||||
|
const store = new GlobalSidecarStore(dir);
|
||||||
|
store.save(UNTITLED, emptyArtifact(UNTITLED));
|
||||||
|
expect(new GlobalSidecarStore(dir).load(UNTITLED)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { CoauthorStore } from "../src/store";
|
||||||
|
import { GlobalSidecarStore } from "../src/globalSidecarStore";
|
||||||
|
import { SidecarRouter, docIdentity } from "../src/sidecarRouter";
|
||||||
|
import { emptyArtifact } from "../src/model";
|
||||||
|
|
||||||
|
let root: string;
|
||||||
|
let globalDir: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "router-root-"));
|
||||||
|
globalDir = fs.mkdtempSync(path.join(os.tmpdir(), "router-global-"));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
fs.rmSync(globalDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeRouter(withRoot = true): SidecarRouter {
|
||||||
|
return new SidecarRouter(
|
||||||
|
withRoot ? new CoauthorStore(root) : null,
|
||||||
|
new GlobalSidecarStore(globalDir),
|
||||||
|
withRoot ? root : undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// minimal duck-typed stand-in for a vscode.TextDocument's identity inputs
|
||||||
|
const docOf = (uriString: string, fsPath: string, scheme: string) => ({
|
||||||
|
uri: { toString: () => uriString, fsPath, scheme },
|
||||||
|
});
|
||||||
|
const hash = (uri: string) => createHash("sha256").update(uri).digest("hex");
|
||||||
|
|
||||||
|
describe("SidecarRouter.keyOf — one identity per document", () => {
|
||||||
|
it("an in-root file: doc → its repo-relative path", () => {
|
||||||
|
const fsPath = path.join(root, "docs", "x.md");
|
||||||
|
const key = makeRouter().keyOf(docIdentity(docOf(`file://${fsPath}`, fsPath, "file")));
|
||||||
|
expect(key).toBe(path.join("docs", "x.md"));
|
||||||
|
});
|
||||||
|
it("an out-of-root file: doc → its URI string", () => {
|
||||||
|
const fsPath = "/elsewhere/y.md";
|
||||||
|
const uri = `file://${fsPath}`;
|
||||||
|
expect(makeRouter().keyOf(docIdentity(docOf(uri, fsPath, "file")))).toBe(uri);
|
||||||
|
});
|
||||||
|
it("an untitled: doc → its URI string", () => {
|
||||||
|
expect(makeRouter().keyOf(docIdentity(docOf("untitled:Untitled-1", "Untitled-1", "untitled")))).toBe(
|
||||||
|
"untitled:Untitled-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("root undefined (no folder) → everything is the URI string", () => {
|
||||||
|
const fsPath = "/anything/z.md";
|
||||||
|
const uri = `file://${fsPath}`;
|
||||||
|
expect(makeRouter(false).keyOf(docIdentity(docOf(uri, fsPath, "file")))).toBe(uri);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SidecarRouter routing — load/save/update dispatch by key home", () => {
|
||||||
|
it("an in-root key writes to the repo .threads/ sidecar, not global storage", () => {
|
||||||
|
const router = makeRouter();
|
||||||
|
const key = path.join("docs", "x.md");
|
||||||
|
router.save(key, emptyArtifact(key));
|
||||||
|
expect(fs.existsSync(path.join(root, ".threads", "docs", "x.md.json"))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(globalDir, "sidecars"))).toBe(false);
|
||||||
|
expect(router.sidecarPath(key)).toBe(path.join(root, ".threads", "docs", "x.md.json"));
|
||||||
|
});
|
||||||
|
it("an out-of-root file: key writes to global storage, not the repo", () => {
|
||||||
|
const router = makeRouter();
|
||||||
|
const key = "file:///elsewhere/y.md";
|
||||||
|
router.save(key, emptyArtifact(key));
|
||||||
|
expect(fs.existsSync(path.join(globalDir, "sidecars", `${hash(key)}.json`))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(root, ".threads"))).toBe(false);
|
||||||
|
expect(router.sidecarPath(key)).toBe(path.join(globalDir, "sidecars", `${hash(key)}.json`));
|
||||||
|
});
|
||||||
|
it("an untitled: key is in-memory (global store), no disk anywhere", () => {
|
||||||
|
const router = makeRouter();
|
||||||
|
const key = "untitled:Untitled-1";
|
||||||
|
router.update(key, (a) => a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] }));
|
||||||
|
expect(router.load(key)!.threads.length).toBe(1);
|
||||||
|
expect(router.sidecarPath(key)).toBeUndefined();
|
||||||
|
expect(fs.existsSync(path.join(globalDir, "sidecars"))).toBe(false);
|
||||||
|
});
|
||||||
|
it("round-trips through load after update on an in-root key", () => {
|
||||||
|
const router = makeRouter();
|
||||||
|
const key = path.join("docs", "x.md");
|
||||||
|
router.update(key, (a) => a.threads.push({ id: "t", anchorId: "an", status: "open", messages: [] }));
|
||||||
|
expect(router.load(key)!.threads.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+26
-21
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { isUnderRoot, selectionRejection } from "../src/workspacePath";
|
import { isAuthorable, isUnderRoot, selectionRejection } from "../src/workspacePath";
|
||||||
|
|
||||||
describe("isUnderRoot", () => {
|
describe("isUnderRoot", () => {
|
||||||
const root = "/a/vscode-cowriting-plugin/sandbox";
|
const root = "/a/vscode-cowriting-plugin/sandbox";
|
||||||
@@ -24,32 +24,37 @@ describe("isUnderRoot", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("selectionRejection", () => {
|
describe("isAuthorable", () => {
|
||||||
const root = "/ws";
|
it("accepts file: and untitled: schemes", () => {
|
||||||
const ok = { hasEditor: true, selectionEmpty: false, scheme: "file", fsPath: "/ws/a.md", root };
|
expect(isAuthorable("file")).toBe(true);
|
||||||
|
expect(isAuthorable("untitled")).toBe(true);
|
||||||
it("returns null when everything is valid", () => {
|
|
||||||
expect(selectionRejection(ok)).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
it("rejects read-only / virtual schemes", () => {
|
||||||
|
expect(isAuthorable("git")).toBe(false);
|
||||||
|
expect(isAuthorable("output")).toBe(false);
|
||||||
|
expect(isAuthorable("cowriting-baseline")).toBe(false);
|
||||||
|
expect(isAuthorable("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectionRejection — F8 widened (accepts out-of-folder + untitled)", () => {
|
||||||
|
const ok = { hasEditor: true, selectionEmpty: false, scheme: "file" };
|
||||||
|
|
||||||
|
it("accepts an in-folder / out-of-folder file (file: scheme)", () => {
|
||||||
|
expect(selectionRejection({ ...ok, scheme: "file" })).toBeNull();
|
||||||
|
});
|
||||||
|
it("accepts an untitled buffer (no longer rejected)", () => {
|
||||||
|
expect(selectionRejection({ ...ok, scheme: "untitled" })).toBeNull();
|
||||||
|
});
|
||||||
it("names the missing editor", () => {
|
it("names the missing editor", () => {
|
||||||
const msg = selectionRejection({ ...ok, hasEditor: false });
|
expect(selectionRejection({ ...ok, hasEditor: false })).toMatch(/focus a text editor/i);
|
||||||
expect(msg).toMatch(/focus a text editor/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names the empty selection (only when there IS an editor)", () => {
|
it("names the empty selection (only when there IS an editor)", () => {
|
||||||
const msg = selectionRejection({ ...ok, selectionEmpty: true });
|
expect(selectionRejection({ ...ok, selectionEmpty: true })).toMatch(/select some text/i);
|
||||||
expect(msg).toMatch(/select some text/i);
|
|
||||||
});
|
});
|
||||||
|
it("rejects a non-{file,untitled} scheme with its own message (not 'select some text')", () => {
|
||||||
it("names an unsaved/non-file document", () => {
|
const msg = selectionRejection({ ...ok, scheme: "git" });
|
||||||
const msg = selectionRejection({ ...ok, scheme: "untitled" });
|
expect(msg).toMatch(/can.?t be edited|read-only|not a file/i);
|
||||||
expect(msg).toMatch(/save this document/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names an out-of-workspace file (the reported bug — NOT a selection problem)", () => {
|
|
||||||
const msg = selectionRejection({ ...ok, fsPath: "/elsewhere/a.md" });
|
|
||||||
expect(msg).toMatch(/outside your workspace folder/i);
|
|
||||||
expect(msg).not.toMatch(/select some text/i);
|
expect(msg).not.toMatch(/select some text/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user