diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e2ea74f2e183..06b658cdd192 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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 + } + // Split actions: Cmd+D / Cmd+Shift+D if matchConfiguredShortcut(event: event, action: .splitRight) { #if DEBUG diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c42e07180091..c8f72b90d8d6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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: diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 994b60ce606b..5696335682ff 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -79,6 +79,7 @@ enum KeyboardShortcutSettings { case splitRight case splitDown case toggleSplitZoom + case equalizeSplits case splitBrowserRight case splitBrowserDown @@ -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") @@ -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: @@ -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) } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 84a69df59a76..5bb03f590cf5 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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 + } + } + Divider() // Numbered workspace selection (9 = last workspace) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index dcaa8ffd408c..8dc8ef0d35f4 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -1,4 +1,5 @@ import XCTest +import Bonsplit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -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 @@ -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) + } + } + func testCreateMainWindowDoesNotDisallowFullScreenTilingByDefault() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/web/data/cmux-settings.schema.json b/web/data/cmux-settings.schema.json index cafbb769b811..ef037078b2e5 100644 --- a/web/data/cmux-settings.schema.json +++ b/web/data/cmux-settings.schema.json @@ -557,6 +557,7 @@ "splitRight", "splitDown", "toggleSplitZoom", + "equalizeSplits", "splitBrowserRight", "splitBrowserDown", "openBrowser", diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 66ccf30de87d..4a0c85ac2a44 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -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: "分割サイズを均等にする" } }, ], }, {