UNPKG

use-fs-access

Version:

A React hook that builds on top of the File System Access API to enable easy file and directory operations in modern browsers.

992 lines (991 loc) 40.1 kB
import { useCallback, useEffect, useRef, useState } from "react"; import { defaultFilters } from "../filters"; const DEFAULT_OPEN_DIRECTORY_MODE = "readwrite"; const DEFAULT_FILTERS = defaultFilters; const DEFAULT_POLL_INTERVAL = 1_000; const DEFAULT_CACHE_TIME = 10_000; const DEFAULT_BATCH_SIZE = 50; const DEFAULT_LOAD_DEPTH = 2; export const isApiSupported = window !== undefined && "showDirectoryPicker" in window; export const showDirectoryPicker = async (options = {}) => { if (!isApiSupported) return; const { mode = DEFAULT_OPEN_DIRECTORY_MODE } = options; try { return await window.showDirectoryPicker({ ...options, mode }); } catch (err) { console.warn("Unable to open directory:", err); } }; export const requestDirectoryPermission = async (handle, mode = DEFAULT_OPEN_DIRECTORY_MODE) => { return (await handle.requestPermission({ mode: mode })) === "granted"; }; export default function useFileSystemAccess(props = {}) { const { filters: filterFns = props?.filters ?? DEFAULT_FILTERS, onFilesAdded: onAddFiles, onFilesDeleted: onDeleteFiles, onFilesModified: onChangeFiles, } = props; const enableFileWatcher = props.enableFileWatcher ?? false; const batchSize = props.fileWatcherOptions?.batchSize ?? DEFAULT_BATCH_SIZE; const cacheTime = props.fileWatcherOptions?.cacheTime ?? DEFAULT_CACHE_TIME; const pollInterval = props.fileWatcherOptions?.pollInterval ?? DEFAULT_POLL_INTERVAL; const debugFileWatcher = props.fileWatcherOptions?.debug ?? true; const pauseFileWatcherRef = useRef(false); const rootHandleRef = useRef(null); const filtersRef = useRef([]); const ignoredPathsRef = useRef(new Set()); const filesRef = useRef(new Map()); const previousFilesRef = useRef(new Map()); const fileWatchRef = useRef(null); const watchedDirectoriesRef = useRef(new Map()); const fileCacheRef = useRef(new Map()); const [files, setFiles] = useState(new Map()); const memoFiltersRef = useRef([]); const memoIgnoredPathsRef = useRef(new Set()); useEffect(() => { return () => { unregisterFileWatcher(); clearOpenedDirectory(); }; }, []); const initFileFilters = useCallback(async () => { const filterPromises = []; for (const filterFn of filterFns) { filterPromises.push(filterFn()); } filtersRef.current = await Promise.all(filterPromises); }, [filterFns]); const clearOpenedDirectory = () => { if (!rootHandleRef.current) return; rootHandleRef.current = null; filesRef.current.clear(); previousFilesRef.current.clear(); watchedDirectoriesRef.current.clear(); ignoredPathsRef.current.clear(); filtersRef.current = []; fileCacheRef.current.clear(); }; const openDirectory = async (directory, depth = DEFAULT_LOAD_DEPTH) => { if (!isApiSupported) return; if ((await directory.requestPermission({ mode: "read" })) == "denied") return; try { clearOpenedDirectory(); rootHandleRef.current = directory; const rootPath = directory.name; if (enableFileWatcher) await registerFileWatcher(); await initFileFilters(); return await expandFileTree(directory, rootPath, depth); } catch (err) { console.warn("Directory opening failed:", err); } }; const expandDirectory = async (path) => { const dirInfo = filesRef.current.get(path); if (dirInfo && dirInfo.kind == "directory" && !dirInfo.loaded) { return await expandFileTree(dirInfo.handle, path); } }; const writeFile = async (path, options = {}, data) => { // Validate path if (!path || typeof path !== "string" || isRootFilePath(path)) { throw new Error(`Invalid file path: ${path}`); } const parentDirInfo = ensureGetParentDirInfo(path); let name = path.substring(path.lastIndexOf("/") + 1); if (!name || name.length === 0) throw new Error(`Invalid file name: ${name}`); const { create = true, open, keepData = true } = options; if (create) { name = getFileEntryName(path); path = `${parentDirInfo.path}/${name}`; } if (!create && !filesRef.current.has(path)) throw new Error(`File not found: ${path}`); try { const fileHandle = await parentDirInfo.handle.getFileHandle(name, { create: create, }); let fileInfo; if (create) { const file = await fileHandle.getFile(); fileInfo = { name: name, path: path, handle: fileHandle, kind: "file", opened: open ?? false, lastModified: file.lastModified, size: file.size, type: file.type, content: undefined, }; } else { fileInfo = filesRef.current.get(path); if (open && fileInfo.opened !== open) fileInfo.opened = open; } if (data != null) { const writable = await fileHandle.createWritable({ keepExistingData: keepData, }); try { await writable.write(data); await writable.close(); const content = typeof data === "string" ? data : await new Response(data).text(); fileInfo.content = content; const file = await fileHandle.getFile(); fileInfo.lastModified = file.lastModified; fileInfo.size = file.size; fileInfo.type = file.type; fileInfo.content = content; if (fileCacheRef.current.has(fileInfo.path)) fileCacheRef.current.set(fileInfo.path, { content: fileInfo.content, timestamp: Date.now(), }); } catch (err) { await writable.abort(); throw err; } } if (parentDirInfo.loaded) { filesRef.current.set(path, fileInfo); previousFilesRef.current.set(path, fileInfo); setFiles(new Map(filesRef.current)); } return fileInfo; } catch (err) { throw new Error(`Unable to create file: ${err}`); } }; const createDirectory = async (name, parentPath) => { name = getFileEntryName(`${parentPath}/${name}`); const dirPath = `${parentPath}/${name}`; const parentInfo = filesRef.current.get(parentPath); if (!parentInfo || parentInfo.kind !== "directory") { throw new Error(`Parent directory not found: ${dirPath}`); } try { const fileHandle = await parentInfo.handle.getDirectoryHandle(name, { create: true, }); const dirInfo = { name: name, path: dirPath, handle: fileHandle, kind: "directory", loaded: false, }; if (parentInfo.loaded) { filesRef.current.set(dirPath, dirInfo); previousFilesRef.current.set(dirPath, dirInfo); setFiles(new Map(filesRef.current)); } return dirInfo; } catch (err) { throw new Error(`Unable to create directory: ${err}`); } }; const deleteFile = async (path, recursive = false) => { if (!path || typeof path !== "string") { throw new Error(`Invalid path: ${path}`); } const entryInfo = filesRef.current.get(path); if (!entryInfo) throw new Error(`File or directory not found: ${path}`); if (isRootFilePath(path)) throw new Error(`Root directory can't be deleted: ${path}`); const isRecursive = entryInfo.kind === "directory" && recursive; pauseFileWatcherRef.current = true; try { const parentDirInfo = ensureGetParentDirInfo(path); await parentDirInfo.handle.removeEntry(entryInfo.name, { recursive: isRecursive, }); filesRef.current.delete(entryInfo.path); previousFilesRef.current.delete(entryInfo.path); if (entryInfo.kind === "file" && entryInfo.opened) fileCacheRef.current.delete(entryInfo.path); if (entryInfo.kind === "directory" && entryInfo.loaded) watchedDirectoriesRef.current.delete(entryInfo.path); if (isRecursive) await deleteSubDirectories(path); setFiles(new Map(filesRef.current)); } catch { throw new Error(`Unable to delete ${entryInfo.kind}: ${path}`); } finally { pauseFileWatcherRef.current = false; } }; //#region OPEN/CLOSE FILE const openFile = async (path) => { if (!path || typeof path !== "string") throw new Error(`Invalid file path: ${path}`); return await toggleFileOpened(path, true); }; const closeFile = async (path) => { if (!path || typeof path !== "string") throw new Error(`Invalid file path: ${path}`); return await toggleFileOpened(path, false); }; const toggleFileOpened = async (path, open) => { const fileInfo = filesRef.current.get(path); if (!fileInfo) throw new Error(`File not found: ${path}`); if (fileInfo.kind != "file") throw new Error(`Entry is not a file: ${path}`); if (open && fileInfo.opened == true) return fileInfo; const fileHandle = fileInfo.handle; try { if (open) { const file = await fileHandle.getFile(); fileInfo.content = await file.text(); fileInfo.lastModified = file.lastModified; fileInfo.size = file.size; fileInfo.type = file.type; fileInfo.name = file.name; fileInfo.opened = true; } else { fileInfo.content = undefined; if (fileInfo.opened) { fileCacheRef.current.delete(fileInfo.path); fileInfo.opened = false; } } setFiles(new Map(filesRef.current)); } catch { throw new Error(`Unable to ${open ? "open" : "close"} file: ${path}`); } return fileInfo; }; //#endregion //#region RENAME FILE AND DIRECTORY const renameFile = async (path, newName) => { if (!path || !path.includes("/")) throw new Error(`Invalid path: ${path}`); if (!newName) throw new Error(`Invalid name: ${newName}`); const oldEntryInfo = filesRef.current.get(path); if (!oldEntryInfo) throw new Error(`File or directory not found: ${path}`); const parentDirInfo = ensureGetParentDirInfo(path); newName = getFileEntryName(`${parentDirInfo.path}/${newName}`); const newPath = `${parentDirInfo.path}/${newName}`; if (filesRef.current.has(newPath)) throw new Error(`File or directory with this name already exists: ${newName}`); const isDirectory = oldEntryInfo.kind === "directory"; pauseFileWatcherRef.current = true; try { if (!isDirectory) return await doRenameFile(oldEntryInfo, parentDirInfo, newName, newPath); return await doRenameDirectory(oldEntryInfo, parentDirInfo, newName, newPath); } catch { throw new Error(`Unable to rename entry: ${path}`); } finally { pauseFileWatcherRef.current = false; } }; const doRenameFile = async (oldFileInfo, parentDir, name, path) => { const file = await oldFileInfo.handle.getFile(); const content = await file.text(); const newFileHandle = await parentDir.handle.getFileHandle(name, { create: true, }); const writable = await newFileHandle.createWritable(); await writable.write(content); await writable.close(); const deleteFile = oldFileInfo.name.toLowerCase() != name.toLowerCase(); if (deleteFile) await parentDir.handle.removeEntry(oldFileInfo.name); const newFile = await newFileHandle.getFile(); const newFileInfo = { handle: newFileHandle, name: newFile.name, lastModified: newFile.lastModified, size: newFile.size, type: newFile.type, kind: "file", path: path, content: content, opened: false, }; if (parentDir.loaded) { filesRef.current.delete(oldFileInfo.path); previousFilesRef.current.delete(oldFileInfo.path); filesRef.current.set(newFileInfo.path, newFileInfo); previousFilesRef.current.set(newFileInfo.path, newFileInfo); } setFiles(new Map(filesRef.current)); return newFileInfo; }; const doRenameDirectory = async (oldDirInfo, parentDir, name, path) => { try { const newDirHandle = await parentDir.handle.getDirectoryHandle(name, { create: true, }); filesRef.current.delete(oldDirInfo.path); if (oldDirInfo.loaded) watchedDirectoriesRef.current.delete(oldDirInfo.path); const newFilesMap = new Map(filesRef.current); const newWatchedDirectories = new Map(watchedDirectoriesRef.current); const newDirInfo = { handle: newDirHandle, name: newDirHandle.name, path: path, kind: "directory", loaded: oldDirInfo.loaded, }; newFilesMap.set(newDirInfo.path, newDirInfo); if (newDirInfo.loaded) newWatchedDirectories.set(newDirInfo.path, { handle: newDirHandle }); await updateDirectoryContents(oldDirInfo.handle, newDirHandle, newFilesMap, newWatchedDirectories, oldDirInfo.path, path); const deleteDir = oldDirInfo.name.toLowerCase() != name.toLowerCase(); if (deleteDir) { await parentDir.handle.removeEntry(oldDirInfo.name, { recursive: true, }); } filesRef.current = new Map(newFilesMap); previousFilesRef.current = new Map(newFilesMap); watchedDirectoriesRef.current = new Map(newWatchedDirectories); setFiles(new Map(filesRef.current)); return newDirInfo; } catch { await parentDir.handle.removeEntry(name, { recursive: true, }); throw new Error(`Unable to rename directory: ${path}`); } }; //#endregion //#region COPY FILE AND DIRECTORY const copyFile = async (path, destination, replace = false) => { if (!path || !path.includes("/")) throw new Error(`Invalid path: ${path}`); if (destination.startsWith(path)) throw new Error(`Destination directory is the subdirectory of the source: ${path}`); const destDirInfo = filesRef.current.get(destination); if (!destDirInfo || destDirInfo.kind !== "directory") throw new Error(`Destination directory not found: ${path}`); const oldEntryInfo = filesRef.current.get(path); if (!oldEntryInfo) throw new Error(`Source file or directory not found: ${path}`); const fileName = oldEntryInfo.name; const newName = getFileEntryName(`${destDirInfo.path}/${fileName}`); const newPath = `${destDirInfo.path}/${newName}`; if (oldEntryInfo.kind === "directory" && destDirInfo.path.startsWith(oldEntryInfo.path)) throw new Error("The destination folder is a subdirectory of the source directory."); const destHandle = destDirInfo.handle; const isDirectory = oldEntryInfo.kind === "directory"; const parentDirInfo = ensureGetParentDirInfo(path); const parentDirHandle = parentDirInfo.handle; pauseFileWatcherRef.current = true; try { // File entry if (!isDirectory) { return await doCopyFile(oldEntryInfo, destHandle, parentDirHandle, newName, newPath, replace); } // Directory entry await doCopyDirectory(oldEntryInfo, destHandle, parentDirHandle, newName, newPath, replace); } catch { throw new Error(`Unable to copy ${oldEntryInfo.kind} to ${destination}`); } finally { pauseFileWatcherRef.current = false; } }; const doCopyFile = async (source, destination, parentDirHandle, newName, newPath, replace) => { const file = await source.handle.getFile(); const content = await file.text(); const newFileHandle = await destination.getFileHandle(newName, { create: true, }); const writable = await newFileHandle.createWritable(); await writable.write(content); await writable.close(); const newFile = await newFileHandle.getFile(); const newFileInfo = { handle: newFileHandle, name: newFile.name, lastModified: newFile.lastModified, size: newFile.size, type: newFile.type, kind: "file", path: newPath, content: content, opened: false, }; if (replace) { await parentDirHandle.removeEntry(source.name); filesRef.current.delete(source.path); previousFilesRef.current.delete(source.path); if (source.opened) fileCacheRef.current.delete(source.path); } filesRef.current.set(newFileInfo.path, newFileInfo); previousFilesRef.current.set(newFileInfo.path, newFileInfo); setFiles(new Map(filesRef.current)); return; }; const doCopyDirectory = async (source, destination, parentDirHandle, newName, newPath, replace) => { const newFilesMap = new Map(filesRef.current); const newWatchedDirectories = new Map(watchedDirectoriesRef.current); const newDirHandle = await destination.getDirectoryHandle(newName, { create: true, }); const newDirInfo = { handle: newDirHandle, name: newDirHandle.name, path: newPath, kind: "directory", loaded: source.loaded, }; newFilesMap.set(newDirInfo.path, newDirInfo); if (newDirInfo.loaded) newWatchedDirectories.set(newDirInfo.path, { handle: newDirHandle }); await updateDirectoryContents(source.handle, newDirHandle, newFilesMap, newWatchedDirectories, source.path, newPath, replace); if (replace) { await parentDirHandle.removeEntry(source.name, { recursive: true, }); filesRef.current.delete(source.path); previousFilesRef.current.delete(source.path); if (source.loaded) watchedDirectoriesRef.current.delete(source.path); } filesRef.current = new Map(newFilesMap); previousFilesRef.current = new Map(newFilesMap); watchedDirectoriesRef.current = new Map(newWatchedDirectories); setFiles(new Map(filesRef.current)); }; //#endregion //#region Shared functions const expandFileTree = async (dirHandle, basePath, depth = 1) => { try { await loadDirectories(dirHandle, basePath, depth); await loadDirectoryNodes(dirHandle, basePath, depth); // Filter loaded files and directories const filterTasks = []; for (const entry of filesRef.current) { filterTasks.push((async ([path, info]) => { if (await ignoreFilePath(filtersRef.current, path, info.handle)) { filesRef.current.delete(path); } })(entry)); } await Promise.all(filterTasks); previousFilesRef.current = filesRef.current; setFiles(new Map(filesRef.current)); return filesRef.current; } catch (err) { console.warn("Directory processing failed:", err); return new Map(); } }; const loadDirectories = async (dirHandle, dirPath, depth = 1) => { if (await ignoreFilePath(filtersRef.current, dirPath, dirHandle)) { ignoredPathsRef.current.add(dirPath); return; } const isNode = depth > 1; // Check if its node or leaf... const prevEntry = filesRef.current.get(dirPath); const entry = prevEntry ?? { name: dirHandle.name, handle: dirHandle, kind: dirHandle.kind, path: dirPath, loaded: !isNode, }; filesRef.current.set(dirPath, entry); const dirEntries = []; const fileEntries = []; for await (const [name, handle] of dirHandle.entries()) { if (handle.kind == "directory") dirEntries.push([name, handle]); else fileEntries.push([name, handle]); } // Process files first (parallelly) const fileTasks = fileEntries.map(async ([name, handle]) => { const fullPath = `${dirPath}/${name}`; if (await ignoreFilePath(filtersRef.current, fullPath, handle)) { ignoredPathsRef.current.add(fullPath); return; } const file = await handle.getFile(); filesRef.current.set(fullPath, { name: file.name, handle: handle, path: fullPath, kind: "file", lastModified: file.lastModified, size: file.size, type: file.type, opened: false, }); }); await Promise.all(fileTasks); // Process directories after (parallelly) const dirTasks = dirEntries.map(async ([name, handle]) => { const fullPath = `${dirPath}/${name}`; if (isNode) { await loadDirectories(handle, fullPath, depth - 1); } else { if (await ignoreFilePath(filtersRef.current, fullPath, handle)) { ignoredPathsRef.current.add(fullPath); return; } // Unloaded directory filesRef.current.set(fullPath, { name: handle.name, handle: handle, path: fullPath, kind: "directory", loaded: false, }); } }); await Promise.all(dirTasks); // All children loaded, so mark this directory entry as loaded entry.loaded = true; }; const loadDirectoryNodes = async (dirHandle, dirPath, depth = 1) => { const isNode = depth > 1; // Get or create the DirectoryNode for the current dir const directoryNode = watchedDirectoriesRef.current.get(dirPath) ?? { handle: dirHandle, }; // Store the current directory node watchedDirectoriesRef.current.set(dirPath, directoryNode); // Collect directory entries const subDirs = []; for await (const [name, handle] of dirHandle.entries()) { if (handle.kind === "directory") { subDirs.push([name, handle]); } } // Recursively load child directories const dirTasks = subDirs.map(async ([name, handle]) => { const fullPath = `${dirPath}/${name}`; if (isNode) { await loadDirectoryNodes(handle, fullPath, depth - 1); } }); await Promise.all(dirTasks); }; const updateDirectoryContents = async (src, dest, filesMap, watchedDirectories, oldBasePath, newBasePath, replace = true) => { for await (const [name, handle] of src.entries()) { const oldPath = `${oldBasePath}/${name}`; const newPath = `${newBasePath}/${name}`; if (handle.kind === "file") { const file = await handle.getFile(); const newFileHandle = await dest.getFileHandle(name, { create: true }); const writable = await newFileHandle.createWritable(); const content = await file.text(); await writable.write(content); await writable.close(); const oldFileInfo = filesMap.get(oldPath); if (!oldFileInfo || oldFileInfo.kind !== "file") continue; const newFileInfo = { name, path: newPath, kind: "file", handle: newFileHandle, size: file.size, lastModified: file.lastModified, type: file.type, opened: oldFileInfo.opened, content: oldFileInfo.opened ? content : undefined, }; if (replace) { filesMap.delete(oldPath); fileCacheRef.current.delete(oldPath); } filesMap.set(newPath, newFileInfo); } else if (handle.kind === "directory") { const newDirHandle = await dest.getDirectoryHandle(name, { create: true, }); const oldDirInfo = filesMap.get(oldPath); if (oldDirInfo != null && oldDirInfo.kind === "directory") { const newDirInfo = { name, path: newPath, kind: "directory", handle: newDirHandle, loaded: oldDirInfo.loaded, }; if (replace) { filesMap.delete(oldPath); filesMap.set(newPath, newDirInfo); if (oldDirInfo.loaded) watchedDirectories.delete(oldDirInfo.path); } if (oldDirInfo.loaded) { watchedDirectories.set(newPath, { handle: newDirHandle }); } } await updateDirectoryContents(handle, newDirHandle, filesMap, watchedDirectories, oldPath, newPath); } } }; const deleteSubDirectories = async (path) => { for (const [key, entry] of filesRef.current.entries()) { if (key !== path && !key.startsWith(path)) continue; if (entry.kind == "directory" && entry.loaded) watchedDirectoriesRef.current.delete(key); filesRef.current.delete(key); previousFilesRef.current.delete(key); } }; const ensureGetParentDirInfo = (path) => { const parentPath = getParentFilePath(path); const parentDirInfo = filesRef.current.get(parentPath); if (!parentDirInfo || parentDirInfo.kind !== "directory") throw new Error(`Parent directory not found: ${parentPath}`); return parentDirInfo; }; const getFileEntryName = (path) => { const filesMap = filesRef.current; const parentPath = path.substring(0, path.lastIndexOf("/") + 1); const fileName = path.substring(path.lastIndexOf("/") + 1); let base = fileName; let ext = ""; const lastDotIndex = fileName.lastIndexOf("."); if (lastDotIndex !== -1) { base = fileName.slice(0, lastDotIndex); ext = fileName.slice(lastDotIndex); } let counter = 1; let candidate = fileName; let currentPath = path; const existingPaths = new Set(Array.from(filesMap.keys()).map((k) => k.toLowerCase())); while (existingPaths.has(currentPath.toLowerCase())) { counter++; const newFileName = `${base} (${counter})${ext}`; candidate = `${newFileName}`; currentPath = `${parentPath}${newFileName}`; } return candidate; }; const registerFileWatcher = async () => { if (fileWatchRef.current || !rootHandleRef.current) return; let processingTask = null; fileWatchRef.current = window.setInterval(() => { if (processingTask || pauseFileWatcherRef.current == true) return; processingTask = (async () => { try { const entries = await filterWatchedDirectories(); await processFilteredEntries(entries); } catch (err) { if (debugFileWatcher) console.log("DEBUG(ERROR)", err); } finally { processingTask = null; } })(); }, pollInterval); if (debugFileWatcher) console.log("DEBUG: File Watcher registered"); }; const unregisterFileWatcher = () => { if (!fileWatchRef.current) return; clearInterval(fileWatchRef.current); memoFiltersRef.current = []; memoIgnoredPathsRef.current.clear(); }; //#endregion //#region File Watcher const processFileNode = useCallback(async (path, node, files, timestamp, addedEntries, modifiedEntries) => { const prevEntry = previousFilesRef.current.get(path); const isNew = !prevEntry; if (node.kind === "directory") { const prevDir = prevEntry?.kind == "directory" ? prevEntry : undefined; const isLoaded = prevDir?.kind === "directory" ? prevDir.loaded : false; const dirInfo = { name: node.handle.name, path, kind: "directory", handle: node.handle, loaded: isLoaded, }; if (isNew) addedEntries.set(path, dirInfo); files.set(path, dirInfo); return; } // File handling const fileHandle = node.handle; const file = await fileHandle.getFile(); const cached = fileCacheRef.current.get(path); const isCached = cached && timestamp - cached.timestamp < cacheTime; const prevFile = prevEntry?.kind === "file" ? prevEntry : undefined; const isModified = prevFile && prevFile.lastModified !== file.lastModified; const isOpened = prevFile != undefined && prevFile.opened; const baseFileInfo = { name: file.name, path: path, kind: "file", handle: fileHandle, opened: isOpened, }; let content; if (isOpened) { if (!isCached || isModified) { content = await file.text(); fileCacheRef.current.set(path, { content, timestamp: timestamp, }); } else { content = cached.content; } } const fileInfo = { ...baseFileInfo, lastModified: file.lastModified, size: file.size, type: file.type, content, opened: isOpened, }; if (isNew) addedEntries.set(path, fileInfo); if (prevFile != null && isModified) { modifiedEntries.set(path, fileInfo); } files.set(path, fileInfo); }, [cacheTime]); const processFilteredEntries = useCallback(async (fileEntries) => { const addedEntries = new Map(); const deletedEntries = new Map(); const modifiedEntries = new Map(); const timestamp = Date.now(); const seenEntries = new Set(); const files = new Map(); const filteredEntries = Array.from(fileEntries.entries()); for (let i = 0; i < filteredEntries.length; i += batchSize) { const batch = filteredEntries.slice(i, i + batchSize); await Promise.all(batch.map(async ([path, node]) => { seenEntries.add(path); return processFileNode(path, node, files, timestamp, addedEntries, modifiedEntries); })); } // Check for removed directories and files for (const [path, info] of previousFilesRef.current) { if (!seenEntries.has(path)) { if (info.kind === "directory") { deletedEntries.set(path, info); // Remove directory from watching const prefix = path.endsWith("/") ? path : path + "/"; for (const key of watchedDirectoriesRef.current.keys()) { if (key === path || key.startsWith(prefix)) { watchedDirectoriesRef.current.delete(key); } } } else { deletedEntries.set(path, info); } } } const hasChanges = addedEntries.size > 0 || deletedEntries.size > 0 || modifiedEntries.size > 0; if (hasChanges) { if (addedEntries.size > 0) { onAddFiles?.(addedEntries); if (debugFileWatcher) console.log("DEBUG: Added entries", addedEntries); } if (deletedEntries.size > 0) { onDeleteFiles?.(deletedEntries); if (debugFileWatcher) console.log("DEBUG: Deleted entries", deletedEntries); } if (modifiedEntries.size > 0) { onChangeFiles?.(modifiedEntries); if (debugFileWatcher) console.log("DEBUG: Modified entries", modifiedEntries); } previousFilesRef.current = filesRef.current; filesRef.current = files; filtersRef.current = memoFiltersRef.current; ignoredPathsRef.current = memoIgnoredPathsRef.current; setFiles(new Map(files)); } }, [ processFileNode, onAddFiles, onDeleteFiles, onChangeFiles, batchSize, debugFileWatcher, ]); const filterDirectory = useCallback(async (dirPath, dirNode, filters, filteredNodes, ignoredEntries) => { if (ignoredEntries.has(dirPath)) return; if (await ignoreFilePath(filters, dirPath, dirNode.handle)) { ignoredEntries.add(dirPath); return; } const dirHandles = []; const fileHandles = []; try { for await (const handle of dirNode.handle.values()) { if (handle.kind == "directory") dirHandles.push(handle); else fileHandles.push(handle); } } catch (err) { console.warn(`Error reading directory ${dirPath}:`, err); return; } if (fileHandles.length > 1000) { return; } const fileTasks = []; for (const handle of fileHandles) { fileTasks.push((async () => { const fullPath = `${dirPath}/${handle.name}`; if (ignoredEntries.has(fullPath)) return; if (await ignoreFilePath(filters, fullPath, handle)) { ignoredEntries.add(fullPath); return; } filteredNodes.set(fullPath, { handle: handle, kind: "file", }); })()); } await Promise.all(fileTasks); const dirTasks = []; for (const handle of dirHandles) { dirTasks.push((async () => { const fullPath = `${dirPath}/${handle.name}`; if (ignoredEntries.has(fullPath)) return; if (await ignoreFilePath(filters, fullPath, handle)) { ignoredEntries.add(fullPath); return; } filteredNodes.set(fullPath, { handle: handle, kind: "directory" }); })()); } await Promise.all(dirTasks); }, []); const filterWatchedDirectories = useCallback(async () => { const rootHandle = rootHandleRef.current; const rootPath = rootHandle.name; const filteredNodes = new Map(); filteredNodes.set(rootPath, { handle: rootHandle, kind: "directory", }); const ignoredEntries = new Set(); const filters = []; for (const filterFn of filterFns) { filters.push(await filterFn()); } for (const [dirPath, dirNode] of watchedDirectoriesRef.current.entries()) { await filterDirectory(dirPath, dirNode, filters, filteredNodes, ignoredEntries); } // Ignore the remaining files that weren't affected by the filters const filterTasks = []; for (const [path, node] of filteredNodes.entries()) { filterTasks.push((async () => { if (await ignoreFilePath(filters, path, node.handle)) { filteredNodes.delete(path); ignoredEntries.add(path); } if (node.kind == "file" && path.endsWith(".crswap")) { filteredNodes.delete(path); ignoredEntries.add(path); } })()); } await Promise.all(filterTasks); if (ignoredEntries.size > 0) { filtersRef.current = filters; ignoredPathsRef.current = ignoredEntries; } memoFiltersRef.current = filters; memoIgnoredPathsRef.current = ignoredEntries; return filteredNodes; }, [filterDirectory, filterFns]); //#endregion // Debounce the files state update const debouncedFiles = useDebounce(files, 50); return { files: debouncedFiles, openDirectory, expandDirectory, openFile, closeFile, writeFile, createDirectory, deleteFile, renameFile, copyFile, }; } //#region Helper functions const ignoreFilePath = async (filters, path, handle) => { for (const filter of filters) { if ((await filter.ignore(path, handle)) === true) { return true; } } return false; }; const isRootFilePath = (path) => { return !path.includes("/"); }; const getParentFilePath = (path) => { return path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : path; }; const useDebounce = (value, delay) => { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handler); }; }, [value, delay]); return debouncedValue; }; //#endregion