Skip to content

Make Separate All Panes and Separate Current Pane keybindable commands - #236

Merged
thdxg merged 33 commits into
mainfrom
claude/pr-228-pane-to-sidebar
Aug 7, 2026
Merged

Make Separate All Panes and Separate Current Pane keybindable commands#236
thdxg merged 33 commits into
mainfrom
claude/pr-228-pane-to-sidebar

Conversation

@thdxg

@thdxg thdxg commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Replaces #231 (same branch — its original PR got wedged in GitHub's stacked-PR machinery after #228 merged). main is merged in; the diff is this work alone.

Separate Panes existed only in the tab context menu. This PR makes it and a new per-pane sibling first-class commands — palette rows plus rebindable hotkeys (unbound by default, matching the other tree-rewriting actions like Apply/Save Layout):

  • Separate All Panes — the context-menu action, invocable on the active tab.
  • Separate Current Pane — new: splits just the focused pane out of the active tab into its own tab, inserted right after it.

Why commands and not a sidebar drag

Dragging a pane's grab handle onto the sidebar was tried first (twice) and is not implementable with public SwiftUI: the sidebar column's hierarchy never receives the grab handle's AppKit NSDraggingSession. Tested against the live app with synthesized drags — row-, ForEach-, and header-level dropDestination; row-, header-, and List-level onDrop; and a target outside the List — none ever got a single delegate callback, while the identical delegate fires fine in the workspace column (the same drag session reshapes splits there). The finding is recorded on MovablePane's doc comment so nobody retries it blind.

How it works

  • AppState.separatePane does removePane's tree/zoom/focus repair minus the surface teardown, so the Pane object (surface and running shell) survives the trip; crossing projects rebinds routing identity the way moveTab does. The destination workspace resolves before detaching, so a failure can't leave the pane belonging to no tab.
  • Both commands follow the add-an-action recipe: AppCommand cases (palette picks them up via allCases), HotkeyAction cases, and routing through the AppCommand.action(in:) single source of truth in Responders.swift, so palette, menu, and binding can't drift. Enablement guards make them no-ops on single-pane tabs.
  • MovablePane stays as the shared drag-pasteboard decode for the workspace's LeafDropDelegate, which previously unpacked the UUID bytes inline.

Testing

  • New AppStateTests: pane lands in its own tab with source-tab zoom/focus repair, insertion at a slot, cross-project rebind + destination activation, only-pane / unknown-pane no-ops. Full suite passes; format/lint clean.
  • Live-verified through the command palette in a hermetic app instance: "Separate Current Pane" on a 2-pane tab produced the new tab right after the source with focus following; "Separate All Panes" exploded a 3-pane tab into single-pane tabs in place.

🤖 Generated with Claude Code

jcuhnoio and others added 30 commits August 6, 2026 13:42
…anes (#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
(#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.
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).
Dragging a tab's sidebar row over the workspace that is already rendering
that tab showed the split drop preview, but releasing could only
self-merge — which AppState.mergeTab refuses — so the overlay promised a
split that never happens. The drop context now carries the rendered tab's
id and the leaf delegates reject a matching MovableTab payload outright
(no preview, drag image animates back). When the Transferable payload
isn't on the drag pasteboard yet the check falls open and the drop lands
on mergeTab's existing guard, the same degradation the row-level merge
delegate documents.
The per-pane pills of a 2-3 pane tab row carried a filled rounded-rect
container with its own padding, which read as heavier chrome than the
surrounding native rows. Drop the fill and inset so each segment is just
its icon and title, spaced apart to keep the segments legible.
…rows

A hand-rolled HStack put the raw SidebarRowIcon next to the text, missing
the List's sidebar Label styling that sizes row icons — so a split tab's
segment icons rendered smaller than a single tab's. Each segment is now a
Label (icon slot + title), inheriting the same metrics.
Hovering a tab drag over a single-pane row slid the row's content aside
with a structural drop-slot preview (the hovered half picking the landing
side), while a multi-pane row just highlighted — the same drop read as
two different interactions depending on the destination's pane count.
Every destination now shows the plain whole-row highlight and the merged
tab always lands on the right, matching what multi-pane destinations
already did. The delegate keeps its DropDelegate shape for the
synchronous self-merge rejection.
With the tab icon set to None and the status indicator on, an idle tab's
row rendered the "none" sentinel as an SF Symbol — an invisible Image
that still reserves the Label's icon column, nudging the title right of
every other icon-less row. The label now gets an icon only while the
status glyph actually draws something (spinner, done dot, or agent
logo); idle falls back to the plain icon-less title.
The tab-onto-tab merge target was a hand-rolled DropDelegate with a
custom overlay highlight. Now that every destination takes the same
treatment, the row uses the native dropDestination directly: SwiftUI
owns the session, a row refuses its own tab by returning false (the
drag animates back natively), and the delegate, its state machine, and
the pasteboard fallback all go away.

macOS draws nothing of its own for a row-level drop target, so the
isTargeted callback restores #227's structural preview as the hover
feedback: the row's content slides into the left half and an accent
drop slot marks the right half — the side the merged tab actually
lands on (.second) — now uniform for single- and multi-pane
destinations alike. The original variant existed only for one-pane
rows (the hovered half picked the landing side, which needed a raw
delegate for the cursor location) and made the same drop feel like two
different interactions.
A split segment's title slot is narrow, and an ellipsis burns several
characters of it. FadingTitle renders two copies of the text: the
layout element is a normal truncating Text drawn invisibly, so the
title stays compressible and the segments keep dividing the row
equally as panes come and go (a fixedSize text in the layout makes
every segment demand its full title width — segments stop re-balancing
and an overflowing title gets hard-clipped by the row edge outside its
own mask). The visible copy draws at intrinsic width in a
layout-neutral overlay, clipped to the slot by a mask whose 18pt
gradient ramp engages only when the text actually overflows (measured
via onGeometryChange) — a short title masked unconditionally would
fade its own last letters.

The segments also take a 10pt trailing inset: the row's content region
extends past the selection highlight's trailing edge, and unlike a
plain Label the segments fill the row, so without it the last title
read as overflowing the highlight.
A split tab's segments each carried their own icon (the pane's agent
logo, else the tab icon) while a renamed tab showed a single one — the
same row read differently depending on how it was named. The segments
are now a title variant inside the row's normal Label, divided by
hairlines so adjacent titles don't read as one run-on name, and every
tab row carries exactly one icon; the status-indicator/agent-icon
logic now applies to split rows too instead of being bypassed.

