2026-08-23 11:11:43 -07:00
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
2026-03-06 06:32:53 -08:00
|
|
|
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
|
|
|
|
import type { FileEntry } from "../lib/types";
|
|
|
|
|
import * as commands from "../lib/tauri-commands";
|
2026-08-23 11:11:43 -07:00
|
|
|
import { useAppState } from "../store/appState";
|
|
|
|
|
import {
|
2026-08-23 12:02:16 -07:00
|
|
|
errorText,
|
2026-08-23 11:11:43 -07:00
|
|
|
fileExistsPath,
|
|
|
|
|
isFileExistsError,
|
2026-08-23 12:02:16 -07:00
|
|
|
readableRefusal,
|
2026-08-23 11:11:43 -07:00
|
|
|
type OverwriteChoice,
|
|
|
|
|
} from "../lib/uploadErrors";
|
2026-03-06 06:32:53 -08:00
|
|
|
|
2026-08-23 11:11:43 -07:00
|
|
|
/**
|
|
|
|
|
* One upload waiting on the user to say whether it may replace what is there.
|
|
|
|
|
* `remaining` is how many files are queued behind this one, which is what
|
|
|
|
|
* decides whether the blanket answers are worth offering.
|
|
|
|
|
*/
|
|
|
|
|
export interface UploadConflict {
|
|
|
|
|
/** Host file being uploaded. */
|
|
|
|
|
hostPath: string;
|
|
|
|
|
/** Bare name, for the prompt. */
|
|
|
|
|
name: string;
|
|
|
|
|
/** Container directory it is going into. */
|
|
|
|
|
directory: string;
|
|
|
|
|
remaining: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** `/a/b/c.txt` and `C:\a\b\c.txt` both give `c.txt`. */
|
|
|
|
|
function baseName(path: string): string {
|
|
|
|
|
const parts = path.split(/[\\/]/);
|
|
|
|
|
return parts[parts.length - 1] || path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ## Where failures are reported
|
|
|
|
|
*
|
|
|
|
|
* Two audiences, two places, and the split is deliberate.
|
|
|
|
|
*
|
|
|
|
|
* The **initial listing** failure stays in `error`, rendered inline above the
|
|
|
|
|
* (empty) grid. It is on screen, it is in context, it explains why there are
|
|
|
|
|
* no rows, and it is not transient — it stands until the directory lists.
|
|
|
|
|
*
|
|
|
|
|
* Every **transient operation** failure — upload, rename, create folder,
|
2026-08-23 15:20:22 -07:00
|
|
|
* save-to-host — goes to `ToastHost` instead. Those used to land
|
2026-08-23 11:11:43 -07:00
|
|
|
* in the same inline `error` div, which is the first child of the *scrolling*
|
|
|
|
|
* list: three hundred rows down, a refused rename produced no visible change
|
|
|
|
|
* at all, just a rename box that stayed open for no stated reason. Worse, the
|
|
|
|
|
* file viewer routes its "Save to host…" through the same call, and the viewer
|
|
|
|
|
* is a `fixed inset-0` portal at `z-50` — so that failure reported *behind* the
|
|
|
|
|
* dialog that caused it. The toast host is a persistent `aria-live` region at
|
|
|
|
|
* `z-[60]`, i.e. the one place in the app that is above a modal and does not
|
|
|
|
|
* scroll away.
|
|
|
|
|
*
|
|
|
|
|
* ## Where the current directory lives
|
|
|
|
|
*
|
|
|
|
|
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it
|
|
|
|
|
* after an await). Every long operation captures the directory it targets at
|
|
|
|
|
* the start and compares it against the ref at the end: a 200 MB upload into
|
|
|
|
|
* `/workspace` must not drag the pane back out of `src/` because that is where
|
|
|
|
|
* the closure happened to be created. The ref moves at the *start* of a
|
|
|
|
|
* navigation rather than when the listing lands, because the question being
|
|
|
|
|
* asked is "where is the user going", not "what is on screen right now" — and
|
|
|
|
|
* it is put back if that navigation fails.
|
|
|
|
|
*/
|
2026-03-06 06:32:53 -08:00
|
|
|
export function useFileManager(projectId: string) {
|
|
|
|
|
const [currentPath, setCurrentPath] = useState("/workspace");
|
|
|
|
|
const [entries, setEntries] = useState<FileEntry[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-08-23 08:30:48 -07:00
|
|
|
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
|
|
|
|
|
const [busy, setBusy] = useState<string | null>(null);
|
2026-08-23 11:11:43 -07:00
|
|
|
/**
|
|
|
|
|
* What just finished. A live region that only ever says "uploading…" tells a
|
|
|
|
|
* screen reader user when to start waiting and never when to stop.
|
|
|
|
|
*/
|
|
|
|
|
const [completed, setCompleted] = useState<string | null>(null);
|
|
|
|
|
const [conflict, setConflict] = useState<UploadConflict | null>(null);
|
|
|
|
|
|
|
|
|
|
const currentPathRef = useRef(currentPath);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A slow listing can land after a newer one and set both the rows and the
|
|
|
|
|
* breadcrumb back to a directory the user already left. Same generation
|
2026-08-23 15:20:22 -07:00
|
|
|
* guard `useContainerMigration` uses: every async write
|
2026-08-23 11:11:43 -07:00
|
|
|
* checks it is still the newest before it lands.
|
|
|
|
|
*/
|
|
|
|
|
const navGeneration = useRef(0);
|
|
|
|
|
|
|
|
|
|
const startWork = useCallback((note: string) => {
|
|
|
|
|
setBusy(note);
|
|
|
|
|
setCompleted(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-08-23 12:02:16 -07:00
|
|
|
/**
|
|
|
|
|
* Report a failed operation, given the headline this hook would write and the
|
|
|
|
|
* raw failures behind it.
|
|
|
|
|
*
|
|
|
|
|
* The headline is what the *hook* knows ("Could not rename …"); it is a
|
|
|
|
|
* category, not an explanation. Some backend refusals are already a finished
|
|
|
|
|
* sentence written for the person reading it — a hidden host folder, a
|
|
|
|
|
* container path outside the roots this panel may change — and those used to
|
|
|
|
|
* arrive as the toast's `detail`, which `ToastHost` renders as collapsed
|
|
|
|
|
* monospace behind a "Details" button. So the sentence that said what was
|
|
|
|
|
* wrong and what to do about it was hidden under a headline that said
|
|
|
|
|
* neither. When every failure reduces to the *same* such sentence — which is
|
|
|
|
|
* the normal case, since these refusals are about the target directory and so
|
|
|
|
|
* fail identically for every file in a batch — it becomes the headline and
|
|
|
|
|
* there is nothing left to hide.
|
|
|
|
|
*/
|
|
|
|
|
const report = useCallback((message: string, ...causes: unknown[]) => {
|
|
|
|
|
const refusals = causes.map(readableRefusal);
|
|
|
|
|
const shared =
|
|
|
|
|
causes.length > 0 && refusals.every((r) => r !== null)
|
|
|
|
|
? [...new Set(refusals as string[])]
|
|
|
|
|
: [];
|
|
|
|
|
const promoted = shared.length === 1 ? shared[0] : null;
|
|
|
|
|
useAppState.getState().pushToast({
|
|
|
|
|
kind: "error",
|
|
|
|
|
message: promoted ?? message,
|
|
|
|
|
detail: promoted || causes.length === 0 ? undefined : causes.map(errorText).join("\n"),
|
|
|
|
|
});
|
2026-08-23 11:11:43 -07:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const confirm = useCallback((message: string) => {
|
|
|
|
|
useAppState.getState().pushToast({ kind: "success", message });
|
|
|
|
|
}, []);
|
2026-03-06 06:32:53 -08:00
|
|
|
|
|
|
|
|
const navigate = useCallback(
|
|
|
|
|
async (path: string) => {
|
2026-08-23 11:11:43 -07:00
|
|
|
const mine = ++navGeneration.current;
|
|
|
|
|
const previous = currentPathRef.current;
|
|
|
|
|
currentPathRef.current = path;
|
2026-03-06 06:32:53 -08:00
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const result = await commands.listContainerFiles(projectId, path);
|
2026-08-23 11:11:43 -07:00
|
|
|
if (navGeneration.current !== mine) return;
|
2026-03-06 06:32:53 -08:00
|
|
|
setEntries(result);
|
|
|
|
|
setCurrentPath(path);
|
|
|
|
|
} catch (e) {
|
2026-08-23 11:11:43 -07:00
|
|
|
if (navGeneration.current !== mine) return;
|
|
|
|
|
// The move did not happen, so the pane is still where it was — the ref
|
|
|
|
|
// has to agree with the breadcrumb or the next operation will decide
|
|
|
|
|
// it targeted a directory nobody is looking at.
|
|
|
|
|
currentPathRef.current = previous;
|
2026-03-06 06:32:53 -08:00
|
|
|
setError(String(e));
|
|
|
|
|
} finally {
|
2026-08-23 11:11:43 -07:00
|
|
|
if (navGeneration.current === mine) setLoading(false);
|
2026-03-06 06:32:53 -08:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[projectId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const goUp = useCallback(() => {
|
2026-08-23 11:11:43 -07:00
|
|
|
const here = currentPathRef.current;
|
|
|
|
|
if (here === "/") return;
|
|
|
|
|
const parent = here.replace(/\/[^/]+$/, "") || "/";
|
2026-03-06 06:32:53 -08:00
|
|
|
navigate(parent);
|
2026-08-23 11:11:43 -07:00
|
|
|
}, [navigate]);
|
2026-03-06 06:32:53 -08:00
|
|
|
|
|
|
|
|
const refresh = useCallback(() => {
|
2026-08-23 11:11:43 -07:00
|
|
|
navigate(currentPathRef.current);
|
|
|
|
|
}, [navigate]);
|
2026-03-06 06:32:53 -08:00
|
|
|
|
2026-08-23 08:30:48 -07:00
|
|
|
/** Copy an entry out to a host path the user picks. */
|
2026-03-06 06:32:53 -08:00
|
|
|
const downloadFile = useCallback(
|
|
|
|
|
async (entry: FileEntry) => {
|
|
|
|
|
try {
|
|
|
|
|
const hostPath = await save({ defaultPath: entry.name });
|
|
|
|
|
if (!hostPath) return;
|
2026-08-23 11:11:43 -07:00
|
|
|
// Every sibling operation sets `busy`; this one did not, so a 200 MB
|
|
|
|
|
// copy was a click, then a frozen-looking pane, then nothing.
|
|
|
|
|
startWork(`Saving "${entry.name}" to the host…`);
|
|
|
|
|
try {
|
|
|
|
|
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
|
|
|
|
setCompleted(`Saved "${entry.name}" to ${hostPath}.`);
|
|
|
|
|
confirm(`Saved "${entry.name}" to the host.`);
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(null);
|
|
|
|
|
}
|
2026-03-06 06:32:53 -08:00
|
|
|
} catch (e) {
|
2026-08-23 12:02:16 -07:00
|
|
|
report(`Could not save "${entry.name}" to the host`, e);
|
2026-03-06 06:32:53 -08:00
|
|
|
}
|
|
|
|
|
},
|
2026-08-23 11:11:43 -07:00
|
|
|
[projectId, startWork, report, confirm],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The pending answer to `conflict`. Kept in a ref rather than state because
|
|
|
|
|
* the upload loop is `await`ing it — it needs the resolver, not a re-render.
|
|
|
|
|
*/
|
|
|
|
|
const conflictResolver = useRef<((choice: OverwriteChoice) => void) | null>(null);
|
|
|
|
|
|
|
|
|
|
const resolveConflict = useCallback((choice: OverwriteChoice) => {
|
|
|
|
|
const resolve = conflictResolver.current;
|
|
|
|
|
conflictResolver.current = null;
|
|
|
|
|
setConflict(null);
|
|
|
|
|
resolve?.(choice);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// A pane unmounted mid-prompt (the tab was closed, the container stopped)
|
|
|
|
|
// would otherwise leave the upload loop awaiting an answer that can never
|
|
|
|
|
// come. Skipping is the safe reading of "the dialog went away".
|
|
|
|
|
useEffect(
|
|
|
|
|
() => () => {
|
|
|
|
|
conflictResolver.current?.("skip-all");
|
|
|
|
|
conflictResolver.current = null;
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const askOverwrite = useCallback(
|
|
|
|
|
(hostPath: string, directory: string, remaining: number, containerPath: string | null) =>
|
|
|
|
|
new Promise<OverwriteChoice>((resolve) => {
|
2026-08-23 12:02:16 -07:00
|
|
|
// One batch asks one question at a time — the loop awaits each answer —
|
|
|
|
|
// so a resolver still sitting here belongs to a *different* batch (two
|
|
|
|
|
// drops in flight at once, or a drop landing while the Upload button's
|
|
|
|
|
// batch is still copying). Installing over it would leave that batch
|
|
|
|
|
// awaiting an answer no dialog can ever produce: a silent hang, with
|
|
|
|
|
// its file neither uploaded nor skipped. Skipping it is the same
|
|
|
|
|
// reading of "the dialog went away" the unmount cleanup uses.
|
|
|
|
|
conflictResolver.current?.("skip");
|
2026-08-23 11:11:43 -07:00
|
|
|
conflictResolver.current = resolve;
|
|
|
|
|
setConflict({
|
|
|
|
|
hostPath,
|
|
|
|
|
name: baseName(containerPath ?? hostPath),
|
|
|
|
|
directory,
|
|
|
|
|
remaining,
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
[],
|
2026-03-06 06:32:53 -08:00
|
|
|
);
|
|
|
|
|
|
2026-08-23 08:30:48 -07:00
|
|
|
/**
|
|
|
|
|
* Copy host files into the current directory. Shared by the Upload button and
|
|
|
|
|
* the native drag-drop listener, so a dropped file and a picked one take the
|
|
|
|
|
* same path — including the one refresh at the end rather than one per file.
|
2026-08-23 11:11:43 -07:00
|
|
|
*
|
|
|
|
|
* The backend refuses to overwrite unless asked to, so a name clash is not a
|
|
|
|
|
* failure here: it is a question, and the answer can be given once for the
|
|
|
|
|
* whole batch.
|
2026-08-23 08:30:48 -07:00
|
|
|
*/
|
|
|
|
|
const uploadPaths = useCallback(
|
|
|
|
|
async (hostPaths: string[]) => {
|
|
|
|
|
if (hostPaths.length === 0) return;
|
2026-08-23 11:11:43 -07:00
|
|
|
// The directory this upload is *for*. Compared against the live ref at
|
|
|
|
|
// the end, because the user is free to walk away while it copies.
|
|
|
|
|
const target = currentPathRef.current;
|
|
|
|
|
startWork(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}…`);
|
2026-08-23 12:02:16 -07:00
|
|
|
/** Raw failures, kept unstringified so `report` can read their shape. */
|
|
|
|
|
const failures: unknown[] = [];
|
2026-08-23 11:11:43 -07:00
|
|
|
let uploaded = 0;
|
|
|
|
|
let skipped = 0;
|
|
|
|
|
/** A "…all" answer, applied to every remaining clash without asking. */
|
|
|
|
|
let blanket: OverwriteChoice | null = null;
|
2026-08-23 08:30:48 -07:00
|
|
|
try {
|
2026-08-23 11:11:43 -07:00
|
|
|
for (let i = 0; i < hostPaths.length; i++) {
|
|
|
|
|
const hostPath = hostPaths[i];
|
2026-08-23 08:30:48 -07:00
|
|
|
try {
|
2026-08-23 11:11:43 -07:00
|
|
|
await commands.uploadFileToContainer(projectId, hostPath, target);
|
|
|
|
|
uploaded++;
|
|
|
|
|
continue;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!isFileExistsError(e)) {
|
2026-08-23 12:02:16 -07:00
|
|
|
failures.push(e);
|
2026-08-23 11:11:43 -07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const choice: OverwriteChoice =
|
|
|
|
|
blanket ??
|
|
|
|
|
(await askOverwrite(
|
|
|
|
|
hostPath,
|
|
|
|
|
target,
|
|
|
|
|
hostPaths.length - i - 1,
|
|
|
|
|
fileExistsPath(e),
|
|
|
|
|
));
|
|
|
|
|
if (choice === "replace-all" || choice === "skip-all") blanket = choice;
|
|
|
|
|
if (choice === "skip" || choice === "skip-all") {
|
|
|
|
|
skipped++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await commands.uploadFileToContainer(projectId, hostPath, target, true);
|
|
|
|
|
uploaded++;
|
2026-08-23 08:30:48 -07:00
|
|
|
} catch (e) {
|
2026-08-23 12:02:16 -07:00
|
|
|
failures.push(e);
|
2026-08-23 08:30:48 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(null);
|
|
|
|
|
}
|
2026-08-23 11:11:43 -07:00
|
|
|
|
|
|
|
|
const summary =
|
|
|
|
|
`Uploaded ${uploaded} item${uploaded === 1 ? "" : "s"}` +
|
|
|
|
|
(skipped > 0 ? `, skipped ${skipped}` : "") +
|
|
|
|
|
(failures.length > 0 ? `, ${failures.length} failed` : "") +
|
|
|
|
|
".";
|
|
|
|
|
setCompleted(summary);
|
|
|
|
|
|
|
|
|
|
if (failures.length > 0) {
|
|
|
|
|
report(
|
|
|
|
|
failures.length === 1 ? "A file could not be uploaded" : `${failures.length} files could not be uploaded`,
|
2026-08-23 12:02:16 -07:00
|
|
|
...failures,
|
2026-08-23 11:11:43 -07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// Only re-list if the user is still looking at the directory this went
|
|
|
|
|
// into. Navigating away during a slow copy used to drag the pane back.
|
|
|
|
|
if (currentPathRef.current === target) await navigate(target);
|
2026-08-23 08:30:48 -07:00
|
|
|
},
|
2026-08-23 11:11:43 -07:00
|
|
|
[projectId, navigate, startWork, report, askOverwrite],
|
2026-08-23 08:30:48 -07:00
|
|
|
);
|
|
|
|
|
|
2026-03-06 06:32:53 -08:00
|
|
|
const uploadFile = useCallback(async () => {
|
|
|
|
|
try {
|
2026-08-23 08:30:48 -07:00
|
|
|
const selected = await openDialog({ multiple: true, directory: false });
|
2026-03-06 06:32:53 -08:00
|
|
|
if (!selected) return;
|
2026-08-23 08:30:48 -07:00
|
|
|
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
|
2026-03-06 06:32:53 -08:00
|
|
|
} catch (e) {
|
2026-08-23 12:02:16 -07:00
|
|
|
report("Could not open the file picker", e);
|
2026-03-06 06:32:53 -08:00
|
|
|
}
|
2026-08-23 11:11:43 -07:00
|
|
|
}, [uploadPaths, report]);
|
2026-08-23 08:30:48 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Rename in place. `newName` is a bare name — Rust rejects anything with a
|
|
|
|
|
* `/` in it, so this can never turn into a move. Resolves true on success so
|
|
|
|
|
* the caller knows whether to leave edit mode.
|
|
|
|
|
*/
|
|
|
|
|
const renameEntry = useCallback(
|
|
|
|
|
async (entry: FileEntry, newName: string) => {
|
|
|
|
|
const trimmed = newName.trim();
|
|
|
|
|
if (!trimmed || trimmed === entry.name) return true;
|
2026-08-23 11:11:43 -07:00
|
|
|
const target = currentPathRef.current;
|
2026-08-23 08:30:48 -07:00
|
|
|
try {
|
|
|
|
|
await commands.renameContainerPath(projectId, entry.path, trimmed);
|
2026-08-23 11:11:43 -07:00
|
|
|
setCompleted(`Renamed "${entry.name}" to "${trimmed}".`);
|
|
|
|
|
if (currentPathRef.current === target) await navigate(target);
|
2026-08-23 08:30:48 -07:00
|
|
|
return true;
|
|
|
|
|
} catch (e) {
|
2026-08-23 12:02:16 -07:00
|
|
|
report(`Could not rename "${entry.name}"`, e);
|
2026-08-23 08:30:48 -07:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-08-23 11:11:43 -07:00
|
|
|
[projectId, navigate, report],
|
2026-08-23 08:30:48 -07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const createFolder = useCallback(
|
|
|
|
|
async (name: string) => {
|
|
|
|
|
const trimmed = name.trim();
|
|
|
|
|
if (!trimmed) return true;
|
2026-08-23 11:11:43 -07:00
|
|
|
const target = currentPathRef.current;
|
2026-08-23 08:30:48 -07:00
|
|
|
try {
|
2026-08-23 11:11:43 -07:00
|
|
|
await commands.createContainerDirectory(projectId, target, trimmed);
|
|
|
|
|
setCompleted(`Created "${trimmed}".`);
|
|
|
|
|
if (currentPathRef.current === target) await navigate(target);
|
2026-08-23 08:30:48 -07:00
|
|
|
return true;
|
|
|
|
|
} catch (e) {
|
2026-08-23 12:02:16 -07:00
|
|
|
report(`Could not create "${trimmed}"`, e);
|
2026-08-23 08:30:48 -07:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-08-23 11:11:43 -07:00
|
|
|
[projectId, navigate, report],
|
2026-08-23 08:30:48 -07:00
|
|
|
);
|
2026-03-06 06:32:53 -08:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
currentPath,
|
|
|
|
|
entries,
|
|
|
|
|
loading,
|
2026-08-23 11:11:43 -07:00
|
|
|
/** Inline, in-context: why the listing on screen is empty. */
|
2026-03-06 06:32:53 -08:00
|
|
|
error,
|
2026-08-23 08:30:48 -07:00
|
|
|
busy,
|
2026-08-23 11:11:43 -07:00
|
|
|
/** What the last operation finished doing, for the live region. */
|
|
|
|
|
completed,
|
|
|
|
|
/** An upload waiting for a Replace / Skip answer, or `null`. */
|
|
|
|
|
conflict,
|
|
|
|
|
resolveConflict,
|
2026-08-23 08:30:48 -07:00
|
|
|
setError,
|
2026-03-06 06:32:53 -08:00
|
|
|
navigate,
|
|
|
|
|
goUp,
|
|
|
|
|
refresh,
|
|
|
|
|
downloadFile,
|
|
|
|
|
uploadFile,
|
2026-08-23 08:30:48 -07:00
|
|
|
uploadPaths,
|
|
|
|
|
renameEntry,
|
|
|
|
|
createFolder,
|
2026-03-06 06:32:53 -08:00
|
|
|
};
|
|
|
|
|
}
|