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:
parent
bea6cf2977
commit
e6d0040021
9 changed files with 224 additions and 95 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue