Hold back the Disk panel and OS drag-out from the ship branch
This is a scope reduction, not an abandonment. Both subsystems are
preserved in full on `hold/disk-and-dragout` and are intended to come
back once they have been hardened separately. Nothing here is a
judgement that the features are unwanted — three successive
audit-and-fix cycles each closed a critical defect in these two areas
and each opened a new one, so the rest of the round ships now and these
two get their own cycle rather than holding it up.
Removed: the Disk settings panel and its whole reclaim / destroy /
compaction surface — `DiskSettings`, `DiskProjectTable`, `useDiskUsage`,
`docker/disk.rs`, `disk_tests.rs`, the disk commands in
`docker_commands.rs`, and their `generate_handler!` entries. Dropping
the IPC entries is the point: a UI-only removal would have left five
commands callable by a compromised webview, one of them a verified
arbitrary-DELETE primitive. `sweep_orphaned_snapshots`'s *command* goes
with them (the panel was its only caller); the sweep itself stays.
Removed: OS drag-out from the Files tab — `stage_container_file_for_drag`
and its host staging lifecycle, the pointer gesture and `dragPreview`,
`stageForDrag` / `isStagedHostPath`, the `tauri-plugin-drag` and
`@crabnebula/tauri-plugin-drag` dependencies, and the
`drag:allow-start-drag` capability grant, which could not be scoped.
The capability test's expected list is updated; its `*:default` and
`store:*` assertions are untouched.
Kept, deliberately: drag-and-drop *into* the app (Files pane and
terminal) and "Save to host…", which is now the only route out of a
container. The prevention work is untouched — the pre-commit scrub and
`SNAPSHOT_SCRUB_PATHS`, capped container logs, the `triple-c.base` /
`triple-c.managed` labels, `sweep_orphaned_snapshots` and the startup
housekeeping, the migration pin/probe reapers, scheduler log pruning,
`formatBytes.ts`, and `project_lock.rs` in full with every acquisition
site outside `disk.rs`.
Entanglements, resolved rather than deleted blind:
* `container.rs`'s `a_compaction_runs_this_module_s_scrub_script_byte_for_byte`
pinned the compaction Dockerfile against `snapshot_scrub_script()`.
Dropped — it existed only for compaction. `snapshot_scrub_script` and
its containment tests are untouched.
* `lib.rs`'s startup reap of `:compacting` tags and `triple-c-compact-*`
containers is dropped: nothing on this branch creates them.
* `project_lock`'s `Compaction` / `CacheClear` variants and
`any_held_excluding`, `migration_commands::is_migrating`, and
`formatBytes{Delta,Ceiling}` lose their last production caller but are
kept and still tested, annotated with why.
* `projects_store::corrupt_since` and `migration_store::peek_ownerless_since`
were read only by the disk survey and are removed. The corrupt-load
marker and `.bak` are still written.
Verified: `npm run test` 611 passing, `npx tsc --noEmit` clean,
`npm run build` green; `cargo test` 419 passed / 2 ignored,
`cargo build` 0 warnings. Every test removed belongs to a removed
feature — no kept-behaviour test was weakened or deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -9,7 +9,6 @@ const uploadFileToContainer = vi.fn(async () => {});
|
||||
const renameContainerPath = vi.fn(async () => "");
|
||||
const createContainerDirectory = vi.fn(async () => "");
|
||||
const readContainerFile = vi.fn();
|
||||
const stageContainerFileForDrag = vi.fn(async () => "/tmp/triple-c-drag-out/s1/notes.txt");
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
@@ -19,19 +18,6 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
|
||||
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
|
||||
}));
|
||||
|
||||
/**
|
||||
* The OS-level drag. Nothing in jsdom can start one, so it is only observed —
|
||||
* including its `onEvent` channel, which is how the plugin reports that the
|
||||
* gesture ended and therefore how the pane knows to start accepting drops
|
||||
* again. `endDragOut` below drives it.
|
||||
*/
|
||||
type DragCallback = (payload: { result: "Dropped" | "Cancelled" }) => void;
|
||||
const startDrag = vi.fn(async (_opts: unknown, _onEvent?: DragCallback) => {});
|
||||
vi.mock("@crabnebula/tauri-plugin-drag", () => ({
|
||||
startDrag: (opts: unknown, onEvent?: DragCallback) => startDrag(opts, onEvent),
|
||||
}));
|
||||
|
||||
/** Transient failures land in `ToastHost`, not in an inline string. */
|
||||
@@ -103,39 +89,6 @@ async function drop(paths: string[], position = { x: 100, y: 100 }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A pointer event carrying real coordinates.
|
||||
*
|
||||
* jsdom implements no `PointerEvent` and Testing Library's synthesized one has
|
||||
* no coordinates — which is the whole gesture here, since the drag only starts
|
||||
* once the pointer has travelled past the threshold. `MouseEvent` has them, and
|
||||
* React dispatches on the type name either way.
|
||||
*/
|
||||
function pointer(el: Element, type: string, clientX: number, clientY: number) {
|
||||
fireEvent(
|
||||
el,
|
||||
new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY, button: 0 }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Press on a row and move far enough to become a drag, leaving the button down. */
|
||||
function dragRow(el: Element) {
|
||||
pointer(el, "pointerdown", 10, 10);
|
||||
pointer(el, "pointermove", 60, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the pane the OS finished with the drag it started — what the plugin's
|
||||
* `onEvent` channel does for real. Until this arrives the pane deliberately
|
||||
* ignores drops, because a drag-out released back over the app arrives as one.
|
||||
*/
|
||||
function endDragOut(result: "Dropped" | "Cancelled" = "Dropped") {
|
||||
const onEvent = startDrag.mock.calls.at(-1)?.[1];
|
||||
act(() => {
|
||||
onEvent?.({ result });
|
||||
});
|
||||
}
|
||||
|
||||
/** Every row that is part of the grid's roving tabindex, in order. */
|
||||
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
|
||||
/** The rows that are actually tab stops. There must never be more than one. */
|
||||
@@ -153,8 +106,6 @@ function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dragHandler = null;
|
||||
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/notes.txt");
|
||||
startDrag.mockResolvedValue(undefined);
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||
entry("notes.txt"),
|
||||
@@ -167,10 +118,6 @@ beforeEach(() => {
|
||||
// Not implemented in jsdom; the image preview needs both halves.
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
// Nor is canvas, which the drag preview draws on. Stubbed to the null jsdom
|
||||
// would return anyway, minus the "not implemented" noise on every drag —
|
||||
// `dragPreview.test.ts` covers what the fallback then produces.
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe("FilesTab listing", () => {
|
||||
@@ -503,191 +450,6 @@ describe("FilesTab save to host", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab drag-out", () => {
|
||||
it("stages the file on the host and starts the native drag on that copy", async () => {
|
||||
// The container path is not draggable — only the host copy is — so the
|
||||
// thing handed to the OS must be what staging returned.
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||
expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
|
||||
expect(startDrag).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ item: ["/tmp/triple-c-drag-out/s1/notes.txt"] }),
|
||||
// The `onEvent` channel: without it, "the drag finished" is unobservable
|
||||
// and a drag released back over the pane reads as a host drop.
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("never drags a directory, which cannot be staged as one file", async () => {
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("src").closest("tr")!);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(stageContainerFileForDrag).not.toHaveBeenCalled();
|
||||
expect(startDrag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays a click until the pointer has actually travelled", async () => {
|
||||
await renderTab();
|
||||
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||
pointer(row, "pointerdown", 10, 10);
|
||||
pointer(row, "pointermove", 12, 11);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(stageContainerFileForDrag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says what went wrong instead of leaving a gesture that did nothing", async () => {
|
||||
stageContainerFileForDrag.mockRejectedValue(
|
||||
'900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.',
|
||||
);
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
|
||||
await waitFor(() => expect(toastText()).toContain("too large"));
|
||||
expect(startDrag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("points at the fallback when the platform refuses the drag itself", async () => {
|
||||
startDrag.mockRejectedValue("drag image not found");
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
|
||||
await waitFor(() => expect(toastText()).toContain("Save to host"));
|
||||
});
|
||||
|
||||
it("tells the user the copy is ready when the drag outlived the gesture", async () => {
|
||||
// Staging is a whole-file copy, and the OS only adopts a drag while the
|
||||
// button is down. Releasing mid-copy used to be — and must not be — a
|
||||
// gesture that did nothing and explained nothing.
|
||||
let release: (path: string) => void = () => {};
|
||||
stageContainerFileForDrag.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
await renderTab();
|
||||
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||
dragRow(row);
|
||||
pointer(row, "pointerup", 60, 10);
|
||||
|
||||
await act(async () => {
|
||||
release("/tmp/triple-c-drag-out/s1/notes.txt");
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/is ready/).textContent).toContain("notes.txt"));
|
||||
expect(startDrag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drags immediately on the retry, reusing the copy it already made", async () => {
|
||||
// The instruction "drag it again" is only honest if the second attempt does
|
||||
// not repeat the copy that made the first one too slow.
|
||||
await renderTab();
|
||||
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||
|
||||
dragRow(row);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(1));
|
||||
pointer(row, "pointerup", 60, 10);
|
||||
|
||||
dragRow(row);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(2));
|
||||
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves Save to host… working — drag-out is the enhancement, not the replacement", async () => {
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText("Save to host… — notes.txt"));
|
||||
});
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
|
||||
expect(startDrag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still accepts a drop into the pane — the two directions coexist", async () => {
|
||||
// The drag-out gesture is pointer-driven precisely so it does not need the
|
||||
// HTML5 machinery that Tauri's native drop listener rules out.
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||
// The OS is done with it — anything arriving now is a genuine host drop.
|
||||
endDragOut();
|
||||
|
||||
await drop(["/host/a.txt"]);
|
||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.txt", "/workspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab drag-out released back over the app", () => {
|
||||
it("does not re-import its own staged copy while the drag is in flight", async () => {
|
||||
// The damaging case, and the reason this is HIGH: the staged copy is keyed
|
||||
// off the *last listing*, so uploading it back is not even idempotent — a
|
||||
// file an agent rewrote since then would be replaced by a stale snapshot.
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||
|
||||
await drop(["/tmp/triple-c-drag-out/s1/notes.txt"]);
|
||||
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still refuses the staged copy after the drag has ended", async () => {
|
||||
// Second line of defence, and the one that survives a platform whose
|
||||
// `onEvent` never arrives: the path is known to be ours, exactly.
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||
endDragOut("Cancelled");
|
||||
|
||||
await drop(["/tmp/triple-c-drag-out/s1/notes.txt"]);
|
||||
|
||||
expect(uploadFileToContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uploads the rest of a mixed drop, minus our own copy", async () => {
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||
endDragOut();
|
||||
|
||||
await drop(["/tmp/triple-c-drag-out/s1/notes.txt", "/host/real.png"]);
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/real.png", "/workspace");
|
||||
});
|
||||
|
||||
it("does not offer to accept files during its own export", async () => {
|
||||
await renderTab();
|
||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
||||
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "enter", position: { x: 100, y: 100 }, paths: [] } });
|
||||
});
|
||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
|
||||
});
|
||||
expect(screen.queryByText(/Drop files into/)).toBeNull();
|
||||
|
||||
// …and it comes back once the gesture is over.
|
||||
endDragOut();
|
||||
await act(async () => {
|
||||
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
|
||||
});
|
||||
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab drop hit test", () => {
|
||||
it("uploads nothing when a dialog is covering the pane", async () => {
|
||||
// The pane still has its rect underneath the viewer's `fixed inset-0`
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { startDrag } from "@crabnebula/tauri-plugin-drag";
|
||||
import type { FileEntry, Project } from "../../../lib/types";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import { classifyDrop, isDropTarget } from "../../../lib/dropTarget";
|
||||
@@ -8,32 +7,12 @@ import { useAppState } from "../../../store/appState";
|
||||
import Button from "../../ui/Button";
|
||||
import FileViewerModal from "./FileViewerModal";
|
||||
import OverwriteConfirmModal from "./OverwriteConfirmModal";
|
||||
import { dragPreviewIcon } from "./dragPreview";
|
||||
import { formatBytes } from "./format";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the pointer must travel before a press becomes a drag. Same few
|
||||
* pixels of slop as the tab strip, so a click that trembles stays a click.
|
||||
*/
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
/**
|
||||
* Belt and braces for the in-flight drag-out flag.
|
||||
*
|
||||
* The flag is cleared by the drag plugin's own `onEvent` channel, which fires
|
||||
* `Dropped` or `Cancelled` for every gesture the OS finishes. A platform that
|
||||
* never fires it would leave the flag stuck and this pane deaf to drops, so it
|
||||
* also times out. Long enough that a deliberate, slow drag across two monitors
|
||||
* is not cut short; short enough that a wedged flag heals within one coffee
|
||||
* sip. The staged-path filter below is the real protection either way — this
|
||||
* only decides how long the *hint* stays suppressed.
|
||||
*/
|
||||
const DRAG_OUT_WATCHDOG_MS = 30_000;
|
||||
|
||||
/** Key of the synthetic "go up one level" row. No listing ever contains `..`. */
|
||||
const PARENT_ROW = "..";
|
||||
|
||||
@@ -73,8 +52,6 @@ export default function FilesTab({ project }: Props) {
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
uploadPaths,
|
||||
stageForDrag,
|
||||
isStagedHostPath,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
} = useFileManager(project.id);
|
||||
@@ -90,8 +67,6 @@ export default function FilesTab({ project }: Props) {
|
||||
const [viewing, setViewing] = useState<FileEntry | null>(null);
|
||||
/** A host drag is currently over this pane. */
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
/** Name of a file staged for drag-out whose gesture did not reach the OS. */
|
||||
const [dragNotice, setDragNotice] = useState<string | null>(null);
|
||||
/** The row that owns the grid's single tab stop. */
|
||||
const [activeRow, setActiveRow] = useState<string | null>(null);
|
||||
|
||||
@@ -108,7 +83,6 @@ export default function FilesTab({ project }: Props) {
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
setRenaming(null);
|
||||
setDragNotice(null);
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -260,153 +234,6 @@ export default function FilesTab({ project }: Props) {
|
||||
goUp();
|
||||
}, [currentPath, goUp]);
|
||||
|
||||
// Container → host drag-out.
|
||||
//
|
||||
// The mirror image of the drop path below, and it has the same constraint
|
||||
// pushing it: `dragDropEnabled` blocks HTML5 drag inside the webview, so
|
||||
// `draggable` + `DataTransfer` is not available and the gesture is driven
|
||||
// from pointer events into the native drag plugin — exactly the shape the tab
|
||||
// strip uses, and for the same reason.
|
||||
//
|
||||
// What makes it more than a pointer gesture is that the file being dragged
|
||||
// does not exist on the host at all: it lives in the container, and the OS
|
||||
// can only drag a real host path. So every drag-out is a copy first (see
|
||||
// `stageForDrag`) and a drag second, which is why the gesture has an async
|
||||
// gap in the middle of something that feels instantaneous.
|
||||
const dragOut = useRef<{
|
||||
path: string;
|
||||
x: number;
|
||||
y: number;
|
||||
down: boolean;
|
||||
started: boolean;
|
||||
} | null>(null);
|
||||
|
||||
/**
|
||||
* A drag-out the OS has taken and not yet finished.
|
||||
*
|
||||
* Without this, releasing a drag-out back over the Files pane fed the app its
|
||||
* own export as if it were a host drop: the staged copy was uploaded straight
|
||||
* back over the container file it came from. Not even idempotent — the staged
|
||||
* copy is cached against the *last listing*, so a file rewritten in the
|
||||
* container since then was replaced by a minutes-old snapshot. The `enter`
|
||||
* and `over` branches consult it too, so the pane does not offer to accept
|
||||
* files during its own export.
|
||||
*
|
||||
* Cleared from the drag plugin's `onEvent` channel, which reports `Dropped`
|
||||
* or `Cancelled` when the gesture ends — the installed
|
||||
* `@crabnebula/tauri-plugin-drag` (2.1.0) takes it as `startDrag`'s second
|
||||
* argument. `startDrag`'s own promise is *not* the signal: on some platforms
|
||||
* it resolves as soon as the OS adopts the drag, i.e. while it is still in
|
||||
* flight. See `DRAG_OUT_WATCHDOG_MS` for what happens if `onEvent` never
|
||||
* arrives.
|
||||
*/
|
||||
const dragOutInFlight = useRef(false);
|
||||
const dragOutWatchdog = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const endDragOut = useCallback(() => {
|
||||
dragOutInFlight.current = false;
|
||||
if (dragOutWatchdog.current !== null) {
|
||||
clearTimeout(dragOutWatchdog.current);
|
||||
dragOutWatchdog.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => endDragOut, [endDragOut]);
|
||||
|
||||
// Pointer-up almost never lands on the row it started on — the pointer has
|
||||
// moved off it by definition, and once the OS takes the drag the webview stops
|
||||
// seeing the pointer at all, which is what makes a lost focus the only
|
||||
// "the button came up" signal left.
|
||||
useEffect(() => {
|
||||
const release = () => {
|
||||
if (dragOut.current) dragOut.current.down = false;
|
||||
};
|
||||
window.addEventListener("pointerup", release);
|
||||
window.addEventListener("pointercancel", release);
|
||||
window.addEventListener("blur", release);
|
||||
return () => {
|
||||
window.removeEventListener("pointerup", release);
|
||||
window.removeEventListener("pointercancel", release);
|
||||
window.removeEventListener("blur", release);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const beginDragOut = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
setDragNotice(null);
|
||||
const staged = await stageForDrag(entry);
|
||||
// `stageForDrag` has already reported the reason through the toast host.
|
||||
if (!staged) return;
|
||||
|
||||
// The OS only adopts a drag while the button is still down, and the copy
|
||||
// that just ran can easily outlast a flick of the wrist. Say so rather
|
||||
// than leaving a gesture that did nothing and explained nothing — and it
|
||||
// is a real instruction, not an apology: the copy is kept, so the second
|
||||
// attempt starts immediately.
|
||||
if (dragOut.current?.path !== entry.path || !dragOut.current.down) {
|
||||
setDragNotice(entry.name);
|
||||
return;
|
||||
}
|
||||
|
||||
dragOutInFlight.current = true;
|
||||
dragOutWatchdog.current = setTimeout(endDragOut, DRAG_OUT_WATCHDOG_MS);
|
||||
try {
|
||||
await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) }, () =>
|
||||
endDragOut(),
|
||||
);
|
||||
} catch (e) {
|
||||
endDragOut();
|
||||
// Drag-out is the enhancement; "Save to host…" is the path that always
|
||||
// works, so a platform that refuses the drag says where to go instead.
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: 'Could not start the drag — use "Save to host…" instead.',
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
},
|
||||
[stageForDrag, endDragOut],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pointer wiring for one row. Directories get none of it: staging copies a
|
||||
* single regular file, and a folder would only ever produce an error.
|
||||
*/
|
||||
const dragOutProps = (entry: FileEntry) => {
|
||||
if (entry.is_directory) return {};
|
||||
return {
|
||||
onPointerDown: (e: React.PointerEvent<HTMLTableRowElement>) => {
|
||||
if (e.button !== 0 || renaming === entry.name) return;
|
||||
// The row's own controls, and the rename input, where a drag is a text
|
||||
// selection.
|
||||
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||
dragOut.current = {
|
||||
path: entry.path,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
down: true,
|
||||
started: false,
|
||||
};
|
||||
// Deliberately no `setPointerCapture` — unlike the tab strip, which
|
||||
// draws its own ghost. Here the OS has to take the pointer over, and a
|
||||
// capture held in the webview is exactly what stops it.
|
||||
},
|
||||
onPointerMove: (e: React.PointerEvent<HTMLTableRowElement>) => {
|
||||
const gesture = dragOut.current;
|
||||
if (!gesture || gesture.started || !gesture.down) return;
|
||||
if (gesture.path !== entry.path) return;
|
||||
if (
|
||||
Math.abs(e.clientX - gesture.x) < DRAG_THRESHOLD &&
|
||||
Math.abs(e.clientY - gesture.y) < DRAG_THRESHOLD
|
||||
) {
|
||||
return;
|
||||
}
|
||||
gesture.started = true;
|
||||
void beginDragOut(entry);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Host → container drag and drop.
|
||||
//
|
||||
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
|
||||
@@ -418,10 +245,6 @@ export default function FilesTab({ project }: Props) {
|
||||
// point? (Not "is that element mine": chrome painted over a pane — a toast,
|
||||
// a button — is not something that swallows a drop, and treating it as such
|
||||
// made permanent dead zones.)
|
||||
//
|
||||
// Two further filters sit in front of it, both about our own drag-out:
|
||||
// `dragOutInFlight`, and the staged-path check, which is exact because
|
||||
// `useFileManager` remembers every host path it staged.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
@@ -435,14 +258,11 @@ export default function FilesTab({ project }: Props) {
|
||||
return;
|
||||
}
|
||||
if (payload.type === "enter" || payload.type === "over") {
|
||||
setDragOver(
|
||||
!dragOutInFlight.current && isDropTarget(paneRef.current, payload.position),
|
||||
);
|
||||
setDragOver(isDropTarget(paneRef.current, payload.position));
|
||||
return;
|
||||
}
|
||||
if (payload.type !== "drop") return;
|
||||
setDragOver(false);
|
||||
if (dragOutInFlight.current) return;
|
||||
const verdict = classifyDrop(paneRef.current, payload.position);
|
||||
// Aimed at this pane and refused anyway: say so. Nothing else would —
|
||||
// the file just never appears in the listing.
|
||||
@@ -460,10 +280,7 @@ export default function FilesTab({ project }: Props) {
|
||||
return;
|
||||
}
|
||||
if (verdict !== "accept") return;
|
||||
// Anything we staged for a drag-out is our own copy of a file that is
|
||||
// already in the container; re-importing it would overwrite the
|
||||
// original with a snapshot.
|
||||
const paths = (payload.paths ?? []).filter((path) => !isStagedHostPath(path));
|
||||
const paths = payload.paths ?? [];
|
||||
if (paths.length === 0) return;
|
||||
await uploadPaths(paths);
|
||||
});
|
||||
@@ -475,7 +292,7 @@ export default function FilesTab({ project }: Props) {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [running, uploadPaths, isStagedHostPath]);
|
||||
}, [running, uploadPaths]);
|
||||
|
||||
const breadcrumbs =
|
||||
currentPath === "/"
|
||||
@@ -518,11 +335,7 @@ export default function FilesTab({ project }: Props) {
|
||||
* frequently not announced at all, which is how "uploading 3 items…" and
|
||||
* every completion notice used to go by in silence.
|
||||
*/
|
||||
const liveText = busy
|
||||
? busy
|
||||
: dragNotice
|
||||
? `"${dragNotice}" is ready — drag it again to drop it on the desktop.`
|
||||
: (completed ?? "");
|
||||
const liveText = busy ? busy : (completed ?? "");
|
||||
|
||||
return (
|
||||
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
|
||||
@@ -568,7 +381,7 @@ export default function FilesTab({ project }: Props) {
|
||||
{/* The one failure that stays inline: it explains why the grid below is
|
||||
empty, it is in context, and there are no rows for it to scroll
|
||||
behind. Every *transient* failure — upload, rename, mkdir,
|
||||
save-to-host, staging — goes to `ToastHost` instead, which is above
|
||||
save-to-host — goes to `ToastHost` instead, which is above
|
||||
the file viewer's overlay and does not scroll away. */}
|
||||
{error && (
|
||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||
@@ -665,7 +478,6 @@ export default function FilesTab({ project }: Props) {
|
||||
setActiveRow(entry.name);
|
||||
}}
|
||||
onDoubleClick={() => openEntry(entry)}
|
||||
{...dragOutProps(entry)}
|
||||
onKeyDown={(e) => {
|
||||
if (isRenaming) return;
|
||||
if (e.key === "Enter") {
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
|
||||
|
||||
/**
|
||||
* Which write to `status` is the newest — the same "is this still mine?"
|
||||
* guard `useDiskUsage` and `useContainerMigration` use around their async
|
||||
* guard `useContainerMigration` uses around its async
|
||||
* writes, and needed here for a reason that is easy to miss.
|
||||
*
|
||||
* There are two sources of truth for this row and only one of them is
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { dragPreviewIcon } from "./dragPreview";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("dragPreviewIcon", () => {
|
||||
it("falls back to a PNG data URL when there is no 2D context", () => {
|
||||
// jsdom has no canvas, and a webview can refuse one. `startDrag` requires
|
||||
// an image and the Rust side accepts nothing but a PNG data URL, so a
|
||||
// fallback that is not one takes the whole drag down with it.
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/);
|
||||
});
|
||||
|
||||
it("falls back rather than throwing when the canvas throws", () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
|
||||
throw new Error("no canvas here");
|
||||
});
|
||||
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
|
||||
it("refuses a canvas that encoded nothing", () => {
|
||||
// jsdom's `toDataURL` answers `data:,` — which the Rust side rejects
|
||||
// outright, so returning it would be worse than not drawing at all.
|
||||
const ctx = {
|
||||
scale: vi.fn(),
|
||||
measureText: () => ({ width: 60 }),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
font: "",
|
||||
fillStyle: "",
|
||||
strokeStyle: "",
|
||||
lineWidth: 0,
|
||||
textBaseline: "",
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue("data:,");
|
||||
|
||||
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/);
|
||||
});
|
||||
|
||||
it("uses what the canvas drew when there is one", () => {
|
||||
const ctx = {
|
||||
scale: vi.fn(),
|
||||
measureText: () => ({ width: 60 }),
|
||||
beginPath: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
font: "",
|
||||
fillStyle: "",
|
||||
strokeStyle: "",
|
||||
lineWidth: 0,
|
||||
textBaseline: "",
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
const drawn = "data:image/png;base64,AAAA";
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx);
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(drawn);
|
||||
|
||||
expect(dragPreviewIcon("notes.txt")).toBe(drawn);
|
||||
// A long name is elided rather than drawn off the edge of the preview.
|
||||
expect(dragPreviewIcon("a-really-quite-long-file-name-indeed.txt")).toBe(drawn);
|
||||
expect(ctx.fillText).toHaveBeenLastCalledWith(
|
||||
expect.stringContaining("…"),
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* The image the OS shows under the cursor during a drag-out.
|
||||
*
|
||||
* `startDrag` requires one — the plugin's `image` argument is not optional, and
|
||||
* it only accepts a `data:image/png;base64,` URL — so this is drawn rather than
|
||||
* shipped as an asset. Drawing it is also what keeps the colours honest: the
|
||||
* palette lives in CSS custom properties, and reading them off the document is
|
||||
* the only way a raw-pixel preview can still come from the design tokens rather
|
||||
* than from hard-coded hexes.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A 1x1 transparent PNG, used when no 2D canvas is available — jsdom has none,
|
||||
* and a webview can refuse a context under memory pressure. `startDrag` needs
|
||||
* *an* image, and a drag with an invisible preview is much better than no drag.
|
||||
*/
|
||||
const TRANSPARENT_PNG =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=";
|
||||
|
||||
/** Longest filename drawn in full; past this the middle is elided. */
|
||||
const MAX_LABEL = 28;
|
||||
|
||||
function cssVar(name: string, fallback: string): string {
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
/** Keep both ends of a long name — the extension is the informative half. */
|
||||
function elide(label: string): string {
|
||||
if (label.length <= MAX_LABEL) return label;
|
||||
const head = label.slice(0, MAX_LABEL - 12);
|
||||
const tail = label.slice(-9);
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
|
||||
export function dragPreviewIcon(label: string): string {
|
||||
try {
|
||||
const text = elide(label);
|
||||
// Cap the scale: the OS draws this at logical size, so a 3x buffer is only
|
||||
// bytes over IPC.
|
||||
const scale = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const height = 24;
|
||||
const padding = 8;
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
// Measuring needs a context, and sizing the canvas resets it — so measure
|
||||
// on a throwaway pass, then size, then draw.
|
||||
const probe = canvas.getContext("2d");
|
||||
if (!probe) return TRANSPARENT_PNG;
|
||||
const font = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
|
||||
probe.font = font;
|
||||
const width = Math.ceil(probe.measureText(text).width) + padding * 2;
|
||||
|
||||
canvas.width = Math.round(width * scale);
|
||||
canvas.height = Math.round(height * scale);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return TRANSPARENT_PNG;
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
ctx.fillStyle = cssVar("--bg-tertiary", "#2a2a2a");
|
||||
ctx.strokeStyle = cssVar("--accent", "#6aa8ff");
|
||||
ctx.lineWidth = 1;
|
||||
if (typeof ctx.roundRect === "function") {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0.5, 0.5, width - 1, height - 1, 4);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.fillRect(0.5, 0.5, width - 1, height - 1);
|
||||
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
|
||||
}
|
||||
|
||||
ctx.font = font;
|
||||
ctx.fillStyle = cssVar("--text-primary", "#e6e6e6");
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, padding, height / 2);
|
||||
|
||||
const url = canvas.toDataURL("image/png");
|
||||
// jsdom (and a canvas that failed to encode) answers `data:,` — which the
|
||||
// Rust side rejects outright, taking the whole drag with it.
|
||||
return url.startsWith("data:image/png;base64,") ? url : TRANSPARENT_PNG;
|
||||
} catch {
|
||||
return TRANSPARENT_PNG;
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import OverflowMenu from "../ui/OverflowMenu";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
import StatusIndicator from "../ui/StatusIndicator";
|
||||
import { formatBytes, formatBytesDelta } from "../../lib/formatBytes";
|
||||
import type { DestructiveItem, ProjectDiskRow } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
rows: ProjectDiskRow[];
|
||||
/** Per-project destructive objects, keyed off the same rows. */
|
||||
destructive: DestructiveItem[];
|
||||
onDestroy: (item: DestructiveItem) => void;
|
||||
}
|
||||
|
||||
const LAYERS_HELP =
|
||||
"Commit layers stacked above the base image — one for every time this project's container was recreated. Nothing merges them, so each one is paid for permanently until the snapshot is compacted.";
|
||||
|
||||
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) : "—";
|
||||
}
|
||||
|
||||
const SNAPSHOT_HELP =
|
||||
"This project's share of its snapshot image — the bytes no other image carries. The base image is shared by every project, so charging it to each row would show the same 4.7 GB eight times over. It is the figure the Total is built from.";
|
||||
|
||||
/** Why a snapshot figure is the whole image rather than a share of one. */
|
||||
const SPLIT_UNKNOWN_HELP =
|
||||
"Nothing measurably shares layers with this snapshot, and the base image it descends from is no longer on the daemon, so there is no split to show and none is guessed. This is the whole image, which is what it actually costs — a compacted snapshot is exactly this shape.";
|
||||
|
||||
/**
|
||||
* How `snapshot_attributed_bytes` was arrived at, in the row's own terms.
|
||||
*
|
||||
* Rust computes the number in one function so the column and the Total cannot
|
||||
* be derived from two different rules again — but the branches do not mean the
|
||||
* same thing to a reader, so the sub-line has to say which one this row is.
|
||||
* `snapshot_above_base_bytes` is `null` in exactly the branch where the figure
|
||||
* *is* the whole image, which is what makes it the test.
|
||||
*/
|
||||
function attributionNote(row: ProjectDiskRow): { note: string; help: string | null } {
|
||||
if (row.snapshot_above_base_bytes !== null) {
|
||||
return { note: `${formatBytes(row.snapshot_bytes)} with base`, help: null };
|
||||
}
|
||||
return { note: "whole image — base unknown", help: SPLIT_UNKNOWN_HELP };
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-project table — the mental model users actually have of this app.
|
||||
*
|
||||
* ## Why "Layers" is a column and not a detail
|
||||
*
|
||||
* A total tells a user their disk is full. The layer count tells them *why*:
|
||||
* every container recreation runs `docker commit`, a commit stacks a layer and
|
||||
* never rewrites one, and 24 different settings changes trigger a recreation.
|
||||
* A project sitting at 14 layers has paid for fourteen full copies of whatever
|
||||
* changed, and no total on its own ever says that.
|
||||
*
|
||||
* "Next commit adds" is the same fact from the other end: it is the container's
|
||||
* writable layer, i.e. exactly what the *next* recreation will bake in
|
||||
* permanently. Seeing 868 MB there is what makes Compact worth doing before the
|
||||
* next settings change rather than after it.
|
||||
*/
|
||||
export default function DiskProjectTable({ rows, destructive, onDestroy }: Props) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
No projects to account for.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// Wide content scrolls inside its own container; the panel itself must
|
||||
// never scroll sideways.
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px] border-collapse">
|
||||
<caption className="sr-only">
|
||||
Disk used by each project, largest first
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-[var(--text-secondary)]">
|
||||
<th scope="col" className="font-medium py-1.5 pr-3">
|
||||
Project
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
|
||||
Snapshot
|
||||
<Tooltip text={SNAPSHOT_HELP} />
|
||||
<span className="sr-only"> — {SNAPSHOT_HELP}</span>
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
|
||||
Layers
|
||||
{/* `Tooltip` renders a portalled div with no `role` and no
|
||||
`aria-describedby`, so its text reaches no assistive tech and
|
||||
the trigger announces as "Help". These two headers are
|
||||
meaningless without their explanation, so it is also emitted
|
||||
as screen-reader-only text. */}
|
||||
<Tooltip text={LAYERS_HELP} />
|
||||
<span className="sr-only"> — {LAYERS_HELP}</span>
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
|
||||
Next commit adds
|
||||
<Tooltip text={NEXT_COMMIT_HELP} />
|
||||
<span className="sr-only"> — {NEXT_COMMIT_HELP}</span>
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right">
|
||||
Home vol
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right">
|
||||
Config vol
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right">
|
||||
Total
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 pl-3">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const mine = destructive.filter((d) => d.project_id === row.project_id);
|
||||
return (
|
||||
<tr
|
||||
key={row.project_id}
|
||||
className="border-t border-[var(--border-color)] align-top"
|
||||
data-testid={`disk-row-${row.project_id}`}
|
||||
>
|
||||
<th scope="row" className="font-normal py-1.5 pr-3 text-[var(--text-primary)]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate max-w-[10rem]">{row.project_name}</span>
|
||||
{row.migrating && (
|
||||
<StatusIndicator
|
||||
tone="busy"
|
||||
label="Migrating"
|
||||
className="text-[11px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="block text-[11px] text-[var(--text-secondary)] font-mono truncate max-w-[12rem]">
|
||||
{row.project_id}
|
||||
</span>
|
||||
</th>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
|
||||
{/* `snapshot_attributed_bytes`, and nothing else. This column
|
||||
used to render `snapshot_above_base_bytes` and fall back
|
||||
to `—` while the Total was computed from
|
||||
`snapshot_bytes - snapshot_shared_bytes` regardless — so a
|
||||
row could show `—` here and still carry a whole 4.7 GB
|
||||
base image in its Total, once per project. One field, one
|
||||
rule, computed once in Rust: the parts add up. */}
|
||||
{row.snapshot_exists ? formatBytes(row.snapshot_attributed_bytes) : "—"}
|
||||
{row.snapshot_exists && (() => {
|
||||
const { note, help } = attributionNote(row);
|
||||
return help === null ? (
|
||||
<span className="block text-[11px] text-[var(--text-secondary)]">
|
||||
{note}
|
||||
</span>
|
||||
) : (
|
||||
// Same treatment as the Layers column: `Tooltip` portals
|
||||
// a plain div with no `role` and no `aria-describedby`,
|
||||
// so the explanation is also emitted as screen-reader
|
||||
// text rather than living in the tooltip alone.
|
||||
<span className="block text-[11px] text-[var(--text-secondary)]">
|
||||
<Tooltip text={help}>
|
||||
<span>{note}</span>
|
||||
</Tooltip>
|
||||
<span className="sr-only"> — {help}</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums">
|
||||
{!row.snapshot_exists ? (
|
||||
"—"
|
||||
) : !row.base_lineage_known ? (
|
||||
// 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.
|
||||
//
|
||||
// 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}
|
||||
{/* Never colour alone: a count worth acting on says so in
|
||||
a word, which is also what a screen reader gets. */}
|
||||
{row.snapshot_commit_layers > 5 && (
|
||||
<span className="ml-1 text-[11px] text-[var(--warning)]">
|
||||
stacked
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
|
||||
{row.container_exists
|
||||
? formatBytesDelta(row.container_writable_bytes)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
|
||||
{cell(row.home_volume_bytes, row.home_volume_present)}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
|
||||
{cell(row.config_volume_bytes, row.config_volume_present)}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap text-[var(--text-primary)] font-medium">
|
||||
{formatBytes(row.total_bytes)}
|
||||
</td>
|
||||
<td className="py-1.5 pl-3">
|
||||
{mine.length > 0 && (
|
||||
<OverflowMenu
|
||||
label={`Delete ${row.project_name} data`}
|
||||
items={mine.map((item) => ({
|
||||
label: `Delete ${item.label.toLowerCase()} (${formatBytes(item.bytes)})…`,
|
||||
onSelect: () => onDestroy(item),
|
||||
danger: true,
|
||||
disabled: item.blocked !== null,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,855 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "../ui/Button";
|
||||
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
|
||||
import Modal from "../ui/Modal";
|
||||
import TypedConfirmModal from "../ui/TypedConfirmModal";
|
||||
import DiskProjectTable from "./DiskProjectTable";
|
||||
import { useDiskUsage } from "../../hooks/useDiskUsage";
|
||||
import { formatBytes, formatBytesCeiling } from "../../lib/formatBytes";
|
||||
import type { DestructiveItem, ReclaimItem, ReclaimTarget } from "../../lib/types";
|
||||
|
||||
/** A stable key for a target, so ticks survive a re-plan. */
|
||||
function targetKey(target: ReclaimTarget): string {
|
||||
return JSON.stringify(target);
|
||||
}
|
||||
|
||||
/** The same, for a destructive object — never ticked, but still listed. */
|
||||
function destructiveKey(item: DestructiveItem): string {
|
||||
return JSON.stringify(item.target);
|
||||
}
|
||||
|
||||
/**
|
||||
* An orphaned volume is confirmed against **its own name**, not a project's.
|
||||
*
|
||||
* There is no project to name: the whole definition of the variant is that its
|
||||
* id matches nothing in the store, and `disk.rs`'s `destroy` takes the orphan
|
||||
* arm before it ever looks a project up. `DestructiveItem.project_name` carries
|
||||
* the volume name for exactly these items, which is what the gate compares.
|
||||
*/
|
||||
function isOrphanVolume(item: DestructiveItem): boolean {
|
||||
return item.target.kind === "orphan_volume";
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the disk went, and how to get it back.
|
||||
*
|
||||
* ## Why the scan is a button
|
||||
*
|
||||
* `getDockerDiskUsage` is `GET /system/df`, which walks every image, container
|
||||
* and volume on the daemon computing shared-layer sizes — seconds on a 100 GB
|
||||
* store, and the only call that produces those numbers at all. So nothing here
|
||||
* runs on open, on a timer, or on a re-render.
|
||||
*
|
||||
* ## Why the buckets are separated the way they are
|
||||
*
|
||||
* Safe work (dangling images, ownerless pins, build cache) gets one list of
|
||||
* ticks and one button, because none of it can lose anything a user has.
|
||||
* Semi-safe work (compaction, cache clearing) is a rewrite or a re-download and
|
||||
* is confirmed one at a time. Destructive work — a live project's volumes, its
|
||||
* snapshot, a live rollback pin, **and an orphaned volume** — is not in either
|
||||
* list: it is reached one object at a time, behind a typed confirmation, and
|
||||
* the backend refuses it in bulk by taking a different type entirely.
|
||||
*
|
||||
* ## Why orphaned volumes are down there and not in the tick list
|
||||
*
|
||||
* They used to be a `ReclaimTarget` at `Safety::Safe`: a tick and the group
|
||||
* Reclaim button, no confirmation. The object behind that tick is a
|
||||
* `triple-c-claude-config-*` volume holding a Claude OAuth credential, every
|
||||
* plugin and skill installed into that project, and every conversation
|
||||
* transcript it ever had — and the *same volume* for a project still in the
|
||||
* store required typing the project's name. The only difference between the two
|
||||
* is a lookup against `projects.json`, which this app has been wrong about
|
||||
* before: a second instance's project is absent from an in-memory list, a
|
||||
* corrupt store empties it, a restored data directory empties it too. It once
|
||||
* flagged two live projects as orphaned.
|
||||
*
|
||||
* So "no matching project" means one thing only — the id is not in the project
|
||||
* list. It is never inferred from a project being stopped, having no container
|
||||
* or having no image; an idle live project looks identical from the daemon's
|
||||
* side. Each volume is deleted on its own, against its own name typed out.
|
||||
*/
|
||||
export default function DiskSettings() {
|
||||
const {
|
||||
report,
|
||||
plan,
|
||||
scanning,
|
||||
working,
|
||||
error,
|
||||
outcome,
|
||||
scan,
|
||||
runReclaim,
|
||||
destroy,
|
||||
runSweep,
|
||||
clearOutcome,
|
||||
} = useDiskUsage();
|
||||
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.
|
||||
useEffect(() => {
|
||||
if (!plan) setTicked(new Set());
|
||||
}, [plan]);
|
||||
|
||||
// Split before anything renders. The per-project table keys off
|
||||
// `project_id`, and an orphan's id matches no row by definition — so without
|
||||
// this split those items are simply invisible, which is how a variant that
|
||||
// moved from the tick list to the destructive list can vanish from the UI
|
||||
// entirely rather than reappear behind a confirmation.
|
||||
const orphanVolumes = plan?.destructive.filter(isOrphanVolume) ?? [];
|
||||
// A destructive item is rendered inside its project's row, so one whose
|
||||
// project id matches no row would be measured and shown nowhere. That is not
|
||||
// hypothetical: `survey_rollback_pins` walks *images*, not projects, and
|
||||
// deliberately tolerates an absent project by falling back to the raw id as
|
||||
// the display name — so a pin left behind by a deleted project is exactly
|
||||
// this case, and it is the multi-GB kind. Anything unmatched gets its own
|
||||
// section rather than being silently dropped.
|
||||
const rowIds = new Set((report?.projects ?? []).map((r) => r.project_id));
|
||||
const projectDestructive =
|
||||
plan?.destructive.filter((d) => !isOrphanVolume(d) && rowIds.has(d.project_id)) ?? [];
|
||||
const unmatchedDestructive =
|
||||
plan?.destructive.filter((d) => !isOrphanVolume(d) && !rowIds.has(d.project_id)) ?? [];
|
||||
|
||||
const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? [];
|
||||
const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? [];
|
||||
const selected = safeItems.filter(
|
||||
(i) => i.blocked === null && ticked.has(targetKey(i.target)),
|
||||
);
|
||||
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);
|
||||
const key = targetKey(item.target);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// What the backend said about the parts it refused, rendered **verbatim**.
|
||||
// A refusal arrives inside `Ok` — the command succeeded at declining — so it
|
||||
// never reaches `error`, and the dialog that asked for the work has nothing
|
||||
// else to show. Not a sentence of our own: the backend is the only side that
|
||||
// knows which blocker is actually holding the project, and one written here
|
||||
// would go stale the day that answer improves.
|
||||
const refusalText =
|
||||
outcome?.results
|
||||
.filter((r) => !r.ok)
|
||||
.map((r) => r.message)
|
||||
.join(" ") ?? "";
|
||||
|
||||
const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off";
|
||||
const statusLabel = scanning
|
||||
? "Scanning"
|
||||
: report
|
||||
? `Scanned ${new Date(report.scanned_at).toLocaleTimeString()}`
|
||||
: "Not scanned";
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-[13px]">
|
||||
{/* --- Why this section exists ------------------------------------- */}
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
Every time a container is recreated, Triple-C commits it — and a commit{" "}
|
||||
<strong className="text-[var(--text-primary)]">stacks a new layer</strong> rather
|
||||
than rewriting the old one. Deleting a file afterwards writes a whiteout; the
|
||||
bytes underneath stay forever. Twenty-four different settings changes trigger a
|
||||
recreation, so a project can quietly accumulate a dozen multi-gigabyte layers it
|
||||
no longer uses any of.
|
||||
</p>
|
||||
|
||||
{/* --- Scan --------------------------------------------------------- */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-[var(--error)]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!report && !scanning && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Nothing has been measured yet. Scanning is the only thing here that costs
|
||||
anything, so it is never done for you.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* --- Windows / WSL2, mandatory when it applies ----------------- */}
|
||||
{report.host.vhdx_applies && (
|
||||
<section
|
||||
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2"
|
||||
data-testid="disk-vhdx-note"
|
||||
>
|
||||
{/* `StatusIndicator` has no warning tone — `error` would put a
|
||||
red glyph in a warning-toned panel. This is advisory, so it
|
||||
carries its own glyph beside the words rather than relying on
|
||||
the panel's colour. */}
|
||||
<p className="text-xs font-medium text-[var(--text-primary)]">
|
||||
<span aria-hidden="true">▲</span> Warning: reclaiming here will not
|
||||
shrink your C: drive
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-primary)] leading-relaxed">
|
||||
{report.host.vhdx_note}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
To actually give the space back to C:, run these in PowerShell as
|
||||
administrator after reclaiming:
|
||||
</p>
|
||||
<pre className="text-[11px] font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] px-2.5 py-2 overflow-x-auto select-text">
|
||||
{report.host.vhdx_fix.join("\n")}
|
||||
</pre>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Or, without Hyper-V: {report.host.vhdx_fix_gui}.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Per-project table ---------------------------------------- */}
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
By project
|
||||
</h3>
|
||||
<DiskProjectTable
|
||||
rows={report.projects}
|
||||
destructive={projectDestructive}
|
||||
onDestroy={openDestroying}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* --- Globals --------------------------------------------------- */}
|
||||
<section className="space-y-2" data-testid="disk-globals">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Shared and left over
|
||||
</h3>
|
||||
<dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-xs">
|
||||
<dt className="text-[var(--text-secondary)]">
|
||||
Base images ({report.base_images.length}) — shared by every project
|
||||
</dt>
|
||||
<dd className="text-right tabular-nums">
|
||||
{formatBytes(report.base_images_bytes)}
|
||||
</dd>
|
||||
|
||||
<dt className="text-[var(--text-secondary)]">
|
||||
Superseded images from past recreations ({report.orphan_image_count})
|
||||
</dt>
|
||||
<dd className="text-right tabular-nums">
|
||||
{formatBytes(report.orphan_image_bytes)}
|
||||
</dd>
|
||||
|
||||
<dt className="text-[var(--text-secondary)]">
|
||||
Volumes with no matching project in Triple-C (
|
||||
{report.orphan_volumes.length})
|
||||
</dt>
|
||||
<dd className="text-right tabular-nums">
|
||||
{formatBytes(report.orphan_volume_bytes)}
|
||||
</dd>
|
||||
|
||||
<dt className="text-[var(--text-secondary)]">
|
||||
Build cache — <strong className="text-[var(--warning)]">whole daemon</strong>,
|
||||
not just Triple-C{" "}
|
||||
{/* 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>
|
||||
<dd className="text-right tabular-nums">
|
||||
{formatBytes(report.build_cache.reclaimable_bytes)} of{" "}
|
||||
{formatBytes(report.build_cache.total_bytes)}
|
||||
</dd>
|
||||
|
||||
<dt className="text-[var(--text-primary)] font-medium pt-1 border-t border-[var(--border-color)]">
|
||||
Attributable to Triple-C
|
||||
</dt>
|
||||
<dd className="text-right tabular-nums text-[var(--text-primary)] font-medium pt-1 border-t border-[var(--border-color)]">
|
||||
{formatBytes(report.triple_c_total_bytes)}
|
||||
</dd>
|
||||
|
||||
<dt className="text-[var(--text-secondary)]">
|
||||
Everything on this daemon, yours included
|
||||
</dt>
|
||||
<dd className="text-right tabular-nums">
|
||||
{formatBytes(
|
||||
report.images_total_bytes +
|
||||
report.containers_total_bytes +
|
||||
report.volumes_total_bytes,
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
{report.build_cache.cli_error && (
|
||||
<p className="text-[11px] text-[var(--warning)]">
|
||||
{/* Without this the panel silently shows `docker system df`'s
|
||||
under-reported build-cache figure and the user has no way
|
||||
to know why it disagrees with their terminal. */}
|
||||
Build-cache figures fell back to <code>docker system df</code>, which
|
||||
under-reports what a prune would free: {report.build_cache.cli_error}
|
||||
</p>
|
||||
)}
|
||||
{report.orphan_volumes.length > 0 && (
|
||||
<p className="text-[11px] text-[var(--text-secondary)] leading-relaxed">
|
||||
“Volumes with no matching project” above means only that the
|
||||
volume’s project id is not in your project list — it is{" "}
|
||||
<em>not</em> inferred from a project being stopped or having no image. A project you have not opened in a
|
||||
while has no container and no snapshot either, and that is normal, so
|
||||
nothing here is deleted in a group: each one is listed below on its own,
|
||||
with the date Docker created it, and removing it takes typing that
|
||||
volume’s name.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] text-[var(--text-secondary)]">
|
||||
Docker stores this at{" "}
|
||||
<span className="font-mono">{report.host.docker_root_dir || "an unknown path"}</span>
|
||||
{report.host.is_docker_desktop && " — a path inside the Docker Desktop VM, not on your filesystem"}.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* --- Store failure, if any ------------------------------------ */}
|
||||
{report.orphan_volumes_unavailable && (
|
||||
<section
|
||||
className="border border-[var(--error)]/40 bg-[var(--error-muted)] rounded-[var(--radius-panel)] px-3.5 py-3"
|
||||
data-testid="disk-store-error"
|
||||
>
|
||||
<StatusIndicator
|
||||
tone="error"
|
||||
label="Could not read the project list"
|
||||
className="text-xs"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-[var(--text-primary)] leading-relaxed">
|
||||
{report.orphan_volumes_unavailable}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- The plan was dropped by a reclaim -------------------------- */}
|
||||
{!plan && (
|
||||
<p className="text-xs text-[var(--text-secondary)]" data-testid="disk-plan-stale">
|
||||
The totals above were measured before that last action. Scan again to see
|
||||
what is left to reclaim.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* --- Safe reclaim ---------------------------------------------- */}
|
||||
{plan && (
|
||||
<section className="space-y-2" data-testid="disk-safe-bucket">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Safe to reclaim
|
||||
</h3>
|
||||
{safeItems.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Nothing here — no leftovers were found.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
None of this is reachable any more, or all of it regenerates on demand.
|
||||
Nothing you have made is in this list.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{safeItems.map((item) => {
|
||||
const key = targetKey(item.target);
|
||||
return (
|
||||
<li key={key}>
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
// A tick that survived onto a now-blocked row is
|
||||
// excluded from `selected`, so showing it checked
|
||||
// would make the count disagree with the screen.
|
||||
checked={item.blocked === null && ticked.has(key)}
|
||||
disabled={item.blocked !== null}
|
||||
onChange={() => toggle(item)}
|
||||
className="mt-0.5 accent-[var(--accent-emphasis)]"
|
||||
/>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="flex items-baseline justify-between gap-3">
|
||||
<span
|
||||
className={
|
||||
item.blocked
|
||||
? "text-[var(--text-disabled)]"
|
||||
: "text-[var(--text-primary)]"
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
{item.daemon_wide && (
|
||||
<span className="ml-1.5 text-[11px] text-[var(--warning)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] px-1 py-px">
|
||||
whole daemon
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="tabular-nums whitespace-nowrap text-[var(--text-secondary)]">
|
||||
{formatBytes(item.bytes)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{item.detail}
|
||||
</span>
|
||||
{item.blocked && (
|
||||
<span className="block text-xs text-[var(--text-disabled)]">
|
||||
{item.blocked}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={selected.length === 0 || working}
|
||||
onClick={() => runReclaim(selected.map((i) => i.target))}
|
||||
>
|
||||
{working ? "Reclaiming…" : "Reclaim"}
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{selected.length === 0
|
||||
? "Nothing ticked."
|
||||
: `${selected.length} selected, ${formatBytes(selectedBytes)}.`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Semi-safe -------------------------------------------------- */}
|
||||
{semiItems.length > 0 && (
|
||||
<section className="space-y-2" data-testid="disk-semi-bucket">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Worth doing, one at a time
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Nothing here loses anything you have installed. Compacting rewrites a
|
||||
project’s stacked layers into one; clearing caches deletes files
|
||||
that refill themselves. Both take a moment and both are confirmed
|
||||
separately.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{semiItems.map((item) => (
|
||||
<li
|
||||
key={targetKey(item.target)}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[var(--text-primary)]">{item.label}</span>
|
||||
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{item.detail}
|
||||
</span>
|
||||
{item.blocked && (
|
||||
<span className="block text-xs text-[var(--text-disabled)]">
|
||||
{item.blocked}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
|
||||
{/* A bound, not a measurement — rendered through a
|
||||
different helper so it cannot read as a promise. */}
|
||||
{item.bytes_are_exact
|
||||
? formatBytes(item.bytes)
|
||||
: formatBytesCeiling(item.bytes)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={item.blocked !== null || working}
|
||||
onClick={() => openConfirming(item)}
|
||||
>
|
||||
Run…
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Destructive leftovers with no project row ------------------- */}
|
||||
{unmatchedDestructive.length > 0 && (
|
||||
<section className="space-y-2" data-testid="disk-unmatched-bucket">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Leftovers from projects no longer in Triple-C
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
These belong to a project id that is not in your project list, so there
|
||||
is no row above to show them under. The same caveat as the volumes below
|
||||
applies: “not in your project list” is the only thing this
|
||||
means, and an idle live project is indistinguishable from a deleted one
|
||||
from Docker’s side. Because there is no project name to type, each
|
||||
one is confirmed against its project <em>id</em>.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{unmatchedDestructive.map((item) => (
|
||||
<li
|
||||
key={destructiveKey(item)}
|
||||
className="flex items-start justify-between gap-3"
|
||||
data-testid={`disk-unmatched-${destructiveKey(item)}`}
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[var(--text-primary)] font-mono break-all">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{item.loses}
|
||||
</span>
|
||||
{item.blocked && (
|
||||
<span className="block text-xs text-[var(--text-secondary)]">
|
||||
{item.blocked}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
|
||||
{formatBytes(item.bytes)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={item.blocked !== null || working}
|
||||
onClick={() => openDestroying(item)}
|
||||
>
|
||||
Delete…
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Orphaned volumes: destructive, one at a time ---------------- */}
|
||||
{orphanVolumes.length > 0 && (
|
||||
<section className="space-y-2" data-testid="disk-orphan-bucket">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Volumes with no matching project
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
A volume here is one whose project id is not in your project list. That
|
||||
is <em>all</em> it means — it is <em>not</em> inferred from a
|
||||
project being stopped, having no container or having no image. An idle
|
||||
live project looks exactly the same from Docker’s side, and that
|
||||
inference has already flagged two live projects here once.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
Deleting a{" "}
|
||||
<span className="font-mono">triple-c-claude-config-*</span> volume
|
||||
deletes{" "}
|
||||
<strong className="text-[var(--text-primary)]">
|
||||
the Claude login credential that project signed in with, every plugin
|
||||
and skill installed into it, and every conversation transcript it ever
|
||||
had
|
||||
</strong>
|
||||
. A <span className="font-mono">triple-c-home-*</span> volume holds its
|
||||
dotfiles, shell history and installed toolchains. There is no other copy
|
||||
of either and nothing regenerates, so each one is deleted on its own,
|
||||
against that volume’s name typed out — never as part of a
|
||||
group.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{orphanVolumes.map((item) => (
|
||||
<li
|
||||
key={destructiveKey(item)}
|
||||
className="flex items-start justify-between gap-3"
|
||||
data-testid={`disk-orphan-${item.project_name}`}
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[var(--text-primary)] font-mono break-all">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{item.loses}
|
||||
</span>
|
||||
{item.blocked && (
|
||||
<span className="block text-xs text-[var(--text-disabled)]">
|
||||
{item.blocked}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
|
||||
{formatBytes(item.bytes)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={item.blocked !== null || working}
|
||||
onClick={() => openDestroying(item)}
|
||||
>
|
||||
Delete…
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Sweep ------------------------------------------------------ */}
|
||||
<section className="flex items-center gap-3 flex-wrap">
|
||||
<Button size="sm" disabled={working} onClick={runSweep}>
|
||||
Sweep superseded images now
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
The same sweep that runs at startup and after every recreation. Unlike the
|
||||
tick above it also reports what it <em>refused</em> to remove, which is how
|
||||
a superseded image pinned by a stopped project shows itself.
|
||||
</span>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* --- Outcome ------------------------------------------------------- */}
|
||||
{outcome && (
|
||||
<section
|
||||
className="border border-[var(--border-color)] bg-[var(--bg-primary)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-1.5"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
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={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}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
|
||||
{outcome.results.map((result, index) => (
|
||||
<li key={index}>
|
||||
{result.message}
|
||||
{result.projected_bytes !== null && (
|
||||
<>
|
||||
{" "}
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Semi-safe confirmation ---------------------------------------- */}
|
||||
{confirming && (
|
||||
<Modal
|
||||
title={confirming.label}
|
||||
onClose={closeConfirming}
|
||||
widthClassName="w-[30rem]"
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={closeConfirming}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
disabled={working}
|
||||
onClick={async () => {
|
||||
// Same reasoning as the destructive modal: a compaction takes
|
||||
// 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.
|
||||
// `false` covers both a throw and a refusal that came back
|
||||
// inside `Ok`; either way the work did not happen, so the
|
||||
// dialog stays put and reports it where the user is looking.
|
||||
const ok = await runReclaim([confirming.target]);
|
||||
setActionFailed(!ok);
|
||||
if (ok) setConfirming(null);
|
||||
}}
|
||||
>
|
||||
{working ? "Working…" : "Run it"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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 ?? (refusalText || "That did not run. Nothing was changed.")}
|
||||
</p>
|
||||
)}
|
||||
<p>{confirming.detail}</p>
|
||||
{confirming.target.kind === "compact_snapshot" && (
|
||||
<>
|
||||
<p>
|
||||
The snapshot is rebuilt into a single layer while the old one is left
|
||||
in place, so a failure at any point leaves this project exactly as it
|
||||
is now.
|
||||
</p>
|
||||
<p>
|
||||
How much comes back depends on how much of those layers a later one
|
||||
already replaced — it could be{" "}
|
||||
{formatBytesCeiling(confirming.bytes)}, and it could be nothing at all.
|
||||
You will be told the real figure when it finishes.
|
||||
</p>
|
||||
<p>
|
||||
One thing worth knowing: the rewritten image no longer shares the base
|
||||
image with your other projects, so it carries its own copy of it. That
|
||||
cost is already subtracted from the figure above, and if the rewrite
|
||||
turns out not to come out ahead it is thrown away and the snapshot is
|
||||
left exactly as it is.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{confirming.target.kind === "clear_caches" &&
|
||||
confirming.target.include_rustup && (
|
||||
<p>
|
||||
Rust toolchains are included in this one. They are regenerable, but
|
||||
getting them back is a download rather than a rebuild.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* --- Destructive confirmation --------------------------------------- */}
|
||||
{destroying && (() => {
|
||||
// An orphaned volume has no project, so nothing about this dialog can
|
||||
// be phrased in terms of one: the gate takes the volume's own name (as
|
||||
// `disk.rs`'s `destroy` does), and the name is never lower-cased on its
|
||||
// way to the title, because the comparison the backend makes is
|
||||
// case-sensitive and a mangled name in the heading is a name the user
|
||||
// cannot type.
|
||||
const orphan = isOrphanVolume(destroying);
|
||||
// A leftover whose project is gone has no name either. `project_name`
|
||||
// is the raw id in that case — which is deliberate on the Rust side and
|
||||
// is exactly what `destroy` compares against — so the gate works, but
|
||||
// the label has to say "id" or it asks for something that does not
|
||||
// exist.
|
||||
const ownerless = !orphan && !rowIds.has(destroying.project_id);
|
||||
return (
|
||||
<TypedConfirmModal
|
||||
title={
|
||||
orphan
|
||||
? `Delete volume ${destroying.project_name}`
|
||||
: `Delete ${destroying.label.toLowerCase()}`
|
||||
}
|
||||
expected={destroying.project_name}
|
||||
subject={orphan ? "volume name" : ownerless ? "project id" : "project name"}
|
||||
confirmLabel={orphan ? "Delete volume" : `Delete ${destroying.label.toLowerCase()}`}
|
||||
busy={working}
|
||||
// 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 ?? (refusalText || "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.
|
||||
const ok = await destroy(destroying.target, typed);
|
||||
setActionFailed(!ok);
|
||||
if (ok) setDestroying(null);
|
||||
}}
|
||||
>
|
||||
{orphan ? (
|
||||
<p>
|
||||
This removes the volume{" "}
|
||||
<strong className="text-[var(--text-primary)] font-mono break-all">
|
||||
{destroying.project_name}
|
||||
</strong>
|
||||
, freeing {formatBytes(destroying.bytes)}. It is offered here for one
|
||||
reason only: no project in your list has its id. That is a lookup against
|
||||
a file, not a judgement about whether anything is using the volume.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
This removes{" "}
|
||||
<strong className="text-[var(--text-primary)]">
|
||||
{destroying.project_name}
|
||||
</strong>
|
||||
’s {destroying.label.toLowerCase()}, freeing{" "}
|
||||
{formatBytes(destroying.bytes)}.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[var(--error)]">{destroying.loses}</p>
|
||||
{orphan && (
|
||||
<p>
|
||||
Nothing here can undo this. If you recognise that project id, close this
|
||||
and leave the volume alone until you are certain.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Your mounted project folders live on the host and are not affected by this.
|
||||
</p>
|
||||
</TypedConfirmModal>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import WebTerminalSettings from "./WebTerminalSettings";
|
||||
import SttSettings from "./SttSettings";
|
||||
import SharedAuthSettings from "./SharedAuthSettings";
|
||||
import CertificateSettings from "./CertificateSettings";
|
||||
import DiskSettings from "./DiskSettings";
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
@@ -174,10 +173,6 @@ export default function SettingsPanel() {
|
||||
<DockerSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="disk" title="Disk" defaultOpen={false}>
|
||||
<DiskSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
|
||||
<CertificateSettings />
|
||||
</AccordionSection>
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import TypedConfirmModal from "./TypedConfirmModal";
|
||||
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
function renderModal(props: Partial<React.ComponentProps<typeof TypedConfirmModal>> = {}) {
|
||||
render(
|
||||
<TypedConfirmModal
|
||||
title="Delete claude config volume"
|
||||
expected="whp"
|
||||
confirmLabel="Delete config volume"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
{...props}
|
||||
>
|
||||
<p>Everything goes.</p>
|
||||
</TypedConfirmModal>,
|
||||
);
|
||||
return {
|
||||
input: screen.getByLabelText(/Type/),
|
||||
confirm: screen.getByRole("button", { name: "Delete config volume" }),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("TypedConfirmModal", () => {
|
||||
it("is a real dialog, from the Modal primitive", () => {
|
||||
renderModal();
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||
});
|
||||
|
||||
it("keeps the confirm button shut until the name is typed exactly", () => {
|
||||
const { input, confirm } = renderModal();
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(input, { target: { value: "wh" } });
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(input, { target: { value: "whp" } });
|
||||
expect(confirm).toBeEnabled();
|
||||
fireEvent.click(confirm);
|
||||
expect(onConfirm).toHaveBeenCalledWith("whp");
|
||||
});
|
||||
|
||||
it("is case-sensitive, because Api and api are different projects", () => {
|
||||
// This gate is the only thing between a misclick on a sorted table of
|
||||
// numbers and a project's transcripts, so a near-miss is a miss.
|
||||
const { input, confirm } = renderModal({ expected: "Api" });
|
||||
fireEvent.change(input, { target: { value: "api" } });
|
||||
expect(confirm).toBeDisabled();
|
||||
fireEvent.change(input, { target: { value: "Api" } });
|
||||
expect(confirm).toBeEnabled();
|
||||
});
|
||||
|
||||
it("forgives surrounding whitespace from a paste", () => {
|
||||
const { input, confirm } = renderModal();
|
||||
fireEvent.change(input, { target: { value: " whp " } });
|
||||
expect(confirm).toBeEnabled();
|
||||
});
|
||||
|
||||
it("announces the gate's state in words rather than only by the button fill", () => {
|
||||
const { input } = renderModal();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Waiting for the exact project name.",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "whp" } });
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Name matches.");
|
||||
});
|
||||
|
||||
it("names what it is waiting for, when that is not a project", () => {
|
||||
// An orphaned volume has no project — its id matches nothing in the store,
|
||||
// which is the definition of the variant — so the gate takes the volume's
|
||||
// own name and must not ask for a string that does not exist.
|
||||
renderModal({ expected: "triple-c-claude-config-gone", subject: "volume name" });
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Waiting for the exact volume name.",
|
||||
);
|
||||
});
|
||||
|
||||
it("spells out what is lost, from the caller's copy", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("Everything goes.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("locks itself while the deletion is running", () => {
|
||||
render(
|
||||
<TypedConfirmModal
|
||||
title="Delete claude config volume"
|
||||
expected="whp"
|
||||
confirmLabel="Delete config volume"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
busy
|
||||
>
|
||||
<p>Everything goes.</p>
|
||||
</TypedConfirmModal>,
|
||||
);
|
||||
// The confirm button reports the work in a word rather than only going
|
||||
// grey, so it is found by its busy label, not its idle one.
|
||||
expect(screen.getByLabelText(/Type/)).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("cancels without confirming", () => {
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries a failed attempt inside the dialog, as an alert", () => {
|
||||
// The caller keeps this dialog open when the deletion fails, because the
|
||||
// panel behind it is several screens long and its error line sits at the
|
||||
// top — nowhere near the row this was opened from.
|
||||
renderModal({ error: "volume triple-c-home-p-whp is in use by a running container" });
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/in use by a running container/);
|
||||
});
|
||||
|
||||
it("says nothing about failure when there has been none", () => {
|
||||
renderModal();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cannot be satisfied by an empty box when there is no name to type", () => {
|
||||
const { confirm } = renderModal({ expected: "" });
|
||||
expect(confirm).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { useId, useRef, useState, type ReactNode } from "react";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
import { inputClass } from "./Field";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
/** What must be typed, verbatim, before the confirm button enables. */
|
||||
expected: string;
|
||||
/**
|
||||
* What `expected` *is*, for the waiting message — "project name" unless the
|
||||
* caller says otherwise.
|
||||
*
|
||||
* An orphaned volume has no project by definition, so its gate takes the
|
||||
* volume's own name (that is what `disk.rs`'s `destroy` compares against),
|
||||
* and telling that user we are "waiting for the exact project name" would be
|
||||
* asking for a string that does not exist.
|
||||
*/
|
||||
subject?: string;
|
||||
/** The verb on the confirm button. Repeat the action — never "OK". */
|
||||
confirmLabel: string;
|
||||
/** What is about to be lost, in full. */
|
||||
children: ReactNode;
|
||||
onConfirm: (typed: string) => void;
|
||||
onCancel: () => void;
|
||||
busy?: boolean;
|
||||
/**
|
||||
* Why the last attempt did not happen. The caller keeps the dialog open when
|
||||
* its action fails, so the failure has to be readable *here* — the panel
|
||||
* behind this one is several screens long and its error line is at the top
|
||||
* of it, which is not where the user is looking.
|
||||
*/
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmation gate for something that has no other copy.
|
||||
*
|
||||
* ## Why this exists when `ConfirmResetModal` already did
|
||||
*
|
||||
* Reset and Remove are reached from a project's own overflow menu, one project
|
||||
* at a time, by a user who went looking for them. The Disk panel lists every
|
||||
* project's volumes side by side in a table of numbers, sorted by size — which
|
||||
* is exactly the layout that invites a misclick on the wrong row. A two-button
|
||||
* dialog does not survive that, because the thing being confirmed (*which*
|
||||
* project) is the thing the user got wrong.
|
||||
*
|
||||
* Typing the name fixes the failure mode rather than adding friction to it: the
|
||||
* gate is not "are you sure", it is "name the project you mean".
|
||||
*
|
||||
* The comparison is `expected.trim() === typed.trim()` and **case-sensitive** —
|
||||
* mirroring `confirmation_matches` in `docker/disk.rs`, which is the check that
|
||||
* actually holds, since this one is only a UI affordance. The backend refuses a
|
||||
* mismatch on its own.
|
||||
*/
|
||||
export default function TypedConfirmModal({
|
||||
title,
|
||||
expected,
|
||||
subject = "project name",
|
||||
confirmLabel,
|
||||
children,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
busy = false,
|
||||
error = null,
|
||||
}: Props) {
|
||||
const [typed, setTyped] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Every other `ui/` component uses `useId`; a hardcoded id breaks the
|
||||
// label association as soon as two of these are mounted at once.
|
||||
const inputId = useId();
|
||||
const matches = expected.trim().length > 0 && typed.trim() === expected.trim();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
widthClassName="w-[30rem]"
|
||||
initialFocusRef={inputRef}
|
||||
dismissible={!busy}
|
||||
footer={
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
onClick={() => onConfirm(typed)}
|
||||
disabled={!matches || busy}
|
||||
className={
|
||||
matches && !busy
|
||||
? "bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
|
||||
: "bg-[var(--bg-tertiary)] text-[var(--text-disabled)] border border-[var(--border-color)]"
|
||||
}
|
||||
>
|
||||
{busy ? "Working…" : confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 text-[13px] text-[var(--text-secondary)]">
|
||||
{children}
|
||||
<div>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-[13px] text-[var(--text-primary)] mb-1.5"
|
||||
>
|
||||
Type <strong className="font-mono">{expected}</strong> to confirm
|
||||
</label>
|
||||
<input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
disabled={busy}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
{/* Announced rather than only coloured — the gate's state has to be
|
||||
readable without relying on the button's fill. */}
|
||||
<p role="status" aria-live="polite" className="mt-1.5 text-xs">
|
||||
{matches ? (
|
||||
<span className="text-[var(--text-secondary)]">Name matches.</span>
|
||||
) : (
|
||||
// Not disabled content — the gate is live and waiting on the
|
||||
// user. `--text-disabled` is ~4.1:1 and fails AA at 12px.
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
Waiting for the exact {subject}.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
// Rendered last, next to the button that was just pressed, and as an
|
||||
// `alert` so it is announced on arrival rather than waiting to be
|
||||
// found.
|
||||
<p role="alert" className="text-[var(--error)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user