Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
6.8 kB · 233 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234import 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<FileSystemTreeNode[]> { return this.scanTree(this.rootPath); }
async listFiles(): Promise<FileSystemEntry[]> { const tree = await this.getTree(); return tree.map((node) => ({ id: node.id, name: node.name })); }
async createFile( parentId?: string, name?: string, content = '', ): Promise<FileSystemEntry> { 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<FileSystemEntry> { 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<FileSystemEntry> { 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<FileSystemEntry> { 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<void> { await this.fs.deletePath(entryId); }
async copyFile(entryId: string): Promise<FileSystemEntry> { 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<FileSystemTreeNode[]> { 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; }); }}