Fix HIGH and MEDIUM frontend defects
Files pane
- F16: a drag-out released back inside the app no longer re-imports its own
staged copy over the container original. An in-flight flag (cleared from the
drag plugin's `onEvent` channel, with a watchdog) suppresses the drop and the
"Drop files into …" hint, and an exact staged-path filter is the second line
of defence — the `path|size|modified` cache could otherwise write a
minutes-old snapshot over a file an agent had since rewritten.
- F17: a slow upload/rename no longer yanks the user back to the directory the
operation started in. Every operation captures its target path and re-lists
only if the user is still there; failures go to the toast host either way.
- The grid keeps keyboard focus. Roving tabindex (one tab stop, not one per
row) plus focus restore after navigation, rename commit/cancel and Escape.
- Transient failures now surface in `ToastHost` (z-[60], persistent aria-live)
instead of a `role="alert"` 300 rows down a scroller or behind a modal
overlay. The inline error is kept only for the listing failure.
- `navigate` is sequenced by generation; "Save to host…" sets `busy`.
- Grid a11y: column headers, a text affordance for folder vs file, a live
region that is mounted empty and announces completion, Label-in-Name fixed.
- FileViewerModal: the blob URL is released only once its replacement exists;
the preview is a focusable, named, scrollable region.
Native drop routing
- New `lib/dropTarget.ts`: the hit test now refuses a drop while any
`[aria-modal="true"]` dialog or `[data-blocks-drop]` overlay is up, and
checks z-order where the environment can answer it. Shared by FilesTab and
TerminalView; App's shutdown overlay opts in.
Disk
- A partially failed reclaim says so in words ("… — 2 of 5 failed"), not by hue
alone.
- The scan/reclaim race is closed: every mutation retires an in-flight scan, so
a scan can no longer repaint a pre-reclaim report plus a clickable plan of
objects that are gone. Scan is disabled while working; the status is a live
region; a failed destructive action keeps its dialog open and reports there.
- The "unknown" layer count gets a screen-reader fallback; `--text-disabled`
no longer carries live information.
Terminal / OAuth
- After the toast is dismissed, a truncated heuristic guess can no longer fill
the slot that an exact OSC 8 or relay URL occupied — the detector remembers
every exact URL and drops any candidate that is a strict prefix of one.
- The prompt is reachable by keyboard: Ctrl+Shift+O jumps to the default
action, Escape dismisses, focus returns to the terminal, and auto-dismiss
holds off while focus is inside. It deliberately does not steal focus.
- UrlToast renders through `ui/Button` and `--shadow-overlay`.
Elsewhere
- AuthBridgeRow: a pushed `auth-bridge-changed` status always outranks an older
awaited toggle result.
- The last two ad-hoc byte formatters route through `lib/formatBytes`.
Contract for the backend agent: `upload_file_to_container` refusing to
overwrite must satisfy `isFileExistsError` in `src/lib/uploadErrors.ts` (marker
`FILE_EXISTS`) and accept an `overwrite` argument; the frontend turns that into
an `ui/Modal` Replace/Skip prompt rather than a raw error string.
Tests: 536 -> 627 passing. `npm run build` and `npx tsc --noEmit` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { useDiskUsage } from "./useDiskUsage";
|
||||
import { useDiskUsage, type DiskUsageState } from "./useDiskUsage";
|
||||
import type { DiskUsageReport } from "../lib/types";
|
||||
|
||||
const getDockerDiskUsage = vi.fn();
|
||||
@@ -226,6 +226,157 @@ describe("useDiskUsage", () => {
|
||||
expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The scan-versus-mutation race
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("throws away a scan that a reclaim overtook", async () => {
|
||||
// The live race the generation counter used to miss entirely. A scan takes
|
||||
// seconds and does not set `working`, so nothing stopped the user
|
||||
// reclaiming on top of one — and when the scan landed it repainted the
|
||||
// pre-reclaim report *and* a fresh, clickable plan listing objects the
|
||||
// reclaim had just deleted.
|
||||
let resolveScan: (value: DiskUsageReport) => void = () => {};
|
||||
getDockerDiskUsage.mockReturnValueOnce(
|
||||
new Promise<DiskUsageReport>((r) => {
|
||||
resolveScan = r;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
let inFlight: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
inFlight = result.current.scan();
|
||||
});
|
||||
expect(result.current.scanning).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
|
||||
});
|
||||
expect(result.current.plan).toBeNull();
|
||||
|
||||
// The overtaken scan finishes last, and must land nothing at all.
|
||||
await act(async () => {
|
||||
resolveScan(report("measured before the reclaim"));
|
||||
await inFlight;
|
||||
});
|
||||
expect(result.current.report).toBeNull();
|
||||
expect(result.current.plan).toBeNull();
|
||||
// It does not even get as far as re-planning: a plan built from a report
|
||||
// this stale is the clickable half of the bug.
|
||||
expect(listReclaimable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not strand `scanning` when a mutation retires the scan", async () => {
|
||||
// `scanning` is cleared against the newest *scan*, not the newest
|
||||
// generation — a mutation bumps the generation without starting a scan, so
|
||||
// guarding on that would leave the button reading "Scanning…" forever.
|
||||
let resolveScan: (value: DiskUsageReport) => void = () => {};
|
||||
getDockerDiskUsage.mockReturnValueOnce(
|
||||
new Promise<DiskUsageReport>((r) => {
|
||||
resolveScan = r;
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
let inFlight: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
inFlight = result.current.scan();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
|
||||
});
|
||||
await act(async () => {
|
||||
resolveScan(report("stale"));
|
||||
await inFlight;
|
||||
});
|
||||
expect(result.current.scanning).toBe(false);
|
||||
});
|
||||
|
||||
it("retires an in-flight scan for a destroy and a sweep too", async () => {
|
||||
// Every mutation invalidates a measurement, not just the bulk one.
|
||||
destroyProjectDiskObject.mockResolvedValue({
|
||||
target: null,
|
||||
destroyed: { kind: "home_volume", project_id: "p1" },
|
||||
ok: true,
|
||||
freed_bytes: 1,
|
||||
projected_bytes: null,
|
||||
message: "gone",
|
||||
});
|
||||
sweepOrphanedSnapshots.mockResolvedValue({
|
||||
removed: [],
|
||||
reclaimed_bytes: 0,
|
||||
in_use: 0,
|
||||
failed: [],
|
||||
unavailable: null,
|
||||
});
|
||||
|
||||
for (const mutate of [
|
||||
(r: DiskUsageState) => r.destroy({ kind: "home_volume", project_id: "p1" }, "whp"),
|
||||
(r: DiskUsageState) => r.runSweep(),
|
||||
]) {
|
||||
let resolveScan: (value: DiskUsageReport) => void = () => {};
|
||||
getDockerDiskUsage.mockReturnValueOnce(
|
||||
new Promise<DiskUsageReport>((r) => {
|
||||
resolveScan = r;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
let inFlight: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
inFlight = result.current.scan();
|
||||
});
|
||||
await act(async () => {
|
||||
await mutate(result.current);
|
||||
});
|
||||
await act(async () => {
|
||||
resolveScan(report("stale"));
|
||||
await inFlight;
|
||||
});
|
||||
expect(result.current.report).toBeNull();
|
||||
expect(result.current.plan).toBeNull();
|
||||
expect(result.current.scanning).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reporting failure back to the caller
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("tells the caller a reclaim failed instead of only swallowing it into `error`", async () => {
|
||||
// The confirmation dialogs close on completion. Without a return value
|
||||
// they closed on failure too, leaving the error at the top of a panel the
|
||||
// user had scrolled well past.
|
||||
reclaim.mockRejectedValueOnce("compaction failed: no space left on device");
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.runReclaim([{ kind: "compact_snapshot", project_id: "p1" }]);
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.error).toMatch(/no space left on device/);
|
||||
});
|
||||
|
||||
it("tells the caller a destroy failed", async () => {
|
||||
destroyProjectDiskObject.mockRejectedValueOnce("volume is in use by a running container");
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.destroy({ kind: "home_volume", project_id: "p1" }, "whp");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.error).toMatch(/in use by a running container/);
|
||||
});
|
||||
|
||||
it("reports success when the call came back", async () => {
|
||||
const { result } = renderHook(() => useDiskUsage());
|
||||
let ok: boolean | undefined;
|
||||
await act(async () => {
|
||||
ok = await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an unreachable daemon in the sweep report as an error", async () => {
|
||||
sweepOrphanedSnapshots.mockResolvedValue({
|
||||
removed: [],
|
||||
|
||||
@@ -32,9 +32,24 @@ import type {
|
||||
* A user who hits Scan twice can have two `df()` calls in flight, and they can
|
||||
* land out of order — the second one is not necessarily slower. Every async
|
||||
* write in `scan` checks it is still the newest before it lands, the same
|
||||
* pattern `useContainerMigration` uses. `runReclaim` and `destroy` do not need
|
||||
* it: the UI disables their buttons while `working` is set, so there is never
|
||||
* a second one to race.
|
||||
* pattern `useContainerMigration` uses.
|
||||
*
|
||||
* The race that actually bites, though, is not scan-versus-scan: it is
|
||||
* scan-versus-**mutation**. A scan takes seconds and does not set `working`, so
|
||||
* nothing stopped a reclaim starting on top of one. The reclaim correctly drops
|
||||
* the plan — and then the still-running scan landed, passed its own generation
|
||||
* check, and repainted a pre-reclaim report *plus a fresh, clickable plan
|
||||
* listing objects that had just been deleted*. So every mutation bumps the
|
||||
* counter as well: whatever a scan is holding was measured before the mutation
|
||||
* and is now a lie, and throwing it away is the only honest thing to do with
|
||||
* it. (The Scan button is disabled while `working` for the mirror-image case,
|
||||
* so a scan can never start *during* a mutation.)
|
||||
*
|
||||
* That is also why `scanning` is not cleared against the same counter: a
|
||||
* mutation bumping it mid-scan would strand the flag at true and leave the
|
||||
* button reading "Scanning…" forever. `latestScan` records the generation the
|
||||
* newest *scan* owns — only a newer scan may take the flag away — and that is
|
||||
* what the `finally` compares against.
|
||||
*/
|
||||
export interface DiskUsageState {
|
||||
report: DiskUsageReport | null;
|
||||
@@ -47,8 +62,15 @@ export interface DiskUsageState {
|
||||
/** The outcome of the last reclaim, kept on screen until the next scan. */
|
||||
outcome: ReclaimOutcome | null;
|
||||
scan: () => Promise<void>;
|
||||
runReclaim: (targets: ReclaimTarget[]) => Promise<void>;
|
||||
destroy: (target: DestructiveTarget, confirmation: string) => Promise<void>;
|
||||
/**
|
||||
* Resolves `true` when the call came back, `false` when it threw and the
|
||||
* failure went into `error`. Callers that dismiss UI on completion — the
|
||||
* confirmation dialogs — must only dismiss on `true`, or the failure is left
|
||||
* with nowhere on screen the user is looking.
|
||||
*/
|
||||
runReclaim: (targets: ReclaimTarget[]) => Promise<boolean>;
|
||||
/** Same contract as `runReclaim`: `false` means it failed and `error` says how. */
|
||||
destroy: (target: DestructiveTarget, confirmation: string) => Promise<boolean>;
|
||||
/** Run the orphaned-snapshot sweep and report what it found *and refused*. */
|
||||
runSweep: () => Promise<void>;
|
||||
clearOutcome: () => void;
|
||||
@@ -62,9 +84,21 @@ export function useDiskUsage(): DiskUsageState {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [outcome, setOutcome] = useState<ReclaimOutcome | null>(null);
|
||||
const generation = useRef(0);
|
||||
/** The generation belonging to the most recently *started* scan. */
|
||||
const latestScan = useRef(0);
|
||||
|
||||
/**
|
||||
* Retire every in-flight scan. Called at the top of each mutation, because
|
||||
* the moment we start deleting things, a measurement taken before that is no
|
||||
* longer describing the daemon the user is looking at.
|
||||
*/
|
||||
const invalidateScans = useCallback(() => {
|
||||
generation.current += 1;
|
||||
}, []);
|
||||
|
||||
const scan = useCallback(async () => {
|
||||
const mine = ++generation.current;
|
||||
latestScan.current = mine;
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
// The previous outcome describes a state that no longer holds once a new
|
||||
@@ -92,49 +126,66 @@ export function useDiskUsage(): DiskUsageState {
|
||||
// user can no longer see the totals for, but that cannot happen: the two
|
||||
// only ever move together.
|
||||
} finally {
|
||||
if (generation.current === mine) setScanning(false);
|
||||
// Deliberately `latestScan`, not `generation`: a mutation that retired
|
||||
// this scan did not start another one, so this scan is still the last
|
||||
// word on whether a scan is running.
|
||||
if (latestScan.current === mine) setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runReclaim = useCallback(async (targets: ReclaimTarget[]) => {
|
||||
if (targets.length === 0) return;
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commands.reclaim(targets);
|
||||
setOutcome(result);
|
||||
// **The plan is now stale and must not stay clickable.** Its rows
|
||||
// describe objects this call just removed, so leaving them ticked lets
|
||||
// the user fire the same reclaim again against nothing. Dropping the plan
|
||||
// (not the report) leaves the totals on screen, marked as measured before
|
||||
// the reclaim, with the tick list gone.
|
||||
//
|
||||
// Deliberately no automatic re-scan: it costs another `df()`, and the
|
||||
// outcome already reports measured bytes for every target — a user who
|
||||
// wants the new totals asks for them.
|
||||
setPlan(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}, []);
|
||||
const runReclaim = useCallback(
|
||||
async (targets: ReclaimTarget[]): Promise<boolean> => {
|
||||
// Nothing was asked for, so nothing failed — a caller gating a dialog on
|
||||
// this must not be left staring at an error that has no cause.
|
||||
if (targets.length === 0) return true;
|
||||
invalidateScans();
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commands.reclaim(targets);
|
||||
setOutcome(result);
|
||||
// **The plan is now stale and must not stay clickable.** Its rows
|
||||
// describe objects this call just removed, so leaving them ticked lets
|
||||
// the user fire the same reclaim again against nothing. Dropping the plan
|
||||
// (not the report) leaves the totals on screen, marked as measured before
|
||||
// the reclaim, with the tick list gone.
|
||||
//
|
||||
// Deliberately no automatic re-scan: it costs another `df()`, and the
|
||||
// outcome already reports measured bytes for every target — a user who
|
||||
// wants the new totals asks for them.
|
||||
setPlan(null);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
},
|
||||
[invalidateScans],
|
||||
);
|
||||
|
||||
const destroy = useCallback(async (target: DestructiveTarget, confirmation: string) => {
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commands.destroyProjectDiskObject(target, confirmation);
|
||||
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
|
||||
// Same reasoning as `runReclaim`: the destructive list named an object
|
||||
// that is now gone.
|
||||
setPlan(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}, []);
|
||||
const destroy = useCallback(
|
||||
async (target: DestructiveTarget, confirmation: string): Promise<boolean> => {
|
||||
invalidateScans();
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await commands.destroyProjectDiskObject(target, confirmation);
|
||||
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
|
||||
// Same reasoning as `runReclaim`: the destructive list named an object
|
||||
// that is now gone.
|
||||
setPlan(null);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
},
|
||||
[invalidateScans],
|
||||
);
|
||||
|
||||
/**
|
||||
* The startup sweep, on demand.
|
||||
@@ -147,6 +198,7 @@ export function useDiskUsage(): DiskUsageState {
|
||||
* report away.
|
||||
*/
|
||||
const runSweep = useCallback(async () => {
|
||||
invalidateScans();
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -178,7 +230,7 @@ export function useDiskUsage(): DiskUsageState {
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}, []);
|
||||
}, [invalidateScans]);
|
||||
|
||||
const clearOutcome = useCallback(() => setOutcome(null), []);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const stageContainerFileForDrag = vi.fn();
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
|
||||
uploadFileToContainer: (p: string, h: string, d: string) => uploadFileToContainer(p, h, d),
|
||||
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
@@ -21,6 +21,22 @@ vi.mock("../lib/tauri-commands", () => ({
|
||||
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Transient failures go to `ToastHost` rather than an inline string — see the
|
||||
* comment at the top of `useFileManager`. The store is mocked down to the one
|
||||
* method the hook reaches for.
|
||||
*/
|
||||
const pushToast = vi.fn();
|
||||
vi.mock("../store/appState", () => ({
|
||||
useAppState: { getState: () => ({ pushToast }) },
|
||||
}));
|
||||
|
||||
/** Everything the hook has said through the toast host, message and detail. */
|
||||
const toastText = () =>
|
||||
pushToast.mock.calls
|
||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||
.join("\n");
|
||||
|
||||
const save = vi.fn();
|
||||
const openDialog = vi.fn();
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
@@ -111,7 +127,10 @@ describe("useFileManager uploads", () => {
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/ok.txt", "/host/huge.bin"]);
|
||||
});
|
||||
expect(result.current.error).toContain("too large");
|
||||
// Inline `error` is reserved for the listing failure the user can see in
|
||||
// context; a failed upload goes where it cannot scroll away.
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(toastText()).toContain("too large");
|
||||
expect(listContainerFiles).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -152,7 +171,7 @@ describe("useFileManager rename and mkdir", () => {
|
||||
ok = await result.current.renameEntry(file("hosts"), "hosts.bak");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.error).toContain("Permission denied");
|
||||
expect(toastText()).toContain("Permission denied");
|
||||
});
|
||||
|
||||
it("treats an unchanged name as a no-op rather than a round trip", async () => {
|
||||
@@ -183,7 +202,7 @@ describe("useFileManager rename and mkdir", () => {
|
||||
ok = await result.current.createFolder("src");
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
expect(result.current.error).toContain("File exists");
|
||||
expect(toastText()).toContain("File exists");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,7 +227,7 @@ describe("useFileManager save to host", () => {
|
||||
await act(async () => {
|
||||
await result.current.downloadFile(file("src", { is_directory: true }));
|
||||
});
|
||||
expect(result.current.error).toContain("is a folder");
|
||||
expect(toastText()).toContain("is a folder");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,8 +289,8 @@ describe("useFileManager drag-out staging", () => {
|
||||
});
|
||||
|
||||
expect(staged).toBeNull();
|
||||
expect(result.current.error).toContain("too large to drag out");
|
||||
expect(result.current.error).toContain("Save to host");
|
||||
expect(toastText()).toContain("too large to drag out");
|
||||
expect(toastText()).toContain("Save to host");
|
||||
expect(result.current.busy).toBeNull();
|
||||
});
|
||||
|
||||
@@ -290,3 +309,193 @@ describe("useFileManager drag-out staging", () => {
|
||||
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager stays where the user is", () => {
|
||||
it("does not drag the pane back when the user navigates away mid-upload", async () => {
|
||||
// The closure captured `/workspace`; the user is in `/workspace/src` by the
|
||||
// time the copy finishes. Re-listing the *captured* path is what used to
|
||||
// yank them out of the directory they had walked into.
|
||||
let failUpload: (reason: unknown) => void = () => {};
|
||||
// `Once`, deliberately: `clearAllMocks` clears calls but not
|
||||
// implementations, so a never-settling one would hang every test after it.
|
||||
uploadFileToContainer.mockImplementationOnce(
|
||||
() => new Promise((_resolve, reject) => { failUpload = reject; }),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let upload!: Promise<void>;
|
||||
await act(async () => {
|
||||
upload = result.current.uploadPaths(["/host/big.bin"]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
listContainerFiles.mockResolvedValue([file("index.ts", { path: "/workspace/src/index.ts" })]);
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/src");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
failUpload("cp: no space left on device");
|
||||
await upload;
|
||||
});
|
||||
|
||||
expect(result.current.currentPath).toBe("/workspace/src");
|
||||
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
|
||||
// No re-list of the directory the upload targeted…
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
// …and no failure text painted over the listing that replaced it.
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(toastText()).toContain("no space left");
|
||||
});
|
||||
|
||||
it("re-lists when the user stayed put, which is the ordinary case", async () => {
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.png"]);
|
||||
});
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
||||
});
|
||||
|
||||
it("lets the newest listing win when a slow one lands last", async () => {
|
||||
// Two listings in flight, and the slower one is not necessarily the older
|
||||
// one. Landing last used to set both the rows and the breadcrumb back.
|
||||
let landSlow: (entries: FileEntry[]) => void = () => {};
|
||||
listContainerFiles.mockImplementationOnce(
|
||||
() => new Promise((resolve) => { landSlow = resolve; }),
|
||||
);
|
||||
listContainerFiles.mockResolvedValueOnce([file("new.txt")]);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let slow!: Promise<void>;
|
||||
await act(async () => {
|
||||
slow = result.current.navigate("/workspace/slow");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/fast");
|
||||
});
|
||||
expect(result.current.currentPath).toBe("/workspace/fast");
|
||||
|
||||
await act(async () => {
|
||||
landSlow([file("stale.txt")]);
|
||||
await slow;
|
||||
});
|
||||
|
||||
expect(result.current.currentPath).toBe("/workspace/fast");
|
||||
expect(result.current.entries.map((e) => e.name)).toEqual(["new.txt"]);
|
||||
});
|
||||
|
||||
it("keeps a failed navigation from claiming the directory it never reached", async () => {
|
||||
listContainerFiles.mockRejectedValueOnce("Permission denied");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/root");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
// The pane never left /workspace, so an upload started now targets it.
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.png"]);
|
||||
});
|
||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager overwrite prompt", () => {
|
||||
const alreadyThere = "FILE_EXISTS: /workspace/a.txt already exists";
|
||||
|
||||
it("asks rather than clobbering, and replaces on demand", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let upload!: Promise<void>;
|
||||
await act(async () => {
|
||||
upload = result.current.uploadPaths(["/host/a.txt"]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
|
||||
expect(result.current.conflict?.directory).toBe("/workspace");
|
||||
// One file, so there is nothing for a blanket answer to apply to.
|
||||
expect(result.current.conflict?.remaining).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("replace");
|
||||
await upload;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
||||
expect(result.current.conflict).toBeNull();
|
||||
});
|
||||
|
||||
it("skips without uploading anything when the user says so", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let upload!: Promise<void>;
|
||||
await act(async () => {
|
||||
upload = result.current.uploadPaths(["/host/a.txt"]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflict).not.toBeNull());
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("skip");
|
||||
await upload;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
||||
// A skip is a choice, not a failure — nothing to report.
|
||||
expect(toastText()).not.toContain("could not be uploaded");
|
||||
});
|
||||
|
||||
it("asks once for a batch when the answer is Replace all", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/b.txt already exists");
|
||||
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let upload!: Promise<void>;
|
||||
await act(async () => {
|
||||
upload = result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflict?.remaining).toBe(1));
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("replace-all");
|
||||
await upload;
|
||||
});
|
||||
|
||||
expect(result.current.conflict).toBeNull();
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(4, "p1", "/host/b.txt", "/workspace", true);
|
||||
});
|
||||
|
||||
it("leaves an unrelated failure alone — no prompt offering a button that cannot work", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/huge.bin"]);
|
||||
});
|
||||
expect(result.current.conflict).toBeNull();
|
||||
expect(toastText()).toContain("too large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager staged host paths", () => {
|
||||
it("recognises a path it staged, and only that path", async () => {
|
||||
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(false);
|
||||
await act(async () => {
|
||||
await result.current.stageForDrag(file("a.txt"));
|
||||
});
|
||||
expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(true);
|
||||
// Same basename, a real host file the user actually wants uploaded.
|
||||
expect(result.current.isStagedHostPath("/home/me/a.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
+264
-36
@@ -1,8 +1,75 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { useAppState } from "../store/appState";
|
||||
import {
|
||||
fileExistsPath,
|
||||
isFileExistsError,
|
||||
type OverwriteChoice,
|
||||
} from "../lib/uploadErrors";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host paths compare on separators, not on case: the OS hands a dropped path
|
||||
* back in whatever form its file dialog produced, and on Windows that is not
|
||||
* reliably the form `stage_container_file_for_drag` returned.
|
||||
*/
|
||||
function normaliseHostPath(path: string): string {
|
||||
return path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* ## 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,
|
||||
* save-to-host, drag staging — 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. 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.
|
||||
*/
|
||||
export function useFileManager(projectId: string) {
|
||||
const [currentPath, setCurrentPath] = useState("/workspace");
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
@@ -10,33 +77,72 @@ export function useFileManager(projectId: string) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
/**
|
||||
* 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
|
||||
* guard `useDiskUsage` and `useContainerMigration` use: every async write
|
||||
* checks it is still the newest before it lands.
|
||||
*/
|
||||
const navGeneration = useRef(0);
|
||||
|
||||
const startWork = useCallback((note: string) => {
|
||||
setBusy(note);
|
||||
setCompleted(null);
|
||||
}, []);
|
||||
|
||||
const report = useCallback((message: string, detail?: string) => {
|
||||
useAppState.getState().pushToast({ kind: "error", message, detail });
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback((message: string) => {
|
||||
useAppState.getState().pushToast({ kind: "success", message });
|
||||
}, []);
|
||||
|
||||
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 {
|
||||
setLoading(false);
|
||||
if (navGeneration.current === mine) setLoading(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
if (currentPath === "/") return;
|
||||
const parent = currentPath.replace(/\/[^/]+$/, "") || "/";
|
||||
const here = currentPathRef.current;
|
||||
if (here === "/") return;
|
||||
const parent = here.replace(/\/[^/]+$/, "") || "/";
|
||||
navigate(parent);
|
||||
}, [currentPath, navigate]);
|
||||
}, [navigate]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
navigate(currentPath);
|
||||
}, [currentPath, navigate]);
|
||||
navigate(currentPathRef.current);
|
||||
}, [navigate]);
|
||||
|
||||
/** Copy an entry out to a host path the user picks. */
|
||||
const downloadFile = useCallback(
|
||||
@@ -44,30 +150,111 @@ export function useFileManager(projectId: string) {
|
||||
try {
|
||||
const hostPath = await save({ defaultPath: entry.name });
|
||||
if (!hostPath) return;
|
||||
setError(null);
|
||||
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
||||
// 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);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
report(`Could not save "${entry.name}" to the host`, String(e));
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
[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) => {
|
||||
conflictResolver.current = resolve;
|
||||
setConflict({
|
||||
hostPath,
|
||||
name: baseName(containerPath ?? hostPath),
|
||||
directory,
|
||||
remaining,
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
const uploadPaths = useCallback(
|
||||
async (hostPaths: string[]) => {
|
||||
if (hostPaths.length === 0) return;
|
||||
setError(null);
|
||||
setBusy(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}…`);
|
||||
// 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" : ""}…`);
|
||||
const failures: string[] = [];
|
||||
let uploaded = 0;
|
||||
let skipped = 0;
|
||||
/** A "…all" answer, applied to every remaining clash without asking. */
|
||||
let blanket: OverwriteChoice | null = null;
|
||||
try {
|
||||
for (const hostPath of hostPaths) {
|
||||
for (let i = 0; i < hostPaths.length; i++) {
|
||||
const hostPath = hostPaths[i];
|
||||
try {
|
||||
await commands.uploadFileToContainer(projectId, hostPath, currentPath);
|
||||
await commands.uploadFileToContainer(projectId, hostPath, target);
|
||||
uploaded++;
|
||||
continue;
|
||||
} catch (e) {
|
||||
if (!isFileExistsError(e)) {
|
||||
failures.push(String(e));
|
||||
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++;
|
||||
} catch (e) {
|
||||
failures.push(String(e));
|
||||
}
|
||||
@@ -75,12 +262,25 @@ export function useFileManager(projectId: string) {
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
// Re-list first: `navigate` clears the error, so reporting before it
|
||||
// would wipe the very message the user needs.
|
||||
await navigate(currentPath);
|
||||
if (failures.length > 0) setError(failures.join(" · "));
|
||||
|
||||
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`,
|
||||
failures.join("\n"),
|
||||
);
|
||||
}
|
||||
// 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);
|
||||
},
|
||||
[projectId, currentPath, navigate],
|
||||
[projectId, navigate, startWork, report, askOverwrite],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -89,10 +289,28 @@ export function useFileManager(projectId: string) {
|
||||
* last listing re-stages rather than dragging a stale copy.
|
||||
*/
|
||||
const stagedRef = useRef(new Map<string, string>());
|
||||
/**
|
||||
* The same paths the other way round, as a set.
|
||||
*
|
||||
* A drag-out released back inside the app arrives as an ordinary host drop
|
||||
* carrying the staged copy's path, and uploading that would write the app's
|
||||
* own temp copy over the container file it came from — which is worse than a
|
||||
* no-op, because the key above is built from the *last listing*, so a file an
|
||||
* agent rewrote since then would be replaced by a minutes-old snapshot. This
|
||||
* set is what makes the "is this ours?" test exact instead of a guess at the
|
||||
* temp directory's name.
|
||||
*/
|
||||
const stagedHostPathsRef = useRef(new Set<string>());
|
||||
|
||||
/** True when `path` is a copy this pane staged for a drag-out. */
|
||||
const isStagedHostPath = useCallback(
|
||||
(path: string) => stagedHostPathsRef.current.has(normaliseHostPath(path)),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Copy an entry onto the host so the OS can drag it, and return the absolute
|
||||
* host path — or `null`, having set `error`, if it could not be staged.
|
||||
* host path — or `null`, having reported why, if it could not be staged.
|
||||
*
|
||||
* `cached` is what the caller needs to tell a gesture that will feel
|
||||
* instantaneous from one that has a whole-file copy in front of it: the copy
|
||||
@@ -105,20 +323,21 @@ export function useFileManager(projectId: string) {
|
||||
const cached = stagedRef.current.get(key);
|
||||
if (cached) return { hostPath: cached, cached: true };
|
||||
|
||||
setError(null);
|
||||
setBusy(`Preparing "${entry.name}"…`);
|
||||
startWork(`Preparing "${entry.name}"…`);
|
||||
try {
|
||||
const hostPath = await commands.stageContainerFileForDrag(projectId, entry.path);
|
||||
stagedRef.current.set(key, hostPath);
|
||||
stagedHostPathsRef.current.add(normaliseHostPath(hostPath));
|
||||
setCompleted(`"${entry.name}" is ready to drag.`);
|
||||
return { hostPath, cached: false };
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
report(`Could not prepare "${entry.name}" for dragging`, String(e));
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
[projectId, startWork, report],
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(async () => {
|
||||
@@ -127,9 +346,9 @@ export function useFileManager(projectId: string) {
|
||||
if (!selected) return;
|
||||
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
report("Could not open the file picker", String(e));
|
||||
}
|
||||
}, [uploadPaths]);
|
||||
}, [uploadPaths, report]);
|
||||
|
||||
/**
|
||||
* Rename in place. `newName` is a bare name — Rust rejects anything with a
|
||||
@@ -140,42 +359,50 @@ export function useFileManager(projectId: string) {
|
||||
async (entry: FileEntry, newName: string) => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed || trimmed === entry.name) return true;
|
||||
const target = currentPathRef.current;
|
||||
try {
|
||||
setError(null);
|
||||
await commands.renameContainerPath(projectId, entry.path, trimmed);
|
||||
await navigate(currentPath);
|
||||
setCompleted(`Renamed "${entry.name}" to "${trimmed}".`);
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
report(`Could not rename "${entry.name}"`, String(e));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, currentPath, navigate],
|
||||
[projectId, navigate, report],
|
||||
);
|
||||
|
||||
const createFolder = useCallback(
|
||||
async (name: string) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return true;
|
||||
const target = currentPathRef.current;
|
||||
try {
|
||||
setError(null);
|
||||
await commands.createContainerDirectory(projectId, currentPath, trimmed);
|
||||
await navigate(currentPath);
|
||||
await commands.createContainerDirectory(projectId, target, trimmed);
|
||||
setCompleted(`Created "${trimmed}".`);
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
report(`Could not create "${trimmed}"`, String(e));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, currentPath, navigate],
|
||||
[projectId, navigate, report],
|
||||
);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
/** Inline, in-context: why the listing on screen is empty. */
|
||||
error,
|
||||
busy,
|
||||
/** What the last operation finished doing, for the live region. */
|
||||
completed,
|
||||
/** An upload waiting for a Replace / Skip answer, or `null`. */
|
||||
conflict,
|
||||
resolveConflict,
|
||||
setError,
|
||||
navigate,
|
||||
goUp,
|
||||
@@ -184,6 +411,7 @@ export function useFileManager(projectId: string) {
|
||||
uploadFile,
|
||||
uploadPaths,
|
||||
stageForDrag,
|
||||
isStagedHostPath,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { formatBytes } from "../lib/formatBytes";
|
||||
import { useAppState } from "../store/appState";
|
||||
import { useProjects } from "./useProjects";
|
||||
import { useTerminal } from "./useTerminal";
|
||||
@@ -122,10 +123,15 @@ export function useProjectActions(project: Project) {
|
||||
if (!hostPath) return;
|
||||
setBackingUp(true);
|
||||
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
||||
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
||||
// `binary` matches what the host's file browser will say about the
|
||||
// tarball this just wrote. The unit is part of the formatted string, so
|
||||
// there is no separate " MB" to append — and unlike the inline
|
||||
// `toFixed(1)` this replaced, a multi-gigabyte backup no longer reports
|
||||
// itself as a five-digit number of megabytes.
|
||||
const size = formatBytes(bytes, { binary: true });
|
||||
pushToast({
|
||||
kind: "success",
|
||||
message: `Backup saved (${mb} MB).`,
|
||||
message: `Backup saved (${size}).`,
|
||||
detail:
|
||||
"Includes Claude config — may contain API keys. Keep the archive private.",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user