+ {children}
+
+ );
+}
+
+/** True unless an ancestor pane says it is currently hidden. */
+export function usePaneVisible(): boolean {
+ return useContext(PaneVisibilityContext);
+}
diff --git a/app/src/hooks/useDiskUsage.test.tsx b/app/src/hooks/useDiskUsage.test.tsx
index 2ddfbf9..0c74a54 100644
--- a/app/src/hooks/useDiskUsage.test.tsx
+++ b/app/src/hooks/useDiskUsage.test.tsx
@@ -368,6 +368,84 @@ describe("useDiskUsage", () => {
expect(result.current.error).toMatch(/in use by a running container/);
});
+ it("calls a refusal that came back inside `Ok` a failure, and keeps the plan", async () => {
+ // `reclaim` reports per-target results, and a compaction the backend
+ // declined is `ok: false` with a sentence saying why — not a thrown error.
+ // Treating that as success closed the dialog that asked for it and took
+ // the tick list away, even though every object it listed is still there.
+ getDockerDiskUsage.mockResolvedValue(report("first"));
+ reclaim.mockResolvedValue({
+ results: [
+ {
+ target: { kind: "compact_snapshot", project_id: "p1" },
+ destroyed: null,
+ ok: false,
+ freed_bytes: 0,
+ projected_bytes: null,
+ message: "Cannot compact p1: a terminal session is still attached.",
+ },
+ ],
+ total_freed_bytes: 0,
+ });
+ const { result } = renderHook(() => useDiskUsage());
+ await act(async () => {
+ await result.current.scan();
+ });
+ let ok: boolean | undefined;
+ await act(async () => {
+ ok = await result.current.runReclaim([{ kind: "compact_snapshot", project_id: "p1" }]);
+ });
+ expect(ok).toBe(false);
+ expect(result.current.plan).toEqual(plan);
+ expect(result.current.outcome?.results[0].message).toMatch(/still attached/);
+ });
+
+ it("drops the plan when part of a batch did happen", async () => {
+ getDockerDiskUsage.mockResolvedValue(report("first"));
+ reclaim.mockResolvedValue({
+ results: [
+ { target: { kind: "dangling_snapshots" }, destroyed: null, ok: true, freed_bytes: 12, projected_bytes: null, message: "Removed 3 images" },
+ { target: { kind: "compact_snapshot", project_id: "p1" }, destroyed: null, ok: false, freed_bytes: 0, projected_bytes: null, message: "Refused" },
+ ],
+ total_freed_bytes: 12,
+ });
+ const { result } = renderHook(() => useDiskUsage());
+ await act(async () => {
+ await result.current.scan();
+ });
+ let ok: boolean | undefined;
+ await act(async () => {
+ ok = await result.current.runReclaim([
+ { kind: "dangling_snapshots" },
+ { kind: "compact_snapshot", project_id: "p1" },
+ ]);
+ });
+ expect(ok).toBe(false);
+ expect(result.current.plan).toBeNull();
+ });
+
+ it("calls a refused destroy a failure and leaves its row in the plan", async () => {
+ getDockerDiskUsage.mockResolvedValue(report("first"));
+ destroyProjectDiskObject.mockResolvedValue({
+ target: null,
+ destroyed: { kind: "home_volume", project_id: "p1" },
+ ok: false,
+ freed_bytes: 0,
+ projected_bytes: null,
+ message: "The volume is still attached to a running container.",
+ });
+ const { result } = renderHook(() => useDiskUsage());
+ await act(async () => {
+ await result.current.scan();
+ });
+ let ok: boolean | undefined;
+ await act(async () => {
+ ok = await result.current.destroy({ kind: "home_volume", project_id: "p1" }, "whp");
+ });
+ expect(ok).toBe(false);
+ expect(result.current.plan).toEqual(plan);
+ });
+
it("reports success when the call came back", async () => {
const { result } = renderHook(() => useDiskUsage());
let ok: boolean | undefined;
diff --git a/app/src/hooks/useDiskUsage.ts b/app/src/hooks/useDiskUsage.ts
index 3f00330..b3f2769 100644
--- a/app/src/hooks/useDiskUsage.ts
+++ b/app/src/hooks/useDiskUsage.ts
@@ -63,13 +63,25 @@ export interface DiskUsageState {
outcome: ReclaimOutcome | null;
scan: () => Promise
;
/**
- * Resolves `true` when the call came back, `false` when it threw and the
- * failure went into `error`. Callers that dismiss UI on completion — the
- * confirmation dialogs — must only dismiss on `true`, or the failure is left
- * with nowhere on screen the user is looking.
+ * Resolves `true` only when the work actually happened.
+ *
+ * Two different failures reach here and both have to answer `false`. One is
+ * the call throwing, which lands in `error`. The other is the backend coming
+ * back inside `Ok` with a *refusal* — `reclaim` reports per-target results,
+ * and a compaction declined because the project is busy is a `ReclaimResult`
+ * with `ok: false` and a sentence saying why. Reading only "did it throw"
+ * treated that refusal as a success: the confirmation dialog closed, the plan
+ * was dropped, and the explanation appeared in the outcome panel several
+ * screens above the row the user had clicked.
+ *
+ * Callers that dismiss UI on completion — the confirmation dialogs — must
+ * only dismiss on `true`, and take the wording from `outcome`'s per-result
+ * `message` rather than writing their own: the backend's sentence is the one
+ * that names the real blocker.
*/
runReclaim: (targets: ReclaimTarget[]) => Promise;
- /** Same contract as `runReclaim`: `false` means it failed and `error` says how. */
+ /** Same contract as `runReclaim`: `false` means it did not happen, and either
+ * `error` or the outcome's `message` says why. */
destroy: (target: DestructiveTarget, confirmation: string) => Promise;
/** Run the orphaned-snapshot sweep and report what it found *and refused*. */
runSweep: () => Promise;
@@ -153,8 +165,15 @@ export function useDiskUsage(): DiskUsageState {
// Deliberately no automatic re-scan: it costs another `df()`, and the
// outcome already reports measured bytes for every target — a user who
// wants the new totals asks for them.
- setPlan(null);
- return true;
+ //
+ // The exception is a call that removed *nothing at all* because every
+ // target was refused: those objects are all still there, so the plan
+ // still describes the daemon accurately and taking it away would leave
+ // the user re-scanning to get back a list that never went stale.
+ const everythingRefused =
+ result.results.length > 0 && result.results.every((r) => !r.ok);
+ if (!everythingRefused) setPlan(null);
+ return result.results.every((r) => r.ok);
} catch (e) {
setError(String(e));
return false;
@@ -173,10 +192,11 @@ export function useDiskUsage(): DiskUsageState {
try {
const result = await commands.destroyProjectDiskObject(target, confirmation);
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
- // Same reasoning as `runReclaim`: the destructive list named an object
- // that is now gone.
- setPlan(null);
- return true;
+ // Same reasoning as `runReclaim`, refusal included: the destructive
+ // list named an object that is now gone — unless the backend declined,
+ // in which case it is still there and so is the row for it.
+ if (result.ok) setPlan(null);
+ return result.ok;
} catch (e) {
setError(String(e));
return false;
diff --git a/app/src/lib/dropTarget.test.ts b/app/src/lib/dropTarget.test.ts
index 07a8f8b..876913c 100644
--- a/app/src/lib/dropTarget.test.ts
+++ b/app/src/lib/dropTarget.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
-import { dropIsBlocked, isDropTarget } from "./dropTarget";
+import { classifyDrop, dropIsBlocked, isDropTarget } from "./dropTarget";
function pane(rect: Partial): HTMLElement {
const el = document.createElement("div");
@@ -20,12 +20,62 @@ function pane(rect: Partial): HTMLElement {
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. So the
+ * tests below install one. A test that passes because the environment lacks
+ * the API under test is not a test.
+ */
+function stubElementFromPoint(top: Element | null): void {
+ Object.defineProperty(document, "elementFromPoint", {
+ configurable: true,
+ writable: true,
+ value: () => top,
+ });
+}
+
+function removeElementFromPoint(): void {
+ delete (document as Partial).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", () => {
@@ -73,4 +123,188 @@ describe("dropTarget", () => {
it("rejects a null pane", () => {
expect(isDropTarget(null, { x: 1, y: 1 }, { devicePixelRatio: 1 })).toBe(false);
});
+
+ // ---------------------------------------------------------------------
+ // Z-order — the branch jsdom cannot reach on its own
+ // ---------------------------------------------------------------------
+
+ describe("z-order, with elementFromPoint actually present", () => {
+ it("confirms the environment gap this whole block exists for", () => {
+ // If jsdom ever grows layout, this fails and the stubs below can be
+ // reconsidered — but until then, *nothing* reaches the z-order branch
+ // unless a test puts the API there itself.
+ expect(typeof document.elementFromPoint).toBe("undefined");
+ });
+
+ it("accepts a drop onto chrome the pane paints over itself", () => {
+ // 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 child = document.createElement("span");
+ el.appendChild(child);
+ stubElementFromPoint(child);
+
+ expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("accept");
+ });
+
+ it("refuses a drop released onto a dialog's backdrop", () => {
+ const el = pane({});
+ const { backdrop } = openModal();
+ stubElementFromPoint(backdrop);
+
+ expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
+ });
+
+ it("refuses a drop released onto the dialog panel or anything inside it", () => {
+ const el = pane({});
+ const { panel, button } = openModal();
+
+ stubElementFromPoint(panel);
+ expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
+
+ stubElementFromPoint(button);
+ expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
+ });
+
+ it("refuses a drop under an unmarked overlay that wraps a dialog", () => {
+ // Belt to the braces: a dialog built without `ui/Modal`'s marked
+ // backdrop is still refused, because the element painted at the point
+ // *contains* something modal.
+ 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);
+ stubElementFromPoint(backdrop);
+
+ expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
+ });
+
+ it("refuses a drop under the shutdown overlay", () => {
+ const el = pane({});
+ const overlay = document.createElement("div");
+ overlay.setAttribute("data-blocks-drop", "true");
+ document.body.appendChild(overlay);
+ stubElementFromPoint(overlay);
+
+ 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", () => {
+ // `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.
+ const el = pane({});
+ const { backdrop } = openModal();
+ backdrop.setAttribute("hidden", "");
+
+ expect(dropIsBlocked()).toBe(false);
+ stubElementFromPoint(backdrop);
+ 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
+ // `` for a point over nothing in particular. Neither is evidence
+ // that the pane is clear, so the conservative answer is the old one.
+ const el = pane({});
+ openModal();
+
+ stubElementFromPoint(null);
+ expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
+
+ 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", () => {
+ // The caller reports one and stays silent about the other: a drop that
+ // was aimed at this pane and swallowed by an overlay is invisible unless
+ // something says so, while a drop into another pane is not ours to
+ // narrate.
+ const el = pane({});
+ const { backdrop } = openModal();
+ stubElementFromPoint(backdrop);
+
+ expect(classifyDrop(el, { x: 50, y: 50 }, { devicePixelRatio: 1 })).toBe("blocked");
+ expect(classifyDrop(el, { x: 900, y: 900 }, { 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);
+ });
+ });
});
diff --git a/app/src/lib/dropTarget.ts b/app/src/lib/dropTarget.ts
index 534009e..e4a3266 100644
--- a/app/src/lib/dropTarget.ts
+++ b/app/src/lib/dropTarget.ts
@@ -11,8 +11,38 @@
* shutdown overlay, which is on screen precisely while nothing should be
* accepting work at all.
*
- * So the hit test is now: nothing is covering the window, **and** the point is
- * inside my rect, **and** whatever is actually painted at that point is mine.
+ * So the hit test is: the point is inside my rect, **and** nothing that
+ * *swallows* drops is painted at that point.
+ *
+ * ## The question the z-order test asks — and the one it must not ask
+ *
+ * The first version of this asked `el.contains(document.elementFromPoint(x,y))`
+ * — "is the thing painted here mine?" That is the wrong question, and it
+ * created permanent dead zones. Panes have chrome painted *over* them that is
+ * not part of the element handed to this function and does not swallow
+ * anything: `TerminalView`'s always-present "▼ Following" button, the URL
+ * 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
+ * here?". A button, a toast or a tooltip over the pane is not one; a modal
+ * backdrop is. Anything else painted at the point belongs to the pane's own
+ * subtree or is chrome that is happy for the drop to fall through to it.
+ *
+ * That rule is also what *scopes* the blocking question. `dropIsBlocked` is
+ * document-wide, and `ui/Modal` portals to `document.body`, so any dialog
+ * anywhere used to refuse every drop in the window. Asking per-point means a
+ * dialog only refuses the points it actually covers.
+ *
+ * ## jsdom
+ *
+ * jsdom implements no layout and has no `elementFromPoint`, so the z-order
+ * branch cannot be exercised by simply rendering — which is exactly how the
+ * containment bug shipped green through 81 drop tests. Every test that cares
+ * about z-order therefore stubs `elementFromPoint` (see `dropTarget.test.ts`),
+ * and the fallback below — when there is no such API, or it cannot resolve the
+ * point — is the conservative document-wide question this used to ask.
*/
export interface DropPoint {
@@ -25,57 +55,146 @@ export interface DropPoint {
*
* `[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`
- * is for full-window overlays that are not dialogs (the shutdown overlay).
+ * 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
+ * `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"]';
-/** True while a modal or a blocking overlay is on screen. */
+/**
+ * A blocker inside one of these is in the DOM but not on screen — `ui/Modal`
+ * marks itself this way when the pane that owns it is not the visible one, so
+ * a dialog left open in project A stops covering project B the moment the tab
+ * changes.
+ */
+const OFFSCREEN_SELECTOR = '[hidden],[aria-hidden="true"]';
+
+/** A blocker that is actually painted, rather than merely mounted. */
+function isOnScreen(el: Element): boolean {
+ return el.closest(OFFSCREEN_SELECTOR) === null;
+}
+
+/** True while a modal or a blocking overlay is on screen *anywhere*. */
export function dropIsBlocked(doc: Document = document): boolean {
- return doc.querySelector(BLOCKING_SELECTOR) !== null;
+ return Array.from(doc.querySelectorAll(BLOCKING_SELECTOR)).some(isOnScreen);
}
export interface DropTargetOptions {
doc?: Document;
/** Override the ratio used to convert physical pixels to CSS pixels. */
devicePixelRatio?: number;
+ /**
+ * Override the platform question "does this payload arrive in physical
+ * pixels?". Tests use it; nothing in the app passes it.
+ */
+ physicalPixelPayload?: boolean;
}
/**
- * Whether a native drop at `pos` (physical pixels) belongs to `el`.
+ * Whether the drop payload's coordinates are physical device pixels.
+ *
+ * **Only Windows delivers physical pixels.** `wry`'s WebView2 drag-drop
+ * handler reads the point from the OS in device pixels and passes it through
+ * (`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.
+ * Dividing by `devicePixelRatio` unconditionally therefore halved every drop
+ * position on a HiDPI Mac or Linux box — which used to be a silent
+ * mis-aimed-but-usually-still-inside-the-pane error and, with a z-order test
+ * 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
+ * real HiDPI macOS or GTK machine — neither is available here — which is why
+ * the divisor is platform-conditional rather than simply deleted: the Windows
+ * behaviour is the one covered by tests and by the shipped code path.
+ */
+function payloadIsPhysical(
+ view: (Window & typeof globalThis) | null,
+ options: DropTargetOptions,
+): boolean {
+ if (options.physicalPixelPayload !== undefined) return options.physicalPixelPayload;
+ return /windows/i.test(view?.navigator?.userAgent ?? "");
+}
+
+/**
+ * Why a native drop at `pos` did or did not belong to `el`.
+ *
+ * - `accept` — it is ours.
+ * - `blocked` — it landed on our rect, but a modal or a blocking overlay is
+ * painted there and swallowed it. Worth *saying* to the user: the drop
+ * visibly did nothing.
+ * - `elsewhere` — not our drop. Silence is the right response; some other
+ * pane's listener is about to accept it.
*
* A hidden pane is `display:none` and therefore has a zero-size rect, which is
* what stops two panes both claiming the same drop.
*/
+export type DropVerdict = "accept" | "blocked" | "elsewhere";
+
+export function classifyDrop(
+ el: HTMLElement | null | undefined,
+ pos: DropPoint,
+ options: DropTargetOptions = {},
+): DropVerdict {
+ const doc = options.doc ?? el?.ownerDocument ?? document;
+
+ const rect = el?.getBoundingClientRect();
+ if (!el || !rect || rect.width === 0 || rect.height === 0) return "elsewhere";
+
+ const view = doc.defaultView;
+ const dpr =
+ options.devicePixelRatio ??
+ (payloadIsPhysical(view, options) ? view?.devicePixelRatio || 1 : 1);
+ const x = pos.x / dpr;
+ const y = pos.y / dpr;
+ if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
+ return "elsewhere";
+ }
+
+ // Z-order, where the environment can answer it. `elementFromPoint` skips
+ // `pointer-events: none`, so the pane's own decorative drop hint does not
+ // 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";
+}
+
+/**
+ * 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 ``/`` — 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
+ * elsewhere) belongs to `el`.
+ */
export function isDropTarget(
el: HTMLElement | null | undefined,
pos: DropPoint,
options: DropTargetOptions = {},
): boolean {
- const doc = options.doc ?? el?.ownerDocument ?? document;
- if (dropIsBlocked(doc)) return false;
-
- const rect = el?.getBoundingClientRect();
- if (!el || !rect || rect.width === 0 || rect.height === 0) return false;
-
- const dpr =
- options.devicePixelRatio ??
- (doc.defaultView?.devicePixelRatio || 1);
- const x = pos.x / dpr;
- const y = pos.y / dpr;
- if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
- return false;
- }
-
- // Z-order, where the environment can answer it. `elementFromPoint` skips
- // `pointer-events: none`, so the pane's own decorative drop hint does not
- // count as something covering it. jsdom has no layout and returns null,
- // which is treated as "no opinion" rather than "not mine".
- if (typeof doc.elementFromPoint === "function") {
- const top = doc.elementFromPoint(x, y);
- if (top && top !== doc.body && top !== doc.documentElement && !el.contains(top)) {
- return false;
- }
- }
-
- return true;
+ return classifyDrop(el, pos, options) === "accept";
}
diff --git a/app/src/lib/uploadErrors.test.ts b/app/src/lib/uploadErrors.test.ts
index f5c9c6e..e701694 100644
--- a/app/src/lib/uploadErrors.test.ts
+++ b/app/src/lib/uploadErrors.test.ts
@@ -49,6 +49,42 @@ describe("isFileExistsError", () => {
expect(isFileExistsError(42)).toBe(false);
expect(isFileExistsError({})).toBe(false);
});
+
+ it("cannot be forged by the name of the file being uploaded", () => {
+ // The one that mattered. Matching `fileexists` anywhere in a normalised
+ // error meant a host file called `file-exists.txt` turned *every* failure
+ // into a collision: the overwrite prompt appeared over a permission error,
+ // and Replace re-invoked the upload with `overwrite: true`, clobbering
+ // whatever shared that name in the container.
+ expect(
+ isFileExistsError("Failed to upload /host/file-exists.txt: Permission denied"),
+ ).toBe(false);
+ expect(
+ isFileExistsError({
+ message: "cp: cannot create regular file '/workspace/FILE_EXISTS.txt'",
+ }),
+ ).toBe(false);
+ expect(isFileExistsError("no space left on device: /host/File Exists.png")).toBe(
+ false,
+ );
+ // A path that merely ends in the marker is a path, not the marker.
+ expect(isFileExistsError("cannot stat /workspace/FILE_EXISTS: no such file")).toBe(
+ false,
+ );
+ // …while the contract's own shape still reads as the refusal it is.
+ expect(
+ isFileExistsError("FILE_EXISTS: /workspace/file-exists.txt already exists"),
+ ).toBe(true);
+ });
+
+ it("still reads a wrapped error whose `error` field is a whole sentence", () => {
+ // `error` is listed as a discriminant field but routinely carries prose,
+ // so it is held to both standards.
+ expect(
+ isFileExistsError({ error: "FILE_EXISTS: /workspace/a.txt already exists" }),
+ ).toBe(true);
+ expect(isFileExistsError({ error: "upload of file-exists.txt failed" })).toBe(false);
+ });
});
describe("fileExistsPath", () => {
diff --git a/app/src/lib/uploadErrors.ts b/app/src/lib/uploadErrors.ts
index 87fa3b5..d329332 100644
--- a/app/src/lib/uploadErrors.ts
+++ b/app/src/lib/uploadErrors.ts
@@ -25,6 +25,26 @@
* a `#[derive(Serialize)]` error enum, or an `Error` if a JS layer wrapped it
* on the way through. All three are the same refusal, and the UI must not
* behave differently depending on which one a future refactor produces.
+ *
+ * **Tolerant about shape is not the same as tolerant about content.** This
+ * used to normalise the whole error (lower-case, `_`/`-` stripped) and ask
+ * whether `fileexists` appeared *anywhere* in it — which a host file named
+ * `file-exists.txt` satisfies on its way through any error at all. Uploading
+ * that file and hitting "permission denied" therefore raised the overwrite
+ * prompt, and answering Replace re-invoked the upload with `overwrite: true`:
+ * an unrelated failure silently promoted into an overwrite of whatever shared
+ * the name in the container. So the marker now has to appear in a form a
+ * *filename* cannot produce:
+ *
+ * - in prose, the canonical `FILE_EXISTS` (or `FILE-EXISTS`) in upper case,
+ * standing alone — end of string, or followed by the `:`/`=` of the agreed
+ * `FILE_EXISTS: ` form. `file-exists.txt`, `FILE_EXISTS.txt` and
+ * `/workspace/FILE_EXISTS` all fail that, because a filename brings its own
+ * extension, quote or path separator along with it.
+ * - in a discriminant field, the *whole* value, case- and separator-insensitive
+ * (`FileExists`, `file_exists`, `file-exists`, `FileExistsError`) — a
+ * discriminant is a variant name, not a sentence, so equality is the right
+ * test and a filename never gets to be one.
*/
/** Marker the backend puts in the error for "a file with this name is already there". */
@@ -47,6 +67,26 @@ function normaliseKind(value: string): string {
const KIND_NEEDLE = normaliseKind(FILE_EXISTS_MARKER);
+/**
+ * The marker standing on its own inside a sentence.
+ *
+ * Derived from `FILE_EXISTS_MARKER` so the two cannot drift. Upper case is
+ * load-bearing (a lower-case `file-exists` is a plausible filename, the
+ * upper-case token is not), and so is the lookahead: the marker must end the
+ * string or be followed by the `:`/`=` that introduces the path. That is what
+ * a path or a filename cannot forge — `FILE_EXISTS.txt`, `"FILE_EXISTS"` and
+ * `/workspace/FILE_EXISTS` are each rejected by one end or the other.
+ */
+const PROSE_MARKER = new RegExp(
+ `(?:^|[\\s:;(\\[{"'\`])${FILE_EXISTS_MARKER.replace(/_/g, "[_-]")}(?=$|[\\s:=])`,
+);
+
+/** A discriminant *is* the refusal, rather than mentioning it. */
+function isFileExistsDiscriminant(value: string): boolean {
+ const normalised = normaliseKind(value);
+ return normalised === KIND_NEEDLE || normalised === `${KIND_NEEDLE}error`;
+}
+
function asRecord(e: unknown): Record | null {
return typeof e === "object" && e !== null ? (e as Record) : null;
}
@@ -57,17 +97,40 @@ function asRecord(e: unknown): Record | null {
* because a wrapped error (`{ error: { kind: … } }`) is the same refusal.
*/
function stringsIn(e: unknown, depth = 0): string[] {
- if (typeof e === "string") return [e];
- if (e instanceof Error) return [e.message, e.name];
+ const { prose, kinds } = partitionStrings(e, depth);
+ return [...prose, ...kinds];
+}
+
+/**
+ * The same flattening, but keeping track of *where* each string came from.
+ *
+ * A discriminant field and a message field are held to different standards
+ * (see the module comment), so they cannot be pooled. `error` is listed as a
+ * discriminant field and yet routinely carries a whole sentence, which is why
+ * a kind string is tested against both rules and a prose string only against
+ * the prose one.
+ */
+function partitionStrings(
+ e: unknown,
+ depth = 0,
+): { prose: string[]; kinds: string[] } {
+ if (typeof e === "string") return { prose: [e], kinds: [] };
+ if (e instanceof Error) return { prose: [e.message], kinds: [e.name] };
const record = asRecord(e);
- if (!record || depth > 1) return [];
- const out: string[] = [];
- for (const field of [...KIND_FIELDS, ...MESSAGE_FIELDS]) {
- const value = record[field];
- if (typeof value === "string") out.push(value);
- else if (value !== undefined) out.push(...stringsIn(value, depth + 1));
- }
- return out;
+ if (!record || depth > 1) return { prose: [], kinds: [] };
+ const prose: string[] = [];
+ const kinds: string[] = [];
+ const walk = (value: unknown, into: string[]) => {
+ if (typeof value === "string") into.push(value);
+ else if (value !== undefined) {
+ const nested = partitionStrings(value, depth + 1);
+ prose.push(...nested.prose);
+ kinds.push(...nested.kinds);
+ }
+ };
+ for (const field of KIND_FIELDS) walk(record[field], kinds);
+ for (const field of MESSAGE_FIELDS) walk(record[field], prose);
+ return { prose, kinds };
}
/**
@@ -78,7 +141,11 @@ function stringsIn(e: unknown, depth = 0): string[] {
* to work.
*/
export function isFileExistsError(e: unknown): boolean {
- return stringsIn(e).some((s) => normaliseKind(s).includes(KIND_NEEDLE));
+ const { prose, kinds } = partitionStrings(e);
+ return (
+ kinds.some((s) => isFileExistsDiscriminant(s) || PROSE_MARKER.test(s)) ||
+ prose.some((s) => PROSE_MARKER.test(s))
+ );
}
/**