696 lines
20 KiB
TypeScript
696 lines
20 KiB
TypeScript
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<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 FolderActions = {
|
|
addToFolder: () => void;
|
|
newPath: (
|
|
path: string,
|
|
buffer?: Buffer,
|
|
completeAction?: CompleteAction
|
|
) => Promise<void>;
|
|
pasteToFolder: () => 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;
|
|
preloadShortcuts?: boolean;
|
|
skipFsWatcher?: boolean;
|
|
skipSorting?: boolean;
|
|
};
|
|
|
|
const NO_FILES = undefined;
|
|
|
|
const useFolder = (
|
|
directory: string,
|
|
setRenaming: React.Dispatch<React.SetStateAction<string>>,
|
|
{ blurEntry, focusEntry }: FocusEntryFunctions,
|
|
{
|
|
hideFolders,
|
|
hideLoading,
|
|
preloadShortcuts,
|
|
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 {
|
|
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<FileStat> => {
|
|
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<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)
|
|
);
|
|
|
|
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<void> => {
|
|
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<void> => {
|
|
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<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,
|
|
extname(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;
|
|
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");
|
|
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<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(([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;
|