From 365306b857d1dcbff88ddfde8f02d1594bbe3771 Mon Sep 17 00:00:00 2001 From: jorge guerrero Date: Fri, 20 Mar 2026 23:30:00 -0400 Subject: [PATCH 1/5] test: add resize_split regression coverage --- cmuxTests/TabManagerUnitTests.swift | 182 ++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index d69423788ccd..ef799c85d1d8 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -747,6 +747,188 @@ final class TabManagerEqualizeSplitsTests: XCTestCase { } } +@MainActor +final class TabManagerResizeSplitsTests: XCTestCase { + func testResizeSplitMovesHorizontalDividerRightForFirstChildPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + "Expected to seed divider position" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: leftPanelId, direction: .right, amount: 120), + "Expected resizeSplit to succeed for the right edge of the left pane" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertGreaterThan( + updatedSplit.dividerPosition, + 0.5, + "Expected resizing the left pane to the right to move the divider toward the second child" + ) + } + + func testResizeSplitMovesHorizontalDividerLeftForSecondChildPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + "Expected to seed divider position" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: rightPanel.id, direction: .left, amount: 120), + "Expected resizeSplit to succeed for the left edge of the right pane" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertLessThan( + updatedSplit.dividerPosition, + 0.5, + "Expected resizing the right pane to the left to move the divider toward the first child" + ) + } + + func testResizeSplitMovesVerticalDividerDownForFirstChildPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let topPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) != nil else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + "Expected to seed divider position" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: topPanelId, direction: .down, amount: 120), + "Expected resizeSplit to succeed for the bottom edge of the top pane" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertGreaterThan( + updatedSplit.dividerPosition, + 0.5, + "Expected resizing the top pane downward to move the divider toward the second child" + ) + } + + func testResizeSplitReturnsFalseWhenPaneHasNoBorderInDirection() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertFalse( + manager.resizeSplit(tabId: workspace.id, surfaceId: leftPanelId, direction: .left, amount: 120), + "Expected resizeSplit to fail when the pane has no adjacent border in that direction" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + XCTAssertEqual(updatedSplit.dividerPosition, split.dividerPosition, accuracy: 0.000_1) + } + + func testResizeSplitClampsDividerPositionAtUpperBound() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) != nil else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.89, forSplit: splitId), + "Expected to seed divider position near upper bound" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: leftPanelId, direction: .right, amount: 10_000), + "Expected resizeSplit to clamp instead of failing" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertEqual(updatedSplit.dividerPosition, 0.9, 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 TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { From 2e13c9722baa676cd1b267eab3fa75d7aca67cda Mon Sep 17 00:00:00 2001 From: jorge guerrero Date: Fri, 20 Mar 2026 23:34:53 -0400 Subject: [PATCH 2/5] fix: implement Ghostty resize_split behavior --- Sources/TabManager.swift | 120 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 6df6abaf3ab2..ded46156f911 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3261,9 +3261,36 @@ class TabManager: ObservableObject { /// Resize split - not directly supported by bonsplit, but we can adjust divider positions func resizeSplit(tabId: UUID, surfaceId: UUID, direction: ResizeDirection, amount: UInt16) -> Bool { - // Bonsplit handles resize through its own divider dragging - // This is a no-op for now as bonsplit manages divider positions internally - return false + guard amount > 0, + let tab = tabs.first(where: { $0.id == tabId }), + let paneId = tab.paneId(forPanelId: surfaceId) else { return false } + + let paneUUID = paneId.id + guard tab.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else { + return false + } + + var candidates: [ResizeSplitCandidate] = [] + let trace = resizeSplitCollectCandidates( + node: tab.bonsplitController.treeSnapshot(), + targetPaneId: paneUUID.uuidString, + candidates: &candidates + ) + guard trace.containsTarget else { return false } + + let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation } + guard !orientationMatches.isEmpty else { return false } + + guard let candidate = orientationMatches.first(where: { + $0.paneInFirstChild == direction.requiresPaneInFirstChild + }) else { + return false + } + + let delta = CGFloat(amount) / candidate.axisPixels + let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta) + let clamped = min(max(requested, 0.1), 0.9) + return tab.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) } /// Equalize splits - not directly supported by bonsplit @@ -3330,6 +3357,68 @@ class TabManager: ObservableObject { } } + private struct ResizeSplitCandidate { + let splitId: UUID + let orientation: String + let paneInFirstChild: Bool + let dividerPosition: CGFloat + let axisPixels: CGFloat + } + + private struct ResizeSplitTrace { + let containsTarget: Bool + let bounds: CGRect + } + + private func resizeSplitCollectCandidates( + node: ExternalTreeNode, + targetPaneId: String, + candidates: inout [ResizeSplitCandidate] + ) -> ResizeSplitTrace { + switch node { + case .pane(let pane): + let bounds = CGRect( + x: pane.frame.x, + y: pane.frame.y, + width: pane.frame.width, + height: pane.frame.height + ) + return ResizeSplitTrace(containsTarget: pane.id == targetPaneId, bounds: bounds) + + case .split(let split): + let first = resizeSplitCollectCandidates( + node: split.first, + targetPaneId: targetPaneId, + candidates: &candidates + ) + let second = resizeSplitCollectCandidates( + node: split.second, + targetPaneId: targetPaneId, + candidates: &candidates + ) + + let combinedBounds = first.bounds.union(second.bounds) + let containsTarget = first.containsTarget || second.containsTarget + + if containsTarget, + let splitUUID = UUID(uuidString: split.id) { + let orientation = split.orientation.lowercased() + let axisPixels: CGFloat = orientation == "horizontal" + ? combinedBounds.width + : combinedBounds.height + candidates.append(ResizeSplitCandidate( + splitId: splitUUID, + orientation: orientation, + paneInFirstChild: first.containsTarget, + dividerPosition: CGFloat(split.dividerPosition), + axisPixels: max(axisPixels, 1) + )) + } + + return ResizeSplitTrace(containsTarget: containsTarget, bounds: combinedBounds) + } + } + /// Close a surface/panel func closeSurface(tabId: UUID, surfaceId: UUID) -> Bool { guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } @@ -5015,6 +5104,31 @@ enum SplitDirection { /// Resize direction for backwards compatibility enum ResizeDirection { case left, right, up, down + + var splitOrientation: String { + switch self { + case .left, .right: + return "horizontal" + case .up, .down: + return "vertical" + } + } + + /// A split controls the target pane's right/bottom edge when the target is + /// the first child, and left/top edge when the target is the second child. + var requiresPaneInFirstChild: Bool { + switch self { + case .right, .down: + return true + case .left, .up: + return false + } + } + + /// Positive values move the divider toward the second child (right/down). + var dividerDeltaSign: CGFloat { + requiresPaneInFirstChild ? 1 : -1 + } } extension Notification.Name { From 75c942475af4d5e6fc1f5c679375dbdae495660b Mon Sep 17 00:00:00 2001 From: jorge guerrero Date: Sat, 21 Mar 2026 12:12:51 -0400 Subject: [PATCH 3/5] test: cover more resize_split cases --- cmuxTests/TabManagerUnitTests.swift | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index ef799c85d1d8..a057f8a7c9f2 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -860,6 +860,43 @@ final class TabManagerResizeSplitsTests: XCTestCase { ) } + func testResizeSplitMovesVerticalDividerUpForSecondChildPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let topPanelId = workspace.focusedPanelId, + let bottomPanel = workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId), + "Expected to seed divider position" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: bottomPanel.id, direction: .up, amount: 120), + "Expected resizeSplit to succeed for the top edge of the bottom pane" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertLessThan( + updatedSplit.dividerPosition, + 0.5, + "Expected resizing the bottom pane upward to move the divider toward the first child" + ) + } + func testResizeSplitReturnsFalseWhenPaneHasNoBorderInDirection() { let manager = TabManager() guard let workspace = manager.selectedWorkspace, @@ -919,6 +956,39 @@ final class TabManagerResizeSplitsTests: XCTestCase { XCTAssertEqual(updatedSplit.dividerPosition, 0.9, accuracy: 0.000_1) } + func testResizeSplitClampsDividerPositionAtLowerBound() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let topPanelId = workspace.focusedPanelId, + let bottomPanel = workspace.newTerminalSplit(from: topPanelId, orientation: .vertical) else { + XCTFail("Expected split setup to succeed") + return + } + + guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first, + let splitId = UUID(uuidString: split.id) else { + XCTFail("Expected a split node in tree snapshot") + return + } + + XCTAssertTrue( + workspace.bonsplitController.setDividerPosition(0.11, forSplit: splitId), + "Expected to seed divider position near lower bound" + ) + + XCTAssertTrue( + manager.resizeSplit(tabId: workspace.id, surfaceId: bottomPanel.id, direction: .up, amount: 10_000), + "Expected resizeSplit to clamp instead of failing" + ) + + guard let updatedSplit = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first else { + XCTFail("Expected updated split node in tree snapshot") + return + } + + XCTAssertEqual(updatedSplit.dividerPosition, 0.1, accuracy: 0.000_1) + } + private func splitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] { switch node { case .pane: From 1ad901731d45a6f6a78214b45b4edbb9178a9c07 Mon Sep 17 00:00:00 2001 From: jorge guerrero Date: Sun, 22 Mar 2026 10:00:25 -0400 Subject: [PATCH 4/5] test: deduplicate split snapshot helper --- cmuxTests/TabManagerUnitTests.swift | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index a057f8a7c9f2..b9fe6f0c8eaf 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -23,6 +23,15 @@ func drainMainQueue() { XCTWaiter().wait(for: [expectation], timeout: 1.0) } +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 TabManagerChildExitCloseTests: XCTestCase { func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { @@ -736,15 +745,6 @@ final class TabManagerEqualizeSplitsTests: XCTestCase { 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 @@ -988,15 +988,6 @@ final class TabManagerResizeSplitsTests: XCTestCase { XCTAssertEqual(updatedSplit.dividerPosition, 0.1, 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) - } - } } From 98d33ebd732ade338b125c4b3ea045e47089d4a9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 25 Mar 2026 00:00:34 -0700 Subject: [PATCH 5/5] Resolve merge conflict: keep both splitNodes and waitForCondition helpers --- cmuxTests/TabManagerUnitTests.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index bb357ed42914..b5c929cdd457 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -67,6 +67,15 @@ private struct ProcessRunResult { let stderr: String } +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) + } +} + private func runProcess( executablePath: String, arguments: [String],