import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode, FilesystemApi, } from '../types'; import type { DocumentProvider } from '../../documents/types'; import type { EditorKind } from '../../editors/types'; import { basename, dirname, join } from '../vaultFs'; export type LocalFilesystemProviderOptions = { /** Absolute filesystem path to the root directory. */ rootPath: string; /** Display name shown in the sidebar. */ displayName: string; /** Stable id for React keys. */ id: string; documents: DocumentProvider; defaultEditorKind?: EditorKind; fs: FilesystemApi; }; /** * Filesystem provider for a single local directory. * Renders the directory's recursive tree in the sidebar. * All filesystem operations route through the main process via IPC. * No syncing — purely local. */ export class LocalFilesystemProvider implements FileSystemProvider { readonly id: string; readonly displayName: string; readonly idleHint = 'Directory is loading…'; readonly documents: DocumentProvider; readonly defaultEditorKind: EditorKind; readonly rootPath: string; private fs: FilesystemApi; private readyListeners: Set<() => void> = new Set(); private titleListeners: Set<(entryId: string, name: string) => void> = new Set(); private _isReady = true; constructor(options: LocalFilesystemProviderOptions) { this.id = options.id; this.displayName = options.displayName; this.rootPath = options.rootPath; this.documents = options.documents; this.defaultEditorKind = options.defaultEditorKind ?? 'automerge'; this.fs = options.fs; } isReady(): boolean { return this._isReady; } subscribeReady(listener: () => void): () => void { this.readyListeners.add(listener); return () => { this.readyListeners.delete(listener); }; } subscribeEntryTitles( listener: (entryId: string, name: string) => void, ): () => void { this.titleListeners.add(listener); return () => { this.titleListeners.delete(listener); }; } async getTree(): Promise { return this.scanTree(this.rootPath); } async listFiles(): Promise { const tree = await this.getTree(); return tree.map((node) => ({ id: node.id, name: node.name })); } async createFile( parentId?: string, name?: string, content = '', ): Promise { const parent = parentId ?? this.rootPath; let fileName: string; if (!name || !name.trim()) { fileName = 'Untitled.md'; let fullPath = join(parent, fileName); let counter = 1; while (await this.fs.fileExists(fullPath)) { const base = fileName.replace(/\.md$/, ''); fileName = `${base}-${counter}.md`; fullPath = join(parent, fileName); counter++; } } else { fileName = name.trim(); if (!fileName.includes('.')) { fileName += '.md'; } } const fullPath = join(parent, fileName); try { if (await this.fs.fileExists(fullPath)) { throw new Error(`A file named "${fileName}" already exists`); } await this.fs.writeFile(fullPath, content); } catch (error) { throw new Error( `Could not create file: ${error instanceof Error ? error.message : String(error)}`, ); } return { id: fullPath, name: fileName }; } async createDirectory( name: string, parentId?: string, ): Promise { const parent = parentId ?? this.rootPath; const dirPath = join(parent, name); try { if (await this.fs.fileExists(dirPath)) { throw new Error(`A directory named "${name}" already exists`); } await this.fs.mkdir(dirPath); } catch (error) { throw new Error( `Could not create directory: ${error instanceof Error ? error.message : String(error)}`, ); } return { id: dirPath, name }; } async renameFile(entryId: string, newName: string): Promise { const parent = dirname(entryId); const newPath = join(parent, newName); try { await this.fs.rename(entryId, newPath); } catch (error) { throw new Error( `Could not rename: ${error instanceof Error ? error.message : String(error)}`, ); } return { id: newPath, name: newName }; } async moveFile( entryId: string, targetDirId: string, ): Promise { const name = basename(entryId); const newPath = join(targetDirId, name); if (newPath === entryId) return { id: entryId, name }; try { if (await this.fs.fileExists(newPath)) { throw new Error(`A file named "${name}" already exists here`); } await this.fs.rename(entryId, newPath); } catch (error) { throw new Error( `Could not move: ${error instanceof Error ? error.message : String(error)}`, ); } return { id: newPath, name }; } async deleteFile(entryId: string): Promise { await this.fs.deletePath(entryId); } async copyFile(entryId: string): Promise { const name = basename(entryId); const parent = dirname(entryId); const extMatch = name.match(/(\.[a-zA-Z0-9]+)$/); const ext = extMatch ? extMatch[1] : ''; const base = ext ? name.slice(0, -ext.length) : name; let newName = `${base} copy${ext}`; let newPath = join(parent, newName); let counter = 2; while (await this.fs.fileExists(newPath)) { newName = `${base} copy ${counter}${ext}`; newPath = join(parent, newName); counter++; } const content = await this.fs.readFile(entryId); await this.fs.writeFile(newPath, content); return { id: newPath, name: newName }; } get rootId(): string { return this.rootPath; } getPath(entryId: string): string { return entryId; } // --- Local directory scanning (via IPC) --- private async scanTree(dirPath: string): Promise { const entries = await this.fs.readdir(dirPath); const nodes: FileSystemTreeNode[] = []; for (const entry of entries) { // Skip hidden files and .textile metadata if (entry.name.startsWith('.')) continue; const fullPath = join(dirPath, entry.name); if (entry.isDirectory) { nodes.push({ id: fullPath, name: entry.name, type: 'folder', children: await this.scanTree(fullPath), }); } else if (entry.isFile) { nodes.push({ id: fullPath, name: entry.name, type: 'file', }); } } return nodes.sort((a, b) => { if (a.type === b.type) return a.name.localeCompare(b.name); return a.type === 'folder' ? -1 : 1; }); } }