import { useCallback, useRef, useState } from "react"; import type { FileEntry } from "../lib/types"; import * as commands from "../lib/tauri-commands"; import { useAppState } from "../store/appState"; import { errorText, readableRefusal } from "../lib/refusalText"; /** * ## 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 — rename, create folder — goes to * `ToastHost` instead. Those used to land 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. 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 slow rename in * `/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. */ export function useFileManager(projectId: string) { const [currentPath, setCurrentPath] = useState("/workspace"); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); /** * 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. */ const [completed, setCompleted] = useState(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 * guard `useContainerMigration` uses: every async write * checks it is still the newest before it lands. */ const navGeneration = useRef(0); /** * Report a failed operation, given the headline this hook would write and the * raw failure 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 container path outside the * roots this panel may change, a name it will not create — 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 there is such a sentence it becomes the headline, and there * is nothing left to hide. */ const report = useCallback((message: string, cause: unknown) => { const promoted = readableRefusal(cause); useAppState.getState().pushToast({ kind: "error", message: promoted ?? message, detail: promoted ? undefined : errorText(cause), }); }, []); const navigate = useCallback( async (path: string) => { const mine = ++navGeneration.current; const previous = currentPathRef.current; currentPathRef.current = path; setLoading(true); setError(null); try { const result = await commands.listContainerFiles(projectId, path); if (navGeneration.current !== mine) return; setEntries(result); setCurrentPath(path); } catch (e) { 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; setError(String(e)); } finally { if (navGeneration.current === mine) setLoading(false); } }, [projectId], ); const goUp = useCallback(() => { const here = currentPathRef.current; if (here === "/") return; const parent = here.replace(/\/[^/]+$/, "") || "/"; navigate(parent); }, [navigate]); const refresh = useCallback(() => { navigate(currentPathRef.current); }, [navigate]); /** * 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; const target = currentPathRef.current; try { await commands.renameContainerPath(projectId, entry.path, trimmed); setCompleted(`Renamed "${entry.name}" to "${trimmed}".`); if (currentPathRef.current === target) await navigate(target); return true; } catch (e) { report(`Could not rename "${entry.name}"`, e); return false; } }, [projectId, navigate, report], ); const createFolder = useCallback( async (name: string) => { const trimmed = name.trim(); if (!trimmed) return true; const target = currentPathRef.current; try { await commands.createContainerDirectory(projectId, target, trimmed); setCompleted(`Created "${trimmed}".`); if (currentPathRef.current === target) await navigate(target); return true; } catch (e) { report(`Could not create "${trimmed}"`, e); return false; } }, [projectId, navigate, report], ); return { currentPath, entries, loading, /** Inline, in-context: why the listing on screen is empty. */ error, /** What the last operation finished doing, for the live region. */ completed, setError, navigate, goUp, refresh, renameEntry, createFolder, }; }