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

This commit is contained in:
2026-08-23 13:15:02 -07:00
16 changed files with 1050 additions and 101 deletions
@@ -394,15 +394,83 @@ describe("FilesTab host drag-and-drop", () => {
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("divides the payload position by devicePixelRatio", async () => {
// The native payload is in physical pixels; the rect is in CSS pixels.
// At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside an 800x600 pane.
const original = window.devicePixelRatio;
it("divides the payload position by devicePixelRatio on Windows only", async () => {
// Only wry's WebView2 backend hands over *physical* pixels; the macOS and
// GTK ones deliver logical points and `tauri-runtime-wry` does not rescale
// them. At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside the
// 800x600 pane — but the same payload on a HiDPI Mac or Linux box really
// is (900, 900) and belongs to nobody.
const originalDpr = window.devicePixelRatio;
const originalUa = window.navigator.userAgent;
Object.defineProperty(window, "devicePixelRatio", { value: 2, configurable: true });
Object.defineProperty(window.navigator, "userAgent", {
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
configurable: true,
});
await renderTab();
await drop(["/host/a.png"], { x: 900, y: 900 });
expect(uploadFileToContainer).toHaveBeenCalled();
Object.defineProperty(window, "devicePixelRatio", { value: original, configurable: true });
Object.defineProperty(window.navigator, "userAgent", {
value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
configurable: true,
});
vi.mocked(uploadFileToContainer).mockClear();
await drop(["/host/a.png"], { x: 900, y: 900 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
// …and the *unhalved* point still lands, which is the half a HiDPI Mac
// user was losing.
await drop(["/host/a.png"], { x: 400, y: 300 });
expect(uploadFileToContainer).toHaveBeenCalled();
Object.defineProperty(window, "devicePixelRatio", {
value: originalDpr,
configurable: true,
});
Object.defineProperty(window.navigator, "userAgent", {
value: originalUa,
configurable: true,
});
});
it("accepts a drop that lands on a toast floating over the pane", async () => {
// `ToastHost` is `fixed bottom-4 right-4 z-[60]` and 24rem wide, and its
// error cards stay until dismissed — so a z-order gate asking "is what is
// painted here part of my pane?" made the bottom-right corner of this pane
// refuse drops for as long as one error was on screen. jsdom has no
// `elementFromPoint`, so that branch only runs when a test supplies one.
await renderTab();
const toastCard = document.createElement("div");
document.body.appendChild(toastCard);
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => toastCard,
});
await drop(["/host/a.png"], { x: 700, y: 550 });
expect(uploadFileToContainer).toHaveBeenCalled();
delete (document as Partial<Document>).elementFromPoint;
toastCard.remove();
});
it("refuses a drop that lands on a dialog painted over the pane", async () => {
await renderTab();
const backdrop = document.createElement("div");
backdrop.setAttribute("data-blocks-drop", "true");
document.body.appendChild(backdrop);
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => backdrop,
});
await drop(["/host/a.png"], { x: 400, y: 300 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
delete (document as Partial<Document>).elementFromPoint;
backdrop.remove();
});
it("highlights the pane while a drag hovers it, and drops the highlight on leave", async () => {
+23 -4
View File
@@ -3,7 +3,7 @@ import { getCurrentWebview } from "@tauri-apps/api/webview";
import { startDrag } from "@crabnebula/tauri-plugin-drag";
import type { FileEntry, Project } from "../../../lib/types";
import { useFileManager } from "../../../hooks/useFileManager";
import { isDropTarget } from "../../../lib/dropTarget";
import { classifyDrop, isDropTarget } from "../../../lib/dropTarget";
import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import FileViewerModal from "./FileViewerModal";
@@ -413,8 +413,11 @@ export default function FilesTab({ project }: Props) {
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
// it), which blocks HTML5 drag inside the webview on Windows, and only the
// native payload carries real file *paths*. The listener is window-wide, so
// routing is `isDropTarget` — the rect hit test, in CSS pixels, *plus* the
// z-order and "is anything modal on screen" questions a rect cannot answer.
// routing is `classifyDrop` — the rect hit test *plus* the z-order question
// a rect cannot answer: is a modal or a blocking overlay painted at that
// point? (Not "is that element mine": chrome painted over a pane — a toast,
// a button — is not something that swallows a drop, and treating it as such
// made permanent dead zones.)
//
// Two further filters sit in front of it, both about our own drag-out:
// `dragOutInFlight`, and the staged-path check, which is exact because
@@ -440,7 +443,23 @@ export default function FilesTab({ project }: Props) {
if (payload.type !== "drop") return;
setDragOver(false);
if (dragOutInFlight.current) return;
if (!isDropTarget(paneRef.current, payload.position)) return;
const verdict = classifyDrop(paneRef.current, payload.position);
// Aimed at this pane and refused anyway: say so. Nothing else would —
// the file just never appears in the listing.
if (verdict === "blocked") {
console.warn(
"[drop] refused: an overlay is covering the drop point",
payload.position,
);
useAppState.getState().pushToast({
kind: "info",
message: "File drop ignored",
detail:
"A dialog or full-window overlay is covering that point. Close it and drop the file again.",
});
return;
}
if (verdict !== "accept") return;
// Anything we staged for a drag-out is our own copy of a file that is
// already in the container; re-importing it would overwrite the
// original with a snapshot.
@@ -943,6 +943,65 @@ describe("DiskSettings", () => {
expect(within(dialog).getByRole("alert")).toHaveTextContent(/no space left on device/);
});
it("keeps the semi-safe confirmation open, in the backend's words, when it is refused", async () => {
// A refusal comes back inside `Ok` — the command succeeded at declining —
// so it never reaches `error`, and reading only "did it throw" closed the
// dialog, dropped the tick list, and left the explanation in the outcome
// panel several screens above the row that was clicked. The sentence shown
// is the backend's own: it is the only side that knows which blocker is
// actually holding the project.
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.mockResolvedValue({
results: [
{
target: { kind: "compact_snapshot", project_id: "p-whp" },
destroyed: null,
ok: false,
freed_bytes: 0,
projected_bytes: null,
message: "Cannot compact whp: a terminal session is still attached.",
} as ReclaimResult,
],
total_freed_bytes: 0,
});
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(
/a terminal session is still attached/,
);
// Nothing was removed, so the row that offered the action is still there.
expect(
within(await screen.findByTestId("disk-semi-bucket")).getByRole("button", {
name: "Run…",
}),
).toBeInTheDocument();
});
it("closes the confirmation once the action succeeds", async () => {
listReclaimable.mockResolvedValue(
plan({
+21 -2
View File
@@ -158,6 +158,18 @@ export default function DiskSettings() {
// five targets can come back with two failures and a real byte total.
const failedCount = outcome?.results.filter((r) => !r.ok).length ?? 0;
// What the backend said about the parts it refused, rendered **verbatim**.
// A refusal arrives inside `Ok` — the command succeeded at declining — so it
// never reaches `error`, and the dialog that asked for the work has nothing
// else to show. Not a sentence of our own: the backend is the only side that
// knows which blocker is actually holding the project, and one written here
// would go stale the day that answer improves.
const refusalText =
outcome?.results
.filter((r) => !r.ok)
.map((r) => r.message)
.join(" ") ?? "";
const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off";
const statusLabel = scanning
? "Scanning"
@@ -706,6 +718,9 @@ export default function DiskSettings() {
// minutes, and the dialog reporting it beats it vanishing —
// and if it fails, the dialog is the only place the user is
// still looking, so it stays open and reports it here.
// `false` covers both a throw and a refusal that came back
// inside `Ok`; either way the work did not happen, so the
// dialog stays put and reports it where the user is looking.
const ok = await runReclaim([confirming.target]);
setActionFailed(!ok);
if (ok) setConfirming(null);
@@ -721,7 +736,7 @@ export default function DiskSettings() {
line, which this dialog is covering. */}
{actionFailed && (
<p role="alert" className="text-[var(--error)]">
{error ?? "That did not run. Nothing was changed."}
{error ?? (refusalText || "That did not run. Nothing was changed.")}
</p>
)}
<p>{confirming.detail}</p>
@@ -787,7 +802,11 @@ export default function DiskSettings() {
// 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}
error={
actionFailed
? (error ?? (refusalText || "That did not run. Nothing was deleted."))
: null
}
onCancel={closeDestroying}
onConfirm={async (typed) => {
// The modal stays mounted until the call settles, so its `busy`
@@ -266,12 +266,17 @@ describe("supersedes — who owns the prompt slot", () => {
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. */
* as a hidden pane.
*
* The rect goes on the *pane wrapper*, which is what the hit test asks
* about: it is what the user sees as the terminal (gutter included), and
* the chrome painted over it — the Following toggle, the URL toast — are
* its children rather than the xterm host's. */
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");
const pane = view.container.firstElementChild as HTMLElement | null;
if (!pane) throw new Error("terminal pane not found");
pane.getBoundingClientRect = () =>
({
left: 0,
@@ -287,6 +292,17 @@ describe("TerminalView — where a dropped file lands", () => {
return view;
}
/** jsdom has no `elementFromPoint`, so the z-order branch is unreachable
* unless a test supplies one — which is exactly how a gate that refused
* every drop under the Following toggle shipped through this file green. */
function stubElementFromPoint(top: Element | null) {
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => top,
});
}
async function drop(x: number, y: number) {
if (!dragDrop.handler) throw new Error("no drag-drop listener registered");
await act(async () => {
@@ -333,6 +349,47 @@ describe("TerminalView — where a dropped file lands", () => {
await drop(400, 300);
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
});
it("uploads a file dropped onto the always-present Following toggle", async () => {
// The regression this file could not see. The toggle is `absolute top-2
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint`
// returns *it* for the terminal's top-right corner — and a gate asking
// "is what is painted here inside the xterm host?" answered no, forever,
// with no message and no log line. jsdom never ran that branch.
const view = await mountWithLayout();
const toggle = view.getByTitle(/Auto-scroll/i);
stubElementFromPoint(toggle);
await drop(780, 10);
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledWith(
"s1",
"/host/dropped.txt",
);
delete (document as Partial<Document>).elementFromPoint;
});
it("refuses — and says so — when a dialog is painted over the drop point", async () => {
await mountWithLayout();
const backdrop = document.createElement("div");
backdrop.setAttribute("data-blocks-drop", "true");
const panel = document.createElement("div");
panel.setAttribute("aria-modal", "true");
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
stubElementFromPoint(backdrop);
await drop(400, 300);
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
// A refused drop is otherwise indistinguishable from a broken one.
expect(
useAppState.getState().toasts.some((t) => t.message === "File drop ignored"),
).toBe(true);
backdrop.remove();
delete (document as Partial<Document>).elementFromPoint;
});
});
describe("TerminalView — reaching the URL prompt without a mouse", () => {
+40 -10
View File
@@ -21,7 +21,7 @@ import {
parseUrlRelayOsc,
sanitizeRelayUrl,
} from "../../lib/urlRelay";
import { isDropTarget } from "../../lib/dropTarget";
import { classifyDrop } from "../../lib/dropTarget";
import UrlToast, {
URL_TOAST_PRIMARY_SELECTOR,
URL_TOAST_SELECTOR,
@@ -237,14 +237,22 @@ export default function TerminalView({ sessionId, active }: Props) {
//
// The listener is window-wide, so every pane decides for itself whether a
// drop was meant for it. `isDropTarget` is that decision, shared with the
// Files pane: the physical-pixel position ÷ `devicePixelRatio` against this
// pane's rect (a hidden pane is `display:none`, so its zero-size rect is what
// stops two panes both claiming the drop), plus z-order — which a rect alone
// cannot see. An open `Modal` is a `fixed inset-0` portal painted *over* the
// window and the pane underneath still has its rect, so the geometric test
// that used to live here uploaded files into the directory a dialog was
// covering. Same for the shutdown overlay, which is on screen precisely while
// nothing should be accepting work at all.
// Files pane: the payload position 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.
//
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
// the pane is what the user sees as "the terminal", gutter included, and the
// chrome painted over it (the Following toggle, the URL toast) is a sibling
// of the host rather than a child. `classifyDrop` answers "is a *blocking
// overlay* here?" rather than "is this element mine?" for the same reason —
// asking the second question turned every pixel under that chrome into a
// permanent dead zone.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
@@ -257,7 +265,29 @@ export default function TerminalView({ sessionId, active }: Props) {
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== "drop") return;
if (!isDropTarget(containerRef.current, event.payload.position)) return;
const verdict = classifyDrop(
terminalContainerRef.current,
event.payload.position,
);
// A refused drop is invisible — the file simply does not arrive — so
// the one case where the user aimed at us and we said no gets both a
// log line and something on screen. The toast, not `imagePasteMsg`:
// whatever refused this is painted over the terminal, and `ToastHost`
// sits above it.
if (verdict === "blocked") {
console.warn(
"[drop] refused: an overlay is covering the drop point",
event.payload.position,
);
useAppState.getState().pushToast({
kind: "info",
message: "File drop ignored",
detail:
"A dialog or full-window overlay is covering that point. Close it and drop the file again.",
});
return;
}
if (verdict !== "accept") return;
const paths = event.payload.paths ?? [];
if (paths.length === 0) return;
+60
View File
@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import Modal from "./Modal";
import { PaneVisibilityProvider } from "./PaneVisibility";
import { dropIsBlocked } from "../../lib/dropTarget";
/**
* Modal focuses asynchronously via rAF so the panel is laid out first; jsdom
@@ -102,6 +104,64 @@ describe("Modal", () => {
expect(container).toBeTruthy();
});
// -------------------------------------------------------------------------
// Stepping aside with the pane that owns it
// -------------------------------------------------------------------------
it("marks its backdrop as swallowing native file drops", async () => {
// The backdrop, not the panel, is what `elementFromPoint` returns for a
// drop released beside the dialog — so it is the element that has to carry
// the marker `lib/dropTarget` looks for.
render(
<Modal title="Reset" onClose={vi.fn()}>
<p>body</p>
</Modal>,
);
const backdrop = document.querySelector(".fixed.inset-0");
expect(backdrop).toHaveAttribute("data-blocks-drop", "true");
expect(dropIsBlocked()).toBe(true);
});
it("paints nothing, traps nothing and blocks no drop while its pane is hidden", async () => {
// A dialog portals to `document.body`, where the `hidden` class its pane
// uses to step aside for another tab cannot reach it. Left to itself it
// stayed on screen over the tab the user switched to, kept its Escape
// binding, and refused every native file drop in the window.
const onClose = vi.fn();
const { rerender } = render(
<PaneVisibilityProvider visible={false}>
<Modal title="Reset" onClose={onClose}>
<button>Confirm</button>
</Modal>
</PaneVisibilityProvider>,
);
await flushFocus();
const backdrop = document.querySelector(".fixed.inset-0") as HTMLElement;
expect(backdrop.hidden).toBe(true);
expect(backdrop.style.display).toBe("none");
expect(dropIsBlocked()).toBe(false);
expect(backdrop.contains(document.activeElement)).toBe(false);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled();
// Back on screen: the same dialog, still mounted, resumes everything.
rerender(
<PaneVisibilityProvider visible={true}>
<Modal title="Reset" onClose={onClose}>
<button>Confirm</button>
</Modal>
</PaneVisibilityProvider>,
);
await flushFocus();
expect(backdrop.hidden).toBe(false);
expect(dropIsBlocked()).toBe(true);
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("ignores Escape and overlay clicks when not dismissible", async () => {
const onClose = vi.fn();
render(
+40 -13
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { usePaneVisible } from "./PaneVisibility";
const FOCUSABLE_SELECTOR = [
"a[href]",
@@ -66,26 +67,43 @@ export default function Modal({
const restoreFocusRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const descId = useId();
// A dialog portals to `document.body`, so the `hidden` class its pane uses to
// step aside for another tab cannot reach it. `PaneVisibility` is how it
// finds out, and while it is false this dialog paints nothing, traps
// nothing, and — via `[hidden]` — blocks no native file drop.
const paneVisible = usePaneVisible();
const paneVisibleRef = useRef(paneVisible);
paneVisibleRef.current = paneVisible;
// Remember what had focus, move focus inside, restore on unmount.
// Remember what had focus, and restore it on unmount — but not if the pane
// is hidden by then: a dialog closed while the user is on another tab would
// otherwise yank focus back to a control they cannot see.
useEffect(() => {
restoreFocusRef.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current;
if (panel) {
const target =
initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel;
// Defer so the panel is laid out (offsetParent) before we query it.
requestAnimationFrame(() => target.focus?.());
}
return () => {
restoreFocusRef.current?.focus?.();
if (paneVisibleRef.current) restoreFocusRef.current?.focus?.();
};
// Mount/unmount only — re-running would steal focus mid-interaction.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Escape closes; Tab is trapped inside the panel.
// Move focus inside — on mount, and again whenever the pane comes back.
useEffect(() => {
if (!paneVisible) return;
const panel = panelRef.current;
if (!panel) return;
const target = initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel;
// Defer so the panel is laid out (offsetParent) before we query it.
const frame = requestAnimationFrame(() => target.focus?.());
return () => cancelAnimationFrame(frame);
// `initialFocusRef` is a ref object; re-running on its identity would steal
// focus mid-interaction.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paneVisible]);
// Escape closes; Tab is trapped inside the panel. Neither applies while the
// pane is hidden — those keystrokes belong to whatever the user is looking
// at instead.
useEffect(() => {
if (!paneVisible) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && dismissible) {
e.stopPropagation();
@@ -119,7 +137,7 @@ export default function Modal({
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [dismissible, onClose]);
}, [dismissible, onClose, paneVisible]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -133,6 +151,15 @@ export default function Modal({
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"
/* The backdrop, not the panel, is what `elementFromPoint` returns for a
drop released beside the dialog — so it is the element that has to say
"I swallow drops". See `lib/dropTarget.ts`. */
data-blocks-drop="true"
hidden={!paneVisible}
aria-hidden={paneVisible ? undefined : true}
/* `hidden` is a base-layer rule and `flex` is a utility-layer one, so the
attribute alone loses. Inline wins over both. */
style={paneVisible ? undefined : { display: "none" }}
>
<div
ref={panelRef}
+40
View File
@@ -0,0 +1,40 @@
import { createContext, useContext, type ReactNode } from "react";
/**
* "Is the pane I belong to the one on screen?"
*
* The main area keeps every tab *mounted* and hides the inactive ones with a
* `hidden` class, so their state survives a tab switch. A `ui/Modal` opened
* inside one of those panes does not go quiet when its pane does: it portals
* to `document.body`, where an ancestor's `display:none` cannot reach it. So a
* dialog opened in project A stayed painted over project B after a tab switch,
* kept its focus trap and its Escape binding, and — because it is a blocking
* overlay — refused every native file drop in the window.
*
* `App` publishes the answer around each pane it mounts — it is what decides
* which one is on screen — and `Modal` reads it. Nothing else needs to:
* dialogs are the only thing in the app that escapes its pane's subtree.
*
* Default `true`, so a dialog with no pane above it — host settings, the
* Docker install prompt — behaves exactly as it always has.
*/
const PaneVisibilityContext = createContext(true);
export function PaneVisibilityProvider({
visible,
children,
}: {
visible: boolean;
children: ReactNode;
}) {
return (
<PaneVisibilityContext.Provider value={visible}>
{children}
</PaneVisibilityContext.Provider>
);
}
/** True unless an ancestor pane says it is currently hidden. */
export function usePaneVisible(): boolean {
return useContext(PaneVisibilityContext);
}