UNPKG

@firesystem/indexeddb

Version:

IndexedDB implementation of Virtual File System

1,463 lines (1,242 loc) 43.2 kB
import type { FileEntry, FileStat, FileMetadata, FSEvent, Disposable, FileSystemEventPayloads, IFileSystemCapabilities, } from "@firesystem/core"; import { normalizePath, dirname, basename, join, TypedEventEmitter, FileSystemEvents, BaseFileSystem, } from "@firesystem/core"; import type { IndexedDBConfig } from "./types"; interface StoredFile { path: string; type: "file" | "directory"; content?: any; size: number; created: Date; modified: Date; metadata?: FileMetadata; parent: string; } interface WatchListener { id: string; pattern: string; callback: (event: FSEvent) => void; } interface FileSnapshot { path: string; type: "file" | "directory"; modified: Date; size: number; contentHash?: string; metadataHash?: string; } export class IndexedDBFileSystem extends BaseFileSystem { private db: IDBDatabase | null = null; private config: Required< Omit<IndexedDBConfig, "enableExternalSync" | "deepChangeDetection"> > & { enableExternalSync: boolean; deepChangeDetection: boolean; }; private watchers: WatchListener[] = []; private isInitialized = false; public readonly events = new TypedEventEmitter<FileSystemEventPayloads>(); private broadcastChannel: BroadcastChannel | null = null; private fileSnapshots: Map<string, FileSnapshot> = new Map(); private pollingInterval: NodeJS.Timeout | null = null; private enableExternalSync = true; private deepChangeDetection = false; // Capabilities do IndexedDB readonly capabilities: IFileSystemCapabilities = { readonly: false, caseSensitive: true, atomicRename: false, // IndexedDB não tem rename atômico verdadeiro supportsWatch: true, supportsMetadata: true, supportsGlob: true, maxFileSize: undefined, // Depende do browser e quota maxPathLength: 1024, }; constructor(config: IndexedDBConfig) { super(); this.config = { dbName: config.dbName, version: config.version || 1, storeName: config.storeName || "files", enableExternalSync: config.enableExternalSync ?? true, deepChangeDetection: config.deepChangeDetection ?? false, }; this.enableExternalSync = config.enableExternalSync ?? true; this.deepChangeDetection = config.deepChangeDetection ?? false; if (this.enableExternalSync && typeof BroadcastChannel !== "undefined") { this.setupBroadcastChannel(); } } private async ensureDB(): Promise<IDBDatabase> { if (this.db && this.db.name === this.config.dbName) { return this.db; } return new Promise((resolve, reject) => { const openWithVersion = async (version: number): Promise<void> => { const request = indexedDB.open(this.config.dbName, version); request.onerror = () => { reject(new Error(`Failed to open IndexedDB: ${request.error}`)); }; request.onsuccess = async () => { const db = request.result; // Check if the expected object store exists if (!db.objectStoreNames.contains(this.config.storeName)) { // Database exists but with wrong schema - need to recreate console.warn( `Database '${this.config.dbName}' exists but missing object store '${this.config.storeName}'. Recreating database...`, ); db.close(); // Delete the corrupted database await new Promise<void>((resolve, reject) => { const deleteRequest = indexedDB.deleteDatabase( this.config.dbName, ); deleteRequest.onsuccess = () => resolve(); deleteRequest.onerror = () => reject( new Error( `Failed to delete corrupted database: ${deleteRequest.error}`, ), ); }); // Reopen with the same version - will trigger onupgradeneeded since DB was deleted await openWithVersion(version); return; } this.db = db; this.isInitialized = true; // Handle connection close this.db.onclose = () => { this.db = null; this.isInitialized = false; }; // Ensure root exists await this.ensureRootExists(); resolve(this.db); }; request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; // Create object store if it doesn't exist if (!db.objectStoreNames.contains(this.config.storeName)) { const store = db.createObjectStore(this.config.storeName, { keyPath: "path", }); // Create indexes for efficient queries store.createIndex("type", "type", { unique: false }); store.createIndex("parent", "parent", { unique: false }); store.createIndex("modified", "modified", { unique: false }); // Add root directory const now = new Date(); store.add({ path: "/", type: "directory", size: 0, created: now, modified: now, parent: "", }); } }; }; // Start with the configured version openWithVersion(this.config.version); }); } async initialize(): Promise<void> { const startTime = Date.now(); this.events.emit(FileSystemEvents.INITIALIZING, undefined as any); try { // Ensure database connection const db = await this.ensureDB(); // Load ALL files from database const allFiles = await this.loadAllFiles(); const fileCount = allFiles.length; // Emit progress for each file loaded for (let i = 0; i < allFiles.length; i++) { const file = allFiles[i]; // Emit individual file events if (file.type === "file") { this.events.emit(FileSystemEvents.FILE_READ, { path: file.path, size: file.size, }); } else { this.events.emit(FileSystemEvents.DIR_READ, { path: file.path, count: 0, // We'll count children later if needed }); } // Emit progress this.events.emit(FileSystemEvents.INIT_PROGRESS, { loaded: i + 1, total: fileCount, phase: "loading-files", }); } this.events.emit(FileSystemEvents.INITIALIZED, { duration: Date.now() - startTime, }); } catch (error) { this.events.emit(FileSystemEvents.INIT_ERROR, { error: error as Error, }); throw error; } } private async loadAllFiles(): Promise<StoredFile[]> { const db = await this.ensureDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readonly"); const store = transaction.objectStore(this.config.storeName); const request = store.getAll(); request.onsuccess = () => { const files = request.result as StoredFile[]; // Exclude root directory from the list const userFiles = files.filter((f) => f.path !== "/"); resolve(userFiles); }; request.onerror = () => { reject(new Error(`Failed to load files: ${request.error}`)); }; }); } async readFile(path: string): Promise<FileEntry> { const normalized = normalizePath(path); const db = await this.ensureDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readonly"); const store = transaction.objectStore(this.config.storeName); const request = store.get(normalized); request.onsuccess = () => { const file = request.result as StoredFile; if (!file) { reject( new Error(`ENOENT: no such file or directory, open '${path}'`), ); return; } if (file.type === "directory") { reject( new Error( `EISDIR: illegal operation on a directory, read '${path}'`, ), ); return; } resolve({ path: file.path, name: basename(file.path), type: file.type, size: file.size, created: file.created, modified: file.modified, metadata: file.metadata, content: file.content, }); }; request.onerror = () => { reject(new Error(`Failed to read file: ${request.error}`)); }; }); } async writeFile( path: string, content: any, metadata?: FileMetadata, ): Promise<FileEntry> { const normalized = normalizePath(path); const db = await this.ensureDB(); // Ensure parent directory exists await this.ensureParentExists(normalized); const now = new Date(); const size = this.calculateSize(content); return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readwrite"); const store = transaction.objectStore(this.config.storeName); // Check if file exists to preserve created date const getRequest = store.get(normalized); getRequest.onsuccess = () => { const existing = getRequest.result as StoredFile | undefined; const isUpdate = !!existing; const storedFile: StoredFile = { path: normalized, type: "file", content, size, created: existing?.created || now, modified: now, metadata, parent: dirname(normalized), } as StoredFile; const putRequest = store.put(storedFile); putRequest.onsuccess = () => { const entry: FileEntry = { path: normalized, name: basename(normalized), type: "file", size, created: storedFile.created, modified: storedFile.modified, metadata, content, }; // Notify watchers const event = { type: isUpdate ? "updated" : ("created" as "updated" | "created"), path: normalized, timestamp: now, }; this.notifyWatchers(event); // Broadcast to other tabs/windows this.broadcastChange({ ...event, size, isDirectory: false, fileType: "file" as const, }); resolve(entry); }; putRequest.onerror = () => { reject(new Error(`Failed to write file: ${putRequest.error}`)); }; }; getRequest.onerror = () => { reject(new Error(`Failed to check existing file: ${getRequest.error}`)); }; }); } async deleteFile(path: string): Promise<void> { const normalized = normalizePath(path); const db = await this.ensureDB(); // First check if it exists and get type const file = await this.stat(normalized).catch(() => null); if (!file) { throw new Error(`ENOENT: no such file or directory, unlink '${path}'`); } return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readwrite"); const store = transaction.objectStore(this.config.storeName); if (file.type === "directory") { // Check if directory is empty const index = store.index("parent"); const request = index.count(normalized); request.onsuccess = () => { if (request.result > 0) { reject( new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`), ); return; } // Directory is empty, delete it const deleteRequest = store.delete(normalized); deleteRequest.onsuccess = () => { const event = { type: "deleted" as const, path: normalized, timestamp: new Date(), }; this.notifyWatchers(event); this.broadcastChange(event); resolve(); }; deleteRequest.onerror = () => { reject( new Error(`Failed to delete directory: ${deleteRequest.error}`), ); }; }; request.onerror = () => { reject( new Error(`Failed to check directory contents: ${request.error}`), ); }; } else { // It's a file, delete directly const request = store.delete(normalized); request.onsuccess = () => { const event = { type: "deleted" as const, path: normalized, timestamp: new Date(), }; this.notifyWatchers(event); this.broadcastChange(event); resolve(); }; request.onerror = () => { reject(new Error(`Failed to delete file: ${request.error}`)); }; } }); } async exists(path: string): Promise<boolean> { const normalized = normalizePath(path); const db = await this.ensureDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readonly"); const store = transaction.objectStore(this.config.storeName); const request = store.count(normalized); request.onsuccess = () => { resolve(request.result > 0); }; request.onerror = () => { reject(new Error(`Failed to check existence: ${request.error}`)); }; }); } async readDir(path: string): Promise<FileEntry[]> { const normalized = normalizePath(path); const db = await this.ensureDB(); // First check if directory exists if (normalized !== "/" && !(await this.exists(normalized))) { throw new Error(`ENOENT: no such file or directory, scandir '${path}'`); } return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readonly"); const store = transaction.objectStore(this.config.storeName); const index = store.index("parent"); const request = index.getAll(normalized); request.onsuccess = () => { const files = request.result as StoredFile[]; const entries = files.map((file) => ({ path: file.path, name: basename(file.path), type: file.type, size: file.size, created: file.created, modified: file.modified, metadata: file.metadata, })); resolve(entries); }; request.onerror = () => { reject(new Error(`Failed to read directory: ${request.error}`)); }; }); } async mkdir(path: string, recursive: boolean = false): Promise<FileEntry> { const normalized = normalizePath(path); if (normalized === "/") { throw new Error(`EEXIST: file already exists, mkdir '${path}'`); } const db = await this.ensureDB(); // Check if already exists if (await this.exists(normalized)) { throw new Error(`EEXIST: file already exists, mkdir '${path}'`); } // Ensure parent exists if not recursive const parent = dirname(normalized); if (!recursive && parent !== "/" && !(await this.exists(parent))) { throw new Error(`ENOENT: no such file or directory, mkdir '${path}'`); } // Create parent directories if recursive if (recursive && parent !== "/" && !(await this.exists(parent))) { await this.mkdir(parent, true); } const now = new Date(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readwrite"); const store = transaction.objectStore(this.config.storeName); const storedFile: StoredFile = { path: normalized, type: "directory", size: 0, created: now, modified: now, parent: dirname(normalized), } as StoredFile; const request = store.add(storedFile); request.onsuccess = () => { const entry: FileEntry = { path: normalized, name: basename(normalized), type: "directory", size: 0, created: now, modified: now, }; const event = { type: "created" as const, path: normalized, timestamp: now, }; this.notifyWatchers(event); this.broadcastChange({ ...event, isDirectory: true, fileType: "directory" as const, }); resolve(entry); }; request.onerror = () => { reject(new Error(`Failed to create directory: ${request.error}`)); }; }); } async rmdir(path: string, recursive: boolean = false): Promise<void> { const normalized = normalizePath(path); if (normalized === "/") { throw new Error(`EBUSY: resource busy or locked, rmdir '${path}'`); } const db = await this.ensureDB(); // Check if directory exists const stat = await this.stat(normalized).catch(() => null); if (!stat) { throw new Error(`ENOENT: no such file or directory, rmdir '${path}'`); } if (stat.type !== "directory") { throw new Error(`ENOTDIR: not a directory, rmdir '${path}'`); } if (recursive) { // Delete all contents recursively await this.deleteRecursive(normalized); } else { // Check if empty const contents = await this.readDir(normalized); if (contents.length > 0) { throw new Error(`ENOTEMPTY: directory not empty, rmdir '${path}'`); } } // Delete the directory itself await this.deleteFile(normalized); } async rename(oldPath: string, newPath: string): Promise<FileEntry> { const oldNormalized = normalizePath(oldPath); const newNormalized = normalizePath(newPath); const db = await this.ensureDB(); // Get the file/directory const oldStat = await this.stat(oldNormalized); // Ensure new parent exists await this.ensureParentExists(newNormalized); // Check if new path already exists if (await this.exists(newNormalized)) { throw new Error( `EEXIST: file already exists, rename '${oldPath}' -> '${newPath}'`, ); } const now = new Date(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readwrite"); const store = transaction.objectStore(this.config.storeName); // If it's a directory, we need to update all children if (oldStat.type === "directory") { // Get all descendants const request = store.openCursor(); const updates: StoredFile[] = []; request.onsuccess = (event) => { const cursor = (event.target as IDBRequest).result; if (cursor) { const file = cursor.value as StoredFile; // Check if this file is under the old path if ( file.path.startsWith(oldNormalized + "/") || file.path === oldNormalized ) { const newFilePath = file.path.replace( oldNormalized, newNormalized, ); updates.push({ ...file, path: newFilePath, parent: dirname(newFilePath), modified: file.path === oldNormalized ? now : file.modified, }); } cursor.continue(); } else { // All files collected, now update them let completed = 0; const total = updates.length; if (total === 0) { reject(new Error(`Directory not found: ${oldPath}`)); return; } updates.forEach((file) => { // Delete old entry store.delete(file.path.replace(newNormalized, oldNormalized)); // Add new entry const addRequest = store.add(file); addRequest.onsuccess = () => { completed++; if (completed === total) { const entry = updates.find((f) => f.path === newNormalized)!; const event = { type: "renamed" as const, path: newNormalized, oldPath: oldNormalized, timestamp: now, }; this.notifyWatchers(event); this.broadcastChange(event); resolve({ path: entry.path, name: basename(entry.path), type: entry.type, size: entry.size, created: entry.created, modified: entry.modified, metadata: entry.metadata, }); } }; addRequest.onerror = () => { reject( new Error(`Failed to rename directory: ${addRequest.error}`), ); }; }); } }; request.onerror = () => { reject(new Error(`Failed to read files: ${request.error}`)); }; } else { // It's a file, simpler case const getRequest = store.get(oldNormalized); getRequest.onsuccess = () => { const file = getRequest.result as StoredFile; if (!file) { reject(new Error(`File not found: ${oldPath}`)); return; } // Delete old entry store.delete(oldNormalized); // Create new entry const newFile: StoredFile = { ...file, path: newNormalized, parent: dirname(newNormalized), modified: now, }; const addRequest = store.add(newFile); addRequest.onsuccess = () => { const entry: FileEntry = { path: newNormalized, name: basename(newNormalized), type: file.type, size: file.size, created: file.created, modified: now, metadata: file.metadata, content: file.content, }; const event = { type: "renamed" as const, path: newNormalized, oldPath: oldNormalized, timestamp: now, }; this.notifyWatchers(event); this.broadcastChange(event); resolve(entry); }; addRequest.onerror = () => { reject(new Error(`Failed to rename file: ${addRequest.error}`)); }; }; getRequest.onerror = () => { reject(new Error(`Failed to read file: ${getRequest.error}`)); }; } }); } async move(sourcePaths: string[], targetPath: string): Promise<void> { const targetNormalized = normalizePath(targetPath); // Ensure target is a directory const targetStat = await this.stat(targetNormalized).catch(() => null); if (!targetStat || targetStat.type !== "directory") { throw new Error(`ENOTDIR: not a directory, move to '${targetPath}'`); } for (const sourcePath of sourcePaths) { const name = basename(sourcePath); const newPath = join(targetNormalized, name); await this.rename(sourcePath, newPath); } } async copy(sourcePath: string, targetPath: string): Promise<FileEntry> { const sourceNormalized = normalizePath(sourcePath); const targetNormalized = normalizePath(targetPath); // Read source file const source = await this.readFile(sourceNormalized); // Write to target return this.writeFile(targetNormalized, source.content, source.metadata); } watch(pattern: string, callback: (event: FSEvent) => void): Disposable { const id = this.generateWatcherId(); const listener: WatchListener = { id, pattern, callback }; this.watchers.push(listener); return { dispose: () => { const index = this.watchers.findIndex((w) => w.id === id); if (index !== -1) { this.watchers.splice(index, 1); } }, }; } async stat(path: string): Promise<FileStat> { const normalized = normalizePath(path); const db = await this.ensureDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readonly"); const store = transaction.objectStore(this.config.storeName); const request = store.get(normalized); request.onsuccess = () => { const file = request.result as StoredFile; if (!file) { reject( new Error(`ENOENT: no such file or directory, stat '${path}'`), ); return; } resolve({ path: file.path, size: file.size, type: file.type, created: file.created, modified: file.modified, readonly: false, // Sempre false em IndexedDB }); }; request.onerror = () => { reject(new Error(`Failed to stat file: ${request.error}`)); }; }); } async glob(pattern: string): Promise<string[]> { const db = await this.ensureDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readonly"); const store = transaction.objectStore(this.config.storeName); const request = store.getAll(); request.onsuccess = () => { const files = request.result as StoredFile[]; const paths = files .map((file) => file.path) .filter((path) => this.matchesPattern(path, pattern)) .sort(); resolve(paths); }; request.onerror = () => { reject(new Error(`Failed to glob: ${request.error}`)); }; }); } async clear(): Promise<void> { const startTime = Date.now(); const operationId = `clear-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "clear", id: operationId, }); this.events.emit(FileSystemEvents.STORAGE_CLEARING, undefined as any); try { const db = await this.ensureDB(); await new Promise<void>((resolve, reject) => { const transaction = db.transaction( [this.config.storeName], "readwrite", ); const store = transaction.objectStore(this.config.storeName); const request = store.clear(); request.onsuccess = () => { // Always ensure root directory exists after clear const rootTransaction = db.transaction( [this.config.storeName], "readwrite", ); const rootStore = rootTransaction.objectStore(this.config.storeName); const now = new Date(); const rootDir: StoredFile = { path: "/", type: "directory", size: 0, created: now, modified: now, parent: "", }; const rootRequest = rootStore.add(rootDir); rootRequest.onsuccess = () => resolve(); rootRequest.onerror = () => reject(new Error(`Failed to recreate root: ${rootRequest.error}`)); }; request.onerror = () => { reject(new Error(`Failed to clear store: ${request.error}`)); }; }); const duration = Date.now() - startTime; this.events.emit(FileSystemEvents.STORAGE_CLEARED, { duration }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "clear", id: operationId, duration, }); } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "clear", error: error as Error, }); throw error; } } async size(): Promise<number> { const startTime = Date.now(); const operationId = `size-${startTime}`; this.events.emit(FileSystemEvents.OPERATION_START, { operation: "size", id: operationId, }); try { const db = await this.ensureDB(); const totalSize = await new Promise<number>((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readonly"); const store = transaction.objectStore(this.config.storeName); const request = store.getAll(); request.onsuccess = () => { const files = request.result as StoredFile[]; const size = files.reduce((sum, file) => sum + file.size, 0); resolve(size); }; request.onerror = () => { reject(new Error(`Failed to calculate size: ${request.error}`)); }; }); this.events.emit(FileSystemEvents.STORAGE_SIZE_CALCULATED, { size: totalSize, }); this.events.emit(FileSystemEvents.OPERATION_END, { operation: "size", id: operationId, duration: Date.now() - startTime, }); return totalSize; } catch (error) { this.events.emit(FileSystemEvents.OPERATION_ERROR, { operation: "size", error: error as Error, }); throw error; } } // Helper methods private async calculateHash(data: any): Promise<string> { // Simple hash for content comparison const str = typeof data === "string" ? data : JSON.stringify(data); let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } return hash.toString(36); } private calculateSize(content: any): number { if (content === null || content === undefined) { return 0; } if (typeof content === "string") { return new Blob([content]).size; } if (content instanceof ArrayBuffer) { return content.byteLength; } if (content instanceof Blob) { return content.size; } if (typeof content === "object") { return new Blob([JSON.stringify(content)]).size; } return 0; } private async ensureParentExists(path: string): Promise<void> { const parent = dirname(path); if (parent === "/" || parent === path) return; if (!(await this.exists(parent))) { throw new Error(`ENOENT: no such file or directory, open '${path}'`); } } private async deleteRecursive(path: string): Promise<void> { const entries = await this.readDir(path); for (const entry of entries) { if (entry.type === "directory") { await this.deleteRecursive(entry.path); } await this.deleteFile(entry.path); } } private matchesPattern(path: string, pattern: string): boolean { // Handle simple cases if (pattern === "**" || pattern === "**/*") { return true; } if (pattern === "*") { // * should only match files in root directory return path.split("/").length === 2 && path !== "/"; } // Convert glob pattern to regex let regex = pattern // Escape special regex characters except glob ones .replace(/[.+^${}()|[\]\\]/g, "\\$&") // Handle ** (matches any number of directories) .replace(/\*\*/g, "___GLOBSTAR___") // Handle * (matches any characters except /) .replace(/\*/g, "[^/]*") // Handle ? (matches single character except /) .replace(/\?/g, "[^/]") // Restore ** handling .replace(/___GLOBSTAR___\//g, "(.*/)?") .replace(/\/___GLOBSTAR___/g, "(/.*)?") .replace(/___GLOBSTAR___/g, ".*"); // Anchor the pattern regex = "^" + regex + "$"; return new RegExp(regex).test(path); } private notifyWatchers(event: FSEvent): void { for (const watcher of this.watchers) { if (this.matchesPattern(event.path, watcher.pattern)) { try { watcher.callback(event); } catch (error) { console.error( `Error in watch callback for pattern "${watcher.pattern}":`, error, ); } } } } private generateWatcherId(): string { return `watch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } private async ensureRootExists(): Promise<void> { if (!this.db) return; const transaction = this.db.transaction( [this.config.storeName], "readonly", ); const store = transaction.objectStore(this.config.storeName); const request = store.get("/"); return new Promise((resolve) => { request.onsuccess = async () => { if (!request.result) { // Root doesn't exist, create it const writeTransaction = this.db!.transaction( [this.config.storeName], "readwrite", ); const writeStore = writeTransaction.objectStore( this.config.storeName, ); const now = new Date(); await new Promise<void>((res, rej) => { const addRequest = writeStore.add({ path: "/", type: "directory", size: 0, created: now, modified: now, parent: "", }); addRequest.onsuccess = () => res(); addRequest.onerror = () => rej(new Error("Failed to create root directory")); }); } resolve(); }; request.onerror = () => resolve(); // Ignore errors, we'll try next time }); } private setupBroadcastChannel(): void { this.broadcastChannel = new BroadcastChannel(`vfs-${this.config.dbName}`); this.broadcastChannel.onmessage = (event) => { const { type, path, oldPath, timestamp } = event.data; // Notify local watchers about external changes this.notifyWatchers({ type, path, oldPath, timestamp: new Date(timestamp), }); // Emit events for external changes switch (type) { case "created": if (event.data.isDirectory) { this.events.emit(FileSystemEvents.DIR_CREATED, { path }); } else { this.events.emit(FileSystemEvents.FILE_WRITTEN, { path, size: event.data.size || 0, }); } break; case "updated": this.events.emit(FileSystemEvents.FILE_WRITTEN, { path, size: event.data.size || 0, }); break; case "deleted": this.events.emit(FileSystemEvents.FILE_DELETED, { path }); break; case "renamed": // Emit as both deleted and created since there's no FILE_RENAMED event if (oldPath) { this.events.emit(FileSystemEvents.FILE_DELETED, { path: oldPath }); } this.events.emit(FileSystemEvents.FILE_WRITTEN, { path, size: event.data.size || 0, }); break; } }; } private broadcastChange( event: FSEvent & { size?: number; isDirectory?: boolean; fileType?: "file" | "directory"; }, ): void { if (this.broadcastChannel) { this.broadcastChannel.postMessage({ ...event, timestamp: event.timestamp.toISOString(), }); } } async startWatching(pollingIntervalMs: number = 250): Promise<void> { if (!this.enableExternalSync) return; // Take initial snapshot await this.updateFileSnapshots(); // Start polling for changes this.pollingInterval = setInterval(async () => { await this.checkForExternalChanges(); }, pollingIntervalMs); } async stopWatching(): Promise<void> { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } } private async updateFileSnapshots(): Promise<void> { const db = await this.ensureDB(); return new Promise((resolve, reject) => { const transaction = db.transaction([this.config.storeName], "readonly"); const store = transaction.objectStore(this.config.storeName); const request = store.getAll(); request.onsuccess = async () => { const files = request.result as StoredFile[]; this.fileSnapshots.clear(); for (const file of files) { // Include both files and directories in snapshots if (file.path !== "/") { // Skip root directory const snapshot: FileSnapshot = { path: file.path, type: file.type, modified: file.modified, size: file.size, }; // Calculate content hash if deep detection is enabled if (this.deepChangeDetection && file.type === "file") { snapshot.contentHash = await this.calculateHash(file.content); if (file.metadata) { snapshot.metadataHash = await this.calculateHash(file.metadata); } } this.fileSnapshots.set(file.path, snapshot); } } resolve(); }; request.onerror = () => { reject(new Error(`Failed to update snapshots: ${request.error}`)); }; }); } private async checkForExternalChanges(): Promise<void> { const db = await this.ensureDB(); const currentFiles = new Map<string, FileSnapshot>(); const filesToDeepCheck: string[] = []; return new Promise((resolve) => { const transaction = db.transaction([this.config.storeName], "readonly"); const store = transaction.objectStore(this.config.storeName); const request = store.getAll(); request.onsuccess = async () => { const files = request.result as StoredFile[]; // Build current state map - include both files and directories files.forEach((file) => { if (file.path !== "/") { // Skip root directory currentFiles.set(file.path, { path: file.path, type: file.type, modified: file.modified, size: file.size, }); } }); // Phase 1: Quick check (date/size) currentFiles.forEach((current, path) => { const snapshot = this.fileSnapshots.get(path); if (!snapshot) { // New file or directory this.notifyWatchers({ type: "created", path, timestamp: new Date(), }); // Also emit proper events if (current.type === "directory") { this.events.emit(FileSystemEvents.DIR_CREATED, { path }); } else { this.events.emit(FileSystemEvents.FILE_WRITTEN, { path, size: current.size, }); } } else if ( current.modified.getTime() !== snapshot.modified.getTime() || current.size !== snapshot.size ) { // Modified file - detected by date/size this.notifyWatchers({ type: "updated", path, timestamp: new Date(), }); if (current.type === "file") { this.events.emit(FileSystemEvents.FILE_WRITTEN, { path, size: current.size, }); } } else if (this.deepChangeDetection && current.type === "file") { // Mark for deep check filesToDeepCheck.push(path); } }); // Check for deleted files/directories this.fileSnapshots.forEach((snapshot, path) => { if (!currentFiles.has(path)) { this.notifyWatchers({ type: "deleted", path, timestamp: new Date(), }); // Also emit proper events if (snapshot.type === "directory") { this.events.emit(FileSystemEvents.DIR_DELETED, { path }); } else { this.events.emit(FileSystemEvents.FILE_DELETED, { path }); } } }); // Phase 2: Deep check (content) - only if enabled and needed if (filesToDeepCheck.length > 0) { await this.performDeepContentCheck(filesToDeepCheck, files); } // Update snapshots with content hashes if deep check is enabled if (this.deepChangeDetection) { for (const file of files) { if (file.type === "file" && file.path !== "/") { const snapshot = currentFiles.get(file.path); if (snapshot) { snapshot.contentHash = await this.calculateHash(file.content); if (file.metadata) { snapshot.metadataHash = await this.calculateHash( file.metadata, ); } } } } } // Update snapshots this.fileSnapshots = currentFiles; resolve(); }; request.onerror = () => resolve(); }); } private async performDeepContentCheck( paths: string[], allFiles: StoredFile[], ): Promise<void> { for (const path of paths) { const file = allFiles.find((f) => f.path === path); const snapshot = this.fileSnapshots.get(path); if (file && snapshot) { const currentContentHash = await this.calculateHash(file.content); const currentMetadataHash = file.metadata ? await this.calculateHash(file.metadata) : undefined; const contentChanged = snapshot.contentHash && snapshot.contentHash !== currentContentHash; const metadataChanged = snapshot.metadataHash && currentMetadataHash && snapshot.metadataHash !== currentMetadataHash; if (contentChanged || metadataChanged) { // Content or metadata changed even though date/size didn't this.notifyWatchers({ type: "updated", path, timestamp: new Date(), }); this.events.emit(FileSystemEvents.FILE_WRITTEN, { path, size: file.size, }); } } } } dispose(): void { this.stopWatching(); if (this.broadcastChannel) { this.broadcastChannel.close(); this.broadcastChannel = null; } if (this.db) { this.db.close(); this.db = null; } this.watchers = []; this.fileSnapshots.clear(); } // Sobrescrever métodos de permissão (sempre permitido em IndexedDB) async canModify(path: string): Promise<boolean> { return true; // Sempre permitido em IndexedDB } async canCreateIn(parentPath: string): Promise<boolean> { try { const stat = await this.stat(parentPath); return stat.type === "directory"; } catch { // Se não existe, verifica se é o root return normalizePath(parentPath) === "/"; } } // Não sobrescrever writeFileAtomic - usar implementação base // IndexedDB não tem rename verdadeiramente atômico }