diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index dda8dd41bff7..f69e84dd8bba 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -121,6 +121,7 @@ B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; B900001AA1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */; }; B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */; }; + F0F0A001A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F0A002A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift */; }; B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; }; B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; @@ -348,6 +349,7 @@ B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = ""; }; B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadUITests.swift; sourceTree = ""; }; B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; + F0F0A002A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSidebarScrollUITests.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = ""; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = ""; }; @@ -478,6 +480,7 @@ 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */, B8F266276A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift */, + F0F0A002A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift */, C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */, E6FA9085A1B2C3D4E5F60718 /* WorkspaceDescriptionUITests.swift */, D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */, @@ -1005,6 +1008,7 @@ B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */, B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */, B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */, + F0F0A001A1B2C3D4E5F60718 /* WorkspaceSidebarScrollUITests.swift in Sources */, B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */, B8F266266A1A3D9A45BD840F /* DisplayResolutionRegressionUITests.swift in Sources */, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index bcfe0395eb5d..a4066263cce6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9126,6 +9126,8 @@ struct VerticalTabsSidebar: View { @State private var dropIndicator: SidebarDropIndicator? @State private var frozenTabItemPresentation: SidebarTabItemPresentationSnapshot? @State private var terminalScrollBarVisibilityGeneration: UInt64 = 0 + @State private var laidOutWorkspaceRowIds: Set = [] + @State private var pendingSelectedWorkspaceScrollId: UUID? @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @@ -9134,6 +9136,10 @@ struct VerticalTabsSidebar: View { private let tabRowSpacing: CGFloat = 2 private let hiddenTitlebarControlsLeadingInset: CGFloat = 72 + private var workspaceScrollTopVisibilityInset: CGFloat { + trafficLightPadding + 8 + } + private var isMinimalMode: Bool { WorkspacePresentationModeSettings.mode(for: workspacePresentationMode) == .minimal } @@ -9147,6 +9153,58 @@ struct VerticalTabsSidebar: View { return KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) } + private func requestSelectedWorkspaceScroll(_ proxy: ScrollViewProxy, workspaceIds: [UUID]) { + guard let selectedWorkspaceId = tabManager.selectedTabId, + workspaceIds.contains(selectedWorkspaceId) else { + pendingSelectedWorkspaceScrollId = nil + return + } + + pendingSelectedWorkspaceScrollId = selectedWorkspaceId + flushPendingSelectedWorkspaceScroll(proxy) + } + + private func flushPendingSelectedWorkspaceScroll( + _ proxy: ScrollViewProxy, + laidOutWorkspaceRowIds: Set? = nil + ) { + guard let selectedWorkspaceId = pendingSelectedWorkspaceScrollId else { return } + let rowIds = laidOutWorkspaceRowIds ?? self.laidOutWorkspaceRowIds + guard rowIds.contains(selectedWorkspaceId) else { return } + + // No anchor means SwiftUI scrolls the minimum needed to reveal the row. + proxy.scrollTo(selectedWorkspaceId) + pendingSelectedWorkspaceScrollId = nil + } + + private func shouldRequestSelectedWorkspaceScrollAfterWorkspaceIdsChange( + from oldWorkspaceIds: [UUID], + to newWorkspaceIds: [UUID] + ) -> Bool { + guard let selectedWorkspaceId = tabManager.selectedTabId else { + return false + } + return !oldWorkspaceIds.contains(selectedWorkspaceId) && newWorkspaceIds.contains(selectedWorkspaceId) + } + + private struct WorkspaceListRenderContext { + let tabs: [Workspace] + let workspaceCount: Int + let canCloseWorkspace: Bool + let workspaceNumberShortcut: StoredShortcut + let tabItemSettings: SidebarTabItemSettingsSnapshot + let tabIndexById: [UUID: Int] + let selectedContextTargetIds: [UUID] + let selectedRemoteContextMenuWorkspaceIds: [UUID] + let allSelectedRemoteContextMenuTargetsConnecting: Bool + let allSelectedRemoteContextMenuTargetsDisconnected: Bool + let workspaceTerminalScrollBarHiddenById: [UUID: Bool] + + var workspaceIds: [UUID] { + tabs.map(\.id) + } + } + var body: some View { let _ = terminalScrollBarVisibilityGeneration let tabs = tabManager.tabs @@ -9168,135 +9226,22 @@ struct VerticalTabsSidebar: View { selectedRemoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .connecting } let allSelectedRemoteContextMenuTargetsDisconnected = !selectedRemoteContextMenuTargets.isEmpty && selectedRemoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .disconnected } + let renderContext = WorkspaceListRenderContext( + tabs: tabs, + workspaceCount: workspaceCount, + canCloseWorkspace: canCloseWorkspace, + workspaceNumberShortcut: workspaceNumberShortcut, + tabItemSettings: tabItemSettings, + tabIndexById: tabIndexById, + selectedContextTargetIds: selectedContextTargetIds, + selectedRemoteContextMenuWorkspaceIds: selectedRemoteContextMenuWorkspaceIds, + allSelectedRemoteContextMenuTargetsConnecting: allSelectedRemoteContextMenuTargetsConnecting, + allSelectedRemoteContextMenuTargetsDisconnected: allSelectedRemoteContextMenuTargetsDisconnected, + workspaceTerminalScrollBarHiddenById: workspaceTerminalScrollBarHiddenById + ) VStack(spacing: 0) { - GeometryReader { proxy in - ScrollView { - VStack(spacing: 0) { - // Space for traffic lights / fullscreen controls - Spacer() - .frame(height: trafficLightPadding) - - // Workspaces are bounded, so prefer a non-lazy stack here. - // LazyVStack + drag-state invalidations can recurse through layout. - VStack(spacing: tabRowSpacing) { - ForEach(tabs, id: \.id) { tab in - let index = tabIndexById[tab.id] ?? 0 - let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) - let contextMenuWorkspaceIds = usesSelectedContextMenuTargets - ? selectedContextTargetIds - : [tab.id] - let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets - ? selectedRemoteContextMenuWorkspaceIds - : (tab.isRemoteWorkspace ? [tab.id] : []) - let allRemoteContextMenuTargetsConnecting = usesSelectedContextMenuTargets - ? allSelectedRemoteContextMenuTargetsConnecting - : (tab.isRemoteWorkspace && tab.remoteConnectionState == .connecting) - let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets - ? allSelectedRemoteContextMenuTargetsDisconnected - : (tab.isRemoteWorkspace && tab.remoteConnectionState == .disconnected) - let allContextMenuWorkspacesHideTerminalScrollBar = !contextMenuWorkspaceIds.isEmpty && - contextMenuWorkspaceIds.allSatisfy { workspaceId in - workspaceTerminalScrollBarHiddenById[workspaceId] == true - } - let liveUnreadCount = notificationStore.unreadCount(forTabId: tab.id) - let liveLatestNotificationText: String? = { - guard showsSidebarNotificationMessage, - let notification = notificationStore.latestNotification(forTabId: tab.id) else { - return nil - } - let text = notification.body.isEmpty ? notification.title : notification.body - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }() - let liveShowsModifierShortcutHints = modifierKeyMonitor.isModifierPressed - let livePresentation = SidebarTabItemPresentationSnapshot( - tabId: tab.id, - unreadCount: liveUnreadCount, - latestNotificationText: liveLatestNotificationText, - showsModifierShortcutHints: liveShowsModifierShortcutHints - ) - let frozenPresentation = frozenTabItemPresentation?.tabId == tab.id - ? frozenTabItemPresentation - : nil - TabItemView( - tabManager: tabManager, - notificationStore: notificationStore, - tab: tab, - index: index, - isActive: tabManager.selectedTabId == tab.id, - workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( - at: index, - workspaceCount: workspaceCount - ), - workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, - canCloseWorkspace: canCloseWorkspace, - accessibilityWorkspaceCount: workspaceCount, - unreadCount: frozenPresentation?.unreadCount ?? liveUnreadCount, - latestNotificationText: frozenPresentation?.latestNotificationText ?? liveLatestNotificationText, - rowSpacing: tabRowSpacing, - setSelectionToTabs: { selection = .tabs }, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - showsModifierShortcutHints: frozenPresentation?.showsModifierShortcutHints ?? liveShowsModifierShortcutHints, - dragAutoScrollController: dragAutoScrollController, - draggedTabId: $draggedTabId, - dropIndicator: $dropIndicator, - contextMenuWorkspaceIds: contextMenuWorkspaceIds, - remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, - allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, - allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, - allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, - settings: tabItemSettings, - livePresentation: livePresentation, - frozenPresentation: $frozenTabItemPresentation - ) - .equatable() - } - } - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - - SidebarEmptyArea( - rowSpacing: tabRowSpacing, - selection: $selection, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dragAutoScrollController: dragAutoScrollController, - draggedTabId: $draggedTabId, - dropIndicator: $dropIndicator - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .frame(minHeight: proxy.size.height, alignment: .top) - } - .background( - SidebarScrollViewResolver { scrollView in - dragAutoScrollController.attach(scrollView: scrollView) - } - .frame(width: 0, height: 0) - ) - .overlay(alignment: .top) { - SidebarTopScrim(height: trafficLightPadding + 20) - .allowsHitTesting(false) - } - .overlay(alignment: .top) { - // Match native titlebar behavior in the sidebar top strip: - // drag-to-move and double-click action (zoom/minimize). - WindowDragHandleView() - .frame(height: trafficLightPadding) - .background(TitlebarDoubleClickMonitorView()) - } - .overlay(alignment: .topLeading) { - if isMinimalMode { - HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) - .padding(.leading, hiddenTitlebarControlsLeadingInset) - .padding(.top, 2) - } - } - .background(Color.clear) - .modifier(ClearScrollBackground()) - } + workspaceScrollArea(renderContext: renderContext) SidebarFooter(updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, onSendFeedback: onSendFeedback) .frame(maxWidth: .infinity, alignment: .leading) } @@ -9378,12 +9323,194 @@ struct VerticalTabsSidebar: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + private func workspaceScrollArea(renderContext: WorkspaceListRenderContext) -> some View { + GeometryReader { geometryProxy in + ScrollViewReader { scrollProxy in + ScrollView { + workspaceScrollContent( + renderContext: renderContext, + minHeight: geometryProxy.size.height + ) + } + .background( + SidebarScrollViewResolver { scrollView in + dragAutoScrollController.attach(scrollView: scrollView) + } + .frame(width: 0, height: 0) + ) + .safeAreaInset(edge: .top, spacing: 0) { + Color.clear + .frame(height: workspaceScrollTopVisibilityInset) + .allowsHitTesting(false) + } + .overlay(alignment: .top) { + SidebarTopScrim(height: trafficLightPadding + 20) + .allowsHitTesting(false) + } + .overlay(alignment: .top) { + // Match native titlebar behavior in the sidebar top strip: + // drag-to-move and double-click action (zoom/minimize). + WindowDragHandleView() + .frame(height: trafficLightPadding) + .background(TitlebarDoubleClickMonitorView()) + } + .overlay(alignment: .topLeading) { + if isMinimalMode { + HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) + .padding(.leading, hiddenTitlebarControlsLeadingInset) + .padding(.top, 2) + } + } + .background(Color.clear) + .modifier(ClearScrollBackground()) + .onAppear { + requestSelectedWorkspaceScroll(scrollProxy, workspaceIds: renderContext.workspaceIds) + } + .onChange(of: tabManager.selectedTabId) { _, _ in + requestSelectedWorkspaceScroll(scrollProxy, workspaceIds: renderContext.workspaceIds) + } + .onChange(of: renderContext.workspaceIds) { oldWorkspaceIds, newWorkspaceIds in + guard shouldRequestSelectedWorkspaceScrollAfterWorkspaceIdsChange( + from: oldWorkspaceIds, + to: newWorkspaceIds + ) else { return } + requestSelectedWorkspaceScroll(scrollProxy, workspaceIds: newWorkspaceIds) + } + .onPreferenceChange(SidebarWorkspaceRowIdsPreferenceKey.self) { rowIds in + laidOutWorkspaceRowIds = rowIds + flushPendingSelectedWorkspaceScroll(scrollProxy, laidOutWorkspaceRowIds: rowIds) + } + } + } + } + + private func workspaceScrollContent( + renderContext: WorkspaceListRenderContext, + minHeight: CGFloat + ) -> some View { + VStack(spacing: 0) { + workspaceRows(renderContext: renderContext) + + SidebarEmptyArea( + rowSpacing: tabRowSpacing, + selection: $selection, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + draggedTabId: $draggedTabId, + dropIndicator: $dropIndicator + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(minHeight: minHeight, alignment: .top) + } + + private func workspaceRows(renderContext: WorkspaceListRenderContext) -> some View { + // Workspaces are bounded, so prefer a non-lazy stack here. + // LazyVStack + drag-state invalidations can recurse through layout. + VStack(spacing: tabRowSpacing) { + ForEach(renderContext.tabs, id: \.id) { tab in + workspaceRow(tab, renderContext: renderContext) + } + } + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func workspaceRow( + _ tab: Workspace, + renderContext: WorkspaceListRenderContext + ) -> some View { + let index = renderContext.tabIndexById[tab.id] ?? 0 + let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) + let contextMenuWorkspaceIds = usesSelectedContextMenuTargets + ? renderContext.selectedContextTargetIds + : [tab.id] + let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets + ? renderContext.selectedRemoteContextMenuWorkspaceIds + : (tab.isRemoteWorkspace ? [tab.id] : []) + let allRemoteContextMenuTargetsConnecting = usesSelectedContextMenuTargets + ? renderContext.allSelectedRemoteContextMenuTargetsConnecting + : (tab.isRemoteWorkspace && tab.remoteConnectionState == .connecting) + let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets + ? renderContext.allSelectedRemoteContextMenuTargetsDisconnected + : (tab.isRemoteWorkspace && tab.remoteConnectionState == .disconnected) + let allContextMenuWorkspacesHideTerminalScrollBar = !contextMenuWorkspaceIds.isEmpty && + contextMenuWorkspaceIds.allSatisfy { workspaceId in + renderContext.workspaceTerminalScrollBarHiddenById[workspaceId] == true + } + let liveUnreadCount = notificationStore.unreadCount(forTabId: tab.id) + let liveLatestNotificationText: String? = { + guard showsSidebarNotificationMessage, + let notification = notificationStore.latestNotification(forTabId: tab.id) else { + return nil + } + let text = notification.body.isEmpty ? notification.title : notification.body + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }() + let liveShowsModifierShortcutHints = modifierKeyMonitor.isModifierPressed + let livePresentation = SidebarTabItemPresentationSnapshot( + tabId: tab.id, + unreadCount: liveUnreadCount, + latestNotificationText: liveLatestNotificationText, + showsModifierShortcutHints: liveShowsModifierShortcutHints + ) + let frozenPresentation = frozenTabItemPresentation?.tabId == tab.id + ? frozenTabItemPresentation + : nil + + return TabItemView( + tabManager: tabManager, + notificationStore: notificationStore, + tab: tab, + index: index, + isActive: tabManager.selectedTabId == tab.id, + workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( + at: index, + workspaceCount: renderContext.workspaceCount + ), + workspaceShortcutModifierSymbol: renderContext.workspaceNumberShortcut.numberedDigitHintPrefix, + canCloseWorkspace: renderContext.canCloseWorkspace, + accessibilityWorkspaceCount: renderContext.workspaceCount, + unreadCount: frozenPresentation?.unreadCount ?? liveUnreadCount, + latestNotificationText: frozenPresentation?.latestNotificationText ?? liveLatestNotificationText, + rowSpacing: tabRowSpacing, + setSelectionToTabs: { selection = .tabs }, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + showsModifierShortcutHints: frozenPresentation?.showsModifierShortcutHints ?? liveShowsModifierShortcutHints, + dragAutoScrollController: dragAutoScrollController, + draggedTabId: $draggedTabId, + dropIndicator: $dropIndicator, + contextMenuWorkspaceIds: contextMenuWorkspaceIds, + remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, + allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, + allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, + allContextMenuWorkspacesHideTerminalScrollBar: allContextMenuWorkspacesHideTerminalScrollBar, + settings: renderContext.tabItemSettings, + livePresentation: livePresentation, + frozenPresentation: $frozenTabItemPresentation + ) + .equatable() + .id(tab.id) + .preference(key: SidebarWorkspaceRowIdsPreferenceKey.self, value: Set([tab.id])) + } + private func debugShortSidebarTabId(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(5)) } } +private struct SidebarWorkspaceRowIdsPreferenceKey: PreferenceKey { + static let defaultValue: Set = [] + + static func reduce(value: inout Set, nextValue: () -> Set) { + value.formUnion(nextValue()) + } +} + enum ShortcutHintModifierPolicy { static let intentionalHoldDelay: TimeInterval = 0.30 diff --git a/cmuxUITests/WorkspaceSidebarScrollUITests.swift b/cmuxUITests/WorkspaceSidebarScrollUITests.swift new file mode 100644 index 000000000000..d72606dbe234 --- /dev/null +++ b/cmuxUITests/WorkspaceSidebarScrollUITests.swift @@ -0,0 +1,122 @@ +import XCTest + +final class WorkspaceSidebarScrollUITests: XCTestCase { + private let topTitlebarWorkspaceClearance: CGFloat = 32 + + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + func testWorkspaceSelectionKeepsSidebarRowVisible() { + let app = XCUIApplication() + configureLaunch(app) + launchAndEnsureRunning(app) + XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 8.0), "Expected a main window") + XCTAssertTrue( + waitForWorkspaceRowHittable(index: 1, count: 1, app: app, timeout: 8.0), + "Expected the initial workspace row to be visible" + ) + + let workspaceCount = 20 + for expectedCount in 2...workspaceCount { + app.typeKey("n", modifierFlags: [.command]) + XCTAssertTrue( + waitForWorkspaceRowHittable(index: expectedCount, count: expectedCount, app: app, timeout: 6.0), + "Expected the newly selected workspace \(expectedCount) to be visible" + ) + } + + XCTAssertTrue( + waitForWorkspaceRowHittable(index: workspaceCount, count: workspaceCount, app: app, timeout: 6.0), + "Expected the newly selected bottom workspace to be visible" + ) + + app.typeKey("1", modifierFlags: [.command]) + XCTAssertTrue( + waitForWorkspaceRowHittable(index: 1, count: workspaceCount, app: app, timeout: 6.0), + "Expected Cmd+1 to scroll the first workspace back into view" + ) + XCTAssertTrue( + waitForWorkspaceRowClearsTitlebar(index: 1, count: workspaceCount, app: app, timeout: 6.0), + "Expected Cmd+1 to keep the first workspace below the titlebar controls" + ) + } + + private func configureLaunch(_ app: XCUIApplication) { + app.launchArguments += ["-newWorkspacePlacement", "end"] + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_TAG"] = "ui-sidebar-scroll" + } + + private func waitForWorkspaceRowHittable( + index: Int, + count: Int, + app: XCUIApplication, + timeout: TimeInterval + ) -> Bool { + return pollUntil(timeout: timeout) { + let row = workspaceRow(index: index, count: count, app: app) + return row.exists && row.isHittable + } + } + + private func waitForWorkspaceRowClearsTitlebar( + index: Int, + count: Int, + app: XCUIApplication, + timeout: TimeInterval + ) -> Bool { + pollUntil(timeout: timeout) { + let row = workspaceRow(index: index, count: count, app: app) + let window = app.windows.firstMatch + guard row.exists, row.isHittable, window.exists else { return false } + return row.frame.minY >= window.frame.minY + topTitlebarWorkspaceClearance + } + } + + private func workspaceRow(index: Int, count: Int, app: XCUIApplication) -> XCUIElement { + let position = "workspace \(index) of \(count)" + return app.descendants(matching: .other) + .matching(NSPredicate(format: "label ENDSWITH %@", position)) + .firstMatch + } + + private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { + pollUntil(timeout: timeout) { + app.windows.count >= count + } + } + + private func launchAndEnsureRunning(_ app: XCUIApplication) { + let options = XCTExpectedFailure.Options() + options.isStrict = false + XCTExpectFailure("Headless CI may launch the app without foreground activation", options: options) { + app.launch() + } + XCTAssertTrue( + pollUntil(timeout: 10.0) { + app.state == .runningForeground || app.state == .runningBackground + }, + "App failed to launch. state=\(app.state.rawValue)" + ) + } + + private func pollUntil( + timeout: TimeInterval, + interval: TimeInterval = 0.05, + condition: () -> Bool + ) -> Bool { + let start = ProcessInfo.processInfo.systemUptime + while true { + if condition() { + return true + } + if ProcessInfo.processInfo.systemUptime - start >= timeout { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(interval)) + } + } +}