489 lines
14 KiB
TypeScript
489 lines
14 KiB
TypeScript
|
import type * as IBrowserFS from "browserfs";
|
||
|
import type IIsoFS from "browserfs/dist/node/backend/IsoFS";
|
||
|
import type IZipFS from "browserfs/dist/node/backend/ZipFS";
|
||
|
import type { ApiError } from "browserfs/dist/node/core/api_error";
|
||
|
import type {
|
||
|
BFSCallback,
|
||
|
FileSystem,
|
||
|
} from "browserfs/dist/node/core/file_system";
|
||
|
import type { FSModule } from "browserfs/dist/node/core/FS";
|
||
|
import useTransferDialog from "components/system/Dialogs/Transfer/useTransferDialog";
|
||
|
import { getMimeType } from "components/system/Files/FileEntry/functions";
|
||
|
import type { InputChangeEvent } from "components/system/Files/FileManager/functions";
|
||
|
import {
|
||
|
handleFileInputEvent,
|
||
|
iterateFileName,
|
||
|
removeInvalidFilenameCharacters,
|
||
|
} from "components/system/Files/FileManager/functions";
|
||
|
import {
|
||
|
addFileSystemHandle,
|
||
|
getFileSystemHandles,
|
||
|
removeFileSystemHandle,
|
||
|
} from "contexts/fileSystem/functions";
|
||
|
import type { AsyncFS, RootFileSystem } from "contexts/fileSystem/useAsyncFs";
|
||
|
import useAsyncFs from "contexts/fileSystem/useAsyncFs";
|
||
|
import { useProcesses } from "contexts/process";
|
||
|
import type { UpdateFiles } from "contexts/session/types";
|
||
|
import { basename, dirname, extname, isAbsolute, join } from "path";
|
||
|
import * as BrowserFS from "public/System/BrowserFS/browserfs.min.js";
|
||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
import {
|
||
|
CLIPBOARD_FILE_EXTENSIONS,
|
||
|
DEFAULT_MAPPED_NAME,
|
||
|
PROCESS_DELIMITER,
|
||
|
TRANSITIONS_IN_MILLISECONDS,
|
||
|
} from "utils/constants";
|
||
|
import { bufferToBlob } from "utils/functions";
|
||
|
|
||
|
type FilePasteOperations = Record<string, "copy" | "move">;
|
||
|
|
||
|
type FileSystemWatchers = Record<string, UpdateFiles[]>;
|
||
|
|
||
|
type IFileSystemAccess = {
|
||
|
FileSystem: {
|
||
|
FileSystemAccess: {
|
||
|
Create: (
|
||
|
opts: { handle: FileSystemDirectoryHandle },
|
||
|
cb: BFSCallback<FileSystem>
|
||
|
) => void;
|
||
|
};
|
||
|
};
|
||
|
};
|
||
|
|
||
|
type FileSystemContextState = AsyncFS & {
|
||
|
addFile: (
|
||
|
directory: string,
|
||
|
callback: (name: string, buffer?: Buffer) => Promise<void>,
|
||
|
accept?: string,
|
||
|
multiple?: boolean
|
||
|
) => void;
|
||
|
addFsWatcher: (folder: string, updateFiles: UpdateFiles) => void;
|
||
|
copyEntries: (entries: string[]) => void;
|
||
|
createPath: (
|
||
|
name: string,
|
||
|
directory: string,
|
||
|
buffer?: Buffer
|
||
|
) => Promise<string>;
|
||
|
deletePath: (path: string) => Promise<void>;
|
||
|
fs?: FSModule;
|
||
|
mapFs: (
|
||
|
directory: string,
|
||
|
existingHandle?: FileSystemDirectoryHandle
|
||
|
) => Promise<string>;
|
||
|
mkdirRecursive: (path: string) => Promise<void>;
|
||
|
mountFs: (url: string) => Promise<void>;
|
||
|
moveEntries: (entries: string[]) => void;
|
||
|
pasteList: FilePasteOperations;
|
||
|
removeFsWatcher: (folder: string, updateFiles: UpdateFiles) => void;
|
||
|
rootFs?: RootFileSystem;
|
||
|
unMapFs: (directory: string) => void;
|
||
|
unMountFs: (url: string) => void;
|
||
|
updateFolder: (folder: string, newFile?: string, oldFile?: string) => void;
|
||
|
};
|
||
|
|
||
|
const SYSTEM_DIRECTORIES = new Set(["/OPFS"]);
|
||
|
|
||
|
const {
|
||
|
FileSystem: { FileSystemAccess, IsoFS, ZipFS },
|
||
|
} = BrowserFS as IFileSystemAccess & typeof IBrowserFS;
|
||
|
|
||
|
const useFileSystemContextState = (): FileSystemContextState => {
|
||
|
const asyncFs = useAsyncFs();
|
||
|
const {
|
||
|
exists,
|
||
|
mkdir,
|
||
|
readdir,
|
||
|
readFile,
|
||
|
rename,
|
||
|
rmdir,
|
||
|
rootFs,
|
||
|
unlink,
|
||
|
writeFile,
|
||
|
} = asyncFs;
|
||
|
const { closeWithTransition } = useProcesses();
|
||
|
const fsWatchersRef = useRef<FileSystemWatchers>(
|
||
|
Object.create(null) as FileSystemWatchers
|
||
|
);
|
||
|
const [pasteList, setPasteList] = useState<FilePasteOperations>(
|
||
|
Object.create(null) as FilePasteOperations
|
||
|
);
|
||
|
const updatePasteEntries = useCallback(
|
||
|
(entries: string[], operation: "copy" | "move"): void =>
|
||
|
setPasteList(
|
||
|
Object.fromEntries(entries.map((entry) => [entry, operation]))
|
||
|
),
|
||
|
[]
|
||
|
);
|
||
|
const copyToClipboard = useCallback(
|
||
|
(entry: string) => {
|
||
|
if (!CLIPBOARD_FILE_EXTENSIONS.has(extname(entry))) return;
|
||
|
|
||
|
let type = getMimeType(entry);
|
||
|
|
||
|
if (!type) return;
|
||
|
|
||
|
// Bypass "Type image/jpeg not supported on write."
|
||
|
if (type === "image/jpeg") type = "image/png";
|
||
|
|
||
|
try {
|
||
|
navigator.clipboard?.write?.([
|
||
|
new ClipboardItem({
|
||
|
[type]: readFile(entry).then((buffer) =>
|
||
|
bufferToBlob(buffer, type)
|
||
|
),
|
||
|
}),
|
||
|
]);
|
||
|
} catch {
|
||
|
// Ignore failure to copy image to clipboard
|
||
|
}
|
||
|
},
|
||
|
[readFile]
|
||
|
);
|
||
|
const copyEntries = useCallback(
|
||
|
(entries: string[]): void => {
|
||
|
if (entries.length === 1) copyToClipboard(entries[0]);
|
||
|
updatePasteEntries(entries, "copy");
|
||
|
},
|
||
|
[copyToClipboard, updatePasteEntries]
|
||
|
);
|
||
|
const moveEntries = useCallback(
|
||
|
(entries: string[]): void => updatePasteEntries(entries, "move"),
|
||
|
[updatePasteEntries]
|
||
|
);
|
||
|
const addFsWatcher = useCallback(
|
||
|
(folder: string, updateFiles: UpdateFiles): void => {
|
||
|
fsWatchersRef.current[folder] = [
|
||
|
...(fsWatchersRef.current[folder] || []),
|
||
|
updateFiles,
|
||
|
];
|
||
|
},
|
||
|
[]
|
||
|
);
|
||
|
const unusedMountsCleanupTimerRef = useRef(0);
|
||
|
const cleanupUnusedMounts = useCallback(
|
||
|
(secondCheck?: boolean) => {
|
||
|
if (rootFs) {
|
||
|
const mountedPaths = Object.keys(rootFs.mntMap || {}).filter(
|
||
|
(mountedPath) => mountedPath !== "/"
|
||
|
);
|
||
|
|
||
|
if (mountedPaths.length === 0) return;
|
||
|
|
||
|
const watchedPaths = Object.keys(fsWatchersRef.current).filter(
|
||
|
(watchedPath) => fsWatchersRef.current[watchedPath].length > 0
|
||
|
);
|
||
|
|
||
|
mountedPaths.forEach((mountedPath) => {
|
||
|
if (
|
||
|
!watchedPaths.some((watchedPath) =>
|
||
|
watchedPath.startsWith(mountedPath)
|
||
|
) &&
|
||
|
rootFs.mntMap[mountedPath]?.getName() !== "FileSystemAccess"
|
||
|
) {
|
||
|
if (secondCheck) {
|
||
|
rootFs.umount?.(mountedPath);
|
||
|
} else {
|
||
|
unusedMountsCleanupTimerRef.current = window.setTimeout(
|
||
|
() => cleanupUnusedMounts(true),
|
||
|
TRANSITIONS_IN_MILLISECONDS.WINDOW
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
},
|
||
|
[rootFs]
|
||
|
);
|
||
|
const removeFsWatcher = useCallback(
|
||
|
(folder: string, updateFiles: UpdateFiles): void => {
|
||
|
fsWatchersRef.current[folder] = (
|
||
|
fsWatchersRef.current[folder] || []
|
||
|
).filter((updateFilesInstance) => updateFilesInstance !== updateFiles);
|
||
|
|
||
|
if (unusedMountsCleanupTimerRef.current) {
|
||
|
window.clearTimeout(unusedMountsCleanupTimerRef.current);
|
||
|
}
|
||
|
unusedMountsCleanupTimerRef.current = window.setTimeout(
|
||
|
cleanupUnusedMounts,
|
||
|
TRANSITIONS_IN_MILLISECONDS.WINDOW
|
||
|
);
|
||
|
},
|
||
|
[cleanupUnusedMounts]
|
||
|
);
|
||
|
const updateFolder = useCallback(
|
||
|
(folder: string, newFile?: string, oldFile?: string): void =>
|
||
|
fsWatchersRef.current[folder]?.forEach((updateFiles) =>
|
||
|
updateFiles(newFile, oldFile)
|
||
|
),
|
||
|
[]
|
||
|
);
|
||
|
const mapFs = useCallback(
|
||
|
async (
|
||
|
directory: string,
|
||
|
existingHandle?: FileSystemDirectoryHandle
|
||
|
): Promise<string> => {
|
||
|
let handle: FileSystemDirectoryHandle;
|
||
|
|
||
|
try {
|
||
|
handle =
|
||
|
existingHandle ??
|
||
|
(await window.showDirectoryPicker({
|
||
|
id: "MapDirectoryPicker",
|
||
|
mode: "readwrite",
|
||
|
startIn: "desktop",
|
||
|
}));
|
||
|
} catch {
|
||
|
// Ignore cancelling the dialog
|
||
|
}
|
||
|
|
||
|
return new Promise((resolve, reject) => {
|
||
|
if (handle instanceof FileSystemDirectoryHandle) {
|
||
|
FileSystemAccess?.Create({ handle }, (error, newFs) => {
|
||
|
if (error || !newFs) {
|
||
|
reject();
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const systemDirectory = SYSTEM_DIRECTORIES.has(directory);
|
||
|
const mappedName =
|
||
|
removeInvalidFilenameCharacters(handle.name).trim() ||
|
||
|
(systemDirectory ? "" : DEFAULT_MAPPED_NAME);
|
||
|
|
||
|
rootFs?.mount?.(join(directory, mappedName), newFs);
|
||
|
resolve(systemDirectory ? directory : mappedName);
|
||
|
addFileSystemHandle(directory, handle, mappedName);
|
||
|
});
|
||
|
} else {
|
||
|
reject();
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
[rootFs]
|
||
|
);
|
||
|
const mountFs = useCallback(
|
||
|
async (url: string): Promise<void> => {
|
||
|
const fileData = await readFile(url);
|
||
|
|
||
|
return new Promise((resolve, reject) => {
|
||
|
const createFs: BFSCallback<IIsoFS | IZipFS> = (createError, newFs) => {
|
||
|
if (createError) reject();
|
||
|
else if (newFs) {
|
||
|
rootFs?.mount?.(url, newFs);
|
||
|
resolve();
|
||
|
}
|
||
|
};
|
||
|
|
||
|
if (extname(url).toLowerCase() === ".iso") {
|
||
|
IsoFS?.Create({ data: fileData }, createFs);
|
||
|
} else {
|
||
|
ZipFS?.Create({ zipData: fileData }, createFs);
|
||
|
}
|
||
|
});
|
||
|
},
|
||
|
[readFile, rootFs]
|
||
|
);
|
||
|
const unMountFs = useCallback(
|
||
|
(url: string): void => rootFs?.umount?.(url),
|
||
|
[rootFs]
|
||
|
);
|
||
|
const unMapFs = useCallback(
|
||
|
(directory: string): void => {
|
||
|
unMountFs(directory);
|
||
|
removeFileSystemHandle(directory);
|
||
|
updateFolder(dirname(directory), undefined, directory);
|
||
|
},
|
||
|
[unMountFs, updateFolder]
|
||
|
);
|
||
|
const { openTransferDialog } = useTransferDialog();
|
||
|
const addFile = useCallback(
|
||
|
(
|
||
|
directory: string,
|
||
|
callback: (name: string, buffer?: Buffer) => Promise<void>
|
||
|
): void => {
|
||
|
const fileInput = document.createElement("input");
|
||
|
|
||
|
fileInput.type = "file";
|
||
|
fileInput.multiple = true;
|
||
|
fileInput.setAttribute("style", "display: none");
|
||
|
fileInput.addEventListener(
|
||
|
"change",
|
||
|
(event) => {
|
||
|
handleFileInputEvent(
|
||
|
event as InputChangeEvent,
|
||
|
callback,
|
||
|
directory,
|
||
|
openTransferDialog
|
||
|
);
|
||
|
fileInput.remove();
|
||
|
},
|
||
|
{ once: true }
|
||
|
);
|
||
|
document.body.append(fileInput);
|
||
|
fileInput.click();
|
||
|
},
|
||
|
[openTransferDialog]
|
||
|
);
|
||
|
const mkdirRecursive = useCallback(
|
||
|
async (path: string): Promise<void> => {
|
||
|
const pathParts = path.split("/").filter(Boolean);
|
||
|
const recursePath = async (position = 1, retry = 0): Promise<void> => {
|
||
|
const makePath = join("/", pathParts.slice(0, position).join("/"));
|
||
|
let created: boolean;
|
||
|
|
||
|
try {
|
||
|
created = (await exists(makePath)) || (await mkdir(makePath));
|
||
|
} catch {
|
||
|
created = false;
|
||
|
}
|
||
|
|
||
|
if (created) {
|
||
|
if (position !== pathParts.length) {
|
||
|
await recursePath(position + 1);
|
||
|
}
|
||
|
} else if (retry < 3) {
|
||
|
await recursePath(position, retry + 1);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
await recursePath();
|
||
|
},
|
||
|
[exists, mkdir]
|
||
|
);
|
||
|
const deletePath = useCallback(
|
||
|
async (path: string): Promise<void> => {
|
||
|
try {
|
||
|
await unlink(path);
|
||
|
} catch (error) {
|
||
|
if ((error as ApiError).code === "EISDIR") {
|
||
|
const dirContents = await readdir(path);
|
||
|
|
||
|
await Promise.all(
|
||
|
dirContents.map((entry) => deletePath(join(path, entry)))
|
||
|
);
|
||
|
await rmdir(path);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (Object.keys(fsWatchersRef.current || {}).includes(path)) {
|
||
|
closeWithTransition(`FileExplorer${PROCESS_DELIMITER}${path}`);
|
||
|
}
|
||
|
},
|
||
|
[closeWithTransition, readdir, rmdir, unlink]
|
||
|
);
|
||
|
const createPath = useCallback(
|
||
|
async (
|
||
|
name: string,
|
||
|
directory: string,
|
||
|
buffer?: Buffer,
|
||
|
iteration = 0
|
||
|
): Promise<string> => {
|
||
|
const isInternal = !buffer && isAbsolute(name);
|
||
|
const baseName = isInternal ? basename(name) : name;
|
||
|
const uniqueName = iteration
|
||
|
? iterateFileName(baseName, iteration)
|
||
|
: baseName;
|
||
|
const fullNewPath = join(directory, uniqueName);
|
||
|
|
||
|
if (isInternal) {
|
||
|
if (
|
||
|
name !== fullNewPath &&
|
||
|
directory !== name &&
|
||
|
!directory.startsWith(`${name}/`) &&
|
||
|
!rootFs?.mntMap[name]
|
||
|
) {
|
||
|
if (await exists(fullNewPath)) {
|
||
|
return createPath(name, directory, buffer, iteration + 1);
|
||
|
}
|
||
|
|
||
|
if (await rename(name, fullNewPath)) {
|
||
|
updateFolder(dirname(name), "", name);
|
||
|
}
|
||
|
|
||
|
return uniqueName;
|
||
|
}
|
||
|
} else {
|
||
|
const maybeMakePath = async (makePath: string): Promise<void> => {
|
||
|
try {
|
||
|
if (!(await exists(makePath))) {
|
||
|
await mkdir(makePath);
|
||
|
updateFolder(dirname(makePath), basename(makePath));
|
||
|
}
|
||
|
} catch (error) {
|
||
|
if ((error as ApiError).code === "ENOENT") {
|
||
|
await maybeMakePath(dirname(makePath));
|
||
|
await maybeMakePath(makePath);
|
||
|
}
|
||
|
}
|
||
|
};
|
||
|
|
||
|
await maybeMakePath(dirname(fullNewPath));
|
||
|
|
||
|
try {
|
||
|
if (
|
||
|
buffer
|
||
|
? await writeFile(fullNewPath, buffer)
|
||
|
: await mkdir(fullNewPath)
|
||
|
) {
|
||
|
return uniqueName;
|
||
|
}
|
||
|
} catch (error) {
|
||
|
if ((error as ApiError)?.code === "EEXIST") {
|
||
|
return createPath(name, directory, buffer, iteration + 1);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return "";
|
||
|
},
|
||
|
[exists, mkdir, rename, rootFs?.mntMap, updateFolder, writeFile]
|
||
|
);
|
||
|
const restoredFsHandles = useRef(false);
|
||
|
|
||
|
useEffect(() => {
|
||
|
if (!restoredFsHandles.current && rootFs) {
|
||
|
const restoreFsHandles = async (): Promise<void> => {
|
||
|
restoredFsHandles.current = true;
|
||
|
|
||
|
Object.entries(await getFileSystemHandles()).forEach(
|
||
|
async ([handleDirectory, handle]) => {
|
||
|
if (!(await exists(handleDirectory))) {
|
||
|
try {
|
||
|
mapFs(
|
||
|
SYSTEM_DIRECTORIES.has(handleDirectory)
|
||
|
? handleDirectory
|
||
|
: dirname(handleDirectory),
|
||
|
handle
|
||
|
);
|
||
|
} catch {
|
||
|
// Ignore failure
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
);
|
||
|
};
|
||
|
|
||
|
restoreFsHandles();
|
||
|
}
|
||
|
}, [exists, mapFs, rootFs]);
|
||
|
|
||
|
return {
|
||
|
addFile,
|
||
|
addFsWatcher,
|
||
|
copyEntries,
|
||
|
createPath,
|
||
|
deletePath,
|
||
|
mapFs,
|
||
|
mkdirRecursive,
|
||
|
mountFs,
|
||
|
moveEntries,
|
||
|
pasteList,
|
||
|
removeFsWatcher,
|
||
|
unMapFs,
|
||
|
unMountFs,
|
||
|
updateFolder,
|
||
|
...asyncFs,
|
||
|
};
|
||
|
};
|
||
|
|
||
|
export default useFileSystemContextState;
|