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:
@@ -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;
|
||||
|
||||
@@ -63,13 +63,25 @@ export interface DiskUsageState {
|
||||
outcome: ReclaimOutcome | null;
|
||||
scan: () => Promise<void>;
|
||||
/**
|
||||
* 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<boolean>;
|
||||
/** 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<boolean>;
|
||||
/** Run the orphaned-snapshot sweep and report what it found *and refused*. */
|
||||
runSweep: () => Promise<void>;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user