Files
Triple-C/app/src/lib/dropTarget.test.ts
T
shadowdaoandClaude Opus 5 f7db4323be Make the drop gate a state question, not a geometry one
The gate that decides whether a native file drop is accepted has been wrong
twice in opposite directions, both times because it tried to be precise about
*which points* a dialog covers:

- Round 1 asked `el.contains(elementFromPoint(x, y))` and was handed the inner
  xterm host while the overlays are siblings, so the always-rendered
  Following/Paused button made the terminal's top-right corner permanently
  refuse drops.
- Round 2 replaced that with "is a blocking overlay painted here?" and deleted
  the document-wide gate. `elementFromPoint` returns the *topmost* element, and
  ToastHost is z-[60] against the Modal backdrop's z-50 in the same stacking
  context — so a refused drop pushed a toast, the toast covered the dialog, and
  the next drop released on it was reported clear and landed in the directory
  the dialog was covering. The gate armed its own hole.

Split the two questions instead of merging them:

- Geometry answers *whose* drop it is (rect hit test, unchanged), so exactly
  one listener speaks for a drop and a hidden pane's zero-size rect still keeps
  TerminalView and FilesTab from both firing.
- `dropIsBlocked` answers whether the app should take a drop at all —
  document-wide, no z-index in it. While a modal or blocking overlay is on
  screen anywhere, every drop is refused.

There is no `elementFromPoint` call left, so no future overlay can become a
drop hole by being painted high enough and no chrome can become a dead zone by
being painted at all. The cost is over-refusal while a dialog is open, in a
state the user entered deliberately, announced, writing nothing.

Also:
- `[aria-hidden="true"]` no longer disqualifies a blocker. It is not a
  visibility statement (it sits on visible decorative content), so a blocker
  nested in such a wrapper would have silently stopped blocking.
- Modal drops `data-blocks-drop` when its pane hides, and moves focus out of
  itself rather than leaving it inside a `display:none` panel.
- The refusal notice stays `kind: "info"` (an expected refusal is not an
  error, and an error card never auto-dismisses) and carries a `dedupeKey`, so
  repeated refusals replace rather than stack.

Tests: mutation-checked against the previous implementation — four in
dropTarget.test.ts, two in each of TerminalView/FilesTab, two in Modal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 15:31:13 -07:00

362 lines
15 KiB
TypeScript

