daedalOS/components/system/Files/FileManager/useFolder.ts
2024-02-07 19:02:36 -08:00

749 lines
21 KiB
TypeScript

import { basename, dirname, extname, join, relative } from "path";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type AsyncZipOptions, type AsyncZippable } from "fflate";
import { type ApiError } from "browserfs/dist/node/core/api_error";
import type Stats from "browserfs/dist/node/core/node_fs_stats";
import useTransferDialog, {
type ObjectReader,
} from "components/system/Dialogs/Transfer/useTransferDialog";
import {
createShortcut,
filterSystemFiles,
getCachedShortcut,
getShortcutInfo,
isExistingFile,
makeExternalShortcut,
} from "components/system/Files/FileEntry/functions";
import {
type FileStat,
findPathsRecursive,
removeInvalidFilenameCharacters,
sortByDate,
sortBySize,
sortContents,
getParentDirectories,
} from "components/system/Files/FileManager/functions";
import { type FocusEntryFunctions } from "components/system/Files/FileManager/useFocusableEntries";
import useSortBy, {
type SetSortBy,
type SortByOrder,
} from "components/system/Files/FileManager/useSortBy";
import { useFileSystem } from "contexts/fileSystem";
import { useProcesses } from "contexts/process";
import { useSession } from "contexts/session";
import {
BASE_ZIP_CONFIG,
DESKTOP_PATH,
PROCESS_DELIMITER,
SHORTCUT_APPEND,
SHORTCUT_EXTENSION,
SYSTEM_SHORTCUT_DIRECTORIES,
} from "utils/constants";
import {
bufferToUrl,
cleanUpBufferUrl,
getExtension,
updateIconPositions,
} from "utils/functions";
import { type CaptureTriggerEvent } from "contexts/menu/useMenuContextState";
export type FileActions = {
archiveFiles: (paths: string[]) => void;
deleteLocalPath: (path: string) => Promise<void>;
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<string, CompleteAction> = {
RENAME: "rename",
UPDATE_URL: "updateUrl",
};
export type NewPath = (
fileName: string,
buffer?: Buffer,
completeAction?: CompleteAction
) => Promise<string>;
export type FolderActions = {
addToFolder: () => Promise<string[]>;
newPath: NewPath;
pasteToFolder: (event?: CaptureTriggerEvent) => void;
resetFiles: () => void;
sortByOrder: [SortByOrder, SetSortBy];
};
type ZipFile = [string, Buffer];
export type Files = Record<string, FileStat>;
type Folder = {
fileActions: FileActions;
files: Files;
folderActions: FolderActions;
isLoading: boolean;
updateFiles: (newFile?: string, oldFile?: string) => void;
};
type FolderFlags = {
hideFolders?: boolean;
hideLoading?: boolean;
skipFsWatcher?: boolean;
skipSorting?: boolean;
};
const NO_FILES = undefined;
const useFolder = (
directory: string,
setRenaming: React.Dispatch<React.SetStateAction<string>>,
{ blurEntry, focusEntry }: FocusEntryFunctions,
{ hideFolders, hideLoading, skipFsWatcher, skipSorting }: FolderFlags
): Folder => {
const [files, setFiles] = useState<Files | typeof NO_FILES>();
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 {
iconPositions,
sessionLoaded,
setIconPositions,
setSortOrder,
sortOrders,
} = useSession();
const { [directory]: [sortOrder, sortBy, sortAscending] = [] } =
sortOrders || {};
const [currentDirectory, setCurrentDirectory] = useState(directory);
const { close, closeProcessesByUrl } = useProcesses();
const statsWithShortcutInfo = useCallback(
async (fileName: string, stats: Stats): Promise<FileStat> => {
if (
SYSTEM_SHORTCUT_DIRECTORIES.has(directory) &&
getExtension(fileName) === SHORTCUT_EXTENSION
) {
const shortcutPath = join(directory, fileName);
const { type } = isExistingFile(stats)
? getCachedShortcut(shortcutPath)
: getShortcutInfo(await readFile(shortcutPath));
return Object.assign(stats, { systemShortcut: 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<Files>(
(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)
);
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,
readdir,
setSortOrder,
skipSorting,
sortAscending,
sortBy,
sortOrder,
stat,
statsWithShortcutInfo,
]
);
const deleteLocalPath = useCallback(
async (path: string): Promise<void> => {
if (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<ZipFile> => [
relative(directory, path),
await readFile(path),
],
[directory, readFile]
);
const renameFile = async (path: string, name?: string): Promise<void> => {
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<string> => {
const uniqueName = await createPath(name, directory, buffer);
if (uniqueName && !uniqueName.includes("/")) {
updateFolder(directory, uniqueName);
if (completeAction === "rename") setRenaming(uniqueName);
else {
blurEntry();
focusEntry(uniqueName);
}
}
return uniqueName;
},
[blurEntry, createPath, directory, focusEntry, setRenaming, updateFolder]
);
const newShortcut = useCallback(
(path: string, process: string): void => {
const pathExtension = getExtension(path);
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<AsyncZippable> => {
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,
getExtension(path) === SHORTCUT_EXTENSION
? makeExternalShortcut(file)
: file,
] as [string, Buffer]
)
.reduce<AsyncZippable>(
(accFiles, [path, file]) =>
addEntryToZippable(accFiles, createZippable(path, file)),
{}
);
},
[getFile, readdir, stat]
);
const archiveFiles = useCallback(
async (paths: string[]): Promise<void> => {
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<void> => {
const zipFiles = await createZipFile(paths);
const zipEntries = Object.entries(zipFiles);
const [[path, file]] = zipEntries.length === 0 ? [["", ""]] : 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<void> => {
const data = await readFile(path);
const { unarchive, unzip } = await import("utils/zipFunctions");
const closeDialog = (): void =>
close(`Transfer${PROCESS_DELIMITER}${path}`);
openTransferDialog(undefined, path);
try {
const unzippedFiles = Object.entries(
[".jsdos", ".wsz", ".zip"].includes(getExtension(path))
? await unzip(data)
: await unarchive(path, data)
);
if (unzippedFiles.length === 0) closeDialog();
else {
const zipFolderName = basename(
path,
path.toLowerCase().endsWith(".tar.gz") ? ".tar.gz" : extname(path)
);
const uniqueName = await createPath(zipFolderName, directory);
const objectReaders = unzippedFiles.map<ObjectReader>(
([extractPath, fileContents]) => {
let aborted = false;
return {
abort: () => {
aborted = true;
},
directory: join(directory, uniqueName),
done: () => updateFolder(directory, uniqueName),
name: extractPath,
operation: "Extracting",
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);
}
} catch (error) {
closeDialog();
if ("message" in (error as Error)) {
console.error((error as Error).message);
}
}
},
[
close,
createPath,
directory,
exists,
mkdir,
mkdirRecursive,
openTransferDialog,
readFile,
updateFolder,
writeFile,
]
);
const pasteToFolder = useCallback(
(event?: CaptureTriggerEvent): void => {
[directory, ...getParentDirectories(directory)].forEach(
(parentDirectory) =>
pasteList[parentDirectory] && delete pasteList[parentDirectory]
);
const pasteEntries = Object.entries(pasteList);
const moving = pasteEntries.some(([, operation]) => operation === "move");
const copyFiles = async (entry: string, basePath = ""): Promise<void> => {
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<ObjectReader>(([pasteEntry]) => {
let aborted = false;
return {
abort: () => {
aborted = true;
},
directory,
done: () => {
if (moving) {
movedPaths
.filter(Boolean)
.forEach((movedPath) => updateFolder(directory, movedPath));
copyEntries([]);
}
},
name: pasteEntry,
operation: moving ? "Moving" : "Copying",
read: async () => {
if (aborted) return;
if (moving) {
movedPaths.push(await createPath(pasteEntry, directory));
} else await copyFiles(pasteEntry);
},
};
});
if (event) {
const { clientX: x, clientY: y } =
"TouchEvent" in window && event.nativeEvent instanceof TouchEvent
? event.nativeEvent.touches[0]
: (event.nativeEvent as MouseEvent);
updateIconPositions(
directory,
event.target as HTMLElement,
iconPositions,
sortOrders,
{ x, y },
pasteEntries.map(([entry]) => basename(entry)),
setIconPositions
);
}
openTransferDialog(objectReaders);
},
[
copyEntries,
createPath,
directory,
iconPositions,
lstat,
openTransferDialog,
pasteList,
readFile,
readdir,
setIconPositions,
sortOrders,
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;