Stop the terminal and Files panes refusing drops onto their own chrome

The z-order gate added last round asked `el.contains(elementFromPoint(x, y))`
— "is the thing painted here mine?" — and was handed `TerminalView`'s inner
xterm host while every overlay in that pane is a *sibling* of it. So any point
under the pane's own chrome answered "not mine" and the drop was refused, with
no message and no log line. The "▼ Following / ▽ Paused" toggle is rendered
unconditionally at `absolute top-2 right-4`, and `ToastHost` is `fixed
bottom-4 right-4` 24rem wide with error cards that never time out: two corners
of the terminal, and one of the Files pane, that could not accept a file for
as long as the app was running.

It shipped green because jsdom has no `elementFromPoint`, so not one of the 81
drop tests entered that branch. The tests here install one.

The question the gate asks is now "is a *blocking overlay* painted here?".
Chrome the pane paints over itself is not one; a dialog backdrop is, and
`ui/Modal` marks its own backdrop so the element `elementFromPoint` actually
returns is the one carrying the marker. `classifyDrop` also separates "aimed at
me and swallowed" from "not my drop", so the first gets a toast and a log line
and the second stays silent.

Three defects around it:

- **A dialog now refuses only the points it covers.** `dropIsBlocked` is
  document-wide and `ui/Modal` portals to `document.body`, so any open dialog
  refused every drop in the window. The deeper half of that is that a dialog
  opened in project A really was still on screen after a tab switch — the pane
  hides itself with a `hidden` class, which a portal does not inherit — so
  `PaneVisibility` lets `App` tell a `Modal` its pane stepped aside, and a
  hidden one paints nothing, traps no focus, answers no Escape and blocks no
  drop while staying mounted with its state intact.

- **`devicePixelRatio` is applied on Windows only.** Only wry's WebView2
  backend hands over physical pixels; the macOS and GTK ones deliver logical
  points and `tauri-runtime-wry` does not rescale them. Halving those was
  survivable while the test was a bare rect and is a refused drop once z-order
  joins in. Read from the wry/tauri sources, not verified on a HiDPI Mac or
  GTK box.

- **`isFileExistsError` can no longer be forged by a filename.** It matched
  `fileexists` anywhere in a normalised error, so uploading a host file called
  `file-exists.txt` turned *any* failure into a collision — and Replace
  re-invoked the upload with `overwrite: true`. The marker now has to stand
  alone in the backend's canonical form, or be a whole discriminant value.

- **A refused compaction or cache-clear keeps its dialog.** `reclaim` reports
  refusals inside `Ok`, so "did it throw" read one as success: the dialog
  closed, the tick list was dropped, and the explanation appeared in the
  outcome panel several screens above the row that was clicked. The dialog now
  stays put and renders the backend's own sentence verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 13:03:57 -07:00
co-authored by Claude Opus 5
parent 42ef1865cc
commit 5926a52ff6
16 changed files with 1050 additions and 101 deletions
+235 -1
View File
@@ -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<DOMRect>): HTMLElement {
const el = document.createElement("div");
@@ -20,12 +20,62 @@ function pane(rect: Partial<DOMRect>): 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<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", () => {
@@ -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
// `<body>` 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);
});
});
});
+152 -33
View File
@@ -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 `<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
* 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";
}
+36
View File
@@ -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", () => {
+78 -11
View File
@@ -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: <path>` 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<string, unknown> | null {
return typeof e === "object" && e !== null ? (e as Record<string, unknown>) : null;
}
@@ -57,17 +97,40 @@ function asRecord(e: unknown): Record<string, unknown> | 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))
);
}
/**