diff --git a/src/App.tsx b/src/App.tsx index 34741ef..6bda774 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,9 +15,19 @@ import type { FileSystemProvider } from './filesystem/types'; import { SubtextFilesystemProvider } from './filesystem/providers/subtextFilesystemProvider'; import { LocalFilesystemProvider } from './filesystem/providers/localFilesystemProvider'; import { RemoteSyncFilesystemProvider } from './filesystem/providers/remoteSyncFilesystemProvider'; +import { SyncedVaultFilesystemProvider } from './filesystem/providers/syncedVaultFilesystemProvider'; import { VaultDocumentProvider } from './documents/providers/vaultDocumentsProvider'; +import { SyncedVaultDocumentsProvider } from './documents/providers/syncedVaultDocumentsProvider'; import { VaultFilesystemApi } from './filesystem/vaultFs'; import { VaultManager } from './components/VaultManager'; +import { HabitatRepoImpl } from './vault/habitat-repo'; +import { VaultSnapshotManager } from './vault/snapshot'; +import { VaultChangeDetector } from './vault/change-detection'; +import { VaultSyncEngine } from './vault/engine'; +import type { HabitatUri, VaultConfig } from './vault/types'; +import { createAutomergeDoc } from './habitat/automergeDoc'; +import { acquireDocSession } from './habitat/automergeDocSession'; +import { HABITAT_VAULT_DIRECTORY_COLLECTION } from './habitat/config'; import { getOpenVaults, addOpenVault, @@ -141,12 +151,50 @@ function AppShell() { const fs = new VaultFilesystemApi(vault); return openVaults.map((vaultEntry) => { if (vaultEntry.type === 'synced') { - return new RemoteSyncFilesystemProvider({ - id: `vault-${vaultEntry.id}`, + const id = `vault-${vaultEntry.id}`; + // Unprovisioned (created before rootUri existed, or signed out): fall + // back to the read-only stub so the app doesn't crash. + if (!vaultEntry.did || !vaultEntry.rootUri) { + return new RemoteSyncFilesystemProvider({ + id, + rootPath: vaultEntry.path, + displayName: vaultEntry.name, + documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), + defaultEditorKind: 'automerge', + }); + } + const repo = new HabitatRepoImpl(vaultEntry.did); + const snapshots = new VaultSnapshotManager(fs); + const detector = new VaultChangeDetector(fs, repo); + const engine = new VaultSyncEngine(fs, repo, snapshots, detector); + const vaultConfig: VaultConfig = { + name: vaultEntry.name, + type: 'synced', + rootUri: vaultEntry.rootUri, + createdAt: '', + }; + // First-mount clone + reconcile (out-of-band renames/deletes). + void engine + .sync(vaultConfig, vaultEntry.path) + .catch((e) => console.error('[vault] initial sync failed', e)); + const documents = new SyncedVaultDocumentsProvider({ + id, + vault: vaultConfig, + rootPath: vaultEntry.path, + engine, + snapshots, + fs, + acquireSession: acquireDocSession, + }); + return new SyncedVaultFilesystemProvider({ + id, rootPath: vaultEntry.path, displayName: vaultEntry.name, - documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), + documents, defaultEditorKind: 'automerge', + fs, + vault: vaultConfig, + engine, }); } return new LocalFilesystemProvider({ @@ -253,16 +301,43 @@ function AppShell() { }, []); const handleOpenVault = useCallback( - (vaultPath: string, vaultType: 'local' | 'synced', did: string | null) => { + async ( + vaultPath: string, + vaultType: 'local' | 'synced', + did: string | null, + ) => { const id = vaultIdFromPath(vaultPath); const name = vaultPath.split('/').pop() || vaultPath; const providerId = `vault-${id}`; - addOpenVault({ id, path: vaultPath, type: vaultType, name, did }); - // TODO: On first mount of a synced vault, run a top-level - // VaultSyncEngine.sync() once — it performs the one-time remote clone and - // reconciles any out-of-band changes (renames/deletes) made while the app - // was closed. Steady-state edits should then go through - // VaultSyncEngine.pushFile/openFile rather than full syncs. + + // Already open — just focus it (avoids duplicate registry entries). + if (getOpenVaults().some((v) => v.id === id)) { + setSelectedProviderId(providerId); + return; + } + + // Provision a synced vault's root directory record on first open so the + // sync engine has a rootUri to clone/sync against. + let rootUri: HabitatUri | undefined; + if (vaultType === 'synced') { + if (!did) { + console.error('[vault] cannot create a synced vault while signed out'); + return; + } + try { + const { uri } = await createAutomergeDoc( + did, + { '@patchwork': { type: 'folder' }, docs: [], name }, + HABITAT_VAULT_DIRECTORY_COLLECTION, + ); + rootUri = uri as HabitatUri; + } catch (e) { + console.error('[vault] failed to provision synced vault root', e); + return; + } + } + + addOpenVault({ id, path: vaultPath, type: vaultType, name, did, rootUri }); // Auto-switch to the newly created vault — same path as the selector. setSelectedProviderId(providerId); }, diff --git a/src/documents/providers/syncedVaultDocumentsProvider.test.ts b/src/documents/providers/syncedVaultDocumentsProvider.test.ts new file mode 100644 index 0000000..4da828a --- /dev/null +++ b/src/documents/providers/syncedVaultDocumentsProvider.test.ts @@ -0,0 +1,198 @@ +import * as nodeFs from 'fs'; +import * as nodePath from 'path'; +import * as os from 'os'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { VaultSyncEngine } from '../../vault/engine'; +import { VaultChangeDetector } from '../../vault/change-detection'; +import { VaultSnapshotManager } from '../../vault/snapshot'; +import { + InMemoryHabitatRepo, + NodeFilesystemApi, + makeFakeSessions, + type DocStore, +} from '../../vault/testing/fakeHabitat'; +import type { + DirectoryDocument, + FileDocument, + HabitatUri, + VaultConfig, +} from '../../vault/types'; +import { getAutomergePayload } from '../../editors/automerge/automergeEditorBinding'; +import { SyncedVaultDocumentsProvider } from './syncedVaultDocumentsProvider'; + +const MIRROR_DEBOUNCE_MS = 1000; + +function makeRootDirectoryDoc(): DirectoryDocument { + return { '@patchwork': { type: 'folder' }, docs: [], name: 'root' }; +} + +function writeFile(dir: string, relPath: string, content: string): void { + const full = nodePath.join(dir, relPath); + nodeFs.mkdirSync(nodePath.dirname(full), { recursive: true }); + nodeFs.writeFileSync(full, content, 'utf-8'); +} + +function readFile(dir: string, relPath: string): string { + return nodeFs.readFileSync(nodePath.join(dir, relPath), 'utf-8'); +} + +function loadSnapshot(dir: string): { + files: [string, { uri: string; head: string[]; contentHash: string }][]; + directories: [string, unknown][]; +} { + return JSON.parse(readFile(dir, '.textile/snapshot.json')); +} + +function snapshotFile(dir: string, relPath: string) { + return loadSnapshot(dir).files.find(([p]) => p === relPath)?.[1]; +} + +describe('SyncedVaultDocumentsProvider', () => { + let tmpDir: string; + let store: DocStore; + let repo: InMemoryHabitatRepo; + let sessions: ReturnType; + let engine: VaultSyncEngine; + let vault: VaultConfig; + let provider: SyncedVaultDocumentsProvider; + + beforeEach(async () => { + tmpDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'textile-syncdoc-')); + store = new Map(); + repo = new InMemoryHabitatRepo(store); + + const { uri: rootUri } = await repo.createDirectory(makeRootDirectoryDoc()); + + vault = { + name: 'Test Vault', + type: 'synced', + rootUri, + createdAt: new Date().toISOString(), + }; + + const fs = new NodeFilesystemApi(); + const snapshots = new VaultSnapshotManager(fs); + const detector = new VaultChangeDetector(fs, repo); + engine = new VaultSyncEngine(fs, repo, snapshots, detector); + sessions = makeFakeSessions(store); + + provider = new SyncedVaultDocumentsProvider({ + id: 'vault-test', + vault, + rootPath: tmpDir, + engine, + snapshots, + fs, + acquireSession: sessions.acquire, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + nodeFs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('opens an untracked on-disk file by provisioning + tracking the remote doc', async () => { + writeFile(tmpDir, 'note.md', 'hello'); + + const handle = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); + + // Snapshot now tracks the file with a uri. + const entry = snapshotFile(tmpDir, 'note.md'); + expect(entry).toBeDefined(); + const uri = entry!.uri as HabitatUri; + expect(handle.id).toBe(uri); + + // The remote doc was created with the on-disk content. + expect((repo.getDoc(uri) as unknown as FileDocument).content).toBe('hello'); + + // The editor binding is wired to the live session doc. + const payload = getAutomergePayload(handle.getEditorBinding()); + expect(payload.getDoc().content).toBe('hello'); + }); + + it('pulls newer remote content to disk before binding a tracked file', async () => { + // Establish shared.md locally + remotely. + writeFile(tmpDir, 'shared.md', 'v1'); + await engine.sync(vault, tmpDir); + const uri = snapshotFile(tmpDir, 'shared.md')!.uri as HabitatUri; + + // Remote edit advances the doc; disk still says v1. + const remote = await repo.find(uri); + remote.change((d) => { + (d as unknown as FileDocument).content = 'v2 from remote'; + }); + const remoteHeads = remote.heads(); + + const handle = await provider.openDocument(nodePath.join(tmpDir, 'shared.md')); + + expect(readFile(tmpDir, 'shared.md')).toBe('v2 from remote'); + expect(getAutomergePayload(handle.getEditorBinding()).getDoc().content).toBe( + 'v2 from remote', + ); + expect(snapshotFile(tmpDir, 'shared.md')!.head).toEqual(remoteHeads); + }); + + it('mirrors in-app edits to disk and keeps the snapshot consistent', async () => { + vi.useFakeTimers(); + writeFile(tmpDir, 'doc.md', 'v1'); + await engine.sync(vault, tmpDir); + const uri = snapshotFile(tmpDir, 'doc.md')!.uri as HabitatUri; + + await provider.openDocument(nodePath.join(tmpDir, 'doc.md')); + + // Edit through the live session, then let the mirror debounce fire. + sessions.get(uri)!.applyChange((d) => { + (d as { content: string }).content = 'edited'; + }); + await vi.advanceTimersByTimeAsync(MIRROR_DEBOUNCE_MS); + + // Mirror wrote to disk; the shared store (remote) reflects the edit too. + expect(readFile(tmpDir, 'doc.md')).toBe('edited'); + expect((repo.getDoc(uri) as unknown as FileDocument).content).toBe('edited'); + + // The snapshot was updated, so a steady-state sync sees no phantom change. + vi.useRealTimers(); + const result = await engine.sync(vault, tmpDir); + expect(result.pushed).toBe(0); + }); + + it('release marks the session released and stops mirroring', async () => { + vi.useFakeTimers(); + writeFile(tmpDir, 'doc.md', 'v1'); + await engine.sync(vault, tmpDir); + const uri = snapshotFile(tmpDir, 'doc.md')!.uri as HabitatUri; + + const handle = await provider.openDocument(nodePath.join(tmpDir, 'doc.md')); + handle.release(); + + expect(sessions.get(uri)!.released).toBe(true); + + // A post-release edit must not be mirrored to disk. + sessions.get(uri)!.applyChange((d) => { + (d as { content: string }).content = 'after release'; + }); + await vi.advanceTimersByTimeAsync(MIRROR_DEBOUNCE_MS); + expect(readFile(tmpDir, 'doc.md')).toBe('v1'); + }); + + it('createDocument() creates a new untitled file on disk and tracks it', async () => { + const handle = await provider.createDocument(); + + expect(nodeFs.existsSync(nodePath.join(tmpDir, 'Untitled.md'))).toBe(true); + const entry = snapshotFile(tmpDir, 'Untitled.md'); + expect(entry).toBeDefined(); + expect(handle.id).toBe(entry!.uri); + }); + + it('opening the same path twice reuses one session', async () => { + writeFile(tmpDir, 'note.md', 'hello'); + const a = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); + const b = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); + + expect(a.id).toBe(b.id); + // A single FakeDocSession instance backs both handles. + expect(sessions.get(a.id)).toBe(sessions.get(b.id)); + }); +}); diff --git a/src/documents/providers/syncedVaultDocumentsProvider.ts b/src/documents/providers/syncedVaultDocumentsProvider.ts new file mode 100644 index 0000000..d56482d --- /dev/null +++ b/src/documents/providers/syncedVaultDocumentsProvider.ts @@ -0,0 +1,172 @@ +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 { join } from '../../filesystem/vaultFs'; +import type { FilesystemApi } from '../../filesystem/types'; +import type { VaultSnapshotManager } from '../../vault/snapshot'; +import type { VaultSyncEngine } from '../../vault/engine'; +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); + + // 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, + getTitle: () => session.getTitle(), + 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; + } +} diff --git a/src/filesystem/providers/syncedVaultFilesystemProvider.ts b/src/filesystem/providers/syncedVaultFilesystemProvider.ts new file mode 100644 index 0000000..e5ea103 --- /dev/null +++ b/src/filesystem/providers/syncedVaultFilesystemProvider.ts @@ -0,0 +1,69 @@ +import type { FileSystemEntry } from '../types'; +import type { VaultConfig } from '../../vault/types'; +import type { VaultSyncEngine } from '../../vault/engine'; +import { + LocalFilesystemProvider, + type LocalFilesystemProviderOptions, +} from './localFilesystemProvider'; + +export type SyncedVaultFilesystemProviderOptions = + LocalFilesystemProviderOptions & { + vault: VaultConfig; + engine: VaultSyncEngine; + }; + +/** + * Filesystem provider for a synced vault. + * + * The sidebar tree and CRUD reuse the on-disk behaviour of + * `LocalFilesystemProvider` (files live on disk after the first-mount clone); + * each mutation then drives the push-only `VaultSyncEngine` to reconcile the + * remote Habitat records. File *content* edits are not handled here — they + * flow through the live Automerge session in `SyncedVaultDocumentsProvider`. + */ +export class SyncedVaultFilesystemProvider extends LocalFilesystemProvider { + private vault: VaultConfig; + private engine: VaultSyncEngine; + + constructor(options: SyncedVaultFilesystemProviderOptions) { + super(options); + this.vault = options.vault; + this.engine = options.engine; + } + + /** Absolute entry id → vault-relative path. */ + private rel(entryId: string): string { + const prefix = `${this.rootPath}/`; + return entryId.startsWith(prefix) ? entryId.slice(prefix.length) : entryId; + } + + async createFile(parentId?: string, name?: string): Promise { + const entry = await super.createFile(parentId, name); + // Creates the remote file doc + a snapshot entry with its uri. + await this.engine.pushFile(this.vault, this.rootPath, this.rel(entry.id)); + return entry; + } + + // createDirectory: the remote directory doc is created lazily by the engine + // when the first child file is pushed (updateParentDirectories), so the + // disk-only behaviour inherited from LocalFilesystemProvider is sufficient. + + async renameFile(entryId: string, newName: string): Promise { + const entry = await super.renameFile(entryId, newName); + // Rename = delete old + add new on disk; sync()'s move detection + // reconciles the remote doc in place. + await this.engine.sync(this.vault, this.rootPath); + return entry; + } + + async deleteFile(entryId: string): Promise { + await super.deleteFile(entryId); + await this.engine.sync(this.vault, this.rootPath); + } + + async copyFile(entryId: string): Promise { + const entry = await super.copyFile(entryId); + await this.engine.pushFile(this.vault, this.rootPath, this.rel(entry.id)); + return entry; + } +} diff --git a/src/vault/engine.test.ts b/src/vault/engine.test.ts index 20b196a..110f36a 100644 --- a/src/vault/engine.test.ts +++ b/src/vault/engine.test.ts @@ -1,161 +1,18 @@ import * as nodeFs from 'fs'; import * as nodePath from 'path'; import * as os from 'os'; -import * as Automerge from '@automerge/automerge'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { VaultSyncEngine } from './engine'; import { VaultChangeDetector } from './change-detection'; import { VaultSnapshotManager } from './snapshot'; +import { InMemoryHabitatRepo, NodeFilesystemApi } from './testing/fakeHabitat'; import type { DirectoryDocument, FileDocument, - HabitatDocHandle, - HabitatRepo, HabitatUri, VaultConfig, } from './types'; -import type { FilesystemApi, FsDirent } from '../filesystem/types'; - -// --------------------------------------------------------------------------- -// Node-native FilesystemApi -// --------------------------------------------------------------------------- - -class NodeFilesystemApi implements FilesystemApi { - async readdir(dirPath: string): Promise { - const entries = nodeFs.readdirSync(dirPath, { withFileTypes: true }); - return entries.map((e) => ({ - name: e.name, - isDirectory: e.isDirectory(), - isFile: e.isFile(), - })); - } - - async readFile(filePath: string): Promise { - return nodeFs.readFileSync(filePath, 'utf-8'); - } - - async writeFile(filePath: string, content: string): Promise { - nodeFs.writeFileSync(filePath, content, 'utf-8'); - } - - async fileExists(filePath: string): Promise { - return nodeFs.existsSync(filePath); - } - - async mkdir(dirPath: string): Promise { - nodeFs.mkdirSync(dirPath, { recursive: true }); - } - - async rename(oldPath: string, newPath: string): Promise { - nodeFs.renameSync(oldPath, newPath); - } - - async deletePath(filePath: string): Promise { - nodeFs.rmSync(filePath, { recursive: true, force: true }); - } - - async copyFile(src: string, dest: string): Promise { - nodeFs.copyFileSync(src, dest); - } -} - -// --------------------------------------------------------------------------- -// In-memory HabitatRepo backed by real Automerge -// --------------------------------------------------------------------------- - -class InMemoryDocHandle implements HabitatDocHandle { - constructor( - private uri: HabitatUri, - private store: Map>, - ) {} - - private get current(): Automerge.Doc { - return this.store.get(this.uri) as Automerge.Doc; - } - - doc(): Automerge.Doc { - return this.current; - } - - heads(): string[] { - return Automerge.getHeads(this.current); - } - - change(fn: (d: T) => void): void { - const next = Automerge.change(this.current, fn as Automerge.ChangeFn); - this.store.set(this.uri, next); - } - - changeAt(heads: string[], fn: (d: T) => void): void { - const result = Automerge.changeAt( - this.current, - heads, - fn as Automerge.ChangeFn, - ); - if (result.newHeads !== null) { - this.store.set(this.uri, result.newDoc); - } - } - - view(heads: string[]): Automerge.Doc { - return Automerge.view(this.current, heads) as Automerge.Doc; - } - - on(_event: 'change', _cb: () => void): () => void { - return () => {}; - } -} - -class InMemoryHabitatRepo implements HabitatRepo { - private store = new Map>(); - private counter = 0; - - private makeUri(collection: string): HabitatUri { - return `at://did:plc:test/${collection}/${++this.counter}` as HabitatUri; - } - - private handle(uri: HabitatUri): InMemoryDocHandle { - return new InMemoryDocHandle(uri, this.store); - } - - async find(uri: HabitatUri): Promise> { - if (!this.store.has(uri)) { - throw new Error(`[InMemoryHabitatRepo] doc not found: ${uri}`); - } - return this.handle(uri); - } - - async createDirectory( - initial: DirectoryDocument, - ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { - const uri = this.makeUri('network.habitat.vault.directory'); - this.store.set(uri, Automerge.from(initial as unknown as Record)); - return { uri, handle: this.handle(uri) }; - } - - async createFile( - initial: FileDocument, - ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { - const uri = this.makeUri('network.habitat.vault.file'); - this.store.set(uri, Automerge.from(initial as unknown as Record)); - return { uri, handle: this.handle(uri) }; - } - - async delete(uri: HabitatUri): Promise { - this.store.delete(uri); - } - - /** Test helper: directly read a doc from the store. */ - getDoc(uri: HabitatUri): Automerge.Doc | undefined { - return this.store.get(uri) as Automerge.Doc | undefined; - } - - /** Test helper: count docs in the store. */ - docCount(): number { - return this.store.size; - } -} // --------------------------------------------------------------------------- // Test fixture helpers diff --git a/src/vault/engine.ts b/src/vault/engine.ts index 8e94198..f399151 100644 --- a/src/vault/engine.ts +++ b/src/vault/engine.ts @@ -165,6 +165,33 @@ export class VaultSyncEngine { 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 diff --git a/src/vault/registry.ts b/src/vault/registry.ts index f0701d7..f143298 100644 --- a/src/vault/registry.ts +++ b/src/vault/registry.ts @@ -1,4 +1,4 @@ -import type { VaultType } from './types'; +import type { HabitatUri, VaultType } from './types'; export interface OpenVault { /** Unique identifier for this open vault instance. */ @@ -10,6 +10,11 @@ export interface OpenVault { type: VaultType; /** DID of the Atmosphere account that owns this vault (null for local-only). */ did: string | null; + /** + * Habitat URI of the root directory record. Set for synced vaults (created + * when the vault is first provisioned); undefined for local vaults. + */ + rootUri?: HabitatUri; } const STORAGE_KEY = 'textile.openVaults'; @@ -23,13 +28,14 @@ function loadPersisted(): OpenVault[] { if (!raw) return []; const parsed = JSON.parse(raw) as Array>; if (!Array.isArray(parsed)) return []; - // Migrate old entries that may not have the `did` field. + // Migrate old entries that may not have the `did`/`rootUri` fields. return parsed.map((entry) => ({ id: entry.id ?? '', name: entry.name ?? '', path: entry.path ?? '', type: entry.type ?? 'local', did: entry.did ?? null, + rootUri: entry.rootUri, })); } catch { return []; diff --git a/src/vault/testing/fakeHabitat.ts b/src/vault/testing/fakeHabitat.ts new file mode 100644 index 0000000..2c9c78d --- /dev/null +++ b/src/vault/testing/fakeHabitat.ts @@ -0,0 +1,253 @@ +/** + * Test-only in-memory fakes for the vault stack. Imported ONLY by `*.test.ts`. + * + * These are fakes (real in-memory state), not mocks: assertions are made on + * their state — the doc store, on-disk files, the snapshot — rather than on + * call order. The repo and the doc session share a single `DocStore`, so edits + * made through a session are visible to the engine via the repo and vice-versa, + * matching the production relationship where `acquireDocSession` and + * `HabitatRepoImpl` wrap the same underlying record. + */ +import * as nodeFs from 'fs'; +import * as Automerge from '@automerge/automerge'; + +import type { AutomergeDocSession } from '../../habitat/automergeDocSession'; +import type { AutomergeDocSchema } from '../../editors/automerge/automergeEditorBinding'; +import type { FilesystemApi, FsDirent } from '../../filesystem/types'; +import type { + DirectoryDocument, + FileDocument, + HabitatDocHandle, + HabitatRepo, + HabitatUri, +} from '../types'; + +// --------------------------------------------------------------------------- +// Shared in-memory document store +// --------------------------------------------------------------------------- + +export type DocStore = Map>; + +// --------------------------------------------------------------------------- +// Node-native FilesystemApi — a fake of the IPC-backed VaultFilesystemApi that +// writes real files to disk so tests can assert on their contents. +// --------------------------------------------------------------------------- + +export class NodeFilesystemApi implements FilesystemApi { + async readdir(dirPath: string): Promise { + const entries = nodeFs.readdirSync(dirPath, { withFileTypes: true }); + return entries.map((e) => ({ + name: e.name, + isDirectory: e.isDirectory(), + isFile: e.isFile(), + })); + } + + async readFile(filePath: string): Promise { + return nodeFs.readFileSync(filePath, 'utf-8'); + } + + async writeFile(filePath: string, content: string): Promise { + nodeFs.writeFileSync(filePath, content, 'utf-8'); + } + + async fileExists(filePath: string): Promise { + return nodeFs.existsSync(filePath); + } + + async mkdir(dirPath: string): Promise { + nodeFs.mkdirSync(dirPath, { recursive: true }); + } + + async rename(oldPath: string, newPath: string): Promise { + nodeFs.renameSync(oldPath, newPath); + } + + async deletePath(filePath: string): Promise { + nodeFs.rmSync(filePath, { recursive: true, force: true }); + } + + async copyFile(src: string, dest: string): Promise { + nodeFs.copyFileSync(src, dest); + } +} + +// --------------------------------------------------------------------------- +// In-memory HabitatRepo backed by real Automerge over a shared DocStore +// --------------------------------------------------------------------------- + +class InMemoryDocHandle implements HabitatDocHandle { + constructor( + private uri: HabitatUri, + private store: DocStore, + ) {} + + private get current(): Automerge.Doc { + return this.store.get(this.uri) as Automerge.Doc; + } + + doc(): Automerge.Doc { + return this.current; + } + + heads(): string[] { + return Automerge.getHeads(this.current); + } + + change(fn: (d: T) => void): void { + const next = Automerge.change(this.current, fn as Automerge.ChangeFn); + this.store.set(this.uri, next); + } + + changeAt(heads: string[], fn: (d: T) => void): void { + const result = Automerge.changeAt( + this.current, + heads, + fn as Automerge.ChangeFn, + ); + if (result.newHeads !== null) { + this.store.set(this.uri, result.newDoc); + } + } + + view(heads: string[]): Automerge.Doc { + return Automerge.view(this.current, heads) as Automerge.Doc; + } + + on(_event: 'change', _cb: () => void): () => void { + return () => {}; + } +} + +export class InMemoryHabitatRepo implements HabitatRepo { + private counter = 0; + + constructor(public readonly store: DocStore = new Map()) {} + + private makeUri(collection: string): HabitatUri { + return `at://did:plc:test/${collection}/${++this.counter}` as HabitatUri; + } + + private handle(uri: HabitatUri): InMemoryDocHandle { + return new InMemoryDocHandle(uri, this.store); + } + + async find(uri: HabitatUri): Promise> { + if (!this.store.has(uri)) { + throw new Error(`[InMemoryHabitatRepo] doc not found: ${uri}`); + } + return this.handle(uri); + } + + async createDirectory( + initial: DirectoryDocument, + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { + const uri = this.makeUri('network.habitat.vault.directory'); + this.store.set(uri, Automerge.from(initial as unknown as Record)); + return { uri, handle: this.handle(uri) }; + } + + async createFile( + initial: FileDocument, + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { + const uri = this.makeUri('network.habitat.vault.file'); + this.store.set(uri, Automerge.from(initial as unknown as Record)); + return { uri, handle: this.handle(uri) }; + } + + async delete(uri: HabitatUri): Promise { + this.store.delete(uri); + } + + /** Test helper: directly read a doc from the store. */ + getDoc(uri: HabitatUri): Automerge.Doc | undefined { + return this.store.get(uri) as Automerge.Doc | undefined; + } + + /** Test helper: count docs in the store. */ + docCount(): number { + return this.store.size; + } +} + +// --------------------------------------------------------------------------- +// Fake live document session backed by the same DocStore +// --------------------------------------------------------------------------- + +export class FakeDocSession implements AutomergeDocSession { + released = false; + readonly provider = null; + readonly node = null; + readonly ownerRecord = {} as AutomergeDocSession['ownerRecord']; + readonly loadPromise = Promise.resolve(); + + private listeners = new Set<() => void>(); + + constructor( + public readonly uri: string, + private store: DocStore, + ) {} + + get doc(): Automerge.Doc { + return this.store.get(this.uri as HabitatUri) as Automerge.Doc; + } + + getTitle = (): string => { + const doc = this.doc as { name?: unknown } | undefined; + return typeof doc?.name === 'string' ? doc.name : ''; + }; + + applyChange = (fn: (doc: AutomergeDocSchema) => void): void => { + this.store.set( + this.uri as HabitatUri, + Automerge.change(this.doc, fn as Automerge.ChangeFn), + ); + this.notify(); + }; + + changeAt = (heads: string[], fn: (doc: AutomergeDocSchema) => void): void => { + const result = Automerge.changeAt( + this.doc, + heads, + fn as Automerge.ChangeFn, + ); + if (result.newHeads !== null) { + this.store.set(this.uri as HabitatUri, result.newDoc); + this.notify(); + } + }; + + subscribe = (cb: () => void): (() => void) => { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + }; + + resync = async (): Promise => {}; + + release = (): void => { + this.released = true; + }; + + private notify(): void { + for (const listener of this.listeners) listener(); + } +} + +/** + * Build a fake session factory over a shared `DocStore`. One `FakeDocSession` + * is reused per uri so tests can read its state after opening a document. + */ +export function makeFakeSessions(store: DocStore) { + const sessions = new Map(); + return { + acquire: (uri: string): FakeDocSession => { + let session = sessions.get(uri); + if (!session) { + session = new FakeDocSession(uri, store); + sessions.set(uri, session); + } + return session; + }, + get: (uri: string): FakeDocSession | undefined => sessions.get(uri), + }; +}