Drag a pane's toolbar onto another pane to swap them

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) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 19:47:06 +01:00
parent c4747546e0
commit c93ebddfa5
5 changed files with 172 additions and 2 deletions

View file

@ -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<NodeId | null>(null);
const [dragOverId, setDragOverId] = useState<NodeId | null>(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<Orchestration>(
() => ({
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,
],
);