The overflow handling consolidates with it: FadingText moves to its own
file as a reusable one-line Text stand-in and now also covers the
single-row tab title and the project name — every sidebar title fades
instead of ellipsizing. The highlight-containment inset becomes one
named rule (rowTrailingInset) applied at each row's root, replacing the
per-call-site paddings, which also brings the merge drop slot inside
the highlight.
The slide-aside hover preview always gave the incoming tab half the
row, even over a destination already split three ways. Both tabs hold
one segment per pane, so the slot now takes the incoming panes' share
of the combined row — a 1-pane tab over a 3-pane destination claims a
quarter, a 2-pane tab over a 2-pane destination claims half — and the
destination's own segments compress to exactly their post-drop share.
The slot also joins the row with the segments' own spacing and
hairline, so the preview reads as the row after the drop rather than a
box appended to it. The row width is measured outside the hover branch
so the slot has its size the moment a drag enters, not a frame later.
…only

Dropping a tab onto another tab's sidebar row merged them, with a
hover preview that tried to mirror the destination's layout — but the
row is a poor canvas for previewing a split, and the interaction kept
fighting the sidebar's other drop roles (reorder between rows, append
on headers). The workspace drop already does this job with an honest
placement grammar — whole-edge, divider, or local pane split, previewed
at real size — so the sidebar keeps only reordering and cross-project
moves, and merging happens by dragging a tab's row into the workspace.

Removes the row drop target and its preview machinery wholesale, plus
the now-unreachable AppState.mergeTab(intoTab:side:) and
TerminalTab.adoptTree(side:) and their tests. The workspace merge path
(mergeTab(at:), mergeTree) is untouched.
Separate Panes existed only in the tab context menu, and dragging a
pane's grab handle to the sidebar was tried as the per-pane counterpart
but is not implementable: the sidebar column's SwiftUI hierarchy never
receives the grab handle's AppKit NSDraggingSession. Every public drop
mechanism was tested against the live app — row-, ForEach-, and
header-level dropDestination, row/header/List-level onDrop, and a
target outside the List — and none ever got a callback, while the same
delegate fires fine in the workspace column. So both operations become
AppCommands instead: palette rows plus rebindable hotkeys, unbound by
default like the other tree-rewriting actions.

- Separate All Panes: the context-menu action, now invocable on the
  active tab from the palette or a binding.
- Separate Current Pane: new — splits just the focused pane out of the
  active tab into its own tab inserted right after it, via
  AppState.separatePane: removePane's tree/zoom/focus repair minus the
  surface teardown, so the pane object (surface and running shell)
  survives the trip; crossing projects rebinds routing identity the way
  moveTab does. Covered by new AppStateTests.

MovablePane stays as the shared drag-pasteboard decode for the
workspace's LeafDropDelegate, which previously unpacked the UUID bytes
inline.
thdxg added 3 commits August 8, 2026 01:08
…-pills-a4a0cb

# Conflicts:
#	CLI/MactermCommand.swift
#	Macterm/App/AppState.swift
#	Macterm/Control/ControlProtocol.swift
#	Macterm/Model/Workspace.swift
#	Macterm/Views/MainWindow.swift
#	Macterm/Views/PaneDragDrop.swift
#	Macterm/Views/Sidebar.swift
#	MactermTests/App/AppStateTests.swift
#	MactermTests/Model/TerminalTabTests.swift
@github-actions github-actions Bot added area:ui Views, Settings UI area:state AppState, models, persistence area:hotkeys Key routing, hotkeys area:tests Test changes labels Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Window-state benchmark

State Metric main@003029b5e this branch Δ
focused CPU % 0.80 0.70 -13%
Memory (RSS MB) 110.2 108.9 -1%
CPU ms/s (powermetrics) 7.4 6.4 -14%
Wakeups/s (powermetrics) 158.3 153.8 -3%
workload-focused CPU % 1.90 1.90 +0%
Memory (RSS MB) 158.1 164.8 +4%
CPU ms/s (powermetrics) 18.7 18.5 -1%
Wakeups/s (powermetrics) 215.8 196.1 -9%
workload-unfocused CPU % 2.30 2.20 -4%
Memory (RSS MB) 130.1 165.9 +28% 🔺
CPU ms/s (powermetrics) 22.6 20.5 -9%
Wakeups/s (powermetrics) 283.6 289.8 +2%

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

  • workload-unfocused — Memory (RSS MB): 130.1 → 165.9 (+28%)

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.

@thdxg
thdxg merged commit e7abb22 into main Aug 7, 2026
10 checks passed
@thdxg
thdxg deleted the claude/pr-228-pane-to-sidebar branch August 7, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:hotkeys Key routing, hotkeys area:state AppState, models, persistence area:tests Test changes area:ui Views, Settings UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants