Drag tabs into splits, rebuild pane reordering on one placement engine, and add Separate Panes - #228
Conversation
…anes (thdxg#227) Dragging a sidebar tab onto another tab row merges the two into a split: on a single-pane destination the hovered half of the row picks which side the dragged tab lands on, and an already-split destination highlights whole and appends. Dropping a tab into the workspace reuses the pane drop zones, so the dragged tab's tree lands on the chosen side of the target pane. Panes and their live surfaces are reused as-is throughout — the merge is a pure tree reshape, mirroring movePane. The inverse is a Separate Panes context-menu item on split tabs: every pane after the first moves into its own fresh tab right after the source, shells intact. Split tabs also render one container per pane in the sidebar instead of concatenating titles with a pipe.
…ane split Where the cursor sits picks the globality of the split, not just its side (thdxg#227 discussion). Near a workspace edge on the root's own axis the tab becomes a new top-level column (thirds for all); on a divider it inserts between the siblings (thirds); just past the midline on the perpendicular axis it takes the whole edge (a full-width bottom pane); anywhere deeper it splits the hovered pane locally (1/4 1/4 1/2). TabDropPlacer resolves a normalized cursor point against the split tree and returns the target plus the exact region the tab would occupy, which the workspace-level drop overlay renders as the preview — so what you see is what you get. Local drops keep the halving pref; divider and edge drops always equalize, because their meaning is positional parity.
…bands The root-axis edge and divider bands were gated behind the hovered pane's corner triangles, so near the top or bottom of a pane the vertical zones swallowed the cursor and a side-by-side drop had almost no hitbox. Those bands now claim their margins at full cross-axis extent, and the perpendicular whole-edge band goes back to its original midline-to-75% size.
|
Here's a demo: Screen.Recording.2026-08-06.at.15.44.24.mov |
Window-state benchmark
|
The three-dots pane drag used its own per-leaf drop targets that could only reorder relative to a single pane. It now lands on the same workspace-level WorkspaceDropTarget as sidebar tab drops, resolved by the same TabDropPlacer bands — so a dragged pane can take a whole edge, insert at a divider as an equal sibling, or split a pane locally, with the identical what-you-see preview. TerminalTab.movePane(to:) detaches the pane and re-merges it through mergeTree, and the legacy movePane(onto:zone:) is now a one-line alias for the .pane target. The per-leaf PaneDropDelegate, its zone highlight, and the onMovePane plumbing through SplitTreeView are gone.
…rgets immediately AppKit only delivers dragging-entered on a transition INTO a destination, so the single whole-workspace target never heard about a grab-handle drag that began inside it — the preview stayed dead until the cursor left the window and came back. Pane payloads are now captured by one small target per leaf (the dragged pane's own leaf carries none), each converting its local location into workspace space and resolving through the same TabDropPlacer into one shared resolution, so the placement bands and the workspace-level preview are unchanged and unduplicated. Tab drags keep the whole-area target, which works because they start outside it. Also pins the reorder contract with a test: a moved pane vanishes from its old spot and every pane survives exactly once.
SwiftUI routes a drag to the topmost drop target by geometry and does not fall through on a type mismatch, so the tab-only target layered over the workspace swallowed every pane-drag session before the leaf targets could see it — the drag went fully dead. The leaves (which tile the workspace exactly) now accept BOTH payloads and are the sole drop layer; the workspace overlay is a pure preview renderer with no target at all. Placement still resolves through the one TabDropPlacer path either way.
…s down Drag-and-drop reorders left panes blank: shells alive, views gone. Traced live (SurfaceAttach debug logging, kept in-tree), it was three distinct teardown behaviors around the pane-owned NSView: 1. SwiftUI builds a reshape's new hierarchy before tearing down the old one, and the old teardown yanks the shared view out of its NEW superview. The representable now returns a disposable container per identity and re-parents the pane's scroll view into it explicitly. 2. SwiftUI creates several transient containers per pane in one remount, and make/update call order says nothing about which survives — so the container that lands in the window claims the view unconditionally. 3. SwiftUI can also deallocate an adopted container outright, killing any weak re-attach pointer. The backstop is model-driven: an orphaned view with no live host bumps Pane.surfaceReattachTick, re-rendering the pane's subtree so SwiftUI re-attaches via a container it guarantees is alive. A view that lands in a window without a surface also creates it (the healing paths bypass the make/update path that normally would). Reproduced and verified headlessly via new DEBUG-only CLI verbs that drive the same code paths as the drags: pane move (movePane(to:)) and tab merge (mergeTab(at:)).
… splittable With four side-by-side panes the fixed workspace-relative bands (0.15 on each side) swallowed every pixel of every 0.25-wide column — the edge band claimed x < 0.15 and the divider band claimed x > 0.10 — so no drop could ever express a top/bottom placement. Each band now claims at most 30% of the hovered pane's extent on its axis, leaving the middle 40% of any pane for the zone-driven placements. At two panes the cap equals the workspace band, so wide layouts are numerically unchanged (every existing placement test passes untouched).
|
I'd love to hear some feedback on the placement engine! |
# Conflicts: # CLI/MactermCommand.swift # Macterm/Control/ControlProtocol.swift
Implements #227.
What's here
TabDropPlacer): the cursor picks not just a side but a LEVEL in the split tree, and the highlight previews exactly the region the tab will occupy.TerminalTab.movePane(to:)detaches the pane and re-merges it through the sharedmergeTree— the moved pane always vanishes from its old spot (the tree collapses around it) and every other pane survives exactly once, pinned by test. The legacymovePane(onto:zone:)survives as a one-line alias into the shared path, so its whole test suite doubles as regression coverage; the quick terminal shares the same drop machinery (pane drags only).How
TerminalSurfacenow gives SwiftUI a disposable container per identity and re-parents the pane's real view into it, with layered healing — window-entry claim, orphan re-attach, late surface creation, and an observablePane.surfaceReattachTickas the owner-of-last-resort so a stranded view always re-renders onto a container SwiftUI guarantees is alive. Verified with liveSurfaceAttachevent traces (debug logging kept in-tree) and headless repros via new DEBUG-only CLI verbs (pane move,tab merge) that drive the same code paths as the drags.SplitNode.inserting(node:...)generalizes the single-pane insert so a whole subtree lands beside a pane in one structural move;tileUnitsis exposed to size drop previews.TerminalTab.adoptTree/insertTree/mergeTreeare the tab-level merges — local drops keep the halving pref, divider/edge drops always equalize (their meaning is positional parity).TabDropPlaceris a pure resolver from a normalized cursor point to a target + preview rect.AppState.mergeTab(intoTab:side:)/mergeTab(at:inProject:)detach the source tab with panes alive, rebind routing identity on cross-project merges (mirroringmoveTab), and restore the tab if the target vanished mid-drag.separateTabPanesis the inverse.Tests
TabDropPlacementTests: the placement bands band-by-band, previews, full-height side-by-side margins, single-pane root, and nested-divider cases.TerminalTabTests:adoptTree/insertTree/mergeTreestructure + ratio assertions (thirds, full-width half, 1/4-1/4-1/2),sidebarRowTitlerename behavior.AppStateTests: merge into tab (sides, whole-tree, self-merge no-op, cross-project rebind + activation), resolution-target merges, separate panes.