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:
+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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user