From c93ebddfa5fbf32e50bd69e6449a5cbcb232fe33 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 19:47:06 +0100 Subject: [PATCH] Drag a pane's toolbar onto another pane to swap them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New interaction: click-and-drag any pane's toolbar onto another pane to swap their positions in the tree. The shells / scrollback stay intact (each leaf keeps its data; only the tree slot it occupies changes). Implementation: - tree.ts: `swapLeaves(root, idA, idB)` walks the tree once, substituting one leaf for the other at each occurrence. The leaf objects themselves carry their id/distro/cwd/label/broadcast across, so React preserves the LeafPane instances via the flat-list keying. - orchestration.tsx: add drag lifecycle to the context — dragSourceId / dragOverId (reactive) plus beginHeaderDrag, setHeaderDragOver, endHeaderDrag (stable methods). - App.tsx: implement those methods. endHeaderDrag(true) swaps if source and over are different leaves. - LeafPane.tsx: pointerdown on .pane-toolbar (skipped if the target is a button/input). 5px movement threshold before drag commits to prevent accidental swaps when clicking a chip etc. Pointer-capture the toolbar so we keep getting move events even outside it. Use document.elementFromPoint to find the leaf under the cursor. - CSS: source pane fades to 40% opacity during drag; target pane shows a 3px dashed blue outline; toolbar shows grab/grabbing cursors. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 37 +++++++++++++ src/lib/layout/LeafPane.css | 15 ++++++ src/lib/layout/LeafPane.tsx | 92 +++++++++++++++++++++++++++++++- src/lib/layout/orchestration.tsx | 8 +++ src/lib/layout/tree.ts | 22 ++++++++ 5 files changed, 172 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index de31fff..f5fc214 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { reshapeToPreset, flattenLayout, updateSplitRatio, + swapLeaves, serialize, deserialize, presetSingle, @@ -238,6 +239,32 @@ export default function App() { setNotifications((ns) => ns.filter((n) => n.id !== id)); }, []); + // ---- header-drag swap --------------------------------------------------- + const [dragSourceId, setDragSourceId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + const beginHeaderDrag = useCallback((leafId: NodeId) => { + setDragSourceId(leafId); + setDragOverId(null); + }, []); + const setHeaderDragOver = useCallback((leafId: NodeId | null) => { + setDragOverId(leafId); + }, []); + const endHeaderDrag = useCallback( + (commitSwap: boolean) => { + if ( + commitSwap && + dragSourceId && + dragOverId && + dragSourceId !== dragOverId + ) { + setTree((t) => swapLeaves(t, dragSourceId, dragOverId)); + } + setDragSourceId(null); + setDragOverId(null); + }, + [dragSourceId, dragOverId], + ); + const orch = useMemo( () => ({ activeLeafId, @@ -251,6 +278,11 @@ export default function App() { registerPaneId, broadcastFrom, notify, + dragSourceId, + dragOverId, + beginHeaderDrag, + setHeaderDragOver, + endHeaderDrag, }), [ activeLeafId, @@ -264,6 +296,11 @@ export default function App() { registerPaneId, broadcastFrom, notify, + dragSourceId, + dragOverId, + beginHeaderDrag, + setHeaderDragOver, + endHeaderDrag, ], ); diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index f66b272..fede6ce 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -17,6 +17,21 @@ .leaf.active.broadcasting { border-color: #ffb840; } +.leaf.drag-source { + opacity: 0.4; +} +.leaf.drag-target { + outline: 3px dashed #5a8cd8; + outline-offset: -3px; +} + +/* Drag handle hint on the toolbar */ +.pane-toolbar { + cursor: grab; +} +.pane-toolbar:active { + cursor: grabbing; +} .pane-toolbar { flex: 0 0 auto; diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index e8e1ffe..62a1996 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -5,6 +5,7 @@ import { useCallback, type KeyboardEvent, type MouseEvent, + type PointerEvent as ReactPointerEvent, } from "react"; import type { LeafNode } from "./tree"; import { useOrchestration } from "./orchestration"; @@ -143,17 +144,104 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { setStatusOk(ok); }, []); + // ---- header-drag swap --------------------------------------------------- + // Drag the toolbar onto another pane's toolbar/body to swap their tree + // positions. Uses a movement threshold so accidental tiny moves while + // clicking a label etc don't initiate a drag. + const DRAG_THRESHOLD_PX = 5; + const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>( + null, + ); + const isDragSource = orch.dragSourceId === leaf.id; + const isDragTarget = + orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id; + + const onToolbarPointerDown = useCallback( + (e: ReactPointerEvent) => { + const target = e.target as HTMLElement; + // Skip if the click landed on an interactive child. + if (target.closest("button, input, .distro-menu")) return; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + dragStartRef.current = { + x: e.clientX, + y: e.clientY, + armed: true, + dragging: false, + }; + // Make this pane active (since clicking the toolbar should focus it). + orch.setActive(leaf.id); + }, + [orch.setActive, leaf.id], + ); + + const onToolbarPointerMove = useCallback( + (e: ReactPointerEvent) => { + const st = dragStartRef.current; + if (!st || !st.armed) return; + const dx = e.clientX - st.x; + const dy = e.clientY - st.y; + if (!st.dragging) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; + st.dragging = true; + orch.beginHeaderDrag(leaf.id); + document.body.style.cursor = "grabbing"; + } + // Find the leaf under the cursor. + const el = document.elementFromPoint(e.clientX, e.clientY); + const tEl = el?.closest("[data-leaf-id]"); + const targetId = tEl?.getAttribute("data-leaf-id") ?? null; + orch.setHeaderDragOver(targetId); + }, + [orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id], + ); + + const onToolbarPointerUp = useCallback( + (e: ReactPointerEvent) => { + const st = dragStartRef.current; + if (!st) return; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + const wasDragging = st.dragging; + dragStartRef.current = null; + if (wasDragging) { + document.body.style.cursor = ""; + orch.endHeaderDrag(true); + } + }, + [orch.endHeaderDrag], + ); + + const onToolbarPointerCancel = useCallback( + (e: ReactPointerEvent) => { + const st = dragStartRef.current; + if (!st) return; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + const wasDragging = st.dragging; + dragStartRef.current = null; + if (wasDragging) { + document.body.style.cursor = ""; + orch.endHeaderDrag(false); + } + }, + [orch.endHeaderDrag], + ); + const labelText = leaf.label ?? "(unnamed)"; return (
-
+
{editingLabel ? ( void; broadcastFrom: (originLeafId: NodeId, dataB64: string) => void; notify: (message: string) => void; + + // Drag-header-to-swap. dragSourceId / dragOverId are reactive so leaves + // can apply hover/source styling. The lifecycle methods are stable. + dragSourceId: NodeId | null; + dragOverId: NodeId | null; + beginHeaderDrag: (leafId: NodeId) => void; + setHeaderDragOver: (leafId: NodeId | null) => void; + endHeaderDrag: (commitSwap: boolean) => void; } const OrchestrationContext = createContext(null); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index c05fdfb..761cf76 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -356,6 +356,28 @@ export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number) }); } +/** Swap two leaves' tree positions. Each leaf carries its own data + * (id, distro, cwd, label, broadcast) into the other's slot. PTYs stay + * alive because React keys on leaf.id and our renderer is flat. */ +export function swapLeaves(root: TreeNode, idA: NodeId, idB: NodeId): TreeNode { + if (idA === idB) return root; + const a = findLeaf(root, idA); + const b = findLeaf(root, idB); + if (!a || !b) return root; + function walk(n: TreeNode): TreeNode { + if (n.kind === "leaf") { + if (n.id === idA) return b!; + if (n.id === idB) return a!; + return n; + } + const na = walk(n.a); + const nb = walk(n.b); + if (na === n.a && nb === n.b) return n; + return { ...n, a: na, b: nb }; + } + return walk(root); +} + export function serialize(root: TreeNode): string { return JSON.stringify(root); }