Merge branch 'fix/front' into integration/round-1

This commit is contained in:
2026-08-23 11:46:17 -07:00
33 changed files with 3120 additions and 315 deletions
@@ -119,5 +119,78 @@ describe("ClaudeCodeSettingsEditor", () => {
"off",
);
});
/**
* Auto-scroll is the second inverted field and had no project-scope test at
* all — every assertion above rides on `session_recap_disabled`, so a
* `BOOLEAN_FIELDS` entry that lost its `invert` flag would be caught for
* one of the two and pass silently for the other. It is stored as
* `auto_scroll_disabled`, so every value here reads back the other way up.
*/
describe("auto-scroll", () => {
const AUTO = "Auto-scroll";
it("starts on Global, which is not the same as on", () => {
// Claude Code scrolls by default, so an inheriting project *behaves*
// as on — but it has taken no position, and rendering it as "On" would
// make a later global change look like it had no effect.
renderEditor(null, "project");
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("global");
});
it("stores the disabled sense in both directions", () => {
const onSave = renderEditor(null, "project");
const auto = screen.getByLabelText(AUTO);
fireEvent.change(auto, { target: { value: "off" } });
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ auto_scroll_disabled: true }),
);
fireEvent.change(auto, { target: { value: "on" } });
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ auto_scroll_disabled: false }),
);
});
it("hands the setting back to the global level when Global is chosen", () => {
// Back to no opinion, and with nothing else set that collapses the
// whole object to `null` — the value that means "adds nothing over the
// global settings".
const onSave = renderEditor(
{ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true },
"project",
);
fireEvent.change(screen.getByLabelText(AUTO), { target: { value: "global" } });
expect(onSave).toHaveBeenCalledWith(null);
});
it("reads a stored override back the right way up", () => {
renderEditor({ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true }, "project");
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("off");
});
});
});
/**
* The inverted fields store a *deviation*, so a stored `false` is the one
* value that means "the user deliberately re-enabled the default". Nothing
* asserted it: every existing test drives the `true` (turned off) direction
* or the `null` (untouched) one, and both scopes would still read correctly
* if the inversion were dropped from the `false` branch alone.
*/
describe.each([
["session_recap_disabled", "Session recap"] as const,
["auto_scroll_disabled", "Auto-scroll"] as const,
])("a stored false on %s", (key, label) => {
it("reads as On at project scope, not as Off", () => {
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false }, "project");
expect((screen.getByLabelText(label) as HTMLSelectElement).value).toBe("on");
});
it("reads as on at global scope, where the control is a switch", () => {
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false });
expect(screen.getByRole("switch", { name: label })).toBeChecked();
});
});
});
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { FileEntry } from "../../../lib/types";
import { readContainerFile } from "../../../lib/tauri-commands";
import Button from "../../ui/Button";
@@ -40,11 +40,28 @@ type Preview =
export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) {
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
/**
* The object URL currently on screen.
*
* This used to be an effect-local variable revoked from the effect's own
* cleanup, which runs *before* the replacement effect body — so switching
* entries (or any re-run of the effect for the same entry) released the URL
* the `<img>` was still pointing at, and a blank image was the result until
* the new read landed. If the new read failed, it stayed blank. So the
* hand-over is explicit instead: a URL is revoked only once its replacement
* exists, and unmount is what releases the last one.
*/
const objectUrlRef = useRef<string | null>(null);
/** Release the previous URL now that something else is on screen. */
const replaceObjectUrl = (next: string | null) => {
const previous = objectUrlRef.current;
objectUrlRef.current = next;
if (previous && previous !== next) URL.revokeObjectURL(previous);
};
useEffect(() => {
let cancelled = false;
// Tracked separately from `preview` so cleanup can revoke it without
// depending on which state the component ended up in.
let objectUrl: string | null = null;
(async () => {
try {
@@ -58,16 +75,20 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
// A truncated image is not a smaller image, it is a broken one.
if (result.truncated) {
setPreview({ kind: "too-large" });
replaceObjectUrl(null);
return;
}
const blob = new Blob([bytes], { type: imageMimeFor(entry.name) ?? "image/png" });
objectUrl = URL.createObjectURL(blob);
setPreview({ kind: "image", url: objectUrl });
const url = URL.createObjectURL(blob);
// The replacement is in hand, so the previous one can go.
setPreview({ kind: "image", url });
replaceObjectUrl(url);
return;
}
if (looksBinary(bytes)) {
setPreview({ kind: "unsupported" });
replaceObjectUrl(null);
return;
}
@@ -78,6 +99,7 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
shownBytes: bytes.length,
trueSize: result.size,
});
replaceObjectUrl(null);
} catch (e) {
if (!cancelled) setPreview({ kind: "error", message: String(e) });
}
@@ -85,10 +107,19 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [projectId, entry.name, entry.path]);
// The bytes are released when the dialog goes, which is the whole reason the
// preview is a `blob:` URL rather than a `data:` one.
useEffect(
() => () => {
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
},
[],
);
const footer = (
<>
<Button
@@ -143,7 +174,18 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
Showing the first {formatBytes(preview.shownBytes)} of {formatBytes(preview.trueSize)}.
</p>
)}
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-primary)]">
{/* Focusable, and its own scroll container, because a megabyte of
text in an unfocusable `<pre>` is reachable by mouse wheel and by
nothing else — no PageDown, no arrows, no keyboard at all. A
scrollable region needs an accessible name to be worth landing
on, hence the role and label. No `focus:outline-none`: the global
`:focus-visible` ring is what says where the caret went. */}
<pre
tabIndex={0}
role="region"
aria-label={`${entry.name} contents`}
className="max-h-[60vh] overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-primary)]"
>
{preview.text}
</pre>
</>
@@ -14,7 +14,7 @@ const stageContainerFileForDrag = vi.fn(async () => "/tmp/triple-c-drag-out/s1/n
vi.mock("../../../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
uploadFileToContainer: (p: string, h: string, d: string) => uploadFileToContainer(p, h, d),
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
@@ -22,12 +22,29 @@ vi.mock("../../../lib/tauri-commands", () => ({
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
}));
/** The OS-level drag. Nothing in jsdom can start one, so it is only observed. */
const startDrag = vi.fn(async () => {});
/**
* 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) => startDrag(opts),
startDrag: (opts: unknown, onEvent?: DragCallback) => startDrag(opts, onEvent),
}));
/** Transient failures land in `ToastHost`, not in an inline string. */
const pushToast = vi.fn();
vi.mock("../../../store/appState", () => ({
useAppState: { getState: () => ({ pushToast }) },
}));
const toastText = () =>
pushToast.mock.calls
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
.join("\n");
const save = vi.fn(async () => "/host/out");
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (o: unknown) => save(o),
@@ -107,6 +124,32 @@ function dragRow(el: Element) {
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. */
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
/** Fire a drop without awaiting it — for the paths that stop to ask a question. */
function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
let pending: unknown;
act(() => {
pending = dragHandler?.({ payload: { type: "drop", position, paths } });
});
return pending as Promise<void> | undefined;
}
beforeEach(() => {
vi.clearAllMocks();
dragHandler = null;
@@ -184,7 +227,9 @@ describe("FilesTab open semantics", () => {
await renderTab();
listContainerFiles.mockClear();
const row = screen.getByText("src").closest("tr")!;
expect(row.getAttribute("tabindex")).toBe("0");
// Not a tab stop — `..` holds the grid's single one until the arrows move
// it — but still focusable and still openable from the keyboard.
expect(row.getAttribute("tabindex")).toBe("-1");
await act(async () => {
fireEvent.keyDown(row, { key: "Enter" });
});
@@ -261,7 +306,7 @@ describe("FilesTab viewer", () => {
describe("FilesTab rename", () => {
it("commits an inline rename on Enter and re-lists", async () => {
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
const input = screen.getByLabelText("New name for notes.txt") as HTMLInputElement;
fireEvent.change(input, { target: { value: "renamed.txt" } });
await act(async () => {
@@ -273,7 +318,7 @@ describe("FilesTab rename", () => {
it("abandons the rename on Escape", async () => {
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
const input = screen.getByLabelText("New name for notes.txt");
fireEvent.change(input, { target: { value: "nope.txt" } });
await act(async () => {
@@ -290,16 +335,22 @@ describe("FilesTab rename", () => {
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
});
it("shows what the container said when a rename is refused", async () => {
it("reports a refused rename where it can be seen, not three hundred rows down", async () => {
// The inline `error` div is the first child of the *scrolling* list, so
// deep in a directory this used to be a rename box that stayed open and
// said nothing. `ToastHost` is fixed, above the modal layer, and persists.
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
fireEvent.click(screen.getByRole("button", { name: "Rename notes.txt" }));
const input = screen.getByLabelText("New name for notes.txt");
fireEvent.change(input, { target: { value: "x" } });
await act(async () => {
fireEvent.blur(input);
});
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
expect(toastText()).toContain("Permission denied");
expect(screen.queryByRole("alert")).toBeNull();
// The editor stays open, because the rename did not happen.
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
});
});
@@ -373,14 +424,14 @@ describe("FilesTab save to host", () => {
it("copies a file out to the path the user picks", async () => {
await renderTab();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Save notes.txt to host" }));
fireEvent.click(screen.getByRole("button", { name: "Save to host… — notes.txt" }));
});
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
});
it("does not offer a directory download, which cannot work", async () => {
await renderTab();
expect(screen.queryByRole("button", { name: "Save src to host" })).toBeNull();
expect(screen.queryByRole("button", { name: "Save to host… — src" })).toBeNull();
});
});
@@ -395,6 +446,9 @@ describe("FilesTab drag-out", () => {
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),
);
});
@@ -428,7 +482,7 @@ describe("FilesTab drag-out", () => {
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("too large"));
await waitFor(() => expect(toastText()).toContain("too large"));
expect(startDrag).not.toHaveBeenCalled();
});
@@ -437,7 +491,7 @@ describe("FilesTab drag-out", () => {
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Save to host"));
await waitFor(() => expect(toastText()).toContain("Save to host"));
});
it("tells the user the copy is ready when the drag outlived the gesture", async () => {
@@ -483,7 +537,7 @@ describe("FilesTab drag-out", () => {
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 notes.txt to host"));
fireEvent.click(screen.getByLabelText("Save to host… — notes.txt"));
});
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
expect(startDrag).not.toHaveBeenCalled();
@@ -495,8 +549,278 @@ describe("FilesTab drag-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`
// portal, which is exactly why a rect alone was the wrong test.
readContainerFile.mockResolvedValue(contents("hello"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
await screen.findByRole("dialog");
await drop(["/host/a.png"]);
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("does not paint the hint under a dialog either", async () => {
readContainerFile.mockResolvedValue(contents("hello"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
await screen.findByRole("dialog");
await act(async () => {
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
});
});
describe("FilesTab grid focus", () => {
it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
// stops and Tab could not get out of the list.
await renderTab();
expect(gridRows()).toHaveLength(3); // .. , src, notes.txt
expect(tabStops()).toHaveLength(1);
expect(tabStops()[0].getAttribute("data-file-row")).toBe("..");
fireEvent.keyDown(tabStops()[0], { key: "ArrowDown" });
expect(tabStops()).toHaveLength(1);
expect(tabStops()[0].getAttribute("data-file-row")).toBe("src");
expect(document.activeElement).toBe(tabStops()[0]);
fireEvent.keyDown(tabStops()[0], { key: "End" });
expect(tabStops()[0].getAttribute("data-file-row")).toBe("notes.txt");
fireEvent.keyDown(tabStops()[0], { key: "Home" });
expect(tabStops()[0].getAttribute("data-file-row")).toBe("..");
});
it("keeps focus inside the grid after Enter opens a directory", async () => {
// Rows are keyed by name, so navigating unmounts the focused `<tr>` — and
// nothing used to re-focus, which ejected the user to `<body>`.
await renderTab();
const row = screen.getByText("src").closest("tr")!;
row.focus();
listContainerFiles.mockResolvedValueOnce([
entry("index.ts", { path: "/workspace/src/index.ts" }),
]);
await act(async () => {
fireEvent.keyDown(row, { key: "Enter" });
});
expect(screen.getByText("index.ts")).toBeTruthy();
expect(document.activeElement).not.toBe(document.body);
expect((document.activeElement as HTMLElement).closest("tr[data-file-row]")).toBeTruthy();
expect(tabStops()).toHaveLength(1);
});
it("puts focus back on the row after a rename is abandoned", async () => {
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
fireEvent.keyDown(row, { key: "F2" });
const input = screen.getByLabelText("New name for notes.txt");
await act(async () => {
fireEvent.keyDown(input, { key: "Escape" });
});
expect(document.activeElement).toBe(
gridRows().find((r) => r.getAttribute("data-file-row") === "notes.txt"),
);
});
it("follows a committed rename to the row's new name", async () => {
// Explicit, because `clearAllMocks` clears calls but not implementations,
// and an earlier test in this file leaves this one rejecting.
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
await renderTab();
fireEvent.click(screen.getByRole("button", { name: "Rename — notes.txt" }));
const input = screen.getByLabelText("New name for notes.txt");
fireEvent.change(input, { target: { value: "renamed.txt" } });
listContainerFiles.mockResolvedValueOnce([
entry("src", { is_directory: true, path: "/workspace/src" }),
entry("renamed.txt"),
]);
await act(async () => {
fireEvent.blur(input);
});
expect(document.activeElement).toBe(
gridRows().find((r) => r.getAttribute("data-file-row") === "renamed.txt"),
);
});
});
describe("FilesTab grid semantics", () => {
it("names its columns", async () => {
await renderTab();
for (const name of ["Name", "Size", "Modified", "Actions"]) {
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
}
});
it("says folder or file in words, not in hue and a hidden emoji", async () => {
await renderTab();
const dir = screen.getByText("src").closest("tr")!;
const plain = screen.getByText("notes.txt").closest("tr")!;
expect(dir.textContent).toContain("Folder");
expect(plain.textContent).toContain("File");
});
it("keeps the visible label inside the accessible name (WCAG 2.5.3)", async () => {
await renderTab();
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
expect(rename.textContent).toBe("Rename");
expect(rename.getAttribute("aria-label")).toContain("Rename");
const saveTo = screen.getByRole("button", { name: "Save to host… — notes.txt" });
expect(saveTo.getAttribute("aria-label")).toContain(saveTo.textContent!);
});
it("mounts the live region empty, then fills it", async () => {
// A `role="status"` node inserted already carrying its text is frequently
// not announced at all, which is how every one of these went by in silence.
await renderTab();
const live = screen.getByRole("status");
expect(live.textContent).toBe("");
await drop(["/host/a.png"]);
// Same node throughout — it is never unmounted.
expect(screen.getByRole("status")).toBe(live);
expect(live.textContent).toContain("Uploaded 1 item");
});
it("keeps a listing failure inline, where the rows it explains are missing", async () => {
// The one failure that does *not* go to the toast host: it is on screen,
// in context, and there is nothing for it to scroll behind.
listContainerFiles.mockRejectedValue("Permission denied");
await renderTab();
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
});
});
describe("FilesTab overwrite prompt", () => {
it("asks before replacing, and re-uploads with overwrite on Replace", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/notes.txt"]);
const dialog = await screen.findByRole("dialog");
expect(dialog.textContent).toContain("notes.txt");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
await pending;
});
expect(uploadFileToContainer).toHaveBeenLastCalledWith(
"p1",
"/host/notes.txt",
"/workspace",
true,
);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("uploads nothing more on Skip", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/notes.txt"]);
await screen.findByRole("dialog");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
await pending;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("offers the blanket answers only when files are queued behind this one", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
await screen.findByRole("dialog");
expect(screen.getByRole("button", { name: "Replace all" })).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Skip all" }));
await pending;
});
expect(screen.queryByRole("dialog")).toBeNull();
});
});
+299 -60
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 { isDropTarget } from "../../../lib/dropTarget";
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";
@@ -18,6 +21,22 @@ interface Props {
*/
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 = "..";
/**
* The project's file manager.
*
@@ -26,6 +45,17 @@ const DRAG_THRESHOLD = 4;
* moved directory navigation onto double click too — a single click used to
* navigate, which made it impossible to select a directory in order to rename
* it. Keyboard mirrors it exactly: Enter opens, F2 renames.
*
* ## Focus, and why it is a roving tabindex
*
* Every row used to be `tabIndex={0}`, which made a 400-entry directory about
* twelve hundred tab stops — Tab could not get *out* of the list, let alone
* past it — and rows are keyed by name, so navigating unmounted the focused
* `<tr>` and dropped focus to `<body>`: Enter on a directory ejected you from
* the grid, arrows dead, Tab restarting from the top of the document. So
* exactly one row carries `tabIndex={0}` (the *active* row), the arrows move
* it, and a single effect below is responsible for putting focus back on a
* sensible row after anything that re-renders the list.
*/
export default function FilesTab({ project }: Props) {
const {
@@ -34,6 +64,9 @@ export default function FilesTab({ project }: Props) {
loading,
error,
busy,
completed,
conflict,
resolveConflict,
navigate,
goUp,
refresh,
@@ -41,9 +74,9 @@ export default function FilesTab({ project }: Props) {
uploadFile,
uploadPaths,
stageForDrag,
isStagedHostPath,
renameEntry,
createFolder,
setError,
} = useFileManager(project.id);
const running = project.status === "running";
@@ -59,6 +92,8 @@ export default function FilesTab({ project }: Props) {
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);
const paneRef = useRef<HTMLDivElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
@@ -87,14 +122,106 @@ export default function FilesTab({ project }: Props) {
if (creatingFolder) folderInputRef.current?.focus();
}, [creatingFolder]);
// ---------------------------------------------------------------------------
// Roving tabindex
// ---------------------------------------------------------------------------
/** Every row's key, in visual order. The parent row is a row like any other. */
const rowKeys = useMemo(
() => [
...(currentPath !== "/" ? [PARENT_ROW] : []),
...entries.map((entry) => entry.name),
],
[currentPath, entries],
);
/**
* The active row, resolved against what is actually on screen. Keeping the
* *intent* in state and resolving it at render time means a rename or a
* deletion cannot leave the grid with no tab stop at all.
*/
const active = activeRow && rowKeys.includes(activeRow) ? activeRow : rowKeys[0];
const rowElement = useCallback((key: string): HTMLElement | undefined => {
// Matched on the dataset rather than a selector, because a file name is
// user data and can contain quotes, brackets and backslashes.
const rows = paneRef.current?.querySelectorAll<HTMLElement>("tr[data-file-row]") ?? [];
return Array.from(rows).find((row) => row.dataset.fileRow === key);
}, []);
const focusRow = useCallback(
(key: string) => {
setActiveRow(key);
rowElement(key)?.focus();
},
[rowElement],
);
/**
* Where focus should land the next time the grid re-renders, if it is loose.
* `key` is a preference, not a promise — the row may not exist any more (a
* rename that failed, a navigation into a different directory), in which case
* the first row takes it.
*/
const wantFocus = useRef<{ key: string | null } | null>(null);
/**
* The single place that decides where focus goes after the list changes.
*
* Runs after a navigation (rows are keyed by name, so the focused `<tr>` is
* gone), after a rename commits or is abandoned, and after Escape. It never
* *steals* focus: if the user has moved on to a button or the breadcrumb it
* drops the request instead, so a background re-list cannot yank the caret
* out from under them.
*/
useEffect(() => {
if (renaming !== null) return; // the rename input owns focus
const want = wantFocus.current;
if (!want) return;
wantFocus.current = null;
const focused = document.activeElement as HTMLElement | null;
const loose =
!focused ||
focused === document.body ||
focused === document.documentElement ||
!!focused.closest?.("tr[data-file-row]");
if (!loose) return;
const key = want.key && rowKeys.includes(want.key) ? want.key : rowKeys[0];
if (key !== undefined) focusRow(key);
}, [rowKeys, renaming, focusRow]);
/** Arrow / Home / End movement over the rows. */
const moveActive = useCallback(
(from: string, to: 1 | -1 | "first" | "last") => {
if (rowKeys.length === 0) return;
const i = rowKeys.indexOf(from);
const next =
to === "first"
? 0
: to === "last"
? rowKeys.length - 1
: Math.min(rowKeys.length - 1, Math.max(0, (i < 0 ? 0 : i) + to));
focusRow(rowKeys[next]);
},
[rowKeys, focusRow],
);
const startRename = useCallback((entry: FileEntry) => {
setSelected(entry.name);
setActiveRow(entry.name);
setRenameDraft(entry.name);
setRenaming(entry.name);
// Whichever way the rename ends, focus comes back to this row unless the
// commit renames it — `commitRename` overwrites the preference below.
wantFocus.current = { key: entry.name };
}, []);
const commitRename = useCallback(
async (entry: FileEntry) => {
const renamedTo = renameDraft.trim();
wantFocus.current = { key: renamedTo || entry.name };
const done = await renameEntry(entry, renameDraft);
if (done) setRenaming(null);
},
@@ -102,36 +229,37 @@ export default function FilesTab({ project }: Props) {
);
const commitFolder = useCallback(async () => {
const created = folderDraft.trim();
const done = await createFolder(folderDraft);
if (done) {
setCreatingFolder(false);
setFolderDraft("");
wantFocus.current = { key: created || null };
}
}, [createFolder, folderDraft]);
/**
* Arrow keys walk the rows. `aria-selected` is only meaningful on a row
* inside a `grid`, and a grid is expected to be arrow-navigable — so the
* roles below and this handler come as a pair.
*/
const moveFocus = useCallback((from: HTMLElement, delta: 1 | -1) => {
const rows = Array.from(
paneRef.current?.querySelectorAll<HTMLElement>('tr[tabindex="0"]') ?? [],
);
const i = rows.indexOf(from);
const next = rows[i + delta];
next?.focus();
}, []);
/** Double click / Enter: directories navigate, files open the viewer. */
const openEntry = useCallback(
(entry: FileEntry) => {
if (entry.is_directory) navigate(entry.path);
else setViewing(entry);
if (entry.is_directory) {
// The new listing's first row is `..`, which is the sensible landing
// place: it is where you go to undo the step you just took.
wantFocus.current = { key: null };
navigate(entry.path);
} else {
setViewing(entry);
}
},
[navigate],
);
const openParent = useCallback(() => {
// Coming back up, the directory just left is the interesting row.
const leaving = currentPath.split("/").filter(Boolean).pop() ?? null;
wantFocus.current = { key: leaving };
goUp();
}, [currentPath, goUp]);
// Container → host drag-out.
//
// The mirror image of the drop path below, and it has the same constraint
@@ -153,6 +281,38 @@ export default function FilesTab({ project }: Props) {
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
@@ -175,7 +335,7 @@ export default function FilesTab({ project }: Props) {
async (entry: FileEntry) => {
setDragNotice(null);
const staged = await stageForDrag(entry);
// `stageForDrag` has already put the reason in `error`.
// `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
@@ -188,15 +348,24 @@ export default function FilesTab({ project }: Props) {
return;
}
dragOutInFlight.current = true;
dragOutWatchdog.current = setTimeout(endDragOut, DRAG_OUT_WATCHDOG_MS);
try {
await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) });
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.
setError(`Could not start the drag: ${e}. Use "Save to host…" instead.`);
useAppState.getState().pushToast({
kind: "error",
message: 'Could not start the drag — use "Save to host…" instead.',
detail: String(e),
});
}
},
[stageForDrag, setError],
[stageForDrag, endDragOut],
);
/**
@@ -244,23 +413,17 @@ export default function FilesTab({ project }: Props) {
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
// it), which blocks HTML5 drag inside the webview on Windows, and only the
// native payload carries real file *paths*. The listener is window-wide, so
// routing is a hit-test of the physical-pixel payload position against this
// pane's rect — a hidden pane has a zero-size rect and never matches, which
// is what keeps this and the terminal's listener from both firing.
// routing is `isDropTarget` — the rect hit test, in CSS pixels, *plus* the
// z-order and "is anything modal on screen" questions a rect cannot answer.
//
// 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;
let cancelled = false;
const insideThisPane = (pos: { x: number; y: number }): boolean => {
const rect = paneRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0 || rect.height === 0) return false;
const dpr = window.devicePixelRatio || 1;
const x = pos.x / dpr;
const y = pos.y / dpr;
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
};
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
const payload = event.payload;
@@ -269,13 +432,19 @@ export default function FilesTab({ project }: Props) {
return;
}
if (payload.type === "enter" || payload.type === "over") {
setDragOver(insideThisPane(payload.position));
setDragOver(
!dragOutInFlight.current && isDropTarget(paneRef.current, payload.position),
);
return;
}
if (payload.type !== "drop") return;
setDragOver(false);
if (!insideThisPane(payload.position)) return;
const paths = payload.paths ?? [];
if (dragOutInFlight.current) return;
if (!isDropTarget(paneRef.current, payload.position)) 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));
if (paths.length === 0) return;
await uploadPaths(paths);
});
@@ -287,7 +456,7 @@ export default function FilesTab({ project }: Props) {
cancelled = true;
unlisten?.();
};
}, [running, uploadPaths]);
}, [running, uploadPaths, isStagedHostPath]);
const breadcrumbs =
currentPath === "/"
@@ -322,6 +491,20 @@ export default function FilesTab({ project }: Props) {
: "hover:bg-[var(--bg-tertiary)]"
}`;
const headerClass = "px-2 py-1.5 font-medium text-[var(--text-secondary)]";
/**
* The live region's text. One region, always mounted, filled and emptied —
* a `role="status"` node that is *inserted* already carrying its text is
* 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 ?? "");
return (
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
@@ -331,7 +514,10 @@ export default function FilesTab({ project }: Props) {
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
<button
type="button"
onClick={() => navigate(crumb.path)}
onClick={() => {
wantFocus.current = { key: null };
navigate(crumb.path);
}}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
>
{crumb.label}
@@ -340,16 +526,9 @@ export default function FilesTab({ project }: Props) {
))}
</nav>
<div className="flex-1" />
{busy && (
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
{busy}
</span>
)}
{!busy && dragNotice && (
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
"{dragNotice}" is ready drag it again to drop it on the desktop.
</span>
)}
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
{liveText}
</span>
<Button
onClick={() => {
setFolderDraft("");
@@ -367,6 +546,11 @@ export default function FilesTab({ project }: Props) {
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{/* 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
the file viewer's overlay and does not scroll away. */}
{error && (
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
{error}
@@ -379,9 +563,25 @@ export default function FilesTab({ project }: Props) {
</div>
) : (
<table role="grid" aria-label="Files" className="w-full text-xs">
<thead>
<tr role="row">
<th role="columnheader" scope="col" className={`${headerClass} px-4 text-left`}>
Name
</th>
<th role="columnheader" scope="col" className={`${headerClass} text-right`}>
Size
</th>
<th role="columnheader" scope="col" className={`${headerClass} text-left`}>
Modified
</th>
<th role="columnheader" scope="col" className={`${headerClass} text-right`}>
Actions
</th>
</tr>
</thead>
<tbody>
{creatingFolder && (
<tr>
<tr role="row">
<td role="gridcell" className="px-4 py-1.5" colSpan={4}>
<input
ref={folderInputRef}
@@ -404,21 +604,28 @@ export default function FilesTab({ project }: Props) {
)}
{currentPath !== "/" && (
<tr
tabIndex={0}
role="row"
data-file-row={PARENT_ROW}
tabIndex={active === PARENT_ROW ? 0 : -1}
aria-label="Parent directory"
onDoubleClick={goUp}
onClick={() => setActiveRow(PARENT_ROW)}
onDoubleClick={openParent}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
goUp();
openParent();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
moveFocus(e.currentTarget, e.key === "ArrowDown" ? 1 : -1);
moveActive(PARENT_ROW, e.key === "ArrowDown" ? 1 : -1);
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
moveActive(PARENT_ROW, e.key === "Home" ? "first" : "last");
}
}}
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
>
<td role="gridcell" className="px-4 py-1.5 text-[var(--text-primary)] font-mono">
<span className="sr-only">Folder, </span>
..
</td>
<td role="gridcell" colSpan={3} />
@@ -430,9 +637,14 @@ export default function FilesTab({ project }: Props) {
return (
<tr
key={entry.name}
tabIndex={0}
role="row"
data-file-row={entry.name}
tabIndex={active === entry.name ? 0 : -1}
aria-selected={isSelected}
onClick={() => setSelected(entry.name)}
onClick={() => {
setSelected(entry.name);
setActiveRow(entry.name);
}}
onDoubleClick={() => openEntry(entry)}
{...dragOutProps(entry)}
onKeyDown={(e) => {
@@ -440,13 +652,17 @@ export default function FilesTab({ project }: Props) {
if (e.key === "Enter") {
e.preventDefault();
setSelected(entry.name);
setActiveRow(entry.name);
openEntry(entry);
} else if (e.key === "F2") {
e.preventDefault();
startRename(entry);
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
moveFocus(e.currentTarget, e.key === "ArrowDown" ? 1 : -1);
moveActive(entry.name, e.key === "ArrowDown" ? 1 : -1);
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
moveActive(entry.name, e.key === "Home" ? "first" : "last");
}
}}
className={rowClass(isSelected)}
@@ -476,6 +692,14 @@ export default function FilesTab({ project }: Props) {
: "text-[var(--text-primary)]"
}`}
>
{/* Directory-ness was carried by hue and an
`aria-hidden` emoji, i.e. by nothing at all for a
screen reader. The emoji stays hidden — it reads
as "file folder" in some voices and as nothing in
others — and the word is what is announced. */}
<span className="sr-only">
{entry.is_directory ? "Folder, " : "File, "}
</span>
{entry.is_directory && <span aria-hidden="true">📁 </span>}
<span>{entry.name}</span>
{entry.is_symlink && (
@@ -498,8 +722,14 @@ export default function FilesTab({ project }: Props) {
<td role="gridcell" className="px-2 py-1.5 text-right whitespace-nowrap">
{!isRenaming && (
<>
{/* WCAG 2.5.3: the accessible name has to *contain*
the visible label, so the row context is appended
rather than substituted. "Rename notes.txt" used
to be the whole name, which left a voice-control
user saying "click Rename" at a button that had
no such name. */}
<Button
aria-label={`Rename ${entry.name}`}
aria-label={`Rename ${entry.name}`}
onClick={(e) => {
e.stopPropagation();
startRename(entry);
@@ -509,7 +739,7 @@ export default function FilesTab({ project }: Props) {
</Button>
{!entry.is_directory && (
<Button
aria-label={`Save ${entry.name} to host`}
aria-label={`Save to host… — ${entry.name}`}
className="ml-1"
onClick={(e) => {
e.stopPropagation();
@@ -526,7 +756,7 @@ export default function FilesTab({ project }: Props) {
);
})}
{entries.length === 0 && !loading && (
<tr>
<tr role="row">
<td
role="gridcell"
colSpan={4}
@@ -554,6 +784,15 @@ export default function FilesTab({ project }: Props) {
</div>
)}
{conflict && (
<OverwriteConfirmModal
name={conflict.name}
directory={conflict.directory}
remaining={conflict.remaining}
onChoose={resolveConflict}
/>
)}
{viewing && (
<FileViewerModal
projectId={project.id}
@@ -0,0 +1,73 @@
import type { OverwriteChoice } from "../../../lib/uploadErrors";
import Button from "../../ui/Button";
import Modal from "../../ui/Modal";
interface Props {
/** Bare name of the file that is already there. */
name: string;
/** Container directory it is going into. */
directory: string;
/** How many more files are queued behind this one. */
remaining: number;
onChoose: (choice: OverwriteChoice) => void;
}
/**
* "That name is taken — replace it?"
*
* This exists because the backend stopped overwriting silently, and a raw
* error string would have been a worse answer than the old silent clobber: it
* tells the user their drop failed without telling them it *can* succeed. The
* dialog names the file and the directory, because a drop is aimed with a
* mouse and "notes.txt" alone does not say which `notes.txt`.
*
* The blanket answers only appear when there is something to apply them to — a
* single-file drop with "Replace all" on it invites the reflex of clicking the
* widest button for no benefit.
*
* Dismissing (Escape, ✕, click-outside) is a **skip**, never a replace: the
* destructive answer has to be chosen explicitly.
*/
export default function OverwriteConfirmModal({ name, directory, remaining, onChoose }: Props) {
const footer = (
<>
{remaining > 0 && (
<>
<Button size="md" onClick={() => onChoose("skip-all")}>
Skip all
</Button>
<Button size="md" onClick={() => onChoose("replace-all")}>
Replace all
</Button>
</>
)}
<Button size="md" onClick={() => onChoose("skip")}>
Skip
</Button>
<Button size="md" variant="primary" onClick={() => onChoose("replace")}>
Replace
</Button>
</>
);
return (
<Modal
title="A file with that name is already there"
description={directory}
onClose={() => onChoose("skip")}
footer={footer}
widthClassName="w-[30rem]"
>
<p className="text-[13px] text-[var(--text-primary)]">
<span className="font-mono">{name}</span> already exists in{" "}
<span className="font-mono">{directory}</span>. Replacing it overwrites the container's
copy, and that cannot be undone from here.
</p>
{remaining > 0 && (
<p className="mt-2 text-xs text-[var(--text-secondary)]">
{remaining} more file{remaining === 1 ? "" : "s"} still to upload.
</p>
)}
</Modal>
);
}
@@ -101,6 +101,80 @@ describe("AuthBridgeRow", () => {
expect(screen.queryByText(/Port 1:/)).not.toBeInTheDocument();
});
/**
* The two halves of this row disagree about *when*, not about *what*.
*
* `set_auth_bridge_enabled` resolves with a status sampled as it returned;
* the poller's event carries one sampled afterwards. Writing the awaited
* value unconditionally therefore rolls the row back in time whenever the
* two overlap — the row says "Watching" while a port is bound, which is the
* exact silent failure the event subscription was added to end. These two
* hold the ordering down from both the resolve and the reject side.
*/
describe("a pushed event outranks an older awaited result", () => {
/** A toggle that will not settle until the test says so. */
function deferToggle() {
let settle!: (s: AuthBridgeStatus) => void;
let fail!: (e: unknown) => void;
setAuthBridgeEnabled.mockImplementation(
() =>
new Promise<AuthBridgeStatus>((resolve, reject) => {
settle = resolve;
fail = reject;
}),
);
return { settle: (s: AuthBridgeStatus) => settle(s), fail: (e: unknown) => fail(e) };
}
const BRIDGING: AuthBridgeStatus = {
enabled: true,
active_ports: [{ port: 54545, family: "v4", bridged_at: "", ipv6_warning: null }],
conflicts: [],
};
async function startToggleThenPush() {
render(<AuthBridgeRow project={project} />);
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
await waitFor(() => expect(emit).not.toBeNull());
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true));
// The poller binds a port while the command is still in flight.
emit!({ project_id: "p1", status: BRIDGING });
expect(await screen.findByText("Bridging 1 port")).toBeInTheDocument();
}
it("keeps the newer state when the command settles with the older one", async () => {
const toggle = deferToggle();
await startToggleThenPush();
// …and only now returns the snapshot it took *before* that port existed.
toggle.settle({ enabled: true, active_ports: [], conflicts: [] });
await waitFor(() =>
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled(),
);
expect(screen.getByText("Bridging 1 port")).toBeInTheDocument();
expect(screen.getByText("127.0.0.1:54545")).toBeInTheDocument();
expect(screen.queryByText("Watching")).not.toBeInTheDocument();
});
it("does not let the rollback undo a status pushed while it was failing", async () => {
// The command failed, so the error belongs on screen — but the bridge
// demonstrably came up, and reverting the switch to off would contradict
// the port listed right beside it.
const toggle = deferToggle();
await startToggleThenPush();
toggle.fail("bridge probe timed out");
expect(await screen.findByText(/probe timed out/)).toBeInTheDocument();
expect(screen.getByText("Bridging 1 port")).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Auth bridge" })).toBeChecked();
});
});
it("puts the switch back if the command rejects", async () => {
setAuthBridgeEnabled.mockRejectedValue("Project p1 not found");
render(<AuthBridgeRow project={project} />);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import {
getAuthBridgeStatus,
@@ -77,13 +77,35 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Which write to `status` is the newest — the same "is this still mine?"
* guard `useDiskUsage` and `useContainerMigration` use around their 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
* ordered. `set_auth_bridge_enabled` resolves with a status *sampled at the
* moment it returned*; the poller's `auth-bridge-changed` event carries one
* sampled later. Awaiting the command therefore hands back a value that may
* already be historical, and writing it unconditionally is how the row ends
* up saying "Watching" while a port is in fact bound — the failure mode the
* event subscription exists to prevent, reintroduced one line below it.
*
* So every write claims a generation and only lands if it still holds it.
* A pushed event always claims a fresh one, which is what makes it win over
* an older awaited result no matter which order the two arrive in.
*/
const generation = useRef(0);
useEffect(() => {
const mine = ++generation.current;
let cancelled = false;
setStatus(null);
setError(null);
getAuthBridgeStatus(projectId)
.then((s) => {
if (!cancelled) setStatus(s);
// The initial fetch races the poller exactly like the toggle does: an
// event can land first and describe a bridge this reply predates.
if (!cancelled && generation.current === mine) setStatus(s);
})
.catch((e) => {
if (!cancelled) setError(String(e));
@@ -98,6 +120,9 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
let unlisten: (() => void) | undefined;
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
if (event.payload.project_id !== projectId) return;
// A pushed status is the most recent observation that exists, so it
// claims the newest generation and invalidates anything still in flight.
generation.current += 1;
setStatus(event.payload.status);
})
.then((un) => {
@@ -116,13 +141,24 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
setBusy(true);
setError(null);
// Optimistic, so the switch responds even though enabling has to await a
// container probe. The command's return value replaces it either way.
// container probe. It claims a generation like every other write, so a
// pushed event that lands mid-flight supersedes it rather than being
// undone by the settle below.
const mine = ++generation.current;
setStatus((s) => (s ? { ...s, enabled: next } : s));
try {
setStatus(await setAuthBridgeEnabled(projectId, next));
const settled = await setAuthBridgeEnabled(projectId, next);
// Stale by the time it arrived: the poller has already told us
// something newer, and `settled` predates it.
if (generation.current !== mine) return;
setStatus(settled);
} catch (e) {
setStatus((s) => (s ? { ...s, enabled: !next } : s));
// The error is reported either way — the command really did fail — but
// the rollback must not resurrect the pre-toggle value over a status
// the poller pushed while the command was failing.
setError(String(e));
if (generation.current !== mine) return;
setStatus((s) => (s ? { ...s, enabled: !next } : s));
} finally {
setBusy(false);
}
@@ -107,6 +107,58 @@ describe("RuntimeSection — VPN support toggle", () => {
});
});
/**
* `scope="project"` on the settings editor is one prop with no visible owner,
* and deleting it fails silently in the worst possible direction: the editor
* falls back to `"global"`, every three-state control collapses to an on/off
* switch, and a field the project is *inheriting* as on renders flat Off. The
* user then reads a lie and, worse, flipping that switch writes a deliberate
* `false` that overrides the global On they thought they were looking at.
*
* Nothing asserted the prop was passed, so these go through what is rendered
* rather than through props — a switch where a select belongs is exactly the
* regression, and it is visible from the outside.
*/
describe("RuntimeSection — Claude Code settings are edited at project scope", () => {
beforeEach(() => vi.clearAllMocks());
it("gives every setting the third Global state a project can inherit through", () => {
renderSection();
const focus = screen.getByLabelText("Focus mode") as HTMLSelectElement;
expect(
Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")),
).toEqual(["global", "off", "on"]);
});
it("renders an untouched setting as inheriting, not as Off", () => {
// `claude_code_settings: null` means "this project has no opinion", which
// is not the same instruction as off. At global scope the same field is a
// plain unchecked switch — indistinguishable from a user who turned it
// off, and the reason the missing prop would never be noticed.
renderSection({ claude_code_settings: null });
expect((screen.getByLabelText("Focus mode") as HTMLSelectElement).value).toBe(
"global",
);
expect(screen.queryByRole("switch", { name: "Focus mode" })).not.toBeInTheDocument();
});
it("keeps a stored project override visible over the inherited value", () => {
renderSection({
claude_code_settings: {
tui_mode: null,
effort: null,
auto_scroll_disabled: null,
focus_mode: true,
show_thinking_summaries: null,
session_recap_disabled: null,
env_scrub: null,
prompt_caching_1h: null,
},
});
expect((screen.getByLabelText("Focus mode") as HTMLSelectElement).value).toBe("on");
});
});
describe("RuntimeSection — auth bridge toggle", () => {
beforeEach(() => vi.clearAllMocks());
@@ -17,6 +17,10 @@ const LAYERS_HELP =
const NEXT_COMMIT_HELP =
"The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that.";
/** Why a layer count reads "unknown" rather than as a number. */
const layersUnknownHelp = (layers: number) =>
`${layers} layers in total, but this project predates the base-image label, so there is no way to tell which of them are commits. Migrating it to the current base restores the count.`;
/** `—` for a column with nothing in it, so an empty cell never reads as zero. */
function cell(bytes: number, present: boolean) {
return present ? formatBytes(bytes) : "—";
@@ -141,11 +145,25 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
// The base this descends from is unknown, so the count
// includes the base's own layers and does not mean
// "recreations". Saying so beats printing a wrong number.
<Tooltip
text={`${row.snapshot_commit_layers} layers in total, but this project predates the base-image label, so there is no way to tell which of them are commits. Migrating it to the current base restores the count.`}
>
<span className="text-[var(--text-secondary)]">unknown</span>
</Tooltip>
//
// The explanation is the only thing standing between
// "unknown" and reading as a bug, so it cannot live in the
// tooltip alone: `Tooltip` portals a plain div with no
// `role` and no `aria-describedby`, and wrapped around
// children it has no focus handlers either — so on hover-
// less input it is unreachable and to a screen reader it
// does not exist. Same treatment as the column headers
// above: tooltip for the mouse, `sr-only` text for
// everything else.
<>
<Tooltip text={layersUnknownHelp(row.snapshot_commit_layers)}>
<span className="text-[var(--text-secondary)]">unknown</span>
</Tooltip>
<span className="sr-only">
{" "}
&mdash; {layersUnknownHelp(row.snapshot_commit_layers)}
</span>
</>
) : (
<span className="text-[var(--text-primary)]">
{row.snapshot_commit_layers}
@@ -6,6 +6,7 @@ import type {
ProjectDiskRow,
ReclaimItem,
ReclaimPlan,
ReclaimResult,
ReclaimTarget,
} from "../../lib/types";
@@ -104,6 +105,16 @@ const item = (over: Partial<ReclaimItem> = {}): ReclaimItem => ({
...over,
});
const result = (over: Partial<ReclaimResult> = {}): ReclaimResult => ({
target: { kind: "dangling_snapshots" },
destroyed: null,
ok: true,
freed_bytes: 0,
projected_bytes: null,
message: "Removed 3 images.",
...over,
});
const plan = (over: Partial<ReclaimPlan> = {}): ReclaimPlan => ({
items: [item()],
destructive: [],
@@ -620,6 +631,209 @@ describe("DiskSettings", () => {
expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Failure has to reach the words, and the place the user is looking
// -------------------------------------------------------------------------
it("puts a partial failure in the headline, not only in the glyph's hue", async () => {
// This panel is where the "never encode status in colour alone" rule is
// documented, and the outcome headline used to say "Reclaimed 1.2 GB" for
// a run where most of the targets threw — only the glyph and its colour
// changed, which is exactly nothing to a screen reader or to anyone who
// does not read red as bad.
reclaim.mockResolvedValue({
results: [
result({ freed_bytes: 1_200_000_000 }),
result({ target: { kind: "migration_pins" } }),
result({ target: { kind: "probe_containers" } }),
result({ target: { kind: "build_cache", all: true }, ok: false }),
result({ target: { kind: "orphan_volume", name: "v" }, ok: false }),
],
total_freed_bytes: 1_200_000_000,
});
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
const outcome = await screen.findByTestId("disk-outcome");
expect(
within(outcome).getByText("Reclaimed 1.2 GB — 2 of 5 failed"),
).toBeInTheDocument();
});
it("keeps the plain wording when every target succeeded", async () => {
reclaim.mockResolvedValue({
results: [result({ freed_bytes: 1_200_000_000 }), result()],
total_freed_bytes: 1_200_000_000,
});
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
const outcome = await screen.findByTestId("disk-outcome");
expect(within(outcome).getByText("Reclaimed 1.2 GB")).toBeInTheDocument();
expect(outcome.textContent).not.toMatch(/failed/);
});
it("keeps the typed confirmation open, and says why, when the deletion fails", async () => {
// The dialog used to close regardless, leaving the failure in a line at
// the very top of a panel the user had scrolled past to reach the row.
listReclaimable.mockResolvedValue(
plan({
destructive: [
{
target: { kind: "home_volume", project_id: "p-whp" },
project_id: "p-whp",
project_name: "whp",
label: "Home volume",
loses: "Shell history and toolchains.",
bytes: 4_860_000_000,
blocked: null,
},
],
}),
);
destroyProjectDiskObject.mockRejectedValue(
"volume triple-c-home-p-whp is in use by a running container",
);
await renderAndScan();
await screen.findByTestId("disk-row-p-whp");
fireEvent.click(screen.getByRole("button", { name: "Delete whp data" }));
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: /Delete home volume/ }));
});
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } });
await act(async () => {
fireEvent.click(within(dialog).getByRole("button", { name: "Delete home volume" }));
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(within(screen.getByRole("dialog")).getByRole("alert")).toHaveTextContent(
/in use by a running container/,
);
});
it("keeps the semi-safe confirmation open when the action fails", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [
item({
target: { kind: "compact_snapshot", project_id: "p-whp" },
safety: "semi_safe",
label: "Compact whp's snapshot",
bytes: 5_100_000_000,
bytes_are_exact: false,
bytes_floor: 0,
}),
],
}),
);
reclaim.mockRejectedValue("compaction failed: no space left on device");
await renderAndScan();
const semi = await screen.findByTestId("disk-semi-bucket");
await act(async () => {
fireEvent.click(within(semi).getByRole("button", { name: "Run…" }));
});
await act(async () => {
fireEvent.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Run it" }),
);
});
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(within(dialog).getByRole("alert")).toHaveTextContent(/no space left on device/);
});
it("closes the confirmation once the action succeeds", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [
item({
target: { kind: "clear_caches", project_id: "p-whp", include_rustup: false },
safety: "semi_safe",
label: "Clear whp's caches",
}),
],
}),
);
await renderAndScan();
const semi = await screen.findByTestId("disk-semi-bucket");
await act(async () => {
fireEvent.click(within(semi).getByRole("button", { name: "Run…" }));
});
await act(async () => {
fireEvent.click(
within(screen.getByRole("dialog")).getByRole("button", { name: "Run it" }),
);
});
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Scan status: announced, and not startable mid-mutation
// -------------------------------------------------------------------------
it("announces the scan status through a live region", async () => {
// The status flips between three states with no other signal; without a
// live region wrapping it the change is silent.
render(<DiskSettings />);
const live = screen.getByRole("status");
expect(live).toHaveAttribute("aria-live", "polite");
expect(live).toHaveTextContent("Not scanned");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
});
// The glyph is `aria-hidden` but still part of `textContent`.
expect(screen.getByRole("status")).toHaveTextContent(/Scanned \d/);
});
it("cannot start a scan while a reclaim is still running", async () => {
// A scan launched on top of a mutation measures a daemon that is being
// changed underneath it — the hook can only throw such a result away, so
// the seconds are better not spent.
let finish: (value: unknown) => void = () => {};
reclaim.mockReturnValue(new Promise((r) => (finish = r)));
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Scan again" })).toBeDisabled(),
);
await act(async () => {
finish({ results: [], total_freed_bytes: 0 });
});
expect(screen.getByRole("button", { name: "Scan again" })).toBeEnabled();
});
it("gives the unknown layer count its explanation without a hover", async () => {
// The tooltip portals a div with no role and no `aria-describedby`, and
// wrapped around children it has no focus handlers either — so without the
// sr-only copy "unknown" reads as a bug to everyone not using a mouse.
getDockerDiskUsage.mockResolvedValue(
report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }),
);
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(projectRow.textContent).toMatch(/predates the base-image label/);
expect(projectRow.textContent).toMatch(/Migrating it to the current base restores the count/);
});
it("surfaces a scan failure as an alert", async () => {
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
render(<DiskSettings />);
+86 -16
View File
@@ -54,6 +54,13 @@ export default function DiskSettings() {
const [ticked, setTicked] = useState<Set<string>>(new Set());
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
const [destroying, setDestroying] = useState<DestructiveItem | null>(null);
// A dialog whose action failed stays open and says so *inside itself*. The
// hook's `error` is rendered at the top of a panel that is metres of scroll
// long, so a user who reached a project row through the table would have
// watched the dialog vanish and seen nothing take its place. This flag is
// what distinguishes "this dialog's action just failed" from a stale scan
// error that happened to still be sitting in `error` when it opened.
const [actionFailed, setActionFailed] = useState(false);
// The plan is dropped after any reclaim, so a tick can never outlive the row
// it was made against and be re-fired at an object that is already gone.
@@ -68,6 +75,25 @@ export default function DiskSettings() {
);
const selectedBytes = selected.reduce((sum, i) => sum + i.bytes, 0);
// Opening or closing either dialog clears the in-dialog failure with it, so
// one never starts out showing the previous attempt's error.
const openConfirming = (item: ReclaimItem) => {
setConfirming(item);
setActionFailed(false);
};
const openDestroying = (item: DestructiveItem) => {
setDestroying(item);
setActionFailed(false);
};
const closeConfirming = () => {
setConfirming(null);
setActionFailed(false);
};
const closeDestroying = () => {
setDestroying(null);
setActionFailed(false);
};
const toggle = (item: ReclaimItem) => {
setTicked((prev) => {
const next = new Set(prev);
@@ -78,6 +104,10 @@ export default function DiskSettings() {
});
};
// Counted from the per-result list rather than from a flag: a reclaim of
// five targets can come back with two failures and a real byte total.
const failedCount = outcome?.results.filter((r) => !r.ok).length ?? 0;
const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off";
const statusLabel = scanning
? "Scanning"
@@ -99,10 +129,21 @@ export default function DiskSettings() {
{/* --- Scan --------------------------------------------------------- */}
<div className="flex items-center gap-3 flex-wrap">
<Button variant="primary" size="md" onClick={scan} disabled={scanning}>
{/* Disabled while a mutation runs, not only while scanning: a scan
started on top of a reclaim measures a daemon that is being changed
underneath it, and the hook can only discard such a result — better
not to spend the seconds. */}
<Button variant="primary" size="md" onClick={scan} disabled={scanning || working}>
{scanning ? "Scanning…" : report ? "Scan again" : "Scan"}
</Button>
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
{/* The status flips between "Scanning", "Scanned HH:MM:SS" and "Not
scanned" with no other signal. The live region is mounted here
unconditionally — wrapping it around the indicator only once there
is something to say would make the region *appear* already
populated, which is the one shape assistive tech does not announce. */}
<span role="status" aria-live="polite">
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
</span>
<span className="text-xs text-[var(--text-secondary)]">
Reads the whole Docker store; takes a few seconds on a large one.
</span>
@@ -161,7 +202,7 @@ export default function DiskSettings() {
<DiskProjectTable
rows={report.projects}
destructive={plan?.destructive ?? []}
onDestroy={setDestroying}
onDestroy={openDestroying}
/>
</section>
@@ -196,7 +237,10 @@ export default function DiskSettings() {
<dt className="text-[var(--text-secondary)]">
Build cache <strong className="text-[var(--warning)]">whole daemon</strong>,
not just Triple-C{" "}
<span className="text-[var(--text-disabled)]">
{/* Live information about where the figure came from, not a
disabled control — `--text-disabled` is ~4.1:1 and fails AA
at this size. */}
<span className="text-[var(--text-secondary)]">
(via {report.build_cache.source})
</span>
</dt>
@@ -400,7 +444,7 @@ export default function DiskSettings() {
<Button
size="sm"
disabled={item.blocked !== null || working}
onClick={() => setConfirming(item)}
onClick={() => openConfirming(item)}
>
Run
</Button>
@@ -434,9 +478,18 @@ export default function DiskSettings() {
data-testid="disk-outcome"
>
<div className="flex items-center justify-between gap-3">
{/* The headline has to carry the failure in words. A partial
reclaim that freed something still has a byte figure worth
printing, so the count is appended to it rather than replacing
it — and the per-result lines below say *which* ones and why,
so this stops at how many. */}
<StatusIndicator
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`}
tone={failedCount === 0 ? "ok" : "error"}
label={
failedCount === 0
? `Reclaimed ${formatBytes(outcome.total_freed_bytes)}`
: `Reclaimed ${formatBytes(outcome.total_freed_bytes)}${failedCount} of ${outcome.results.length} failed`
}
className="text-xs"
/>
<Button size="sm" variant="ghost" onClick={clearOutcome}>
@@ -450,7 +503,9 @@ export default function DiskSettings() {
{result.projected_bytes !== null && (
<>
{" "}
<span className="text-[var(--text-disabled)]">
{/* The comparison that makes a compaction's yield
readable — live information, so not the disabled ink. */}
<span className="text-[var(--text-secondary)]">
(projected {formatBytesCeiling(result.projected_bytes)}, actually{" "}
{formatBytes(result.freed_bytes)})
</span>
@@ -466,11 +521,11 @@ export default function DiskSettings() {
{confirming && (
<Modal
title={confirming.label}
onClose={() => setConfirming(null)}
onClose={closeConfirming}
widthClassName="w-[30rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={() => setConfirming(null)}>
<Button size="md" variant="ghost" onClick={closeConfirming}>
Cancel
</Button>
<Button
@@ -479,9 +534,12 @@ export default function DiskSettings() {
disabled={working}
onClick={async () => {
// Same reasoning as the destructive modal: a compaction takes
// minutes, and the dialog reporting it beats it vanishing.
await runReclaim([confirming.target]);
setConfirming(null);
// minutes, and the dialog reporting it beats it vanishing
// and if it fails, the dialog is the only place the user is
// still looking, so it stays open and reports it here.
const ok = await runReclaim([confirming.target]);
setActionFailed(!ok);
if (ok) setConfirming(null);
}}
>
{working ? "Working…" : "Run it"}
@@ -490,6 +548,13 @@ export default function DiskSettings() {
}
>
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
{/* The failure lands here rather than only in the panel's error
line, which this dialog is covering. */}
{actionFailed && (
<p role="alert" className="text-[var(--error)]">
{error ?? "That did not run. Nothing was changed."}
</p>
)}
<p>{confirming.detail}</p>
{confirming.target.kind === "compact_snapshot" && (
<>
@@ -531,13 +596,18 @@ export default function DiskSettings() {
expected={destroying.project_name}
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
busy={working}
onCancel={() => setDestroying(null)}
// A failure here has to land inside the dialog. The panel's own
// error line is at the top of several screens of scroll, and this
// dialog was reached from a project row far below it.
error={actionFailed ? (error ?? "That did not run. Nothing was deleted.") : null}
onCancel={closeDestroying}
onConfirm={async (typed) => {
// The modal stays mounted until the call settles, so its `busy`
// state is what the user sees while a multi-second volume removal
// runs. Clearing it first made the whole busy path dead code.
await destroy(destroying.target, typed);
setDestroying(null);
const ok = await destroy(destroying.target, typed);
setActionFailed(!ok);
if (ok) setDestroying(null);
}}
>
<p>
+6 -6
View File
@@ -2,6 +2,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import type { UpdateInfo } from "../../lib/types";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import { formatBytes } from "../../lib/formatBytes";
interface Props {
updateInfo: UpdateInfo;
@@ -24,11 +25,6 @@ export default function UpdateDialog({
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Modal
title="Update Available"
@@ -83,7 +79,11 @@ export default function UpdateDialog({
>
<span className="truncate font-mono">{asset.name}</span>
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
{formatSize(asset.size)}
{/* `binary` because a release asset's size is the ÷1024 figure
every OS file browser shows for the same download. This
used to be a local copy that rendered KB whole and stopped
the ladder at MB; see `formatBytes.ts`. */}
{formatBytes(asset.size, { binary: true })}
</span>
</button>
))}
@@ -1,7 +1,24 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup } from "@testing-library/react";
import { render, fireEvent, cleanup, act } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView";
import { useAppState } from "../../store/appState";
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
/**
* The window-wide native drag-drop listener, captured at registration.
*
* Tauri routes *every* file drop to *every* listener, which is the whole reason
* `TerminalView` hit-tests one — so a test that wants to know what the hit test
* decides has to be able to fire the event itself.
*/
const dragDrop = vi.hoisted(() => ({
handler: null as null | ((event: unknown) => unknown),
}));
/** The `terminal-output-{id}` listeners, so a test can be the PTY. */
const ptyOutput = vi.hoisted(() => ({
listeners: new Map<string, (e: { payload: number[] }) => void>(),
}));
/**
* Shift+Enter has to reach the container as ESC+CR.
@@ -30,7 +47,10 @@ vi.mock("../../lib/tauri-commands", () => ({
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
listen: async (event: string, cb: (e: { payload: number[] }) => void) => {
ptyOutput.listeners.set(event, cb);
return () => ptyOutput.listeners.delete(event);
},
}));
vi.mock("@tauri-apps/plugin-opener", () => ({
@@ -38,7 +58,14 @@ vi.mock("@tauri-apps/plugin-opener", () => ({
}));
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({ onDragDropEvent: vi.fn(async () => () => {}) }),
getCurrentWebview: () => ({
onDragDropEvent: async (cb: (event: unknown) => unknown) => {
dragDrop.handler = cb;
return () => {
dragDrop.handler = null;
};
},
}),
}));
/** jsdom has no ResizeObserver, and the mount effect installs one. */
@@ -96,6 +123,11 @@ beforeEach(() => {
}),
);
terminalInput.mockClear();
vi.mocked(uploadHostFileToTerminal).mockClear();
vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt");
dragDrop.handler = null;
ptyOutput.listeners.clear();
document.body.innerHTML = "";
useAppState.setState({ sessions: [] });
});
@@ -200,6 +232,14 @@ describe("supersedes — who owns the prompt slot", () => {
expect(supersedes(relay(COMPLETE), guess(TRUNCATED))).toBe(true);
});
it("refuses to let a truncated guess replace another guess it truncates", () => {
// The same rule one rank down. Both are scrapes of the same repainting
// frame, so recency says the newer one wins and recency is wrong: a
// repaint that lands a *shorter* view of the link already on screen is
// showing less of it, not something new.
expect(supersedes(guess(TRUNCATED), guess(COMPLETE))).toBe(false);
});
it("lets a scraped candidate grow into the complete link", () => {
// A repaint can land the truncated copy first. Extending it is safe: a
// longer string with the same prefix has the same origin.
@@ -222,3 +262,162 @@ describe("supersedes — who owns the prompt slot", () => {
).toBe(true);
});
});
describe("TerminalView — where a dropped file lands", () => {
/** Mount, let the async drag-drop registration settle, and give the pane a
* rect — jsdom has no layout, so every element is 0×0 and would be rejected
* as a hidden pane. */
async function mountWithLayout() {
const view = mountSession("bash");
await act(async () => {});
const pane = view.container.querySelector(".xterm")?.parentElement;
if (!pane) throw new Error("terminal host element not found");
pane.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
return view;
}
async function drop(x: number, y: number) {
if (!dragDrop.handler) throw new Error("no drag-drop listener registered");
await act(async () => {
await dragDrop.handler!({
payload: { type: "drop", position: { x, y }, paths: ["/host/dropped.txt"] },
});
});
}
it("uploads a file dropped onto the pane", async () => {
await mountWithLayout();
await drop(400, 300);
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledWith(
"s1",
"/host/dropped.txt",
);
});
it("ignores a drop that lands outside the pane", async () => {
await mountWithLayout();
await drop(4000, 300);
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
});
it("ignores a drop released onto an open modal", async () => {
// The hit test used to be purely geometric, and a `Modal` is a
// `fixed inset-0 z-50` portal painted *over* the whole window — so the pane
// underneath still had its rect and happily uploaded the file into the
// directory the dialog was covering. Same for the shutdown overlay, which is
// up precisely while nothing should be accepting work.
await mountWithLayout();
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
await drop(400, 300);
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
// …and it is the modal, not the mount, that is refusing: close it and the
// very same drop goes through.
dialog.remove();
await drop(400, 300);
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
});
});
describe("TerminalView — reaching the URL prompt without a mouse", () => {
// This toast is the only route to completing a sign-in started in a terminal.
// It used to be mouse-only: nothing moved focus to it, nothing dismissed it
// from the keyboard, and xterm's helper textarea eats Tab, so its buttons
// could not be reached at all.
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
/** What `container/triple-c-open` writes to its controlling terminal. */
function relaySequence(url: string): number[] {
const payload = btoa(url);
return Array.from(
new TextEncoder().encode(`\x1b]7777;open;${payload}\x07`),
);
}
/** Mount, and let the container ask for a URL to be opened. */
async function mountWithPrompt() {
const view = mountSession("claude");
await act(async () => {});
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({ payload: relaySequence(SIGN_IN) });
// xterm parses on its own write queue.
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
return view;
}
function primaryAction(): HTMLElement {
const el = document.querySelector<HTMLElement>('[data-url-toast-primary="true"]');
if (!el) throw new Error("toast default action not found");
return el;
}
it("does not take focus away from the terminal when the prompt appears", async () => {
// Deliberate: the terminal is live, and the default action opens a URL the
// *container* chose. A focused button is one stray Enter from doing it.
const { container } = await mountWithPrompt();
expect(document.querySelector('[data-testid="url-toast"]')).not.toBeNull();
expect(document.activeElement).toBe(helperTextarea(container));
});
it("jumps to the default action on Ctrl+Shift+O", async () => {
const { container } = await mountWithPrompt();
fireEvent.keyDown(helperTextarea(container), {
key: "O",
ctrlKey: true,
shiftKey: true,
});
expect(document.activeElement).toBe(primaryAction());
});
it("dismisses on Escape and hands focus back to the terminal", async () => {
// Not back to `document.body`, where the next keystroke goes nowhere.
const { container } = await mountWithPrompt();
fireEvent.keyDown(helperTextarea(container), {
key: "O",
ctrlKey: true,
shiftKey: true,
});
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(document.querySelector('[data-testid="url-toast"]')).toBeNull();
expect(document.activeElement).toBe(helperTextarea(container));
});
it("leaves Ctrl+Shift+O to the terminal when there is no prompt", async () => {
const { container } = mountSession("claude");
await act(async () => {});
const before = document.activeElement;
fireEvent.keyDown(helperTextarea(container), {
key: "O",
ctrlKey: true,
shiftKey: true,
});
expect(document.activeElement).toBe(before);
});
});
+89 -24
View File
@@ -21,7 +21,12 @@ import {
parseUrlRelayOsc,
sanitizeRelayUrl,
} from "../../lib/urlRelay";
import UrlToast from "./UrlToast";
import { isDropTarget } from "../../lib/dropTarget";
import UrlToast, {
URL_TOAST_PRIMARY_SELECTOR,
URL_TOAST_SELECTOR,
URL_TOAST_SHORTCUT,
} from "./UrlToast";
import { trimSelection } from "./trimSelection";
import TerminalContextMenu from "./TerminalContextMenu";
@@ -131,6 +136,26 @@ export default function TerminalView({ sessionId, active }: Props) {
} | null>(null);
const promptSeqRef = useRef(0);
const relayLimiterRef = useRef(new RelayRateLimiter());
// Read by the long-lived keyboard listener below, which is registered once
// and would otherwise close over the prompt as it was at mount.
const urlPromptRef = useRef<{ url: string } | null>(null);
/**
* Empty the prompt slot, and put focus somewhere real if it was inside the
* toast.
*
* The toast never *takes* focus — see the note in `UrlToast` — but a keyboard
* user who jumped into it with {@link URL_TOAST_SHORTCUT} is standing on a
* node that is about to unmount, and React does not rehome focus: it lands on
* `document.body`, where the terminal receives nothing and the next keystroke
* goes nowhere. Every route out of the toast goes through here for that
* reason — Open, In container, ✕, Escape and the auto-dismiss alike.
*/
const dismissUrlPrompt = useCallback(() => {
const wasInside = !!document.activeElement?.closest(URL_TOAST_SELECTOR);
setUrlPrompt(null);
if (wasInside) termRef.current?.focus();
}, []);
/**
* The only writer of the prompt slot. Re-validates whatever the caller
@@ -158,6 +183,38 @@ export default function TerminalView({ sessionId, active }: Props) {
},
[],
);
useEffect(() => {
urlPromptRef.current = urlPrompt;
}, [urlPrompt]);
/**
* The keyboard route into the toast.
*
* Registered on `document` in the capture phase for the same reason
* `useKeyboardShortcuts` does it there: xterm would otherwise forward the
* chord to the shell. It is *not* added to that hook because the target is
* this pane's own toast — the hook has no way to name it, and only one pane
* is on screen at a time, which is what `activeRef` checks.
*
* Nothing is swallowed unless there is a prompt to jump to, so Ctrl+Shift+O
* reaches the terminal untouched the rest of the time.
*/
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
if (e.key !== "o" && e.key !== "O") return;
if (!activeRef.current || !urlPromptRef.current) return;
const primary = terminalContainerRef.current?.querySelector<HTMLElement>(
`${URL_TOAST_SELECTOR} ${URL_TOAST_PRIMARY_SELECTOR}`,
);
if (!primary) return;
e.preventDefault();
e.stopPropagation();
primary.focus();
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, []);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
@@ -177,24 +234,21 @@ export default function TerminalView({ sessionId, active }: Props) {
// in-container paths typed into the prompt so Claude Code can read them.
// Tauri intercepts OS file drops at the webview level, so we use
// onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
// The listener is window-wide, so we route purely by a hit-test against this
// terminal's bounds: the pane the drop lands on handles it. Inactive panes are
// `display:none` (zero-size rect) so they never match — this works for the
// current tabbed layout and would also do the right thing with split panes.
//
// The listener is window-wide, so every pane decides for itself whether a
// drop was meant for it. `isDropTarget` is that decision, shared with the
// Files pane: the physical-pixel position ÷ `devicePixelRatio` against this
// pane's rect (a hidden pane is `display:none`, so its zero-size rect is what
// stops two panes both claiming the drop), plus z-order — which a rect alone
// cannot see. An open `Modal` is a `fixed inset-0` portal painted *over* the
// window and the pane underneath still has its rect, so the geometric test
// that used to live here uploaded files into the directory a dialog was
// covering. Same for the shutdown overlay, which is on screen precisely while
// nothing should be accepting work at all.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
const insideThisTerminal = (pos: { x: number; y: number }): boolean => {
const rect = containerRef.current?.getBoundingClientRect();
// A hidden (display:none) pane has a zero-size rect — never a drop target.
if (!rect || rect.width === 0 || rect.height === 0) return false;
const dpr = window.devicePixelRatio || 1;
const x = pos.x / dpr;
const y = pos.y / dpr;
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
};
// Always single-quote: a dropped filename can contain shell metacharacters
// ($(), &&, ', spaces) even with no whitespace, and this path is typed into
// a live shell. Single-quoting with '\'' escaping neutralizes all of them.
@@ -203,7 +257,7 @@ export default function TerminalView({ sessionId, active }: Props) {
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== "drop") return;
if (!insideThisTerminal(event.payload.position)) return;
if (!isDropTarget(containerRef.current, event.payload.position)) return;
const paths = event.payload.paths ?? [];
if (paths.length === 0) return;
@@ -391,6 +445,10 @@ export default function TerminalView({ sessionId, active }: Props) {
console.warn("URL relay: rate-limited", url);
return true;
}
// Exact by construction (base64 over OSC 7777), and the detector never
// sees it — so tell it, or a truncated scrape of the same link could
// still fill the slot once this prompt is dismissed.
detectorRef.current?.noteExactUrl(url);
promptUrl(url, "Container asked to open a URL", "relay");
return true;
});
@@ -619,12 +677,19 @@ export default function TerminalView({ sessionId, active }: Props) {
}
}, [active]);
// Auto-dismiss toast after 30 seconds
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
// A keyboard user who has just jumped into the toast is mid-decision, and
// pulling it out from under them costs them the only route to finishing a
// sign-in. It goes when they act on it, which is the same thing a mouse user
// does by clicking.
useEffect(() => {
if (!urlPrompt) return;
const timer = setTimeout(() => setUrlPrompt(null), 30_000);
const timer = setTimeout(() => {
if (document.activeElement?.closest(URL_TOAST_SELECTOR)) return;
dismissUrlPrompt();
}, 30_000);
return () => clearTimeout(timer);
}, [urlPrompt]);
}, [urlPrompt, dismissUrlPrompt]);
// Auto-dismiss image paste message after 3 seconds
useEffect(() => {
@@ -639,13 +704,13 @@ export default function TerminalView({ sessionId, active }: Props) {
// sanitizes, so this can only fail if that invariant is broken — which is
// precisely when it matters that the last thing before `openUrl` checks.
const safe = sanitizeRelayUrl(urlPrompt.url);
setUrlPrompt(null);
dismissUrlPrompt();
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
return;
}
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
}, [urlPrompt]);
}, [urlPrompt, dismissUrlPrompt]);
/**
* Open the prompted URL in the container's own browser instead of the host's.
@@ -658,7 +723,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const handleOpenUrlInContainer = useCallback(() => {
if (!urlPrompt) return;
const safe = sanitizeRelayUrl(urlPrompt.url);
setUrlPrompt(null);
dismissUrlPrompt();
if (!safe) {
console.warn("Refusing to open a URL that failed validation");
return;
@@ -690,7 +755,7 @@ export default function TerminalView({ sessionId, active }: Props) {
detail: String(e),
}),
);
}, [urlPrompt, projectId]);
}, [urlPrompt, projectId, dismissUrlPrompt]);
const handleScrollToBottom = useCallback(() => {
const term = termRef.current;
@@ -770,7 +835,7 @@ export default function TerminalView({ sessionId, active }: Props) {
label={urlPrompt.label}
onOpen={handleOpenUrl}
onOpenInContainer={handleOpenUrlInContainer}
onDismiss={() => setUrlPrompt(null)}
onDismiss={dismissUrlPrompt}
/>
)}
{imagePasteMsg && (
+107 -2
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import UrlToast from "./UrlToast";
import { fireEvent, render, screen } from "@testing-library/react";
import UrlToast, { URL_TOAST_PRIMARY_SELECTOR } from "./UrlToast";
/**
* The toast is the *only* thing standing between a container-chosen URL and
@@ -59,6 +59,111 @@ describe("UrlToast", () => {
expect(onOpen).toHaveBeenCalledTimes(1);
});
describe("keyboard", () => {
// This toast is the only route to completing a sign-in started in a
// terminal, and xterm's helper textarea swallows Tab — so without these it
// is unreachable for a keyboard-only user.
const SIGN_IN =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&response_type=code";
it("does not take focus from the live terminal when it appears", () => {
// Deliberate. The user may be mid-command, and the default action opens a
// URL the *container* chose — a focused button is one stray Enter away
// from doing it. The shortcut hint below is what makes that affordable.
render(
<UrlToast url="https://example.com/" onOpen={noop} onDismiss={noop} />,
);
expect(document.activeElement).toBe(document.body);
});
it("says how to reach it, since nothing announces a shortcut by itself", () => {
render(
<UrlToast url="https://example.com/" onOpen={noop} onDismiss={noop} />,
);
expect(screen.getByTestId("url-toast-shortcut")).toHaveTextContent(
"Ctrl+Shift+O",
);
});
it("marks the default action so the shortcut has somewhere to land", () => {
// Which button that is depends on the URL, so the marker moves with the
// decision rather than the owner having to repeat it.
const { rerender } = render(
<UrlToast
url="https://github.com/login/device?code=ABCD"
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
).toHaveTextContent("Open");
rerender(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
expect(
document.querySelector(URL_TOAST_PRIMARY_SELECTOR),
).toHaveTextContent("In container");
});
it("dismisses on Escape from anywhere inside it", () => {
const onDismiss = vi.fn();
render(
<UrlToast
url="https://example.com/"
onOpen={noop}
onDismiss={onDismiss}
/>,
);
fireEvent.keyDown(screen.getByRole("button", { name: "Open" }), {
key: "Escape",
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("does not answer Escape pressed outside it", () => {
// Escape belongs to whatever is running in the terminal — vim, above all.
// A document-level binding would break it for everyone who never looked
// at this toast.
const onDismiss = vi.fn();
render(
<UrlToast
url="https://example.com/"
onOpen={noop}
onDismiss={onDismiss}
/>,
);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onDismiss).not.toHaveBeenCalled();
});
it("gives every action a real button, so Tab reaches all three", () => {
render(
<UrlToast
url={SIGN_IN}
onOpen={noop}
onOpenInContainer={noop}
onDismiss={noop}
/>,
);
const names = screen
.getAllByRole("button")
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
expect(names).toEqual(["In container", "Open", "Dismiss"]);
// Nothing is taken out of the tab order.
for (const b of screen.getAllByRole("button")) {
expect(b).not.toHaveAttribute("tabindex", "-1");
}
});
});
describe("Anthropic sign-in links", () => {
// The callback listener a `claude login` is waiting on is *inside* the
// container. Sending the user to their host browser completes the sign-in
+85 -71
View File
@@ -1,5 +1,32 @@
import type { CSSProperties, MouseEvent } from "react";
import type { KeyboardEvent } from "react";
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
import Button from "../ui/Button";
/**
* Marks the toast's subtree. `TerminalView` uses it to answer "is focus inside
* the thing I am about to unmount?", which is what decides whether dismissing
* has to hand focus back to the terminal.
*/
export const URL_TOAST_SELECTOR = '[data-testid="url-toast"]';
/**
* The chord that jumps from the terminal into this toast.
*
* Bound in `TerminalView` on `document` in the capture phase, the same way
* `useKeyboardShortcuts` binds the app's other chords, because xterm would
* otherwise forward it to the shell. Shift is what keeps it clear of the
* terminal: plain Ctrl+O is readline's `operate-and-get-next`.
*/
export const URL_TOAST_SHORTCUT = "Ctrl+Shift+O";
/**
* Marks the *default* action inside the toast, so the owner can put focus
* there without a ref threaded through `ui/Button` which is a plain function
* component and not this file's to change. Which button it is depends on the
* URL (see the sign-in note below), so the attribute moves with the decision
* rather than the caller having to repeat it.
*/
export const URL_TOAST_PRIMARY_SELECTOR = '[data-url-toast-primary="true"]';
interface Props {
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */
@@ -41,6 +68,28 @@ interface Props {
* with no host round trip and no auth bridge, so it leads and the host button
* stays, because a user who has the auth bridge on, or who wants their existing
* browser session, still needs it.
*
* ## Reachable without a mouse, and it does not take focus to manage it
*
* This toast is the only route to completing a sign-in started in a terminal,
* and it used to be mouse-only: xterm's helper textarea swallows Tab, so there
* was no way to reach these buttons at all from the keyboard.
*
* The obvious fix focus the default action when the toast appears was
* rejected on two counts. The terminal underneath is *live*: the user may be
* mid-command, and every keystroke after the steal would go to a button instead
* of the shell. Worse, the default action opens a URL chosen by the untrusted
* side of the sandbox, and a focused button is one stray Space or Enter away
* from doing it. This prompt exists precisely to make that a deliberate act.
*
* So focus stays where the user put it and the toast is reachable on demand:
* {@link URL_TOAST_SHORTCUT} jumps to the default action (the hint is on
* screen, next to the label, because a shortcut nobody is told about is not a
* route), Tab then moves between the actions normally this subtree is not
* inside xterm and Escape dismisses. Escape is handled *here*, on the
* toast's own subtree, rather than globally: Escape belongs to whatever is
* running in the terminal, and a document-level binding for it would break vim
* for everyone who never looked at this toast.
*/
export default function UrlToast({
url,
@@ -55,82 +104,56 @@ export default function UrlToast({
// host button is the only action there is, so it stays primary.
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
// Filled uses `--accent-emphasis`, never `--accent`the latter is the
// `Button` already owns the filled/outlined variantsincluding the rule
// that filled uses `--accent-emphasis` and never `--accent`, which is the
// foreground/link accent and fails WCAG AA behind white text.
const primaryStyle: CSSProperties = {
padding: "4px 12px",
fontSize: 12,
fontWeight: 600,
color: "#fff",
background: "var(--accent-emphasis)",
border: "1px solid transparent",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
};
const secondaryStyle: CSSProperties = {
padding: "4px 10px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-primary)",
background: "transparent",
border: "1px solid var(--border-color)",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
};
/** Hover feedback for whichever button is currently the filled one. */
const hover = (primary: boolean) =>
primary
? {
onMouseEnter: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--accent-emphasis-hover)"),
onMouseLeave: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--accent-emphasis)"),
}
: {
onMouseEnter: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "var(--bg-tertiary)"),
onMouseLeave: (e: MouseEvent<HTMLButtonElement>) =>
(e.currentTarget.style.background = "transparent"),
};
const hostButton = (
<button
<Button
variant={signIn ? "secondary" : "primary"}
data-url-toast-primary={signIn ? undefined : "true"}
onClick={onOpen}
className="flex-shrink-0"
title={
signIn
? "Open in your own browser instead — the callback then has to reach the container by some other route"
: undefined
}
style={signIn ? secondaryStyle : primaryStyle}
{...hover(!signIn)}
>
Open
</button>
</Button>
);
const containerButton = onOpenInContainer && (
// A sign-in completed in the *container's* browser lands its callback on
// the container's own loopback, which is where the tool waiting for it is
// listening — no host round trip, no auth bridge.
<button
<Button
variant={signIn ? "primary" : "secondary"}
data-url-toast-primary={signIn ? "true" : undefined}
onClick={onOpenInContainer}
className="flex-shrink-0"
title="Open in a browser inside the container, and watch it in the Browser tab"
style={signIn ? primaryStyle : secondaryStyle}
{...hover(signIn)}
>
In container
</button>
</Button>
);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== "Escape") return;
// Scoped to this subtree, so the terminal's own Escape is untouched.
e.preventDefault();
e.stopPropagation();
onDismiss();
};
return (
<div
className="animate-slide-down"
data-testid="url-toast"
role="status"
aria-atomic="true"
aria-keyshortcuts="Control+Shift+O"
onKeyDown={onKeyDown}
style={{
position: "absolute",
top: 12,
@@ -144,7 +167,7 @@ export default function UrlToast({
background: "var(--bg-secondary)",
border: "1px solid var(--border-color)",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
boxShadow: "var(--shadow-overlay)",
maxWidth: "min(90%, 600px)",
}}
>
@@ -157,6 +180,11 @@ export default function UrlToast({
}}
>
{label}
{" · "}
<span data-testid="url-toast-shortcut" style={{ fontFamily: "monospace" }}>
{URL_TOAST_SHORTCUT}
</span>{" "}
to reach the buttons, Esc to dismiss
</div>
<div
data-testid="url-toast-url"
@@ -226,29 +254,15 @@ export default function UrlToast({
</>
)}
<button
<Button
variant="ghost"
onClick={onDismiss}
style={{
padding: "2px 6px",
fontSize: 14,
lineHeight: 1,
color: "var(--text-secondary)",
background: "transparent",
border: "none",
borderRadius: 4,
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--text-primary)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "var(--text-secondary)")
}
className="flex-shrink-0"
aria-label="Dismiss"
title="Dismiss (Esc)"
>
</button>
</Button>
</div>
);
}
@@ -103,6 +103,19 @@ describe("TypedConfirmModal", () => {
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();
+19 -1
View File
@@ -14,6 +14,13 @@ interface Props {
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;
}
/**
@@ -44,6 +51,7 @@ export default function TypedConfirmModal({
onConfirm,
onCancel,
busy = false,
error = null,
}: Props) {
const [typed, setTyped] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
@@ -104,12 +112,22 @@ export default function TypedConfirmModal({
{matches ? (
<span className="text-[var(--text-secondary)]">Name matches.</span>
) : (
<span className="text-[var(--text-disabled)]">
// 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 project name.
</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>
);