import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { classifyDrop, dropIsBlocked, isDropTarget } from "./dropTarget";
function pane(rect: Partial<DOMRect>): HTMLElement {
const el = document.createElement("div");
document.body.appendChild(el);
el.getBoundingClientRect = () =>
({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
x: 0,
y: 0,
toJSON: () => ({}),
...rect,
}) as DOMRect;
return el;
}
/**
* **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
* through 81 drop tests: the branch was never entered, in any of them.
*
* 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): ReturnType<typeof vi.fn> {
const spy = vi.fn(() => top);
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: spy,
});
return spy;
}
function removeElementFromPoint(): void {
delete (document as Partial<Document>).elementFromPoint;
}
/** The DOM `ui/Modal` really renders: a marked backdrop around the dialog. */
function openModal(): { backdrop: HTMLElement; panel: HTMLElement; button: HTMLElement } {
const backdrop = document.createElement("div");
backdrop.setAttribute("data-blocks-drop", "true");
const panel = document.createElement("div");
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
const button = document.createElement("button");
panel.appendChild(button);
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
return { backdrop, panel, button };
}
function withUserAgent(ua: string): void {
Object.defineProperty(window.navigator, "userAgent", {
configurable: true,
value: ua,
});
}
const REAL_UA = window.navigator.userAgent;
const REAL_DPR = window.devicePixelRatio;
function withDevicePixelRatio(value: number): void {
Object.defineProperty(window, "devicePixelRatio", { configurable: true, value });
}
describe("dropTarget", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
document.body.innerHTML = "";
removeElementFromPoint();
withUserAgent(REAL_UA);
withDevicePixelRatio(REAL_DPR);
});
it("accepts a point inside the pane", () => {
expect(isDropTarget(pane({}), { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(true);
});
it("rejects a point outside the pane", () => {
expect(isDropTarget(pane({}), { x: 400, y: 50 }, { devicePixelRatio: 1 })).toBe(false);
});
it("converts physical pixels to CSS pixels", () => {
const el = pane({});
expect(isDropTarget(el, { x: 150, y: 150 }, { devicePixelRatio: 2 })).toBe(true);
expect(isDropTarget(el, { x: 150, y: 150 }, { devicePixelRatio: 1 })).toBe(false);
});
it("rejects a hidden pane, which has a zero-size rect", () => {
const el = pane({ right: 0, bottom: 0, width: 0, height: 0 });
expect(isDropTarget(el, { x: 0, y: 0 }, { devicePixelRatio: 1 })).toBe(false);
});
it("rejects every drop while a modal is open", () => {
const el = pane({});
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
expect(dropIsBlocked()).toBe(true);
expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(false);
dialog.remove();
expect(dropIsBlocked()).toBe(false);
expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(true);
});
it("rejects every drop while a blocking overlay is up", () => {
const el = pane({});
const overlay = document.createElement("div");
overlay.setAttribute("data-blocks-drop", "true");
document.body.appendChild(overlay);
expect(isDropTarget(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe(false);
});
it("rejects a null pane", () => {
expect(isDropTarget(null, { x: 1, y: 1 }, { devicePixelRatio: 1 })).toBe(false);
});
// ---------------------------------------------------------------------
// The gate itself: document-wide, and provably not geometric
// ---------------------------------------------------------------------
describe("the blocking gate is document-wide, with no z-order in it", () => {
it("never consults elementFromPoint, however tempting its answer", () => {
// Round 2 asked `elementFromPoint` whether a *blocker* was painted at
// the drop point, and trusted the answer absolutely. Anything painted
// above the `z-50` backdrop in the same stacking context — `ToastHost`
// 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
// future edit that reintroduces it fails here rather than in the wild.
const el = pane({});
const child = document.createElement("span");
el.appendChild(child);
const spy = stubElementFromPoint(child);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
expect(spy).not.toHaveBeenCalled();
});
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 { backdrop } = openModal();
stubElementFromPoint(document.createElement("div")); // "nothing here"
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 under the shutdown overlay, which is not a dialog", () => {
const el = pane({});
const overlay = document.createElement("div");
overlay.setAttribute("data-blocks-drop", "true");
document.body.appendChild(overlay);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
});
it("still refuses a dialog built without ui/Modal's marked backdrop", () => {
// Only `aria-modal` is needed; `ui/Modal` is the supported route, but a
// hand-rolled dialog must not be a hole either.
const el = pane({});
const backdrop = document.createElement("div");
const panel = document.createElement("div");
panel.setAttribute("aria-modal", "true");
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
});
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 wrapper = document.createElement("div");
wrapper.setAttribute("aria-hidden", "true");
const overlay = document.createElement("div");
overlay.setAttribute("data-blocks-drop", "true");
wrapper.appendChild(overlay);
document.body.appendChild(wrapper);
expect(dropIsBlocked()).toBe(true);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
});
it("ignores a blocker whose pane has stepped aside", () => {
// `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
// 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 { backdrop } = openModal();
backdrop.setAttribute("hidden", "");
expect(dropIsBlocked()).toBe(false);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
});
});
// ---------------------------------------------------------------------
// Round 1's shape: chrome painted over a pane must never refuse a drop
// ---------------------------------------------------------------------
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 child = document.createElement("span");
el.appendChild(child);
stubElementFromPoint(child);
expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
stubElementFromPoint(el); // the gutter: the wrapper itself is topmost
expect(classifyDrop(el, { x: 1, y: 99 }, { devicePixelRatio: 1 })).toBe("accept");
});
it("accepts a drop the view could not resolve to any element", () => {
// `elementFromPoint` answers `null` outside the viewport and `<body>`
// over nothing in particular. With no dialog open neither is a reason
// to refuse a drop that is inside the pane's rect.
const el = pane({});
stubElementFromPoint(null);
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");
});
});
// ---------------------------------------------------------------------
// 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",
);
});
});
// ---------------------------------------------------------------------
// HiDPI
// ---------------------------------------------------------------------
describe("physical vs logical payload coordinates", () => {
it("divides by devicePixelRatio on Windows", () => {
// wry's WebView2 drag-drop handler passes the OS point through in device
// pixels, so a physical (150,150) at dpr 2 is a CSS (75,75).
withUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
withDevicePixelRatio(2);
expect(isDropTarget(pane({}), { x: 150, y: 150 })).toBe(true);
});
it("does not divide on macOS or Linux, where the payload is already logical", () => {
// The macOS and GTK backends deliver logical points and
// `tauri-runtime-wry` does not rescale them. Halving one there aimed the
// hit test at a point the user never touched — harmless while the test
// was a bare rect, and a refused drop once z-order joined in.
withDevicePixelRatio(2);
withUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15");
expect(isDropTarget(pane({}), { x: 150, y: 150 })).toBe(false);
expect(isDropTarget(pane({}), { x: 50, y: 50 })).toBe(true);
withUserAgent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36");
expect(isDropTarget(pane({}), { x: 150, y: 150 })).toBe(false);
expect(isDropTarget(pane({}), { x: 50, y: 50 })).toBe(true);
});
it("takes an explicit override over the platform guess", () => {
withUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15");
withDevicePixelRatio(2);
expect(
isDropTarget(pane({}), { x: 150, y: 150 }, { physicalPixelPayload: true }),
).toBe(true);
});
});
});