F8: out-of-workspace authoring — hybrid sidecar persistence (#25) #26

Merged
benstull merged 8 commits from f8-out-of-workspace into main 2026-06-11 20:18:55 +00:00
2 changed files with 39 additions and 50 deletions
Showing only changes of commit 7892e2fe87 - Show all commits
+2 -2
View File
@@ -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"
} }
], ],
+37 -48
View File
@@ -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,
}; };
} }