Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192import * 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<DocumentHandle> { 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<DocumentHandle> { 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<typeof setTimeout> | 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<HabitatUri | null> { const snap = await this.opts.snapshots.load(this.opts.rootPath); return snap?.files.get(relPath)?.uri ?? null; }}