Skip to content

Apply pane zoom by collapsing splits in place instead of swapping the rendered node - #175

Open
johnlindquist wants to merge 2 commits into
manaflow-ai:mainfrom
johnlindquist:zoom-collapse-in-place
Open

johnlindquist wants to merge 2 commits into
manaflow-ai:mainfrom
johnlindquist:zoom-collapse-in-place

Conversation

@johnlindquist

@johnlindquist johnlindquist commented Jul 10, 2026 •

Copy link
Copy Markdown

Problem

Toggling pane zoom rendered zoomedNode ?? rootNode, which changes SwiftUI structural identity — the entire AppKit split hierarchy (every NSSplitView plus one NSHostingController per pane) was torn down on maximize and fully rebuilt on minimize. Cost scales with pane count: with 8 panes, maximize took 443.8 ms and minimize 1303.4 ms median (debug), and minimize recreated all 8 pane content views. In cmux this shows up as a very laggy cmd+shift+enter with multiple panes open.

Fix

Zoom keeps the tree mounted and collapses the side of each split that does not contain the zoomed pane (hidden arranged subviews are excluded from NSSplitView tiling, so the zoom path expands to the full container).

  • Synchronous apply: split coordinators register a collapse applier with SplitViewController; the AppKit collapse runs in the same runloop turn as togglePaneZoom() instead of waiting for SwiftUI to re-thread state through every nested hosting controller (display-paced, hundreds of ms on deep trees).
  • Idempotent backstop: updateNSView re-applies the same state reading live controller state, so stale SwiftUI passes cannot flash the pre-toggle layout (restore-then-recollapse flap).
  • Model protection: divider sync/persistence and entry-animation placement are suspended while a split is zoom-collapsed, so the temporary full-size layout never overwrites the stored divider ratio.

Results

New window-hosted benchmark Tests/BonsplitTests/ZoomTogglePerformanceTests.swift (8 panes, 12 cycles, swift test --filter ZoomTogglePerformanceTests):

metric (median) before after
maximize settle 443.8 ms ~170 ms*
minimize settle 1303.4 ms 3.3 ms
pane content NSViews recreated per maximize 1 0
pane content NSViews recreated per minimize 8 0

* residual is offscreen-window SwiftUI update scheduling in the headless harness; a probe confirms AppKit frames are final within the toggle call. In cmux (11 panes, real key events) end-to-end settle went from 315→115 ms (maximize) and 825→130 ms (minimize), with terminal-portal detach/attach per minimize going from 18–20 to 0.

Full benchmark report: https://polite-sandal-rgcv.here.now/

All 186 package tests pass.

Note for cmux integration: cmux's WorkspaceContentView re-keyed the Bonsplit subtree with .id(zoomState) (workaround for stale pane chrome above browser portals under the old node-swap rendering). That re-key must be removed together with this change, and pane content visibility made zoom-aware, since collapsed panes now stay mounted; the cmux-side commit is prepared to land with the submodule bump.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Apply pane zoom by collapsing the non-zoomed side of each split in place, and move divider syncing out of SwiftUI. Keeps the AppKit split tree mounted, makes zoom toggles much faster, and cuts CPU during resize storms.

  • Refactors

    • Always render the full tree; hide the side that doesn’t contain the zoomed pane in each split.
    • Synchronous collapse: SplitViewController notifies registered split coordinators on zoomedPaneId change in the same runloop turn.
    • Idempotent backstop: updateNSView re-applies collapse using live controller state to avoid layout flashes.
    • Divider syncing: observe the model’s dividerPosition and apply changes directly to NSSplitView (no updateNSView dependency, no layoutSubtreeIfNeeded on ticks); reduces main-thread work under heavy resizes (cmux: app CPU ~93% → ~50–60%).
    • Suspend divider syncing/persistence and entry animations while zoomed; restore the model ratio on unzoom.
    • SplitViewContainer passes zoomedPaneId down; appliers are registered in makeNSView and unregistered in dismantleNSView.
    • Added ZoomTogglePerformanceTests.swift (8 panes): minimize 1303.4 ms → 3.3 ms; maximize 443.8 ms → ~170 ms; pane content NSView churn → 0.
  • Migration

    • In cmux, remove .id(zoomState) from WorkspaceContentView and make pane content visibility zoom-aware, since collapsed panes now remain mounted.

Written for commit 8062854. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved pane zooming/restoration across nested split layouts by collapsing the non-zoomed side and reliably restoring the previous layout.
    • Reduced visual flicker and eliminated unnecessary split hierarchy rebuilding when toggling zoom.
    • Kept divider behavior consistent during zoom by suspending/safeguarding divider syncing and position persistence.
  • New Features
    • Enabled more accurate zoom-aware propagation so nested split containers respond correctly to the current zoomed pane.
  • Tests
    • Added performance benchmarks for zoom toggle latency and view churn in an 8-pane window scenario.

… rendered node

Rendering `zoomedNode ?? rootNode` changed SwiftUI structural identity on
every zoom toggle, tearing down and rebuilding the entire AppKit split
hierarchy (every NSSplitView plus one NSHostingController per pane). With
multiple panes open, un-zoom rebuilt all N panes and cost >1s in debug
builds (8 panes: maximize 444ms / minimize 1303ms median).

Zoom now keeps the tree mounted and collapses the side of each split that
does not contain the zoomed pane (hidden arranged subviews are excluded
from NSSplitView tiling). Split coordinators register a collapse applier
with the controller so the AppKit collapse runs synchronously in the same
runloop turn as the toggle; the SwiftUI pass re-applies the same state
idempotently as a backstop, reading live controller state so stale update
passes cannot flash the pre-toggle layout. Divider sync/persistence and
entry-animation placement are suspended while a split is zoom-collapsed so
the temporary full-size layout never overwrites the model ratio.

New window-hosted benchmark (ZoomTogglePerformanceTests, 8 panes,
12 cycles): maximize 443.8ms -> ~170ms median, minimize 1303.4ms -> 3.3ms
median, pane content NSView churn 1/8 -> 0/0 per toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 10, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Zoom-collapse flow

Layer / File(s) Summary
Controller notification and split-tree wiring
Sources/Bonsplit/Internal/Controllers/SplitViewController.swift, Sources/Bonsplit/Internal/Views/SplitNodeView.swift, Sources/Bonsplit/Internal/Views/SplitContainerView.swift, Sources/Bonsplit/Internal/Views/SplitViewContainer.swift
SplitViewController synchronously notifies registered appliers, while the full split tree receives zoomedPaneId and remains rendered during zoom.
Coordinator zoom-collapse application
Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Coordinators collapse and restore split sides, unregister during dismantling, preserve divider state, and suspend divider synchronization while zoom-collapsed.
Zoom toggle performance benchmark
Tests/BonsplitTests/ZoomTogglePerformanceTests.swift
Adds an eight-pane XCTest benchmark for zoom settle latency and content-view churn.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SplitViewController
  participant SplitContainerView
  participant NSSplitView
  User->>SplitViewController: Toggle pane zoom
  SplitViewController->>SplitContainerView: Notify registered applier
  SplitContainerView->>NSSplitView: Hide or restore arranged subview
  NSSplitView-->>SplitContainerView: Adjust subviews
  SplitContainerView->>SplitViewController: Restore divider ratio when unzoomed
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: in-place split collapsing for pane zoom instead of swapping the rendered node.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR changes pane zoom to keep the split tree mounted. The main changes are:

  • Registers synchronous zoom-collapse callbacks on split coordinators.
  • Collapses the non-zoom side of each NSSplitView instead of swapping the rendered node.
  • Skips divider sync and persistence while zoom collapse is active.
  • Threads zoom state through nested split views as an update trigger.
  • Adds a window-hosted zoom-toggle performance benchmark.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
Sources/Bonsplit/Internal/Controllers/SplitViewController.swift Adds zoom-collapse applier registration and synchronous notification when the zoomed pane changes.
Sources/Bonsplit/Internal/Views/SplitContainerView.swift Adds split hiding/restoring for zoom, coordinator registration, and divider-sync guards during temporary collapse.
Sources/Bonsplit/Internal/Views/SplitNodeView.swift Threads the zoomed pane id into split containers so nested views update on zoom changes.
Sources/Bonsplit/Internal/Views/SplitViewContainer.swift Always renders the root split tree and delegates zoom behavior to split collapse.
Tests/BonsplitTests/ZoomTogglePerformanceTests.swift Adds a window-hosted benchmark for zoom latency and pane content view churn.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[togglePaneZoom updates zoomedPaneId] --> B[SplitViewController notifies appliers]
  B --> C[Split coordinator finds zoom path]
  C --> D[Hide non-zoom arranged subview]
  D --> E[Skip divider persistence while collapsed]
  A --> F[SwiftUI update pass]
  F --> G[updateNSView reads live zoom state]
  G --> H[Reapply collapse or restore idempotently]
  H --> I[Restore model divider ratio on zoom out]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
  A[togglePaneZoom updates zoomedPaneId] --> B[SplitViewController notifies appliers]
  B --> C[Split coordinator finds zoom path]
  C --> D[Hide non-zoom arranged subview]
  D --> E[Skip divider persistence while collapsed]
  A --> F[SwiftUI update pass]
  F --> G[updateNSView reads live zoom state]
  G --> H[Reapply collapse or restore idempotently]
  H --> I[Restore model divider ratio on zoom out]
Loading

