import * as Automerge from '@automerge/automerge'; import { createAutomergeBinding } from '../../editors/automerge/automergeEditorBinding'; // Type-only import: keeps this module free of the libp2p-backed session runtime // so it stays importable in tests. The real factory is injected at the // composition root (see App.tsx); tests inject an in-memory fake. import type { AutomergeDocSession } from '../../habitat/automergeDocSession'; import { basename, join } from '../../filesystem/vaultFs'; import type { FilesystemApi } from '../../filesystem/types'; import type { VaultSnapshotManager } from '../../vault/snapshot'; import type { VaultSyncEngine } from '../../vault/engine'; import { isTextFile } from '../../vault/utils/fileType'; import type { VaultConfig, HabitatUri } from '../../vault/types'; import type { DocumentHandle, DocumentProvider } from '../types'; export type SyncedVaultDocumentProviderOptions = { vault: VaultConfig; /** Absolute path to the vault root. */ rootPath: string; engine: VaultSyncEngine; snapshots: VaultSnapshotManager; fs: FilesystemApi; /** * Live-session factory. Wired to the real `acquireDocSession` at the * composition root; tests inject an in-memory fake. */ acquireSession: (uri: string) => AutomergeDocSession; }; const MIRROR_DEBOUNCE_MS = 1000; /** * Document provider for synced vault files. * * Opening a file binds the editor to the live Automerge session * (`acquireDocSession`), giving real-time multiplayer over libp2p. The session * is the source of truth; this provider mirrors its content back to the on-disk * file (debounced) so external tools see current text, and keeps the sync * snapshot in step so a later `engine.sync()` doesn't re-push the mirror. */ export class SyncedVaultDocumentsProvider implements DocumentProvider { readonly id: string; private acquireSession: (uri: string) => AutomergeDocSession; constructor( private opts: SyncedVaultDocumentProviderOptions & { id: string }, ) { this.id = opts.id; this.acquireSession = opts.acquireSession; } async openDocument(absPath: string): Promise { const relPath = this.rel(absPath); // Vault editing is text-only for now — binary files would be overwritten // with text by the markdown editor. if (!isTextFile(relPath)) { throw new Error( `Cannot open non-text file "${basename(relPath)}" in the editor`, ); } // Resolve the file's Habitat URI. A brand-new local file isn't in the // snapshot yet — push it first so a remote doc + uri exist. let uri = await this.resolveUri(relPath); if (!uri) { await this.opts.engine.pushFile( this.opts.vault, this.opts.rootPath, relPath, ); uri = await this.resolveUri(relPath); } if (!uri) { throw new Error(`[SyncedVault] could not resolve uri for ${relPath}`); } // Pull the latest merged remote state to disk before binding. await this.opts.engine.openFile( this.opts.vault, this.opts.rootPath, relPath, ); const session = this.acquireSession(uri); const stopMirror = this.mirrorSessionToDisk(session, relPath, uri); return { id: session.uri, loadPromise: session.loadPromise, // Vault files are identified by their filename (matches the sidebar), // not the first content line. getTitle: () => basename(relPath), subscribeTitle: () => () => {}, release: () => { stopMirror(); session.release(); }, getEditorBinding: () => createAutomergeBinding({ getDoc: () => session.doc, applyChange: session.applyChange, subscribeToChanges: session.subscribe, caretProvider: null, }), }; } async createDocument(options?: { id?: string }): Promise { if (options?.id) { return this.openDocument(options.id); } // Create a new untitled file in the vault root, then open it (openDocument // pushes it to get a uri). let fileName = 'Untitled.md'; let filePath = join(this.opts.rootPath, fileName); let counter = 1; while (await this.opts.fs.fileExists(filePath)) { fileName = `Untitled-${counter}.md`; filePath = join(this.opts.rootPath, fileName); counter++; } await this.opts.fs.writeFile(filePath, ''); return this.openDocument(filePath); } /** * Mirror live-session edits to the on-disk file (debounced) and keep the * snapshot consistent. Returns a stop function that flushes a final write. */ private mirrorSessionToDisk( session: AutomergeDocSession, relPath: string, uri: HabitatUri, ): () => void { const fullPath = join(this.opts.rootPath, relPath); let timer: ReturnType | null = null; let lastContent: string | null = null; const flush = async () => { const content = this.contentOf(session); if (content === lastContent) return; lastContent = content; try { await this.opts.fs.writeFile(fullPath, content); await this.opts.engine.noteMirroredFile( this.opts.vault, this.opts.rootPath, relPath, uri, Automerge.getHeads(session.doc), content, ); } catch { // Best-effort mirror; the live session remains the source of truth. } }; const unsubscribe = session.subscribe(() => { if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; void flush(); }, MIRROR_DEBOUNCE_MS); }); return () => { unsubscribe(); if (timer) { clearTimeout(timer); timer = null; } void flush(); }; } private contentOf(session: AutomergeDocSession): string { const content = (session.doc as { content?: unknown }).content; return typeof content === 'string' ? content : ''; } private rel(absPath: string): string { const prefix = `${this.opts.rootPath}/`; return absPath.startsWith(prefix) ? absPath.slice(prefix.length) : absPath; } private async resolveUri(relPath: string): Promise { const snap = await this.opts.snapshots.load(this.opts.rootPath); return snap?.files.get(relPath)?.uri ?? null; } }