Fix workspace accumulation, tab-close popover, scrollbars, drag ghost

- window_state.rs: persist only the main window's workspaces. The aggregator
  flattened every window's tabs into the saved file; main then adopted the
  whole blob on launch, so detached windows' ephemeral tabs (and Pane N
  drag-out artifacts) accumulated without bound.
- TabStrip: portal the close-confirm popover to <body> with fixed,
  viewport-clamped positioning so the horizontally-scrolling strip can't clip
  it and it never runs off a window edge.
- styles.css: make themed ::-webkit-scrollbar global, not just xterm viewport.
- LeafPane: B1 drag-out ghost chip (portal, edge-pinned, orange detach state).
- App.tsx: moveToNewWindow waits briefly for pane registration instead of
  failing instantly on an in-flight spawn/adopt.
- gitignore cargo-test.lo*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-28 20:24:09 +01:00
parent bea6cf2977
commit e6d0040021
9 changed files with 224 additions and 95 deletions

View file

@ -7,6 +7,7 @@ import {
type MouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { createPortal } from "react-dom";
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
import { useOrchestration } from "./orchestration";
import XtermPane from "../../components/XtermPane";
@ -15,6 +16,19 @@ import "./LeafPane.css";
const IDLE_THRESHOLD_MS = 5000;
/** How far past a viewport edge the cursor must travel before a release is
* treated as "drag pane out of window" instead of "drop on empty space
* inside this window". Picked so an accidental release on the OS titlebar
* (~30px tall) stays inside the threshold. */
const PANE_DRAG_OUT_MARGIN = 60;
/** True when a point is past any viewport edge by PANE_DRAG_OUT_MARGIN. */
const isFarOutsideViewport = (x: number, y: number) =>
x < -PANE_DRAG_OUT_MARGIN ||
x > window.innerWidth + PANE_DRAG_OUT_MARGIN ||
y < -PANE_DRAG_OUT_MARGIN ||
y > window.innerHeight + PANE_DRAG_OUT_MARGIN;
export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const orch = useOrchestration();
const isActive = orch.activeLeafId === leaf.id;
@ -225,6 +239,17 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>(
null,
);
// Cursor-following ghost shown while dragging the toolbar. `detach` flips
// true once the cursor is past the viewport edge by PANE_DRAG_OUT_MARGIN,
// mirroring the release condition in onToolbarPointerUp so the ghost
// previews what a release right now would do.
const [dragGhost, setDragGhost] = useState<{
x: number;
y: number;
detach: boolean;
flipX: boolean;
flipY: boolean;
} | null>(null);
const isDragSource = orch.dragSourceId === leaf.id;
const isDragTarget =
orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id;
@ -264,16 +289,27 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const tEl = el?.closest("[data-leaf-id]");
const targetId = tEl?.getAttribute("data-leaf-id") ?? null;
orch.setHeaderDragOver(targetId);
// Move the cursor-following ghost (B1). It has pointer-events:none so
// it doesn't interfere with the elementFromPoint hit-test above.
// A webview can't paint outside its own OS window, so once the cursor
// crosses the edge we clamp the chip to the viewport (and flip it to
// the cursor's inner side near right/bottom) so it stays visible and
// its `detach` styling is what previews the release. `detach` itself
// is computed from the RAW cursor position so the preview is accurate.
const GHOST_PAD = 4;
const FLIP_X_ZONE = 180; // ~max chip width
const FLIP_Y_ZONE = 48;
setDragGhost({
x: Math.max(GHOST_PAD, Math.min(e.clientX, window.innerWidth - GHOST_PAD)),
y: Math.max(GHOST_PAD, Math.min(e.clientY, window.innerHeight - GHOST_PAD)),
detach: isFarOutsideViewport(e.clientX, e.clientY),
flipX: e.clientX > window.innerWidth - FLIP_X_ZONE,
flipY: e.clientY > window.innerHeight - FLIP_Y_ZONE,
});
},
[orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id],
);
/** How far past a viewport edge the cursor must travel before a release
* is treated as "drag pane out of window" instead of "drop on empty
* space inside this window". Picked so an accidental release on the OS
* titlebar (~30px tall) stays inside the threshold. */
const PANE_DRAG_OUT_MARGIN = 60;
const onToolbarPointerUp = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
const st = dragStartRef.current;
@ -281,14 +317,11 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
const wasDragging = st.dragging;
dragStartRef.current = null;
setDragGhost(null);
if (!wasDragging) return;
document.body.style.cursor = "";
const releasedFarOutside =
e.clientX < -PANE_DRAG_OUT_MARGIN ||
e.clientX > window.innerWidth + PANE_DRAG_OUT_MARGIN ||
e.clientY < -PANE_DRAG_OUT_MARGIN ||
e.clientY > window.innerHeight + PANE_DRAG_OUT_MARGIN;
const releasedFarOutside = isFarOutsideViewport(e.clientX, e.clientY);
if (releasedFarOutside) {
// Cancel any in-flight swap state without committing, then pop
@ -310,6 +343,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
const wasDragging = st.dragging;
dragStartRef.current = null;
setDragGhost(null);
if (wasDragging) {
document.body.style.cursor = "";
orch.endHeaderDrag(false);
@ -579,6 +613,26 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
</button>
</div>
)}
{dragGhost &&
createPortal(
<div
className={`pane-drag-ghost${dragGhost.detach ? " detach" : ""}`}
style={{
left: dragGhost.x,
top: dragGhost.y,
transform: `translate(${
dragGhost.flipX ? "calc(-100% - 12px)" : "12px"
}, ${dragGhost.flipY ? "calc(-100% - 12px)" : "12px"})`,
}}
aria-hidden="true"
>
<span className="pane-drag-ghost-label">{labelText}</span>
{dragGhost.detach && (
<span className="pane-drag-ghost-hint"> New window</span>
)}
</div>,
document.body,
)}
</div>
);
}