Files
vscode-cowriting-plugin/src/sidecarRouter.ts
T
Ben Stull dca12f4f50 feat(f8): SidecarRouter — keyOf + per-document routing (spec §6.2)
Routes by #24's isUnderRoot: in-workspace file: → CoauthorStore (repo-relative key,
.threads/), out-of-folder file: + untitled: → GlobalSidecarStore (URI-string key).
keyOf is the single document identity. vscode-free; CoauthorStore conforms
structurally (unmodified, INV-2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:08:01 -07:00

90 lines
3.7 KiB
TypeScript

/**
* 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);
}
}