Merge branch 'r3/drop' into ship/core
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user