import type { FilesystemApi } from '../filesystem/types'; import { join, basename, dirname } from '../filesystem/vaultFs'; import { VaultChangeDetector } from './change-detection'; import { detectMoves } from './move-detection'; import type { VaultSnapshotManager } from './snapshot'; import { contentHash } from './utils/content'; import { readDocContent } from './utils/text-diff'; import type { DetectedChange, DirectoryDocument, DirectoryEntry, FileDocument, HabitatRepo, HabitatUri, MoveCandidate, SnapshotDirectoryEntry, SnapshotFileEntry, SyncSnapshot, VaultConfig, } from './types'; export interface SyncResult { pushed: number; /** Files written to disk. Non-zero only on the first-open clone. */ pulled: number; moves: number; } // Pure snapshot mutation helpers — return new snapshots rather than mutating. function snapshotWithFile( snap: SyncSnapshot, path: string, entry: SnapshotFileEntry, ): SyncSnapshot { const files = new Map(snap.files); files.set(path, entry); return { ...snap, timestamp: Date.now(), files }; } function snapshotWithRemovedFile( snap: SyncSnapshot, path: string, ): SyncSnapshot { const files = new Map(snap.files); files.delete(path); return { ...snap, timestamp: Date.now(), files }; } function snapshotWithDirectory( snap: SyncSnapshot, path: string, entry: SnapshotDirectoryEntry, ): SyncSnapshot { const directories = new Map(snap.directories); directories.set(path, entry); return { ...snap, timestamp: Date.now(), directories }; } function extname(name: string): string { const idx = name.lastIndexOf('.'); return idx > 0 ? name.substring(idx + 1) : ''; } function pathDepth(p: string): number { if (p === '/') return 0; return p.split('/').length; } export class VaultSyncEngine { constructor( private fs: FilesystemApi, private repo: HabitatRepo, private snapshotManager: VaultSnapshotManager, private detector: VaultChangeDetector, ) {} async sync(vault: VaultConfig, rootPath: string): Promise { if (vault.type === 'local') { return { pushed: 0, pulled: 0, moves: 0 }; } // First sync (no snapshot on disk) does a one-time clone of the remote // tree; every subsequent sync is push-only. Remote edits to already-cloned // files are merged when the file is opened (see `openFile`) or live over // libp2p — never by walking the remote tree here. const existing = await this.snapshotManager.load(rootPath); const firstSync = existing === null; let snap = existing ?? this.snapshotManager.createEmpty(vault.rootUri); let pulled = 0; if (firstSync) { const remote = await this.detector.scanRemoteTree(vault.rootUri, snap); ({ snap, pulled } = await this.pull(rootPath, snap, remote)); } // Push local changes (on first sync this also uploads any pre-existing // local files, since the snapshot started empty). const localChanges = await this.detector.detectLocal(rootPath, snap); const { moves, remainingChanges } = await detectMoves( localChanges, async (path) => { // Deleted files no longer exist on disk — fall back to the last known // content from the repo so move detection can compare similarity. try { return await this.fs.readFile(join(rootPath, path)); } catch { const entry = snap.files.get(path); if (entry) { try { const handle = await this.repo.find(entry.uri); const c = readDocContent( (handle.doc() as unknown as FileDocument).content, ); return typeof c === 'string' ? c : null; } catch { return null; } } return null; } }, ); const { snap: pushedSnap, pushed, moved, } = await this.push(rootPath, vault.rootUri, snap, remainingChanges, moves); snap = pushedSnap; await this.snapshotManager.save(rootPath, snap); return { pushed, pulled, moves: moved }; } /** * Per-file pull invoked when a file is opened. `repo.find` loads the doc * through the live session (merging PDS + libp2p state), so this writes the * latest merged content back to disk and refreshes the snapshot entry. * No-op for untracked files — remote discovery is handled by the clone. */ async openFile( vault: VaultConfig, rootPath: string, relPath: string, ): Promise { if (vault.type === 'local') return; let snap = (await this.snapshotManager.load(rootPath)) ?? this.snapshotManager.createEmpty(vault.rootUri); const entry = snap.files.get(relPath); if (!entry) return; snap = await this.pullFile( rootPath, { type: 'edit', path: relPath, isDirectory: false, uri: entry.uri, }, snap, ); await this.snapshotManager.save(rootPath, snap); } /** * Record that an open file's on-disk mirror now matches the given doc state. * Called by the synced document provider after it mirrors live-session edits * to disk, so a later `sync()` doesn't mistake the in-app edit for an * external on-disk change and redundantly re-push it. `head` is the doc's * current Automerge heads; `content` is its text. */ async noteMirroredFile( vault: VaultConfig, rootPath: string, relPath: string, uri: HabitatUri, head: string[], content: string, ): Promise { if (vault.type === 'local') return; let snap = (await this.snapshotManager.load(rootPath)) ?? this.snapshotManager.createEmpty(vault.rootUri); snap = snapshotWithFile(snap, relPath, { uri, head, contentHash: contentHash(content), }); await this.snapshotManager.save(rootPath, snap); } /** * Push a single file (add or edit) without scanning the whole tree. Intended * for save/edit events. Determines add-vs-edit from the snapshot, skips when * the on-disk content is unchanged, and — for new files only — registers the * file in its parent directory doc(s) so other clients can discover it. * Deletes and renames still go through `sync` (they need tree-wide context). */ async pushFile( vault: VaultConfig, rootPath: string, relPath: string, ): Promise { if (vault.type === 'local') return; let snap = (await this.snapshotManager.load(rootPath)) ?? this.snapshotManager.createEmpty(vault.rootUri); const content = await this.fs.readFile(join(rootPath, relPath)); const hash = contentHash(content); const existing = snap.files.get(relPath); // Nothing changed since the last push — skip the network round-trip. if (existing && existing.contentHash === hash) return; const isAdd = !existing; snap = await this.pushFileChange( rootPath, { type: isAdd ? 'add' : 'edit', path: relPath, isDirectory: false, uri: existing?.uri, contentHash: hash, }, snap, ); // A new file needs a directory entry so it's discoverable; edits don't // change the listing, so skip the directory rewrite for them. if (isAdd) { snap = await this.updateParentDirectories([relPath], vault.rootUri, snap); } await this.snapshotManager.save(rootPath, snap); } // --------------------------------------------------------------------------- // Push phase // --------------------------------------------------------------------------- private async push( rootPath: string, rootUri: HabitatUri, snap: SyncSnapshot, changes: DetectedChange[], moves: MoveCandidate[], ): Promise<{ snap: SyncSnapshot; pushed: number; moved: number }> { const affectedPaths: string[] = []; // 1. Moves — update existing doc in place to preserve CRDT history. for (const move of moves) { const content = await this.fs.readFile(join(rootPath, move.newPath)); const oldEntry = snap.files.get(move.oldPath); if (oldEntry) { const handle = await this.repo.find(oldEntry.uri); const ext = extname(move.newPath); const newName = basename(move.newPath); handle.changeAt(oldEntry.head, (d) => { const fd = d as unknown as FileDocument; fd.content = content; fd.name = newName; fd.extension = ext; }); snap = snapshotWithRemovedFile(snap, move.oldPath); snap = snapshotWithFile(snap, move.newPath, { uri: oldEntry.uri, head: handle.heads(), contentHash: contentHash(content), }); affectedPaths.push(move.oldPath, move.newPath); } } // 2. File adds and edits. const fileChanges = changes.filter( (c) => (c.type === 'add' || c.type === 'edit') && !c.isDirectory, ); for (const change of fileChanges) { snap = await this.pushFileChange(rootPath, change, snap); affectedPaths.push(change.path); } // 3. File deletes. const fileDeletes = changes.filter( (c) => c.type === 'delete' && !c.isDirectory, ); for (const change of fileDeletes) { snap = await this.deleteRemoteFile(change, snap); affectedPaths.push(change.path); } const pushed = fileChanges.length + fileDeletes.length; const moved = moves.length; // 4. Update parent directories depth-first. if (affectedPaths.length > 0) { snap = await this.updateParentDirectories(affectedPaths, rootUri, snap); } await this.snapshotManager.save(rootPath, snap); return { snap, pushed, moved }; } private async pushFileChange( rootPath: string, change: DetectedChange, snap: SyncSnapshot, ): Promise { const content = await this.fs.readFile(join(rootPath, change.path)); const hash = contentHash(content); const name = basename(change.path); if (change.type === 'add') { const { uri, handle } = await this.repo.createFile( this.buildFileDocument(name, content), ); return snapshotWithFile(snap, change.path, { uri, head: handle.heads(), contentHash: hash, }); } // edit const entry = snap.files.get(change.path); if (!entry) { throw new Error( `[VaultEngine] no snapshot entry for edited file: ${change.path}`, ); } const handle = await this.repo.find(entry.uri); handle.changeAt(entry.head, (d) => { (d as unknown as FileDocument).content = content; }); return snapshotWithFile(snap, change.path, { uri: entry.uri, head: handle.heads(), contentHash: hash, }); } private async deleteRemoteFile( change: DetectedChange, snap: SyncSnapshot, ): Promise { if (change.uri) { await this.repo.delete(change.uri); } return snapshotWithRemovedFile(snap, change.path); } // --------------------------------------------------------------------------- // Directory update helpers // --------------------------------------------------------------------------- private async updateParentDirectories( affectedPaths: string[], rootUri: HabitatUri, snap: SyncSnapshot, ): Promise { const dirSet = new Set(); for (const path of affectedPaths) { let d = dirname(path); dirSet.add(d); while (d !== '/') { d = dirname(d); dirSet.add(d); } } // Deepest first — children before parents. const sorted = Array.from(dirSet).sort( (a, b) => pathDepth(b) - pathDepth(a), ); for (const dir of sorted) { snap = await this.updateOneDirectory(dir, rootUri, snap); } return snap; } private async updateOneDirectory( dirRelPath: string, rootUri: HabitatUri, snap: SyncSnapshot, ): Promise { const isRoot = dirRelPath === '/'; const dirEntry = isRoot ? null : snap.directories.get(dirRelPath); let dirUri: HabitatUri | undefined = isRoot ? rootUri : dirEntry?.uri; if (!dirUri) { // New directory that doesn't yet exist remotely. const name = basename(dirRelPath); const { uri: newUri, handle } = await this.repo.createDirectory( this.buildDirectoryDocument(name), ); dirUri = newUri; snap = snapshotWithDirectory(snap, dirRelPath, { uri: newUri, head: handle.heads(), }); } const handle = await this.repo.find(dirUri); const existingHeads = dirEntry?.head ?? handle.heads(); const expected = this.buildExpectedEntries(dirRelPath, snap); handle.changeAt(existingHeads, (d) => { const docs = d.docs as unknown as DirectoryEntry[]; docs.splice(0, docs.length); for (const e of expected) { docs.push(e); } (d as unknown as DirectoryDocument).lastSyncAt = Date.now(); }); // Root is always known via vault.rootUri — no need to track it in the // snapshot. Storing '/' causes dirname('/') === '/' which makes root // appear as its own child in buildExpectedEntries. if (isRoot) { return snap; } return snapshotWithDirectory(snap, dirRelPath, { uri: dirUri, head: handle.heads(), }); } private buildExpectedEntries( dirRelPath: string, snap: SyncSnapshot, ): DirectoryEntry[] { const entries: DirectoryEntry[] = []; for (const [filePath, fileEntry] of snap.files) { if (dirname(filePath) === dirRelPath) { const name = basename(filePath); const ext = extname(name) || 'md'; entries.push({ name, type: ext, url: fileEntry.uri }); } } for (const [subdirPath, subdirEntry] of snap.directories) { if (dirname(subdirPath) === dirRelPath) { entries.push({ name: basename(subdirPath), type: 'folder', url: subdirEntry.uri, }); } } return entries; } // --------------------------------------------------------------------------- // Pull phase // --------------------------------------------------------------------------- private async pull( rootPath: string, snap: SyncSnapshot, remoteChanges: DetectedChange[], ): Promise<{ snap: SyncSnapshot; pulled: number }> { let pulled = 0; for (const change of remoteChanges) { if (change.isDirectory) { const fullPath = join(rootPath, change.path); try { await this.fs.mkdir(fullPath); } catch { // Directory may already exist. } if (change.uri && change.head) { snap = snapshotWithDirectory(snap, change.path, { uri: change.uri, head: change.head, }); } pulled++; } else { snap = await this.pullFile(rootPath, change, snap); pulled++; } } return { snap, pulled }; } private async pullFile( rootPath: string, change: DetectedChange, snap: SyncSnapshot, ): Promise { if (!change.uri) return snap; const handle = await this.repo.find(change.uri); const rawContent = readDocContent( (handle.doc() as unknown as FileDocument).content, ); const content = typeof rawContent === 'string' ? rawContent : ''; const fullPath = join(rootPath, change.path); const parentDir = dirname(fullPath); if (parentDir && parentDir !== '/') { const parentExists = await this.fs.fileExists(parentDir); if (!parentExists) { await this.fs.mkdir(parentDir); } } await this.fs.writeFile(fullPath, content); return snapshotWithFile(snap, change.path, { uri: change.uri, head: handle.heads(), contentHash: contentHash(content), }); } // --------------------------------------------------------------------------- // Document builders // --------------------------------------------------------------------------- private buildFileDocument(name: string, content: string): FileDocument { const ext = extname(name); const mimeType = ext === 'md' ? 'text/markdown' : 'text/plain'; return { '@patchwork': { type: 'file' }, name, extension: ext, mimeType, content, metadata: { permissions: 0o644 }, }; } private buildDirectoryDocument(name: string): DirectoryDocument { return { '@patchwork': { type: 'folder' }, docs: [], name, }; } }