From 0109731bcaa814cdd308d05057e51b297fa3d227 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 16:43:18 -0700 Subject: [PATCH 01/28] Add hidden-titlebar minimalism UI regressions --- Sources/AppDelegate.swift | 173 +++++++ cmuxUITests/BonsplitTabDragUITests.swift | 546 +++++++++++++++++++++++ 2 files changed, 719 insertions(+) create mode 100644 cmuxUITests/BonsplitTabDragUITests.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6fe2b698..c0413ab3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1985,6 +1985,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? private var jumpUnreadFocusObserver: NSObjectProtocol? private var didSetupGotoSplitUITest = false + private var didSetupBonsplitTabDragUITest = false + private var bonsplitTabDragUITestRecorder: DispatchSourceTimer? private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? @@ -2367,6 +2369,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() + setupBonsplitTabDragUITestIfNeeded() setupMultiWindowNotificationsUITestIfNeeded() // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. @@ -6526,6 +6529,176 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func setupBonsplitTabDragUITestIfNeeded() { + guard !didSetupBonsplitTabDragUITest else { return } + didSetupBonsplitTabDragUITest = true + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] == "1" else { return } + guard tabManager != nil else { return } + let startWithHiddenSidebar = env["CMUX_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] == "1" + + let deadline = Date().addingTimeInterval(20.0) + func hasMainTerminalWindow() -> Bool { + NSApp.windows.contains { window in + guard let raw = window.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + } + } + + func runSetupWhenWindowReady() { + guard Date() < deadline else { + writeBonsplitTabDragUITestData(["setupError": "Timed out waiting for main window"]) + return + } + guard hasMainTerminalWindow() else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + runSetupWhenWindowReady() + } + return + } + if let mainWindow = NSApp.windows.first(where: { window in + guard let raw = window.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + }) { + let screenFrame = mainWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame + if let screenFrame { + let targetSize = NSSize(width: min(960, screenFrame.width - 80), height: min(720, screenFrame.height - 80)) + let targetOrigin = NSPoint( + x: screenFrame.minX + 40, + y: screenFrame.maxY - 40 - targetSize.height + ) + let targetFrame = NSRect(origin: targetOrigin, size: targetSize) + if !mainWindow.frame.equalTo(targetFrame) { + mainWindow.setFrame(targetFrame, display: true) + } + } + } + guard let tabManager = self.tabManager, + let workspace = tabManager.selectedWorkspace ?? tabManager.tabs.first, + let alphaPanelId = workspace.focusedPanelId else { + self.writeBonsplitTabDragUITestData(["setupError": "Missing initial workspace or panel"]) + return + } + + let workspaceTitle = "UITest Workspace" + let alphaTitle = "UITest Alpha" + let betaTitle = "UITest Beta" + tabManager.setCustomTitle(tabId: workspace.id, title: workspaceTitle) + workspace.setPanelCustomTitle(panelId: alphaPanelId, title: alphaTitle) + tabManager.newSurface() + + guard let betaPanelId = workspace.focusedPanelId, betaPanelId != alphaPanelId else { + self.writeBonsplitTabDragUITestData(["setupError": "Failed to create second surface"]) + return + } + + workspace.setPanelCustomTitle(panelId: betaPanelId, title: betaTitle) + if startWithHiddenSidebar { + self.sidebarState?.isVisible = false + } + self.writeBonsplitTabDragUITestData([ + "ready": "1", + "sidebarVisible": startWithHiddenSidebar ? "0" : "1", + "workspaceId": workspace.id.uuidString, + "workspaceTitle": workspaceTitle, + "alphaTitle": alphaTitle, + "betaTitle": betaTitle, + "alphaPanelId": alphaPanelId.uuidString, + "betaPanelId": betaPanelId.uuidString, + ]) + self.startBonsplitTabDragUITestRecorder( + workspaceId: workspace.id, + alphaPanelId: alphaPanelId, + betaPanelId: betaPanelId + ) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard self != nil else { return } + runSetupWhenWindowReady() + } + } + + private func bonsplitTabDragUITestDataPath() -> String? { + let env = ProcessInfo.processInfo.environment + guard env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] == "1", + let path = env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"], + !path.isEmpty else { + return nil + } + return path + } + + private func startBonsplitTabDragUITestRecorder( + workspaceId: UUID, + alphaPanelId: UUID, + betaPanelId: UUID + ) { + bonsplitTabDragUITestRecorder?.cancel() + bonsplitTabDragUITestRecorder = nil + + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(100)) + timer.setEventHandler { [weak self] in + self?.recordBonsplitTabDragUITestState( + workspaceId: workspaceId, + alphaPanelId: alphaPanelId, + betaPanelId: betaPanelId + ) + } + bonsplitTabDragUITestRecorder = timer + timer.resume() + } + + private func recordBonsplitTabDragUITestState( + workspaceId: UUID, + alphaPanelId: UUID, + betaPanelId: UUID + ) { + guard let tabManager else { return } + guard let workspace = (tabManager.tabs.first { $0.id == workspaceId } ?? tabManager.selectedWorkspace ?? tabManager.tabs.first) else { + return + } + + let trackedPaneId = workspace.paneId(forPanelId: alphaPanelId) + ?? workspace.paneId(forPanelId: betaPanelId) + ?? workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first + guard let trackedPaneId else { return } + + let titles: [String] = workspace.bonsplitController.tabs(inPane: trackedPaneId).compactMap { tab in + guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { return nil } + return workspace.panelTitle(panelId: panelId) + } + let selectedTitle = workspace.bonsplitController.selectedTab(inPane: trackedPaneId) + .flatMap { workspace.panelIdFromSurfaceId($0.id) } + .flatMap { workspace.panelTitle(panelId: $0) } ?? "" + + writeBonsplitTabDragUITestData([ + "trackedPaneId": trackedPaneId.description, + "trackedPaneTabTitles": titles.joined(separator: "|"), + "trackedPaneTabCount": String(titles.count), + "trackedPaneSelectedTitle": selectedTitle, + ]) + } + + private func writeBonsplitTabDragUITestData(_ updates: [String: String]) { + guard let path = bonsplitTabDragUITestDataPath() else { return } + var payload = loadBonsplitTabDragUITestData(at: path) + for (key, value) in updates { + payload[key] = value + } + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + private func loadBonsplitTabDragUITestData(at path: String) -> [String: String] { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return object + } private func isGotoSplitUITestRecordingEnabled() -> Bool { let env = ProcessInfo.processInfo.environment return env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" || env["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] == "1" diff --git a/cmuxUITests/BonsplitTabDragUITests.swift b/cmuxUITests/BonsplitTabDragUITests.swift new file mode 100644 index 00000000..7379bc81 --- /dev/null +++ b/cmuxUITests/BonsplitTabDragUITests.swift @@ -0,0 +1,546 @@ +import XCTest +import Foundation +import AppKit +import CoreGraphics + +final class BonsplitTabDragUITests: XCTestCase { + private let launchTimeout: TimeInterval = 20.0 + private let setupTimeout: TimeInterval = 25.0 + + override func setUp() { + super.setUp() + continueAfterFailure = false + + let cleanup = XCUIApplication() + cleanup.terminate() + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + + func testHiddenWorkspaceTitlebarKeepsTabReorderWorking() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for Bonsplit tab drag UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let betaTitle = ready["betaTitle"] ?? "UITest Beta" + let window = app.windows.element(boundBy: 0) + let alphaTab = app.buttons[alphaTitle] + let betaTab = app.buttons[betaTitle] + let dropIndicator = app.descendants(matching: .any).matching(identifier: "paneTabBar.dropIndicator").firstMatch + let initialOrder = "\(alphaTitle)|\(betaTitle)" + let reorderedOrder = "\(betaTitle)|\(alphaTitle)" + + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + XCTAssertTrue(betaTab.waitForExistence(timeout: 5.0), "Expected beta tab to exist") + XCTAssertTrue( + waitForJSONKey("trackedPaneTabTitles", equals: initialOrder, atPath: dataPath, timeout: 5.0) != nil, + "Expected initial tracked tab order to be \(initialOrder). data=\(loadJSON(atPath: dataPath) ?? [:])" + ) + XCTAssertLessThan(alphaTab.frame.minX, betaTab.frame.minX, "Expected beta tab to start to the right of alpha") + let windowFrameBeforeDrag = window.frame + + let start = CGPoint(x: betaTab.frame.midX, y: betaTab.frame.midY) + let destination = CGPoint(x: alphaTab.frame.midX - 14, y: alphaTab.frame.midY) + guard let dragSession = beginMouseDrag( + fromAccessibilityPoint: start, + holdDuration: 0.20 + ) else { + XCTFail("Expected raw mouse drag session to start") + return + } + continueMouseDrag( + dragSession, + toAccessibilityPoint: destination, + steps: 28, + dragDuration: 0.45 + ) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { dropIndicator.exists }, + "Expected dragging beta onto alpha to reveal the Bonsplit drop indicator." + ) + endMouseDrag(dragSession, atAccessibilityPoint: destination) + + XCTAssertTrue( + waitForJSONKey("trackedPaneTabTitles", equals: reorderedOrder, atPath: dataPath, timeout: 5.0) != nil, + "Expected tracked tab order to become \(reorderedOrder). data=\(loadJSON(atPath: dataPath) ?? [:])" + ) + XCTAssertTrue( + waitForCondition(timeout: 5.0) { betaTab.frame.minX < alphaTab.frame.minX }, + "Expected dragging beta onto alpha to reorder tab frames. alpha=\(alphaTab.frame) beta=\(betaTab.frame)" + ) + XCTAssertEqual(window.frame.origin.x, windowFrameBeforeDrag.origin.x, accuracy: 2.0, "Expected tab drag not to move the window horizontally") + XCTAssertEqual(window.frame.origin.y, windowFrameBeforeDrag.origin.y, accuracy: 2.0, "Expected tab drag not to move the window vertically") + } + + func testHiddenWorkspaceTitlebarPlacesPaneTabBarAtTopEdge() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for hidden titlebar top-gap UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let alphaTab = app.buttons[alphaTitle] + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + + let gapIfOriginIsBottomLeft = abs(window.frame.maxY - alphaTab.frame.maxY) + let gapIfOriginIsTopLeft = abs(alphaTab.frame.minY - window.frame.minY) + let topGap = min(gapIfOriginIsBottomLeft, gapIfOriginIsTopLeft) + XCTAssertLessThanOrEqual( + topGap, + 8, + "Expected the selected pane tab to reach the top edge when the workspace titlebar is hidden. window=\(window.frame) alphaTab=\(alphaTab.frame) gap.bottomLeft=\(gapIfOriginIsBottomLeft) gap.topLeft=\(gapIfOriginIsTopLeft)" + ) + } + + func testHiddenWorkspaceTitlebarKeepsSidebarRowsBelowTrafficLights() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for hidden titlebar sidebar inset UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let workspaceId = ready["workspaceId"] ?? "" + let workspaceRowIdentifier = "sidebarWorkspace.\(workspaceId)" + let workspaceRow = app.descendants(matching: .any).matching(identifier: workspaceRowIdentifier).firstMatch + XCTAssertTrue(workspaceRow.waitForExistence(timeout: 5.0), "Expected workspace row to exist") + + let topInset = distanceToTopEdge(of: workspaceRow, in: window) + XCTAssertEqual( + topInset, + 36, + accuracy: 4, + "Expected hidden-titlebar mode to keep the sidebar workspace row offset unchanged while reserving the existing traffic-light strip. window=\(window.frame) workspaceRow=\(workspaceRow.frame) topInset=\(topInset)" + ) + } + + func testHiddenWorkspaceTitlebarSidebarControlsRevealOnlyFromSidebarHover() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for hidden titlebar titlebar-controls hover UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let sidebar = app.descendants(matching: .any).matching(identifier: "Sidebar").firstMatch + XCTAssertTrue(sidebar.waitForExistence(timeout: 5.0), "Expected sidebar to exist") + + let toggleSidebarButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.toggleSidebar").firstMatch + let notificationsButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.showNotifications").firstMatch + let newWorkspaceButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.newTab").firstMatch + + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let alphaTab = app.buttons[alphaTitle] + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + + let paneLeadingGap = alphaTab.frame.minX - sidebar.frame.maxX + XCTAssertLessThan( + paneLeadingGap, + 28, + "Expected visible-sidebar hidden-titlebar mode to keep pane tabs tight to the sidebar edge while the traffic lights sit over the sidebar. window=\(window.frame) sidebar=\(sidebar.frame) alphaTab=\(alphaTab.frame) paneLeadingGap=\(paneLeadingGap)" + ) + + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable + }, + "Expected hidden-titlebar sidebar controls to stay hidden away from the sidebar hover zone." + ) + + hover(in: window, at: CGPoint(x: window.frame.maxX - 48, y: window.frame.minY + 18)) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable + }, + "Expected the removed titlebar area to stop revealing hidden-titlebar controls." + ) + + hover( + in: window, + at: CGPoint( + x: min(sidebar.frame.maxX - 36, sidebar.frame.minX + 116), + y: window.frame.minY + 18 + ) + ) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + toggleSidebarButton.exists && toggleSidebarButton.isHittable && + notificationsButton.exists && notificationsButton.isHittable && + newWorkspaceButton.exists && newWorkspaceButton.isHittable + }, + "Expected hidden-titlebar sidebar controls to reveal when hovering the sidebar chrome area." + ) + } + + func testHiddenWorkspaceTitlebarCollapsedSidebarKeepsControlsSuppressed() { + let (app, dataPath) = launchConfiguredApp(startWithHiddenSidebar: true) + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for collapsed-sidebar hidden-titlebar controls UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + XCTAssertEqual(ready["sidebarVisible"], "0", "Expected hidden-sidebar UI test setup to collapse the sidebar. data=\(ready)") + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let alphaTab = app.buttons[alphaTitle] + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + + let toggleSidebarButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.toggleSidebar").firstMatch + let notificationsButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.showNotifications").firstMatch + let newWorkspaceButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.newTab").firstMatch + + hover(in: window, at: CGPoint(x: window.frame.maxX - 48, y: window.frame.minY + 18)) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + (!toggleSidebarButton.exists || !toggleSidebarButton.isHittable) && + (!notificationsButton.exists || !notificationsButton.isHittable) && + (!newWorkspaceButton.exists || !newWorkspaceButton.isHittable) + }, + "Expected collapsed-sidebar hidden-titlebar mode to keep titlebar controls suppressed. toggle=\(toggleSidebarButton.debugDescription) notifications=\(notificationsButton.debugDescription) new=\(newWorkspaceButton.debugDescription)" + ) + + let leadingInset = alphaTab.frame.minX - window.frame.minX + XCTAssertLessThan( + leadingInset, + 96, + "Expected pane tabs to stay near the leading edge when collapsed-sidebar hidden-titlebar mode removes the titlebar accessory lane. window=\(window.frame) alphaTab=\(alphaTab.frame) leadingInset=\(leadingInset)" + ) + } + + func testHiddenWorkspaceTitlebarKeepsSidebarControlsVisibleWhileNotificationsPopoverIsShown() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for hidden-titlebar notifications-popover pinning UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let toggleSidebarButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.toggleSidebar").firstMatch + let notificationsButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.showNotifications").firstMatch + let newWorkspaceButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.newTab").firstMatch + + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable + }, + "Expected hidden-titlebar sidebar controls to start hidden away from hover." + ) + + app.typeKey("i", modifierFlags: [.command]) + XCTAssertTrue( + app.buttons["notificationsPopover.jumpToLatest"].waitForExistence(timeout: 6.0) + || app.staticTexts["No notifications yet"].waitForExistence(timeout: 6.0), + "Expected notifications popover to open." + ) + + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + toggleSidebarButton.exists && toggleSidebarButton.isHittable && + notificationsButton.exists && notificationsButton.isHittable && + newWorkspaceButton.exists && newWorkspaceButton.isHittable + }, + "Expected hidden-titlebar sidebar controls to stay visible while the notifications popover is open." + ) + } + + func testPaneTabBarControlsRevealWhenHoveringAnywhereOnPaneTabBar() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for Bonsplit controls hover UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let betaTitle = ready["betaTitle"] ?? "UITest Beta" + let alphaTab = app.buttons[alphaTitle] + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + let betaTab = app.buttons[betaTitle] + XCTAssertTrue(betaTab.waitForExistence(timeout: 5.0), "Expected beta tab to exist") + + let newTerminalButton = app.descendants(matching: .any).matching(identifier: "paneTabBarControl.newTerminal").firstMatch + + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { !newTerminalButton.exists || !newTerminalButton.isHittable }, + "Expected pane tab bar controls to hide away from the pane tab bar. button=\(newTerminalButton.debugDescription)" + ) + + hover( + in: window, + at: CGPoint( + x: min(window.frame.maxX - 140, betaTab.frame.maxX + 80), + y: alphaTab.frame.midY + ) + ) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { newTerminalButton.exists && newTerminalButton.isHittable }, + "Expected pane tab bar controls to reveal when hovering inside empty pane-tab-bar space. window=\(window.frame) alphaTab=\(alphaTab.frame) betaTab=\(betaTab.frame) button=\(newTerminalButton.debugDescription)" + ) + + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { !newTerminalButton.exists || !newTerminalButton.isHittable }, + "Expected pane tab bar controls to hide again after leaving the pane tab bar. button=\(newTerminalButton.debugDescription)" + ) + } + + private func launchConfiguredApp(startWithHiddenSidebar: Bool = false) -> (XCUIApplication, String) { + let app = XCUIApplication() + let dataPath = "/tmp/cmux-ui-test-bonsplit-tab-drag-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: dataPath) + + app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] = "1" + app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"] = dataPath + if startWithHiddenSidebar { + app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] = "1" + } + app.launchArguments += ["-workspaceTitlebarVisible", "NO"] + app.launch() + app.activate() + return (app, dataPath) + } + + private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { + if app.wait(for: .runningForeground, timeout: timeout) { + return true + } + if app.state == .runningBackground { + app.activate() + return app.wait(for: .runningForeground, timeout: 6.0) + } + return false + } + + private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if loadJSON(atPath: path) != nil { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return loadJSON(atPath: path) != nil + } + + private func waitForJSONKey(_ key: String, equals expected: String, atPath path: String, timeout: TimeInterval) -> [String: String]? { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let data = loadJSON(atPath: path), data[key] == expected { + return data + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + if let data = loadJSON(atPath: path), data[key] == expected { + return data + } + return nil + } + + private func loadJSON(atPath path: String) -> [String: String]? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return nil + } + return object + } + + private func waitForCondition(timeout: TimeInterval, _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if condition() { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return condition() + } + + private func hover(in window: XCUIElement, at point: CGPoint) { + let origin = window.coordinate(withNormalizedOffset: .zero) + origin.withOffset( + CGVector( + dx: point.x - window.frame.minX, + dy: point.y - window.frame.minY + ) + ).hover() + } + + private func distanceToTopEdge(of element: XCUIElement, in window: XCUIElement) -> CGFloat { + let gapIfOriginIsBottomLeft = abs(window.frame.maxY - element.frame.maxY) + let gapIfOriginIsTopLeft = abs(element.frame.minY - window.frame.minY) + return min(gapIfOriginIsBottomLeft, gapIfOriginIsTopLeft) + } + + private struct RawMouseDragSession { + let source: CGEventSource + } + + private func beginMouseDrag( + fromAccessibilityPoint start: CGPoint, + holdDuration: TimeInterval = 0.15 + ) -> RawMouseDragSession? { + let source = CGEventSource(stateID: .hidSystemState) + XCTAssertNotNil(source, "Expected CGEventSource for raw mouse drag") + guard let source else { return nil } + + let quartzStart = quartzPoint(fromAccessibilityPoint: start) + + postMouseEvent(type: .mouseMoved, at: quartzStart, source: source) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + postMouseEvent(type: .leftMouseDown, at: quartzStart, source: source) + RunLoop.current.run(until: Date().addingTimeInterval(holdDuration)) + return RawMouseDragSession(source: source) + } + + private func continueMouseDrag( + _ session: RawMouseDragSession, + toAccessibilityPoint end: CGPoint, + steps: Int = 20, + dragDuration: TimeInterval = 0.30 + ) { + let currentLocation = NSEvent.mouseLocation + let quartzEnd = quartzPoint(fromAccessibilityPoint: end) + let clampedSteps = max(2, steps) + for step in 1...clampedSteps { + let progress = CGFloat(step) / CGFloat(clampedSteps) + let point = CGPoint( + x: currentLocation.x + ((quartzEnd.x - currentLocation.x) * progress), + y: currentLocation.y + ((quartzEnd.y - currentLocation.y) * progress) + ) + postMouseEvent(type: .leftMouseDragged, at: point, source: session.source) + RunLoop.current.run(until: Date().addingTimeInterval(dragDuration / Double(clampedSteps))) + } + } + + private func endMouseDrag( + _ session: RawMouseDragSession, + atAccessibilityPoint end: CGPoint + ) { + let quartzEnd = quartzPoint(fromAccessibilityPoint: end) + postMouseEvent(type: .leftMouseUp, at: quartzEnd, source: session.source) + RunLoop.current.run(until: Date().addingTimeInterval(0.2)) + } + + private func postMouseEvent( + type: CGEventType, + at point: CGPoint, + source: CGEventSource + ) { + guard let event = CGEvent( + mouseEventSource: source, + mouseType: type, + mouseCursorPosition: point, + mouseButton: .left + ) else { + XCTFail("Expected CGEvent for mouse type \(type.rawValue) at \(point)") + return + } + + event.setIntegerValueField(.mouseEventClickState, value: 1) + event.post(tap: .cghidEventTap) + } + + private func quartzPoint(fromAccessibilityPoint point: CGPoint) -> CGPoint { + let desktopBounds = NSScreen.screens.reduce(CGRect.null) { partialResult, screen in + partialResult.union(screen.frame) + } + XCTAssertFalse(desktopBounds.isNull, "Expected at least one screen when converting raw mouse coordinates") + guard !desktopBounds.isNull else { return point } + return CGPoint(x: point.x, y: desktopBounds.maxY - point.y) + } +} From e4ef98aca142ceb9c30c3e972b2d254a51998781 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 16:43:26 -0700 Subject: [PATCH 02/28] Implement hidden-titlebar minimalism mode --- Resources/Localizable.xcstrings | 51 ++++++++++++ Sources/AppDelegate.swift | 4 + Sources/ContentView.swift | 26 +++++- Sources/Update/UpdateTitlebarAccessory.swift | 83 ++++++++++++++++++++ Sources/WorkspaceContentView.swift | 13 ++- Sources/cmuxApp.swift | 42 ++++++++++ vendor/bonsplit | 2 +- 7 files changed, 215 insertions(+), 6 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 812aef3b..b10c4e69 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -43635,6 +43635,57 @@ } } }, + "settings.app.showWorkspaceTitlebar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Workspace Title Bar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースのタイトルバーを表示" + } + } + } + }, + "settings.app.showWorkspaceTitlebar.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide the workspace title bar and show sidebar or pane actions only on hover." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースのタイトルバーを隠し、サイドバーやペインタブの操作はホバー時のみ表示します。" + } + } + } + }, + "settings.app.showWorkspaceTitlebar.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show the folder and active title above pane tabs." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペインタブの上にフォルダ名と現在のタイトルを表示します。" + } + } + } + }, "settings.app.showPullRequests": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c0413ab3..5b0c9cb2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7663,6 +7663,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent titlebarAccessoryController.dismissNotificationsPopoverIfShown() } + func isNotificationsPopoverShown() -> Bool { + titlebarAccessoryController.isNotificationsPopoverShown() + } + func jumpToLatestUnread() { guard let notificationStore else { return } #if DEBUG diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7d966328..b7d3c90d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1951,6 +1951,12 @@ struct ContentView: View { /// Space at top of content area for the titlebar. This must be at least the actual titlebar /// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags. @State private var titlebarPadding: CGFloat = 32 + @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) + private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar + + private var effectiveTitlebarPadding: CGFloat { + showWorkspaceTitlebar ? titlebarPadding : 0 + } private var terminalContent: some View { let mountedWorkspaceIdSet = Set(mountedWorkspaceIds) @@ -2004,10 +2010,12 @@ struct ContentView: View { .allowsHitTesting(sidebarSelectionState.selection == .notifications) .accessibilityHidden(sidebarSelectionState.selection != .notifications) } - .padding(.top, titlebarPadding) + .padding(.top, effectiveTitlebarPadding) .overlay(alignment: .top) { - // Titlebar overlay is only over terminal content, not the sidebar. - customTitlebar + if showWorkspaceTitlebar { + // Titlebar overlay is only over terminal content, not the sidebar. + customTitlebar + } } } @@ -2224,7 +2232,7 @@ struct ContentView: View { contentAndSidebarLayout .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .topLeading) { - if isFullScreen && sidebarState.isVisible { + if isFullScreen && sidebarState.isVisible && showWorkspaceTitlebar { fullscreenControls .padding(.leading, 10) .padding(.top, 4) @@ -7765,10 +7773,13 @@ struct VerticalTabsSidebar: View { private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @AppStorage(SidebarWorkspaceDetailSettings.showNotificationMessageKey) private var sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage + @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) + private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar /// Space at top of sidebar for traffic light buttons private let trafficLightPadding: CGFloat = 28 private let tabRowSpacing: CGFloat = 2 + private let hiddenTitlebarControlsLeadingInset: CGFloat = 72 private var showsSidebarNotificationMessage: Bool { SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( @@ -7856,6 +7867,13 @@ struct VerticalTabsSidebar: View { WindowDragHandleView() .frame(height: trafficLightPadding) } + .overlay(alignment: .topLeading) { + if !showWorkspaceTitlebar { + HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) + .padding(.leading, hiddenTitlebarControlsLeadingInset) + .padding(.top, 2) + } + } .background(Color.clear) .modifier(ClearScrollBackground()) } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 984df39c..cd18fb56 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -119,6 +119,22 @@ final class TitlebarControlsViewModel: ObservableObject { weak var notificationsAnchorView: NSView? } +extension Notification.Name { + static let cmuxNotificationsPopoverVisibilityDidChange = Notification.Name("cmux.notificationsPopoverVisibilityDidChange") +} + +private enum NotificationsPopoverVisibilityUserInfoKey { + static let isShown = "isShown" +} + +private func postNotificationsPopoverVisibilityDidChange(isShown: Bool) { + NotificationCenter.default.post( + name: .cmuxNotificationsPopoverVisibilityDidChange, + object: nil, + userInfo: [NotificationsPopoverVisibilityUserInfoKey.isShown: isShown] + ) +} + struct NotificationsAnchorView: NSViewRepresentable { let onResolve: (NSView) -> Void @@ -508,6 +524,54 @@ struct TitlebarControlsView: View { } } +struct HiddenTitlebarSidebarControlsView: View { + @ObservedObject var notificationStore: TerminalNotificationStore + @StateObject private var viewModel = TitlebarControlsViewModel() + @State private var isHoveringControls = false + @State private var isNotificationsPopoverShown = false + + private let hostWidth: CGFloat = 124 + private let hostHeight: CGFloat = 28 + + private var shouldShowControls: Bool { + isHoveringControls || isNotificationsPopoverShown + } + + var body: some View { + ZStack(alignment: .leading) { + Color.clear + .frame(width: hostWidth, height: hostHeight) + + TitlebarControlsView( + notificationStore: notificationStore, + viewModel: viewModel, + onToggleSidebar: { _ = AppDelegate.shared?.sidebarState?.toggle() }, + onToggleNotifications: { [viewModel] in + AppDelegate.shared?.toggleNotificationsPopover( + animated: true, + anchorView: viewModel.notificationsAnchorView + ) + }, + onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() } + ) + .opacity(shouldShowControls ? 1 : 0) + .allowsHitTesting(shouldShowControls) + .animation(.easeInOut(duration: 0.12), value: shouldShowControls) + } + .frame(width: hostWidth, height: hostHeight, alignment: .leading) + .contentShape(Rectangle()) + .onHover { hovering in + isHoveringControls = hovering + } + .onAppear { + isNotificationsPopoverShown = AppDelegate.shared?.isNotificationsPopoverShown() ?? false + } + .onReceive(NotificationCenter.default.publisher(for: .cmuxNotificationsPopoverVisibilityDidChange)) { notification in + isNotificationsPopoverShown = (notification.userInfo?[NotificationsPopoverVisibilityUserInfoKey.isShown] as? Bool) ?? false + } + } +} + @MainActor private final class TitlebarShortcutHintModifierMonitor: ObservableObject { @Published private(set) var isModifierPressed = false @@ -714,6 +778,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private let viewModel = TitlebarControlsViewModel() private var userDefaultsObserver: NSObjectProtocol? var popoverIsShownForTesting: Bool { notificationsPopover.isShown } + private var showWorkspaceTitlebar: Bool { WorkspaceTitlebarSettings.isVisible() } init(notificationStore: TerminalNotificationStore) { self.notificationStore = notificationStore @@ -749,9 +814,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont object: nil, queue: .main ) { [weak self] _ in + self?.applyWorkspaceTitlebarVisibility() self?.scheduleSizeUpdate(invalidateFittingSize: true) } + applyWorkspaceTitlebarVisibility() scheduleSizeUpdate(invalidateFittingSize: true) } @@ -796,6 +863,8 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont } private func updateSize() { + applyWorkspaceTitlebarVisibility() + guard showWorkspaceTitlebar else { return } let contentSize: NSSize if fittingSizeNeedsRefresh || cachedFittingSize == nil { hostingView.invalidateIntrinsicContentSize() @@ -828,6 +897,16 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height) } + private func applyWorkspaceTitlebarVisibility() { + let shouldShow = showWorkspaceTitlebar + view.isHidden = !shouldShow + if !shouldShow { + preferredContentSize = .zero + containerView.frame = .zero + hostingView.frame = .zero + } + } + func toggleNotificationsPopover(animated: Bool = true, externalAnchor: NSView? = nil) { if notificationsPopover.isShown { notificationsPopover.performClose(nil) @@ -861,6 +940,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont if !anchorRect.isEmpty { notificationsPopover.animates = animated notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY) + postNotificationsPopoverVisibilityDidChange(isShown: true) return } } @@ -871,6 +951,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont if !anchorRect.isEmpty { notificationsPopover.animates = animated notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY) + postNotificationsPopoverVisibilityDidChange(isShown: true) return } } @@ -880,6 +961,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont let anchorRect = NSRect(x: 12, y: bounds.maxY - 8, width: 1, height: 1) notificationsPopover.animates = animated notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY) + postNotificationsPopoverVisibilityDidChange(isShown: true) } func dismissNotificationsPopover() { @@ -902,6 +984,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont func popoverDidClose(_ notification: Notification) { // Clear the content view controller to stop SwiftUI observers when popover is hidden notificationsPopover.contentViewController = nil + postNotificationsPopoverVisibilityDidChange(isShown: false) } } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 0b955943..b352a51a 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -16,6 +16,8 @@ struct WorkspaceContentView: View { _ notificationPayloadHex: String? ) -> Void)? @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit") + @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) + private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -52,7 +54,7 @@ struct WorkspaceContentView: View { } }() - BonsplitView(controller: workspace.bonsplitController) { tab, paneId in + let bonsplitView = BonsplitView(controller: workspace.bonsplitController) { tab, paneId in // Content for each tab in bonsplit let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) if let panel = workspace.panel(for: tab.id) { @@ -147,6 +149,15 @@ struct WorkspaceContentView: View { notificationPayloadHex: payloadHex ) } + + Group { + if showWorkspaceTitlebar { + bonsplitView + } else { + bonsplitView + .ignoresSafeArea(.container, edges: .top) + } + } } private func syncBonsplitNotificationBadges() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 20739849..3da2656d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4,6 +4,18 @@ import Darwin import Bonsplit import UniformTypeIdentifiers +enum WorkspaceTitlebarSettings { + static let showTitlebarKey = "workspaceTitlebarVisible" + static let defaultShowTitlebar = true + + static func isVisible(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: showTitlebarKey) == nil { + return defaultShowTitlebar + } + return defaults.bool(forKey: showTitlebarKey) + } +} + @main struct cmuxApp: App { @StateObject private var tabManager: TabManager @@ -3085,6 +3097,8 @@ struct SettingsView: View { @AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue + @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) + private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @@ -3167,6 +3181,19 @@ struct SettingsView: View { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement } + private var workspaceTitlebarSubtitle: String { + if showWorkspaceTitlebar { + return String( + localized: "settings.app.showWorkspaceTitlebar.subtitleOn", + defaultValue: "Show the folder and active title above pane tabs." + ) + } + return String( + localized: "settings.app.showWorkspaceTitlebar.subtitleOff", + defaultValue: "Hide the workspace title bar and show sidebar or pane actions only on hover." + ) + } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) } @@ -3559,6 +3586,20 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar"), + subtitle: workspaceTitlebarSubtitle + ) { + Toggle("", isOn: $showWorkspaceTitlebar) + .labelsHidden() + .controlSize(.small) + .accessibilityLabel( + String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar") + ) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") @@ -4623,6 +4664,7 @@ struct SettingsView: View { ShortcutHintDebugSettings.resetVisibilityDefaults() alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage diff --git a/vendor/bonsplit b/vendor/bonsplit index 73c1ef2d..a5598131 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826 +Subproject commit a55981319828bd832981c9be2275d199c266da41 From f592d971267476d7d9245b01c818dfad90c6af94 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 18:03:35 -0700 Subject: [PATCH 03/28] Add hidden titlebar regression coverage --- .../AppDelegateShortcutRoutingTests.swift | 80 +++++++++++++++++++ cmuxUITests/SidebarHelpMenuUITests.swift | 62 ++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 820cdb0b..fdefb9c0 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -671,6 +671,86 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } } + func testHiddenWorkspaceTitlebarUsesZeroTopSafeAreaForMainWindowContentView() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let defaults = UserDefaults.standard + let previousValue = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defaults.set(false, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + } else { + defaults.removeObject(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + } + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let contentView = window.contentView else { + XCTFail("Expected main window content view") + return + } + + contentView.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual( + contentView.safeAreaInsets.top, + 0, + accuracy: 0.5, + "Hidden workspace titlebar should not leave a top safe-area inset in the main window content view" + ) + } + + func testAttachUpdateAccessoryRemovesTitlebarAccessoryWhenWorkspaceTitlebarHidden() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let defaults = UserDefaults.standard + let previousValue = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defaults.set(true, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defer { + if let previousValue { + defaults.set(previousValue, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + } else { + defaults.removeObject(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + } + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected main window") + return + } + + let hasTitlebarAccessory: () -> Bool = { + window.titlebarAccessoryViewControllers.contains { + $0.view.identifier?.rawValue == "cmux.titlebarControls" + } + } + + XCTAssertTrue(hasTitlebarAccessory(), "Expected visible-titlebar mode to attach the titlebar accessory") + + defaults.set(false, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + appDelegate.attachUpdateAccessory(to: window) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertFalse( + hasTitlebarAccessory(), + "Hidden workspace titlebar should remove the titlebar accessory instead of keeping a hidden controller attached" + ) + } + func testCmdPhysicalPWithDvorakCharactersDoesNotTriggerCommandPaletteSwitcher() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index b27cee62..89e86916 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -475,6 +475,43 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) } + func testWorkspaceTitlebarToggleKeepsSettingsWindowFocused() throws { + let app = XCUIApplication() + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1" + launchAndActivate(app) + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 8.0) { + app.windows.count >= 2 + }, + "Expected the main window and Settings window to be visible" + ) + + focusSettingsWindow(app: app) + let toggle = try requireShowWorkspaceTitlebarToggle(app: app) + let initialState = toggleIsOn(toggle) + + toggle.click() + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { + toggle.exists && toggleIsOn(toggle) != initialState + }, + "Expected the workspace titlebar setting to toggle" + ) + + app.typeKey("w", modifierFlags: [.command]) + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { + app.windows.count == 1 && !toggle.exists + }, + "Expected Cmd+W after toggling the workspace titlebar setting to close the focused Settings window instead of defocusing back to the workspace window" + ) + } + private func launchAndActivate(_ app: XCUIApplication) { app.launch() XCTAssertTrue( @@ -539,6 +576,31 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { throw XCTSkip("Could not find the command palette all-surfaces toggle") } + private func requireShowWorkspaceTitlebarToggle(app: XCUIApplication) throws -> XCUIElement { + let scrollView = app.scrollViews.firstMatch + let candidates = [ + app.switches["SettingsShowWorkspaceTitlebarToggle"], + app.checkBoxes["SettingsShowWorkspaceTitlebarToggle"], + app.buttons["SettingsShowWorkspaceTitlebarToggle"], + app.otherElements["SettingsShowWorkspaceTitlebarToggle"], + app.switches["Show Workspace Title Bar"], + app.checkBoxes["Show Workspace Title Bar"], + app.buttons["Show Workspace Title Bar"], + app.otherElements["Show Workspace Title Bar"], + ] + + for _ in 0..<8 { + if let element = firstExistingElement(candidates: candidates, timeout: 0.4), element.isHittable { + return element + } + if scrollView.exists { + scrollView.swipeUp() + } + } + + throw XCTSkip("Could not find the workspace titlebar toggle") + } + private func toggleIsOn(_ element: XCUIElement) -> Bool { let value = String(describing: element.value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return value == "1" || value == "true" || value == "on" From d67237b891b4fe8c1885bf777cbede9fcd2ffcfb Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 18:03:38 -0700 Subject: [PATCH 04/28] Fix hidden titlebar underlap and settings focus --- Sources/AppDelegate.swift | 26 ++++++++- Sources/Update/UpdateTitlebarAccessory.swift | 61 ++++++++++++++++++-- Sources/cmuxApp.swift | 26 ++++++++- 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5b0c9cb2..aaf0fe05 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9,6 +9,30 @@ import Combine import ObjectiveC.runtime import Darwin +final class MainWindowHostingView: NSHostingView { + private let zeroSafeAreaLayoutGuide = NSLayoutGuide() + + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } + override var safeAreaRect: NSRect { bounds } + override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } + + required init(rootView: Content) { + super.init(rootView: rootView) + addLayoutGuide(zeroSafeAreaLayoutGuide) + NSLayoutConstraint.activate([ + zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), + zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), + zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), + zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + private enum CmuxThemeNotifications { static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config") } @@ -5457,7 +5481,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } else { window.center() } - window.contentView = NSHostingView(rootView: root) + window.contentView = MainWindowHostingView(rootView: root) // Apply shared window styling. attachUpdateAccessory(to: window) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index cd18fb56..0b9c956c 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1173,6 +1173,7 @@ private struct NotificationPopoverRow: View { } } +@MainActor final class UpdateTitlebarAccessoryController { private weak var updateViewModel: UpdateViewModel? private var didStart = false @@ -1213,7 +1214,9 @@ final class UpdateTitlebarAccessoryController { queue: .main ) { [weak self] notification in guard let window = notification.object as? NSWindow else { return } - self?.attachIfNeeded(to: window) + Task { @MainActor [weak self] in + self?.attachIfNeeded(to: window) + } }) observers.append(center.addObserver( @@ -1222,7 +1225,9 @@ final class UpdateTitlebarAccessoryController { queue: .main ) { [weak self] notification in guard let window = notification.object as? NSWindow else { return } - self?.attachIfNeeded(to: window) + Task { @MainActor [weak self] in + self?.attachIfNeeded(to: window) + } }) // We intentionally do not rely on "window became visible" notifications here: @@ -1242,7 +1247,9 @@ final class UpdateTitlebarAccessoryController { let delays: [TimeInterval] = [0.05, 0.15, 0.3, 0.6, 1.0, 2.0, 3.0] for delay in delays { let item = DispatchWorkItem { [weak self] in - self?.attachToExistingWindows() + Task { @MainActor [weak self] in + self?.attachToExistingWindows() + } #if DEBUG let env = ProcessInfo.processInfo.environment if env["CMUX_UI_TEST_MODE"] == "1" { @@ -1258,7 +1265,6 @@ final class UpdateTitlebarAccessoryController { } private func attachIfNeeded(to window: NSWindow) { - guard !attachedWindows.contains(window) else { return } guard !isSettingsWindow(window) else { return } // Window identifiers are assigned by SwiftUI via WindowAccessor, which can run @@ -1270,8 +1276,10 @@ final class UpdateTitlebarAccessoryController { if attempts < 40 { pendingAttachRetries[key] = attempts + 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak window] in - guard let self, let window else { return } - self.attachIfNeeded(to: window) + Task { @MainActor [weak self, weak window] in + guard let self, let window else { return } + self.attachIfNeeded(to: window) + } } } else { pendingAttachRetries.removeValue(forKey: key) @@ -1281,6 +1289,13 @@ final class UpdateTitlebarAccessoryController { pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) + guard WorkspaceTitlebarSettings.isVisible() else { + removeAccessoryIfPresent(from: window) + return + } + + guard !attachedWindows.contains(window) else { return } + if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == controlsIdentifier }) { let controls = TitlebarControlsAccessoryViewController( notificationStore: TerminalNotificationStore.shared @@ -1302,6 +1317,40 @@ final class UpdateTitlebarAccessoryController { #endif } + private func removeAccessoryIfPresent(from window: NSWindow) { + let matchingIndices = window.titlebarAccessoryViewControllers.indices.reversed().filter { index in + window.titlebarAccessoryViewControllers[index].view.identifier == controlsIdentifier + } + guard !matchingIndices.isEmpty || attachedWindows.contains(window) else { return } + + for index in matchingIndices { + let accessory = window.titlebarAccessoryViewControllers[index] + if let controls = accessory as? TitlebarControlsAccessoryViewController { + controls.dismissNotificationsPopover() + } + window.removeTitlebarAccessoryViewController(at: index) + } + + attachedWindows.remove(window) + pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) + DispatchQueue.main.async { [weak window] in + guard let window else { return } + window.contentView?.needsLayout = true + window.contentView?.superview?.needsLayout = true + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.superview?.layoutSubtreeIfNeeded() + window.invalidateShadow() + } + +#if DEBUG + let env = ProcessInfo.processInfo.environment + if env["CMUX_UI_TEST_MODE"] == "1" { + let ident = window.identifier?.rawValue ?? "" + UpdateLogStore.shared.append("removed titlebar accessories from window id=\(ident)") + } +#endif + } + private func isSettingsWindow(_ window: NSWindow) -> Bool { if window.identifier?.rawValue == "cmux.settings" { return true diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 3da2656d..ea6c3e35 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3241,6 +3241,16 @@ struct SettingsView: View { ) } + private var showWorkspaceTitlebarBinding: Binding { + Binding( + get: { showWorkspaceTitlebar }, + set: { newValue in + showWorkspaceTitlebar = newValue + reassertSettingsWindowFocusIfNeeded() + } + ) + } + private var settingsSidebarTintLightBinding: Binding { Binding( get: { @@ -3590,9 +3600,10 @@ struct SettingsView: View { String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar"), subtitle: workspaceTitlebarSubtitle ) { - Toggle("", isOn: $showWorkspaceTitlebar) + Toggle("", isOn: showWorkspaceTitlebarBinding) .labelsHidden() .controlSize(.small) + .accessibilityIdentifier("SettingsShowWorkspaceTitlebarToggle") .accessibilityLabel( String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar") ) @@ -4625,6 +4636,19 @@ struct SettingsView: View { NSApplication.shared.terminate(nil) } + private func reassertSettingsWindowFocusIfNeeded() { + DispatchQueue.main.async { + guard let window = SettingsWindowController.shared.window, window.isVisible else { return } + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + guard window.isVisible else { return } + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + } + } + } + private func resetAllSettings() { isResettingSettings = true appLanguage = LanguageSettings.defaultLanguage.rawValue From b73a74492b57dace2bee8c0b2eedc573fd677eb8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 18:40:36 -0700 Subject: [PATCH 05/28] Port Bonsplit hidden-titlebar underlap fixes --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index a5598131..743de85d 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit a55981319828bd832981c9be2275d199c266da41 +Subproject commit 743de85dfbdd138a7b07c2b91fc9e60c88fa2cf5 From f883299152baa342d37952723ec2906057543dbf Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 21:31:35 -0700 Subject: [PATCH 06/28] Add fade-buttons regressions --- .../AppDelegateShortcutRoutingTests.swift | 112 ++++++++++++++++++ cmuxUITests/BonsplitTabDragUITests.swift | 2 +- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index fdefb9c0..250a3247 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -751,6 +751,110 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } + func testWorkspaceButtonFadeModeDefaultsOffWhenTitlebarVisible() { + let defaults = UserDefaults.standard + let savedMode = defaults.object(forKey: WorkspaceButtonFadeSettings.modeKey) + let savedTitlebarVisibility = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + let savedLegacyTitlebarMode = defaults.object(forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) + let savedLegacyPaneMode = defaults.object(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) + defer { + restoreDefaultsValue(savedMode, forKey: WorkspaceButtonFadeSettings.modeKey, defaults: defaults) + restoreDefaultsValue(savedTitlebarVisibility, forKey: WorkspaceTitlebarSettings.showTitlebarKey, defaults: defaults) + restoreDefaultsValue(savedLegacyTitlebarMode, forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey, defaults: defaults) + restoreDefaultsValue(savedLegacyPaneMode, forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey, defaults: defaults) + } + + defaults.removeObject(forKey: WorkspaceButtonFadeSettings.modeKey) + defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) + defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) + defaults.set(true, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + + WorkspaceButtonFadeSettings.initializeStoredModeIfNeeded(defaults: defaults) + + XCTAssertEqual( + defaults.string(forKey: WorkspaceButtonFadeSettings.modeKey), + WorkspaceButtonFadeSettings.Mode.disabled.rawValue + ) + } + + func testWorkspaceButtonFadeModeDefaultsOnWhenTitlebarHidden() { + let defaults = UserDefaults.standard + let savedMode = defaults.object(forKey: WorkspaceButtonFadeSettings.modeKey) + let savedTitlebarVisibility = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + let savedLegacyTitlebarMode = defaults.object(forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) + let savedLegacyPaneMode = defaults.object(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) + defer { + restoreDefaultsValue(savedMode, forKey: WorkspaceButtonFadeSettings.modeKey, defaults: defaults) + restoreDefaultsValue(savedTitlebarVisibility, forKey: WorkspaceTitlebarSettings.showTitlebarKey, defaults: defaults) + restoreDefaultsValue(savedLegacyTitlebarMode, forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey, defaults: defaults) + restoreDefaultsValue(savedLegacyPaneMode, forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey, defaults: defaults) + } + + defaults.removeObject(forKey: WorkspaceButtonFadeSettings.modeKey) + defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) + defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) + defaults.set(false, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + + WorkspaceButtonFadeSettings.initializeStoredModeIfNeeded(defaults: defaults) + + XCTAssertEqual( + defaults.string(forKey: WorkspaceButtonFadeSettings.modeKey), + WorkspaceButtonFadeSettings.Mode.enabled.rawValue + ) + } + + func testWorkspaceButtonFadeModeMigratesLegacyHoverVisibilityPreference() { + let defaults = UserDefaults.standard + let savedMode = defaults.object(forKey: WorkspaceButtonFadeSettings.modeKey) + let savedTitlebarVisibility = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + let savedLegacyTitlebarMode = defaults.object(forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) + let savedLegacyPaneMode = defaults.object(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) + defer { + restoreDefaultsValue(savedMode, forKey: WorkspaceButtonFadeSettings.modeKey, defaults: defaults) + restoreDefaultsValue(savedTitlebarVisibility, forKey: WorkspaceTitlebarSettings.showTitlebarKey, defaults: defaults) + restoreDefaultsValue(savedLegacyTitlebarMode, forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey, defaults: defaults) + restoreDefaultsValue(savedLegacyPaneMode, forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey, defaults: defaults) + } + + defaults.removeObject(forKey: WorkspaceButtonFadeSettings.modeKey) + defaults.set(true, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defaults.set("always", forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) + defaults.set("onHover", forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) + + WorkspaceButtonFadeSettings.initializeStoredModeIfNeeded(defaults: defaults) + + XCTAssertEqual( + defaults.string(forKey: WorkspaceButtonFadeSettings.modeKey), + WorkspaceButtonFadeSettings.Mode.enabled.rawValue + ) + } + + func testWorkspaceButtonFadeModePreservesExistingStoredMode() { + let defaults = UserDefaults.standard + let savedMode = defaults.object(forKey: WorkspaceButtonFadeSettings.modeKey) + let savedTitlebarVisibility = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + let savedLegacyTitlebarMode = defaults.object(forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) + let savedLegacyPaneMode = defaults.object(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) + defer { + restoreDefaultsValue(savedMode, forKey: WorkspaceButtonFadeSettings.modeKey, defaults: defaults) + restoreDefaultsValue(savedTitlebarVisibility, forKey: WorkspaceTitlebarSettings.showTitlebarKey, defaults: defaults) + restoreDefaultsValue(savedLegacyTitlebarMode, forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey, defaults: defaults) + restoreDefaultsValue(savedLegacyPaneMode, forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey, defaults: defaults) + } + + defaults.set(WorkspaceButtonFadeSettings.Mode.disabled.rawValue, forKey: WorkspaceButtonFadeSettings.modeKey) + defaults.set(false, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defaults.set("onHover", forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) + defaults.set("onHover", forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) + + WorkspaceButtonFadeSettings.initializeStoredModeIfNeeded(defaults: defaults) + + XCTAssertEqual( + defaults.string(forKey: WorkspaceButtonFadeSettings.modeKey), + WorkspaceButtonFadeSettings.Mode.disabled.rawValue + ) + } + func testCmdPhysicalPWithDvorakCharactersDoesNotTriggerCommandPaletteSwitcher() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -2767,6 +2871,14 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { window.performClose(nil) RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) } + + private func restoreDefaultsValue(_ value: Any?, forKey key: String, defaults: UserDefaults) { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } } private final class CommandPaletteMarkedTextFieldEditor: NSTextView { diff --git a/cmuxUITests/BonsplitTabDragUITests.swift b/cmuxUITests/BonsplitTabDragUITests.swift index 7379bc81..3074a56d 100644 --- a/cmuxUITests/BonsplitTabDragUITests.swift +++ b/cmuxUITests/BonsplitTabDragUITests.swift @@ -392,7 +392,7 @@ final class BonsplitTabDragUITests: XCTestCase { if startWithHiddenSidebar { app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] = "1" } - app.launchArguments += ["-workspaceTitlebarVisible", "NO"] + app.launchArguments += ["-workspaceTitlebarVisible", "NO", "-workspaceButtonsFadeMode", "enabled"] app.launch() app.activate() return (app, dataPath) From 70ec1a0915bbf8bcd7ed8bd5a120ad5d9ab2c68a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 21:31:41 -0700 Subject: [PATCH 07/28] Split fade buttons from titlebar visibility --- Resources/Localizable.xcstrings | 55 ++++++- Sources/Update/UpdateTitlebarAccessory.swift | 76 +++++----- Sources/cmuxApp.swift | 145 +++++++++++++++++-- vendor/bonsplit | 2 +- 4 files changed, 224 insertions(+), 54 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b10c4e69..af2b4691 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -43658,13 +43658,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Hide the workspace title bar and show sidebar or pane actions only on hover." + "value": "Hide the folder and active title above pane tabs." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ワークスペースのタイトルバーを隠し、サイドバーやペインタブの操作はホバー時のみ表示します。" + "value": "ペインタブの上にあるフォルダ名と現在のタイトルを隠します。" } } } @@ -43686,6 +43686,57 @@ } } }, + "settings.app.fadeButtons": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fade Buttons" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ボタンをフェード表示" + } + } + } + }, + "settings.app.fadeButtons.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep action buttons always visible." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作ボタンを常に表示します。" + } + } + } + }, + "settings.app.fadeButtons.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show action buttons only on hover." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作ボタンはホバー時のみ表示します。" + } + } + } + }, "settings.app.showPullRequests": { "extractionState": "manual", "localizations": { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 0b9c956c..191cf68e 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -257,10 +257,14 @@ struct TitlebarControlsView: View { let onToggleNotifications: () -> Void let onNewTab: () -> Void @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue + @AppStorage(WorkspaceButtonFadeSettings.modeKey) + private var workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @State private var shortcutRefreshTick = 0 + @State private var isHoveringControls = false + @State private var isNotificationsPopoverShown = false @StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor() private let titlebarHintRightSafetyShift: CGFloat = 10 private let titlebarHintBaseXShift: CGFloat = -10 @@ -295,6 +299,17 @@ struct TitlebarControlsView: View { alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed } + private var fadeButtonsEnabled: Bool { + WorkspaceButtonFadeSettings.mode(for: workspaceButtonsFadeMode) == .enabled + } + + private var shouldShowControls: Bool { + if !fadeButtonsEnabled { + return true + } + return isHoveringControls || isNotificationsPopoverShown || shouldShowTitlebarShortcutHints + } + var body: some View { // Force the `.safeHelp(...)` tooltips to re-evaluate when shortcuts are changed in settings. // (The titlebar controls don't otherwise re-render on UserDefaults changes.) @@ -304,15 +319,28 @@ struct TitlebarControlsView: View { controlsGroup(config: config) .padding(.leading, 4) .padding(.trailing, titlebarHintTrailingInset) + .contentShape(Rectangle()) + .opacity(shouldShowControls ? 1 : 0) + .allowsHitTesting(shouldShowControls) + .animation(.easeInOut(duration: 0.14), value: shouldShowControls) .background( WindowAccessor { window in modifierKeyMonitor.setHostWindow(window) } .frame(width: 0, height: 0) ) + .onHover { hovering in + isHoveringControls = hovering + } .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in shortcutRefreshTick &+= 1 } + .onAppear { + isNotificationsPopoverShown = AppDelegate.shared?.isNotificationsPopoverShown() ?? false + } + .onReceive(NotificationCenter.default.publisher(for: .cmuxNotificationsPopoverVisibilityDidChange)) { notification in + isNotificationsPopoverShown = (notification.userInfo?[NotificationsPopoverVisibilityUserInfoKey.isShown] as? Bool) ?? false + } .onAppear { modifierKeyMonitor.start() } @@ -527,48 +555,24 @@ struct TitlebarControlsView: View { struct HiddenTitlebarSidebarControlsView: View { @ObservedObject var notificationStore: TerminalNotificationStore @StateObject private var viewModel = TitlebarControlsViewModel() - @State private var isHoveringControls = false - @State private var isNotificationsPopoverShown = false private let hostWidth: CGFloat = 124 private let hostHeight: CGFloat = 28 - private var shouldShowControls: Bool { - isHoveringControls || isNotificationsPopoverShown - } - var body: some View { - ZStack(alignment: .leading) { - Color.clear - .frame(width: hostWidth, height: hostHeight) - - TitlebarControlsView( - notificationStore: notificationStore, - viewModel: viewModel, - onToggleSidebar: { _ = AppDelegate.shared?.sidebarState?.toggle() }, - onToggleNotifications: { [viewModel] in - AppDelegate.shared?.toggleNotificationsPopover( - animated: true, - anchorView: viewModel.notificationsAnchorView - ) - }, - onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() } - ) - .opacity(shouldShowControls ? 1 : 0) - .allowsHitTesting(shouldShowControls) - .animation(.easeInOut(duration: 0.12), value: shouldShowControls) - } + TitlebarControlsView( + notificationStore: notificationStore, + viewModel: viewModel, + onToggleSidebar: { _ = AppDelegate.shared?.sidebarState?.toggle() }, + onToggleNotifications: { [viewModel] in + AppDelegate.shared?.toggleNotificationsPopover( + animated: true, + anchorView: viewModel.notificationsAnchorView + ) + }, + onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() } + ) .frame(width: hostWidth, height: hostHeight, alignment: .leading) - .contentShape(Rectangle()) - .onHover { hovering in - isHoveringControls = hovering - } - .onAppear { - isNotificationsPopoverShown = AppDelegate.shared?.isNotificationsPopoverShown() ?? false - } - .onReceive(NotificationCenter.default.publisher(for: .cmuxNotificationsPopoverVisibilityDidChange)) { notification in - isNotificationsPopoverShown = (notification.userInfo?[NotificationsPopoverVisibilityUserInfoKey.isShown] as? Bool) ?? false - } } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ea6c3e35..9e9c5222 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -16,6 +16,54 @@ enum WorkspaceTitlebarSettings { } } +enum WorkspaceButtonFadeSettings { + static let modeKey = "workspaceButtonsFadeMode" + static let legacyTitlebarControlsVisibilityModeKey = "titlebarControlsVisibilityMode" + static let legacyPaneTabBarControlsVisibilityModeKey = "paneTabBarControlsVisibilityMode" + + enum Mode: String { + case enabled + case disabled + } + + static let defaultMode: Mode = .disabled + + static func mode(for rawValue: String?) -> Mode { + Mode(rawValue: rawValue ?? "") ?? defaultMode + } + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + mode(for: defaults.string(forKey: modeKey)) == .enabled + } + + static func initializeStoredModeIfNeeded(defaults: UserDefaults = .standard) { + guard defaults.string(forKey: modeKey) == nil else { return } + + if let migratedMode = migratedLegacyMode(defaults: defaults) { + defaults.set(migratedMode.rawValue, forKey: modeKey) + return + } + + let initialMode: Mode = WorkspaceTitlebarSettings.isVisible(defaults: defaults) ? .disabled : .enabled + defaults.set(initialMode.rawValue, forKey: modeKey) + } + + private static func migratedLegacyMode(defaults: UserDefaults) -> Mode? { + let legacyValues = [ + defaults.string(forKey: legacyTitlebarControlsVisibilityModeKey), + defaults.string(forKey: legacyPaneTabBarControlsVisibilityModeKey), + ] + + if legacyValues.contains(where: { $0 == "onHover" || $0 == "hover" || $0 == "enabled" }) { + return .enabled + } + if legacyValues.contains(where: { $0 == "always" || $0 == "disabled" }) { + return .disabled + } + return nil + } +} + @main struct cmuxApp: App { @StateObject private var tabManager: TabManager @@ -66,6 +114,7 @@ struct cmuxApp: App { _tabManager = StateObject(wrappedValue: TabManager()) // Migrate legacy and old-format socket mode values to the new enum. let defaults = UserDefaults.standard + WorkspaceButtonFadeSettings.initializeStoredModeIfNeeded(defaults: defaults) if let stored = defaults.string(forKey: SocketControlSettings.appStorageKey) { let migrated = SocketControlSettings.migrateMode(stored) if migrated.rawValue != stored { @@ -1994,6 +2043,7 @@ private struct AcknowledgmentsView: View { final class SettingsWindowController: NSWindowController, NSWindowDelegate { static let shared = SettingsWindowController() + private var pendingFocusRestoreWorkItems: [DispatchWorkItem] = [] private init() { let window = NSWindow( @@ -2036,6 +2086,37 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") #endif } + + func preserveFocusAfterPreferenceMutation() { + guard let window, window.isVisible, window.isKeyWindow else { return } + cancelPendingFocusRestore() + + let delays: [TimeInterval] = [0, 0.05, 0.12, 0.24, 0.4] + for delay in delays { + let workItem = DispatchWorkItem { [weak window] in + guard let window, window.isVisible else { return } + guard !window.isKeyWindow else { return } + NSApp.activate(ignoringOtherApps: true) + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + } + pendingFocusRestoreWorkItems.append(workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + } + + func windowWillClose(_ notification: Notification) { + cancelPendingFocusRestore() + } + + func windowDidBecomeKey(_ notification: Notification) { + cancelPendingFocusRestore() + } + + private func cancelPendingFocusRestore() { + pendingFocusRestoreWorkItems.forEach { $0.cancel() } + pendingFocusRestoreWorkItems.removeAll() + } } enum SettingsNavigationTarget: String { @@ -3099,6 +3180,8 @@ struct SettingsView: View { @AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar + @AppStorage(WorkspaceButtonFadeSettings.modeKey) + private var workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @@ -3190,7 +3273,24 @@ struct SettingsView: View { } return String( localized: "settings.app.showWorkspaceTitlebar.subtitleOff", - defaultValue: "Hide the workspace title bar and show sidebar or pane actions only on hover." + defaultValue: "Hide the folder and active title above pane tabs." + ) + } + + private var fadeButtonsEnabled: Bool { + WorkspaceButtonFadeSettings.mode(for: workspaceButtonsFadeMode) == .enabled + } + + private var workspaceButtonFadeSubtitle: String { + if fadeButtonsEnabled { + return String( + localized: "settings.app.fadeButtons.subtitleOn", + defaultValue: "Show action buttons only on hover." + ) + } + return String( + localized: "settings.app.fadeButtons.subtitleOff", + defaultValue: "Keep action buttons always visible." ) } @@ -3246,7 +3346,19 @@ struct SettingsView: View { get: { showWorkspaceTitlebar }, set: { newValue in showWorkspaceTitlebar = newValue - reassertSettingsWindowFocusIfNeeded() + SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() + } + ) + } + + private var fadeButtonsBinding: Binding { + Binding( + get: { fadeButtonsEnabled }, + set: { newValue in + workspaceButtonsFadeMode = newValue + ? WorkspaceButtonFadeSettings.Mode.enabled.rawValue + : WorkspaceButtonFadeSettings.Mode.disabled.rawValue + SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() } ) } @@ -3611,6 +3723,21 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.fadeButtons", defaultValue: "Fade Buttons"), + subtitle: workspaceButtonFadeSubtitle + ) { + Toggle("", isOn: fadeButtonsBinding) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsFadeButtonsToggle") + .accessibilityLabel( + String(localized: "settings.app.fadeButtons", defaultValue: "Fade Buttons") + ) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"), subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.") @@ -4636,19 +4763,6 @@ struct SettingsView: View { NSApplication.shared.terminate(nil) } - private func reassertSettingsWindowFocusIfNeeded() { - DispatchQueue.main.async { - guard let window = SettingsWindowController.shared.window, window.isVisible else { return } - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - DispatchQueue.main.async { - guard window.isVisible else { return } - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - } - } - } - private func resetAllSettings() { isResettingSettings = true appLanguage = LanguageSettings.defaultLanguage.rawValue @@ -4689,6 +4803,7 @@ struct SettingsView: View { alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar + workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage diff --git a/vendor/bonsplit b/vendor/bonsplit index 743de85d..1ae8ee43 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 743de85dfbdd138a7b07c2b91fc9e60c88fa2cf5 +Subproject commit 1ae8ee43d6813e6ce7ee67611afa1af42c0762af From 1f85da716103b3bb9eca9061620858009942dc2c Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 21:41:24 -0700 Subject: [PATCH 08/28] Add settings focus regression --- cmuxUITests/SidebarHelpMenuUITests.swift | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index 89e86916..16cb684a 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -477,9 +477,12 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { func testWorkspaceTitlebarToggleKeepsSettingsWindowFocused() throws { let app = XCUIApplication() + let diagnosticsPath = "/tmp/cmux-ui-test-settings-focus-\(UUID().uuidString).json" + try? FileManager.default.removeItem(atPath: diagnosticsPath) app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1" + app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath launchAndActivate(app) XCTAssertTrue( @@ -502,6 +505,24 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { "Expected the workspace titlebar setting to toggle" ) + let diagnostics = waitForDiagnostics( + at: diagnosticsPath, + timeout: 3.0 + ) { data in + data["keyWindowIdentifier"] == "cmux.settings" && data["settingsWindowIsKey"] == "1" + } + + XCTAssertEqual( + diagnostics?["keyWindowIdentifier"], + "cmux.settings", + "Expected the Settings window to remain key after toggling the workspace titlebar setting. diagnostics=\(diagnostics ?? [:])" + ) + XCTAssertEqual( + diagnostics?["settingsWindowIsKey"], + "1", + "Expected the Settings window to report itself as key after toggling the workspace titlebar setting. diagnostics=\(diagnostics ?? [:])" + ) + app.typeKey("w", modifierFlags: [.command]) XCTAssertTrue( @@ -702,6 +723,35 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { return ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendJSON(request) } + private func waitForDiagnostics( + at path: String, + timeout: TimeInterval, + condition: ([String: String]) -> Bool + ) -> [String: String]? { + let deadline = Date().addingTimeInterval(timeout) + var last: [String: String]? + + while Date() < deadline { + if let data = loadDiagnostics(at: path) { + last = data + if condition(data) { + return data + } + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + + return last + } + + private func loadDiagnostics(at path: String) -> [String: String]? { + guard let raw = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: raw) as? [String: String] else { + return nil + } + return object + } + private final class ControlSocketClient { private let path: String private let responseTimeout: TimeInterval From 43734c6f3dd9f14338e915b07d6ba8474585bb29 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 21:42:03 -0700 Subject: [PATCH 09/28] Keep Settings focused after titlebar toggle --- Sources/cmuxApp.swift | 91 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 9e9c5222..738faadb 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2044,6 +2044,7 @@ private struct AcknowledgmentsView: View { final class SettingsWindowController: NSWindowController, NSWindowDelegate { static let shared = SettingsWindowController() private var pendingFocusRestoreWorkItems: [DispatchWorkItem] = [] + private var focusRestoreGeneration = 0 private init() { let window = NSWindow( @@ -2088,34 +2089,90 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } func preserveFocusAfterPreferenceMutation() { - guard let window, window.isVisible, window.isKeyWindow else { return } + guard let window, window.isVisible else { return } cancelPendingFocusRestore() + focusRestoreGeneration += 1 + let generation = focusRestoreGeneration + writeFocusDiagnosticsIfNeeded(stage: "requested") + scheduleFocusRestore( + for: window, + generation: generation, + delays: [0, 0.04, 0.12, 0.24, 0.4, 0.7] + ) + } - let delays: [TimeInterval] = [0, 0.05, 0.12, 0.24, 0.4] - for delay in delays { - let workItem = DispatchWorkItem { [weak window] in - guard let window, window.isVisible else { return } - guard !window.isKeyWindow else { return } - NSApp.activate(ignoringOtherApps: true) - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) + func windowWillClose(_ notification: Notification) { + cancelPendingFocusRestore() + writeFocusDiagnosticsIfNeeded(stage: "windowWillClose") + } + + func windowDidBecomeKey(_ notification: Notification) { + writeFocusDiagnosticsIfNeeded(stage: "didBecomeKey") + } + + func windowDidResignKey(_ notification: Notification) { + guard let window else { return } + writeFocusDiagnosticsIfNeeded(stage: "didResignKey") + guard focusRestoreGeneration > 0 else { return } + scheduleFocusRestore( + for: window, + generation: focusRestoreGeneration, + delays: [0, 0.03, 0.1] + ) + } + + private func scheduleFocusRestore( + for window: NSWindow, + generation: Int, + delays: [TimeInterval] + ) { + for (index, delay) in delays.enumerated() { + let isLastAttempt = index == delays.count - 1 + let workItem = DispatchWorkItem { [weak self, weak window] in + guard let self, let window, window.isVisible else { return } + guard self.focusRestoreGeneration == generation else { return } + self.writeFocusDiagnosticsIfNeeded(stage: "restoreAttempt.\(index)") + if !window.isKeyWindow { + NSApp.activate(ignoringOtherApps: true) + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + self.writeFocusDiagnosticsIfNeeded(stage: "restoreApplied.\(index)") + } + if isLastAttempt, self.focusRestoreGeneration == generation { + self.focusRestoreGeneration = 0 + } } pendingFocusRestoreWorkItems.append(workItem) DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } } - func windowWillClose(_ notification: Notification) { - cancelPendingFocusRestore() - } - - func windowDidBecomeKey(_ notification: Notification) { - cancelPendingFocusRestore() - } - private func cancelPendingFocusRestore() { pendingFocusRestoreWorkItems.forEach { $0.cancel() } pendingFocusRestoreWorkItems.removeAll() + focusRestoreGeneration = 0 + } + + private func writeFocusDiagnosticsIfNeeded(stage: String) { + let env = ProcessInfo.processInfo.environment + guard let path = env["CMUX_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } + + var payload = loadFocusDiagnostics(at: path) + payload["focusStage"] = stage + payload["keyWindowIdentifier"] = NSApp.keyWindow?.identifier?.rawValue ?? "" + payload["mainWindowIdentifier"] = NSApp.mainWindow?.identifier?.rawValue ?? "" + payload["settingsWindowIsKey"] = (window?.isKeyWindow ?? false) ? "1" : "0" + + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + private func loadFocusDiagnostics(at path: String) -> [String: String] { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return object } } From 9b9c7d557e3d43db23e886d20304a840e60abd14 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 21:52:20 -0700 Subject: [PATCH 10/28] Strengthen settings focus regression --- cmuxUITests/SidebarHelpMenuUITests.swift | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index 16cb684a..551480a5 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -522,6 +522,15 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { "1", "Expected the Settings window to report itself as key after toggling the workspace titlebar setting. diagnostics=\(diagnostics ?? [:])" ) + XCTAssertTrue( + diagnosticsRemainStable( + at: diagnosticsPath, + duration: 0.8 + ) { data in + data["keyWindowIdentifier"] == "cmux.settings" && data["settingsWindowIsKey"] == "1" + }, + "Expected the Settings window to stay key after toggling the workspace titlebar setting. diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])" + ) app.typeKey("w", modifierFlags: [.command]) @@ -744,6 +753,21 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { return last } + private func diagnosticsRemainStable( + at path: String, + duration: TimeInterval, + condition: ([String: String]) -> Bool + ) -> Bool { + let deadline = Date().addingTimeInterval(duration) + while Date() < deadline { + guard let data = loadDiagnostics(at: path), condition(data) else { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + return true + } + private func loadDiagnostics(at path: String) -> [String: String]? { guard let raw = try? Data(contentsOf: URL(fileURLWithPath: path)), let object = try? JSONSerialization.jsonObject(with: raw) as? [String: String] else { From eaa0d871fa1f144abfe09127c3638229cc8d3eb5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 21:52:23 -0700 Subject: [PATCH 11/28] Stop terminal focus from stealing Settings --- Sources/GhosttyTerminalView.swift | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 013e387f..2383126a 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5869,9 +5869,22 @@ func shouldAllowEnsureFocusWindowActivation( activeTabManager: TabManager?, targetTabManager: TabManager, keyWindow: NSWindow?, - mainWindow: NSWindow? + mainWindow: NSWindow?, + targetWindow: NSWindow ) -> Bool { - activeTabManager === targetTabManager || (keyWindow == nil && mainWindow == nil) + guard activeTabManager === targetTabManager || (keyWindow == nil && mainWindow == nil) else { + return false + } + + if let keyWindow { + return keyWindow === targetWindow + } + + if let mainWindow { + return mainWindow === targetWindow + } + + return true } final class GhosttySurfaceScrollView: NSView { @@ -7289,7 +7302,8 @@ final class GhosttySurfaceScrollView: NSView { activeTabManager: delegate.tabManager, targetTabManager: tabManager, keyWindow: NSApp.keyWindow, - mainWindow: NSApp.mainWindow + mainWindow: NSApp.mainWindow, + targetWindow: window ) else { return } From b1d4db1bdb7e88e9d1234ec92f7c578465138e8a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 22:09:34 -0700 Subject: [PATCH 12/28] Add shortcut change notification regression --- .../AppDelegateShortcutRoutingTests.swift | 15 +++++++++++++ ...sttyEnsureFocusWindowActivationTests.swift | 22 +++++++++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 250a3247..61b83a2a 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -855,6 +855,21 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } + func testKeyboardShortcutSettingsSetShortcutPostsSpecificChangeNotification() { + let notificationName = Notification.Name("cmux.keyboardShortcutSettingsDidChange") + let expectedAction = KeyboardShortcutSettings.Action.toggleSidebar.rawValue + let expectation = expectation(forNotification: notificationName, object: nil) { notification in + notification.userInfo?["action"] as? String == expectedAction + } + + KeyboardShortcutSettings.setShortcut( + StoredShortcut(key: "s", command: true, shift: false, option: false, control: true), + for: .toggleSidebar + ) + + wait(for: [expectation], timeout: 0.2) + } + func testCmdPhysicalPWithDvorakCharactersDoesNotTriggerCommandPaletteSwitcher() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") diff --git a/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift index e2718c9a..308c5ce2 100644 --- a/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift +++ b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift @@ -12,34 +12,40 @@ final class GhosttyEnsureFocusWindowActivationTests: XCTestCase { func testAllowsActivationForActiveManager() { let activeManager = TabManager() let otherManager = TabManager() + let targetWindow = NSWindow() + let otherWindow = NSWindow() XCTAssertTrue( shouldAllowEnsureFocusWindowActivation( activeTabManager: activeManager, targetTabManager: activeManager, - keyWindow: NSWindow(), - mainWindow: NSWindow() + keyWindow: targetWindow, + mainWindow: targetWindow, + targetWindow: targetWindow ) ) XCTAssertFalse( shouldAllowEnsureFocusWindowActivation( activeTabManager: activeManager, targetTabManager: otherManager, - keyWindow: NSWindow(), - mainWindow: NSWindow() + keyWindow: otherWindow, + mainWindow: otherWindow, + targetWindow: targetWindow ) ) } func testAllowsActivationWhenAppHasNoKeyAndNoMainWindow() { let targetManager = TabManager() + let targetWindow = NSWindow() XCTAssertTrue( shouldAllowEnsureFocusWindowActivation( activeTabManager: nil, targetTabManager: targetManager, keyWindow: nil, - mainWindow: nil + mainWindow: nil, + targetWindow: targetWindow ) ) XCTAssertFalse( @@ -47,7 +53,8 @@ final class GhosttyEnsureFocusWindowActivationTests: XCTestCase { activeTabManager: nil, targetTabManager: targetManager, keyWindow: NSWindow(), - mainWindow: nil + mainWindow: nil, + targetWindow: targetWindow ) ) XCTAssertFalse( @@ -55,7 +62,8 @@ final class GhosttyEnsureFocusWindowActivationTests: XCTestCase { activeTabManager: nil, targetTabManager: targetManager, keyWindow: nil, - mainWindow: NSWindow() + mainWindow: NSWindow(), + targetWindow: targetWindow ) ) } From 5da7da127a620b28c5883c07b420f9571bc32af9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 22:09:37 -0700 Subject: [PATCH 13/28] Narrow shortcut settings notifications --- Sources/AppDelegate.swift | 2 +- Sources/KeyboardShortcutSettings.swift | 23 +++++++++++++++++++- Sources/Update/UpdateTitlebarAccessory.swift | 2 +- Sources/cmuxApp.swift | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index aaf0fe05..884b76e3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7810,7 +7810,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func installShortcutDefaultsObserver() { guard shortcutDefaultsObserver == nil else { return } shortcutDefaultsObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, + forName: KeyboardShortcutSettings.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f06c255b..26b41ea3 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -3,6 +3,9 @@ import SwiftUI /// Stores customizable keyboard shortcuts (definitions + persistence). enum KeyboardShortcutSettings { + static let didChangeNotification = Notification.Name("cmux.keyboardShortcutSettingsDidChange") + static let actionUserInfoKey = "action" + enum Action: String, CaseIterable, Identifiable { // Titlebar / primary UI case toggleSidebar @@ -198,16 +201,34 @@ enum KeyboardShortcutSettings { if let data = try? JSONEncoder().encode(shortcut) { UserDefaults.standard.set(data, forKey: action.defaultsKey) } + postDidChangeNotification(action: action) } static func resetShortcut(for action: Action) { UserDefaults.standard.removeObject(forKey: action.defaultsKey) + postDidChangeNotification(action: action) } static func resetAll() { for action in Action.allCases { - resetShortcut(for: action) + UserDefaults.standard.removeObject(forKey: action.defaultsKey) } + postDidChangeNotification() + } + + private static func postDidChangeNotification( + action: Action? = nil, + center: NotificationCenter = .default + ) { + var userInfo: [AnyHashable: Any] = [:] + if let action { + userInfo[actionUserInfoKey] = action.rawValue + } + center.post( + name: didChangeNotification, + object: nil, + userInfo: userInfo.isEmpty ? nil : userInfo + ) } // MARK: - Backwards-Compatible API (call-sites can migrate gradually) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 191cf68e..b08a8997 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -332,7 +332,7 @@ struct TitlebarControlsView: View { .onHover { hovering in isHoveringControls = hovering } - .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + .onReceive(NotificationCenter.default.publisher(for: KeyboardShortcutSettings.didChangeNotification)) { _ in shortcutRefreshTick &+= 1 } .onAppear { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 738faadb..db9e6a73 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5419,7 +5419,7 @@ private struct ShortcutSettingRow: View { .onChange(of: shortcut) { newValue in KeyboardShortcutSettings.setShortcut(newValue, for: action) } - .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + .onReceive(NotificationCenter.default.publisher(for: KeyboardShortcutSettings.didChangeNotification)) { _ in let latest = KeyboardShortcutSettings.shortcut(for: action) if latest != shortcut { shortcut = latest From 3528ffe414627de12c85c1b5a74548df0d747e50 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 22:42:28 -0700 Subject: [PATCH 14/28] Test always-visible minimal-mode sidebar controls --- cmuxUITests/BonsplitTabDragUITests.swift | 58 ++++++++++++------------ 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/cmuxUITests/BonsplitTabDragUITests.swift b/cmuxUITests/BonsplitTabDragUITests.swift index 3074a56d..e005d039 100644 --- a/cmuxUITests/BonsplitTabDragUITests.swift +++ b/cmuxUITests/BonsplitTabDragUITests.swift @@ -156,7 +156,7 @@ final class BonsplitTabDragUITests: XCTestCase { ) } - func testHiddenWorkspaceTitlebarSidebarControlsRevealOnlyFromSidebarHover() { + func testHiddenWorkspaceTitlebarSidebarControlsStayVisibleWhileSidebarIsVisible() { let (app, dataPath) = launchConfiguredApp() XCTAssertTrue( @@ -195,36 +195,33 @@ final class BonsplitTabDragUITests: XCTestCase { "Expected visible-sidebar hidden-titlebar mode to keep pane tabs tight to the sidebar edge while the traffic lights sit over the sidebar. window=\(window.frame) sidebar=\(sidebar.frame) alphaTab=\(alphaTab.frame) paneLeadingGap=\(paneLeadingGap)" ) - window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() - XCTAssertTrue( - waitForCondition(timeout: 2.0) { - !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable - }, - "Expected hidden-titlebar sidebar controls to stay hidden away from the sidebar hover zone." - ) - - hover(in: window, at: CGPoint(x: window.frame.maxX - 48, y: window.frame.minY + 18)) - XCTAssertTrue( - waitForCondition(timeout: 2.0) { - !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable - }, - "Expected the removed titlebar area to stop revealing hidden-titlebar controls." - ) - - hover( - in: window, - at: CGPoint( - x: min(sidebar.frame.maxX - 36, sidebar.frame.minX + 116), - y: window.frame.minY + 18 - ) - ) XCTAssertTrue( waitForCondition(timeout: 2.0) { toggleSidebarButton.exists && toggleSidebarButton.isHittable && notificationsButton.exists && notificationsButton.isHittable && newWorkspaceButton.exists && newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to reveal when hovering the sidebar chrome area." + "Expected hidden-titlebar sidebar controls to stay visible whenever the sidebar is visible." + ) + + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + toggleSidebarButton.exists && toggleSidebarButton.isHittable && + notificationsButton.exists && notificationsButton.isHittable && + newWorkspaceButton.exists && newWorkspaceButton.isHittable + }, + "Expected hidden-titlebar sidebar controls to remain visible even when hovering away from the sidebar header." + ) + + hover(in: window, at: CGPoint(x: window.frame.maxX - 48, y: window.frame.minY + 18)) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + toggleSidebarButton.exists && toggleSidebarButton.isHittable && + notificationsButton.exists && notificationsButton.isHittable && + newWorkspaceButton.exists && newWorkspaceButton.isHittable + }, + "Expected hidden-titlebar sidebar controls to remain visible without any special hover zone." ) } @@ -277,7 +274,7 @@ final class BonsplitTabDragUITests: XCTestCase { ) } - func testHiddenWorkspaceTitlebarKeepsSidebarControlsVisibleWhileNotificationsPopoverIsShown() { + func testHiddenWorkspaceTitlebarSidebarControlsRemainVisibleWhileNotificationsPopoverIsShown() { let (app, dataPath) = launchConfiguredApp() XCTAssertTrue( @@ -302,12 +299,13 @@ final class BonsplitTabDragUITests: XCTestCase { let notificationsButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.showNotifications").firstMatch let newWorkspaceButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.newTab").firstMatch - window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() XCTAssertTrue( waitForCondition(timeout: 2.0) { - !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable + toggleSidebarButton.exists && toggleSidebarButton.isHittable && + notificationsButton.exists && notificationsButton.isHittable && + newWorkspaceButton.exists && newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to start hidden away from hover." + "Expected hidden-titlebar sidebar controls to stay visible while the sidebar is visible." ) app.typeKey("i", modifierFlags: [.command]) @@ -324,7 +322,7 @@ final class BonsplitTabDragUITests: XCTestCase { notificationsButton.exists && notificationsButton.isHittable && newWorkspaceButton.exists && newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to stay visible while the notifications popover is open." + "Expected hidden-titlebar sidebar controls to remain visible while the notifications popover is open." ) } From 2611a32ef79764a4835dfc58ddff1f3d9a1453e1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 22:44:47 -0700 Subject: [PATCH 15/28] Keep minimal-mode sidebar buttons always visible --- Sources/Update/UpdateTitlebarAccessory.swift | 28 +++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index b08a8997..686e8b04 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -251,11 +251,17 @@ struct TitlebarControlButton: View { } struct TitlebarControlsView: View { + enum VisibilityBehavior { + case followsFadeSetting + case alwaysVisible + } + @ObservedObject var notificationStore: TerminalNotificationStore @ObservedObject var viewModel: TitlebarControlsViewModel let onToggleSidebar: () -> Void let onToggleNotifications: () -> Void let onNewTab: () -> Void + let visibilityBehavior: VisibilityBehavior @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue @AppStorage(WorkspaceButtonFadeSettings.modeKey) private var workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue @@ -269,6 +275,22 @@ struct TitlebarControlsView: View { private let titlebarHintRightSafetyShift: CGFloat = 10 private let titlebarHintBaseXShift: CGFloat = -10 + init( + notificationStore: TerminalNotificationStore, + viewModel: TitlebarControlsViewModel, + onToggleSidebar: @escaping () -> Void, + onToggleNotifications: @escaping () -> Void, + onNewTab: @escaping () -> Void, + visibilityBehavior: VisibilityBehavior = .followsFadeSetting + ) { + _notificationStore = ObservedObject(wrappedValue: notificationStore) + _viewModel = ObservedObject(wrappedValue: viewModel) + self.onToggleSidebar = onToggleSidebar + self.onToggleNotifications = onToggleNotifications + self.onNewTab = onNewTab + self.visibilityBehavior = visibilityBehavior + } + private enum HintSlot: Int, CaseIterable { case toggleSidebar case showNotifications @@ -304,6 +326,9 @@ struct TitlebarControlsView: View { } private var shouldShowControls: Bool { + if visibilityBehavior == .alwaysVisible { + return true + } if !fadeButtonsEnabled { return true } @@ -570,7 +595,8 @@ struct HiddenTitlebarSidebarControlsView: View { anchorView: viewModel.notificationsAnchorView ) }, - onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() } + onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() }, + visibilityBehavior: .alwaysVisible ) .frame(width: hostWidth, height: hostHeight, alignment: .leading) } From 583045c9ef531ea65dcbf3e3e55381562864cdac Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 22:49:41 -0700 Subject: [PATCH 16/28] Test Fade Buttons for minimal-mode sidebar controls --- cmuxUITests/BonsplitTabDragUITests.swift | 94 +++++++++++++++++++++--- 1 file changed, 84 insertions(+), 10 deletions(-) diff --git a/cmuxUITests/BonsplitTabDragUITests.swift b/cmuxUITests/BonsplitTabDragUITests.swift index e005d039..e0c6c497 100644 --- a/cmuxUITests/BonsplitTabDragUITests.swift +++ b/cmuxUITests/BonsplitTabDragUITests.swift @@ -156,7 +156,7 @@ final class BonsplitTabDragUITests: XCTestCase { ) } - func testHiddenWorkspaceTitlebarSidebarControlsStayVisibleWhileSidebarIsVisible() { + func testHiddenWorkspaceTitlebarSidebarControlsRevealOnlyFromSidebarHoverWhenFadeButtonsEnabled() { let (app, dataPath) = launchConfiguredApp() XCTAssertTrue( @@ -195,13 +195,85 @@ final class BonsplitTabDragUITests: XCTestCase { "Expected visible-sidebar hidden-titlebar mode to keep pane tabs tight to the sidebar edge while the traffic lights sit over the sidebar. window=\(window.frame) sidebar=\(sidebar.frame) alphaTab=\(alphaTab.frame) paneLeadingGap=\(paneLeadingGap)" ) + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable + }, + "Expected hidden-titlebar sidebar controls to stay hidden away from the sidebar hover zone when Fade Buttons is enabled." + ) + + hover(in: window, at: CGPoint(x: window.frame.maxX - 48, y: window.frame.minY + 18)) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable + }, + "Expected the removed titlebar area to stop revealing hidden-titlebar controls when Fade Buttons is enabled." + ) + + hover( + in: window, + at: CGPoint( + x: min(sidebar.frame.maxX - 36, sidebar.frame.minX + 116), + y: window.frame.minY + 18 + ) + ) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + toggleSidebarButton.exists && toggleSidebarButton.isHittable && + notificationsButton.exists && notificationsButton.isHittable && + newWorkspaceButton.exists && newWorkspaceButton.isHittable + }, + "Expected hidden-titlebar sidebar controls to reveal when hovering the sidebar chrome area with Fade Buttons enabled." + ) + } + + func testHiddenWorkspaceTitlebarSidebarControlsStayVisibleWhenFadeButtonsDisabled() { + let (app, dataPath) = launchConfiguredApp(fadeButtonsMode: "disabled") + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for hidden titlebar sidebar visibility UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let sidebar = app.descendants(matching: .any).matching(identifier: "Sidebar").firstMatch + XCTAssertTrue(sidebar.waitForExistence(timeout: 5.0), "Expected sidebar to exist") + + let toggleSidebarButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.toggleSidebar").firstMatch + let notificationsButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.showNotifications").firstMatch + let newWorkspaceButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.newTab").firstMatch + + let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" + let alphaTab = app.buttons[alphaTitle] + XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") + + let paneLeadingGap = alphaTab.frame.minX - sidebar.frame.maxX + XCTAssertLessThan( + paneLeadingGap, + 28, + "Expected visible-sidebar hidden-titlebar mode to keep pane tabs tight to the sidebar edge while the traffic lights sit over the sidebar. window=\(window.frame) sidebar=\(sidebar.frame) alphaTab=\(alphaTab.frame) paneLeadingGap=\(paneLeadingGap)" + ) + XCTAssertTrue( waitForCondition(timeout: 2.0) { toggleSidebarButton.exists && toggleSidebarButton.isHittable && notificationsButton.exists && notificationsButton.isHittable && newWorkspaceButton.exists && newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to stay visible whenever the sidebar is visible." + "Expected hidden-titlebar sidebar controls to stay visible whenever the sidebar is visible and Fade Buttons is disabled." ) window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() @@ -211,7 +283,7 @@ final class BonsplitTabDragUITests: XCTestCase { notificationsButton.exists && notificationsButton.isHittable && newWorkspaceButton.exists && newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to remain visible even when hovering away from the sidebar header." + "Expected hidden-titlebar sidebar controls to remain visible away from the sidebar header when Fade Buttons is disabled." ) hover(in: window, at: CGPoint(x: window.frame.maxX - 48, y: window.frame.minY + 18)) @@ -221,7 +293,7 @@ final class BonsplitTabDragUITests: XCTestCase { notificationsButton.exists && notificationsButton.isHittable && newWorkspaceButton.exists && newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to remain visible without any special hover zone." + "Expected hidden-titlebar sidebar controls to remain visible without any special hover zone when Fade Buttons is disabled." ) } @@ -299,13 +371,12 @@ final class BonsplitTabDragUITests: XCTestCase { let notificationsButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.showNotifications").firstMatch let newWorkspaceButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.newTab").firstMatch + window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() XCTAssertTrue( waitForCondition(timeout: 2.0) { - toggleSidebarButton.exists && toggleSidebarButton.isHittable && - notificationsButton.exists && notificationsButton.isHittable && - newWorkspaceButton.exists && newWorkspaceButton.isHittable + !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to stay visible while the sidebar is visible." + "Expected hidden-titlebar sidebar controls to start hidden away from hover when Fade Buttons is enabled." ) app.typeKey("i", modifierFlags: [.command]) @@ -380,7 +451,10 @@ final class BonsplitTabDragUITests: XCTestCase { ) } - private func launchConfiguredApp(startWithHiddenSidebar: Bool = false) -> (XCUIApplication, String) { + private func launchConfiguredApp( + startWithHiddenSidebar: Bool = false, + fadeButtonsMode: String = "enabled" + ) -> (XCUIApplication, String) { let app = XCUIApplication() let dataPath = "/tmp/cmux-ui-test-bonsplit-tab-drag-\(UUID().uuidString).json" try? FileManager.default.removeItem(atPath: dataPath) @@ -390,7 +464,7 @@ final class BonsplitTabDragUITests: XCTestCase { if startWithHiddenSidebar { app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] = "1" } - app.launchArguments += ["-workspaceTitlebarVisible", "NO", "-workspaceButtonsFadeMode", "enabled"] + app.launchArguments += ["-workspaceTitlebarVisible", "NO", "-workspaceButtonsFadeMode", fadeButtonsMode] app.launch() app.activate() return (app, dataPath) From f633ddbfe214442e99b4c444998bda5248ea599e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 15 Mar 2026 22:50:59 -0700 Subject: [PATCH 17/28] Apply Fade Buttons to minimal-mode sidebar controls --- Sources/Update/UpdateTitlebarAccessory.swift | 28 +------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 686e8b04..b08a8997 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -251,17 +251,11 @@ struct TitlebarControlButton: View { } struct TitlebarControlsView: View { - enum VisibilityBehavior { - case followsFadeSetting - case alwaysVisible - } - @ObservedObject var notificationStore: TerminalNotificationStore @ObservedObject var viewModel: TitlebarControlsViewModel let onToggleSidebar: () -> Void let onToggleNotifications: () -> Void let onNewTab: () -> Void - let visibilityBehavior: VisibilityBehavior @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue @AppStorage(WorkspaceButtonFadeSettings.modeKey) private var workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue @@ -275,22 +269,6 @@ struct TitlebarControlsView: View { private let titlebarHintRightSafetyShift: CGFloat = 10 private let titlebarHintBaseXShift: CGFloat = -10 - init( - notificationStore: TerminalNotificationStore, - viewModel: TitlebarControlsViewModel, - onToggleSidebar: @escaping () -> Void, - onToggleNotifications: @escaping () -> Void, - onNewTab: @escaping () -> Void, - visibilityBehavior: VisibilityBehavior = .followsFadeSetting - ) { - _notificationStore = ObservedObject(wrappedValue: notificationStore) - _viewModel = ObservedObject(wrappedValue: viewModel) - self.onToggleSidebar = onToggleSidebar - self.onToggleNotifications = onToggleNotifications - self.onNewTab = onNewTab - self.visibilityBehavior = visibilityBehavior - } - private enum HintSlot: Int, CaseIterable { case toggleSidebar case showNotifications @@ -326,9 +304,6 @@ struct TitlebarControlsView: View { } private var shouldShowControls: Bool { - if visibilityBehavior == .alwaysVisible { - return true - } if !fadeButtonsEnabled { return true } @@ -595,8 +570,7 @@ struct HiddenTitlebarSidebarControlsView: View { anchorView: viewModel.notificationsAnchorView ) }, - onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() }, - visibilityBehavior: .alwaysVisible + onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() } ) .frame(width: hostWidth, height: hostHeight, alignment: .leading) } From e8d3f556bdbaff8c048e2726e3a4d015a7fb7a52 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:06:00 -0700 Subject: [PATCH 18/28] Test minimal mode settings defaults --- .../AppDelegateShortcutRoutingTests.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 61b83a2a..d0f7ba23 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -855,6 +855,27 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) } + func testWorkspaceMinimalModeDefaultsToStandardPresentation() { + let defaults = UserDefaults.standard + let savedMode = defaults.object(forKey: WorkspacePresentationModeSettings.modeKey) + let savedLegacyTitlebar = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + let savedLegacyFade = defaults.object(forKey: WorkspaceButtonFadeSettings.modeKey) + defer { + restoreDefaultsValue(savedMode, forKey: WorkspacePresentationModeSettings.modeKey, defaults: defaults) + restoreDefaultsValue(savedLegacyTitlebar, forKey: WorkspaceTitlebarSettings.showTitlebarKey, defaults: defaults) + restoreDefaultsValue(savedLegacyFade, forKey: WorkspaceButtonFadeSettings.modeKey, defaults: defaults) + } + + defaults.removeObject(forKey: WorkspacePresentationModeSettings.modeKey) + defaults.set(false, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defaults.set(WorkspaceButtonFadeSettings.Mode.enabled.rawValue, forKey: WorkspaceButtonFadeSettings.modeKey) + + XCTAssertEqual( + WorkspacePresentationModeSettings.mode(defaults: defaults), + .standard + ) + } + func testKeyboardShortcutSettingsSetShortcutPostsSpecificChangeNotification() { let notificationName = Notification.Name("cmux.keyboardShortcutSettingsDidChange") let expectedAction = KeyboardShortcutSettings.Action.toggleSidebar.rawValue From 21824f86fd488dac989fceb4d9d97e83a68ce39f Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:06:05 -0700 Subject: [PATCH 19/28] Replace titlebar and fade toggles with minimal mode --- Resources/Localizable.xcstrings | 51 ++++++++++++++ Sources/cmuxApp.swift | 116 ++++++++++++++------------------ 2 files changed, 102 insertions(+), 65 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index af2b4691..69d84ef0 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -43737,6 +43737,57 @@ } } }, + "settings.app.minimalMode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Minimal Mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ミニマルモード" + } + } + } + }, + "settings.app.minimalMode.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Use the standard workspace title bar and controls." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "標準のワークスペースタイトルバーと操作を使います。" + } + } + } + }, + "settings.app.minimalMode.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide the workspace title bar and move workspace controls into the sidebar." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースのタイトルバーを隠し、ワークスペース操作をサイドバーに移動します。" + } + } + } + }, "settings.app.showPullRequests": { "extractionState": "manual", "localizations": { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index db9e6a73..ca7ff40c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -16,6 +16,29 @@ enum WorkspaceTitlebarSettings { } } +enum WorkspacePresentationModeSettings { + static let modeKey = "workspacePresentationMode" + + enum Mode: String { + case standard + case minimal + } + + static let defaultMode: Mode = .standard + + static func mode(for rawValue: String?) -> Mode { + Mode(rawValue: rawValue ?? "") ?? defaultMode + } + + static func mode(defaults: UserDefaults = .standard) -> Mode { + mode(for: defaults.string(forKey: modeKey)) + } + + static func isMinimal(defaults: UserDefaults = .standard) -> Bool { + mode(defaults: defaults) == .minimal + } +} + enum WorkspaceButtonFadeSettings { static let modeKey = "workspaceButtonsFadeMode" static let legacyTitlebarControlsVisibilityModeKey = "titlebarControlsVisibilityMode" @@ -114,7 +137,6 @@ struct cmuxApp: App { _tabManager = StateObject(wrappedValue: TabManager()) // Migrate legacy and old-format socket mode values to the new enum. let defaults = UserDefaults.standard - WorkspaceButtonFadeSettings.initializeStoredModeIfNeeded(defaults: defaults) if let stored = defaults.string(forKey: SocketControlSettings.appStorageKey) { let migrated = SocketControlSettings.migrateMode(stored) if migrated.rawValue != stored { @@ -3235,10 +3257,8 @@ struct SettingsView: View { @AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage(AppIconSettings.modeKey) private var appIconMode = AppIconSettings.defaultMode.rawValue - @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) - private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar - @AppStorage(WorkspaceButtonFadeSettings.modeKey) - private var workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue + @AppStorage(WorkspacePresentationModeSettings.modeKey) + private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(ClaudeCodeIntegrationSettings.hooksEnabledKey) private var claudeCodeHooksEnabled = ClaudeCodeIntegrationSettings.defaultHooksEnabled @@ -3321,33 +3341,20 @@ struct SettingsView: View { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement } - private var workspaceTitlebarSubtitle: String { - if showWorkspaceTitlebar { + private var minimalModeEnabled: Bool { + WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal + } + + private var minimalModeSubtitle: String { + if minimalModeEnabled { return String( - localized: "settings.app.showWorkspaceTitlebar.subtitleOn", - defaultValue: "Show the folder and active title above pane tabs." + localized: "settings.app.minimalMode.subtitleOn", + defaultValue: "Hide the workspace title bar and move workspace controls into the sidebar." ) } return String( - localized: "settings.app.showWorkspaceTitlebar.subtitleOff", - defaultValue: "Hide the folder and active title above pane tabs." - ) - } - - private var fadeButtonsEnabled: Bool { - WorkspaceButtonFadeSettings.mode(for: workspaceButtonsFadeMode) == .enabled - } - - private var workspaceButtonFadeSubtitle: String { - if fadeButtonsEnabled { - return String( - localized: "settings.app.fadeButtons.subtitleOn", - defaultValue: "Show action buttons only on hover." - ) - } - return String( - localized: "settings.app.fadeButtons.subtitleOff", - defaultValue: "Keep action buttons always visible." + localized: "settings.app.minimalMode.subtitleOff", + defaultValue: "Use the standard workspace title bar and controls." ) } @@ -3398,23 +3405,13 @@ struct SettingsView: View { ) } - private var showWorkspaceTitlebarBinding: Binding { + private var minimalModeBinding: Binding { Binding( - get: { showWorkspaceTitlebar }, + get: { minimalModeEnabled }, set: { newValue in - showWorkspaceTitlebar = newValue - SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() - } - ) - } - - private var fadeButtonsBinding: Binding { - Binding( - get: { fadeButtonsEnabled }, - set: { newValue in - workspaceButtonsFadeMode = newValue - ? WorkspaceButtonFadeSettings.Mode.enabled.rawValue - : WorkspaceButtonFadeSettings.Mode.disabled.rawValue + workspacePresentationMode = newValue + ? WorkspacePresentationModeSettings.Mode.minimal.rawValue + : WorkspacePresentationModeSettings.Mode.standard.rawValue SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() } ) @@ -3766,30 +3763,15 @@ struct SettingsView: View { SettingsCardDivider() SettingsCardRow( - String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar"), - subtitle: workspaceTitlebarSubtitle + String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"), + subtitle: minimalModeSubtitle ) { - Toggle("", isOn: showWorkspaceTitlebarBinding) + Toggle("", isOn: minimalModeBinding) .labelsHidden() .controlSize(.small) - .accessibilityIdentifier("SettingsShowWorkspaceTitlebarToggle") + .accessibilityIdentifier("SettingsMinimalModeToggle") .accessibilityLabel( - String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar") - ) - } - - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.app.fadeButtons", defaultValue: "Fade Buttons"), - subtitle: workspaceButtonFadeSubtitle - ) { - Toggle("", isOn: fadeButtonsBinding) - .labelsHidden() - .controlSize(.small) - .accessibilityIdentifier("SettingsFadeButtonsToggle") - .accessibilityLabel( - String(localized: "settings.app.fadeButtons", defaultValue: "Fade Buttons") + String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode") ) } @@ -4859,8 +4841,12 @@ struct SettingsView: View { ShortcutHintDebugSettings.resetVisibilityDefaults() alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue - showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar - workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue + workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue + let defaults = UserDefaults.standard + defaults.removeObject(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defaults.removeObject(forKey: WorkspaceButtonFadeSettings.modeKey) + defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyTitlebarControlsVisibilityModeKey) + defaults.removeObject(forKey: WorkspaceButtonFadeSettings.legacyPaneTabBarControlsVisibilityModeKey) workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage From 03cfb7a88541ab2a24eb522fe7ad88bcf4c1e860 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:07:59 -0700 Subject: [PATCH 20/28] Retarget settings focus regression to minimal mode --- cmuxUITests/SidebarHelpMenuUITests.swift | 34 ++++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index 551480a5..d4ed8991 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -475,7 +475,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) } - func testWorkspaceTitlebarToggleKeepsSettingsWindowFocused() throws { + func testMinimalModeToggleKeepsSettingsWindowFocused() throws { let app = XCUIApplication() let diagnosticsPath = "/tmp/cmux-ui-test-settings-focus-\(UUID().uuidString).json" try? FileManager.default.removeItem(atPath: diagnosticsPath) @@ -493,7 +493,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) focusSettingsWindow(app: app) - let toggle = try requireShowWorkspaceTitlebarToggle(app: app) + let toggle = try requireMinimalModeToggle(app: app) let initialState = toggleIsOn(toggle) toggle.click() @@ -502,7 +502,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { sidebarHelpPollUntil(timeout: 3.0) { toggle.exists && toggleIsOn(toggle) != initialState }, - "Expected the workspace titlebar setting to toggle" + "Expected the minimal mode setting to toggle" ) let diagnostics = waitForDiagnostics( @@ -515,12 +515,12 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { XCTAssertEqual( diagnostics?["keyWindowIdentifier"], "cmux.settings", - "Expected the Settings window to remain key after toggling the workspace titlebar setting. diagnostics=\(diagnostics ?? [:])" + "Expected the Settings window to remain key after toggling minimal mode. diagnostics=\(diagnostics ?? [:])" ) XCTAssertEqual( diagnostics?["settingsWindowIsKey"], "1", - "Expected the Settings window to report itself as key after toggling the workspace titlebar setting. diagnostics=\(diagnostics ?? [:])" + "Expected the Settings window to report itself as key after toggling minimal mode. diagnostics=\(diagnostics ?? [:])" ) XCTAssertTrue( diagnosticsRemainStable( @@ -529,7 +529,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) { data in data["keyWindowIdentifier"] == "cmux.settings" && data["settingsWindowIsKey"] == "1" }, - "Expected the Settings window to stay key after toggling the workspace titlebar setting. diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])" + "Expected the Settings window to stay key after toggling minimal mode. diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])" ) app.typeKey("w", modifierFlags: [.command]) @@ -538,7 +538,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { sidebarHelpPollUntil(timeout: 3.0) { app.windows.count == 1 && !toggle.exists }, - "Expected Cmd+W after toggling the workspace titlebar setting to close the focused Settings window instead of defocusing back to the workspace window" + "Expected Cmd+W after toggling minimal mode to close the focused Settings window instead of defocusing back to the workspace window" ) } @@ -606,17 +606,17 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { throw XCTSkip("Could not find the command palette all-surfaces toggle") } - private func requireShowWorkspaceTitlebarToggle(app: XCUIApplication) throws -> XCUIElement { + private func requireMinimalModeToggle(app: XCUIApplication) throws -> XCUIElement { let scrollView = app.scrollViews.firstMatch let candidates = [ - app.switches["SettingsShowWorkspaceTitlebarToggle"], - app.checkBoxes["SettingsShowWorkspaceTitlebarToggle"], - app.buttons["SettingsShowWorkspaceTitlebarToggle"], - app.otherElements["SettingsShowWorkspaceTitlebarToggle"], - app.switches["Show Workspace Title Bar"], - app.checkBoxes["Show Workspace Title Bar"], - app.buttons["Show Workspace Title Bar"], - app.otherElements["Show Workspace Title Bar"], + app.switches["SettingsMinimalModeToggle"], + app.checkBoxes["SettingsMinimalModeToggle"], + app.buttons["SettingsMinimalModeToggle"], + app.otherElements["SettingsMinimalModeToggle"], + app.switches["Minimal Mode"], + app.checkBoxes["Minimal Mode"], + app.buttons["Minimal Mode"], + app.otherElements["Minimal Mode"], ] for _ in 0..<8 { @@ -628,7 +628,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { } } - throw XCTSkip("Could not find the workspace titlebar toggle") + throw XCTSkip("Could not find the minimal mode toggle") } private func toggleIsOn(_ element: XCUIElement) -> Bool { From de580213c34b98c0b58f1d04aaa9a7819d584dff Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:15:19 -0700 Subject: [PATCH 21/28] Test minimal mode runtime window presentation --- .../AppDelegateShortcutRoutingTests.swift | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index d0f7ba23..8ad7005e 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -671,21 +671,20 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } } - func testHiddenWorkspaceTitlebarUsesZeroTopSafeAreaForMainWindowContentView() { + func testMinimalModeUsesZeroTopSafeAreaForMainWindowContentView() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") return } let defaults = UserDefaults.standard - let previousValue = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) - defaults.set(false, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + let savedMode = defaults.object(forKey: WorkspacePresentationModeSettings.modeKey) + let savedLegacyTitlebar = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defaults.set(WorkspacePresentationModeSettings.Mode.minimal.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + defaults.removeObject(forKey: WorkspaceTitlebarSettings.showTitlebarKey) defer { - if let previousValue { - defaults.set(previousValue, forKey: WorkspaceTitlebarSettings.showTitlebarKey) - } else { - defaults.removeObject(forKey: WorkspaceTitlebarSettings.showTitlebarKey) - } + restoreDefaultsValue(savedMode, forKey: WorkspacePresentationModeSettings.modeKey, defaults: defaults) + restoreDefaultsValue(savedLegacyTitlebar, forKey: WorkspaceTitlebarSettings.showTitlebarKey, defaults: defaults) } let windowId = appDelegate.createMainWindow() @@ -704,25 +703,24 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { contentView.safeAreaInsets.top, 0, accuracy: 0.5, - "Hidden workspace titlebar should not leave a top safe-area inset in the main window content view" + "Minimal mode should not leave a top safe-area inset in the main window content view" ) } - func testAttachUpdateAccessoryRemovesTitlebarAccessoryWhenWorkspaceTitlebarHidden() { + func testAttachUpdateAccessoryRemovesTitlebarAccessoryWhenMinimalModeEnabled() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") return } let defaults = UserDefaults.standard - let previousValue = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) - defaults.set(true, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + let savedMode = defaults.object(forKey: WorkspacePresentationModeSettings.modeKey) + let savedLegacyTitlebar = defaults.object(forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defaults.set(WorkspacePresentationModeSettings.Mode.standard.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + defaults.removeObject(forKey: WorkspaceTitlebarSettings.showTitlebarKey) defer { - if let previousValue { - defaults.set(previousValue, forKey: WorkspaceTitlebarSettings.showTitlebarKey) - } else { - defaults.removeObject(forKey: WorkspaceTitlebarSettings.showTitlebarKey) - } + restoreDefaultsValue(savedMode, forKey: WorkspacePresentationModeSettings.modeKey, defaults: defaults) + restoreDefaultsValue(savedLegacyTitlebar, forKey: WorkspaceTitlebarSettings.showTitlebarKey, defaults: defaults) } let windowId = appDelegate.createMainWindow() @@ -741,13 +739,13 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(hasTitlebarAccessory(), "Expected visible-titlebar mode to attach the titlebar accessory") - defaults.set(false, forKey: WorkspaceTitlebarSettings.showTitlebarKey) + defaults.set(WorkspacePresentationModeSettings.Mode.minimal.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) appDelegate.attachUpdateAccessory(to: window) RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) XCTAssertFalse( hasTitlebarAccessory(), - "Hidden workspace titlebar should remove the titlebar accessory instead of keeping a hidden controller attached" + "Minimal mode should remove the titlebar accessory instead of keeping a hidden controller attached" ) } From b8a87d8914a4a1a27b9033a14ea322b9b79a243a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:15:22 -0700 Subject: [PATCH 22/28] Gate workspace chrome from minimal mode --- Sources/ContentView.swift | 27 ++++++++++++------- Sources/Update/UpdateTitlebarAccessory.swift | 28 +++++++++++--------- Sources/WorkspaceContentView.swift | 14 ++++++---- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b7d3c90d..30559fb4 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1951,11 +1951,15 @@ struct ContentView: View { /// Space at top of content area for the titlebar. This must be at least the actual titlebar /// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags. @State private var titlebarPadding: CGFloat = 32 - @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) - private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar + @AppStorage(WorkspacePresentationModeSettings.modeKey) + private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue + + private var isMinimalMode: Bool { + WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal + } private var effectiveTitlebarPadding: CGFloat { - showWorkspaceTitlebar ? titlebarPadding : 0 + isMinimalMode ? 0 : titlebarPadding } private var terminalContent: some View { @@ -2012,7 +2016,7 @@ struct ContentView: View { } .padding(.top, effectiveTitlebarPadding) .overlay(alignment: .top) { - if showWorkspaceTitlebar { + if !isMinimalMode { // Titlebar overlay is only over terminal content, not the sidebar. customTitlebar } @@ -2054,7 +2058,8 @@ struct ContentView: View { anchorView: fullscreenControlsViewModel.notificationsAnchorView ) }, - onNewTab: { tabManager.addTab() } + onNewTab: { tabManager.addTab() }, + visibilityMode: .alwaysVisible ) } @@ -2232,7 +2237,7 @@ struct ContentView: View { contentAndSidebarLayout .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .topLeading) { - if isFullScreen && sidebarState.isVisible && showWorkspaceTitlebar { + if isFullScreen && sidebarState.isVisible && !isMinimalMode { fullscreenControls .padding(.leading, 10) .padding(.top, 4) @@ -7773,14 +7778,18 @@ struct VerticalTabsSidebar: View { private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @AppStorage(SidebarWorkspaceDetailSettings.showNotificationMessageKey) private var sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage - @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) - private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar + @AppStorage(WorkspacePresentationModeSettings.modeKey) + private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue /// Space at top of sidebar for traffic light buttons private let trafficLightPadding: CGFloat = 28 private let tabRowSpacing: CGFloat = 2 private let hiddenTitlebarControlsLeadingInset: CGFloat = 72 + private var isMinimalMode: Bool { + WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal + } + private var showsSidebarNotificationMessage: Bool { SidebarWorkspaceDetailSettings.resolvedNotificationMessageVisibility( showNotificationMessage: sidebarShowNotificationMessage, @@ -7868,7 +7877,7 @@ struct VerticalTabsSidebar: View { .frame(height: trafficLightPadding) } .overlay(alignment: .topLeading) { - if !showWorkspaceTitlebar { + if isMinimalMode { HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) .padding(.leading, hiddenTitlebarControlsLeadingInset) .padding(.top, 2) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index b08a8997..c5eaf78c 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -256,9 +256,8 @@ struct TitlebarControlsView: View { let onToggleSidebar: () -> Void let onToggleNotifications: () -> Void let onNewTab: () -> Void + let visibilityMode: TitlebarControlsVisibilityMode @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue - @AppStorage(WorkspaceButtonFadeSettings.modeKey) - private var workspaceButtonsFadeMode = WorkspaceButtonFadeSettings.defaultMode.rawValue @AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX @AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @@ -299,12 +298,8 @@ struct TitlebarControlsView: View { alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed } - private var fadeButtonsEnabled: Bool { - WorkspaceButtonFadeSettings.mode(for: workspaceButtonsFadeMode) == .enabled - } - private var shouldShowControls: Bool { - if !fadeButtonsEnabled { + if visibilityMode == .alwaysVisible { return true } return isHoveringControls || isNotificationsPopoverShown || shouldShowTitlebarShortcutHints @@ -570,12 +565,18 @@ struct HiddenTitlebarSidebarControlsView: View { anchorView: viewModel.notificationsAnchorView ) }, - onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() } + onNewTab: { _ = AppDelegate.shared?.tabManager?.addTab() }, + visibilityMode: .onHover ) .frame(width: hostWidth, height: hostHeight, alignment: .leading) } } +enum TitlebarControlsVisibilityMode { + case alwaysVisible + case onHover +} + @MainActor private final class TitlebarShortcutHintModifierMonitor: ObservableObject { @Published private(set) var isModifierPressed = false @@ -782,7 +783,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private let viewModel = TitlebarControlsViewModel() private var userDefaultsObserver: NSObjectProtocol? var popoverIsShownForTesting: Bool { notificationsPopover.isShown } - private var showWorkspaceTitlebar: Bool { WorkspaceTitlebarSettings.isVisible() } + private var showsWorkspaceTitlebar: Bool { !WorkspacePresentationModeSettings.isMinimal() } init(notificationStore: TerminalNotificationStore) { self.notificationStore = notificationStore @@ -796,7 +797,8 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont viewModel: viewModel, onToggleSidebar: toggleSidebar, onToggleNotifications: toggleNotifications, - onNewTab: newTab + onNewTab: newTab, + visibilityMode: .alwaysVisible ) ) @@ -868,7 +870,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private func updateSize() { applyWorkspaceTitlebarVisibility() - guard showWorkspaceTitlebar else { return } + guard showsWorkspaceTitlebar else { return } let contentSize: NSSize if fittingSizeNeedsRefresh || cachedFittingSize == nil { hostingView.invalidateIntrinsicContentSize() @@ -902,7 +904,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont } private func applyWorkspaceTitlebarVisibility() { - let shouldShow = showWorkspaceTitlebar + let shouldShow = showsWorkspaceTitlebar view.isHidden = !shouldShow if !shouldShow { preferredContentSize = .zero @@ -1293,7 +1295,7 @@ final class UpdateTitlebarAccessoryController { pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) - guard WorkspaceTitlebarSettings.isVisible() else { + guard !WorkspacePresentationModeSettings.isMinimal() else { removeAccessoryIfPresent(from: window) return } diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index b352a51a..a74efffa 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -16,11 +16,15 @@ struct WorkspaceContentView: View { _ notificationPayloadHex: String? ) -> Void)? @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit") - @AppStorage(WorkspaceTitlebarSettings.showTitlebarKey) - private var showWorkspaceTitlebar = WorkspaceTitlebarSettings.defaultShowTitlebar + @AppStorage(WorkspacePresentationModeSettings.modeKey) + private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @Environment(\.colorScheme) private var colorScheme @EnvironmentObject var notificationStore: TerminalNotificationStore + private var isMinimalMode: Bool { + WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal + } + static func panelVisibleInUI( isWorkspaceVisible: Bool, isSelectedInPane: Bool, @@ -151,11 +155,11 @@ struct WorkspaceContentView: View { } Group { - if showWorkspaceTitlebar { - bonsplitView - } else { + if isMinimalMode { bonsplitView .ignoresSafeArea(.container, edges: .top) + } else { + bonsplitView } } } From 91db307abdc3481b53306aca4db975af9d906a22 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:20:15 -0700 Subject: [PATCH 23/28] Test minimal mode sidebar control behavior --- cmuxUITests/BonsplitTabDragUITests.swift | 183 +++++++++++------------ 1 file changed, 84 insertions(+), 99 deletions(-) diff --git a/cmuxUITests/BonsplitTabDragUITests.swift b/cmuxUITests/BonsplitTabDragUITests.swift index e0c6c497..88812cb4 100644 --- a/cmuxUITests/BonsplitTabDragUITests.swift +++ b/cmuxUITests/BonsplitTabDragUITests.swift @@ -16,12 +16,12 @@ final class BonsplitTabDragUITests: XCTestCase { RunLoop.current.run(until: Date().addingTimeInterval(0.5)) } - func testHiddenWorkspaceTitlebarKeepsTabReorderWorking() { + func testMinimalModeKeepsTabReorderWorking() { let (app, dataPath) = launchConfiguredApp() XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: launchTimeout), - "Expected app to launch for Bonsplit tab drag UI test. state=\(app.state.rawValue)" + "Expected app to launch for minimal-mode Bonsplit tab drag UI test. state=\(app.state.rawValue)" ) XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { @@ -86,12 +86,12 @@ final class BonsplitTabDragUITests: XCTestCase { XCTAssertEqual(window.frame.origin.y, windowFrameBeforeDrag.origin.y, accuracy: 2.0, "Expected tab drag not to move the window vertically") } - func testHiddenWorkspaceTitlebarPlacesPaneTabBarAtTopEdge() { + func testMinimalModePlacesPaneTabBarAtTopEdge() { let (app, dataPath) = launchConfiguredApp() XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: launchTimeout), - "Expected app to launch for hidden titlebar top-gap UI test. state=\(app.state.rawValue)" + "Expected app to launch for minimal-mode top-gap UI test. state=\(app.state.rawValue)" ) XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { @@ -117,16 +117,16 @@ final class BonsplitTabDragUITests: XCTestCase { XCTAssertLessThanOrEqual( topGap, 8, - "Expected the selected pane tab to reach the top edge when the workspace titlebar is hidden. window=\(window.frame) alphaTab=\(alphaTab.frame) gap.bottomLeft=\(gapIfOriginIsBottomLeft) gap.topLeft=\(gapIfOriginIsTopLeft)" + "Expected the selected pane tab to reach the top edge in minimal mode. window=\(window.frame) alphaTab=\(alphaTab.frame) gap.bottomLeft=\(gapIfOriginIsBottomLeft) gap.topLeft=\(gapIfOriginIsTopLeft)" ) } - func testHiddenWorkspaceTitlebarKeepsSidebarRowsBelowTrafficLights() { + func testMinimalModeKeepsSidebarRowsBelowTrafficLights() { let (app, dataPath) = launchConfiguredApp() XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: launchTimeout), - "Expected app to launch for hidden titlebar sidebar inset UI test. state=\(app.state.rawValue)" + "Expected app to launch for minimal-mode sidebar inset UI test. state=\(app.state.rawValue)" ) XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { @@ -152,16 +152,65 @@ final class BonsplitTabDragUITests: XCTestCase { topInset, 36, accuracy: 4, - "Expected hidden-titlebar mode to keep the sidebar workspace row offset unchanged while reserving the existing traffic-light strip. window=\(window.frame) workspaceRow=\(workspaceRow.frame) topInset=\(topInset)" + "Expected minimal mode to keep the sidebar workspace row offset unchanged while reserving the existing traffic-light strip. window=\(window.frame) workspaceRow=\(workspaceRow.frame) topInset=\(topInset)" ) } - func testHiddenWorkspaceTitlebarSidebarControlsRevealOnlyFromSidebarHoverWhenFadeButtonsEnabled() { + func testStandardModeKeepsWorkspaceControlsOutOfSidebar() { + let (app, dataPath) = launchConfiguredApp(presentationMode: .standard) + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "Expected app to launch for standard-mode sidebar control placement UI test. state=\(app.state.rawValue)" + ) + XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") + guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { + XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") + return + } + + if let setupError = ready["setupError"], !setupError.isEmpty { + XCTFail("Setup failed: \(setupError)") + return + } + + let window = app.windows.element(boundBy: 0) + XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") + + let sidebar = app.descendants(matching: .any).matching(identifier: "Sidebar").firstMatch + XCTAssertTrue(sidebar.waitForExistence(timeout: 5.0), "Expected sidebar to exist") + + let toggleSidebarButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.toggleSidebar").firstMatch + let notificationsButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.showNotifications").firstMatch + let newWorkspaceButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.newTab").firstMatch + + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + toggleSidebarButton.exists && toggleSidebarButton.isHittable && + notificationsButton.exists && notificationsButton.isHittable && + newWorkspaceButton.exists && newWorkspaceButton.isHittable + }, + "Expected standard mode to keep workspace controls visible in the titlebar." + ) + + let leadingControlX = min( + toggleSidebarButton.frame.minX, + notificationsButton.frame.minX, + newWorkspaceButton.frame.minX + ) + XCTAssertGreaterThanOrEqual( + leadingControlX, + sidebar.frame.maxX - 4, + "Expected standard mode workspace controls to stay outside the sidebar header. sidebar=\(sidebar.frame) toggle=\(toggleSidebarButton.frame) notifications=\(notificationsButton.frame) new=\(newWorkspaceButton.frame)" + ) + } + + func testMinimalModeSidebarControlsRevealOnlyFromSidebarHover() { let (app, dataPath) = launchConfiguredApp() XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: launchTimeout), - "Expected app to launch for hidden titlebar titlebar-controls hover UI test. state=\(app.state.rawValue)" + "Expected app to launch for minimal-mode sidebar hover UI test. state=\(app.state.rawValue)" ) XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { @@ -192,7 +241,7 @@ final class BonsplitTabDragUITests: XCTestCase { XCTAssertLessThan( paneLeadingGap, 28, - "Expected visible-sidebar hidden-titlebar mode to keep pane tabs tight to the sidebar edge while the traffic lights sit over the sidebar. window=\(window.frame) sidebar=\(sidebar.frame) alphaTab=\(alphaTab.frame) paneLeadingGap=\(paneLeadingGap)" + "Expected visible-sidebar minimal mode to keep pane tabs tight to the sidebar edge while the traffic lights sit over the sidebar. window=\(window.frame) sidebar=\(sidebar.frame) alphaTab=\(alphaTab.frame) paneLeadingGap=\(paneLeadingGap)" ) window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() @@ -200,7 +249,7 @@ final class BonsplitTabDragUITests: XCTestCase { waitForCondition(timeout: 2.0) { !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to stay hidden away from the sidebar hover zone when Fade Buttons is enabled." + "Expected minimal-mode sidebar controls to stay hidden away from the sidebar hover zone." ) hover(in: window, at: CGPoint(x: window.frame.maxX - 48, y: window.frame.minY + 18)) @@ -208,7 +257,7 @@ final class BonsplitTabDragUITests: XCTestCase { waitForCondition(timeout: 2.0) { !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable }, - "Expected the removed titlebar area to stop revealing hidden-titlebar controls when Fade Buttons is enabled." + "Expected the removed titlebar area to stop revealing minimal-mode controls." ) hover( @@ -224,85 +273,16 @@ final class BonsplitTabDragUITests: XCTestCase { notificationsButton.exists && notificationsButton.isHittable && newWorkspaceButton.exists && newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to reveal when hovering the sidebar chrome area with Fade Buttons enabled." + "Expected minimal-mode sidebar controls to reveal when hovering the sidebar chrome area." ) } - func testHiddenWorkspaceTitlebarSidebarControlsStayVisibleWhenFadeButtonsDisabled() { - let (app, dataPath) = launchConfiguredApp(fadeButtonsMode: "disabled") - - XCTAssertTrue( - ensureForegroundAfterLaunch(app, timeout: launchTimeout), - "Expected app to launch for hidden titlebar sidebar visibility UI test. state=\(app.state.rawValue)" - ) - XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") - guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { - XCTFail("Timed out waiting for ready=1. data=\(loadJSON(atPath: dataPath) ?? [:])") - return - } - - if let setupError = ready["setupError"], !setupError.isEmpty { - XCTFail("Setup failed: \(setupError)") - return - } - - let window = app.windows.element(boundBy: 0) - XCTAssertTrue(window.waitForExistence(timeout: 5.0), "Expected main window to exist") - - let sidebar = app.descendants(matching: .any).matching(identifier: "Sidebar").firstMatch - XCTAssertTrue(sidebar.waitForExistence(timeout: 5.0), "Expected sidebar to exist") - - let toggleSidebarButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.toggleSidebar").firstMatch - let notificationsButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.showNotifications").firstMatch - let newWorkspaceButton = app.descendants(matching: .any).matching(identifier: "titlebarControl.newTab").firstMatch - - let alphaTitle = ready["alphaTitle"] ?? "UITest Alpha" - let alphaTab = app.buttons[alphaTitle] - XCTAssertTrue(alphaTab.waitForExistence(timeout: 5.0), "Expected alpha tab to exist") - - let paneLeadingGap = alphaTab.frame.minX - sidebar.frame.maxX - XCTAssertLessThan( - paneLeadingGap, - 28, - "Expected visible-sidebar hidden-titlebar mode to keep pane tabs tight to the sidebar edge while the traffic lights sit over the sidebar. window=\(window.frame) sidebar=\(sidebar.frame) alphaTab=\(alphaTab.frame) paneLeadingGap=\(paneLeadingGap)" - ) - - XCTAssertTrue( - waitForCondition(timeout: 2.0) { - toggleSidebarButton.exists && toggleSidebarButton.isHittable && - notificationsButton.exists && notificationsButton.isHittable && - newWorkspaceButton.exists && newWorkspaceButton.isHittable - }, - "Expected hidden-titlebar sidebar controls to stay visible whenever the sidebar is visible and Fade Buttons is disabled." - ) - - window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() - XCTAssertTrue( - waitForCondition(timeout: 2.0) { - toggleSidebarButton.exists && toggleSidebarButton.isHittable && - notificationsButton.exists && notificationsButton.isHittable && - newWorkspaceButton.exists && newWorkspaceButton.isHittable - }, - "Expected hidden-titlebar sidebar controls to remain visible away from the sidebar header when Fade Buttons is disabled." - ) - - hover(in: window, at: CGPoint(x: window.frame.maxX - 48, y: window.frame.minY + 18)) - XCTAssertTrue( - waitForCondition(timeout: 2.0) { - toggleSidebarButton.exists && toggleSidebarButton.isHittable && - notificationsButton.exists && notificationsButton.isHittable && - newWorkspaceButton.exists && newWorkspaceButton.isHittable - }, - "Expected hidden-titlebar sidebar controls to remain visible without any special hover zone when Fade Buttons is disabled." - ) - } - - func testHiddenWorkspaceTitlebarCollapsedSidebarKeepsControlsSuppressed() { + func testMinimalModeCollapsedSidebarKeepsWorkspaceControlsSuppressed() { let (app, dataPath) = launchConfiguredApp(startWithHiddenSidebar: true) XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: launchTimeout), - "Expected app to launch for collapsed-sidebar hidden-titlebar controls UI test. state=\(app.state.rawValue)" + "Expected app to launch for collapsed-sidebar minimal-mode controls UI test. state=\(app.state.rawValue)" ) XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { @@ -335,23 +315,23 @@ final class BonsplitTabDragUITests: XCTestCase { (!notificationsButton.exists || !notificationsButton.isHittable) && (!newWorkspaceButton.exists || !newWorkspaceButton.isHittable) }, - "Expected collapsed-sidebar hidden-titlebar mode to keep titlebar controls suppressed. toggle=\(toggleSidebarButton.debugDescription) notifications=\(notificationsButton.debugDescription) new=\(newWorkspaceButton.debugDescription)" + "Expected collapsed-sidebar minimal mode to keep workspace controls suppressed. toggle=\(toggleSidebarButton.debugDescription) notifications=\(notificationsButton.debugDescription) new=\(newWorkspaceButton.debugDescription)" ) let leadingInset = alphaTab.frame.minX - window.frame.minX XCTAssertLessThan( leadingInset, 96, - "Expected pane tabs to stay near the leading edge when collapsed-sidebar hidden-titlebar mode removes the titlebar accessory lane. window=\(window.frame) alphaTab=\(alphaTab.frame) leadingInset=\(leadingInset)" + "Expected pane tabs to stay near the leading edge when collapsed-sidebar minimal mode removes the titlebar accessory lane. window=\(window.frame) alphaTab=\(alphaTab.frame) leadingInset=\(leadingInset)" ) } - func testHiddenWorkspaceTitlebarSidebarControlsRemainVisibleWhileNotificationsPopoverIsShown() { + func testMinimalModeSidebarControlsRemainVisibleWhileNotificationsPopoverIsShown() { let (app, dataPath) = launchConfiguredApp() XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: launchTimeout), - "Expected app to launch for hidden-titlebar notifications-popover pinning UI test. state=\(app.state.rawValue)" + "Expected app to launch for minimal-mode notifications-popover pinning UI test. state=\(app.state.rawValue)" ) XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { @@ -376,7 +356,7 @@ final class BonsplitTabDragUITests: XCTestCase { waitForCondition(timeout: 2.0) { !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to start hidden away from hover when Fade Buttons is enabled." + "Expected minimal-mode sidebar controls to start hidden away from hover." ) app.typeKey("i", modifierFlags: [.command]) @@ -393,16 +373,16 @@ final class BonsplitTabDragUITests: XCTestCase { notificationsButton.exists && notificationsButton.isHittable && newWorkspaceButton.exists && newWorkspaceButton.isHittable }, - "Expected hidden-titlebar sidebar controls to remain visible while the notifications popover is open." + "Expected minimal-mode sidebar controls to remain visible while the notifications popover is open." ) } - func testPaneTabBarControlsRevealWhenHoveringAnywhereOnPaneTabBar() { - let (app, dataPath) = launchConfiguredApp() + func testMinimalModeCollapsedSidebarStillRevealsPaneTabBarControlsOnHover() { + let (app, dataPath) = launchConfiguredApp(startWithHiddenSidebar: true) XCTAssertTrue( ensureForegroundAfterLaunch(app, timeout: launchTimeout), - "Expected app to launch for Bonsplit controls hover UI test. state=\(app.state.rawValue)" + "Expected app to launch for collapsed-sidebar minimal-mode Bonsplit controls hover UI test. state=\(app.state.rawValue)" ) XCTAssertTrue(waitForAnyJSON(atPath: dataPath, timeout: setupTimeout), "Expected tab-drag setup data at \(dataPath)") guard let ready = waitForJSONKey("ready", equals: "1", atPath: dataPath, timeout: setupTimeout) else { @@ -429,7 +409,7 @@ final class BonsplitTabDragUITests: XCTestCase { window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() XCTAssertTrue( waitForCondition(timeout: 2.0) { !newTerminalButton.exists || !newTerminalButton.isHittable }, - "Expected pane tab bar controls to hide away from the pane tab bar. button=\(newTerminalButton.debugDescription)" + "Expected pane tab bar controls to hide away from the pane tab bar in minimal mode. button=\(newTerminalButton.debugDescription)" ) hover( @@ -441,19 +421,24 @@ final class BonsplitTabDragUITests: XCTestCase { ) XCTAssertTrue( waitForCondition(timeout: 2.0) { newTerminalButton.exists && newTerminalButton.isHittable }, - "Expected pane tab bar controls to reveal when hovering inside empty pane-tab-bar space. window=\(window.frame) alphaTab=\(alphaTab.frame) betaTab=\(betaTab.frame) button=\(newTerminalButton.debugDescription)" + "Expected pane tab bar controls to reveal when hovering inside empty pane-tab-bar space in collapsed-sidebar minimal mode. window=\(window.frame) alphaTab=\(alphaTab.frame) betaTab=\(betaTab.frame) button=\(newTerminalButton.debugDescription)" ) window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).hover() XCTAssertTrue( waitForCondition(timeout: 2.0) { !newTerminalButton.exists || !newTerminalButton.isHittable }, - "Expected pane tab bar controls to hide again after leaving the pane tab bar. button=\(newTerminalButton.debugDescription)" + "Expected pane tab bar controls to hide again after leaving the pane tab bar in minimal mode. button=\(newTerminalButton.debugDescription)" ) } + private enum WorkspacePresentationMode: String { + case standard + case minimal + } + private func launchConfiguredApp( startWithHiddenSidebar: Bool = false, - fadeButtonsMode: String = "enabled" + presentationMode: WorkspacePresentationMode = .minimal ) -> (XCUIApplication, String) { let app = XCUIApplication() let dataPath = "/tmp/cmux-ui-test-bonsplit-tab-drag-\(UUID().uuidString).json" @@ -464,7 +449,7 @@ final class BonsplitTabDragUITests: XCTestCase { if startWithHiddenSidebar { app.launchEnvironment["CMUX_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] = "1" } - app.launchArguments += ["-workspaceTitlebarVisible", "NO", "-workspaceButtonsFadeMode", fadeButtonsMode] + app.launchArguments += ["-workspacePresentationMode", presentationMode.rawValue] app.launch() app.activate() return (app, dataPath) From 4c7e300332397ffd7457add7d6210e23e8e5eb54 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 16 Mar 2026 23:21:28 -0700 Subject: [PATCH 24/28] Implement minimal mode sidebar control behavior --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 1ae8ee43..29a36a12 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 1ae8ee43d6813e6ce7ee67611afa1af42c0762af +Subproject commit 29a36a128185f84c8c60e6c94825c41a80dcb50f From 853da3af19c1f38cbd71d01a992c6143466193ae Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 19:57:27 -0700 Subject: [PATCH 25/28] Update bonsplit for empty tab bar drag fix --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index d807a44d..b136cadb 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit d807a44d9ea3e45eb057e7b28f1d3fbdfa4c0b22 +Subproject commit b136cadb3718f6ff9a6b222ad8f21474e77c5da0 From 03820ae20fea560631a7548a10ebf5e535788975 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Tue, 17 Mar 2026 20:05:24 -0700 Subject: [PATCH 26/28] Update bonsplit for minimal mode titlebar double click --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index b136cadb..31c3810a 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit b136cadb3718f6ff9a6b222ad8f21474e77c5da0 +Subproject commit 31c3810a3411d792da6f60e5f5da3deca0b637e5 From b4b345d4ad5780b4abf80eee09a965ff29dcb877 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 18 Mar 2026 04:13:01 -0700 Subject: [PATCH 27/28] Add command palette minimal mode toggle UI test --- cmuxUITests/SidebarHelpMenuUITests.swift | 91 ++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index aeb2c339..9f7aa5e6 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -586,6 +586,97 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) } + func testCommandPaletteCanEnableAndDisableMinimalMode() throws { + let app = XCUIApplication() + configureSocketControlledLaunch(app, showSettingsWindow: true) + app.launchArguments += ["-workspacePresentationMode", "standard"] + launchAndActivate(app) + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 8.0) { + app.windows.count >= 2 + }, + "Expected the main window and Settings window to be visible" + ) + XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)") + + let mainWindowId = try XCTUnwrap( + socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines) + ) + + focusSettingsWindow(app: app) + let toggle = try requireMinimalModeToggle(app: app) + if toggleIsOn(toggle) { + toggle.click() + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { + toggle.exists && !toggleIsOn(toggle) + }, + "Expected the minimal mode setting to start from off for this test" + ) + } + + XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK") + openCommandPaletteCommands(app: app) + let searchField = app.textFields["CommandPaletteSearchField"] + searchField.typeText("minimal") + + let enableSnapshot = try XCTUnwrap( + waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "commands", query: "minimal", timeout: 5.0) { snapshot in + self.commandPaletteResultRows(from: snapshot).contains { row in + (row["command_id"] as? String) == "palette.enableMinimalMode" + } + }, + "Expected the command palette to show Enable Minimal Mode while standard mode is active" + ) + XCTAssertFalse( + commandPaletteResultRows(from: enableSnapshot).contains { row in + (row["command_id"] as? String) == "palette.disableMinimalMode" + }, + "Expected Disable Minimal Mode to stay hidden while standard mode is active. snapshot=\(enableSnapshot)" + ) + + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + focusSettingsWindow(app: app) + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { + toggle.exists && toggleIsOn(toggle) + }, + "Expected running the command palette action to enable minimal mode" + ) + + XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK") + openCommandPaletteCommands(app: app) + let disableSearchField = app.textFields["CommandPaletteSearchField"] + disableSearchField.typeText("minimal") + + let disableSnapshot = try XCTUnwrap( + waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "commands", query: "minimal", timeout: 5.0) { snapshot in + self.commandPaletteResultRows(from: snapshot).contains { row in + (row["command_id"] as? String) == "palette.disableMinimalMode" + } + }, + "Expected the command palette to show Disable Minimal Mode while minimal mode is active" + ) + XCTAssertFalse( + commandPaletteResultRows(from: disableSnapshot).contains { row in + (row["command_id"] as? String) == "palette.enableMinimalMode" + }, + "Expected Enable Minimal Mode to stay hidden while minimal mode is active. snapshot=\(disableSnapshot)" + ) + + app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) + + focusSettingsWindow(app: app) + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { + toggle.exists && !toggleIsOn(toggle) + }, + "Expected running the command palette action to disable minimal mode" + ) + } + func testSwitcherEmptyStateDoesNotBlinkWhileRefiningNoMatchQuery() throws { let app = XCUIApplication() configureSocketControlledLaunch(app) From 637a0eed136a640d0c525308f8b2183877534749 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Wed, 18 Mar 2026 04:18:11 -0700 Subject: [PATCH 28/28] Add command palette minimal mode actions --- Resources/Localizable.xcstrings | 34 +++++++++++++++++++++++++++++++++ Sources/ContentView.swift | 26 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2cdbf7ca..94e2a8db 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -14349,6 +14349,40 @@ } } }, + "command.enableMinimalMode.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable Minimal Mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ミニマルモードを有効にする" + } + } + } + }, + "command.disableMinimalMode.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable Minimal Mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ミニマルモードを無効にする" + } + } + } + }, "command.installCLI.subtitle": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 35d0fd95..1d862346 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1609,6 +1609,7 @@ struct ContentView: View { static let hasWorkspace = "workspace.hasSelection" static let workspaceName = "workspace.name" static let workspaceHasCustomName = "workspace.hasCustomName" + static let workspaceMinimalModeEnabled = "workspace.minimalModeEnabled" static let workspaceShouldPin = "workspace.shouldPin" static let workspaceHasPullRequests = "workspace.hasPullRequests" static let workspaceHasSplits = "workspace.hasSplits" @@ -4888,6 +4889,7 @@ struct ContentView: View { terminalOpenTargets: Set? = nil ) -> CommandPaletteContextSnapshot { var snapshot = CommandPaletteContextSnapshot() + snapshot.setBool(CommandPaletteContextKeys.workspaceMinimalModeEnabled, isMinimalMode) if let workspace = tabManager.selectedWorkspace { snapshot.setBool(CommandPaletteContextKeys.hasWorkspace, true) @@ -5092,6 +5094,24 @@ struct ContentView: View { keywords: ["toggle", "sidebar", "layout"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.enableMinimalMode", + title: constant(String(localized: "command.enableMinimalMode.title", defaultValue: "Enable Minimal Mode")), + subtitle: constant(String(localized: "command.toggleSidebar.subtitle", defaultValue: "Layout")), + keywords: ["minimal", "mode", "titlebar", "sidebar", "layout"], + when: { !$0.bool(CommandPaletteContextKeys.workspaceMinimalModeEnabled) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.disableMinimalMode", + title: constant(String(localized: "command.disableMinimalMode.title", defaultValue: "Disable Minimal Mode")), + subtitle: constant(String(localized: "command.toggleSidebar.subtitle", defaultValue: "Layout")), + keywords: ["minimal", "mode", "titlebar", "sidebar", "layout"], + when: { $0.bool(CommandPaletteContextKeys.workspaceMinimalModeEnabled) } + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.triggerFlash", @@ -5711,6 +5731,12 @@ struct ContentView: View { registry.register(commandId: "palette.toggleSidebar") { sidebarState.toggle() } + registry.register(commandId: "palette.enableMinimalMode") { + workspacePresentationMode = WorkspacePresentationModeSettings.Mode.minimal.rawValue + } + registry.register(commandId: "palette.disableMinimalMode") { + workspacePresentationMode = WorkspacePresentationModeSettings.Mode.standard.rawValue + } registry.register(commandId: "palette.triggerFlash") { tabManager.triggerFocusFlash() }