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:
@@ -943,6 +943,65 @@ describe("DiskSettings", () => {
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent(/no space left on device/);
|
||||
});
|
||||
|
||||
it("keeps the semi-safe confirmation open, in the backend's words, when it is refused", async () => {
|
||||
// A refusal comes back inside `Ok` — the command succeeded at declining —
|
||||
// so it never reaches `error`, and reading only "did it throw" closed the
|
||||
// dialog, dropped the tick list, and left the explanation in the outcome
|
||||
// panel several screens above the row that was clicked. The sentence shown
|
||||
// is the backend's own: it is the only side that knows which blocker is
|
||||
// actually holding the project.
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
items: [
|
||||
item({
|
||||
target: { kind: "compact_snapshot", project_id: "p-whp" },
|
||||
safety: "semi_safe",
|
||||
label: "Compact whp's snapshot",
|
||||
bytes: 5_100_000_000,
|
||||
bytes_are_exact: false,
|
||||
bytes_floor: 0,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
reclaim.mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
target: { kind: "compact_snapshot", project_id: "p-whp" },
|
||||
destroyed: null,
|
||||
ok: false,
|
||||
freed_bytes: 0,
|
||||
projected_bytes: null,
|
||||
message: "Cannot compact whp: a terminal session is still attached.",
|
||||
} as ReclaimResult,
|
||||
],
|
||||
total_freed_bytes: 0,
|
||||
});
|
||||
|
||||
await renderAndScan();
|
||||
const semi = await screen.findByTestId("disk-semi-bucket");
|
||||
await act(async () => {
|
||||
fireEvent.click(within(semi).getByRole("button", { name: "Run…" }));
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(
|
||||
within(screen.getByRole("dialog")).getByRole("button", { name: "Run it" }),
|
||||
);
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole("alert")).toHaveTextContent(
|
||||
/a terminal session is still attached/,
|
||||
);
|
||||
// Nothing was removed, so the row that offered the action is still there.
|
||||
expect(
|
||||
within(await screen.findByTestId("disk-semi-bucket")).getByRole("button", {
|
||||
name: "Run…",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the confirmation once the action succeeds", async () => {
|
||||
listReclaimable.mockResolvedValue(
|
||||
plan({
|
||||
|
||||
@@ -158,6 +158,18 @@ export default function DiskSettings() {
|
||||
// five targets can come back with two failures and a real byte total.
|
||||
const failedCount = outcome?.results.filter((r) => !r.ok).length ?? 0;
|
||||
|
||||
// What the backend said about the parts it refused, rendered **verbatim**.
|
||||
// A refusal arrives inside `Ok` — the command succeeded at declining — so it
|
||||
// never reaches `error`, and the dialog that asked for the work has nothing
|
||||
// else to show. Not a sentence of our own: the backend is the only side that
|
||||
// knows which blocker is actually holding the project, and one written here
|
||||
// would go stale the day that answer improves.
|
||||
const refusalText =
|
||||
outcome?.results
|
||||
.filter((r) => !r.ok)
|
||||
.map((r) => r.message)
|
||||
.join(" ") ?? "";
|
||||
|
||||
const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off";
|
||||
const statusLabel = scanning
|
||||
? "Scanning"
|
||||
@@ -706,6 +718,9 @@ export default function DiskSettings() {
|
||||
// minutes, and the dialog reporting it beats it vanishing —
|
||||
// and if it fails, the dialog is the only place the user is
|
||||
// still looking, so it stays open and reports it here.
|
||||
// `false` covers both a throw and a refusal that came back
|
||||
// inside `Ok`; either way the work did not happen, so the
|
||||
// dialog stays put and reports it where the user is looking.
|
||||
const ok = await runReclaim([confirming.target]);
|
||||
setActionFailed(!ok);
|
||||
if (ok) setConfirming(null);
|
||||
@@ -721,7 +736,7 @@ export default function DiskSettings() {
|
||||
line, which this dialog is covering. */}
|
||||
{actionFailed && (
|
||||
<p role="alert" className="text-[var(--error)]">
|
||||
{error ?? "That did not run. Nothing was changed."}
|
||||
{error ?? (refusalText || "That did not run. Nothing was changed.")}
|
||||
</p>
|
||||
)}
|
||||
<p>{confirming.detail}</p>
|
||||
@@ -787,7 +802,11 @@ export default function DiskSettings() {
|
||||
// A failure here has to land inside the dialog. The panel's own
|
||||
// error line is at the top of several screens of scroll, and this
|
||||
// dialog was reached from a project row far below it.
|
||||
error={actionFailed ? (error ?? "That did not run. Nothing was deleted.") : null}
|
||||
error={
|
||||
actionFailed
|
||||
? (error ?? (refusalText || "That did not run. Nothing was deleted."))
|
||||
: null
|
||||
}
|
||||
onCancel={closeDestroying}
|
||||
onConfirm={async (typed) => {
|
||||
// The modal stays mounted until the call settles, so its `busy`
|
||||
|
||||
Reference in New Issue
Block a user