Merge branch 'r3/drop' into ship/core

This commit is contained in:
2026-08-23 15:48:19 -07:00
10 changed files with 446 additions and 246 deletions
@@ -381,11 +381,13 @@ describe("FilesTab host drag-and-drop", () => {
});
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.
// Round 1. `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 ran when a test supplied one;
// the gate no longer asks, and this pins that nothing painted over a pane
// can refuse a drop on its own account.
await renderTab();
const toastCard = document.createElement("div");
document.body.appendChild(toastCard);
@@ -402,21 +404,37 @@ describe("FilesTab host drag-and-drop", () => {
toastCard.remove();
});
it("refuses a drop that lands on a dialog painted over the pane", async () => {
it("refuses a drop while a dialog is open, toast painted over it or not", async () => {
// Round 2, which is the reason this file exists in its current shape. The
// refusal pushes a toast; `ToastHost` is `z-[60]` and the `Modal` backdrop
// is `z-50` in the same stacking context, so the *toast* becomes the
// topmost element over a covered pane. A gate that asked `elementFromPoint`
// "is a blocker painted here?" then answered no and uploaded into the
// directory the dialog was covering — one refused drop was all it took to
// open the hole. Both stubs below therefore have to be refused.
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,
});
const toastCard = document.createElement("div"); // z-[60], above the backdrop
document.body.appendChild(toastCard);
const stub = (top: Element) =>
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => top,
});
stub(backdrop);
await drop(["/host/a.png"], { x: 400, y: 300 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
stub(toastCard);
await drop(["/host/a.png"], { x: 700, y: 550 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
delete (document as Partial<Document>).elementFromPoint;
toastCard.remove();
backdrop.remove();
});
+8 -16
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import type { FileEntry, Project } from "../../../lib/types";
import { useFileManager } from "../../../hooks/useFileManager";
import { classifyDrop, isDropTarget } from "../../../lib/dropTarget";
import { classifyDrop, isDropTarget, DROP_BLOCKED_TOAST } from "../../../lib/dropTarget";
import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import FileViewerModal from "./FileViewerModal";
@@ -240,11 +240,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 `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.)
// routing is `classifyDrop` — the rect hit test, which says *whose* drop it
// is, plus the document-wide question a rect cannot answer: is a modal or a
// blocking overlay on screen at all? That second half is deliberately not a
// per-point z-order test; `lib/dropTarget.ts` records the two ways that went
// wrong.
useEffect(() => {
if (!running) return;
let unlisten: (() => void) | undefined;
@@ -267,16 +267,8 @@ export default function FilesTab({ project }: Props) {
// 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.",
});
console.warn("[drop] refused: a dialog or overlay is open", payload.position);
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
return;
}
if (verdict !== "accept") return;
@@ -292,9 +292,12 @@ describe("TerminalView — where a dropped file lands", () => {
return view;
}
/** jsdom has no `elementFromPoint`, so the z-order branch is unreachable
/** jsdom has no `elementFromPoint`, so a 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. */
* every drop under the Following toggle shipped through this file green.
* The gate asks no per-point question any more, but the tests below still
* install one and feed it the most misleading answer available, to pin
* that the routing does not change when it is there. */
function stubElementFromPoint(top: Element | null) {
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
@@ -369,7 +372,8 @@ describe("TerminalView — where a dropped file lands", () => {
delete (document as Partial<Document>).elementFromPoint;
});
it("refuses — and says so — when a dialog is painted over the drop point", async () => {
it("refuses — and says so — while a dialog is open", async () => {
useAppState.setState({ toasts: [] });
await mountWithLayout();
const backdrop = document.createElement("div");
backdrop.setAttribute("data-blocks-drop", "true");
@@ -383,13 +387,52 @@ describe("TerminalView — where a dropped file lands", () => {
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);
const notice = useAppState
.getState()
.toasts.find((t) => t.message === "File drop ignored");
expect(notice).toBeTruthy();
// Not an error: the user has a dialog open, which is a state they chose
// and can leave with Escape. An error card would sit there until
// dismissed, and `ToastHost` paints at `z-[60]`.
expect(notice?.kind).toBe("info");
backdrop.remove();
delete (document as Partial<Document>).elementFromPoint;
});
it("keeps refusing when the refusal's own toast is painted over the dialog", async () => {
// C1, end to end. Refusing pushes a toast; `ToastHost` is `fixed
// bottom-4 right-4 z-[60]` and the `Modal` backdrop is `z-50` in the same
// stacking context — so the toast is the topmost element over the covered
// pane, and a gate that asked "is a blocker painted here?" answered no and
// uploaded into the directory the dialog was covering. The gate had armed
// its own hole: one refused drop was all it took to open it.
useAppState.setState({ toasts: [] });
await mountWithLayout();
const backdrop = document.createElement("div");
backdrop.setAttribute("data-blocks-drop", "true");
document.body.appendChild(backdrop);
stubElementFromPoint(backdrop);
await drop(400, 300);
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
expect(useAppState.getState().toasts).toHaveLength(1);
// The toast is now on screen, above the backdrop, and the user drops again
// on the very point it occupies.
const toastCard = document.createElement("div");
document.body.appendChild(toastCard);
stubElementFromPoint(toastCard);
await drop(700, 550);
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
// …and a second refusal replaces the first notice rather than stacking.
expect(useAppState.getState().toasts).toHaveLength(1);
toastCard.remove();
backdrop.remove();
delete (document as Partial<Document>).elementFromPoint;
});
});
describe("TerminalView — reaching the URL prompt without a mouse", () => {
+15 -21
View File
@@ -21,7 +21,7 @@ import {
parseUrlRelayOsc,
sanitizeRelayUrl,
} from "../../lib/urlRelay";
import { classifyDrop } from "../../lib/dropTarget";
import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
import UrlToast, {
URL_TOAST_PRIMARY_SELECTOR,
URL_TOAST_SELECTOR,
@@ -236,23 +236,22 @@ export default function TerminalView({ sessionId, active }: Props) {
// onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
//
// 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 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.
// drop was meant for it. `classifyDrop` is that decision, shared with the
// Files pane, and it asks two things in order: is the payload position
// inside this pane's rect (a hidden pane is `display:none`, so its zero-size
// rect is what stops two panes both claiming the drop), and — document-wide,
// with no geometry — is a modal or blocking overlay on screen at all? An
// open `Modal` is a `fixed inset-0` portal painted *over* the window and the
// pane underneath still has its rect, so a rect alone uploaded files into
// the directory a dialog was covering. See `lib/dropTarget.ts` for why the
// blocking half is deliberately not a per-point z-order test.
//
// 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.
// of the host rather than a child. Nothing painted over the pane refuses a
// drop on its own account — asking "is this element mine?" once turned every
// pixel under that chrome into a permanent dead zone.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
@@ -276,15 +275,10 @@ export default function TerminalView({ sessionId, active }: Props) {
// sits above it.
if (verdict === "blocked") {
console.warn(
"[drop] refused: an overlay is covering the drop point",
"[drop] refused: a dialog or overlay is open",
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.",
});
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
return;
}
if (verdict !== "accept") return;
+47 -3
View File
@@ -109,9 +109,9 @@ describe("Modal", () => {
// -------------------------------------------------------------------------
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.
// `lib/dropTarget` refuses every drop in the window while a dialog is on
// screen, and this marker is half of how it knows one is (the panel's
// `aria-modal` is the other half).
render(
<Modal title="Reset" onClose={vi.fn()}>
<p>body</p>
@@ -141,12 +141,16 @@ describe("Modal", () => {
expect(backdrop.hidden).toBe(true);
expect(backdrop.style.display).toBe("none");
expect(dropIsBlocked()).toBe(false);
// Two independent reasons it does not block, because they are maintained
// in two files: the marker is gone *and* `[hidden]` disqualifies it.
expect(backdrop.hasAttribute("data-blocks-drop")).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.
// (Including the marker: it is dropped on hide, not written once at mount.)
rerender(
<PaneVisibilityProvider visible={true}>
<Modal title="Reset" onClose={onClose}>
@@ -162,6 +166,46 @@ describe("Modal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not leave focus inside itself when its pane steps aside", async () => {
// The dialog goes `display:none` with the keyboard focus still inside it,
// and nothing else relocates it — so the user arrives on the tab they
// switched to with focus held by a dialog they cannot see, Tab resuming
// from inside it. jsdom does not blur on `display:none` either, so this
// is exactly the state the assertion below describes.
const { rerender } = render(
<PaneVisibilityProvider visible={true}>
<Modal title="Reset" onClose={vi.fn()}>
<button>Confirm</button>
</Modal>
</PaneVisibilityProvider>,
);
await flushFocus();
const panel = screen.getByRole("dialog");
expect(panel.contains(document.activeElement)).toBe(true);
rerender(
<PaneVisibilityProvider visible={false}>
<Modal title="Reset" onClose={vi.fn()}>
<button>Confirm</button>
</Modal>
</PaneVisibilityProvider>,
);
await flushFocus();
expect(panel.contains(document.activeElement)).toBe(false);
expect(document.activeElement).toBe(document.body);
// …and coming back puts it where it was: inside the dialog.
rerender(
<PaneVisibilityProvider visible={true}>
<Modal title="Reset" onClose={vi.fn()}>
<button>Confirm</button>
</Modal>
</PaneVisibilityProvider>,
);
await flushFocus();
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true);
});
it("ignores Escape and overlay clicks when not dismissible", async () => {
const onClose = vi.fn();
render(
+16 -7
View File
@@ -70,7 +70,7 @@ export default function Modal({
// 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.
// nothing, holds no focus, and blocks no native file drop.
const paneVisible = usePaneVisible();
const paneVisibleRef = useRef(paneVisible);
paneVisibleRef.current = paneVisible;
@@ -85,11 +85,19 @@ export default function Modal({
};
}, []);
// Move focus inside — on mount, and again whenever the pane comes back.
// Move focus inside — on mount, and again whenever the pane comes back. And
// move it *out* when the pane steps aside: the backdrop goes `display:none`
// with the keyboard focus still inside it, and nothing else relocates it, so
// the user lands on the new tab with focus held by a dialog they cannot see.
// Blurring puts it on `<body>`, which is where a fresh Tab starts.
useEffect(() => {
if (!paneVisible) return;
const panel = panelRef.current;
if (!panel) return;
if (!paneVisible) {
const active = panel.ownerDocument.activeElement as HTMLElement | null;
if (active && panel.contains(active)) active.blur?.();
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?.());
@@ -151,10 +159,11 @@ 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"
/* This dialog swallows native file drops for as long as it is on screen.
Dropped while the owning pane is hidden, so a dialog parked on another
tab does not keep refusing drops here — `lib/dropTarget.ts` also
filters `[hidden]`, and these two must not disagree. */
data-blocks-drop={paneVisible ? "true" : undefined}
hidden={!paneVisible}
aria-hidden={paneVisible ? undefined : true}
/* `hidden` is a base-layer rule and `flex` is a utility-layer one, so the