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 () => { 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 // Round 1. `ToastHost` is `fixed bottom-4 right-4 z-[60]` and 24rem wide,
// error cards stay until dismissed — so a z-order gate asking "is what is // and its error cards stay until dismissed — so a z-order gate asking "is
// painted here part of my pane?" made the bottom-right corner of this pane // what is painted here part of my pane?" made the bottom-right corner of
// refuse drops for as long as one error was on screen. jsdom has no // this pane refuse drops for as long as one error was on screen. jsdom has
// `elementFromPoint`, so that branch only runs when a test supplies one. // 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(); await renderTab();
const toastCard = document.createElement("div"); const toastCard = document.createElement("div");
document.body.appendChild(toastCard); document.body.appendChild(toastCard);
@@ -402,21 +404,37 @@ describe("FilesTab host drag-and-drop", () => {
toastCard.remove(); 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(); await renderTab();
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
backdrop.setAttribute("data-blocks-drop", "true"); backdrop.setAttribute("data-blocks-drop", "true");
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
Object.defineProperty(document, "elementFromPoint", { const toastCard = document.createElement("div"); // z-[60], above the backdrop
configurable: true, document.body.appendChild(toastCard);
writable: true, const stub = (top: Element) =>
value: () => backdrop, Object.defineProperty(document, "elementFromPoint", {
}); configurable: true,
writable: true,
value: () => top,
});
stub(backdrop);
await drop(["/host/a.png"], { x: 400, y: 300 }); await drop(["/host/a.png"], { x: 400, y: 300 });
expect(uploadFileToContainer).not.toHaveBeenCalled(); 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; delete (document as Partial<Document>).elementFromPoint;
toastCard.remove();
backdrop.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 { getCurrentWebview } from "@tauri-apps/api/webview";
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 { classifyDrop, isDropTarget } from "../../../lib/dropTarget"; import { classifyDrop, isDropTarget, DROP_BLOCKED_TOAST } from "../../../lib/dropTarget";
import { useAppState } from "../../../store/appState"; import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button"; import Button from "../../ui/Button";
import FileViewerModal from "./FileViewerModal"; import FileViewerModal from "./FileViewerModal";
@@ -240,11 +240,11 @@ 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 `classifyDrop` — the rect hit test *plus* the z-order question // routing is `classifyDrop` — the rect hit test, which says *whose* drop it
// a rect cannot answer: is a modal or a blocking overlay painted at that // is, plus the document-wide question a rect cannot answer: is a modal or a
// point? (Not "is that element mine": chrome painted over a pane — a toast, // blocking overlay on screen at all? That second half is deliberately not a
// a button — is not something that swallows a drop, and treating it as such // per-point z-order test; `lib/dropTarget.ts` records the two ways that went
// made permanent dead zones.) // wrong.
useEffect(() => { useEffect(() => {
if (!running) return; if (!running) return;
let unlisten: (() => void) | undefined; 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 — // Aimed at this pane and refused anyway: say so. Nothing else would —
// the file just never appears in the listing. // the file just never appears in the listing.
if (verdict === "blocked") { if (verdict === "blocked") {
console.warn( console.warn("[drop] refused: a dialog or overlay is open", payload.position);
"[drop] refused: an overlay is covering the drop point", useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
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; return;
} }
if (verdict !== "accept") return; if (verdict !== "accept") return;
@@ -292,9 +292,12 @@ describe("TerminalView — where a dropped file lands", () => {
return view; 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 * 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) { function stubElementFromPoint(top: Element | null) {
Object.defineProperty(document, "elementFromPoint", { Object.defineProperty(document, "elementFromPoint", {
configurable: true, configurable: true,
@@ -369,7 +372,8 @@ describe("TerminalView — where a dropped file lands", () => {
delete (document as Partial<Document>).elementFromPoint; 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(); await mountWithLayout();
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
backdrop.setAttribute("data-blocks-drop", "true"); backdrop.setAttribute("data-blocks-drop", "true");
@@ -383,13 +387,52 @@ describe("TerminalView — where a dropped file lands", () => {
expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled(); expect(vi.mocked(uploadHostFileToTerminal)).not.toHaveBeenCalled();
// A refused drop is otherwise indistinguishable from a broken one. // A refused drop is otherwise indistinguishable from a broken one.
expect( const notice = useAppState
useAppState.getState().toasts.some((t) => t.message === "File drop ignored"), .getState()
).toBe(true); .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(); backdrop.remove();
delete (document as Partial<Document>).elementFromPoint; 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", () => { describe("TerminalView — reaching the URL prompt without a mouse", () => {
+15 -21
View File
@@ -21,7 +21,7 @@ import {
parseUrlRelayOsc, parseUrlRelayOsc,
sanitizeRelayUrl, sanitizeRelayUrl,
} from "../../lib/urlRelay"; } from "../../lib/urlRelay";
import { classifyDrop } from "../../lib/dropTarget"; import { classifyDrop, DROP_BLOCKED_TOAST } from "../../lib/dropTarget";
import UrlToast, { import UrlToast, {
URL_TOAST_PRIMARY_SELECTOR, URL_TOAST_PRIMARY_SELECTOR,
URL_TOAST_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). // onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
// //
// The listener is window-wide, so every pane decides for itself whether a // 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 // drop was meant for it. `classifyDrop` is that decision, shared with the
// Files pane: the payload position against this pane's rect (a hidden pane is // Files pane, and it asks two things in order: is the payload position
// `display:none`, so its zero-size rect is what stops two panes both claiming // inside this pane's rect (a hidden pane is `display:none`, so its zero-size
// the drop), plus z-order — which a rect alone cannot see. An open `Modal` is // rect is what stops two panes both claiming the drop), and — document-wide,
// a `fixed inset-0` portal painted *over* the window and the pane underneath // with no geometry — is a modal or blocking overlay on screen at all? An
// still has its rect, so the geometric test that used to live here uploaded // open `Modal` is a `fixed inset-0` portal painted *over* the window and the
// files into the directory a dialog was covering. Same for the shutdown // pane underneath still has its rect, so a rect alone uploaded files into
// overlay, which is on screen precisely while nothing should be accepting // the directory a dialog was covering. See `lib/dropTarget.ts` for why the
// work at all. // 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 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 // 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 // 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 // of the host rather than a child. Nothing painted over the pane refuses a
// overlay* here?" rather than "is this element mine?" for the same reason — // drop on its own account — asking "is this element mine?" once turned every
// asking the second question turned every pixel under that chrome into a // pixel under that chrome into a permanent dead zone.
// permanent dead zone.
useEffect(() => { useEffect(() => {
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
let cancelled = false; let cancelled = false;
@@ -276,15 +275,10 @@ export default function TerminalView({ sessionId, active }: Props) {
// sits above it. // sits above it.
if (verdict === "blocked") { if (verdict === "blocked") {
console.warn( console.warn(
"[drop] refused: an overlay is covering the drop point", "[drop] refused: a dialog or overlay is open",
event.payload.position, event.payload.position,
); );
useAppState.getState().pushToast({ useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
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; return;
} }
if (verdict !== "accept") 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 () => { it("marks its backdrop as swallowing native file drops", async () => {
// The backdrop, not the panel, is what `elementFromPoint` returns for a // `lib/dropTarget` refuses every drop in the window while a dialog is on
// drop released beside the dialog — so it is the element that has to carry // screen, and this marker is half of how it knows one is (the panel's
// the marker `lib/dropTarget` looks for. // `aria-modal` is the other half).
render( render(
<Modal title="Reset" onClose={vi.fn()}> <Modal title="Reset" onClose={vi.fn()}>
<p>body</p> <p>body</p>
@@ -141,12 +141,16 @@ describe("Modal", () => {
expect(backdrop.hidden).toBe(true); expect(backdrop.hidden).toBe(true);
expect(backdrop.style.display).toBe("none"); expect(backdrop.style.display).toBe("none");
expect(dropIsBlocked()).toBe(false); 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); expect(backdrop.contains(document.activeElement)).toBe(false);
fireEvent.keyDown(document, { key: "Escape" }); fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
// Back on screen: the same dialog, still mounted, resumes everything. // Back on screen: the same dialog, still mounted, resumes everything.
// (Including the marker: it is dropped on hide, not written once at mount.)
rerender( rerender(
<PaneVisibilityProvider visible={true}> <PaneVisibilityProvider visible={true}>
<Modal title="Reset" onClose={onClose}> <Modal title="Reset" onClose={onClose}>
@@ -162,6 +166,46 @@ describe("Modal", () => {
expect(onClose).toHaveBeenCalledTimes(1); 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 () => { it("ignores Escape and overlay clicks when not dismissible", async () => {
const onClose = vi.fn(); const onClose = vi.fn();
render( 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 // 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 // step aside for another tab cannot reach it. `PaneVisibility` is how it
// finds out, and while it is false this dialog paints nothing, traps // 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 paneVisible = usePaneVisible();
const paneVisibleRef = useRef(paneVisible); const paneVisibleRef = useRef(paneVisible);
paneVisibleRef.current = 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(() => { useEffect(() => {
if (!paneVisible) return;
const panel = panelRef.current; const panel = panelRef.current;
if (!panel) return; 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; const target = initialFocusRef?.current ?? focusableWithin(panel)[0] ?? panel;
// Defer so the panel is laid out (offsetParent) before we query it. // Defer so the panel is laid out (offsetParent) before we query it.
const frame = requestAnimationFrame(() => target.focus?.()); const frame = requestAnimationFrame(() => target.focus?.());
@@ -151,10 +159,11 @@ export default function Modal({
ref={overlayRef} ref={overlayRef}
onClick={handleOverlayClick} onClick={handleOverlayClick}
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" 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 /* This dialog swallows native file drops for as long as it is on screen.
drop released beside the dialog — so it is the element that has to say Dropped while the owning pane is hidden, so a dialog parked on another
"I swallow drops". See `lib/dropTarget.ts`. */ tab does not keep refusing drops here — `lib/dropTarget.ts` also
data-blocks-drop="true" filters `[hidden]`, and these two must not disagree. */
data-blocks-drop={paneVisible ? "true" : undefined}
hidden={!paneVisible} hidden={!paneVisible}
aria-hidden={paneVisible ? undefined : true} aria-hidden={paneVisible ? undefined : true}
/* `hidden` is a base-layer rule and `flex` is a utility-layer one, so the /* `hidden` is a base-layer rule and `flex` is a utility-layer one, so the
+147 -96
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { classifyDrop, dropIsBlocked, isDropTarget } from "./dropTarget"; import { classifyDrop, dropIsBlocked, isDropTarget } from "./dropTarget";
function pane(rect: Partial<DOMRect>): HTMLElement { function pane(rect: Partial<DOMRect>): HTMLElement {
@@ -23,16 +23,22 @@ function pane(rect: Partial<DOMRect>): HTMLElement {
/** /**
* **jsdom has no `elementFromPoint`.** That single fact is how a z-order gate * **jsdom has no `elementFromPoint`.** That single fact is how a z-order gate
* that refused every drop under a button, a toast and a dialog shipped green * that refused every drop under a button, a toast and a dialog shipped green
* through 81 drop tests: the branch was never entered, in any of them. So the * through 81 drop tests: the branch was never entered, in any of them.
* tests below install one. A test that passes because the environment lacks *
* the API under test is not a test. * The gate no longer asks a per-point question at all, so the gap can no
* longer hide anything here — but the tests below still *install* an
* `elementFromPoint` and hand it the most misleading answer available, because
* "the module ignores it" is now a property worth pinning. `spy.mock.calls`
* proves it directly.
*/ */
function stubElementFromPoint(top: Element | null): void { function stubElementFromPoint(top: Element | null): ReturnType<typeof vi.fn> {
const spy = vi.fn(() => top);
Object.defineProperty(document, "elementFromPoint", { Object.defineProperty(document, "elementFromPoint", {
configurable: true, configurable: true,
writable: true, writable: true,
value: () => top, value: spy,
}); });
return spy;
} }
function removeElementFromPoint(): void { function removeElementFromPoint(): void {
@@ -125,148 +131,193 @@ describe("dropTarget", () => {
}); });
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Z-order — the branch jsdom cannot reach on its own // The gate itself: document-wide, and provably not geometric
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
describe("z-order, with elementFromPoint actually present", () => { describe("the blocking gate is document-wide, with no z-order in it", () => {
it("confirms the environment gap this whole block exists for", () => { it("never consults elementFromPoint, however tempting its answer", () => {
// If jsdom ever grows layout, this fails and the stubs below can be // Round 2 asked `elementFromPoint` whether a *blocker* was painted at
// reconsidered — but until then, *nothing* reaches the z-order branch // the drop point, and trusted the answer absolutely. Anything painted
// unless a test puts the API there itself. // above the `z-50` backdrop in the same stacking context — `ToastHost`
expect(typeof document.elementFromPoint).toBe("undefined"); // at `z-[60]`, `TerminalContextMenu` at `z-[60]` — answered "no blocker
}); // here" on a point the dialog was covering. The fix is not a better
// answer, it is not asking: this pins that the call is gone, so a
it("accepts a drop onto chrome the pane paints over itself", () => { // future edit that reintroduces it fails here rather than in the wild.
// The regression. `TerminalView`'s "▼ Following / ▽ Paused" toggle is
// `absolute top-2 right-4 z-50` and is rendered *unconditionally*, as a
// sibling of the xterm host rather than a child — so a gate asking "does
// the pane contain what is painted here?" turned the terminal's top-right
// corner into a dead zone that no user action could clear.
const el = pane({});
const following = document.createElement("button");
document.body.appendChild(following); // sibling, not a child of the pane
stubElementFromPoint(following);
expect(classifyDrop(el, { x: 90, y: 5 }, { devicePixelRatio: 1 })).toBe("accept");
});
it("accepts a drop onto a toast floating above every pane", () => {
// `ToastHost` is `fixed bottom-4 right-4 z-[60]`, 24rem wide, and its
// error cards never time out — so under the containment rule the
// bottom-right corner of both the terminal and the Files pane stopped
// accepting drops for as long as one error stayed on screen.
const el = pane({});
const toastCard = document.createElement("div");
document.body.appendChild(toastCard);
stubElementFromPoint(toastCard);
expect(classifyDrop(el, { x: 95, y: 95 }, { devicePixelRatio: 1 })).toBe("accept");
});
it("accepts a drop onto the pane's own content", () => {
const el = pane({}); const el = pane({});
const child = document.createElement("span"); const child = document.createElement("span");
el.appendChild(child); el.appendChild(child);
stubElementFromPoint(child); const spy = stubElementFromPoint(child);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept"); expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
expect(spy).not.toHaveBeenCalled();
}); });
it("refuses a drop released onto a dialog's backdrop", () => { it("refuses a covered drop even when a toast is painted over the dialog", () => {
// C1, exactly. A refused drop pushes a toast; `ToastHost` is
// `fixed bottom-4 right-4 z-[60]` and an error card stays until
// dismissed; the *next* drop released on that card had a topmost
// element with no blocker in it, and landed in the directory the dialog
// was covering. The gate had armed its own hole.
const el = pane({});
openModal(); // z-50 backdrop, covering the pane
const toastCard = document.createElement("div"); // z-[60], over the backdrop
document.body.appendChild(toastCard);
const spy = stubElementFromPoint(toastCard);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
expect(spy).not.toHaveBeenCalled();
});
it("refuses a drop anywhere in the window while a dialog is open", () => {
// Deliberately stricter than "the points the dialog covers". A dialog
// is a state the user entered on purpose and leaves with Escape, the
// refusal is announced, and nothing is written — whereas being precise
// about coverage has silently uploaded into a covered directory twice.
const el = pane({}); const el = pane({});
const { backdrop } = openModal(); const { backdrop } = openModal();
stubElementFromPoint(backdrop); stubElementFromPoint(document.createElement("div")); // "nothing here"
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked"); expect(classifyDrop(el, { x: 5, y: 5 }, { devicePixelRatio: 1 })).toBe("blocked");
expect(classifyDrop(el, { x: 95, y: 95 }, { devicePixelRatio: 1 })).toBe("blocked");
backdrop.remove();
expect(classifyDrop(el, { x: 5, y: 5 }, { devicePixelRatio: 1 })).toBe("accept");
}); });
it("refuses a drop released onto the dialog panel or anything inside it", () => { it("refuses under the shutdown overlay, which is not a dialog", () => {
const el = pane({}); const el = pane({});
const { panel, button } = openModal(); const overlay = document.createElement("div");
overlay.setAttribute("data-blocks-drop", "true");
stubElementFromPoint(panel); document.body.appendChild(overlay);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
stubElementFromPoint(button);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked"); expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
}); });
it("refuses a drop under an unmarked overlay that wraps a dialog", () => { it("still refuses a dialog built without ui/Modal's marked backdrop", () => {
// Belt to the braces: a dialog built without `ui/Modal`'s marked // Only `aria-modal` is needed; `ui/Modal` is the supported route, but a
// backdrop is still refused, because the element painted at the point // hand-rolled dialog must not be a hole either.
// *contains* something modal.
const el = pane({}); const el = pane({});
const backdrop = document.createElement("div"); const backdrop = document.createElement("div");
const panel = document.createElement("div"); const panel = document.createElement("div");
panel.setAttribute("aria-modal", "true"); panel.setAttribute("aria-modal", "true");
backdrop.appendChild(panel); backdrop.appendChild(panel);
document.body.appendChild(backdrop); document.body.appendChild(backdrop);
stubElementFromPoint(backdrop);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked"); expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
}); });
it("refuses a drop under the shutdown overlay", () => { it("counts a blocker that is only decoratively hidden from assistive tech", () => {
// `aria-hidden="true"` used to disqualify a blocker, and it is not a
// visibility statement — `ui/Modal`'s own ✕ glyph carries it while
// perfectly visible. A blocker nested inside such a wrapper would have
// silently stopped blocking. Only `[hidden]` counts now.
const el = pane({}); const el = pane({});
const wrapper = document.createElement("div");
wrapper.setAttribute("aria-hidden", "true");
const overlay = document.createElement("div"); const overlay = document.createElement("div");
overlay.setAttribute("data-blocks-drop", "true"); overlay.setAttribute("data-blocks-drop", "true");
document.body.appendChild(overlay); wrapper.appendChild(overlay);
stubElementFromPoint(overlay); document.body.appendChild(wrapper);
expect(dropIsBlocked()).toBe(true);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked"); expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
}); });
it("scopes a dialog's refusal to the points it actually covers", () => {
// `dropIsBlocked` is document-wide and `ui/Modal` portals to
// `document.body`, so an open dialog used to refuse every drop in the
// window. Asked per point, a dialog only swallows what lands on it.
const el = pane({});
openModal();
const paneContent = document.createElement("span");
el.appendChild(paneContent);
stubElementFromPoint(paneContent);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
});
it("ignores a blocker whose pane has stepped aside", () => { it("ignores a blocker whose pane has stepped aside", () => {
// `ui/Modal` marks itself `hidden` when the tab that owns it is not the // `ui/Modal` marks itself `hidden` when the tab that owns it is not the
// one on screen. A dialog left open in project A must not keep refusing // one on screen. A dialog left open in project A must not keep refusing
// drops in project B. // drops in project B — this is the one case where over-refusal would be
// unbounded, since the user cannot see the dialog to close it.
const el = pane({}); const el = pane({});
const { backdrop } = openModal(); const { backdrop } = openModal();
backdrop.setAttribute("hidden", ""); backdrop.setAttribute("hidden", "");
expect(dropIsBlocked()).toBe(false); expect(dropIsBlocked()).toBe(false);
stubElementFromPoint(backdrop);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept"); expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
}); });
});
it("falls back to the document-wide question when the point cannot be resolved", () => { // ---------------------------------------------------------------------
// `elementFromPoint` answers `null` for a point outside the viewport, and // Round 1's shape: chrome painted over a pane must never refuse a drop
// `<body>` for a point over nothing in particular. Neither is evidence // ---------------------------------------------------------------------
// that the pane is clear, so the conservative answer is the old one.
describe("chrome over a pane, with no dialog open", () => {
/** Everything that is painted over a pane and is not a blocker. */
const CHROME: Array<[string, () => HTMLElement]> = [
// `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2
// right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm
// host — so "does the pane contain what is painted here?" made the
// terminal's top-right corner a dead zone no user action could clear.
["the Following/Paused toggle", () => document.createElement("button")],
// `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every
// pane, and its error cards stay until dismissed.
["a toast card", () => document.createElement("div")],
// The drop hint is `pointer-events-none`, so a real `elementFromPoint`
// skips it — but nothing may depend on that any more.
["the pane's own drop hint", () => document.createElement("div")],
// `Tooltip` portals to `document.body`, so it is nobody's child.
["a portaled tooltip", () => document.createElement("div")],
];
for (const [name, make] of CHROME) {
it(`accepts a drop released onto ${name}`, () => {
const el = pane({});
const chrome = make();
document.body.appendChild(chrome); // a sibling, not a child of the pane
stubElementFromPoint(chrome);
expect(classifyDrop(el, { x: 90, y: 5 }, { devicePixelRatio: 1 })).toBe("accept");
expect(classifyDrop(el, { x: 95, y: 95 }, { devicePixelRatio: 1 })).toBe("accept");
});
}
it("accepts a drop on the gutter around the terminal, and on its content", () => {
const el = pane({}); const el = pane({});
openModal(); const child = document.createElement("span");
el.appendChild(child);
stubElementFromPoint(child);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
stubElementFromPoint(null); stubElementFromPoint(el); // the gutter: the wrapper itself is topmost
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked"); expect(classifyDrop(el, { x: 1, y: 99 }, { devicePixelRatio: 1 })).toBe("accept");
stubElementFromPoint(document.body);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
}); });
it("tells a refused drop apart from someone else's drop", () => { it("accepts a drop the view could not resolve to any element", () => {
// The caller reports one and stays silent about the other: a drop that // `elementFromPoint` answers `null` outside the viewport and `<body>`
// was aimed at this pane and swallowed by an overlay is invisible unless // over nothing in particular. With no dialog open neither is a reason
// something says so, while a drop into another pane is not ours to // to refuse a drop that is inside the pane's rect.
// narrate.
const el = pane({}); const el = pane({});
const { backdrop } = openModal(); stubElementFromPoint(null);
stubElementFromPoint(backdrop); expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
stubElementFromPoint(document.body);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
});
});
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked"); // ---------------------------------------------------------------------
expect(classifyDrop(el, { x: 900, y: 900 }, { devicePixelRatio: 1 })).toBe("elsewhere"); // Routing: exactly one listener speaks for a given drop
// ---------------------------------------------------------------------
describe("routing", () => {
it("lets only the pane the drop landed on report a refusal", () => {
// Geometry is asked before the gate for this reason: both listeners are
// live for every drop, and if the gate came first they would both push
// "File drop ignored" for one drop.
const hit = pane({});
const missed = pane({ left: 200, right: 300, top: 200, bottom: 300 });
openModal();
expect(classifyDrop(hit, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
expect(classifyDrop(missed, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(
"elsewhere",
);
});
it("says elsewhere, not blocked, for a hidden pane's zero-size rect", () => {
const hidden = pane({ right: 0, bottom: 0, width: 0, height: 0 });
openModal();
expect(classifyDrop(hidden, { x: 0, y: 0 }, { devicePixelRatio: 1 })).toBe(
"elsewhere",
);
}); });
}); });
+86 -85
View File
@@ -2,47 +2,58 @@
* Routing for Tauri's *native* drag-drop event. * Routing for Tauri's *native* drag-drop event.
* *
* The listener is window-wide — every pane that wants dropped file paths gets * 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 * the same event — so the module answers two separate questions, and keeping
* for it. That decision used to be purely geometric: is the payload position * them separate is the whole design:
* 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: the point is inside my rect, **and** nothing that * 1. **Which pane is this drop for?** Geometry, and nothing else: is the
* *swallows* drops is painted at that point. * payload position inside my rect? A hidden pane is `display:none` and so
* has a zero-size rect, which is what stops `TerminalView` and `FilesTab`
* both claiming the same drop.
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked` —
* document-wide, no geometry, no z-order. While a modal or a blocking
* overlay is on screen anywhere, every drop is refused.
* *
* ## The question the z-order test asks — and the one it must not ask * ## Why there is no z-order test here, and must not be one
* *
* The first version of this asked `el.contains(document.elementFromPoint(x,y))` * A drop that lands underneath a dialog and silently uploads into the
* — "is the thing painted here mine?" That is the wrong question, and it * directory the dialog is covering is the failure mode that matters: it is
* created permanent dead zones. Panes have chrome painted *over* them that is * invisible, it writes to the container, and the user did not ask for it.
* not part of the element handed to this function and does not swallow * Every attempt to be *precise* about which points a dialog covers has gone
* anything: `TerminalView`'s always-present "▼ Following" button, the URL * wrong, twice, in opposite directions:
* toast, and `ToastHost`'s bottom-right stack — which is `fixed` at `z-[60]`
* over *every* pane and whose error cards stay until dismissed. Under the
* containment rule a drop onto any of them was silently refused, forever.
* *
* The question that matches the intent is "is a *blocking overlay* painted * - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing
* here?". A button, a toast or a tooltip over the pane is not one; a modal * painted here mine?" — refused drops onto anything painted *over* a pane
* backdrop is. Anything else painted at the point belongs to the pane's own * that is not part of it: `TerminalView`'s always-rendered "▼ Following"
* subtree or is chrome that is happy for the drop to fall through to it. * toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack.
* Permanent dead zones no user action could clear.
* - Replacing that with "is a *blocking overlay* painted here?" removed the
* dead zones and opened a hole instead. `elementFromPoint` returns the
* topmost painted element, and plenty of things paint above a `z-50` modal
* backdrop in the same stacking context: `ToastHost` is `z-[60]`, so is
* `TerminalContextMenu`. A refused drop pushed a toast; the toast then sat
* over the dialog; the next drop released on that toast was reported as
* "clear" and landed in the covered directory. The gate armed its own hole.
* *
* That rule is also what *scopes* the blocking question. `dropIsBlocked` is * Both bugs are the same mistake: trusting a per-point answer to decide
* document-wide, and `ui/Modal` portals to `document.body`, so any dialog * whether the app should be accepting work at all. The document-wide question
* anywhere used to refuse every drop in the window. Asking per-point means a * has no z-index in it, so no future overlay can become a drop hole by being
* dialog only refuses the points it actually covers. * painted high enough, and no chrome can become a dead zone by being painted
* at all.
*
* What it costs: while any dialog is open, drops are refused *everywhere*,
* including on parts of a pane the dialog does not cover. That is a state the
* user put the app in deliberately and can leave in one keystroke, the
* refusal is announced, and nothing is written. It is strictly the better
* failure.
* *
* ## jsdom * ## jsdom
* *
* jsdom implements no layout and has no `elementFromPoint`, so the z-order * Note for future changes: jsdom implements no layout and has no
* branch cannot be exercised by simply rendering — which is exactly how the * `elementFromPoint`, which is how the first of those two bugs shipped green
* containment bug shipped green through 81 drop tests. Every test that cares * through 81 drop tests — the branch was never entered in any of them. This
* about z-order therefore stubs `elementFromPoint` (see `dropTarget.test.ts`), * module no longer calls it (`dropTarget.test.ts` asserts that it does not),
* and the fallback below — when there is no such API, or it cannot resolve the * so the gap can no longer hide a bug here. Anything that reintroduces a
* point — is the conservative document-wide question this used to ask. * geometric z-order test reintroduces the gap as well.
*/ */
export interface DropPoint { export interface DropPoint {
@@ -51,24 +62,30 @@ export interface DropPoint {
} }
/** /**
* Anything that swallows a drop wherever it lands. * Anything that swallows drops while it is on screen.
* *
* `[aria-modal="true"]` is every dialog in the app for free — `ui/Modal` is * `[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` * 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), and * is for full-window overlays that are not dialogs (the shutdown overlay), and
* `ui/Modal` puts it on its backdrop as well: the backdrop is what * `ui/Modal` puts it on its backdrop as well.
* `elementFromPoint` returns for a point outside the dialog panel, and it is
* the element that is really covering the pane.
*/ */
const BLOCKING_SELECTOR = '[aria-modal="true"],[data-blocks-drop="true"]'; const BLOCKING_SELECTOR = '[aria-modal="true"],[data-blocks-drop="true"]';
/** /**
* A blocker inside one of these is in the DOM but not on screen — `ui/Modal` * A blocker inside this is in the DOM but not on screen — `ui/Modal` marks
* marks itself this way when the pane that owns it is not the visible one, so * itself `hidden` when the pane that owns it is not the visible one, so a
* a dialog left open in project A stops covering project B the moment the tab * dialog left open in project A stops refusing drops in project B the moment
* changes. * the tab changes.
*
* `[hidden]` only, deliberately. `aria-hidden="true"` used to count too, and
* it is not a visibility statement: it is routinely put on *visible*
* decorative content (`ui/Modal`'s own ✕ glyph, every `StatusIndicator`
* dot). An overlay that happened to sit inside such a wrapper would have
* silently stopped blocking — the exact class of hole this gate exists to
* close. `ui/Modal` sets `hidden`, the `hidden` attribute, and inline
* `display:none` together, so nothing in the app depended on the aria half.
*/ */
const OFFSCREEN_SELECTOR = '[hidden],[aria-hidden="true"]'; const OFFSCREEN_SELECTOR = "[hidden]";
/** A blocker that is actually painted, rather than merely mounted. */ /** A blocker that is actually painted, rather than merely mounted. */
function isOnScreen(el: Element): boolean { function isOnScreen(el: Element): boolean {
@@ -80,6 +97,22 @@ export function dropIsBlocked(doc: Document = document): boolean {
return Array.from(doc.querySelectorAll(BLOCKING_SELECTOR)).some(isOnScreen); return Array.from(doc.querySelectorAll(BLOCKING_SELECTOR)).some(isOnScreen);
} }
/**
* What both listeners say when they refuse a drop.
*
* `kind: "info"`, so it times out on its own: a drop refused because the user
* has a dialog open is expected behaviour, not an error, and an error card
* would sit on screen until dismissed. `dedupeKey` means three refused drops
* leave one notice rather than a stack of three.
*/
export const DROP_BLOCKED_TOAST = {
kind: "info",
message: "File drop ignored",
detail:
"A dialog or full-window overlay is open, so nothing accepts dropped files. Close it and drop again.",
dedupeKey: "drop-blocked",
} as const;
export interface DropTargetOptions { export interface DropTargetOptions {
doc?: Document; doc?: Document;
/** Override the ratio used to convert physical pixels to CSS pixels. */ /** Override the ratio used to convert physical pixels to CSS pixels. */
@@ -99,10 +132,8 @@ export interface DropTargetOptions {
* (`wry/src/webview2/drag_drop.rs`), while the macOS and GTK backends deliver * (`wry/src/webview2/drag_drop.rs`), while the macOS and GTK backends deliver
* logical points and `tauri-runtime-wry`'s forwarding does not rescale them. * logical points and `tauri-runtime-wry`'s forwarding does not rescale them.
* Dividing by `devicePixelRatio` unconditionally therefore halved every drop * Dividing by `devicePixelRatio` unconditionally therefore halved every drop
* position on a HiDPI Mac or Linux box — which used to be a silent * position on a HiDPI Mac or Linux box, aiming the hit test at a point the
* mis-aimed-but-usually-still-inside-the-pane error and, with a z-order test * user never touched.
* in place, becomes a drop refused because the *halved* point lands on
* something else.
* *
* Verified by reading the wry/tauri sources named above. **Not** verified on a * Verified by reading the wry/tauri sources named above. **Not** verified on a
* real HiDPI macOS or GTK machine — neither is available here — which is why * real HiDPI macOS or GTK machine — neither is available here — which is why
@@ -120,15 +151,15 @@ function payloadIsPhysical(
/** /**
* Why a native drop at `pos` did or did not belong to `el`. * Why a native drop at `pos` did or did not belong to `el`.
* *
* - `accept` — it is ours. * - `accept` — it is ours, and the app is in a state to take it.
* - `blocked` — it landed on our rect, but a modal or a blocking overlay is * - `blocked` — it was aimed at us, but a modal or a blocking overlay is on
* painted there and swallowed it. Worth *saying* to the user: the drop * screen. Worth *saying* to the user: the drop visibly did nothing.
* visibly did nothing.
* - `elsewhere` — not our drop. Silence is the right response; some other * - `elsewhere` — not our drop. Silence is the right response; some other
* pane's listener is about to accept it. * pane's listener may be about to accept it.
* *
* A hidden pane is `display:none` and therefore has a zero-size rect, which is * Geometry is asked **first**, so exactly one pane can ever answer `blocked`
* what stops two panes both claiming the same drop. * for a given drop and the refusal is announced once rather than once per
* listener.
*/ */
export type DropVerdict = "accept" | "blocked" | "elsewhere"; export type DropVerdict = "accept" | "blocked" | "elsewhere";
@@ -152,41 +183,11 @@ export function classifyDrop(
return "elsewhere"; return "elsewhere";
} }
// Z-order, where the environment can answer it. `elementFromPoint` skips // Whose drop it is has been settled. Whether the app should be taking drops
// `pointer-events: none`, so the pane's own decorative drop hint does not // at all is a separate, document-wide question — see the header.
// count as something covering it.
const top =
typeof doc.elementFromPoint === "function" ? doc.elementFromPoint(x, y) : null;
if (top && top !== doc.body && top !== doc.documentElement) {
return coveredByBlocker(top, doc) ? "blocked" : "accept";
}
// No layout information — jsdom, or a point the view could not resolve. Fall
// back to the document-wide question, which is the conservative answer: a
// dialog somewhere refuses everything rather than risking a drop landing
// underneath one.
return dropIsBlocked(doc) ? "blocked" : "accept"; return dropIsBlocked(doc) ? "blocked" : "accept";
} }
/**
* Is the element painted at the drop point part of something that swallows
* drops?
*
* Two directions, because a dialog is two elements: the panel carries
* `aria-modal`, and the backdrop around it is what is painted over the pane.
* `ui/Modal` marks its own backdrop, so `closest` covers both; the `contains`
* half is the safety net for any overlay that wraps a dialog without marking
* itself, and is deliberately not asked of `<body>`/`<html>` — those contain
* every portal in the app and would make the answer "blocked" always.
*/
function coveredByBlocker(top: Element, doc: Document): boolean {
const nearest = top.closest(BLOCKING_SELECTOR);
if (nearest && isOnScreen(nearest)) return true;
if (top === doc.body || top === doc.documentElement) return false;
const inside = top.querySelector(BLOCKING_SELECTOR);
return inside !== null && isOnScreen(inside);
}
/** /**
* Whether a native drop at `pos` (physical pixels on Windows, logical * Whether a native drop at `pos` (physical pixels on Windows, logical
* elsewhere) belongs to `el`. * elsewhere) belongs to `el`.
+35
View File
@@ -77,3 +77,38 @@ describe("tab reordering", () => {
expect(useAppState.getState().activeTabKey).toBe(C); expect(useAppState.getState().activeTabKey).toBe(C);
}); });
}); });
describe("toasts", () => {
beforeEach(() => useAppState.setState({ toasts: [] }));
const toasts = () => useAppState.getState().toasts;
it("stacks unkeyed toasts, which are one-off reports", () => {
const { pushToast } = useAppState.getState();
pushToast({ kind: "error", message: "one" });
pushToast({ kind: "error", message: "two" });
expect(toasts().map((t) => t.message)).toEqual(["one", "two"]);
});
it("lets a keyed toast supersede the one already on screen", () => {
// A recurring notice — "File drop ignored" is the one that exists — must
// not build a wall of identical cards when the user tries three times.
const { pushToast } = useAppState.getState();
const first = pushToast({ kind: "info", message: "ignored", dedupeKey: "drop-blocked" });
pushToast({ kind: "error", message: "unrelated" });
const second = pushToast({ kind: "info", message: "ignored", dedupeKey: "drop-blocked" });
expect(toasts().map((t) => t.message)).toEqual(["unrelated", "ignored"]);
// A fresh id, so the card re-mounts and its dismissal timer restarts
// rather than the replacement inheriting the first one's remaining time.
expect(second).not.toBe(first);
expect(toasts().some((t) => t.id === first)).toBe(false);
});
it("keeps toasts with different keys apart", () => {
const { pushToast } = useAppState.getState();
pushToast({ kind: "info", message: "a", dedupeKey: "x" });
pushToast({ kind: "info", message: "b", dedupeKey: "y" });
expect(toasts()).toHaveLength(2);
});
});
+14 -1
View File
@@ -47,6 +47,12 @@ export interface Toast {
message: string; message: string;
/** Long text (e.g. a bollard error) shown behind a "Details" disclosure. */ /** Long text (e.g. a bollard error) shown behind a "Details" disclosure. */
detail?: string; detail?: string;
/**
* Optional identity for a *recurring* notice. Pushing another toast with the
* same key replaces the one already on screen instead of stacking a second
* copy of it — three refused file drops leave one card, not three.
*/
dedupeKey?: string;
} }
let toastCounter = 0; let toastCounter = 0;
@@ -355,7 +361,14 @@ export const useAppState = create<AppState>((set) => ({
toasts: [], toasts: [],
pushToast: (toast) => { pushToast: (toast) => {
const id = `toast-${++toastCounter}`; const id = `toast-${++toastCounter}`;
set((state) => ({ toasts: [...state.toasts, { ...toast, id }] })); set((state) => {
// A keyed toast supersedes the previous one with that key. The new card
// gets a new id, so it re-mounts and its dismissal timer restarts.
const kept = toast.dedupeKey
? state.toasts.filter((t) => t.dedupeKey !== toast.dedupeKey)
: state.toasts;
return { toasts: [...kept, { ...toast, id }] };
});
return id; return id;
}, },
dismissToast: (id) => dismissToast: (id) =>