Skip to content

Drag tabs into splits, rebuild pane reordering on one placement engine, and add Separate Panes - #228

Merged
thdxg merged 20 commits into
thdxg:mainfrom
jcuhnoio:feat/tab-drag-to-split
Aug 7, 2026
Merged

Drag tabs into splits, rebuild pane reordering on one placement engine, and add Separate Panes#228
thdxg merged 20 commits into
thdxg:mainfrom
jcuhnoio:feat/tab-drag-to-split

Conversation

@jcuhnoio

@jcuhnoio jcuhnoio commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Implements #227.

What's here

  • Drag a sidebar tab onto another tab row to merge the two into a split. On a single-pane destination, the half of the row you hover picks which side the dragged tab lands on — the splitee's row content slides aside in real time to show the drop slot (the issue's preview behavior); an already-split destination highlights whole and appends. The drag/drop region covers the entire row.
  • Drag a sidebar tab into the workspace, with level-aware targeting (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.
    • Within 15% of a workspace edge on the layout's own axis → new top-level column/row, equalized (two columns become thirds). These side-by-side margins span the full cross-axis height, not gated by the corner triangles.
    • Within 15% of a divider → insert between the siblings, equalized (thirds).
    • In between → local split of the hovered pane (1/4 · 1/4 · 1/2).
    • On the perpendicular axis, the band from the midline to 75% takes the whole edge (e.g. a full-width bottom pane); deeper splits just the hovered pane.
  • The original pane reordering (the three-dots grab handle) is rebuilt on the same placement engine. The old per-leaf drop targets could only reorder a pane relative to a single other pane; a grab-handle drag now resolves through the identical bands and preview, so a dragged pane can also take a whole edge or insert at a divider as an equal sibling. TerminalTab.movePane(to:) detaches the pane and re-merges it through the shared mergeTree — 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 legacy movePane(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).
  • Drop bands are capped per pane at 30% of the hovered pane's extent, so skinny panes stay splittable: with four side-by-side columns the fixed workspace-relative bands used to swallow every pixel of every pane, making top/bottom placements unreachable. Two-pane layouts are numerically unchanged.
  • Separate Panes context-menu item on split tabs: every pane after the first moves into its own tab, inserted in tree order after the source; shells and surfaces reused, never respawned.
  • Sidebar rendering for split tabs: 2–3 panes render as one tab-like container per pane sharing the row (per-pane icon + title); 4+ panes collapse back to a single tab titled with the pane count ("4 panes"). A custom rename always wins, and clearing it falls back to the count.

How

  • The reshapes these drags produce remount the split tree, which exposed how fragile handing the pane-owned NSView directly to SwiftUI is (several distinct teardown orders can strand it). TerminalSurface now 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 observable Pane.surfaceReattachTick as the owner-of-last-resort so a stranded view always re-renders onto a container SwiftUI guarantees is alive. Verified with live SurfaceAttach event 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; tileUnits is exposed to size drop previews.
  • TerminalTab.adoptTree/insertTree/mergeTree are the tab-level merges — local drops keep the halving pref, divider/edge drops always equalize (their meaning is positional parity). TabDropPlacer is a pure resolver from a normalized cursor point to a target + preview rect.
  • Workspace tab drops live on ONE workspace-level drop target (per-pane geometry can't see globality); pane drags keep the existing per-leaf zones. Both new drop delegates guard against the dropUpdated-after-performDrop race so highlights never stick.
  • AppState.mergeTab(intoTab:side:) / mergeTab(at:inProject:) detach the source tab with panes alive, rebind routing identity on cross-project merges (mirroring moveTab), and restore the tab if the target vanished mid-drag. separateTabPanes is 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/mergeTree structure + ratio assertions (thirds, full-width half, 1/4-1/4-1/2), sidebarRowTitle rename behavior.
  • AppStateTests: merge into tab (sides, whole-tree, self-merge no-op, cross-project rebind + activation), resolution-target merges, separate panes.

jcuhnoio added 14 commits August 6, 2026 13:42
…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.
@github-actions github-actions Bot added area:ui Views, Settings UI area:state AppState, models, persistence area:tests Test changes labels Aug 6, 2026
@jcuhnoio

jcuhnoio commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Here's a demo:

Screen.Recording.2026-08-06.at.15.44.24.mov

@jcuhnoio jcuhnoio self-assigned this Aug 6, 2026
@jcuhnoio
jcuhnoio requested a review from thdxg August 6, 2026 06:49
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Window-state benchmark

State Metric main@5ac902e9a this branch Δ
focused CPU % 1.10 1.20 +9%
Memory (RSS MB) 108.6 109.2 +1%
CPU ms/s (powermetrics) 10.8 11.0 +2%
Wakeups/s (powermetrics) 181.9 178.7 -2%
workload-focused CPU % 2.30 3.30 +43% 🔺
Memory (RSS MB) 166.4 165.2 -1%
CPU ms/s (powermetrics) 20.7 33.2 +60% 🔺
Wakeups/s (powermetrics) 253.3 252.8 -0%
workload-unfocused CPU % 2.50 3.20 +28% 🔺
Memory (RSS MB) 169.5 168.4 -1%
CPU ms/s (powermetrics) 24.3 31.3 +29% 🔺
Wakeups/s (powermetrics) 273.6 204.4 -25% 🔻

⚠️ Labeled benchmark:regression

This PR is labeled benchmark:regression because ≥2 metrics regressed by ≥25% vs main@5ac902e9a (beyond each metric's noise floor), at least one under workload:

  • workload-focused — CPU %: 2.30 → 3.30 (+43%)
  • workload-focused — CPU ms/s (powermetrics): 20.7 → 33.2 (+60%)
  • workload-unfocused — CPU %: 2.50 → 3.20 (+28%)
  • workload-unfocused — CPU ms/s (powermetrics): 24.3 → 31.3 (+29%)

Not labeled benchmark:improvement: only one metric moved. A label needs ≥2 metrics past threshold with at least one under workload. Flagged cells (improved):

  • workload-unfocused — Wakeups/s (powermetrics): 273.6 → 204.4 (-25%)

Reported value is the median of 3×10s windows per state (splitting the window and taking the median keeps one co-scheduled spike from skewing a state); CPU % is the process CPU-time delta over a window. Runs land on different shared runners, so treat small deltas as noise — 🔺/🔻 marks changes ≥25% that also clear the metric's absolute noise floor (CPU % ≥0.5, Memory (RSS MB) ≥25, CPU ms/s ≥5, Wakeups/s ≥50); CPU deltas off a noise-dominated baseline aren't flagged (CPU % baseline ≥1.5, CPU ms/s baseline ≥15). The benchmark:regression / benchmark:improvement label needs corroboration — ≥2 flagged metrics in the same direction, at least one under workload — so a lone noisy cell shows its arrow here without tagging the PR.

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.
@github-actions github-actions Bot added the benchmark:improvement CI benchmark: significant resource improvement vs main label Aug 6, 2026
…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:)).
@github-actions github-actions Bot added the area:terminal Terminal surface, ghostty integration label Aug 6, 2026
… 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).
@jcuhnoio jcuhnoio changed the title Let tabs merge into splits by dragging and split apart via Separate Panes Drag tabs into splits, rebuild pane reordering on one placement engine, and add Separate Panes Aug 6, 2026
@github-actions github-actions Bot removed the benchmark:improvement CI benchmark: significant resource improvement vs main label Aug 6, 2026
@jcuhnoio

jcuhnoio commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

I'd love to hear some feedback on the placement engine!

# Conflicts:
#	CLI/MactermCommand.swift
#	Macterm/Control/ControlProtocol.swift
@github-actions github-actions Bot added the benchmark:regression CI benchmark: significant resource regression vs main label Aug 7, 2026
@thdxg
thdxg merged commit 003029b into thdxg:main Aug 7, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:state AppState, models, persistence area:terminal Terminal surface, ghostty integration area:tests Test changes area:ui Views, Settings UI benchmark:regression CI benchmark: significant resource regression vs main

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants