Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10609,6 +10609,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}

if matchConfiguredShortcut(event: event, action: .equalizeSplits) {
guard let workspace = tabManager?.selectedWorkspace,
tabManager?.equalizeSplits(tabId: workspace.id) == true else {
NSSound.beep()
return true
}
return true
}
Comment on lines +10612 to +10619

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Beep fired when no splits exist

equalizeSplits(tabId:) returns false in two distinct cases: the tab wasn't found, and the workspace has no splits at all (foundSplit stays false → false && allSucceeded). The current guard treats both as failures and fires NSSound.beep(), so a user with a single unsplit pane gets an audible error every time they press Cmd+Ctrl+=.

The parallel toggleSplitZoom handler immediately above discards its return value (_ = tabManager?.toggleFocusedSplitZoom()) and never beeps on a no-op. Aligning with that pattern—or at least guarding only on a nil tabManager/workspace rather than on the action's Boolean result—would avoid the unexpected beep.

if matchConfiguredShortcut(event: event, action: .equalizeSplits) {
    if let workspace = tabManager?.selectedWorkspace {
        _ = tabManager?.equalizeSplits(tabId: workspace.id)
    }
    return true
}


// Split actions: Cmd+D / Cmd+Shift+D
if matchConfiguredShortcut(event: event, action: .splitRight) {
#if DEBUG
Expand Down
2 changes: 2 additions & 0 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6409,6 +6409,8 @@ struct ContentView: View {
return .hideFind
case "palette.toggleSplitZoom":
return .toggleSplitZoom
case "palette.equalizeSplits":
return .equalizeSplits
case "palette.triggerFlash":
return .triggerFlash
default:
Expand Down
5 changes: 5 additions & 0 deletions Sources/KeyboardShortcutSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ enum KeyboardShortcutSettings {
case splitRight
case splitDown
case toggleSplitZoom
case equalizeSplits
case splitBrowserRight
case splitBrowserDown

Expand Down Expand Up @@ -151,6 +152,7 @@ enum KeyboardShortcutSettings {
case .splitRight: return String(localized: "shortcut.splitRight.label", defaultValue: "Split Right")
case .splitDown: return String(localized: "shortcut.splitDown.label", defaultValue: "Split Down")
case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom")
case .equalizeSplits: return String(localized: "shortcut.equalizeSplits.label", defaultValue: "Equalize Splits")
case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right")
case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down")
case .toggleFileExplorer: return String(localized: "shortcut.toggleFileExplorer.label", defaultValue: "Toggle File Explorer")
Expand Down Expand Up @@ -271,6 +273,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false)
case .toggleSplitZoom:
return StoredShortcut(key: "\r", command: true, shift: true, option: false, control: false)
case .equalizeSplits:
return StoredShortcut(key: "=", command: true, shift: false, option: false, control: true)
case .splitBrowserRight:
return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false)
case .splitBrowserDown:
Expand Down Expand Up @@ -756,6 +760,7 @@ enum KeyboardShortcutSettings {
static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) }
static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) }
static func toggleSplitZoomShortcut() -> StoredShortcut { shortcut(for: .toggleSplitZoom) }
static func equalizeSplitsShortcut() -> StoredShortcut { shortcut(for: .equalizeSplits) }
static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) }
static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) }

Expand Down
8 changes: 8 additions & 0 deletions Sources/cmuxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,14 @@ struct cmuxApp: App {
performBrowserSplitFromMenu(direction: .down)
}

splitCommandButton(title: String(localized: "command.equalizeSplits.title", defaultValue: "Equalize Splits"), shortcut: menuShortcut(for: .equalizeSplits)) {
guard let workspace = activeTabManager.selectedWorkspace,
activeTabManager.equalizeSplits(tabId: workspace.id) else {
NSSound.beep()
return
}
}
Comment on lines +696 to +702

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Same spurious beep on single-pane workspaces

This menu-item action shares the same beep-on-no-op problem as the keyboard handler: equalizeSplits(tabId:) returns false for an unsplit workspace, triggering NSSound.beep(). A user choosing "Equalize Splits" from the menu with only one pane open will hear an error sound with no visual explanation.

splitCommandButton(title: String(localized: "command.equalizeSplits.title", defaultValue: "Equalize Splits"), shortcut: menuShortcut(for: .equalizeSplits)) {
    if let workspace = activeTabManager.selectedWorkspace {
        _ = activeTabManager.equalizeSplits(tabId: workspace.id)
    }
}


