import type { ApiError } from "browserfs/dist/node/core/api_error"; import type Stats from "browserfs/dist/node/core/node_fs_stats"; import useTransferDialog from "components/system/Dialogs/Transfer/useTransferDialog"; import { createShortcut, filterSystemFiles, getShortcutInfo, makeExternalShortcut, } from "components/system/Files/FileEntry/functions"; import type { FileStat } from "components/system/Files/FileManager/functions"; import { findPathsRecursive, removeInvalidFilenameCharacters, sortByDate, sortBySize, sortContents, } from "components/system/Files/FileManager/functions"; import type { FocusEntryFunctions } from "components/system/Files/FileManager/useFocusableEntries"; import type { SetSortBy, SortByOrder, } from "components/system/Files/FileManager/useSortBy"; import useSortBy from "components/system/Files/FileManager/useSortBy"; import { useFileSystem } from "contexts/fileSystem"; import { useProcesses } from "contexts/process"; import { useSession } from "contexts/session"; import type { AsyncZipOptions, AsyncZippable } from "fflate"; import { basename, dirname, extname, join, relative } from "path"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { BASE_ZIP_CONFIG, DESKTOP_PATH, SHORTCUT_APPEND, SHORTCUT_EXTENSION, SYSTEM_SHORTCUT_DIRECTORIES, } from "utils/constants"; import { bufferToUrl, cleanUpBufferUrl, preloadLibs } from "utils/functions"; export type FileActions = { archiveFiles: (paths: string[]) => void; deleteLocalPath: (path: string) => Promise; downloadFiles: (paths: string[]) => void; extractFiles: (path: string) => void; newShortcut: (path: string, process: string) => void; renameFile: (path: string, name?: string) => void; }; export type CompleteAction = "rename" | "updateUrl"; export const COMPLETE_ACTION: Record = { RENAME: "rename", UPDATE_URL: "updateUrl", }; export type FolderActions = { addToFolder: () => void; newPath: ( path: string, buffer?: Buffer, completeAction?: CompleteAction ) => Promise; pasteToFolder: () => void; resetFiles: () => void; sortByOrder: [SortByOrder, SetSortBy]; }; type ZipFile = [string, Buffer]; export type Files = Record; type Folder = { fileActions: FileActions; files: Files; folderActions: FolderActions; isLoading: boolean; updateFiles: (newFile?: string, oldFile?: string) => void; }; type FolderFlags = { hideFolders?: boolean; hideLoading?: boolean; preloadShortcuts?: boolean; skipFsWatcher?: boolean; skipSorting?: boolean; }; const NO_FILES = undefined; const useFolder = ( directory: string, setRenaming: React.Dispatch>, { blurEntry, focusEntry }: FocusEntryFunctions, { hideFolders, hideLoading, preloadShortcuts, skipFsWatcher, skipSorting, }: FolderFlags ): Folder => { const [files, setFiles] = useState(); const [downloadLink, setDownloadLink] = useState(""); const [isLoading, setIsLoading] = useState(true); const { addFile, addFsWatcher, copyEntries, createPath, deletePath, exists, fs, lstat, mkdir, mkdirRecursive, pasteList, readdir, readFile, removeFsWatcher, rename, stat, updateFolder, writeFile, } = useFileSystem(); const { sessionLoaded, setIconPositions, setSortOrder, sortOrders: { [directory]: [sortOrder, sortBy, sortAscending] = [] } = {}, } = useSession(); const [currentDirectory, setCurrentDirectory] = useState(directory); const { closeProcessesByUrl } = useProcesses(); const statsWithShortcutInfo = useCallback( async (fileName: string, stats: Stats): Promise => { if ( SYSTEM_SHORTCUT_DIRECTORIES.has(directory) && extname(fileName).toLowerCase() === SHORTCUT_EXTENSION ) { return Object.assign(stats, { systemShortcut: getShortcutInfo(await readFile(join(directory, fileName))).type === "System", }); } return stats; }, [directory, readFile] ); const isSimpleSort = skipSorting || !sortBy || sortBy === "name" || sortBy === "type"; const updateFiles = useCallback( async (newFile?: string, oldFile?: string) => { if (oldFile) { if (!(await exists(join(directory, oldFile)))) { const oldName = basename(oldFile); if (newFile) { setFiles((currentFiles = {}) => Object.entries(currentFiles).reduce( (newFiles, [fileName, fileStats]) => { // eslint-disable-next-line no-param-reassign newFiles[ fileName === oldName ? basename(newFile) : fileName ] = fileStats; return newFiles; }, {} ) ); } else { blurEntry(oldName); setFiles( ({ [oldName]: _fileStats, ...currentFiles } = {}) => currentFiles ); } } } else if (newFile) { const baseName = basename(newFile); const filePath = join(directory, newFile); const fileStats = await statsWithShortcutInfo( baseName, isSimpleSort ? await lstat(filePath) : await stat(filePath) ); setFiles((currentFiles = {}) => ({ ...currentFiles, [baseName]: fileStats, })); } else { setIsLoading(true); try { const dirContents = (await readdir(directory)).filter( filterSystemFiles(directory) ); if (preloadShortcuts) { preloadLibs( dirContents .filter((entry) => entry.endsWith(SHORTCUT_EXTENSION)) .map((entry) => `${directory}/${entry}`) ); } const sortedFiles = await dirContents.reduce( async (processedFiles, file) => { try { const filePath = join(directory, file); const fileStats = isSimpleSort ? await lstat(filePath) : await stat(filePath); const hideEntry = hideFolders && fileStats.isDirectory(); let newFiles = await processedFiles; if (!hideEntry) { newFiles[file] = await statsWithShortcutInfo(file, fileStats); newFiles = sortContents( newFiles, (!skipSorting && sortOrder) || [], isSimpleSort ? undefined : sortBy === "date" ? sortByDate(directory) : sortBySize, sortAscending ); } if (hideLoading) setFiles(newFiles); return newFiles; } catch { return processedFiles; } }, Promise.resolve({} as Files) ); if (dirContents.length > 0) { if (!hideLoading) setFiles(sortedFiles); const newSortOrder = Object.keys(sortedFiles); if ( !skipSorting && (!sortOrder || sortOrder?.some( (entry, index) => newSortOrder[index] !== entry )) ) { window.requestAnimationFrame(() => setSortOrder(directory, newSortOrder) ); } } else { setFiles(Object.create(null) as Files); } } catch (error) { if ((error as ApiError).code === "ENOENT") { closeProcessesByUrl(directory); } } setIsLoading(false); } }, [ blurEntry, closeProcessesByUrl, directory, exists, hideFolders, hideLoading, isSimpleSort, lstat, preloadShortcuts, readdir, setSortOrder, skipSorting, sortAscending, sortBy, sortOrder, stat, statsWithShortcutInfo, ] ); const deleteLocalPath = useCallback( async (path: string): Promise => { await deletePath(path); updateFolder(directory, undefined, basename(path)); }, [deletePath, directory, updateFolder] ); const createLink = (contents: Buffer, fileName?: string): void => { const link = document.createElement("a"); link.href = bufferToUrl(contents); link.download = fileName ? extname(fileName) ? fileName : `${fileName}.zip` : "download.zip"; link.click(); setDownloadLink(link.href); }; const getFile = useCallback( async (path: string): Promise => [ relative(directory, path), await readFile(path), ], [directory, readFile] ); const renameFile = async (path: string, name?: string): Promise => { let newName = removeInvalidFilenameCharacters(name).trim(); if (newName?.endsWith(".")) { newName = newName.slice(0, -1); } if (newName) { const renamedPath = join( directory, `${newName}${ path.endsWith(SHORTCUT_EXTENSION) ? SHORTCUT_EXTENSION : "" }` ); if (!(await exists(renamedPath))) { await rename(path, renamedPath); updateFolder(directory, renamedPath, path); } if (dirname(path) === DESKTOP_PATH) { setIconPositions((currentPositions) => { const { [path]: iconPosition, ...newPositions } = currentPositions; if (iconPosition) { newPositions[renamedPath] = iconPosition; } return newPositions; }); } } }; const newPath = useCallback( async ( name: string, buffer?: Buffer, completeAction?: CompleteAction ): Promise => { const uniqueName = await createPath(name, directory, buffer); if (uniqueName && !uniqueName.includes("/")) { updateFolder(directory, uniqueName); if (completeAction === "rename") setRenaming(uniqueName); else { blurEntry(); focusEntry(uniqueName); } } }, [blurEntry, createPath, directory, focusEntry, setRenaming, updateFolder] ); const newShortcut = useCallback( (path: string, process: string): void => { const pathExtension = extname(path).toLowerCase(); if (pathExtension === SHORTCUT_EXTENSION) { fs?.readFile(path, (_readError, contents = Buffer.from("")) => newPath(basename(path), contents) ); return; } const baseName = basename(path); const shortcutPath = `${baseName}${SHORTCUT_APPEND}${SHORTCUT_EXTENSION}`; const shortcutData = createShortcut({ BaseURL: process, URL: path }); newPath(shortcutPath, Buffer.from(shortcutData)); }, [fs, newPath] ); const createZipFile = useCallback( async (paths: string[]): Promise => { const allPaths = await findPathsRecursive(paths, readdir, stat); const filePaths = await Promise.all( allPaths.map((path) => getFile(path)) ); const { addEntryToZippable, createZippable } = await import( "utils/zipFunctions" ); return filePaths .filter(Boolean) .map( ([path, file]) => [ path, extname(path) === SHORTCUT_EXTENSION ? makeExternalShortcut(file) : file, ] as [string, Buffer] ) .reduce( (accFiles, [path, file]) => addEntryToZippable(accFiles, createZippable(path, file)), {} ); }, [getFile, readdir, stat] ); const archiveFiles = useCallback( async (paths: string[]): Promise => { const { zip } = await import("fflate"); zip( await createZipFile(paths), BASE_ZIP_CONFIG, (_zipError, newZipFile) => { if (newZipFile) { newPath( `${basename(directory) || "archive"}.zip`, Buffer.from(newZipFile) ); } } ); }, [createZipFile, directory, newPath] ); const downloadFiles = useCallback( async (paths: string[]): Promise => { const zipFiles = await createZipFile(paths); const zipEntries = Object.entries(zipFiles); const [[path, file]] = zipEntries; const singleParentEntry = zipEntries.length === 1; if (singleParentEntry && extname(path)) { const [contents] = file as [Uint8Array, AsyncZipOptions]; createLink(contents as Buffer, basename(path)); } else { const { zip } = await import("fflate"); zip( singleParentEntry ? (file as AsyncZippable) : zipFiles, BASE_ZIP_CONFIG, (_zipError, newZipFile) => { if (newZipFile) { createLink( Buffer.from(newZipFile), singleParentEntry ? path : undefined ); } } ); } }, [createZipFile] ); const { openTransferDialog } = useTransferDialog(); const extractFiles = useCallback( async (path: string): Promise => { const data = await readFile(path); const { unarchive, unzip } = await import("utils/zipFunctions"); openTransferDialog(undefined, path); const unzippedFiles = [".jsdos", ".wsz", ".zip"].includes( extname(path).toLowerCase() ) ? await unzip(data) : await unarchive(path, data); const zipFolderName = basename( path, path.toLowerCase().endsWith(".tar.gz") ? ".tar.gz" : extname(path) ); const uniqueName = await createPath(zipFolderName, directory); const objectReaders = Object.entries(unzippedFiles).map( ([extractPath, fileContents]) => { let aborted = false; return { abort: () => { aborted = true; }, directory: join(directory, uniqueName), done: () => updateFolder(directory, uniqueName), name: extractPath, read: async () => { if (aborted) return; try { const localPath = join(directory, uniqueName, extractPath); if (fileContents.length === 0 && extractPath.endsWith("/")) { await mkdir(localPath); } else { if (!(await exists(dirname(localPath)))) { await mkdirRecursive(dirname(localPath)); } await writeFile(localPath, Buffer.from(fileContents)); } } catch { // Ignore failure to extract } }, }; } ); openTransferDialog(objectReaders, path); }, [ createPath, directory, exists, mkdir, mkdirRecursive, openTransferDialog, readFile, updateFolder, writeFile, ] ); const pasteToFolder = useCallback((): void => { const pasteEntries = Object.entries(pasteList); const moving = pasteEntries.some(([, operation]) => operation === "move"); const copyFiles = async (entry: string, basePath = ""): Promise => { const newBasePath = join(basePath, basename(entry)); let uniquePath: string; if ((await lstat(entry)).isDirectory()) { uniquePath = await createPath(newBasePath, directory); await Promise.all( ( await readdir(entry) ).map((dirEntry) => copyFiles(join(entry, dirEntry), uniquePath)) ); } else { uniquePath = await createPath( newBasePath, directory, await readFile(entry) ); } if (!basePath) updateFolder(directory, uniquePath); }; const movedPaths: string[] = []; const objectReaders = pasteEntries.map(([pasteEntry]) => { let aborted = false; return { abort: () => { aborted = true; }, directory, done: () => { if (moving) { movedPaths .filter(Boolean) .forEach((movedPath) => updateFolder(directory, movedPath)); copyEntries([]); } }, name: pasteEntry, read: async () => { if (aborted) return; if (moving) movedPaths.push(await createPath(pasteEntry, directory)); else await copyFiles(pasteEntry); }, }; }); openTransferDialog(objectReaders); }, [ copyEntries, createPath, directory, lstat, openTransferDialog, pasteList, readFile, readdir, updateFolder, ]); const sortByOrder = useSortBy(directory, files); const folderActions = useMemo( () => ({ addToFolder: () => addFile(directory, newPath), newPath, pasteToFolder, resetFiles: () => setFiles(NO_FILES), sortByOrder, }), [addFile, directory, newPath, pasteToFolder, sortByOrder] ); const updatingFiles = useRef(false); useEffect(() => { if (directory !== currentDirectory) { setCurrentDirectory(directory); setFiles(NO_FILES); } }, [currentDirectory, directory]); useEffect(() => { if (sessionLoaded) { if (files) { const fileNames = Object.keys(files); if ( sortOrder && fileNames.length === sortOrder.length && directory === currentDirectory ) { if (fileNames.some((file) => !sortOrder.includes(file))) { const oldName = sortOrder.find( (entry) => !fileNames.includes(entry) ); const newName = fileNames.find( (entry) => !sortOrder.includes(entry) ); if (oldName && newName) { setSortOrder( directory, sortOrder.map((entry) => (entry === oldName ? newName : entry)) ); } } else if ( fileNames.some((file, index) => file !== sortOrder[index]) ) { setFiles((currentFiles) => sortContents(currentFiles || files, sortOrder) ); } } } else if (!updatingFiles.current) { updatingFiles.current = true; updateFiles().then(() => { updatingFiles.current = false; }); } } }, [ currentDirectory, directory, files, sessionLoaded, setSortOrder, sortOrder, updateFiles, ]); useEffect( () => () => { if (downloadLink) cleanUpBufferUrl(downloadLink); }, [downloadLink] ); useEffect(() => { if (!skipFsWatcher) addFsWatcher?.(directory, updateFiles); return () => { if (!skipFsWatcher) removeFsWatcher?.(directory, updateFiles); }; }, [addFsWatcher, directory, removeFsWatcher, skipFsWatcher, updateFiles]); return { fileActions: { archiveFiles, deleteLocalPath, downloadFiles, extractFiles, newShortcut, renameFile, }, files: files || {}, folderActions, isLoading, updateFiles, }; }; export default useFolder;