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:
@@ -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">
|
||||
{" "}
|
||||
— {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 />);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user