From 780f959a48d845a2e7fea64a798edc76e41216d1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:27:18 -0800 Subject: [PATCH] Fix equalize splits to recursively set all dividers to 0.5 (#575) The equalize splits command was a no-op that always returned false. Implement it by recursively walking the bonsplit tree and setting every split divider position to 0.5. Also register the command in the command palette with a "workspace has splits" precondition so it only appears when there are multiple panes. Adds a regression test that creates a nested split layout, skews divider positions, equalizes, and verifies all dividers are at 0.5. Fixes https://github.com/manaflow-ai/cmux/issues/571 --- Sources/ContentView.swift | 21 ++++++++ Sources/TabManager.swift | 49 +++++++++++++++++-- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 47 ++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ed2f2ada..3962d41e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1385,6 +1385,7 @@ struct ContentView: View { static let workspaceHasCustomName = "workspace.hasCustomName" static let workspaceShouldPin = "workspace.shouldPin" static let workspaceHasPullRequests = "workspace.hasPullRequests" + static let workspaceHasSplits = "workspace.hasSplits" static let hasFocusedPanel = "panel.hasFocus" static let panelName = "panel.name" @@ -3373,6 +3374,10 @@ struct ContentView: View { CommandPaletteContextKeys.workspaceHasPullRequests, !workspace.sidebarPullRequestsInDisplayOrder().isEmpty ) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasSplits, + workspace.bonsplitController.allPaneIds.count > 1 + ) } if let panelContext = focusedPanelContext { @@ -3938,6 +3943,15 @@ struct ContentView: View { when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.equalizeSplits", + title: constant("Equalize Splits"), + subtitle: workspaceSubtitle, + keywords: ["split", "equalize", "balance", "divider", "layout"], + when: { $0.bool(CommandPaletteContextKeys.workspaceHasSplits) } + ) + ) return contributions } @@ -4180,6 +4194,13 @@ struct ContentView: View { registry.register(commandId: "palette.terminalSplitBrowserDown") { _ = tabManager.createBrowserSplit(direction: .down) } + registry.register(commandId: "palette.equalizeSplits") { + guard let workspace = tabManager.selectedWorkspace, + tabManager.equalizeSplits(tabId: workspace.id) else { + NSSound.beep() + return + } + } } private var focusedPanelContext: (workspace: Workspace, panelId: UUID, panel: any Panel)? { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 61c8c8ea..391176bd 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2006,9 +2006,17 @@ class TabManager: ObservableObject { /// Equalize splits - not directly supported by bonsplit func equalizeSplits(tabId: UUID) -> Bool { - // Bonsplit doesn't have a built-in equalize feature - // This would require manually setting all divider positions to 0.5 - return false + guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } + + var foundSplit = false + var allSucceeded = true + equalizeSplits( + in: tab.bonsplitController.treeSnapshot(), + controller: tab.bonsplitController, + foundSplit: &foundSplit, + allSucceeded: &allSucceeded + ) + return foundSplit && allSucceeded } /// Toggle zoom on a panel - bonsplit doesn't have zoom support @@ -2017,6 +2025,41 @@ class TabManager: ObservableObject { return false } + private func equalizeSplits( + in node: ExternalTreeNode, + controller: BonsplitController, + foundSplit: inout Bool, + allSucceeded: inout Bool + ) { + switch node { + case .pane: + return + case .split(let splitNode): + foundSplit = true + guard let splitId = UUID(uuidString: splitNode.id) else { + allSucceeded = false + return + } + + if !controller.setDividerPosition(0.5, forSplit: splitId) { + allSucceeded = false + } + + equalizeSplits( + in: splitNode.first, + controller: controller, + foundSplit: &foundSplit, + allSucceeded: &allSucceeded + ) + equalizeSplits( + in: splitNode.second, + controller: controller, + foundSplit: &foundSplit, + allSucceeded: &allSucceeded + ) + } + } + /// Close a surface/panel func closeSurface(tabId: UUID, surfaceId: UUID) -> Bool { guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a9da122c..baeab641 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -4,6 +4,7 @@ import SwiftUI import WebKit import SwiftUI import ObjectiveC.runtime +import Bonsplit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -3317,6 +3318,52 @@ final class TabManagerSurfaceCreationTests: XCTestCase { } } +@MainActor +final class TabManagerEqualizeSplitsTests: XCTestCase { + func testEqualizeSplitsSetsEverySplitDividerToHalf() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + workspace.newTerminalSplit(from: rightPanel.id, orientation: .vertical) != nil else { + XCTFail("Expected nested split setup to succeed") + return + } + + let initialSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot()) + XCTAssertGreaterThanOrEqual(initialSplits.count, 2, "Expected at least two split nodes in nested layout") + + for (index, split) in initialSplits.enumerated() { + guard let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected split ID to be a UUID") + return + } + let targetPosition: CGFloat = index.isMultiple(of: 2) ? 0.2 : 0.8 + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(targetPosition, forSplit: splitId), + "Expected to seed divider position for split \(splitId)" + ) + } + + XCTAssertTrue(manager.equalizeSplits(tabId: workspace.id), "Expected equalize splits command to succeed") + + let equalizedSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot()) + XCTAssertEqual(equalizedSplits.count, initialSplits.count) + for split in equalizedSplits { + XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1) + } + } + + private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { + switch node { + case .pane: + return [] + case .split(let split): + return [split] + splitNodes(in: split.first) + splitNodes(in: split.second) + } + } +} + @MainActor final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() {