Fix HIGH and MEDIUM frontend defects
Files pane
- F16: a drag-out released back inside the app no longer re-imports its own
staged copy over the container original. An in-flight flag (cleared from the
drag plugin's `onEvent` channel, with a watchdog) suppresses the drop and the
"Drop files into …" hint, and an exact staged-path filter is the second line
of defence — the `path|size|modified` cache could otherwise write a
minutes-old snapshot over a file an agent had since rewritten.
- F17: a slow upload/rename no longer yanks the user back to the directory the
operation started in. Every operation captures its target path and re-lists
only if the user is still there; failures go to the toast host either way.
- The grid keeps keyboard focus. Roving tabindex (one tab stop, not one per
row) plus focus restore after navigation, rename commit/cancel and Escape.
- Transient failures now surface in `ToastHost` (z-[60], persistent aria-live)
instead of a `role="alert"` 300 rows down a scroller or behind a modal
overlay. The inline error is kept only for the listing failure.
- `navigate` is sequenced by generation; "Save to host…" sets `busy`.
- Grid a11y: column headers, a text affordance for folder vs file, a live
region that is mounted empty and announces completion, Label-in-Name fixed.
- FileViewerModal: the blob URL is released only once its replacement exists;
the preview is a focusable, named, scrollable region.
Native drop routing
- New `lib/dropTarget.ts`: the hit test now refuses a drop while any
`[aria-modal="true"]` dialog or `[data-blocks-drop]` overlay is up, and
checks z-order where the environment can answer it. Shared by FilesTab and
TerminalView; App's shutdown overlay opts in.
Disk
- A partially failed reclaim says so in words ("… — 2 of 5 failed"), not by hue
alone.
- The scan/reclaim race is closed: every mutation retires an in-flight scan, so
a scan can no longer repaint a pre-reclaim report plus a clickable plan of
objects that are gone. Scan is disabled while working; the status is a live
region; a failed destructive action keeps its dialog open and reports there.
- The "unknown" layer count gets a screen-reader fallback; `--text-disabled`
no longer carries live information.
Terminal / OAuth
- After the toast is dismissed, a truncated heuristic guess can no longer fill
the slot that an exact OSC 8 or relay URL occupied — the detector remembers
every exact URL and drops any candidate that is a strict prefix of one.
- The prompt is reachable by keyboard: Ctrl+Shift+O jumps to the default
action, Escape dismisses, focus returns to the terminal, and auto-dismiss
holds off while focus is inside. It deliberately does not steal focus.
- UrlToast renders through `ui/Button` and `--shadow-overlay`.
Elsewhere
- AuthBridgeRow: a pushed `auth-bridge-changed` status always outranks an older
awaited toggle result.
- The last two ad-hoc byte formatters route through `lib/formatBytes`.
Contract for the backend agent: `upload_file_to_container` refusing to
overwrite must satisfy `isFileExistsError` in `src/lib/uploadErrors.ts` (marker
`FILE_EXISTS`) and accept an `overwrite` argument; the frontend turns that into
an `ui/Modal` Replace/Skip prompt rather than a raw error string.
Tests: 536 -> 627 passing. `npm run build` and `npx tsc --noEmit` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -156,6 +156,9 @@ export default function App() {
|
|||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
|
/* Covers the whole window, so no pane underneath may accept a
|
||||||
|
native file drop while it is up — see `lib/dropTarget.ts`. */
|
||||||
|
data-blocks-drop="true"
|
||||||
data-testid="shutdown-overlay"
|
data-testid="shutdown-overlay"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-2 px-6 text-center">
|
<div className="flex flex-col items-center gap-2 px-6 text-center">
|
||||||
|
|||||||
@@ -119,5 +119,78 @@ describe("ClaudeCodeSettingsEditor", () => {
|
|||||||
"off",
|
"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 type { FileEntry } from "../../../lib/types";
|
||||||
import { readContainerFile } from "../../../lib/tauri-commands";
|
import { readContainerFile } from "../../../lib/tauri-commands";
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
@@ -40,11 +40,28 @@ type Preview =
|
|||||||
export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) {
|
export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) {
|
||||||
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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 () => {
|
(async () => {
|
||||||
try {
|
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.
|
// A truncated image is not a smaller image, it is a broken one.
|
||||||
if (result.truncated) {
|
if (result.truncated) {
|
||||||
setPreview({ kind: "too-large" });
|
setPreview({ kind: "too-large" });
|
||||||
|
replaceObjectUrl(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blob = new Blob([bytes], { type: imageMimeFor(entry.name) ?? "image/png" });
|
const blob = new Blob([bytes], { type: imageMimeFor(entry.name) ?? "image/png" });
|
||||||
objectUrl = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
setPreview({ kind: "image", url: objectUrl });
|
// The replacement is in hand, so the previous one can go.
|
||||||
|
setPreview({ kind: "image", url });
|
||||||
|
replaceObjectUrl(url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (looksBinary(bytes)) {
|
if (looksBinary(bytes)) {
|
||||||
setPreview({ kind: "unsupported" });
|
setPreview({ kind: "unsupported" });
|
||||||
|
replaceObjectUrl(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +99,7 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
|||||||
shownBytes: bytes.length,
|
shownBytes: bytes.length,
|
||||||
trueSize: result.size,
|
trueSize: result.size,
|
||||||
});
|
});
|
||||||
|
replaceObjectUrl(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) setPreview({ kind: "error", message: String(e) });
|
if (!cancelled) setPreview({ kind: "error", message: String(e) });
|
||||||
}
|
}
|
||||||
@@ -85,10 +107,19 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
||||||
};
|
};
|
||||||
}, [projectId, entry.name, entry.path]);
|
}, [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 = (
|
const footer = (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -143,7 +174,18 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
|
|||||||
Showing the first {formatBytes(preview.shownBytes)} of {formatBytes(preview.trueSize)}.
|
Showing the first {formatBytes(preview.shownBytes)} of {formatBytes(preview.trueSize)}.
|
||||||
</p>
|
</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}
|
{preview.text}
|
||||||
</pre>
|
</pre>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const stageContainerFileForDrag = vi.fn(async () => "/tmp/triple-c-drag-out/s1/n
|
|||||||
vi.mock("../../../lib/tauri-commands", () => ({
|
vi.mock("../../../lib/tauri-commands", () => ({
|
||||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||||
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
|
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),
|
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||||
createContainerDirectory(p, parent, n),
|
createContainerDirectory(p, parent, n),
|
||||||
@@ -22,12 +22,29 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
|||||||
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
|
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", () => ({
|
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");
|
const save = vi.fn(async () => "/host/out");
|
||||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
save: (o: unknown) => save(o),
|
save: (o: unknown) => save(o),
|
||||||
@@ -107,6 +124,32 @@ function dragRow(el: Element) {
|
|||||||
pointer(el, "pointermove", 60, 10);
|
pointer(el, "pointermove", 60, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the pane the OS finished with the drag it started — what the plugin's
|
||||||
|
* `onEvent` channel does for real. Until this arrives the pane deliberately
|
||||||
|
* ignores drops, because a drag-out released back over the app arrives as one.
|
||||||
|
*/
|
||||||
|
function endDragOut(result: "Dropped" | "Cancelled" = "Dropped") {
|
||||||
|
const onEvent = startDrag.mock.calls.at(-1)?.[1];
|
||||||
|
act(() => {
|
||||||
|
onEvent?.({ result });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every row that is part of the grid's roving tabindex, in order. */
|
||||||
|
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
|
||||||
|
/** The rows that are actually tab stops. There must never be more than one. */
|
||||||
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
dragHandler = null;
|
dragHandler = null;
|
||||||
@@ -184,7 +227,9 @@ describe("FilesTab open semantics", () => {
|
|||||||
await renderTab();
|
await renderTab();
|
||||||
listContainerFiles.mockClear();
|
listContainerFiles.mockClear();
|
||||||
const row = screen.getByText("src").closest("tr")!;
|
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 () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(row, { key: "Enter" });
|
fireEvent.keyDown(row, { key: "Enter" });
|
||||||
});
|
});
|
||||||
@@ -261,7 +306,7 @@ describe("FilesTab viewer", () => {
|
|||||||
describe("FilesTab rename", () => {
|
describe("FilesTab rename", () => {
|
||||||
it("commits an inline rename on Enter and re-lists", async () => {
|
it("commits an inline rename on Enter and re-lists", async () => {
|
||||||
await renderTab();
|
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;
|
const input = screen.getByLabelText("New name for notes.txt") as HTMLInputElement;
|
||||||
fireEvent.change(input, { target: { value: "renamed.txt" } });
|
fireEvent.change(input, { target: { value: "renamed.txt" } });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -273,7 +318,7 @@ describe("FilesTab rename", () => {
|
|||||||
|
|
||||||
it("abandons the rename on Escape", async () => {
|
it("abandons the rename on Escape", async () => {
|
||||||
await renderTab();
|
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");
|
const input = screen.getByLabelText("New name for notes.txt");
|
||||||
fireEvent.change(input, { target: { value: "nope.txt" } });
|
fireEvent.change(input, { target: { value: "nope.txt" } });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -290,16 +335,22 @@ describe("FilesTab rename", () => {
|
|||||||
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
|
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");
|
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
|
||||||
await renderTab();
|
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");
|
const input = screen.getByLabelText("New name for notes.txt");
|
||||||
fireEvent.change(input, { target: { value: "x" } });
|
fireEvent.change(input, { target: { value: "x" } });
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.blur(input);
|
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 () => {
|
it("copies a file out to the path the user picks", async () => {
|
||||||
await renderTab();
|
await renderTab();
|
||||||
await act(async () => {
|
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");
|
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not offer a directory download, which cannot work", async () => {
|
it("does not offer a directory download, which cannot work", async () => {
|
||||||
await renderTab();
|
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(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
|
||||||
expect(startDrag).toHaveBeenCalledWith(
|
expect(startDrag).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ item: ["/tmp/triple-c-drag-out/s1/notes.txt"] }),
|
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();
|
await renderTab();
|
||||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
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();
|
expect(startDrag).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -437,7 +491,7 @@ describe("FilesTab drag-out", () => {
|
|||||||
await renderTab();
|
await renderTab();
|
||||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
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 () => {
|
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 () => {
|
it("leaves Save to host… working — drag-out is the enhancement, not the replacement", async () => {
|
||||||
await renderTab();
|
await renderTab();
|
||||||
await act(async () => {
|
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(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
|
||||||
expect(startDrag).not.toHaveBeenCalled();
|
expect(startDrag).not.toHaveBeenCalled();
|
||||||
@@ -495,8 +549,278 @@ describe("FilesTab drag-out", () => {
|
|||||||
await renderTab();
|
await renderTab();
|
||||||
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
dragRow(screen.getByText("notes.txt").closest("tr")!);
|
||||||
await waitFor(() => expect(startDrag).toHaveBeenCalled());
|
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"]);
|
await drop(["/host/a.txt"]);
|
||||||
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.txt", "/workspace");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
import { startDrag } from "@crabnebula/tauri-plugin-drag";
|
import { startDrag } from "@crabnebula/tauri-plugin-drag";
|
||||||
import type { FileEntry, Project } from "../../../lib/types";
|
import type { FileEntry, Project } from "../../../lib/types";
|
||||||
import { useFileManager } from "../../../hooks/useFileManager";
|
import { useFileManager } from "../../../hooks/useFileManager";
|
||||||
|
import { isDropTarget } from "../../../lib/dropTarget";
|
||||||
|
import { useAppState } from "../../../store/appState";
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
import FileViewerModal from "./FileViewerModal";
|
import FileViewerModal from "./FileViewerModal";
|
||||||
|
import OverwriteConfirmModal from "./OverwriteConfirmModal";
|
||||||
import { dragPreviewIcon } from "./dragPreview";
|
import { dragPreviewIcon } from "./dragPreview";
|
||||||
import { formatBytes } from "./format";
|
import { formatBytes } from "./format";
|
||||||
|
|
||||||
@@ -18,6 +21,22 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
const DRAG_THRESHOLD = 4;
|
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.
|
* 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
|
* 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
|
* navigate, which made it impossible to select a directory in order to rename
|
||||||
* it. Keyboard mirrors it exactly: Enter opens, F2 renames.
|
* 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) {
|
export default function FilesTab({ project }: Props) {
|
||||||
const {
|
const {
|
||||||
@@ -34,6 +64,9 @@ export default function FilesTab({ project }: Props) {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
busy,
|
busy,
|
||||||
|
completed,
|
||||||
|
conflict,
|
||||||
|
resolveConflict,
|
||||||
navigate,
|
navigate,
|
||||||
goUp,
|
goUp,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -41,9 +74,9 @@ export default function FilesTab({ project }: Props) {
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
uploadPaths,
|
uploadPaths,
|
||||||
stageForDrag,
|
stageForDrag,
|
||||||
|
isStagedHostPath,
|
||||||
renameEntry,
|
renameEntry,
|
||||||
createFolder,
|
createFolder,
|
||||||
setError,
|
|
||||||
} = useFileManager(project.id);
|
} = useFileManager(project.id);
|
||||||
|
|
||||||
const running = project.status === "running";
|
const running = project.status === "running";
|
||||||
@@ -59,6 +92,8 @@ export default function FilesTab({ project }: Props) {
|
|||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
/** Name of a file staged for drag-out whose gesture did not reach the OS. */
|
/** Name of a file staged for drag-out whose gesture did not reach the OS. */
|
||||||
const [dragNotice, setDragNotice] = useState<string | null>(null);
|
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 paneRef = useRef<HTMLDivElement>(null);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -87,14 +122,106 @@ export default function FilesTab({ project }: Props) {
|
|||||||
if (creatingFolder) folderInputRef.current?.focus();
|
if (creatingFolder) folderInputRef.current?.focus();
|
||||||
}, [creatingFolder]);
|
}, [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) => {
|
const startRename = useCallback((entry: FileEntry) => {
|
||||||
setSelected(entry.name);
|
setSelected(entry.name);
|
||||||
|
setActiveRow(entry.name);
|
||||||
setRenameDraft(entry.name);
|
setRenameDraft(entry.name);
|
||||||
setRenaming(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(
|
const commitRename = useCallback(
|
||||||
async (entry: FileEntry) => {
|
async (entry: FileEntry) => {
|
||||||
|
const renamedTo = renameDraft.trim();
|
||||||
|
wantFocus.current = { key: renamedTo || entry.name };
|
||||||
const done = await renameEntry(entry, renameDraft);
|
const done = await renameEntry(entry, renameDraft);
|
||||||
if (done) setRenaming(null);
|
if (done) setRenaming(null);
|
||||||
},
|
},
|
||||||
@@ -102,36 +229,37 @@ export default function FilesTab({ project }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const commitFolder = useCallback(async () => {
|
const commitFolder = useCallback(async () => {
|
||||||
|
const created = folderDraft.trim();
|
||||||
const done = await createFolder(folderDraft);
|
const done = await createFolder(folderDraft);
|
||||||
if (done) {
|
if (done) {
|
||||||
setCreatingFolder(false);
|
setCreatingFolder(false);
|
||||||
setFolderDraft("");
|
setFolderDraft("");
|
||||||
|
wantFocus.current = { key: created || null };
|
||||||
}
|
}
|
||||||
}, [createFolder, folderDraft]);
|
}, [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. */
|
/** Double click / Enter: directories navigate, files open the viewer. */
|
||||||
const openEntry = useCallback(
|
const openEntry = useCallback(
|
||||||
(entry: FileEntry) => {
|
(entry: FileEntry) => {
|
||||||
if (entry.is_directory) navigate(entry.path);
|
if (entry.is_directory) {
|
||||||
else setViewing(entry);
|
// 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],
|
[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.
|
// Container → host drag-out.
|
||||||
//
|
//
|
||||||
// The mirror image of the drop path below, and it has the same constraint
|
// 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;
|
started: boolean;
|
||||||
} | null>(null);
|
} | 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
|
// 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
|
// 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
|
// 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) => {
|
async (entry: FileEntry) => {
|
||||||
setDragNotice(null);
|
setDragNotice(null);
|
||||||
const staged = await stageForDrag(entry);
|
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;
|
if (!staged) return;
|
||||||
|
|
||||||
// The OS only adopts a drag while the button is still down, and the copy
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dragOutInFlight.current = true;
|
||||||
|
dragOutWatchdog.current = setTimeout(endDragOut, DRAG_OUT_WATCHDOG_MS);
|
||||||
try {
|
try {
|
||||||
await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) });
|
await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) }, () =>
|
||||||
|
endDragOut(),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
endDragOut();
|
||||||
// Drag-out is the enhancement; "Save to host…" is the path that always
|
// 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.
|
// 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
|
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
|
||||||
// it), which blocks HTML5 drag inside the webview on Windows, and only the
|
// 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
|
// 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
|
// routing is `isDropTarget` — the rect hit test, in CSS pixels, *plus* the
|
||||||
// pane's rect — a hidden pane has a zero-size rect and never matches, which
|
// z-order and "is anything modal on screen" questions a rect cannot answer.
|
||||||
// is what keeps this and the terminal's listener from both firing.
|
//
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!running) return;
|
if (!running) return;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let cancelled = false;
|
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 () => {
|
(async () => {
|
||||||
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||||
const payload = event.payload;
|
const payload = event.payload;
|
||||||
@@ -269,13 +432,19 @@ export default function FilesTab({ project }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload.type === "enter" || payload.type === "over") {
|
if (payload.type === "enter" || payload.type === "over") {
|
||||||
setDragOver(insideThisPane(payload.position));
|
setDragOver(
|
||||||
|
!dragOutInFlight.current && isDropTarget(paneRef.current, payload.position),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload.type !== "drop") return;
|
if (payload.type !== "drop") return;
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
if (!insideThisPane(payload.position)) return;
|
if (dragOutInFlight.current) return;
|
||||||
const paths = payload.paths ?? [];
|
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;
|
if (paths.length === 0) return;
|
||||||
await uploadPaths(paths);
|
await uploadPaths(paths);
|
||||||
});
|
});
|
||||||
@@ -287,7 +456,7 @@ export default function FilesTab({ project }: Props) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
};
|
};
|
||||||
}, [running, uploadPaths]);
|
}, [running, uploadPaths, isStagedHostPath]);
|
||||||
|
|
||||||
const breadcrumbs =
|
const breadcrumbs =
|
||||||
currentPath === "/"
|
currentPath === "/"
|
||||||
@@ -322,6 +491,20 @@ export default function FilesTab({ project }: Props) {
|
|||||||
: "hover:bg-[var(--bg-tertiary)]"
|
: "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 (
|
return (
|
||||||
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
|
<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">
|
<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>}
|
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
|
||||||
>
|
>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
@@ -340,16 +526,9 @@ export default function FilesTab({ project }: Props) {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{busy && (
|
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
|
||||||
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
|
{liveText}
|
||||||
{busy}
|
</span>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFolderDraft("");
|
setFolderDraft("");
|
||||||
@@ -367,6 +546,11 @@ export default function FilesTab({ project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<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 && (
|
{error && (
|
||||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||||
{error}
|
{error}
|
||||||
@@ -379,9 +563,25 @@ export default function FilesTab({ project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table role="grid" aria-label="Files" className="w-full text-xs">
|
<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>
|
<tbody>
|
||||||
{creatingFolder && (
|
{creatingFolder && (
|
||||||
<tr>
|
<tr role="row">
|
||||||
<td role="gridcell" className="px-4 py-1.5" colSpan={4}>
|
<td role="gridcell" className="px-4 py-1.5" colSpan={4}>
|
||||||
<input
|
<input
|
||||||
ref={folderInputRef}
|
ref={folderInputRef}
|
||||||
@@ -404,21 +604,28 @@ export default function FilesTab({ project }: Props) {
|
|||||||
)}
|
)}
|
||||||
{currentPath !== "/" && (
|
{currentPath !== "/" && (
|
||||||
<tr
|
<tr
|
||||||
tabIndex={0}
|
role="row"
|
||||||
|
data-file-row={PARENT_ROW}
|
||||||
|
tabIndex={active === PARENT_ROW ? 0 : -1}
|
||||||
aria-label="Parent directory"
|
aria-label="Parent directory"
|
||||||
onDoubleClick={goUp}
|
onClick={() => setActiveRow(PARENT_ROW)}
|
||||||
|
onDoubleClick={openParent}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
goUp();
|
openParent();
|
||||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
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"
|
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">
|
<td role="gridcell" className="px-4 py-1.5 text-[var(--text-primary)] font-mono">
|
||||||
|
<span className="sr-only">Folder, </span>
|
||||||
..
|
..
|
||||||
</td>
|
</td>
|
||||||
<td role="gridcell" colSpan={3} />
|
<td role="gridcell" colSpan={3} />
|
||||||
@@ -430,9 +637,14 @@ export default function FilesTab({ project }: Props) {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={entry.name}
|
key={entry.name}
|
||||||
tabIndex={0}
|
role="row"
|
||||||
|
data-file-row={entry.name}
|
||||||
|
tabIndex={active === entry.name ? 0 : -1}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
onClick={() => setSelected(entry.name)}
|
onClick={() => {
|
||||||
|
setSelected(entry.name);
|
||||||
|
setActiveRow(entry.name);
|
||||||
|
}}
|
||||||
onDoubleClick={() => openEntry(entry)}
|
onDoubleClick={() => openEntry(entry)}
|
||||||
{...dragOutProps(entry)}
|
{...dragOutProps(entry)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -440,13 +652,17 @@ export default function FilesTab({ project }: Props) {
|
|||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelected(entry.name);
|
setSelected(entry.name);
|
||||||
|
setActiveRow(entry.name);
|
||||||
openEntry(entry);
|
openEntry(entry);
|
||||||
} else if (e.key === "F2") {
|
} else if (e.key === "F2") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startRename(entry);
|
startRename(entry);
|
||||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
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)}
|
className={rowClass(isSelected)}
|
||||||
@@ -476,6 +692,14 @@ export default function FilesTab({ project }: Props) {
|
|||||||
: "text-[var(--text-primary)]"
|
: "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>}
|
{entry.is_directory && <span aria-hidden="true">📁 </span>}
|
||||||
<span>{entry.name}</span>
|
<span>{entry.name}</span>
|
||||||
{entry.is_symlink && (
|
{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">
|
<td role="gridcell" className="px-2 py-1.5 text-right whitespace-nowrap">
|
||||||
{!isRenaming && (
|
{!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
|
<Button
|
||||||
aria-label={`Rename ${entry.name}`}
|
aria-label={`Rename — ${entry.name}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
startRename(entry);
|
startRename(entry);
|
||||||
@@ -509,7 +739,7 @@ export default function FilesTab({ project }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
{!entry.is_directory && (
|
{!entry.is_directory && (
|
||||||
<Button
|
<Button
|
||||||
aria-label={`Save ${entry.name} to host`}
|
aria-label={`Save to host… — ${entry.name}`}
|
||||||
className="ml-1"
|
className="ml-1"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -526,7 +756,7 @@ export default function FilesTab({ project }: Props) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{entries.length === 0 && !loading && (
|
{entries.length === 0 && !loading && (
|
||||||
<tr>
|
<tr role="row">
|
||||||
<td
|
<td
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
colSpan={4}
|
colSpan={4}
|
||||||
@@ -554,6 +784,15 @@ export default function FilesTab({ project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{conflict && (
|
||||||
|
<OverwriteConfirmModal
|
||||||
|
name={conflict.name}
|
||||||
|
directory={conflict.directory}
|
||||||
|
remaining={conflict.remaining}
|
||||||
|
onChoose={resolveConflict}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{viewing && (
|
{viewing && (
|
||||||
<FileViewerModal
|
<FileViewerModal
|
||||||
projectId={project.id}
|
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();
|
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 () => {
|
it("puts the switch back if the command rejects", async () => {
|
||||||
setAuthBridgeEnabled.mockRejectedValue("Project p1 not found");
|
setAuthBridgeEnabled.mockRejectedValue("Project p1 not found");
|
||||||
render(<AuthBridgeRow project={project} />);
|
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 { listen } from "@tauri-apps/api/event";
|
||||||
import {
|
import {
|
||||||
getAuthBridgeStatus,
|
getAuthBridgeStatus,
|
||||||
@@ -77,13 +77,35 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
|
const mine = ++generation.current;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
getAuthBridgeStatus(projectId)
|
getAuthBridgeStatus(projectId)
|
||||||
.then((s) => {
|
.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) => {
|
.catch((e) => {
|
||||||
if (!cancelled) setError(String(e));
|
if (!cancelled) setError(String(e));
|
||||||
@@ -98,6 +120,9 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
|
|||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
|
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
|
||||||
if (event.payload.project_id !== projectId) return;
|
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);
|
setStatus(event.payload.status);
|
||||||
})
|
})
|
||||||
.then((un) => {
|
.then((un) => {
|
||||||
@@ -116,13 +141,24 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
// Optimistic, so the switch responds even though enabling has to await a
|
// 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));
|
setStatus((s) => (s ? { ...s, enabled: next } : s));
|
||||||
try {
|
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) {
|
} 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));
|
setError(String(e));
|
||||||
|
if (generation.current !== mine) return;
|
||||||
|
setStatus((s) => (s ? { ...s, enabled: !next } : s));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
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", () => {
|
describe("RuntimeSection — auth bridge toggle", () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ const LAYERS_HELP =
|
|||||||
const NEXT_COMMIT_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.";
|
"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. */
|
/** `—` for a column with nothing in it, so an empty cell never reads as zero. */
|
||||||
function cell(bytes: number, present: boolean) {
|
function cell(bytes: number, present: boolean) {
|
||||||
return present ? formatBytes(bytes) : "—";
|
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
|
// The base this descends from is unknown, so the count
|
||||||
// includes the base's own layers and does not mean
|
// includes the base's own layers and does not mean
|
||||||
// "recreations". Saying so beats printing a wrong number.
|
// "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.`}
|
// The explanation is the only thing standing between
|
||||||
>
|
// "unknown" and reading as a bug, so it cannot live in the
|
||||||
<span className="text-[var(--text-secondary)]">unknown</span>
|
// tooltip alone: `Tooltip` portals a plain div with no
|
||||||
</Tooltip>
|
// `role` and no `aria-describedby`, and wrapped around
|
||||||
|
// children it has no focus handlers either — so on hover-
|
||||||
|
// less input it is unreachable and to a screen reader it
|
||||||
|
// does not exist. Same treatment as the column headers
|
||||||
|
// above: tooltip for the mouse, `sr-only` text for
|
||||||
|
// everything else.
|
||||||
|
<>
|
||||||
|
<Tooltip text={layersUnknownHelp(row.snapshot_commit_layers)}>
|
||||||
|
<span className="text-[var(--text-secondary)]">unknown</span>
|
||||||
|
</Tooltip>
|
||||||
|
<span className="sr-only">
|
||||||
|
{" "}
|
||||||
|
— {layersUnknownHelp(row.snapshot_commit_layers)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[var(--text-primary)]">
|
<span className="text-[var(--text-primary)]">
|
||||||
{row.snapshot_commit_layers}
|
{row.snapshot_commit_layers}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ProjectDiskRow,
|
ProjectDiskRow,
|
||||||
ReclaimItem,
|
ReclaimItem,
|
||||||
ReclaimPlan,
|
ReclaimPlan,
|
||||||
|
ReclaimResult,
|
||||||
ReclaimTarget,
|
ReclaimTarget,
|
||||||
} from "../../lib/types";
|
} from "../../lib/types";
|
||||||
|
|
||||||
@@ -104,6 +105,16 @@ const item = (over: Partial<ReclaimItem> = {}): ReclaimItem => ({
|
|||||||
...over,
|
...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 => ({
|
const plan = (over: Partial<ReclaimPlan> = {}): ReclaimPlan => ({
|
||||||
items: [item()],
|
items: [item()],
|
||||||
destructive: [],
|
destructive: [],
|
||||||
@@ -620,6 +631,209 @@ describe("DiskSettings", () => {
|
|||||||
expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument();
|
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 () => {
|
it("surfaces a scan failure as an alert", async () => {
|
||||||
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
|
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
|
||||||
render(<DiskSettings />);
|
render(<DiskSettings />);
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ export default function DiskSettings() {
|
|||||||
const [ticked, setTicked] = useState<Set<string>>(new Set());
|
const [ticked, setTicked] = useState<Set<string>>(new Set());
|
||||||
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
|
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
|
||||||
const [destroying, setDestroying] = useState<DestructiveItem | 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
|
// 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.
|
// 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);
|
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) => {
|
const toggle = (item: ReclaimItem) => {
|
||||||
setTicked((prev) => {
|
setTicked((prev) => {
|
||||||
const next = new Set(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 tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off";
|
||||||
const statusLabel = scanning
|
const statusLabel = scanning
|
||||||
? "Scanning"
|
? "Scanning"
|
||||||
@@ -99,10 +129,21 @@ export default function DiskSettings() {
|
|||||||
|
|
||||||
{/* --- Scan --------------------------------------------------------- */}
|
{/* --- Scan --------------------------------------------------------- */}
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<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"}
|
{scanning ? "Scanning…" : report ? "Scan again" : "Scan"}
|
||||||
</Button>
|
</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)]">
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
Reads the whole Docker store; takes a few seconds on a large one.
|
Reads the whole Docker store; takes a few seconds on a large one.
|
||||||
</span>
|
</span>
|
||||||
@@ -161,7 +202,7 @@ export default function DiskSettings() {
|
|||||||
<DiskProjectTable
|
<DiskProjectTable
|
||||||
rows={report.projects}
|
rows={report.projects}
|
||||||
destructive={plan?.destructive ?? []}
|
destructive={plan?.destructive ?? []}
|
||||||
onDestroy={setDestroying}
|
onDestroy={openDestroying}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -196,7 +237,10 @@ export default function DiskSettings() {
|
|||||||
<dt className="text-[var(--text-secondary)]">
|
<dt className="text-[var(--text-secondary)]">
|
||||||
Build cache — <strong className="text-[var(--warning)]">whole daemon</strong>,
|
Build cache — <strong className="text-[var(--warning)]">whole daemon</strong>,
|
||||||
not just Triple-C{" "}
|
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})
|
(via {report.build_cache.source})
|
||||||
</span>
|
</span>
|
||||||
</dt>
|
</dt>
|
||||||
@@ -400,7 +444,7 @@ export default function DiskSettings() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={item.blocked !== null || working}
|
disabled={item.blocked !== null || working}
|
||||||
onClick={() => setConfirming(item)}
|
onClick={() => openConfirming(item)}
|
||||||
>
|
>
|
||||||
Run…
|
Run…
|
||||||
</Button>
|
</Button>
|
||||||
@@ -434,9 +478,18 @@ export default function DiskSettings() {
|
|||||||
data-testid="disk-outcome"
|
data-testid="disk-outcome"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<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
|
<StatusIndicator
|
||||||
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
|
tone={failedCount === 0 ? "ok" : "error"}
|
||||||
label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`}
|
label={
|
||||||
|
failedCount === 0
|
||||||
|
? `Reclaimed ${formatBytes(outcome.total_freed_bytes)}`
|
||||||
|
: `Reclaimed ${formatBytes(outcome.total_freed_bytes)} — ${failedCount} of ${outcome.results.length} failed`
|
||||||
|
}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="ghost" onClick={clearOutcome}>
|
<Button size="sm" variant="ghost" onClick={clearOutcome}>
|
||||||
@@ -450,7 +503,9 @@ export default function DiskSettings() {
|
|||||||
{result.projected_bytes !== null && (
|
{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{" "}
|
(projected {formatBytesCeiling(result.projected_bytes)}, actually{" "}
|
||||||
{formatBytes(result.freed_bytes)})
|
{formatBytes(result.freed_bytes)})
|
||||||
</span>
|
</span>
|
||||||
@@ -466,11 +521,11 @@ export default function DiskSettings() {
|
|||||||
{confirming && (
|
{confirming && (
|
||||||
<Modal
|
<Modal
|
||||||
title={confirming.label}
|
title={confirming.label}
|
||||||
onClose={() => setConfirming(null)}
|
onClose={closeConfirming}
|
||||||
widthClassName="w-[30rem]"
|
widthClassName="w-[30rem]"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button size="md" variant="ghost" onClick={() => setConfirming(null)}>
|
<Button size="md" variant="ghost" onClick={closeConfirming}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -479,9 +534,12 @@ export default function DiskSettings() {
|
|||||||
disabled={working}
|
disabled={working}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
// Same reasoning as the destructive modal: a compaction takes
|
// Same reasoning as the destructive modal: a compaction takes
|
||||||
// minutes, and the dialog reporting it beats it vanishing.
|
// minutes, and the dialog reporting it beats it vanishing —
|
||||||
await runReclaim([confirming.target]);
|
// and if it fails, the dialog is the only place the user is
|
||||||
setConfirming(null);
|
// 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"}
|
{working ? "Working…" : "Run it"}
|
||||||
@@ -490,6 +548,13 @@ export default function DiskSettings() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
|
<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>
|
<p>{confirming.detail}</p>
|
||||||
{confirming.target.kind === "compact_snapshot" && (
|
{confirming.target.kind === "compact_snapshot" && (
|
||||||
<>
|
<>
|
||||||
@@ -531,13 +596,18 @@ export default function DiskSettings() {
|
|||||||
expected={destroying.project_name}
|
expected={destroying.project_name}
|
||||||
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
|
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
|
||||||
busy={working}
|
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) => {
|
onConfirm={async (typed) => {
|
||||||
// The modal stays mounted until the call settles, so its `busy`
|
// The modal stays mounted until the call settles, so its `busy`
|
||||||
// state is what the user sees while a multi-second volume removal
|
// state is what the user sees while a multi-second volume removal
|
||||||
// runs. Clearing it first made the whole busy path dead code.
|
// runs. Clearing it first made the whole busy path dead code.
|
||||||
await destroy(destroying.target, typed);
|
const ok = await destroy(destroying.target, typed);
|
||||||
setDestroying(null);
|
setActionFailed(!ok);
|
||||||
|
if (ok) setDestroying(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
|||||||
import type { UpdateInfo } from "../../lib/types";
|
import type { UpdateInfo } from "../../lib/types";
|
||||||
import Modal from "../ui/Modal";
|
import Modal from "../ui/Modal";
|
||||||
import Button from "../ui/Button";
|
import Button from "../ui/Button";
|
||||||
|
import { formatBytes } from "../../lib/formatBytes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
updateInfo: UpdateInfo;
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Update Available"
|
title="Update Available"
|
||||||
@@ -83,7 +79,11 @@ export default function UpdateDialog({
|
|||||||
>
|
>
|
||||||
<span className="truncate font-mono">{asset.name}</span>
|
<span className="truncate font-mono">{asset.name}</span>
|
||||||
<span className="text-[var(--text-secondary)] ml-2 flex-shrink-0">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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 TerminalView, { supersedes } from "./TerminalView";
|
||||||
import { useAppState } from "../../store/appState";
|
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.
|
* 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", () => ({
|
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", () => ({
|
vi.mock("@tauri-apps/plugin-opener", () => ({
|
||||||
@@ -38,7 +58,14 @@ vi.mock("@tauri-apps/plugin-opener", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/webview", () => ({
|
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. */
|
/** jsdom has no ResizeObserver, and the mount effect installs one. */
|
||||||
@@ -96,6 +123,11 @@ beforeEach(() => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
terminalInput.mockClear();
|
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: [] });
|
useAppState.setState({ sessions: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,6 +232,14 @@ describe("supersedes — who owns the prompt slot", () => {
|
|||||||
expect(supersedes(relay(COMPLETE), guess(TRUNCATED))).toBe(true);
|
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", () => {
|
it("lets a scraped candidate grow into the complete link", () => {
|
||||||
// A repaint can land the truncated copy first. Extending it is safe: a
|
// A repaint can land the truncated copy first. Extending it is safe: a
|
||||||
// longer string with the same prefix has the same origin.
|
// longer string with the same prefix has the same origin.
|
||||||
@@ -222,3 +262,162 @@ describe("supersedes — who owns the prompt slot", () => {
|
|||||||
).toBe(true);
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ import {
|
|||||||
parseUrlRelayOsc,
|
parseUrlRelayOsc,
|
||||||
sanitizeRelayUrl,
|
sanitizeRelayUrl,
|
||||||
} from "../../lib/urlRelay";
|
} 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 { trimSelection } from "./trimSelection";
|
||||||
import TerminalContextMenu from "./TerminalContextMenu";
|
import TerminalContextMenu from "./TerminalContextMenu";
|
||||||
|
|
||||||
@@ -131,6 +136,26 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const promptSeqRef = useRef(0);
|
const promptSeqRef = useRef(0);
|
||||||
const relayLimiterRef = useRef(new RelayRateLimiter());
|
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
|
* 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 [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
const [isAutoFollow, setIsAutoFollow] = 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.
|
// 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
|
// Tauri intercepts OS file drops at the webview level, so we use
|
||||||
// onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
|
// 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
|
// The listener is window-wide, so every pane decides for itself whether a
|
||||||
// `display:none` (zero-size rect) so they never match — this works for the
|
// drop was meant for it. `isDropTarget` is that decision, shared with the
|
||||||
// current tabbed layout and would also do the right thing with split panes.
|
// 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(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let cancelled = false;
|
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
|
// Always single-quote: a dropped filename can contain shell metacharacters
|
||||||
// ($(), &&, ', spaces) even with no whitespace, and this path is typed into
|
// ($(), &&, ', spaces) even with no whitespace, and this path is typed into
|
||||||
// a live shell. Single-quoting with '\'' escaping neutralizes all of them.
|
// a live shell. Single-quoting with '\'' escaping neutralizes all of them.
|
||||||
@@ -203,7 +257,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
(async () => {
|
(async () => {
|
||||||
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||||
if (event.payload.type !== "drop") return;
|
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 ?? [];
|
const paths = event.payload.paths ?? [];
|
||||||
if (paths.length === 0) return;
|
if (paths.length === 0) return;
|
||||||
@@ -391,6 +445,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
console.warn("URL relay: rate-limited", url);
|
console.warn("URL relay: rate-limited", url);
|
||||||
return true;
|
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");
|
promptUrl(url, "Container asked to open a URL", "relay");
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -619,12 +677,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}
|
}
|
||||||
}, [active]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!urlPrompt) return;
|
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);
|
return () => clearTimeout(timer);
|
||||||
}, [urlPrompt]);
|
}, [urlPrompt, dismissUrlPrompt]);
|
||||||
|
|
||||||
// Auto-dismiss image paste message after 3 seconds
|
// Auto-dismiss image paste message after 3 seconds
|
||||||
useEffect(() => {
|
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
|
// sanitizes, so this can only fail if that invariant is broken — which is
|
||||||
// precisely when it matters that the last thing before `openUrl` checks.
|
// precisely when it matters that the last thing before `openUrl` checks.
|
||||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||||
setUrlPrompt(null);
|
dismissUrlPrompt();
|
||||||
if (!safe) {
|
if (!safe) {
|
||||||
console.warn("Refusing to open a URL that failed validation");
|
console.warn("Refusing to open a URL that failed validation");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openUrl(safe).catch((e) => console.error("Failed to open URL:", e));
|
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.
|
* 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(() => {
|
const handleOpenUrlInContainer = useCallback(() => {
|
||||||
if (!urlPrompt) return;
|
if (!urlPrompt) return;
|
||||||
const safe = sanitizeRelayUrl(urlPrompt.url);
|
const safe = sanitizeRelayUrl(urlPrompt.url);
|
||||||
setUrlPrompt(null);
|
dismissUrlPrompt();
|
||||||
if (!safe) {
|
if (!safe) {
|
||||||
console.warn("Refusing to open a URL that failed validation");
|
console.warn("Refusing to open a URL that failed validation");
|
||||||
return;
|
return;
|
||||||
@@ -690,7 +755,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
detail: String(e),
|
detail: String(e),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [urlPrompt, projectId]);
|
}, [urlPrompt, projectId, dismissUrlPrompt]);
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
const handleScrollToBottom = useCallback(() => {
|
||||||
const term = termRef.current;
|
const term = termRef.current;
|
||||||
@@ -770,7 +835,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
label={urlPrompt.label}
|
label={urlPrompt.label}
|
||||||
onOpen={handleOpenUrl}
|
onOpen={handleOpenUrl}
|
||||||
onOpenInContainer={handleOpenUrlInContainer}
|
onOpenInContainer={handleOpenUrlInContainer}
|
||||||
onDismiss={() => setUrlPrompt(null)}
|
onDismiss={dismissUrlPrompt}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{imagePasteMsg && (
|
{imagePasteMsg && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import UrlToast from "./UrlToast";
|
import UrlToast, { URL_TOAST_PRIMARY_SELECTOR } from "./UrlToast";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The toast is the *only* thing standing between a container-chosen URL and
|
* The toast is the *only* thing standing between a container-chosen URL and
|
||||||
@@ -59,6 +59,111 @@ describe("UrlToast", () => {
|
|||||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
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", () => {
|
describe("Anthropic sign-in links", () => {
|
||||||
// The callback listener a `claude login` is waiting on is *inside* the
|
// The callback listener a `claude login` is waiting on is *inside* the
|
||||||
// container. Sending the user to their host browser completes the sign-in
|
// container. Sending the user to their host browser completes the sign-in
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
import type { CSSProperties, MouseEvent } from "react";
|
import type { KeyboardEvent } from "react";
|
||||||
import { isAnthropicSignInUrl, urlOrigin } from "../../lib/urlRelay";
|
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 {
|
interface Props {
|
||||||
/** Already validated by `sanitizeRelayUrl` — this component never opens it. */
|
/** 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
|
* 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
|
* stays, because a user who has the auth bridge on, or who wants their existing
|
||||||
* browser session, still needs it.
|
* 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({
|
export default function UrlToast({
|
||||||
url,
|
url,
|
||||||
@@ -55,82 +104,56 @@ export default function UrlToast({
|
|||||||
// host button is the only action there is, so it stays primary.
|
// host button is the only action there is, so it stays primary.
|
||||||
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
|
const signIn = !!onOpenInContainer && isAnthropicSignInUrl(url);
|
||||||
|
|
||||||
// Filled uses `--accent-emphasis`, never `--accent` — the latter is the
|
// `Button` already owns the filled/outlined variants — including the rule
|
||||||
|
// that filled uses `--accent-emphasis` and never `--accent`, which is the
|
||||||
// foreground/link accent and fails WCAG AA behind white text.
|
// 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 = (
|
const hostButton = (
|
||||||
<button
|
<Button
|
||||||
|
variant={signIn ? "secondary" : "primary"}
|
||||||
|
data-url-toast-primary={signIn ? undefined : "true"}
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
|
className="flex-shrink-0"
|
||||||
title={
|
title={
|
||||||
signIn
|
signIn
|
||||||
? "Open in your own browser instead — the callback then has to reach the container by some other route"
|
? "Open in your own browser instead — the callback then has to reach the container by some other route"
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
style={signIn ? secondaryStyle : primaryStyle}
|
|
||||||
{...hover(!signIn)}
|
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerButton = onOpenInContainer && (
|
const containerButton = onOpenInContainer && (
|
||||||
// A sign-in completed in the *container's* browser lands its callback on
|
// 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
|
// the container's own loopback, which is where the tool waiting for it is
|
||||||
// listening — no host round trip, no auth bridge.
|
// listening — no host round trip, no auth bridge.
|
||||||
<button
|
<Button
|
||||||
|
variant={signIn ? "primary" : "secondary"}
|
||||||
|
data-url-toast-primary={signIn ? "true" : undefined}
|
||||||
onClick={onOpenInContainer}
|
onClick={onOpenInContainer}
|
||||||
|
className="flex-shrink-0"
|
||||||
title="Open in a browser inside the container, and watch it in the Browser tab"
|
title="Open in a browser inside the container, and watch it in the Browser tab"
|
||||||
style={signIn ? primaryStyle : secondaryStyle}
|
|
||||||
{...hover(signIn)}
|
|
||||||
>
|
>
|
||||||
In container
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="animate-slide-down"
|
className="animate-slide-down"
|
||||||
|
data-testid="url-toast"
|
||||||
role="status"
|
role="status"
|
||||||
|
aria-atomic="true"
|
||||||
|
aria-keyshortcuts="Control+Shift+O"
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 12,
|
top: 12,
|
||||||
@@ -144,7 +167,7 @@ export default function UrlToast({
|
|||||||
background: "var(--bg-secondary)",
|
background: "var(--bg-secondary)",
|
||||||
border: "1px solid var(--border-color)",
|
border: "1px solid var(--border-color)",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
boxShadow: "var(--shadow-overlay)",
|
||||||
maxWidth: "min(90%, 600px)",
|
maxWidth: "min(90%, 600px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -157,6 +180,11 @@ export default function UrlToast({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
{" · "}
|
||||||
|
<span data-testid="url-toast-shortcut" style={{ fontFamily: "monospace" }}>
|
||||||
|
{URL_TOAST_SHORTCUT}
|
||||||
|
</span>{" "}
|
||||||
|
to reach the buttons, Esc to dismiss
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
data-testid="url-toast-url"
|
data-testid="url-toast-url"
|
||||||
@@ -226,29 +254,15 @@ export default function UrlToast({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
style={{
|
className="flex-shrink-0"
|
||||||
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)")
|
|
||||||
}
|
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
|
title="Dismiss (Esc)"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,19 @@ describe("TypedConfirmModal", () => {
|
|||||||
expect(onConfirm).not.toHaveBeenCalled();
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries a failed attempt inside the dialog, as an alert", () => {
|
||||||
|
// The caller keeps this dialog open when the deletion fails, because the
|
||||||
|
// panel behind it is several screens long and its error line sits at the
|
||||||
|
// top — nowhere near the row this was opened from.
|
||||||
|
renderModal({ error: "volume triple-c-home-p-whp is in use by a running container" });
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent(/in use by a running container/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about failure when there has been none", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("cannot be satisfied by an empty box when there is no name to type", () => {
|
it("cannot be satisfied by an empty box when there is no name to type", () => {
|
||||||
const { confirm } = renderModal({ expected: "" });
|
const { confirm } = renderModal({ expected: "" });
|
||||||
expect(confirm).toBeDisabled();
|
expect(confirm).toBeDisabled();
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ interface Props {
|
|||||||
onConfirm: (typed: string) => void;
|
onConfirm: (typed: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
busy?: boolean;
|
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,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
busy = false,
|
busy = false,
|
||||||
|
error = null,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [typed, setTyped] = useState("");
|
const [typed, setTyped] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -104,12 +112,22 @@ export default function TypedConfirmModal({
|
|||||||
{matches ? (
|
{matches ? (
|
||||||
<span className="text-[var(--text-secondary)]">Name matches.</span>
|
<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.
|
Waiting for the exact project name.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { useDiskUsage } from "./useDiskUsage";
|
import { useDiskUsage, type DiskUsageState } from "./useDiskUsage";
|
||||||
import type { DiskUsageReport } from "../lib/types";
|
import type { DiskUsageReport } from "../lib/types";
|
||||||
|
|
||||||
const getDockerDiskUsage = vi.fn();
|
const getDockerDiskUsage = vi.fn();
|
||||||
@@ -226,6 +226,157 @@ describe("useDiskUsage", () => {
|
|||||||
expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/);
|
expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// The scan-versus-mutation race
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("throws away a scan that a reclaim overtook", async () => {
|
||||||
|
// The live race the generation counter used to miss entirely. A scan takes
|
||||||
|
// seconds and does not set `working`, so nothing stopped the user
|
||||||
|
// reclaiming on top of one — and when the scan landed it repainted the
|
||||||
|
// pre-reclaim report *and* a fresh, clickable plan listing objects the
|
||||||
|
// reclaim had just deleted.
|
||||||
|
let resolveScan: (value: DiskUsageReport) => void = () => {};
|
||||||
|
getDockerDiskUsage.mockReturnValueOnce(
|
||||||
|
new Promise<DiskUsageReport>((r) => {
|
||||||
|
resolveScan = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDiskUsage());
|
||||||
|
let inFlight: Promise<void> = Promise.resolve();
|
||||||
|
act(() => {
|
||||||
|
inFlight = result.current.scan();
|
||||||
|
});
|
||||||
|
expect(result.current.scanning).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
|
||||||
|
});
|
||||||
|
expect(result.current.plan).toBeNull();
|
||||||
|
|
||||||
|
// The overtaken scan finishes last, and must land nothing at all.
|
||||||
|
await act(async () => {
|
||||||
|
resolveScan(report("measured before the reclaim"));
|
||||||
|
await inFlight;
|
||||||
|
});
|
||||||
|
expect(result.current.report).toBeNull();
|
||||||
|
expect(result.current.plan).toBeNull();
|
||||||
|
// It does not even get as far as re-planning: a plan built from a report
|
||||||
|
// this stale is the clickable half of the bug.
|
||||||
|
expect(listReclaimable).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not strand `scanning` when a mutation retires the scan", async () => {
|
||||||
|
// `scanning` is cleared against the newest *scan*, not the newest
|
||||||
|
// generation — a mutation bumps the generation without starting a scan, so
|
||||||
|
// guarding on that would leave the button reading "Scanning…" forever.
|
||||||
|
let resolveScan: (value: DiskUsageReport) => void = () => {};
|
||||||
|
getDockerDiskUsage.mockReturnValueOnce(
|
||||||
|
new Promise<DiskUsageReport>((r) => {
|
||||||
|
resolveScan = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDiskUsage());
|
||||||
|
let inFlight: Promise<void> = Promise.resolve();
|
||||||
|
act(() => {
|
||||||
|
inFlight = result.current.scan();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
resolveScan(report("stale"));
|
||||||
|
await inFlight;
|
||||||
|
});
|
||||||
|
expect(result.current.scanning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retires an in-flight scan for a destroy and a sweep too", async () => {
|
||||||
|
// Every mutation invalidates a measurement, not just the bulk one.
|
||||||
|
destroyProjectDiskObject.mockResolvedValue({
|
||||||
|
target: null,
|
||||||
|
destroyed: { kind: "home_volume", project_id: "p1" },
|
||||||
|
ok: true,
|
||||||
|
freed_bytes: 1,
|
||||||
|
projected_bytes: null,
|
||||||
|
message: "gone",
|
||||||
|
});
|
||||||
|
sweepOrphanedSnapshots.mockResolvedValue({
|
||||||
|
removed: [],
|
||||||
|
reclaimed_bytes: 0,
|
||||||
|
in_use: 0,
|
||||||
|
failed: [],
|
||||||
|
unavailable: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const mutate of [
|
||||||
|
(r: DiskUsageState) => r.destroy({ kind: "home_volume", project_id: "p1" }, "whp"),
|
||||||
|
(r: DiskUsageState) => r.runSweep(),
|
||||||
|
]) {
|
||||||
|
let resolveScan: (value: DiskUsageReport) => void = () => {};
|
||||||
|
getDockerDiskUsage.mockReturnValueOnce(
|
||||||
|
new Promise<DiskUsageReport>((r) => {
|
||||||
|
resolveScan = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useDiskUsage());
|
||||||
|
let inFlight: Promise<void> = Promise.resolve();
|
||||||
|
act(() => {
|
||||||
|
inFlight = result.current.scan();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await mutate(result.current);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
resolveScan(report("stale"));
|
||||||
|
await inFlight;
|
||||||
|
});
|
||||||
|
expect(result.current.report).toBeNull();
|
||||||
|
expect(result.current.plan).toBeNull();
|
||||||
|
expect(result.current.scanning).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Reporting failure back to the caller
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("tells the caller a reclaim failed instead of only swallowing it into `error`", async () => {
|
||||||
|
// The confirmation dialogs close on completion. Without a return value
|
||||||
|
// they closed on failure too, leaving the error at the top of a panel the
|
||||||
|
// user had scrolled well past.
|
||||||
|
reclaim.mockRejectedValueOnce("compaction failed: no space left on device");
|
||||||
|
const { result } = renderHook(() => useDiskUsage());
|
||||||
|
let ok: boolean | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
ok = await result.current.runReclaim([{ kind: "compact_snapshot", project_id: "p1" }]);
|
||||||
|
});
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(result.current.error).toMatch(/no space left on device/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells the caller a destroy failed", async () => {
|
||||||
|
destroyProjectDiskObject.mockRejectedValueOnce("volume is in use by a running container");
|
||||||
|
const { result } = renderHook(() => useDiskUsage());
|
||||||
|
let ok: boolean | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
ok = await result.current.destroy({ kind: "home_volume", project_id: "p1" }, "whp");
|
||||||
|
});
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(result.current.error).toMatch(/in use by a running container/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports success when the call came back", async () => {
|
||||||
|
const { result } = renderHook(() => useDiskUsage());
|
||||||
|
let ok: boolean | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
ok = await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
|
||||||
|
});
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("treats an unreachable daemon in the sweep report as an error", async () => {
|
it("treats an unreachable daemon in the sweep report as an error", async () => {
|
||||||
sweepOrphanedSnapshots.mockResolvedValue({
|
sweepOrphanedSnapshots.mockResolvedValue({
|
||||||
removed: [],
|
removed: [],
|
||||||
|
|||||||
@@ -32,9 +32,24 @@ import type {
|
|||||||
* A user who hits Scan twice can have two `df()` calls in flight, and they can
|
* A user who hits Scan twice can have two `df()` calls in flight, and they can
|
||||||
* land out of order — the second one is not necessarily slower. Every async
|
* land out of order — the second one is not necessarily slower. Every async
|
||||||
* write in `scan` checks it is still the newest before it lands, the same
|
* write in `scan` checks it is still the newest before it lands, the same
|
||||||
* pattern `useContainerMigration` uses. `runReclaim` and `destroy` do not need
|
* pattern `useContainerMigration` uses.
|
||||||
* it: the UI disables their buttons while `working` is set, so there is never
|
*
|
||||||
* a second one to race.
|
* The race that actually bites, though, is not scan-versus-scan: it is
|
||||||
|
* scan-versus-**mutation**. A scan takes seconds and does not set `working`, so
|
||||||
|
* nothing stopped a reclaim starting on top of one. The reclaim correctly drops
|
||||||
|
* the plan — and then the still-running scan landed, passed its own generation
|
||||||
|
* check, and repainted a pre-reclaim report *plus a fresh, clickable plan
|
||||||
|
* listing objects that had just been deleted*. So every mutation bumps the
|
||||||
|
* counter as well: whatever a scan is holding was measured before the mutation
|
||||||
|
* and is now a lie, and throwing it away is the only honest thing to do with
|
||||||
|
* it. (The Scan button is disabled while `working` for the mirror-image case,
|
||||||
|
* so a scan can never start *during* a mutation.)
|
||||||
|
*
|
||||||
|
* That is also why `scanning` is not cleared against the same counter: a
|
||||||
|
* mutation bumping it mid-scan would strand the flag at true and leave the
|
||||||
|
* button reading "Scanning…" forever. `latestScan` records the generation the
|
||||||
|
* newest *scan* owns — only a newer scan may take the flag away — and that is
|
||||||
|
* what the `finally` compares against.
|
||||||
*/
|
*/
|
||||||
export interface DiskUsageState {
|
export interface DiskUsageState {
|
||||||
report: DiskUsageReport | null;
|
report: DiskUsageReport | null;
|
||||||
@@ -47,8 +62,15 @@ export interface DiskUsageState {
|
|||||||
/** The outcome of the last reclaim, kept on screen until the next scan. */
|
/** The outcome of the last reclaim, kept on screen until the next scan. */
|
||||||
outcome: ReclaimOutcome | null;
|
outcome: ReclaimOutcome | null;
|
||||||
scan: () => Promise<void>;
|
scan: () => Promise<void>;
|
||||||
runReclaim: (targets: ReclaimTarget[]) => Promise<void>;
|
/**
|
||||||
destroy: (target: DestructiveTarget, confirmation: string) => Promise<void>;
|
* Resolves `true` when the call came back, `false` when it threw and the
|
||||||
|
* failure went into `error`. Callers that dismiss UI on completion — the
|
||||||
|
* confirmation dialogs — must only dismiss on `true`, or the failure is left
|
||||||
|
* with nowhere on screen the user is looking.
|
||||||
|
*/
|
||||||
|
runReclaim: (targets: ReclaimTarget[]) => Promise<boolean>;
|
||||||
|
/** Same contract as `runReclaim`: `false` means it failed and `error` says how. */
|
||||||
|
destroy: (target: DestructiveTarget, confirmation: string) => Promise<boolean>;
|
||||||
/** Run the orphaned-snapshot sweep and report what it found *and refused*. */
|
/** Run the orphaned-snapshot sweep and report what it found *and refused*. */
|
||||||
runSweep: () => Promise<void>;
|
runSweep: () => Promise<void>;
|
||||||
clearOutcome: () => void;
|
clearOutcome: () => void;
|
||||||
@@ -62,9 +84,21 @@ export function useDiskUsage(): DiskUsageState {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [outcome, setOutcome] = useState<ReclaimOutcome | null>(null);
|
const [outcome, setOutcome] = useState<ReclaimOutcome | null>(null);
|
||||||
const generation = useRef(0);
|
const generation = useRef(0);
|
||||||
|
/** The generation belonging to the most recently *started* scan. */
|
||||||
|
const latestScan = useRef(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire every in-flight scan. Called at the top of each mutation, because
|
||||||
|
* the moment we start deleting things, a measurement taken before that is no
|
||||||
|
* longer describing the daemon the user is looking at.
|
||||||
|
*/
|
||||||
|
const invalidateScans = useCallback(() => {
|
||||||
|
generation.current += 1;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scan = useCallback(async () => {
|
const scan = useCallback(async () => {
|
||||||
const mine = ++generation.current;
|
const mine = ++generation.current;
|
||||||
|
latestScan.current = mine;
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
// The previous outcome describes a state that no longer holds once a new
|
// The previous outcome describes a state that no longer holds once a new
|
||||||
@@ -92,49 +126,66 @@ export function useDiskUsage(): DiskUsageState {
|
|||||||
// user can no longer see the totals for, but that cannot happen: the two
|
// user can no longer see the totals for, but that cannot happen: the two
|
||||||
// only ever move together.
|
// only ever move together.
|
||||||
} finally {
|
} finally {
|
||||||
if (generation.current === mine) setScanning(false);
|
// Deliberately `latestScan`, not `generation`: a mutation that retired
|
||||||
|
// this scan did not start another one, so this scan is still the last
|
||||||
|
// word on whether a scan is running.
|
||||||
|
if (latestScan.current === mine) setScanning(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runReclaim = useCallback(async (targets: ReclaimTarget[]) => {
|
const runReclaim = useCallback(
|
||||||
if (targets.length === 0) return;
|
async (targets: ReclaimTarget[]): Promise<boolean> => {
|
||||||
setWorking(true);
|
// Nothing was asked for, so nothing failed — a caller gating a dialog on
|
||||||
setError(null);
|
// this must not be left staring at an error that has no cause.
|
||||||
try {
|
if (targets.length === 0) return true;
|
||||||
const result = await commands.reclaim(targets);
|
invalidateScans();
|
||||||
setOutcome(result);
|
setWorking(true);
|
||||||
// **The plan is now stale and must not stay clickable.** Its rows
|
setError(null);
|
||||||
// describe objects this call just removed, so leaving them ticked lets
|
try {
|
||||||
// the user fire the same reclaim again against nothing. Dropping the plan
|
const result = await commands.reclaim(targets);
|
||||||
// (not the report) leaves the totals on screen, marked as measured before
|
setOutcome(result);
|
||||||
// the reclaim, with the tick list gone.
|
// **The plan is now stale and must not stay clickable.** Its rows
|
||||||
//
|
// describe objects this call just removed, so leaving them ticked lets
|
||||||
// Deliberately no automatic re-scan: it costs another `df()`, and the
|
// the user fire the same reclaim again against nothing. Dropping the plan
|
||||||
// outcome already reports measured bytes for every target — a user who
|
// (not the report) leaves the totals on screen, marked as measured before
|
||||||
// wants the new totals asks for them.
|
// the reclaim, with the tick list gone.
|
||||||
setPlan(null);
|
//
|
||||||
} catch (e) {
|
// Deliberately no automatic re-scan: it costs another `df()`, and the
|
||||||
setError(String(e));
|
// outcome already reports measured bytes for every target — a user who
|
||||||
} finally {
|
// wants the new totals asks for them.
|
||||||
setWorking(false);
|
setPlan(null);
|
||||||
}
|
return true;
|
||||||
}, []);
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[invalidateScans],
|
||||||
|
);
|
||||||
|
|
||||||
const destroy = useCallback(async (target: DestructiveTarget, confirmation: string) => {
|
const destroy = useCallback(
|
||||||
setWorking(true);
|
async (target: DestructiveTarget, confirmation: string): Promise<boolean> => {
|
||||||
setError(null);
|
invalidateScans();
|
||||||
try {
|
setWorking(true);
|
||||||
const result = await commands.destroyProjectDiskObject(target, confirmation);
|
setError(null);
|
||||||
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
|
try {
|
||||||
// Same reasoning as `runReclaim`: the destructive list named an object
|
const result = await commands.destroyProjectDiskObject(target, confirmation);
|
||||||
// that is now gone.
|
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
|
||||||
setPlan(null);
|
// Same reasoning as `runReclaim`: the destructive list named an object
|
||||||
} catch (e) {
|
// that is now gone.
|
||||||
setError(String(e));
|
setPlan(null);
|
||||||
} finally {
|
return true;
|
||||||
setWorking(false);
|
} catch (e) {
|
||||||
}
|
setError(String(e));
|
||||||
}, []);
|
return false;
|
||||||
|
} finally {
|
||||||
|
setWorking(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[invalidateScans],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The startup sweep, on demand.
|
* The startup sweep, on demand.
|
||||||
@@ -147,6 +198,7 @@ export function useDiskUsage(): DiskUsageState {
|
|||||||
* report away.
|
* report away.
|
||||||
*/
|
*/
|
||||||
const runSweep = useCallback(async () => {
|
const runSweep = useCallback(async () => {
|
||||||
|
invalidateScans();
|
||||||
setWorking(true);
|
setWorking(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -178,7 +230,7 @@ export function useDiskUsage(): DiskUsageState {
|
|||||||
} finally {
|
} finally {
|
||||||
setWorking(false);
|
setWorking(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [invalidateScans]);
|
||||||
|
|
||||||
const clearOutcome = useCallback(() => setOutcome(null), []);
|
const clearOutcome = useCallback(() => setOutcome(null), []);
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const stageContainerFileForDrag = vi.fn();
|
|||||||
vi.mock("../lib/tauri-commands", () => ({
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||||
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
|
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),
|
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||||
createContainerDirectory(p, parent, n),
|
createContainerDirectory(p, parent, n),
|
||||||
@@ -21,6 +21,22 @@ vi.mock("../lib/tauri-commands", () => ({
|
|||||||
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
|
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transient failures go to `ToastHost` rather than an inline string — see the
|
||||||
|
* comment at the top of `useFileManager`. The store is mocked down to the one
|
||||||
|
* method the hook reaches for.
|
||||||
|
*/
|
||||||
|
const pushToast = vi.fn();
|
||||||
|
vi.mock("../store/appState", () => ({
|
||||||
|
useAppState: { getState: () => ({ pushToast }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Everything the hook has said through the toast host, message and detail. */
|
||||||
|
const toastText = () =>
|
||||||
|
pushToast.mock.calls
|
||||||
|
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
const save = vi.fn();
|
const save = vi.fn();
|
||||||
const openDialog = vi.fn();
|
const openDialog = vi.fn();
|
||||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
@@ -111,7 +127,10 @@ describe("useFileManager uploads", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.uploadPaths(["/host/ok.txt", "/host/huge.bin"]);
|
await result.current.uploadPaths(["/host/ok.txt", "/host/huge.bin"]);
|
||||||
});
|
});
|
||||||
expect(result.current.error).toContain("too large");
|
// Inline `error` is reserved for the listing failure the user can see in
|
||||||
|
// context; a failed upload goes where it cannot scroll away.
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
expect(toastText()).toContain("too large");
|
||||||
expect(listContainerFiles).toHaveBeenCalled();
|
expect(listContainerFiles).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,7 +171,7 @@ describe("useFileManager rename and mkdir", () => {
|
|||||||
ok = await result.current.renameEntry(file("hosts"), "hosts.bak");
|
ok = await result.current.renameEntry(file("hosts"), "hosts.bak");
|
||||||
});
|
});
|
||||||
expect(ok).toBe(false);
|
expect(ok).toBe(false);
|
||||||
expect(result.current.error).toContain("Permission denied");
|
expect(toastText()).toContain("Permission denied");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats an unchanged name as a no-op rather than a round trip", async () => {
|
it("treats an unchanged name as a no-op rather than a round trip", async () => {
|
||||||
@@ -183,7 +202,7 @@ describe("useFileManager rename and mkdir", () => {
|
|||||||
ok = await result.current.createFolder("src");
|
ok = await result.current.createFolder("src");
|
||||||
});
|
});
|
||||||
expect(ok).toBe(false);
|
expect(ok).toBe(false);
|
||||||
expect(result.current.error).toContain("File exists");
|
expect(toastText()).toContain("File exists");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,7 +227,7 @@ describe("useFileManager save to host", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current.downloadFile(file("src", { is_directory: true }));
|
await result.current.downloadFile(file("src", { is_directory: true }));
|
||||||
});
|
});
|
||||||
expect(result.current.error).toContain("is a folder");
|
expect(toastText()).toContain("is a folder");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,8 +289,8 @@ describe("useFileManager drag-out staging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(staged).toBeNull();
|
expect(staged).toBeNull();
|
||||||
expect(result.current.error).toContain("too large to drag out");
|
expect(toastText()).toContain("too large to drag out");
|
||||||
expect(result.current.error).toContain("Save to host");
|
expect(toastText()).toContain("Save to host");
|
||||||
expect(result.current.busy).toBeNull();
|
expect(result.current.busy).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,3 +309,193 @@ describe("useFileManager drag-out staging", () => {
|
|||||||
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
|
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("useFileManager stays where the user is", () => {
|
||||||
|
it("does not drag the pane back when the user navigates away mid-upload", async () => {
|
||||||
|
// The closure captured `/workspace`; the user is in `/workspace/src` by the
|
||||||
|
// time the copy finishes. Re-listing the *captured* path is what used to
|
||||||
|
// yank them out of the directory they had walked into.
|
||||||
|
let failUpload: (reason: unknown) => void = () => {};
|
||||||
|
// `Once`, deliberately: `clearAllMocks` clears calls but not
|
||||||
|
// implementations, so a never-settling one would hang every test after it.
|
||||||
|
uploadFileToContainer.mockImplementationOnce(
|
||||||
|
() => new Promise((_resolve, reject) => { failUpload = reject; }),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
let upload!: Promise<void>;
|
||||||
|
await act(async () => {
|
||||||
|
upload = result.current.uploadPaths(["/host/big.bin"]);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
listContainerFiles.mockResolvedValue([file("index.ts", { path: "/workspace/src/index.ts" })]);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.navigate("/workspace/src");
|
||||||
|
});
|
||||||
|
listContainerFiles.mockClear();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
failUpload("cp: no space left on device");
|
||||||
|
await upload;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.currentPath).toBe("/workspace/src");
|
||||||
|
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
|
||||||
|
// No re-list of the directory the upload targeted…
|
||||||
|
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||||
|
// …and no failure text painted over the listing that replaced it.
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
expect(toastText()).toContain("no space left");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-lists when the user stayed put, which is the ordinary case", async () => {
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.navigate("/workspace");
|
||||||
|
});
|
||||||
|
listContainerFiles.mockClear();
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.uploadPaths(["/host/a.png"]);
|
||||||
|
});
|
||||||
|
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the newest listing win when a slow one lands last", async () => {
|
||||||
|
// Two listings in flight, and the slower one is not necessarily the older
|
||||||
|
// one. Landing last used to set both the rows and the breadcrumb back.
|
||||||
|
let landSlow: (entries: FileEntry[]) => void = () => {};
|
||||||
|
listContainerFiles.mockImplementationOnce(
|
||||||
|
() => new Promise((resolve) => { landSlow = resolve; }),
|
||||||
|
);
|
||||||
|
listContainerFiles.mockResolvedValueOnce([file("new.txt")]);
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
let slow!: Promise<void>;
|
||||||
|
await act(async () => {
|
||||||
|
slow = result.current.navigate("/workspace/slow");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.navigate("/workspace/fast");
|
||||||
|
});
|
||||||
|
expect(result.current.currentPath).toBe("/workspace/fast");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
landSlow([file("stale.txt")]);
|
||||||
|
await slow;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.currentPath).toBe("/workspace/fast");
|
||||||
|
expect(result.current.entries.map((e) => e.name)).toEqual(["new.txt"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a failed navigation from claiming the directory it never reached", async () => {
|
||||||
|
listContainerFiles.mockRejectedValueOnce("Permission denied");
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.navigate("/root");
|
||||||
|
});
|
||||||
|
listContainerFiles.mockClear();
|
||||||
|
// The pane never left /workspace, so an upload started now targets it.
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.uploadPaths(["/host/a.png"]);
|
||||||
|
});
|
||||||
|
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useFileManager overwrite prompt", () => {
|
||||||
|
const alreadyThere = "FILE_EXISTS: /workspace/a.txt already exists";
|
||||||
|
|
||||||
|
it("asks rather than clobbering, and replaces on demand", async () => {
|
||||||
|
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
||||||
|
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
let upload!: Promise<void>;
|
||||||
|
await act(async () => {
|
||||||
|
upload = result.current.uploadPaths(["/host/a.txt"]);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
|
||||||
|
expect(result.current.conflict?.directory).toBe("/workspace");
|
||||||
|
// One file, so there is nothing for a blanket answer to apply to.
|
||||||
|
expect(result.current.conflict?.remaining).toBe(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.current.resolveConflict("replace");
|
||||||
|
await upload;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
||||||
|
expect(result.current.conflict).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips without uploading anything when the user says so", async () => {
|
||||||
|
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
let upload!: Promise<void>;
|
||||||
|
await act(async () => {
|
||||||
|
upload = result.current.uploadPaths(["/host/a.txt"]);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.conflict).not.toBeNull());
|
||||||
|
await act(async () => {
|
||||||
|
result.current.resolveConflict("skip");
|
||||||
|
await upload;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
||||||
|
// A skip is a choice, not a failure — nothing to report.
|
||||||
|
expect(toastText()).not.toContain("could not be uploaded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks once for a batch when the answer is Replace all", async () => {
|
||||||
|
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
|
||||||
|
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||||
|
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/b.txt already exists");
|
||||||
|
uploadFileToContainer.mockResolvedValueOnce(undefined);
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
let upload!: Promise<void>;
|
||||||
|
await act(async () => {
|
||||||
|
upload = result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.conflict?.remaining).toBe(1));
|
||||||
|
await act(async () => {
|
||||||
|
result.current.resolveConflict("replace-all");
|
||||||
|
await upload;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.conflict).toBeNull();
|
||||||
|
expect(uploadFileToContainer).toHaveBeenNthCalledWith(4, "p1", "/host/b.txt", "/workspace", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an unrelated failure alone — no prompt offering a button that cannot work", async () => {
|
||||||
|
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.uploadPaths(["/host/huge.bin"]);
|
||||||
|
});
|
||||||
|
expect(result.current.conflict).toBeNull();
|
||||||
|
expect(toastText()).toContain("too large");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useFileManager staged host paths", () => {
|
||||||
|
it("recognises a path it staged, and only that path", async () => {
|
||||||
|
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
|
||||||
|
const { result } = renderHook(() => useFileManager("p1"));
|
||||||
|
|
||||||
|
expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(false);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.stageForDrag(file("a.txt"));
|
||||||
|
});
|
||||||
|
expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(true);
|
||||||
|
// Same basename, a real host file the user actually wants uploaded.
|
||||||
|
expect(result.current.isStagedHostPath("/home/me/a.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+264
-36
@@ -1,8 +1,75 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import type { FileEntry } from "../lib/types";
|
import type { FileEntry } from "../lib/types";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
import {
|
||||||
|
fileExistsPath,
|
||||||
|
isFileExistsError,
|
||||||
|
type OverwriteChoice,
|
||||||
|
} from "../lib/uploadErrors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One upload waiting on the user to say whether it may replace what is there.
|
||||||
|
* `remaining` is how many files are queued behind this one, which is what
|
||||||
|
* decides whether the blanket answers are worth offering.
|
||||||
|
*/
|
||||||
|
export interface UploadConflict {
|
||||||
|
/** Host file being uploaded. */
|
||||||
|
hostPath: string;
|
||||||
|
/** Bare name, for the prompt. */
|
||||||
|
name: string;
|
||||||
|
/** Container directory it is going into. */
|
||||||
|
directory: string;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/a/b/c.txt` and `C:\a\b\c.txt` both give `c.txt`. */
|
||||||
|
function baseName(path: string): string {
|
||||||
|
const parts = path.split(/[\\/]/);
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host paths compare on separators, not on case: the OS hands a dropped path
|
||||||
|
* back in whatever form its file dialog produced, and on Windows that is not
|
||||||
|
* reliably the form `stage_container_file_for_drag` returned.
|
||||||
|
*/
|
||||||
|
function normaliseHostPath(path: string): string {
|
||||||
|
return path.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## Where failures are reported
|
||||||
|
*
|
||||||
|
* Two audiences, two places, and the split is deliberate.
|
||||||
|
*
|
||||||
|
* The **initial listing** failure stays in `error`, rendered inline above the
|
||||||
|
* (empty) grid. It is on screen, it is in context, it explains why there are
|
||||||
|
* no rows, and it is not transient — it stands until the directory lists.
|
||||||
|
*
|
||||||
|
* Every **transient operation** failure — upload, rename, create folder,
|
||||||
|
* save-to-host, drag staging — goes to `ToastHost` instead. Those used to land
|
||||||
|
* in the same inline `error` div, which is the first child of the *scrolling*
|
||||||
|
* list: three hundred rows down, a refused rename produced no visible change
|
||||||
|
* at all, just a rename box that stayed open for no stated reason. Worse, the
|
||||||
|
* file viewer routes its "Save to host…" through the same call, and the viewer
|
||||||
|
* is a `fixed inset-0` portal at `z-50` — so that failure reported *behind* the
|
||||||
|
* dialog that caused it. The toast host is a persistent `aria-live` region at
|
||||||
|
* `z-[60]`, i.e. the one place in the app that is above a modal and does not
|
||||||
|
* scroll away.
|
||||||
|
*
|
||||||
|
* ## Where the current directory lives
|
||||||
|
*
|
||||||
|
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it
|
||||||
|
* after an await). Every long operation captures the directory it targets at
|
||||||
|
* the start and compares it against the ref at the end: a 200 MB upload into
|
||||||
|
* `/workspace` must not drag the pane back out of `src/` because that is where
|
||||||
|
* the closure happened to be created. The ref moves at the *start* of a
|
||||||
|
* navigation rather than when the listing lands, because the question being
|
||||||
|
* asked is "where is the user going", not "what is on screen right now" — and
|
||||||
|
* it is put back if that navigation fails.
|
||||||
|
*/
|
||||||
export function useFileManager(projectId: string) {
|
export function useFileManager(projectId: string) {
|
||||||
const [currentPath, setCurrentPath] = useState("/workspace");
|
const [currentPath, setCurrentPath] = useState("/workspace");
|
||||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||||
@@ -10,33 +77,72 @@ export function useFileManager(projectId: string) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
|
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
/**
|
||||||
|
* What just finished. A live region that only ever says "uploading…" tells a
|
||||||
|
* screen reader user when to start waiting and never when to stop.
|
||||||
|
*/
|
||||||
|
const [completed, setCompleted] = useState<string | null>(null);
|
||||||
|
const [conflict, setConflict] = useState<UploadConflict | null>(null);
|
||||||
|
|
||||||
|
const currentPathRef = useRef(currentPath);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slow listing can land after a newer one and set both the rows and the
|
||||||
|
* breadcrumb back to a directory the user already left. Same generation
|
||||||
|
* guard `useDiskUsage` and `useContainerMigration` use: every async write
|
||||||
|
* checks it is still the newest before it lands.
|
||||||
|
*/
|
||||||
|
const navGeneration = useRef(0);
|
||||||
|
|
||||||
|
const startWork = useCallback((note: string) => {
|
||||||
|
setBusy(note);
|
||||||
|
setCompleted(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const report = useCallback((message: string, detail?: string) => {
|
||||||
|
useAppState.getState().pushToast({ kind: "error", message, detail });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirm = useCallback((message: string) => {
|
||||||
|
useAppState.getState().pushToast({ kind: "success", message });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
async (path: string) => {
|
async (path: string) => {
|
||||||
|
const mine = ++navGeneration.current;
|
||||||
|
const previous = currentPathRef.current;
|
||||||
|
currentPathRef.current = path;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await commands.listContainerFiles(projectId, path);
|
const result = await commands.listContainerFiles(projectId, path);
|
||||||
|
if (navGeneration.current !== mine) return;
|
||||||
setEntries(result);
|
setEntries(result);
|
||||||
setCurrentPath(path);
|
setCurrentPath(path);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (navGeneration.current !== mine) return;
|
||||||
|
// The move did not happen, so the pane is still where it was — the ref
|
||||||
|
// has to agree with the breadcrumb or the next operation will decide
|
||||||
|
// it targeted a directory nobody is looking at.
|
||||||
|
currentPathRef.current = previous;
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (navGeneration.current === mine) setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId],
|
[projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const goUp = useCallback(() => {
|
const goUp = useCallback(() => {
|
||||||
if (currentPath === "/") return;
|
const here = currentPathRef.current;
|
||||||
const parent = currentPath.replace(/\/[^/]+$/, "") || "/";
|
if (here === "/") return;
|
||||||
|
const parent = here.replace(/\/[^/]+$/, "") || "/";
|
||||||
navigate(parent);
|
navigate(parent);
|
||||||
}, [currentPath, navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
navigate(currentPath);
|
navigate(currentPathRef.current);
|
||||||
}, [currentPath, navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
/** Copy an entry out to a host path the user picks. */
|
/** Copy an entry out to a host path the user picks. */
|
||||||
const downloadFile = useCallback(
|
const downloadFile = useCallback(
|
||||||
@@ -44,30 +150,111 @@ export function useFileManager(projectId: string) {
|
|||||||
try {
|
try {
|
||||||
const hostPath = await save({ defaultPath: entry.name });
|
const hostPath = await save({ defaultPath: entry.name });
|
||||||
if (!hostPath) return;
|
if (!hostPath) return;
|
||||||
setError(null);
|
// Every sibling operation sets `busy`; this one did not, so a 200 MB
|
||||||
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
// copy was a click, then a frozen-looking pane, then nothing.
|
||||||
|
startWork(`Saving "${entry.name}" to the host…`);
|
||||||
|
try {
|
||||||
|
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
||||||
|
setCompleted(`Saved "${entry.name}" to ${hostPath}.`);
|
||||||
|
confirm(`Saved "${entry.name}" to the host.`);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
report(`Could not save "${entry.name}" to the host`, String(e));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId],
|
[projectId, startWork, report, confirm],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pending answer to `conflict`. Kept in a ref rather than state because
|
||||||
|
* the upload loop is `await`ing it — it needs the resolver, not a re-render.
|
||||||
|
*/
|
||||||
|
const conflictResolver = useRef<((choice: OverwriteChoice) => void) | null>(null);
|
||||||
|
|
||||||
|
const resolveConflict = useCallback((choice: OverwriteChoice) => {
|
||||||
|
const resolve = conflictResolver.current;
|
||||||
|
conflictResolver.current = null;
|
||||||
|
setConflict(null);
|
||||||
|
resolve?.(choice);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A pane unmounted mid-prompt (the tab was closed, the container stopped)
|
||||||
|
// would otherwise leave the upload loop awaiting an answer that can never
|
||||||
|
// come. Skipping is the safe reading of "the dialog went away".
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
conflictResolver.current?.("skip-all");
|
||||||
|
conflictResolver.current = null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const askOverwrite = useCallback(
|
||||||
|
(hostPath: string, directory: string, remaining: number, containerPath: string | null) =>
|
||||||
|
new Promise<OverwriteChoice>((resolve) => {
|
||||||
|
conflictResolver.current = resolve;
|
||||||
|
setConflict({
|
||||||
|
hostPath,
|
||||||
|
name: baseName(containerPath ?? hostPath),
|
||||||
|
directory,
|
||||||
|
remaining,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy host files into the current directory. Shared by the Upload button and
|
* Copy host files into the current directory. Shared by the Upload button and
|
||||||
* the native drag-drop listener, so a dropped file and a picked one take the
|
* the native drag-drop listener, so a dropped file and a picked one take the
|
||||||
* same path — including the one refresh at the end rather than one per file.
|
* same path — including the one refresh at the end rather than one per file.
|
||||||
|
*
|
||||||
|
* The backend refuses to overwrite unless asked to, so a name clash is not a
|
||||||
|
* failure here: it is a question, and the answer can be given once for the
|
||||||
|
* whole batch.
|
||||||
*/
|
*/
|
||||||
const uploadPaths = useCallback(
|
const uploadPaths = useCallback(
|
||||||
async (hostPaths: string[]) => {
|
async (hostPaths: string[]) => {
|
||||||
if (hostPaths.length === 0) return;
|
if (hostPaths.length === 0) return;
|
||||||
setError(null);
|
// The directory this upload is *for*. Compared against the live ref at
|
||||||
setBusy(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}…`);
|
// the end, because the user is free to walk away while it copies.
|
||||||
|
const target = currentPathRef.current;
|
||||||
|
startWork(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}…`);
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
|
let uploaded = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
/** A "…all" answer, applied to every remaining clash without asking. */
|
||||||
|
let blanket: OverwriteChoice | null = null;
|
||||||
try {
|
try {
|
||||||
for (const hostPath of hostPaths) {
|
for (let i = 0; i < hostPaths.length; i++) {
|
||||||
|
const hostPath = hostPaths[i];
|
||||||
try {
|
try {
|
||||||
await commands.uploadFileToContainer(projectId, hostPath, currentPath);
|
await commands.uploadFileToContainer(projectId, hostPath, target);
|
||||||
|
uploaded++;
|
||||||
|
continue;
|
||||||
|
} catch (e) {
|
||||||
|
if (!isFileExistsError(e)) {
|
||||||
|
failures.push(String(e));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const choice: OverwriteChoice =
|
||||||
|
blanket ??
|
||||||
|
(await askOverwrite(
|
||||||
|
hostPath,
|
||||||
|
target,
|
||||||
|
hostPaths.length - i - 1,
|
||||||
|
fileExistsPath(e),
|
||||||
|
));
|
||||||
|
if (choice === "replace-all" || choice === "skip-all") blanket = choice;
|
||||||
|
if (choice === "skip" || choice === "skip-all") {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await commands.uploadFileToContainer(projectId, hostPath, target, true);
|
||||||
|
uploaded++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
failures.push(String(e));
|
failures.push(String(e));
|
||||||
}
|
}
|
||||||
@@ -75,12 +262,25 @@ export function useFileManager(projectId: string) {
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
// Re-list first: `navigate` clears the error, so reporting before it
|
|
||||||
// would wipe the very message the user needs.
|
const summary =
|
||||||
await navigate(currentPath);
|
`Uploaded ${uploaded} item${uploaded === 1 ? "" : "s"}` +
|
||||||
if (failures.length > 0) setError(failures.join(" · "));
|
(skipped > 0 ? `, skipped ${skipped}` : "") +
|
||||||
|
(failures.length > 0 ? `, ${failures.length} failed` : "") +
|
||||||
|
".";
|
||||||
|
setCompleted(summary);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
report(
|
||||||
|
failures.length === 1 ? "A file could not be uploaded" : `${failures.length} files could not be uploaded`,
|
||||||
|
failures.join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Only re-list if the user is still looking at the directory this went
|
||||||
|
// into. Navigating away during a slow copy used to drag the pane back.
|
||||||
|
if (currentPathRef.current === target) await navigate(target);
|
||||||
},
|
},
|
||||||
[projectId, currentPath, navigate],
|
[projectId, navigate, startWork, report, askOverwrite],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,10 +289,28 @@ export function useFileManager(projectId: string) {
|
|||||||
* last listing re-stages rather than dragging a stale copy.
|
* last listing re-stages rather than dragging a stale copy.
|
||||||
*/
|
*/
|
||||||
const stagedRef = useRef(new Map<string, string>());
|
const stagedRef = useRef(new Map<string, string>());
|
||||||
|
/**
|
||||||
|
* The same paths the other way round, as a set.
|
||||||
|
*
|
||||||
|
* A drag-out released back inside the app arrives as an ordinary host drop
|
||||||
|
* carrying the staged copy's path, and uploading that would write the app's
|
||||||
|
* own temp copy over the container file it came from — which is worse than a
|
||||||
|
* no-op, because the key above is built from the *last listing*, so a file an
|
||||||
|
* agent rewrote since then would be replaced by a minutes-old snapshot. This
|
||||||
|
* set is what makes the "is this ours?" test exact instead of a guess at the
|
||||||
|
* temp directory's name.
|
||||||
|
*/
|
||||||
|
const stagedHostPathsRef = useRef(new Set<string>());
|
||||||
|
|
||||||
|
/** True when `path` is a copy this pane staged for a drag-out. */
|
||||||
|
const isStagedHostPath = useCallback(
|
||||||
|
(path: string) => stagedHostPathsRef.current.has(normaliseHostPath(path)),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy an entry onto the host so the OS can drag it, and return the absolute
|
* Copy an entry onto the host so the OS can drag it, and return the absolute
|
||||||
* host path — or `null`, having set `error`, if it could not be staged.
|
* host path — or `null`, having reported why, if it could not be staged.
|
||||||
*
|
*
|
||||||
* `cached` is what the caller needs to tell a gesture that will feel
|
* `cached` is what the caller needs to tell a gesture that will feel
|
||||||
* instantaneous from one that has a whole-file copy in front of it: the copy
|
* instantaneous from one that has a whole-file copy in front of it: the copy
|
||||||
@@ -105,20 +323,21 @@ export function useFileManager(projectId: string) {
|
|||||||
const cached = stagedRef.current.get(key);
|
const cached = stagedRef.current.get(key);
|
||||||
if (cached) return { hostPath: cached, cached: true };
|
if (cached) return { hostPath: cached, cached: true };
|
||||||
|
|
||||||
setError(null);
|
startWork(`Preparing "${entry.name}"…`);
|
||||||
setBusy(`Preparing "${entry.name}"…`);
|
|
||||||
try {
|
try {
|
||||||
const hostPath = await commands.stageContainerFileForDrag(projectId, entry.path);
|
const hostPath = await commands.stageContainerFileForDrag(projectId, entry.path);
|
||||||
stagedRef.current.set(key, hostPath);
|
stagedRef.current.set(key, hostPath);
|
||||||
|
stagedHostPathsRef.current.add(normaliseHostPath(hostPath));
|
||||||
|
setCompleted(`"${entry.name}" is ready to drag.`);
|
||||||
return { hostPath, cached: false };
|
return { hostPath, cached: false };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
report(`Could not prepare "${entry.name}" for dragging`, String(e));
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId],
|
[projectId, startWork, report],
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadFile = useCallback(async () => {
|
const uploadFile = useCallback(async () => {
|
||||||
@@ -127,9 +346,9 @@ export function useFileManager(projectId: string) {
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
|
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
report("Could not open the file picker", String(e));
|
||||||
}
|
}
|
||||||
}, [uploadPaths]);
|
}, [uploadPaths, report]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename in place. `newName` is a bare name — Rust rejects anything with a
|
* Rename in place. `newName` is a bare name — Rust rejects anything with a
|
||||||
@@ -140,42 +359,50 @@ export function useFileManager(projectId: string) {
|
|||||||
async (entry: FileEntry, newName: string) => {
|
async (entry: FileEntry, newName: string) => {
|
||||||
const trimmed = newName.trim();
|
const trimmed = newName.trim();
|
||||||
if (!trimmed || trimmed === entry.name) return true;
|
if (!trimmed || trimmed === entry.name) return true;
|
||||||
|
const target = currentPathRef.current;
|
||||||
try {
|
try {
|
||||||
setError(null);
|
|
||||||
await commands.renameContainerPath(projectId, entry.path, trimmed);
|
await commands.renameContainerPath(projectId, entry.path, trimmed);
|
||||||
await navigate(currentPath);
|
setCompleted(`Renamed "${entry.name}" to "${trimmed}".`);
|
||||||
|
if (currentPathRef.current === target) await navigate(target);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
report(`Could not rename "${entry.name}"`, String(e));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId, currentPath, navigate],
|
[projectId, navigate, report],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createFolder = useCallback(
|
const createFolder = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return true;
|
if (!trimmed) return true;
|
||||||
|
const target = currentPathRef.current;
|
||||||
try {
|
try {
|
||||||
setError(null);
|
await commands.createContainerDirectory(projectId, target, trimmed);
|
||||||
await commands.createContainerDirectory(projectId, currentPath, trimmed);
|
setCompleted(`Created "${trimmed}".`);
|
||||||
await navigate(currentPath);
|
if (currentPathRef.current === target) await navigate(target);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
report(`Could not create "${trimmed}"`, String(e));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId, currentPath, navigate],
|
[projectId, navigate, report],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentPath,
|
currentPath,
|
||||||
entries,
|
entries,
|
||||||
loading,
|
loading,
|
||||||
|
/** Inline, in-context: why the listing on screen is empty. */
|
||||||
error,
|
error,
|
||||||
busy,
|
busy,
|
||||||
|
/** What the last operation finished doing, for the live region. */
|
||||||
|
completed,
|
||||||
|
/** An upload waiting for a Replace / Skip answer, or `null`. */
|
||||||
|
conflict,
|
||||||
|
resolveConflict,
|
||||||
setError,
|
setError,
|
||||||
navigate,
|
navigate,
|
||||||
goUp,
|
goUp,
|
||||||
@@ -184,6 +411,7 @@ export function useFileManager(projectId: string) {
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
uploadPaths,
|
uploadPaths,
|
||||||
stageForDrag,
|
stageForDrag,
|
||||||
|
isStagedHostPath,
|
||||||
renameEntry,
|
renameEntry,
|
||||||
createFolder,
|
createFolder,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useState } from "react";
|
|||||||
import { save } from "@tauri-apps/plugin-dialog";
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
import type { Project } from "../lib/types";
|
import type { Project } from "../lib/types";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import { formatBytes } from "../lib/formatBytes";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import { useProjects } from "./useProjects";
|
import { useProjects } from "./useProjects";
|
||||||
import { useTerminal } from "./useTerminal";
|
import { useTerminal } from "./useTerminal";
|
||||||
@@ -122,10 +123,15 @@ export function useProjectActions(project: Project) {
|
|||||||
if (!hostPath) return;
|
if (!hostPath) return;
|
||||||
setBackingUp(true);
|
setBackingUp(true);
|
||||||
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
|
||||||
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
// `binary` matches what the host's file browser will say about the
|
||||||
|
// tarball this just wrote. The unit is part of the formatted string, so
|
||||||
|
// there is no separate " MB" to append — and unlike the inline
|
||||||
|
// `toFixed(1)` this replaced, a multi-gigabyte backup no longer reports
|
||||||
|
// itself as a five-digit number of megabytes.
|
||||||
|
const size = formatBytes(bytes, { binary: true });
|
||||||
pushToast({
|
pushToast({
|
||||||
kind: "success",
|
kind: "success",
|
||||||
message: `Backup saved (${mb} MB).`,
|
message: `Backup saved (${size}).`,
|
||||||
detail:
|
detail:
|
||||||
"Includes Claude config — may contain API keys. Keep the archive private.",
|
"Includes Claude config — may contain API keys. Keep the archive private.",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||||
|
import { dropIsBlocked, isDropTarget } from "./dropTarget";
|
||||||
|
|
||||||
|
function pane(rect: Partial<DOMRect>): HTMLElement {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.getBoundingClientRect = () =>
|
||||||
|
({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 100,
|
||||||
|
bottom: 100,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
...rect,
|
||||||
|
}) as DOMRect;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dropTarget", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a point inside the pane", () => {
|
||||||
|
expect(isDropTarget(pane({}), { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a point outside the pane", () => {
|
||||||
|
expect(isDropTarget(pane({}), { x: 400, y: 50 }, { devicePixelRatio: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts physical pixels to CSS pixels", () => {
|
||||||
|
const el = pane({});
|
||||||
|
expect(isDropTarget(el, { x: 150, y: 150 }, { devicePixelRatio: 2 })).toBe(true);
|
||||||
|
expect(isDropTarget(el, { x: 150, y: 150 }, { devicePixelRatio: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a hidden pane, which has a zero-size rect", () => {
|
||||||
|
const el = pane({ right: 0, bottom: 0, width: 0, height: 0 });
|
||||||
|
expect(isDropTarget(el, { x: 0, y: 0 }, { devicePixelRatio: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects every drop while a modal is open", () => {
|
||||||
|
const el = pane({});
|
||||||
|
const dialog = document.createElement("div");
|
||||||
|
dialog.setAttribute("role", "dialog");
|
||||||
|
dialog.setAttribute("aria-modal", "true");
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
|
expect(dropIsBlocked()).toBe(true);
|
||||||
|
expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(false);
|
||||||
|
|
||||||
|
dialog.remove();
|
||||||
|
expect(dropIsBlocked()).toBe(false);
|
||||||
|
expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects every drop while a blocking overlay is up", () => {
|
||||||
|
const el = pane({});
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.setAttribute("data-blocks-drop", "true");
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a null pane", () => {
|
||||||
|
expect(isDropTarget(null, { x: 1, y: 1 }, { devicePixelRatio: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Routing for Tauri's *native* drag-drop event.
|
||||||
|
*
|
||||||
|
* The listener is window-wide — every pane that wants dropped file paths gets
|
||||||
|
* the same event — so each one decides for itself whether the drop was meant
|
||||||
|
* for it. That decision used to be purely geometric: is the payload position
|
||||||
|
* inside my rect? A rect is not what the user sees, though. An open `Modal` is
|
||||||
|
* a `fixed inset-0` portal at `z-50` painted *over* the whole window, and the
|
||||||
|
* pane underneath still had its rect, so releasing a drag onto a dialog
|
||||||
|
* uploaded the file into the directory the dialog was covering. Same for the
|
||||||
|
* shutdown overlay, which is on screen precisely while nothing should be
|
||||||
|
* accepting work at all.
|
||||||
|
*
|
||||||
|
* So the hit test is now: nothing is covering the window, **and** the point is
|
||||||
|
* inside my rect, **and** whatever is actually painted at that point is mine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DropPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anything that swallows a drop wherever it lands.
|
||||||
|
*
|
||||||
|
* `[aria-modal="true"]` is every dialog in the app for free — `ui/Modal` is
|
||||||
|
* the only way one is built, and it sets that attribute. `data-blocks-drop`
|
||||||
|
* is for full-window overlays that are not dialogs (the shutdown overlay).
|
||||||
|
*/
|
||||||
|
const BLOCKING_SELECTOR = '[aria-modal="true"],[data-blocks-drop="true"]';
|
||||||
|
|
||||||
|
/** True while a modal or a blocking overlay is on screen. */
|
||||||
|
export function dropIsBlocked(doc: Document = document): boolean {
|
||||||
|
return doc.querySelector(BLOCKING_SELECTOR) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropTargetOptions {
|
||||||
|
doc?: Document;
|
||||||
|
/** Override the ratio used to convert physical pixels to CSS pixels. */
|
||||||
|
devicePixelRatio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a native drop at `pos` (physical pixels) belongs to `el`.
|
||||||
|
*
|
||||||
|
* A hidden pane is `display:none` and therefore has a zero-size rect, which is
|
||||||
|
* what stops two panes both claiming the same drop.
|
||||||
|
*/
|
||||||
|
export function isDropTarget(
|
||||||
|
el: HTMLElement | null | undefined,
|
||||||
|
pos: DropPoint,
|
||||||
|
options: DropTargetOptions = {},
|
||||||
|
): boolean {
|
||||||
|
const doc = options.doc ?? el?.ownerDocument ?? document;
|
||||||
|
if (dropIsBlocked(doc)) return false;
|
||||||
|
|
||||||
|
const rect = el?.getBoundingClientRect();
|
||||||
|
if (!el || !rect || rect.width === 0 || rect.height === 0) return false;
|
||||||
|
|
||||||
|
const dpr =
|
||||||
|
options.devicePixelRatio ??
|
||||||
|
(doc.defaultView?.devicePixelRatio || 1);
|
||||||
|
const x = pos.x / dpr;
|
||||||
|
const y = pos.y / dpr;
|
||||||
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Z-order, where the environment can answer it. `elementFromPoint` skips
|
||||||
|
// `pointer-events: none`, so the pane's own decorative drop hint does not
|
||||||
|
// count as something covering it. jsdom has no layout and returns null,
|
||||||
|
// which is treated as "no opinion" rather than "not mine".
|
||||||
|
if (typeof doc.elementFromPoint === "function") {
|
||||||
|
const top = doc.elementFromPoint(x, y);
|
||||||
|
if (top && top !== doc.body && top !== doc.documentElement && !el.contains(top)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -29,6 +29,22 @@ describe("formatBytes", () => {
|
|||||||
expect(formatBytes(1_610_612_736, { binary: true })).toBe("1.5 GB");
|
expect(formatBytes(1_610_612_736, { binary: true })).toBe("1.5 GB");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("absorbs the last two ad-hoc formatters, behaviour change and all", () => {
|
||||||
|
// `UpdateDialog.formatSize` and the inline `toFixed(1)` in
|
||||||
|
// `useProjectActions` both divided by 1024 and both stopped at MB. Routing
|
||||||
|
// them here is what finally makes this the *only* byte formatter, and it
|
||||||
|
// changes two things on purpose — pinned so neither reads as a regression
|
||||||
|
// to whoever meets them next.
|
||||||
|
//
|
||||||
|
// KB gains a decimal, matching every other size in the app:
|
||||||
|
expect(formatBytes(512 * 1024, { binary: true })).toBe("512.0 KB");
|
||||||
|
// and the ladder no longer bottoms out at a five-digit megabyte count:
|
||||||
|
expect(formatBytes(2 * 1024 ** 3, { binary: true })).toBe("2.0 GB");
|
||||||
|
// Sub-kilobyte sizes stop rendering as "0 KB", which is what the old
|
||||||
|
// `(bytes / 1024).toFixed(0)` said about every release asset under 512 B.
|
||||||
|
expect(formatBytes(400, { binary: true })).toBe("400 B");
|
||||||
|
});
|
||||||
|
|
||||||
it("reproduces the migration convention exactly by default", () => {
|
it("reproduces the migration convention exactly by default", () => {
|
||||||
// `migrationCopy.formatDataSize` is now a call to this, and its output is
|
// `migrationCopy.formatDataSize` is now a call to this, and its output is
|
||||||
// asserted in MigrateContainerModal.test.tsx.
|
// asserted in MigrateContainerModal.test.tsx.
|
||||||
|
|||||||
@@ -4,11 +4,16 @@
|
|||||||
* The app had four of them — `projects/home/format.ts`,
|
* The app had four of them — `projects/home/format.ts`,
|
||||||
* `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline
|
* `projects/migrationCopy.ts`, `settings/UpdateDialog.tsx` and an inline
|
||||||
* `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the
|
* `toFixed(1)` in `useProjectActions.ts` — disagreeing about the divisor, the
|
||||||
* unit labels and the precision. The first two now delegate here.
|
* unit labels and the precision. All four now delegate here, and there are no
|
||||||
|
* remaining copies.
|
||||||
*
|
*
|
||||||
* The other two deliberately do not, yet: `UpdateDialog` renders KB at
|
* The last two were held back because re-pointing them changes what they
|
||||||
* `toFixed(0)`, so re-pointing it would change what a download size reads as,
|
* render, and that turned out to be the argument for doing it rather than
|
||||||
* and neither is on the Disk panel's path. They are the remaining copies.
|
* against. `UpdateDialog` rendered KB at `toFixed(0)` (`512 KB` is now
|
||||||
|
* `512.0 KB`, consistent with every other size in the app) and both stopped
|
||||||
|
* the ladder at MB, so a 2 GB asset or backup read as a five-digit number of
|
||||||
|
* megabytes. Both are `{ binary: true }`: they describe files, and a host file
|
||||||
|
* browser shows the ÷1024 figure for the same bytes.
|
||||||
*
|
*
|
||||||
* ## Why the default is base 1000
|
* ## Why the default is base 1000
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -75,8 +75,21 @@ export const downloadContainerFile = (projectId: string, containerPath: string,
|
|||||||
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
|
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
|
||||||
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
|
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
|
||||||
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
|
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
|
||||||
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
|
/**
|
||||||
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
|
* Copy a host file into a container directory.
|
||||||
|
*
|
||||||
|
* `overwrite` is opt-in because a drop is aimed with a mouse: the backend
|
||||||
|
* refuses by default when the name is already taken (see `lib/uploadErrors.ts`
|
||||||
|
* for the marker that refusal carries), and the caller re-runs with `true`
|
||||||
|
* only once the user has said "Replace" to that specific file. Leaving it off
|
||||||
|
* is the safe default every existing caller gets.
|
||||||
|
*/
|
||||||
|
export const uploadFileToContainer = (
|
||||||
|
projectId: string,
|
||||||
|
hostPath: string,
|
||||||
|
containerDir: string,
|
||||||
|
overwrite?: boolean,
|
||||||
|
) => invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir, overwrite });
|
||||||
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
|
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
|
||||||
invoke<FileContents>("read_container_file", { projectId, path, maxBytes });
|
invoke<FileContents>("read_container_file", { projectId, path, maxBytes });
|
||||||
/** `toPath` is the new *name*, not a destination — renames never move. */
|
/** `toPath` is the new *name*, not a destination — renames never move. */
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
FILE_EXISTS_MARKER,
|
||||||
|
fileExistsPath,
|
||||||
|
isFileExistsError,
|
||||||
|
} from "./uploadErrors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shapes here are the point of the module.
|
||||||
|
*
|
||||||
|
* A Tauri command error crosses the IPC boundary as whatever `serde` made of
|
||||||
|
* it, and the Rust side is free to change from `Err(String)` to a serialised
|
||||||
|
* error enum without anyone thinking of this file. Every one of these has to
|
||||||
|
* keep meaning "that name is taken", or an upload that could have been
|
||||||
|
* retried with `overwrite: true` degrades into a raw string in a toast.
|
||||||
|
*/
|
||||||
|
describe("isFileExistsError", () => {
|
||||||
|
it("recognises the agreed prose form", () => {
|
||||||
|
expect(isFileExistsError("FILE_EXISTS: /workspace/notes.txt already exists")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises a bare marker", () => {
|
||||||
|
expect(isFileExistsError(FILE_EXISTS_MARKER)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises a serialised error enum, whatever case it is written in", () => {
|
||||||
|
expect(isFileExistsError({ kind: "FileExists", path: "/workspace/a.txt" })).toBe(true);
|
||||||
|
expect(isFileExistsError({ code: "file-exists" })).toBe(true);
|
||||||
|
expect(isFileExistsError({ type: "file_exists" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises it inside a message field", () => {
|
||||||
|
expect(isFileExistsError({ message: "upload refused: FILE_EXISTS" })).toBe(true);
|
||||||
|
expect(isFileExistsError(new Error("FILE_EXISTS: /workspace/a.txt"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("looks one level into a wrapped error", () => {
|
||||||
|
expect(isFileExistsError({ error: { kind: "FileExists" } })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says no to every other failure, which must not raise an overwrite prompt", () => {
|
||||||
|
expect(isFileExistsError("File too large to upload (900 MB; limit 256 MB)")).toBe(false);
|
||||||
|
expect(isFileExistsError("cp: cannot create regular file: Permission denied")).toBe(false);
|
||||||
|
expect(isFileExistsError({ kind: "NotRunning" })).toBe(false);
|
||||||
|
expect(isFileExistsError(null)).toBe(false);
|
||||||
|
expect(isFileExistsError(undefined)).toBe(false);
|
||||||
|
expect(isFileExistsError(42)).toBe(false);
|
||||||
|
expect(isFileExistsError({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fileExistsPath", () => {
|
||||||
|
it("reads the path out of the agreed prose form", () => {
|
||||||
|
expect(fileExistsPath("FILE_EXISTS: /workspace/notes.txt already exists")).toBe(
|
||||||
|
"/workspace/notes.txt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a structured field", () => {
|
||||||
|
expect(fileExistsPath({ kind: "FileExists", path: "/workspace/a.txt" })).toBe(
|
||||||
|
"/workspace/a.txt",
|
||||||
|
);
|
||||||
|
expect(fileExistsPath({ kind: "FileExists", container_path: "/workspace/b.txt" })).toBe(
|
||||||
|
"/workspace/b.txt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds one in a wrapped error", () => {
|
||||||
|
expect(fileExistsPath({ error: { kind: "FileExists", path: "/workspace/c.txt" } })).toBe(
|
||||||
|
"/workspace/c.txt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null rather than guessing", () => {
|
||||||
|
// The caller falls back to the host path it was uploading, which is always
|
||||||
|
// known — so "no path" is a perfectly good answer.
|
||||||
|
expect(fileExistsPath("FILE_EXISTS")).toBeNull();
|
||||||
|
expect(fileExistsPath({ kind: "FileExists" })).toBeNull();
|
||||||
|
expect(fileExistsPath(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* The one place the frontend agrees with Rust about "that name is taken".
|
||||||
|
*
|
||||||
|
* `upload_file_to_container` used to clobber whatever was already at the
|
||||||
|
* destination, which is the wrong default for a drop: a drag is aimed with a
|
||||||
|
* mouse, and the file it lands on is frequently not the file the user meant to
|
||||||
|
* replace. So the backend refuses by default and the frontend asks — but only
|
||||||
|
* if it can tell *this* refusal apart from "permission denied" or "no space
|
||||||
|
* left", because an overwrite prompt raised over an unrelated failure would
|
||||||
|
* offer a button that cannot possibly work.
|
||||||
|
*
|
||||||
|
* **This module is the contract point, and the Rust half has to hold up its
|
||||||
|
* end**: `upload_file_to_container` must put `FILE_EXISTS_MARKER` in the error
|
||||||
|
* it returns when the destination already exists, ideally in the agreed shape
|
||||||
|
*
|
||||||
|
* FILE_EXISTS: /workspace/notes.txt already exists
|
||||||
|
*
|
||||||
|
* and must accept an `overwrite: bool` argument that skips the check. Nothing
|
||||||
|
* here parses a human sentence — the marker is the whole agreement, and the
|
||||||
|
* path is a bonus that is only used to name the file in the prompt.
|
||||||
|
*
|
||||||
|
* The predicate is deliberately tolerant about the *shape* of the error rather
|
||||||
|
* than its wording, because a Tauri command error crosses the IPC boundary as
|
||||||
|
* whatever `serde` made of it: a bare string from `Err(String)`, an object from
|
||||||
|
* a `#[derive(Serialize)]` error enum, or an `Error` if a JS layer wrapped it
|
||||||
|
* on the way through. All three are the same refusal, and the UI must not
|
||||||
|
* behave differently depending on which one a future refactor produces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Marker the backend puts in the error for "a file with this name is already there". */
|
||||||
|
export const FILE_EXISTS_MARKER = "FILE_EXISTS";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured error shapes carry the marker in a discriminant rather than in
|
||||||
|
* prose. These are the field names a serialised Rust error realistically uses;
|
||||||
|
* matching is case-insensitive and ignores `_`/`-` so `FileExists`,
|
||||||
|
* `file_exists` and `FILE-EXISTS` all read as the same variant.
|
||||||
|
*/
|
||||||
|
const KIND_FIELDS = ["kind", "code", "type", "error", "reason"] as const;
|
||||||
|
const MESSAGE_FIELDS = ["message", "msg", "detail", "description"] as const;
|
||||||
|
const PATH_FIELDS = ["path", "container_path", "containerPath", "target", "file"] as const;
|
||||||
|
|
||||||
|
/** `FileExists` / `file-exists` / `FILE_EXISTS` all normalise to `fileexists`. */
|
||||||
|
function normaliseKind(value: string): string {
|
||||||
|
return value.toLowerCase().replace(/[\s_-]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_NEEDLE = normaliseKind(FILE_EXISTS_MARKER);
|
||||||
|
|
||||||
|
function asRecord(e: unknown): Record<string, unknown> | null {
|
||||||
|
return typeof e === "object" && e !== null ? (e as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every string an error carries, flattened: the error itself if it is one, its
|
||||||
|
* message-ish fields, and its kind-ish fields. Nesting is followed one level
|
||||||
|
* because a wrapped error (`{ error: { kind: … } }`) is the same refusal.
|
||||||
|
*/
|
||||||
|
function stringsIn(e: unknown, depth = 0): string[] {
|
||||||
|
if (typeof e === "string") return [e];
|
||||||
|
if (e instanceof Error) return [e.message, e.name];
|
||||||
|
const record = asRecord(e);
|
||||||
|
if (!record || depth > 1) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const field of [...KIND_FIELDS, ...MESSAGE_FIELDS]) {
|
||||||
|
const value = record[field];
|
||||||
|
if (typeof value === "string") out.push(value);
|
||||||
|
else if (value !== undefined) out.push(...stringsIn(value, depth + 1));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the backend refused an upload because the destination is taken.
|
||||||
|
*
|
||||||
|
* Accepts a bare string, an `Error`, or an object with a `kind`/`code`
|
||||||
|
* discriminant or a `message` — see the module comment for why all three have
|
||||||
|
* to work.
|
||||||
|
*/
|
||||||
|
export function isFileExistsError(e: unknown): boolean {
|
||||||
|
return stringsIn(e).some((s) => normaliseKind(s).includes(KIND_NEEDLE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The container path the conflict is about, when the error carries one — used
|
||||||
|
* only to name the file in the prompt, so `null` is a perfectly good answer
|
||||||
|
* and the caller falls back to the host path it was uploading.
|
||||||
|
*/
|
||||||
|
export function fileExistsPath(e: unknown): string | null {
|
||||||
|
const record = asRecord(e);
|
||||||
|
if (record) {
|
||||||
|
for (const field of PATH_FIELDS) {
|
||||||
|
const value = record[field];
|
||||||
|
if (typeof value === "string" && value.length > 0) return value;
|
||||||
|
}
|
||||||
|
// One level down, for `{ error: { path } }`.
|
||||||
|
for (const field of KIND_FIELDS) {
|
||||||
|
const nested = fileExistsPath(record[field]);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const s of stringsIn(e)) {
|
||||||
|
// The agreed prose form: `FILE_EXISTS: <path>` — everything up to the
|
||||||
|
// first space after the marker.
|
||||||
|
const match = new RegExp(`${FILE_EXISTS_MARKER}\\s*[:=]\\s*(\\S+)`).exec(s);
|
||||||
|
if (match) return match[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the user answered to one conflict. The blanket answers exist because a
|
||||||
|
* ten-file drop onto a populated directory is ten prompts otherwise, which is
|
||||||
|
* the kind of dialog people dismiss without reading.
|
||||||
|
*/
|
||||||
|
export type OverwriteChoice = "replace" | "skip" | "replace-all" | "skip-all";
|
||||||
@@ -225,6 +225,55 @@ describe("UrlDetector — OSC 8", () => {
|
|||||||
expect(seen).toEqual([[url, "heuristic"]]);
|
expect(seen).toEqual([[url, "heuristic"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("never hands back a truncated guess at a link it has already seen exactly", () => {
|
||||||
|
// The defect: the prompt slot is emptied (dismissed, or auto-dismissed
|
||||||
|
// after 30 s), the OSC 8 target is deduped for the session and cannot come
|
||||||
|
// back, and the next repaint — sliced at a different offset, so a *new*
|
||||||
|
// string — reassembles into a prefix of the real link that fills the empty
|
||||||
|
// slot. It parses, it points at claude.ai, and it authorises nothing.
|
||||||
|
//
|
||||||
|
// Nothing here knows the slot was emptied, and that is the point: the rule
|
||||||
|
// holds however many times it is.
|
||||||
|
const seen: [string, UrlSource][] = [];
|
||||||
|
const d = new UrlDetector((u, s) => seen.push([u, s]), () => COLS);
|
||||||
|
|
||||||
|
feed(d, "Open this link to sign in:\r\n" + slicedHyperlink(SIGN_IN_URL) + "\r\ndone\r\n");
|
||||||
|
expect(seen).toEqual([[SIGN_IN_URL, "osc8"]]);
|
||||||
|
|
||||||
|
// …the user dismisses the toast; the TUI repaints the same link as plain
|
||||||
|
// text, cut short by the frame it was painted into.
|
||||||
|
feed(d, SIGN_IN_URL.slice(0, 150) + "\r\nWaiting for the browser…\r\n");
|
||||||
|
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(seen.map(([u]) => u)).not.toContain(SIGN_IN_URL.slice(0, 150));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still offers a genuinely different link after an exact one", () => {
|
||||||
|
// The suppression is a prefix rule, not "one prompt per session".
|
||||||
|
const seen: string[] = [];
|
||||||
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||||
|
const other = "https://github.com/login/device?code=" + "x".repeat(90);
|
||||||
|
|
||||||
|
feed(d, slicedHyperlink(SIGN_IN_URL) + "\r\n");
|
||||||
|
feed(d, other + "\r\nnext\r\n");
|
||||||
|
|
||||||
|
expect(seen).toEqual([SIGN_IN_URL, other]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses a guess at a URL the consumer reported from the relay", () => {
|
||||||
|
// The OSC 7777 relay hands `TerminalView` a base64-encoded — therefore
|
||||||
|
// exact — URL that this detector never sees. `noteExactUrl` is how it gets
|
||||||
|
// told, so a dismissed relay prompt cannot be replaced by a scrape of the
|
||||||
|
// same link either.
|
||||||
|
const seen: string[] = [];
|
||||||
|
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||||
|
d.noteExactUrl(SIGN_IN_URL);
|
||||||
|
|
||||||
|
feed(d, SIGN_IN_URL.slice(0, 150) + "\r\nnext\r\n");
|
||||||
|
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores a short hyperlink", () => {
|
it("ignores a short hyperlink", () => {
|
||||||
// `ls --hyperlink` decorates every filename; none of that is a prompt.
|
// `ls --hyperlink` decorates every filename; none of that is a prompt.
|
||||||
const seen: string[] = [];
|
const seen: string[] = [];
|
||||||
|
|||||||
@@ -45,8 +45,27 @@
|
|||||||
*
|
*
|
||||||
* So each emitted candidate is tagged with where it came from, and the consumer
|
* So each emitted candidate is tagged with where it came from, and the consumer
|
||||||
* refuses to let a `heuristic` candidate displace an `osc8` one.
|
* refuses to let a `heuristic` candidate displace an `osc8` one.
|
||||||
|
*
|
||||||
|
* ## …and the exact copy keeps winning after the prompt is gone
|
||||||
|
*
|
||||||
|
* The consumer's precedence rule only compares a new candidate against what is
|
||||||
|
* *currently* in the prompt slot. Empty the slot — the user dismisses the
|
||||||
|
* toast, or its 30 s auto-dismiss fires — and it has nothing to compare
|
||||||
|
* against, so the next truncated guess walks straight in. Meanwhile the OSC 8
|
||||||
|
* target is deduped for the life of the session and cannot come back to
|
||||||
|
* displace it. The user is then holding a URL that parses, points at
|
||||||
|
* claude.ai, and authorises nothing, which is the exact bug the OSC 8 branch
|
||||||
|
* was added to kill.
|
||||||
|
*
|
||||||
|
* That is fixed *here* rather than in the consumer, because this is the side
|
||||||
|
* that knows both halves: {@link UrlDetector} remembers every exact URL it has
|
||||||
|
* seen and refuses to emit a heuristic candidate that is a strict prefix of
|
||||||
|
* one — see `truncatesKnownExact`. The rule then holds however often the slot
|
||||||
|
* is emptied, and needs no cooperation from whoever owns it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { extendsUrl } from "./urlRelay";
|
||||||
|
|
||||||
const ANSI_RE =
|
const ANSI_RE =
|
||||||
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
|
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
|
||||||
|
|
||||||
@@ -196,6 +215,21 @@ export class UrlDetector {
|
|||||||
/** OSC 8 targets already offered, so a hyperlink repainted every frame does
|
/** OSC 8 targets already offered, so a hyperlink repainted every frame does
|
||||||
* not re-prompt. Bounded by {@link MAX_REMEMBERED_LINKS}. */
|
* not re-prompt. Bounded by {@link MAX_REMEMBERED_LINKS}. */
|
||||||
private emittedLinks = new Set<string>();
|
private emittedLinks = new Set<string>();
|
||||||
|
/**
|
||||||
|
* Every *exact* URL this session has seen — OSC 8 parameters, plus whatever
|
||||||
|
* the consumer reports through {@link noteExactUrl} (the OSC 7777 relay).
|
||||||
|
*
|
||||||
|
* Kept separately from `emittedLinks` because the two answer different
|
||||||
|
* questions: that one is "have I already prompted for this?", this one is "do
|
||||||
|
* I know the full text of a link some guess might be a prefix of?". The
|
||||||
|
* second answer must survive the prompt being dismissed; the whole defect is
|
||||||
|
* that a truncated guess fills the slot the moment it is empty.
|
||||||
|
*
|
||||||
|
* Bounded the same way, and cleared wholesale rather than evicted one by one:
|
||||||
|
* a program printing a fresh hyperlink every frame is not a program whose
|
||||||
|
* older links are still on screen to be mis-scraped.
|
||||||
|
*/
|
||||||
|
private exactUrls = new Set<string>();
|
||||||
|
|
||||||
constructor(callback: UrlCallback, columns: ColumnsGetter) {
|
constructor(callback: UrlCallback, columns: ColumnsGetter) {
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
@@ -285,7 +319,7 @@ export class UrlDetector {
|
|||||||
|
|
||||||
// 6. URL is clearly complete (more content follows) — dedup + emit
|
// 6. URL is clearly complete (more content follows) — dedup + emit
|
||||||
this.pendingUrl = null;
|
this.pendingUrl = null;
|
||||||
if (url !== this.lastEmitted) {
|
if (url !== this.lastEmitted && !this.truncatesKnownExact(url)) {
|
||||||
this.lastEmitted = url;
|
this.lastEmitted = url;
|
||||||
this.callback(url, "heuristic");
|
this.callback(url, "heuristic");
|
||||||
}
|
}
|
||||||
@@ -304,10 +338,23 @@ export class UrlDetector {
|
|||||||
* `lastEmitted` is moved along with them so an identical string arriving on
|
* `lastEmitted` is moved along with them so an identical string arriving on
|
||||||
* the heuristic path a moment later is recognised as the same candidate
|
* the heuristic path a moment later is recognised as the same candidate
|
||||||
* rather than fired a second time.
|
* rather than fired a second time.
|
||||||
|
*
|
||||||
|
* Every target is remembered as exact whether or not it is offered — a
|
||||||
|
* hyperlink repainted a second time is the same known link, and the dedup
|
||||||
|
* that stops it re-prompting must not also stop it counting as something a
|
||||||
|
* later guess can be a truncation of.
|
||||||
|
*
|
||||||
|
* The alternative fix considered here was to make this dedup *releasable*,
|
||||||
|
* so the consumer could hand the exact URL back and have it re-offered once
|
||||||
|
* the prompt slot emptied. Rejected: it re-offers on the very next repaint,
|
||||||
|
* so dismissing the toast would put it straight back on screen — and it
|
||||||
|
* still would not establish the invariant, because a truncated guess and the
|
||||||
|
* released exact URL would simply race for the empty slot.
|
||||||
*/
|
*/
|
||||||
private scanLinks(): void {
|
private scanLinks(): void {
|
||||||
for (const uri of osc8Targets(this.buffer)) {
|
for (const uri of osc8Targets(this.buffer)) {
|
||||||
if (uri.length < MIN_URL_LENGTH) continue;
|
if (uri.length < MIN_URL_LENGTH) continue;
|
||||||
|
this.rememberExact(uri);
|
||||||
if (this.emittedLinks.has(uri)) continue;
|
if (this.emittedLinks.has(uri)) continue;
|
||||||
if (this.emittedLinks.size >= MAX_REMEMBERED_LINKS) {
|
if (this.emittedLinks.size >= MAX_REMEMBERED_LINKS) {
|
||||||
this.emittedLinks.clear();
|
this.emittedLinks.clear();
|
||||||
@@ -319,13 +366,56 @@ export class UrlDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private emitPending(): void {
|
private emitPending(): void {
|
||||||
if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) {
|
if (
|
||||||
|
this.pendingUrl &&
|
||||||
|
this.pendingUrl !== this.lastEmitted &&
|
||||||
|
!this.truncatesKnownExact(this.pendingUrl)
|
||||||
|
) {
|
||||||
this.lastEmitted = this.pendingUrl;
|
this.lastEmitted = this.pendingUrl;
|
||||||
this.callback(this.pendingUrl, "heuristic");
|
this.callback(this.pendingUrl, "heuristic");
|
||||||
}
|
}
|
||||||
this.pendingUrl = null;
|
this.pendingUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `url` is a strict prefix of an exact URL already seen — i.e. a
|
||||||
|
* truncated guess at a link whose full text is known.
|
||||||
|
*
|
||||||
|
* {@link extendsUrl} is the predicate, used in the direction that asks "does
|
||||||
|
* the link I already have *extend* this guess?". It is the same rule the
|
||||||
|
* prompt slot uses to let a candidate grow into its complete form, which is
|
||||||
|
* the point: the two must agree about what "the same link, only shorter"
|
||||||
|
* means, so there is one implementation of it.
|
||||||
|
*
|
||||||
|
* Deliberately *not* symmetric. A candidate that is longer than a known exact
|
||||||
|
* URL and starts with it is a different problem (text glued onto the end by a
|
||||||
|
* wrap that was not a wrap), and it is still shown in full and confirmed by
|
||||||
|
* the user before anything opens.
|
||||||
|
*/
|
||||||
|
private truncatesKnownExact(url: string): boolean {
|
||||||
|
for (const exact of this.exactUrls) {
|
||||||
|
if (extendsUrl(exact, url)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a URL that arrived somewhere exact, outside this detector.
|
||||||
|
*
|
||||||
|
* The OSC 7777 relay hands `TerminalView` a base64-encoded URL — exact by
|
||||||
|
* construction, and never seen here. Without this the suppression rule above
|
||||||
|
* would cover hyperlinks and miss the relay, and a dismissed relay prompt
|
||||||
|
* could still be replaced by a truncated scrape of the same link.
|
||||||
|
*/
|
||||||
|
noteExactUrl(url: string): void {
|
||||||
|
this.rememberExact(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberExact(url: string): void {
|
||||||
|
if (this.exactUrls.size >= MAX_REMEMBERED_LINKS) this.exactUrls.clear();
|
||||||
|
this.exactUrls.add(url);
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this.timer !== null) {
|
if (this.timer !== null) {
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
|
|||||||
Reference in New Issue
Block a user