Files pane
- F16: a drag-out released back inside the app no longer re-imports its own
staged copy over the container original. An in-flight flag (cleared from the
drag plugin's `onEvent` channel, with a watchdog) suppresses the drop and the
"Drop files into …" hint, and an exact staged-path filter is the second line
of defence — the `path|size|modified` cache could otherwise write a
minutes-old snapshot over a file an agent had since rewritten.
- F17: a slow upload/rename no longer yanks the user back to the directory the
operation started in. Every operation captures its target path and re-lists
only if the user is still there; failures go to the toast host either way.
- The grid keeps keyboard focus. Roving tabindex (one tab stop, not one per
row) plus focus restore after navigation, rename commit/cancel and Escape.
- Transient failures now surface in `ToastHost` (z-[60], persistent aria-live)
instead of a `role="alert"` 300 rows down a scroller or behind a modal
overlay. The inline error is kept only for the listing failure.
- `navigate` is sequenced by generation; "Save to host…" sets `busy`.
- Grid a11y: column headers, a text affordance for folder vs file, a live
region that is mounted empty and announces completion, Label-in-Name fixed.
- FileViewerModal: the blob URL is released only once its replacement exists;
the preview is a focusable, named, scrollable region.
Native drop routing
- New `lib/dropTarget.ts`: the hit test now refuses a drop while any
`[aria-modal="true"]` dialog or `[data-blocks-drop]` overlay is up, and
checks z-order where the environment can answer it. Shared by FilesTab and
TerminalView; App's shutdown overlay opts in.
Disk
- A partially failed reclaim says so in words ("… — 2 of 5 failed"), not by hue
alone.
- The scan/reclaim race is closed: every mutation retires an in-flight scan, so
a scan can no longer repaint a pre-reclaim report plus a clickable plan of
objects that are gone. Scan is disabled while working; the status is a live
region; a failed destructive action keeps its dialog open and reports there.
- The "unknown" layer count gets a screen-reader fallback; `--text-disabled`
no longer carries live information.
Terminal / OAuth
- After the toast is dismissed, a truncated heuristic guess can no longer fill
the slot that an exact OSC 8 or relay URL occupied — the detector remembers
every exact URL and drops any candidate that is a strict prefix of one.
- The prompt is reachable by keyboard: Ctrl+Shift+O jumps to the default
action, Escape dismisses, focus returns to the terminal, and auto-dismiss
holds off while focus is inside. It deliberately does not steal focus.
- UrlToast renders through `ui/Button` and `--shadow-overlay`.
Elsewhere
- AuthBridgeRow: a pushed `auth-bridge-changed` status always outranks an older
awaited toggle result.
- The last two ad-hoc byte formatters route through `lib/formatBytes`.
Contract for the backend agent: `upload_file_to_container` refusing to
overwrite must satisfy `isFileExistsError` in `src/lib/uploadErrors.ts` (marker
`FILE_EXISTS`) and accept an `overwrite` argument; the frontend turns that into
an `ui/Modal` Replace/Skip prompt rather than a raw error string.
Tests: 536 -> 627 passing. `npm run build` and `npx tsc --noEmit` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
82 lines
2.9 KiB
TypeScript
82 lines
2.9 KiB
TypeScript
/**
|
|
* Routing for Tauri's *native* drag-drop event.
|
|
*
|
|
* The listener is window-wide — every pane that wants dropped file paths gets
|
|
* the same event — so each one decides for itself whether the drop was meant
|
|
* for it. That decision used to be purely geometric: is the payload position
|
|
* inside my rect? A rect is not what the user sees, though. An open `Modal` is
|
|
* a `fixed inset-0` portal at `z-50` painted *over* the whole window, and the
|
|
* pane underneath still had its rect, so releasing a drag onto a dialog
|
|
* uploaded the file into the directory the dialog was covering. Same for the
|
|
* 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.
|
|
*/
|
|
|
|
export interface DropPoint {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
/**
|
|
* Anything that swallows a drop wherever it lands.
|
|
*
|
|
* `[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).
|
|
*/
|
|
const BLOCKING_SELECTOR = '[aria-modal="true"],[data-blocks-drop="true"]';
|
|
|
|
/** True while a modal or a blocking overlay is on screen. */
|
|
export function dropIsBlocked(doc: Document = document): boolean {
|
|
return doc.querySelector(BLOCKING_SELECTOR) !== null;
|
|
}
|
|
|
|
export interface DropTargetOptions {
|
|
doc?: Document;
|
|
/** Override the ratio used to convert physical pixels to CSS pixels. */
|
|
devicePixelRatio?: number;
|
|
}
|
|
|
|
/**
|
|
* Whether a native drop at `pos` (physical pixels) belongs to `el`.
|
|
*
|
|
* 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 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;
|
|
}
|