Divider()

// Numbered workspace selection (9 = last workspace)
Expand Down
68 changes: 68 additions & 0 deletions cmuxTests/AppDelegateShortcutRoutingTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import XCTest
import Bonsplit

#if canImport(cmux_DEV)
@testable import cmux_DEV
Expand All @@ -11,6 +12,16 @@ private final class FakeWKInspectorContainerView: NSView {}
private final class FocusableTestView: NSView {
override var acceptsFirstResponder: Bool { true }
}

private func shortcutRoutingSplitNodes(in node: ExternalTreeNode) -> [ExternalSplitNode] {
switch node {
case .pane:
return []
case .split(let split):
return [split] + shortcutRoutingSplitNodes(in: split.first) + shortcutRoutingSplitNodes(in: split.second)
}
}

private final class GhosttyCommandEquivalentProbeView: GhosttyNSView {
var afterMenuMissCallCount = 0
var pasteCallCount = 0
Expand Down Expand Up @@ -680,6 +691,63 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(workspace.panels.count, initialPanelCount, "Unmatched chord suffix must not trigger the action")
}

func testConfiguredEqualizeSplitsShortcutBalancesWorkspaceDividers() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}

let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }

guard let window = window(withId: windowId),
let manager = appDelegate.tabManagerFor(windowId: windowId),
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")
return
}

window.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))

let seededSplits = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot())
XCTAssertGreaterThanOrEqual(seededSplits.count, 2, "Expected nested splits")

for (index, split) in seededSplits.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))
}

guard let event = makeKeyDownEvent(
key: "=",
modifiers: [.command, .control],
keyCode: 24,
windowNumber: window.windowNumber
) else {
XCTFail("Failed to construct Cmd+Ctrl+= event")
return
}

#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif

let equalizedSplits = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot())
XCTAssertEqual(equalizedSplits.count, seededSplits.count)
for split in equalizedSplits {
XCTAssertEqual(split.dividerPosition, 0.5, accuracy: 0.000_1)
Comment on lines +719 to +747

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Assert seeded layout is actually uneven before invoking equalize.

setDividerPosition(...) == true alone doesn’t guarantee the layout moved away from 0.5. Without a precondition check, this test can false-pass if seeding becomes a no-op.

Suggested patch
         for (index, split) in seededSplits.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))
         }
+
+        let postSeedSplits = shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot())
+        XCTAssertTrue(
+            postSeedSplits.contains(where: { abs($0.dividerPosition - 0.5) > 0.000_1 }),
+            "Precondition failed: expected at least one non-equal divider before triggering equalize shortcut"
+        )
 
         guard let event = makeKeyDownEvent(
             key: "=",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/AppDelegateShortcutRoutingTests.swift` around lines 719 - 747,
Before invoking debugHandleCustomShortcut, assert the seeded layout is actually
uneven by checking the divider positions returned from
shortcutRoutingSplitNodes(in: workspace.bonsplitController.treeSnapshot()) (or
by querying each split's dividerPosition after calling
bonsplitController.setDividerPosition) and failing the test if all splits are
0.5; this ensures setDividerPosition (used in the loop over seededSplits and its
targetPosition) actually changed at least one split away from 0.5 before you
call appDelegate.debugHandleCustomShortcut(event:) and verify equalization.

}
}

func testCreateMainWindowDoesNotDisallowFullScreenTilingByDefault() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
Expand Down
1 change: 1 addition & 0 deletions web/data/cmux-settings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@
"splitRight",
"splitDown",
"toggleSplitZoom",
"equalizeSplits",
"splitBrowserRight",
"splitBrowserDown",
"openBrowser",
Expand Down
1 change: 1 addition & 0 deletions web/data/cmux-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const shortcutCategories: ShortcutCategory[] = [
{ id: "splitBrowserRight", combos: [["⌥", "⌘", "D"]], description: { en: "Split browser right", ja: "右にブラウザ分割" } },
{ id: "splitBrowserDown", combos: [["⌥", "⌘", "⇧", "D"]], description: { en: "Split browser down", ja: "下にブラウザ分割" } },
{ id: "toggleSplitZoom", combos: [["⌘", "⇧", "↩"]], description: { en: "Toggle pane zoom", ja: "ペインズームを切り替え" } },
{ id: "equalizeSplits", combos: [["⌃", "⌘", "="]], description: { en: "Equalize split sizes", ja: "分割サイズを均等にする" } },
],
},
{
Expand Down