Reviews (1): Last reviewed commit: "Apply pane zoom by collapsing splits in ..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/Bonsplit/Internal/Views/SplitContainerView.swift`:
- Around line 269-276: Use the captured local animationOrigin variable in the
zoomHiddenIndex early-return branch of SplitContainerView’s update logic instead
of checking the already-cleared splitState.animationOrigin; when it is non-nil,
clear it and reset context.coordinator.isAnimating to prevent the coordinator
from remaining permanently animating.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5b5f658e-2839-4913-ba11-69ee5ef1ae92

📥 Commits

Reviewing files that changed from the base of the PR and between e1a8d41 and a9b968b.

📒 Files selected for processing (5)
  • Sources/Bonsplit/Internal/Controllers/SplitViewController.swift
  • Sources/Bonsplit/Internal/Views/SplitContainerView.swift
  • Sources/Bonsplit/Internal/Views/SplitNodeView.swift
  • Sources/Bonsplit/Internal/Views/SplitViewContainer.swift
  • Tests/BonsplitTests/ZoomTogglePerformanceTests.swift

Comment on lines +269 to +276
if context.coordinator.zoomHiddenIndex != nil {
context.coordinator.didApplyInitialDividerPosition = true
if splitState.animationOrigin != nil {
splitState.animationOrigin = nil
context.coordinator.isAnimating = false
}
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

isAnimating can get permanently stuck when a split is created during active zoom.

At line 249, splitState.animationOrigin is cleared (= nil). The zoom-collapsed early-return path at line 271 checks splitState.animationOrigin != nil, which will always be false. If shouldAnimate was true, isAnimating was set to true at line 256 but is never reset in this path. This permanently breaks syncPosition and splitViewDidResizeSubviews for the affected coordinator.

The fix is to use the captured local animationOrigin variable (line 227) instead of the already-cleared splitState.animationOrigin. The non-zoom-collapsed path at line 322 already correctly uses the local animationOrigin.

🐛 Proposed fix: use captured `animationOrigin` instead of already-cleared `splitState.animationOrigin`
             if context.coordinator.zoomHiddenIndex != nil {
                 context.coordinator.didApplyInitialDividerPosition = true
-                if splitState.animationOrigin != nil {
-                    splitState.animationOrigin = nil
+                if animationOrigin != nil {
                     context.coordinator.isAnimating = false
                 }
                 return
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if context.coordinator.zoomHiddenIndex != nil {
context.coordinator.didApplyInitialDividerPosition = true
if splitState.animationOrigin != nil {
splitState.animationOrigin = nil
context.coordinator.isAnimating = false
}
return
}
if context.coordinator.zoomHiddenIndex != nil {
context.coordinator.didApplyInitialDividerPosition = true
if animationOrigin != nil {
context.coordinator.isAnimating = false
}
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Bonsplit/Internal/Views/SplitContainerView.swift` around lines 269 -
276, Use the captured local animationOrigin variable in the zoomHiddenIndex
early-return branch of SplitContainerView’s update logic instead of checking the
already-cleared splitState.animationOrigin; when it is non-nil, clear it and
reset context.coordinator.isAnimating to prevent the coordinator from remaining
permanently animating.

…invalidation or forced layout

Under an external resize storm (socket resize-pane driving nested splits
while 10 TUI panes render), two costs dominated the main thread:

- syncPosition forced layoutSubtreeIfNeeded per applied divider change
  (~30% of main-thread samples): setPosition already updates arranged
  frames and marks layout dirty, so let AppKit coalesce the recursive
  pass into the display cycle instead of flushing per tick.
- updateNSView read splitState.dividerPosition, so every model divider
  change invalidated the representable and cascaded a full pane-subtree
  diff (tab bars included) through the hosted rootView reassignment.
  Divider changes are now watched with Observation and applied straight
  to the NSSplitView by the coordinator; SwiftUI only handles structural
  updates. Arming is deferred a runloop turn so the tracked read is not
  captured by SwiftUI's own observation scope.

Interactive divider drags are unaffected (view-driven writes still
persist via splitViewDidResizeSubviews; the watcher's re-apply no-ops
when view and model already agree).

cmux end-to-end (10 live claude/codex TUIs, scripted resize storm,
debug build): 93% -> ~50-60% app CPU; SplitContainerView sync work no
longer appears in the top main-thread samples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@johnlindquist

Copy link
Copy Markdown
Author

Added a second commit (8062854): model divider changes are now applied to the NSSplitView directly via Observation — no SwiftUI representable invalidation and no forced layoutSubtreeIfNeeded per divider tick. Under a scripted resize storm with 10 live claude/codex TUI panes, app CPU went from 93% to ~50-60% and SplitContainerView sync work disappeared from the top main-thread samples. All 186 package tests pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant