Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208import type { FilesystemApi } from '../filesystem/types';import { join, basename, dirname } from '../filesystem/vaultFs';import { contentHash } from './utils/content';import type { DetectedChange, DirectoryDocument, HabitatRepo, HabitatUri, SyncSnapshot,} from './types';import { isFolderEntry } from './types';
export class VaultChangeDetector { constructor( private fs: FilesystemApi, private repo: HabitatRepo, ) {}
/** * Steady-state detection: local filesystem vs snapshot only. No network. * Returns local file and directory changes (adds/edits/deletes) to push. */ async detectLocal( rootPath: string, snapshot: SyncSnapshot, ): Promise<DetectedChange[]> { const [files, dirs] = await Promise.all([ this.scanLocalFiles(rootPath, snapshot), this.scanLocalDirs(rootPath, snapshot), ]); return [...files, ...dirs]; }
/** * First-open clone only: walk the entire remote tree and return every entry * not yet in the snapshot as an "add". Expensive (one `repo.find` per * directory) — only run once, when a vault has no local snapshot yet. */ async scanRemoteTree( rootUri: HabitatUri, snapshot: SyncSnapshot, ): Promise<DetectedChange[]> { return this.walkRemoteTree(rootUri, '', snapshot); }
private async walkFilesystem( rootPath: string, relDir: string, ): Promise<string[]> { const base = relDir ? join(rootPath, relDir) : rootPath; const entries = await this.fs.readdir(base); const result: string[] = []; for (const entry of entries) { if (entry.name.startsWith('.')) continue; const relPath = relDir ? join(relDir, entry.name) : entry.name; if (entry.isDirectory) { result.push(...(await this.walkFilesystem(rootPath, relPath))); } else if (entry.isFile) { result.push(relPath); } } return result; }
private async walkFilesystemDirs( rootPath: string, relDir: string, ): Promise<string[]> { const base = relDir ? join(rootPath, relDir) : rootPath; const entries = await this.fs.readdir(base); const result: string[] = []; for (const entry of entries) { if (entry.name.startsWith('.')) continue; if (entry.isDirectory) { const relPath = relDir ? join(relDir, entry.name) : entry.name; result.push(relPath); result.push(...(await this.walkFilesystemDirs(rootPath, relPath))); } } return result; }
private async scanLocalFiles( rootPath: string, snapshot: SyncSnapshot, ): Promise<DetectedChange[]> { const fsFiles = await this.walkFilesystem(rootPath, ''); const changes: DetectedChange[] = [];
for (const relPath of fsFiles) { const content = await this.fs.readFile(join(rootPath, relPath)); const hash = contentHash(content);
if (snapshot.files.has(relPath)) { const entry = snapshot.files.get(relPath)!; if (entry.contentHash !== hash) { changes.push({ type: 'edit', path: relPath, isDirectory: false, uri: entry.uri, contentHash: hash, }); } } else { changes.push({ type: 'add', path: relPath, isDirectory: false, contentHash: hash, }); } }
for (const [relPath, entry] of snapshot.files) { if (!fsFiles.includes(relPath)) { changes.push({ type: 'delete', path: relPath, isDirectory: false, uri: entry.uri, }); } }
return changes; }
private async scanLocalDirs( rootPath: string, snapshot: SyncSnapshot, ): Promise<DetectedChange[]> { const fsDirs = await this.walkFilesystemDirs(rootPath, ''); const changes: DetectedChange[] = [];
for (const relPath of fsDirs) { if (!snapshot.directories.has(relPath)) { changes.push({ type: 'add', path: relPath, isDirectory: true }); } }
for (const [relPath] of snapshot.directories) { if (!fsDirs.includes(relPath)) { const entry = snapshot.directories.get(relPath)!; changes.push({ type: 'delete', path: relPath, isDirectory: true, uri: entry.uri, }); } }
return changes; }
private async walkRemoteTree( dirUri: HabitatUri, relPath: string, snapshot: SyncSnapshot, ): Promise<DetectedChange[]> { const result: DetectedChange[] = [];
try { const handle = await this.repo.find<DirectoryDocument>(dirUri); const dirDoc = handle.doc();
for (const entry of dirDoc.docs ?? []) { const childRel = relPath ? join(relPath, entry.name) : entry.name;
if (isFolderEntry(entry)) { if (!snapshot.directories.has(childRel)) { const childHandle = await this.repo.find<DirectoryDocument>( entry.url, ); result.push({ type: 'add', path: childRel, isDirectory: true, uri: entry.url, head: childHandle.heads(), }); } result.push( ...(await this.walkRemoteTree(entry.url, childRel, snapshot)), ); } else { if (!snapshot.files.has(childRel)) { result.push({ type: 'add', path: childRel, isDirectory: false, uri: entry.url, }); } } } } catch (e) { console.warn('[ChangeDetector] walkRemoteTree failed for', dirUri, e); }
return result; }}
// Re-export for use in engineexport { basename, dirname };