import * as vscode from "vscode"; import type * as Y from "yjs"; import type { FileMeta } from "../lib/editor-protocol"; import type { ForgeApi } from "./forge"; import type { RoomClient } from "./room-client"; const encoder = new TextEncoder(); const decoder = new TextDecoder(); const READ_ONLY_KINDS = new Set(["lfs", "symlink", "binary", "too-large", "gitlink"]); /** * `gitfor:` file system over the branch tree plus the live room. Directory listings come from the tree at * the session head, overlaid with files created or deleted in the room; file contents are the shared * Y.Text, so every reader sees co-editors' edits. Writes go into the room, never straight to git. */ /** Rewrites `text` to `next` touching only the differing middle, so remote cursors stay put. */ function replaceText(text: Y.Text, next: string): void { const current = text.toString(); let start = 0; while (start < current.length && start < next.length && current[start] === next[start]) start++; let end = 0; while ( end < current.length - start && end < next.length - start && current[current.length - 1 - end] === next[next.length - 1 - end] ) end++; text.delete(start, current.length - start - end); text.insert(start, next.slice(start, next.length - end)); } export class GitforFileSystem implements vscode.FileSystemProvider { private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeFile = this.emitter.event; private headFiles = new Set(); private headDirs = new Set([""]); private loaded: Promise | null = null; /** Set by the extension: paths whose open editor is already bound to the room. */ isBound: (path: string, content: string) => boolean = () => false; constructor( private readonly api: ForgeApi, private readonly room: RoomClient, readonly root: string, ) { room.files.observe((event: Y.YMapEvent) => { const changes: vscode.FileChangeEvent[] = []; for (const path of event.keysChanged) { const meta = room.files.get(path); const type = !meta || meta.state === "deleted" ? vscode.FileChangeType.Deleted : meta.state === "created" ? vscode.FileChangeType.Created : vscode.FileChangeType.Changed; changes.push({ type, uri: this.uri(path) }); } if (changes.length) this.emitter.fire(changes); }); } uri(path: string): vscode.Uri { return vscode.Uri.from({ scheme: "gitfor", path: `${this.root}/${path}` }); } pathOf(uri: vscode.Uri): string | null { if (uri.scheme !== "gitfor") return null; if (uri.path === this.root) return ""; return uri.path.startsWith(`${this.root}/`) ? uri.path.slice(this.root.length + 1) : null; } /** Reloads the head tree, e.g. after a commit moved the branch. */ refresh(): void { this.loaded = null; this.emitter.fire([{ type: vscode.FileChangeType.Changed, uri: this.uri("").with({ path: this.root }) }]); } private load(): Promise { this.loaded ??= this.api.tree().then((tree) => { this.headFiles = new Set(tree.entries.map((entry) => entry.path)); this.headDirs = new Set([""]); for (const path of this.headFiles) { const parts = path.split("/"); for (let length = 1; length < parts.length; length++) this.headDirs.add(parts.slice(0, length).join("/")); } }); return this.loaded; } private exists(path: string): "file" | "dir" | null { const meta = this.room.files.get(path); if (meta?.state === "deleted") return null; if (meta && meta.state !== "missing") return "file"; if (this.headFiles.has(path)) return "file"; if (this.headDirs.has(path)) return "dir"; for (const [created, info] of this.room.files.entries()) { if (info.state === "created" && created.startsWith(`${path}/`)) return "dir"; } return null; } async stat(uri: vscode.Uri): Promise { await this.load(); const path = this.pathOf(uri); if (path === null) throw vscode.FileSystemError.FileNotFound(uri); const kind = this.exists(path); if (!kind) throw vscode.FileSystemError.FileNotFound(uri); if (kind === "dir") return { type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0 }; const meta = this.room.files.get(path); const readonly = !this.room.writable || (meta !== undefined && READ_ONLY_KINDS.has(meta.kind)); return { type: vscode.FileType.File, ctime: 0, mtime: 0, size: meta ? this.room.text(path).length : 0, permissions: readonly ? vscode.FilePermission.Readonly : undefined, }; } async readDirectory(uri: vscode.Uri): Promise> { await this.load(); const base = this.pathOf(uri); if (base === null) throw vscode.FileSystemError.FileNotFound(uri); const prefix = base ? `${base}/` : ""; const out = new Map(); const candidates = new Set([...this.headFiles, ...this.headDirs, ...this.room.files.keys()]); for (const path of candidates) { if (!path.startsWith(prefix) || path === base) continue; const rest = path.slice(prefix.length); const name = rest.split("/")[0]; const child = `${prefix}${name}`; const kind = this.exists(child); if (kind) out.set(name, kind === "dir" ? vscode.FileType.Directory : vscode.FileType.File); } return [...out.entries()]; } async readFile(uri: vscode.Uri): Promise { const path = this.pathOf(uri); if (!path) throw vscode.FileSystemError.FileNotFound(uri); let meta: FileMeta; try { meta = await this.room.open(path); } catch { throw vscode.FileSystemError.FileNotFound(uri); } if (meta.state === "deleted") throw vscode.FileSystemError.FileNotFound(uri); if (meta.kind === "binary" || meta.kind === "too-large" || meta.kind === "gitlink") { return encoder.encode( `(${meta.kind === "too-large" ? "Too large" : "Binary"} file, ${meta.size} bytes: not editable in the browser.)\n`, ); } return encoder.encode(this.room.text(path).toString()); } async writeFile( uri: vscode.Uri, content: Uint8Array, options: { create: boolean; overwrite: boolean }, ): Promise { const path = this.pathOf(uri); if (!path) throw vscode.FileSystemError.NoPermissions(uri); if (!this.room.writable) throw vscode.FileSystemError.NoPermissions("This session is read-only"); await this.load(); const exists = this.exists(path) === "file"; if (!exists && !options.create) throw vscode.FileSystemError.FileNotFound(uri); if (exists && !options.overwrite && !this.room.files.get(path)) throw vscode.FileSystemError.FileExists(uri); if (!exists) this.room.control({ type: "create", path }); const meta = await this.room.open(path); if (READ_ONLY_KINDS.has(meta.kind)) throw vscode.FileSystemError.NoPermissions(`${path} can't be edited in the browser`); // A bound editor already streams every keystroke into the room; saving it (auto-save included) must // not rewrite the shared text, which may hold co-editors' changes the editor hasn't applied yet. const next = decoder.decode(content); if (exists && this.isBound(path, next)) return; const text = this.room.text(path); if (text.toString() !== next) this.room.doc.transact(() => replaceText(text, next), "fs"); } async delete(uri: vscode.Uri): Promise { const path = this.pathOf(uri); if (!path || !this.room.writable) throw vscode.FileSystemError.NoPermissions(uri); await this.load(); const targets = [...new Set([...this.headFiles, ...this.room.files.keys()])].filter( (candidate) => candidate === path || candidate.startsWith(`${path}/`), ); for (const target of targets) { await this.room.open(target).catch(() => undefined); this.room.control({ type: "delete", path: target }); } } async rename(oldUri: vscode.Uri, newUri: vscode.Uri): Promise { const content = await this.readFile(oldUri); await this.writeFile(newUri, content, { create: true, overwrite: false }); await this.delete(oldUri); } /** Every file in the workspace: the head tree plus files created in the room, minus those deleted there. */ async files(): Promise { await this.load(); const out = new Set(this.headFiles); for (const [path, meta] of this.room.files.entries()) { if (meta.state === "deleted" || meta.state === "missing") out.delete(path); else out.add(path); } return [...out]; } createDirectory(): void { return; } watch(): vscode.Disposable { return new vscode.Disposable(() => undefined); } }