diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 11f77ec7..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": { @@ -45454,6 +45488,159 @@ } } }, + "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 folder and active title above pane tabs." + } + }, + "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.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.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/AppDelegate.swift b/Sources/AppDelegate.swift index b0e05526..d3160cb7 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") } @@ -2041,6 +2065,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 private var didSetupDisplayResolutionUITestDiagnostics = false @@ -2644,6 +2670,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() + setupBonsplitTabDragUITestIfNeeded() setupMultiWindowNotificationsUITestIfNeeded() setupDisplayResolutionUITestDiagnosticsIfNeeded() @@ -5773,7 +5800,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) @@ -6966,6 +6993,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" @@ -8349,6 +8546,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent titlebarAccessoryController.dismissNotificationsPopoverIfShown() } + func isNotificationsPopoverShown() -> Bool { + titlebarAccessoryController.isNotificationsPopoverShown() + } + func jumpToLatestUnread() { guard let notificationStore else { return } #if DEBUG @@ -8468,7 +8669,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/ContentView.swift b/Sources/ContentView.swift index b9ac6437..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" @@ -2035,6 +2036,16 @@ 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(WorkspacePresentationModeSettings.modeKey) + private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue + + private var isMinimalMode: Bool { + WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal + } + + private var effectiveTitlebarPadding: CGFloat { + isMinimalMode ? 0 : titlebarPadding + } private var terminalContent: some View { let mountedWorkspaceIdSet = Set(mountedWorkspaceIds) @@ -2092,10 +2103,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 !isMinimalMode { + // Titlebar overlay is only over terminal content, not the sidebar. + customTitlebar + } } } @@ -2134,7 +2147,8 @@ struct ContentView: View { anchorView: fullscreenControlsViewModel.notificationsAnchorView ) }, - onNewTab: { tabManager.addTab() } + onNewTab: { tabManager.addTab() }, + visibilityMode: .alwaysVisible ) } @@ -2312,7 +2326,7 @@ struct ContentView: View { contentAndSidebarLayout .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .topLeading) { - if isFullScreen && sidebarState.isVisible { + if isFullScreen && sidebarState.isVisible && !isMinimalMode { fullscreenControls .padding(.leading, 10) .padding(.top, 4) @@ -4875,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) @@ -5079,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", @@ -5698,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() } @@ -8093,10 +8132,17 @@ struct VerticalTabsSidebar: View { private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @AppStorage(SidebarWorkspaceDetailSettings.showNotificationMessageKey) private var sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage + @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( @@ -8194,6 +8240,13 @@ struct VerticalTabsSidebar: View { WindowDragHandleView() .frame(height: trafficLightPadding) } + .overlay(alignment: .topLeading) { + if isMinimalMode { + HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) + .padding(.leading, hiddenTitlebarControlsLeadingInset) + .padding(.top, 2) + } + } .background(Color.clear) .modifier(ClearScrollBackground()) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ded6ac00..c136afa4 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6093,9 +6093,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 { @@ -7652,7 +7665,8 @@ final class GhosttySurfaceScrollView: NSView { activeTabManager: delegate.tabManager, targetTabManager: tabManager, keyWindow: NSApp.keyWindow, - mainWindow: NSApp.mainWindow + mainWindow: NSApp.mainWindow, + targetWindow: window ) else { return } 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 984df39c..c5eaf78c 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 @@ -240,11 +256,14 @@ struct TitlebarControlsView: View { let onToggleSidebar: () -> Void let onToggleNotifications: () -> Void let onNewTab: () -> Void + let visibilityMode: TitlebarControlsVisibilityMode @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.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 @@ -279,6 +298,13 @@ struct TitlebarControlsView: View { alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed } + private var shouldShowControls: Bool { + if visibilityMode == .alwaysVisible { + 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.) @@ -288,15 +314,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) ) - .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + .onHover { hovering in + isHoveringControls = hovering + } + .onReceive(NotificationCenter.default.publisher(for: KeyboardShortcutSettings.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() } @@ -508,6 +547,36 @@ struct TitlebarControlsView: View { } } +struct HiddenTitlebarSidebarControlsView: View { + @ObservedObject var notificationStore: TerminalNotificationStore + @StateObject private var viewModel = TitlebarControlsViewModel() + + private let hostWidth: CGFloat = 124 + private let hostHeight: CGFloat = 28 + + var body: some View { + 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() }, + 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 @@ -714,6 +783,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont private let viewModel = TitlebarControlsViewModel() private var userDefaultsObserver: NSObjectProtocol? var popoverIsShownForTesting: Bool { notificationsPopover.isShown } + private var showsWorkspaceTitlebar: Bool { !WorkspacePresentationModeSettings.isMinimal() } init(notificationStore: TerminalNotificationStore) { self.notificationStore = notificationStore @@ -727,7 +797,8 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont viewModel: viewModel, onToggleSidebar: toggleSidebar, onToggleNotifications: toggleNotifications, - onNewTab: newTab + onNewTab: newTab, + visibilityMode: .alwaysVisible ) ) @@ -749,9 +820,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont object: nil, queue: .main ) { [weak self] _ in + self?.applyWorkspaceTitlebarVisibility() self?.scheduleSizeUpdate(invalidateFittingSize: true) } + applyWorkspaceTitlebarVisibility() scheduleSizeUpdate(invalidateFittingSize: true) } @@ -796,6 +869,8 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont } private func updateSize() { + applyWorkspaceTitlebarVisibility() + guard showsWorkspaceTitlebar else { return } let contentSize: NSSize if fittingSizeNeedsRefresh || cachedFittingSize == nil { hostingView.invalidateIntrinsicContentSize() @@ -828,6 +903,16 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height) } + private func applyWorkspaceTitlebarVisibility() { + let shouldShow = showsWorkspaceTitlebar + 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 +946,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 +957,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 +967,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 +990,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) } } @@ -1090,6 +1179,7 @@ private struct NotificationPopoverRow: View { } } +@MainActor final class UpdateTitlebarAccessoryController { private weak var updateViewModel: UpdateViewModel? private var didStart = false @@ -1130,7 +1220,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( @@ -1139,7 +1231,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: @@ -1159,7 +1253,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" { @@ -1175,7 +1271,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 @@ -1187,8 +1282,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) @@ -1198,6 +1295,13 @@ final class UpdateTitlebarAccessoryController { pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) + guard !WorkspacePresentationModeSettings.isMinimal() 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 @@ -1219,6 +1323,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/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index 0b955943..a74efffa 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -16,9 +16,15 @@ struct WorkspaceContentView: View { _ notificationPayloadHex: String? ) -> Void)? @State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit") + @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, @@ -52,7 +58,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 +153,15 @@ struct WorkspaceContentView: View { notificationPayloadHex: payloadHex ) } + + Group { + if isMinimalMode { + bonsplitView + .ignoresSafeArea(.container, edges: .top) + } else { + bonsplitView + } + } } private func syncBonsplitNotificationBadges() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 48a7de8d..ad61dc8a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4,6 +4,89 @@ 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) + } +} + +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" + 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 + } +} + enum UITestLaunchManifest { static let argumentName = "-cmuxUITestLaunchManifest" @@ -2492,6 +2575,8 @@ 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( @@ -2534,6 +2619,93 @@ 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 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] + ) + } + + 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) + } + } + + 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 + } } enum SettingsNavigationTarget: String { @@ -3597,6 +3769,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(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 @@ -3687,6 +3861,23 @@ struct SettingsView: View { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement } + private var minimalModeEnabled: Bool { + WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal + } + + private var minimalModeSubtitle: String { + if minimalModeEnabled { + return String( + localized: "settings.app.minimalMode.subtitleOn", + defaultValue: "Hide the workspace title bar and move workspace controls into the sidebar." + ) + } + return String( + localized: "settings.app.minimalMode.subtitleOff", + defaultValue: "Use the standard workspace title bar and controls." + ) + } + private var keepWorkspaceOpenOnLastSurfaceShortcut: Bool { !closeWorkspaceOnLastSurfaceShortcut } @@ -3782,6 +3973,18 @@ struct SettingsView: View { ) } + private var minimalModeBinding: Binding { + Binding( + get: { minimalModeEnabled }, + set: { newValue in + workspacePresentationMode = newValue + ? WorkspacePresentationModeSettings.Mode.minimal.rawValue + : WorkspacePresentationModeSettings.Mode.standard.rawValue + SettingsWindowController.shared.preserveFocusAfterPreferenceMutation() + } + ) + } + private var settingsSidebarTintLightBinding: Binding { Binding( get: { @@ -4142,6 +4345,21 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode"), + subtitle: minimalModeSubtitle + ) { + Toggle("", isOn: minimalModeBinding) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsMinimalModeToggle") + .accessibilityLabel( + String(localized: "settings.app.minimalMode", defaultValue: "Minimal Mode") + ) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Keep Workspace Open When Closing Last Surface"), subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle @@ -5305,6 +5523,12 @@ struct SettingsView: View { ShortcutHintDebugSettings.resetVisibilityDefaults() alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.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) closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails @@ -5870,7 +6094,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 diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 8bc58a59..1d868654 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -875,6 +875,224 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { } } + func testMinimalModeUsesZeroTopSafeAreaForMainWindowContentView() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let defaults = UserDefaults.standard + 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 { + restoreDefaultsValue(savedMode, forKey: WorkspacePresentationModeSettings.modeKey, defaults: defaults) + restoreDefaultsValue(savedLegacyTitlebar, forKey: WorkspaceTitlebarSettings.showTitlebarKey, defaults: defaults) + } + + 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, + "Minimal mode should not leave a top safe-area inset in the main window content view" + ) + } + + func testAttachUpdateAccessoryRemovesTitlebarAccessoryWhenMinimalModeEnabled() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let defaults = UserDefaults.standard + 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 { + restoreDefaultsValue(savedMode, forKey: WorkspacePresentationModeSettings.modeKey, defaults: defaults) + restoreDefaultsValue(savedLegacyTitlebar, forKey: WorkspaceTitlebarSettings.showTitlebarKey, defaults: defaults) + } + + 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(WorkspacePresentationModeSettings.Mode.minimal.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + appDelegate.attachUpdateAccessory(to: window) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertFalse( + hasTitlebarAccessory(), + "Minimal mode should remove the titlebar accessory instead of keeping a hidden controller attached" + ) + } + + 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 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 + 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") @@ -2920,6 +3138,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/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 ) ) } diff --git a/cmuxUITests/BonsplitTabDragUITests.swift b/cmuxUITests/BonsplitTabDragUITests.swift new file mode 100644 index 00000000..88812cb4 --- /dev/null +++ b/cmuxUITests/BonsplitTabDragUITests.swift @@ -0,0 +1,603 @@ +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 testMinimalModeKeepsTabReorderWorking() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "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 { + 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 testMinimalModePlacesPaneTabBarAtTopEdge() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "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 { + 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 in minimal mode. window=\(window.frame) alphaTab=\(alphaTab.frame) gap.bottomLeft=\(gapIfOriginIsBottomLeft) gap.topLeft=\(gapIfOriginIsTopLeft)" + ) + } + + func testMinimalModeKeepsSidebarRowsBelowTrafficLights() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "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 { + 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 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 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 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 { + 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 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() + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable + }, + "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)) + XCTAssertTrue( + waitForCondition(timeout: 2.0) { + !toggleSidebarButton.isHittable && !notificationsButton.isHittable && !newWorkspaceButton.isHittable + }, + "Expected the removed titlebar area to stop revealing minimal-mode 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 minimal-mode sidebar controls to reveal when hovering the sidebar chrome area." + ) + } + + func testMinimalModeCollapsedSidebarKeepsWorkspaceControlsSuppressed() { + let (app, dataPath) = launchConfiguredApp(startWithHiddenSidebar: true) + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "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 { + 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 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 minimal mode removes the titlebar accessory lane. window=\(window.frame) alphaTab=\(alphaTab.frame) leadingInset=\(leadingInset)" + ) + } + + func testMinimalModeSidebarControlsRemainVisibleWhileNotificationsPopoverIsShown() { + let (app, dataPath) = launchConfiguredApp() + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "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 { + 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 minimal-mode 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 minimal-mode sidebar controls to remain visible while the notifications popover is open." + ) + } + + func testMinimalModeCollapsedSidebarStillRevealsPaneTabBarControlsOnHover() { + let (app, dataPath) = launchConfiguredApp(startWithHiddenSidebar: true) + + XCTAssertTrue( + ensureForegroundAfterLaunch(app, timeout: launchTimeout), + "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 { + 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 in minimal mode. 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 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 in minimal mode. button=\(newTerminalButton.debugDescription)" + ) + } + + private enum WorkspacePresentationMode: String { + case standard + case minimal + } + + private func launchConfiguredApp( + startWithHiddenSidebar: Bool = false, + presentationMode: WorkspacePresentationMode = .minimal + ) -> (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 += ["-workspacePresentationMode", presentationMode.rawValue] + 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) + } +} diff --git a/cmuxUITests/SidebarHelpMenuUITests.swift b/cmuxUITests/SidebarHelpMenuUITests.swift index d52ae8f5..9f7aa5e6 100644 --- a/cmuxUITests/SidebarHelpMenuUITests.swift +++ b/cmuxUITests/SidebarHelpMenuUITests.swift @@ -519,6 +519,164 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { ) } + func testMinimalModeToggleKeepsSettingsWindowFocused() 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( + sidebarHelpPollUntil(timeout: 8.0) { + app.windows.count >= 2 + }, + "Expected the main window and Settings window to be visible" + ) + + focusSettingsWindow(app: app) + let toggle = try requireMinimalModeToggle(app: app) + let initialState = toggleIsOn(toggle) + + toggle.click() + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { + toggle.exists && toggleIsOn(toggle) != initialState + }, + "Expected the minimal mode 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 minimal mode. diagnostics=\(diagnostics ?? [:])" + ) + XCTAssertEqual( + diagnostics?["settingsWindowIsKey"], + "1", + "Expected the Settings window to report itself as key after toggling minimal mode. 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 minimal mode. diagnostics=\(loadDiagnostics(at: diagnosticsPath) ?? [:])" + ) + + app.typeKey("w", modifierFlags: [.command]) + + XCTAssertTrue( + sidebarHelpPollUntil(timeout: 3.0) { + app.windows.count == 1 && !toggle.exists + }, + "Expected Cmd+W after toggling minimal mode to close the focused Settings window instead of defocusing back to the workspace window" + ) + } + + 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) @@ -672,6 +830,31 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase { throw XCTSkip("Could not find the command palette all-surfaces toggle") } + private func requireMinimalModeToggle(app: XCUIApplication) throws -> XCUIElement { + let scrollView = app.scrollViews.firstMatch + let candidates = [ + 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 { + if let element = firstExistingElement(candidates: candidates, timeout: 0.4), element.isHittable { + return element + } + if scrollView.exists { + scrollView.swipeUp() + } + } + + throw XCTSkip("Could not find the minimal mode toggle") + } + private func toggleIsOn(_ element: XCUIElement) -> Bool { let value = String(describing: element.value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return value == "1" || value == "true" || value == "on" @@ -833,6 +1016,50 @@ 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 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 { + return nil + } + return object + } + private final class ControlSocketClient { private let path: String private let responseTimeout: TimeInterval diff --git a/vendor/bonsplit b/vendor/bonsplit index 02fa188c..31c3810a 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795 +Subproject commit 31c3810a3411d792da6f60e5f5da3deca0b637e5