2026-08-23 17:05:56 -07:00
|
|
|
import { useCallback, useRef, useState } from "react";
|
2026-03-06 06:32:53 -08:00
|
|
|
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";
|
2026-08-23 17:05:56 -07:00
|
|
|
import { errorText, readableRefusal } from "../lib/refusalText";
|
2026-08-25 10:00:21 -07:00
|
|
|
import { formatBytes } from "../lib/formatBytes";
|
2026-08-23 11:11:43 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ## 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.
|
|
|
|
|
*
|
2026-08-25 10:00:21 -07:00
|
|
|
* Every **transient operation** failure — rename, create folder, upload, save
|
|
|
|
|
* to host — goes to `ToastHost` instead. Those used to land in the same inline `error` div, which
|
2026-08-23 17:05:56 -07:00
|
|
|
* 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. 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.
|
2026-08-23 11:11:43 -07:00
|
|
|
*
|
|
|
|
|
* ## 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
|
2026-08-23 17:05:56 -07:00
|
|
|
* the start and compares it against the ref at the end: a slow rename in
|
2026-08-23 11:11:43 -07:00
|
|
|
* `/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 11:11:43 -07:00
|
|
|
/**
|
2026-08-23 17:05:56 -07:00
|
|
|
* What just finished, for the live region — a rename or a new folder is a
|
|
|
|
|
* change a sighted user sees in the grid and a screen reader user does not.
|
2026-08-23 11:11:43 -07:00
|
|
|
*/
|
|
|
|
|
const [completed, setCompleted] = useState<string | null>(null);
|
2026-08-25 10:00:21 -07:00
|
|
|
/**
|
|
|
|
|
* Which host transfers are in flight.
|
|
|
|
|
*
|
|
|
|
|
* Both actions open an OS dialog and can then run for a long time on a large
|
|
|
|
|
* file, with nothing on screen to say so. Without this the buttons stay live:
|
|
|
|
|
* a second click opens a second dialog and runs a second concurrent exec
|
|
|
|
|
* against the same file, and a multi-gigabyte save is indistinguishable from
|
|
|
|
|
* a click that did nothing.
|
|
|
|
|
*
|
2026-08-25 10:42:26 -07:00
|
|
|
* `savingPaths` is a **set**, not one path. Keeping only the row being
|
|
|
|
|
* disabled is what makes the pane usable during a big transfer — and that is
|
|
|
|
|
* precisely what makes a *second* save startable, so the state has to be able
|
|
|
|
|
* to hold two. As a scalar it could not: starting a save on `notes.txt` while
|
|
|
|
|
* `big.bin` was still streaming overwrote it, so `big.bin`'s button went live
|
|
|
|
|
* again mid-transfer; and whichever save finished first cleared the flag for
|
|
|
|
|
* both. Dismissing the second dialog was enough to do it.
|
|
|
|
|
*
|
|
|
|
|
* Paths are unique within a listing, so a path is a usable key — `FilesTab`
|
|
|
|
|
* relies on the same fact for its row keys.
|
2026-08-25 10:00:21 -07:00
|
|
|
*/
|
|
|
|
|
const [uploading, setUploading] = useState(false);
|
2026-08-25 10:42:26 -07:00
|
|
|
const [savingPaths, setSavingPaths] = useState<ReadonlySet<string>>(new Set());
|
2026-08-23 11:11:43 -07:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-08-23 12:02:16 -07:00
|
|
|
/**
|
|
|
|
|
* Report a failed operation, given the headline this hook would write and the
|
2026-08-23 17:05:56 -07:00
|
|
|
* raw failure behind it.
|
2026-08-23 12:02:16 -07:00
|
|
|
*
|
|
|
|
|
* The headline is what the *hook* knows ("Could not rename …"); it is a
|
|
|
|
|
* category, not an explanation. Some backend refusals are already a finished
|
2026-08-23 17:05:56 -07:00
|
|
|
* sentence written for the person reading it — a container path outside the
|
|
|
|
|
* roots this panel may change, a name it will not create — and those used to
|
2026-08-23 12:02:16 -07:00
|
|
|
* 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
|
2026-08-23 17:05:56 -07:00
|
|
|
* neither. When there is such a sentence it becomes the headline, and there
|
|
|
|
|
* is nothing left to hide.
|
2026-08-23 12:02:16 -07:00
|
|
|
*/
|
2026-08-23 17:05:56 -07:00
|
|
|
const report = useCallback((message: string, cause: unknown) => {
|
|
|
|
|
const promoted = readableRefusal(cause);
|
2026-08-23 12:02:16 -07:00
|
|
|
useAppState.getState().pushToast({
|
|
|
|
|
kind: "error",
|
|
|
|
|
message: promoted ?? message,
|
2026-08-23 17:05:56 -07:00
|
|
|
detail: promoted ? undefined : errorText(cause),
|
2026-08-23 12:02:16 -07:00
|
|
|
});
|
2026-08-23 11:11:43 -07:00
|
|
|
}, []);
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
2026-08-25 10:00:21 -07:00
|
|
|
/**
|
|
|
|
|
* Copy host files into the directory on screen.
|
|
|
|
|
*
|
|
|
|
|
* The picker is opened by **Rust**, not here — `upload_files_to_container`
|
|
|
|
|
* shows it, reads what the user chose and never lets a host path near IPC.
|
|
|
|
|
* So this passes a directory and gets back an outcome; `null` means the user
|
|
|
|
|
* dismissed the dialog, which is not a failure and says nothing.
|
|
|
|
|
*
|
|
|
|
|
* One dialog can select several files and they need not agree, hence two
|
|
|
|
|
* lists. Every failure is reported, because "3 of 5 uploaded" without saying
|
|
|
|
|
* which two is not a report. The listing is refreshed once, at the end, and
|
|
|
|
|
* only if the user is still looking at the directory that was targeted.
|
|
|
|
|
*/
|
|
|
|
|
const uploadFiles = useCallback(async () => {
|
|
|
|
|
const target = currentPathRef.current;
|
|
|
|
|
setUploading(true);
|
|
|
|
|
try {
|
2026-08-25 10:42:26 -07:00
|
|
|
let outcome;
|
|
|
|
|
try {
|
|
|
|
|
outcome = await commands.uploadFilesToContainer(projectId, target);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// A failure *before* the picker: no container, not running, or a
|
|
|
|
|
// directory this pane may not write to. One toast, not one per file.
|
|
|
|
|
report("Could not upload", e);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!outcome) return;
|
|
|
|
|
for (const failure of outcome.failures) {
|
|
|
|
|
useAppState.getState().pushToast({ kind: "error", message: failure });
|
|
|
|
|
}
|
|
|
|
|
if (outcome.uploaded.length === 0) return;
|
|
|
|
|
// The directory is named, not implied. `target` is captured at click time
|
|
|
|
|
// and the picker is a modal OS dialog — the user has all the time in the
|
|
|
|
|
// world to browse somewhere else while it is open, and the files land
|
|
|
|
|
// where they started. "Uploaded 2 files." in front of a grid that does
|
|
|
|
|
// not contain them is a worse answer than no message at all.
|
2026-08-25 10:00:21 -07:00
|
|
|
const count = outcome.uploaded.length;
|
|
|
|
|
setCompleted(
|
|
|
|
|
`Uploaded ${count === 1 ? "1 file" : `${count} files`} to ${target}.`,
|
|
|
|
|
);
|
|
|
|
|
if (currentPathRef.current === target) await navigate(target);
|
2026-08-25 10:42:26 -07:00
|
|
|
} finally {
|
|
|
|
|
// Around the *whole* body, refresh included. Clearing it the moment the
|
|
|
|
|
// command settled put the button back before the re-listing had run, so
|
|
|
|
|
// a second click landed mid-refresh on a grid that was still the old one.
|
|
|
|
|
setUploading(false);
|
2026-08-25 10:00:21 -07:00
|
|
|
}
|
|
|
|
|
}, [projectId, navigate, report]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Save one file out to the host, with Rust opening the save dialog.
|
|
|
|
|
*
|
|
|
|
|
* No refresh: nothing in the container changed. The save dialog is also what
|
|
|
|
|
* asks about overwriting an existing host file, which is why the backend has
|
|
|
|
|
* no collision handling of its own to get wrong. `null` is a dismissal.
|
|
|
|
|
*/
|
|
|
|
|
const saveToHost = useCallback(
|
|
|
|
|
async (entry: FileEntry) => {
|
2026-08-25 10:42:26 -07:00
|
|
|
setSavingPaths((live) => new Set(live).add(entry.path));
|
2026-08-25 10:00:21 -07:00
|
|
|
try {
|
|
|
|
|
const bytes = await commands.downloadContainerFile(projectId, entry.path);
|
|
|
|
|
// `0` is a real answer — an empty file saved is a success — so this
|
|
|
|
|
// tests for the dismissal sentinel, not for falsiness.
|
|
|
|
|
if (bytes === null) return;
|
|
|
|
|
setCompleted(`Saved "${entry.name}" (${formatBytes(bytes)}).`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
report(`Could not save "${entry.name}"`, e);
|
|
|
|
|
} finally {
|
2026-08-25 10:42:26 -07:00
|
|
|
// Remove only this one. A save that finishes while another is still
|
|
|
|
|
// streaming must not re-enable the other's row.
|
|
|
|
|
setSavingPaths((live) => {
|
|
|
|
|
const next = new Set(live);
|
|
|
|
|
next.delete(entry.path);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
2026-08-25 10:00:21 -07:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[projectId, report],
|
|
|
|
|
);
|
|
|
|
|
|
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 11:11:43 -07:00
|
|
|
/** What the last operation finished doing, for the live region. */
|
|
|
|
|
completed,
|
2026-08-23 08:30:48 -07:00
|
|
|
setError,
|
2026-03-06 06:32:53 -08:00
|
|
|
navigate,
|
|
|
|
|
goUp,
|
|
|
|
|
refresh,
|
2026-08-23 08:30:48 -07:00
|
|
|
renameEntry,
|
|
|
|
|
createFolder,
|
2026-08-25 10:00:21 -07:00
|
|
|
uploadFiles,
|
|
|
|
|
saveToHost,
|
|
|
|
|
/** A host transfer is in flight — see the state declarations above. */
|
|
|
|
|
uploading,
|
2026-08-25 10:42:26 -07:00
|
|
|
savingPaths,
|
2026-03-06 06:32:53 -08:00
|
|
|
};
|
|
|
|
|
}
|