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:
2026-08-23 11:11:43 -07:00
co-authored by Claude Opus 5
parent 0003793abb
commit d6f065a2b6
33 changed files with 3120 additions and 315 deletions
@@ -17,6 +17,10 @@ const LAYERS_HELP =
const NEXT_COMMIT_HELP =
"The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that.";
/** Why a layer count reads "unknown" rather than as a number. */
const layersUnknownHelp = (layers: number) =>
`${layers} layers in total, but this project predates the base-image label, so there is no way to tell which of them are commits. Migrating it to the current base restores the count.`;
/** `—` for a column with nothing in it, so an empty cell never reads as zero. */
function cell(bytes: number, present: boolean) {
return present ? formatBytes(bytes) : "—";
@@ -141,11 +145,25 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
// The base this descends from is unknown, so the count
// includes the base's own layers and does not mean
// "recreations". Saying so beats printing a wrong number.
<Tooltip
text={`${row.snapshot_commit_layers} layers in total, but this project predates the base-image label, so there is no way to tell which of them are commits. Migrating it to the current base restores the count.`}
>
<span className="text-[var(--text-secondary)]">unknown</span>
</Tooltip>
//
// The explanation is the only thing standing between
// "unknown" and reading as a bug, so it cannot live in the
// tooltip alone: `Tooltip` portals a plain div with no
// `role` and no `aria-describedby`, and wrapped around
// children it has no focus handlers either — so on hover-
// less input it is unreachable and to a screen reader it
// does not exist. Same treatment as the column headers
// above: tooltip for the mouse, `sr-only` text for
// everything else.
<>
<Tooltip text={layersUnknownHelp(row.snapshot_commit_layers)}>
<span className="text-[var(--text-secondary)]">unknown</span>
</Tooltip>
<span className="sr-only">
{" "}
&mdash; {layersUnknownHelp(row.snapshot_commit_layers)}
</span>
</>
) : (
<span className="text-[var(--text-primary)]">
{row.snapshot_commit_layers}
@@ -6,6 +6,7 @@ import type {
ProjectDiskRow,
ReclaimItem,
ReclaimPlan,
ReclaimResult,
ReclaimTarget,
} from "../../lib/types";
@@ -104,6 +105,16 @@ const item = (over: Partial<ReclaimItem> = {}): ReclaimItem => ({
...over,
});
const result = (over: Partial<ReclaimResult> = {}): ReclaimResult => ({
target: { kind: "dangling_snapshots" },
destroyed: null,
ok: true,
freed_bytes: 0,
projected_bytes: null,
message: "Removed 3 images.",
...over,
});
const plan = (over: Partial<ReclaimPlan> = {}): ReclaimPlan => ({
items: [item()],
destructive: [],
@@ -620,6 +631,209 @@ describe("DiskSettings", () => {
expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Failure has to reach the words, and the place the user is looking
// -------------------------------------------------------------------------
it("puts a partial failure in the headline, not only in the glyph's hue", async () => {
// This panel is where the "never encode status in colour alone" rule is
// documented, and the outcome headline used to say "Reclaimed 1.2 GB" for
// a run where most of the targets threw — only the glyph and its colour
// changed, which is exactly nothing to a screen reader or to anyone who
// does not read red as bad.
reclaim.mockResolvedValue({
results: [
result({ freed_bytes: 1_200_000_000 }),
result({ target: { kind: "migration_pins" } }),
result({ target: { kind: "probe_containers" } }),
result({ target: { kind: "build_cache", all: true }, ok: false }),
result({ target: { kind: "orphan_volume", name: "v" }, ok: false }),
],
total_freed_bytes: 1_200_000_000,
});
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
const outcome = await screen.findByTestId("disk-outcome");
expect(
within(outcome).getByText("Reclaimed 1.2 GB — 2 of 5 failed"),
).toBeInTheDocument();
});
it("keeps the plain wording when every target succeeded", async () => {
reclaim.mockResolvedValue({
results: [result({ freed_bytes: 1_200_000_000 }), result()],
total_freed_bytes: 1_200_000_000,
});
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
const outcome = await screen.findByTestId("disk-outcome");
expect(within(outcome).getByText("Reclaimed 1.2 GB")).toBeInTheDocument();
expect(outcome.textContent).not.toMatch(/failed/);
});
it("keeps the typed confirmation open, and says why, when the deletion fails", async () => {
// The dialog used to close regardless, leaving the failure in a line at
// the very top of a panel the user had scrolled past to reach the row.
listReclaimable.mockResolvedValue(
plan({
destructive: [
{
target: { kind: "home_volume", project_id: "p-whp" },
project_id: "p-whp",
project_name: "whp",
label: "Home volume",
loses: "Shell history and toolchains.",
bytes: 4_860_000_000,
blocked: null,
},
],
}),
);
destroyProjectDiskObject.mockRejectedValue(
"volume triple-c-home-p-whp is in use by a running container",
);
await renderAndScan();
await screen.findByTestId("disk-row-p-whp");
fireEvent.click(screen.getByRole("button", { name: "Delete whp data" }));
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: /Delete home volume/ }));
});
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } });
await act(async () => {
fireEvent.click(within(dialog).getByRole("button", { name: "Delete home volume" }));
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(within(screen.getByRole("dialog")).getByRole("alert")).toHaveTextContent(
/in use by a running container/,
);
});
it("keeps the semi-safe confirmation open when the action fails", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [
item({
target: { kind: "compact_snapshot", project_id: "p-whp" },
safety: "semi_safe",
label: "Compact whp's snapshot",
bytes: 5_100_000_000,
bytes_are_exact: false,
bytes_floor: 0,
}),
],
}),
);
reclaim.mockRejectedValue("compaction failed: no space left on device");
await renderAndScan();
const semi = await screen.findByTestId("disk-semi-bucket");
await act(async () => {
fireEvent.click(within(semi).getByRole("button", { name: "Run…" }));
});
await act(async () => {
fireEvent.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Run it" }),
);
});
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByRole("alert")).toHaveTextContent(/no space left on device/);
});
it("closes the confirmation once the action succeeds", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [
item({
target: { kind: "clear_caches", project_id: "p-whp", include_rustup: false },
safety: "semi_safe",
label: "Clear whp's caches",
}),
],
}),
);
await renderAndScan();
const semi = await screen.findByTestId("disk-semi-bucket");
await act(async () => {
fireEvent.click(within(semi).getByRole("button", { name: "Run…" }));
});
await act(async () => {
fireEvent.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Run it" }),
);
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Scan status: announced, and not startable mid-mutation
// -------------------------------------------------------------------------
it("announces the scan status through a live region", async () => {
// The status flips between three states with no other signal; without a
// live region wrapping it the change is silent.
render(<DiskSettings />);
const live = screen.getByRole("status");
expect(live).toHaveAttribute("aria-live", "polite");
expect(live).toHaveTextContent("Not scanned");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
});
// The glyph is `aria-hidden` but still part of `textContent`.
expect(screen.getByRole("status")).toHaveTextContent(/Scanned \d/);
});
it("cannot start a scan while a reclaim is still running", async () => {
// A scan launched on top of a mutation measures a daemon that is being
// changed underneath it — the hook can only throw such a result away, so
// the seconds are better not spent.
let finish: (value: unknown) => void = () => {};
reclaim.mockReturnValue(new Promise((r) => (finish = r)));
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Scan again" })).toBeDisabled(),
);
await act(async () => {
finish({ results: [], total_freed_bytes: 0 });
});
expect(screen.getByRole("button", { name: "Scan again" })).toBeEnabled();
});
it("gives the unknown layer count its explanation without a hover", async () => {
// The tooltip portals a div with no role and no `aria-describedby`, and
// wrapped around children it has no focus handlers either — so without the
// sr-only copy "unknown" reads as a bug to everyone not using a mouse.
getDockerDiskUsage.mockResolvedValue(
report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }),
);
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(projectRow.textContent).toMatch(/predates the base-image label/);
expect(projectRow.textContent).toMatch(/Migrating it to the current base restores the count/);
});
it("surfaces a scan failure as an alert", async () => {
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
render(<DiskSettings />);
+86 -16
View File
@@ -54,6 +54,13 @@ export default function DiskSettings() {
const [ticked, setTicked] = useState<Set<string>>(new Set());
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
const [destroying, setDestroying] = useState<DestructiveItem | null>(null);
// A dialog whose action failed stays open and says so *inside itself*. The
// hook's `error` is rendered at the top of a panel that is metres of scroll
// long, so a user who reached a project row through the table would have
// watched the dialog vanish and seen nothing take its place. This flag is
// what distinguishes "this dialog's action just failed" from a stale scan
// error that happened to still be sitting in `error` when it opened.
const [actionFailed, setActionFailed] = useState(false);
// The plan is dropped after any reclaim, so a tick can never outlive the row
// it was made against and be re-fired at an object that is already gone.
@@ -68,6 +75,25 @@ export default function DiskSettings() {
);
const selectedBytes = selected.reduce((sum, i) => sum + i.bytes, 0);
// Opening or closing either dialog clears the in-dialog failure with it, so
// one never starts out showing the previous attempt's error.
const openConfirming = (item: ReclaimItem) => {
setConfirming(item);
setActionFailed(false);
};
const openDestroying = (item: DestructiveItem) => {
setDestroying(item);
setActionFailed(false);
};
const closeConfirming = () => {
setConfirming(null);
setActionFailed(false);
};
const closeDestroying = () => {
setDestroying(null);
setActionFailed(false);
};
const toggle = (item: ReclaimItem) => {
setTicked((prev) => {
const next = new Set(prev);
@@ -78,6 +104,10 @@ export default function DiskSettings() {
});
};
// Counted from the per-result list rather than from a flag: a reclaim of
// five targets can come back with two failures and a real byte total.
const failedCount = outcome?.results.filter((r) => !r.ok).length ?? 0;
const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off";
const statusLabel = scanning
? "Scanning"
@@ -99,10 +129,21 @@ export default function DiskSettings() {
{/* --- Scan --------------------------------------------------------- */}
<div className="flex items-center gap-3 flex-wrap">
<Button variant="primary" size="md" onClick={scan} disabled={scanning}>
{/* Disabled while a mutation runs, not only while scanning: a scan
started on top of a reclaim measures a daemon that is being changed
underneath it, and the hook can only discard such a result — better
not to spend the seconds. */}
<Button variant="primary" size="md" onClick={scan} disabled={scanning || working}>
{scanning ? "Scanning…" : report ? "Scan again" : "Scan"}
</Button>
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
{/* The status flips between "Scanning", "Scanned HH:MM:SS" and "Not
scanned" with no other signal. The live region is mounted here
unconditionally — wrapping it around the indicator only once there
is something to say would make the region *appear* already
populated, which is the one shape assistive tech does not announce. */}
<span role="status" aria-live="polite">
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
</span>
<span className="text-xs text-[var(--text-secondary)]">
Reads the whole Docker store; takes a few seconds on a large one.
</span>
@@ -161,7 +202,7 @@ export default function DiskSettings() {
<DiskProjectTable
rows={report.projects}
destructive={plan?.destructive ?? []}
onDestroy={setDestroying}
onDestroy={openDestroying}
/>
</section>
@@ -196,7 +237,10 @@ export default function DiskSettings() {
<dt className="text-[var(--text-secondary)]">
Build cache <strong className="text-[var(--warning)]">whole daemon</strong>,
not just Triple-C{" "}
<span className="text-[var(--text-disabled)]">
{/* Live information about where the figure came from, not a
disabled control — `--text-disabled` is ~4.1:1 and fails AA
at this size. */}
<span className="text-[var(--text-secondary)]">
(via {report.build_cache.source})
</span>
</dt>
@@ -400,7 +444,7 @@ export default function DiskSettings() {
<Button
size="sm"
disabled={item.blocked !== null || working}
onClick={() => setConfirming(item)}
onClick={() => openConfirming(item)}
>
Run
</Button>
@@ -434,9 +478,18 @@ export default function DiskSettings() {
data-testid="disk-outcome"
>
<div className="flex items-center justify-between gap-3">
{/* The headline has to carry the failure in words. A partial
reclaim that freed something still has a byte figure worth
printing, so the count is appended to it rather than replacing
it — and the per-result lines below say *which* ones and why,
so this stops at how many. */}
<StatusIndicator
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`}
tone={failedCount === 0 ? "ok" : "error"}
label={
failedCount === 0
? `Reclaimed ${formatBytes(outcome.total_freed_bytes)}`
: `Reclaimed ${formatBytes(outcome.total_freed_bytes)}${failedCount} of ${outcome.results.length} failed`
}
className="text-xs"
/>
<Button size="sm" variant="ghost" onClick={clearOutcome}>
@@ -450,7 +503,9 @@ export default function DiskSettings() {
{result.projected_bytes !== null && (
<>
{" "}
<span className="text-[var(--text-disabled)]">
{/* The comparison that makes a compaction's yield
readable — live information, so not the disabled ink. */}
<span className="text-[var(--text-secondary)]">
(projected {formatBytesCeiling(result.projected_bytes)}, actually{" "}
{formatBytes(result.freed_bytes)})
</span>
@@ -466,11 +521,11 @@ export default function DiskSettings() {
{confirming && (
<Modal
title={confirming.label}
onClose={() => setConfirming(null)}
onClose={closeConfirming}
widthClassName="w-[30rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={() => setConfirming(null)}>
<Button size="md" variant="ghost" onClick={closeConfirming}>
Cancel
</Button>
<Button
@@ -479,9 +534,12 @@ export default function DiskSettings() {
disabled={working}
onClick={async () => {
// Same reasoning as the destructive modal: a compaction takes
// minutes, and the dialog reporting it beats it vanishing.
await runReclaim([confirming.target]);
setConfirming(null);
// minutes, and the dialog reporting it beats it vanishing
// and if it fails, the dialog is the only place the user is
// still looking, so it stays open and reports it here.
const ok = await runReclaim([confirming.target]);
setActionFailed(!ok);
if (ok) setConfirming(null);
}}
>
{working ? "Working…" : "Run it"}
@@ -490,6 +548,13 @@ export default function DiskSettings() {
}
>
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
{/* The failure lands here rather than only in the panel's error
line, which this dialog is covering. */}
{actionFailed && (
<p role="alert" className="text-[var(--error)]">
{error ?? "That did not run. Nothing was changed."}
</p>
)}
<p>{confirming.detail}</p>
{confirming.target.kind === "compact_snapshot" && (
<>
@@ -531,13 +596,18 @@ export default function DiskSettings() {
expected={destroying.project_name}
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
busy={working}
onCancel={() => setDestroying(null)}
// A failure here has to land inside the dialog. The panel's own
// error line is at the top of several screens of scroll, and this
// dialog was reached from a project row far below it.
error={actionFailed ? (error ?? "That did not run. Nothing was deleted.") : null}
onCancel={closeDestroying}
onConfirm={async (typed) => {
// The modal stays mounted until the call settles, so its `busy`
// state is what the user sees while a multi-second volume removal
// runs. Clearing it first made the whole busy path dead code.
await destroy(destroying.target, typed);
setDestroying(null);
const ok = await destroy(destroying.target, typed);
setActionFailed(!ok);
if (ok) setDestroying(null);
}}
>
<p>
+6 -6
View File
@@ -2,6 +2,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "../../lib/types";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import { formatBytes } from "../../lib/formatBytes";
interface Props {
updateInfo: UpdateInfo;
@@ -24,11 +25,6 @@ export default function UpdateDialog({
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Modal
title="Update Available"
@@ -83,7 +79,11 @@ export default function UpdateDialog({
>
<span className="truncate font-mono">{asset.name}</span>
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
{formatSize(asset.size)}
{/* `binary` because a release asset's size is the ÷1024 figure
every OS file browser shows for the same download. This
used to be a local copy that rendered KB whole and stopped
the ladder at MB; see `formatBytes.ts`. */}
{formatBytes(asset.size, { binary: true })}
</span>
</button>
))}