Merge pull request #1479 from manaflow-ai/feat-hidden-titlebar-minimalism-reset
Hide workspace titlebar chrome in minimal mode
This commit is contained in:
commit
e4c3d93e6c
13 changed files with 1946 additions and 29 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,30 @@ import Combine
|
|||
import ObjectiveC.runtime
|
||||
import Darwin
|
||||
|
||||
final class MainWindowHostingView<Content: View>: NSHostingView<Content> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<TerminalDirectoryOpenTarget>? = 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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ?? "<nil>"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<Bool> {
|
||||
Binding(
|
||||
get: { minimalModeEnabled },
|
||||
set: { newValue in
|
||||
workspacePresentationMode = newValue
|
||||
? WorkspacePresentationModeSettings.Mode.minimal.rawValue
|
||||
: WorkspacePresentationModeSettings.Mode.standard.rawValue
|
||||
SettingsWindowController.shared.preserveFocusAfterPreferenceMutation()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var settingsSidebarTintLightBinding: Binding<Color> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
603
cmuxUITests/BonsplitTabDragUITests.swift
Normal file
603
cmuxUITests/BonsplitTabDragUITests.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795
|
||||
Subproject commit 31c3810a3411d792da6f60e5f5da3deca0b637e5
|
||||
Loading…
Add table
Add a link
Reference in a new issue