diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e7bb8bc..cd3dc3a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,15 @@ on: pull_request: jobs: + workflow-guard-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate self-hosted runner guards + run: ./tests/test_ci_self_hosted_guard.sh + web-typecheck: runs-on: ubuntu-latest defaults: @@ -26,6 +35,8 @@ jobs: run: bun tsc --noEmit ui-tests: + # Never run self-hosted jobs for fork pull requests. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted concurrency: group: self-hosted-build diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e200f251..a8ebeea4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -294,6 +294,19 @@ jobs: # by appcast URLs to prevent signature/asset mismatch races. cp "$DMG_RELEASE" "$NIGHTLY_DMG_IMMUTABLE" + - name: Upload dSYMs to Sentry + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: manaflow + SENTRY_PROJECT: cmuxterm-macos + run: | + if [ -z "$SENTRY_AUTH_TOKEN" ]; then + echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload" + exit 0 + fi + brew install getsentry/tools/sentry-cli || true + sentry-cli debug-files upload --include-sources build/Build/Products/Release/ + - name: Generate Sparkle appcast (nightly) env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3176697b..9063de75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -250,6 +250,20 @@ jobs: xcrun stapler staple "$DMG_RELEASE" xcrun stapler validate "$DMG_RELEASE" + - name: Upload dSYMs to Sentry + if: steps.guard_release_assets.outputs.skip_all != 'true' + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: manaflow + SENTRY_PROJECT: cmuxterm-macos + run: | + if [ -z "$SENTRY_AUTH_TOKEN" ]; then + echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload" + exit 0 + fi + brew install getsentry/tools/sentry-cli || true + sentry-cli debug-files upload --include-sources build/Build/Products/Release/ + - name: Generate Sparkle appcast if: steps.guard_release_assets.outputs.skip_all != 'true' env: diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d65e7ec1..0b100e14 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; + A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; @@ -149,6 +150,7 @@ A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; + A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; }; A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = ""; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; @@ -328,6 +330,7 @@ A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, + A5001600 /* SentryHelper.swift */, A5001090 /* AppDelegate.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, @@ -560,6 +563,7 @@ A5001007 /* TerminalController.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, + A5001601 /* SentryHelper.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9c8ad174..4e31697b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -36,6 +36,188 @@ enum FinderServicePathResolver { } } +enum TerminalDirectoryOpenTarget: String, CaseIterable { + case vscode + case cursor + case windsurf + case antigravity + case finder + case terminal + case iterm2 + case ghostty + case warp + case xcode + case androidStudio + case zed + + struct DetectionEnvironment { + let homeDirectoryPath: String + let fileExistsAtPath: (String) -> Bool + + static let live = DetectionEnvironment( + homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, + fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) } + ) + } + + static var commandPaletteShortcutTargets: [Self] { + Array(allCases) + } + + static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { + Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) + } + + static let cachedLiveAvailableTargets: Set = availableTargets(in: .live) + + var commandPaletteCommandId: String { + "palette.terminalOpenDirectory.\(rawValue)" + } + + var commandPaletteTitle: String { + switch self { + case .vscode: + return "Open Current Directory in VS Code" + case .cursor: + return "Open Current Directory in Cursor" + case .windsurf: + return "Open Current Directory in Windsurf" + case .antigravity: + return "Open Current Directory in Antigravity" + case .finder: + return "Open Current Directory in Finder" + case .terminal: + return "Open Current Directory in Terminal" + case .iterm2: + return "Open Current Directory in iTerm2" + case .ghostty: + return "Open Current Directory in Ghostty" + case .warp: + return "Open Current Directory in Warp" + case .xcode: + return "Open Current Directory in Xcode" + case .androidStudio: + return "Open Current Directory in Android Studio" + case .zed: + return "Open Current Directory in Zed" + } + } + + var commandPaletteKeywords: [String] { + let common = ["terminal", "directory", "open", "ide"] + switch self { + case .vscode: + return common + ["vs", "code", "visual", "studio"] + case .cursor: + return common + ["cursor"] + case .windsurf: + return common + ["windsurf"] + case .antigravity: + return common + ["antigravity"] + case .finder: + return common + ["finder", "file", "manager", "reveal"] + case .terminal: + return common + ["terminal", "shell"] + case .iterm2: + return common + ["iterm", "iterm2", "terminal", "shell"] + case .ghostty: + return common + ["ghostty", "terminal", "shell"] + case .warp: + return common + ["warp", "terminal", "shell"] + case .xcode: + return common + ["xcode", "apple"] + case .androidStudio: + return common + ["android", "studio"] + case .zed: + return common + ["zed"] + } + } + + func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { + applicationPath(in: environment) != nil + } + + func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { + guard let path = applicationPath(in: environment) else { return nil } + return URL(fileURLWithPath: path, isDirectory: true) + } + + private func applicationPath(in environment: DetectionEnvironment) -> String? { + for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { + return path + } + return nil + } + + private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { + let globalPrefix = "/Applications/" + let userPrefix = "\(environment.homeDirectoryPath)/Applications/" + var expanded: [String] = [] + + for candidate in applicationBundlePathCandidates { + expanded.append(candidate) + if candidate.hasPrefix(globalPrefix) { + let suffix = String(candidate.dropFirst(globalPrefix.count)) + expanded.append(userPrefix + suffix) + } + } + + return uniquePreservingOrder(expanded) + } + + private var applicationBundlePathCandidates: [String] { + switch self { + case .vscode: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .cursor: + return [ + "/Applications/Cursor.app", + "/Applications/Cursor Preview.app", + "/Applications/Cursor Nightly.app", + ] + case .windsurf: + return ["/Applications/Windsurf.app"] + case .antigravity: + return ["/Applications/Antigravity.app"] + case .finder: + return ["/System/Library/CoreServices/Finder.app"] + case .terminal: + return ["/System/Applications/Utilities/Terminal.app"] + case .iterm2: + return [ + "/Applications/iTerm.app", + "/Applications/iTerm2.app", + ] + case .ghostty: + return ["/Applications/Ghostty.app"] + case .warp: + return ["/Applications/Warp.app"] + case .xcode: + return ["/Applications/Xcode.app"] + case .androidStudio: + return ["/Applications/Android Studio.app"] + case .zed: + return [ + "/Applications/Zed.app", + "/Applications/Zed Preview.app", + "/Applications/Zed Nightly.app", + ] + } + } + + private func uniquePreservingOrder(_ paths: [String]) -> [String] { + var seen: Set = [] + var deduped: [String] = [] + for path in paths where seen.insert(path).inserted { + deduped.append(path) + } + return deduped + } +} + enum WorkspaceShortcutMapper { /// Maps Cmd+digit workspace shortcuts to a zero-based workspace index. /// Cmd+1...Cmd+8 target fixed indices; Cmd+9 always targets the last workspace. @@ -192,6 +374,17 @@ func browserZoomShortcutAction( return nil } +func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: Bool, + hostedSize: CGSize, + hostedHiddenInHierarchy: Bool, + hostedAttachedToWindow: Bool +) -> Bool { + guard firstResponderIsWindow else { return false } + let tinyGeometry = hostedSize.width <= 1 || hostedSize.height <= 1 + return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow +} + func shouldRouteTerminalFontZoomShortcutToGhostty( firstResponderIsGhostty: Bool, flags: NSEvent.ModifierFlags, @@ -295,6 +488,29 @@ func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) } #endif +func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { + var candidate = hitView + while let view = candidate { + if view is DraggableFolderNSView { + return true + } + candidate = view.superview + } + return false +} + +func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + window.isMovable, + let contentView = window.contentView else { + return false + } + + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) +} + @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { static var shared: AppDelegate? @@ -404,6 +620,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } method_exchangeImplementations(originalMethod, swizzledMethod) }() + private static let didInstallWindowSendEventSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.sendEvent(_:)) + let swizzledSelector = #selector(NSWindow.cmux_sendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() #if DEBUG private var didSetupJumpUnreadUITest = false @@ -552,6 +778,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent options.debug = false #endif options.sendDefaultPii = true + + // Performance tracing (10% of transactions) + options.tracesSampleRate = 0.1 + // App hang timeout (default is 2s, be explicit) + options.appHangTimeoutInterval = 2.0 + // Attach stack traces to all events + options.attachStacktrace = true + // Capture failed HTTP requests + options.enableCaptureFailedRequests = true } if !isRunningUnderXCTest { @@ -657,6 +892,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif func applicationDidBecomeActive(_ notification: Notification) { + sentryBreadcrumb("app.didBecomeActive", category: "lifecycle", data: [ + "tabCount": tabManager?.tabs.count ?? 0 + ]) let env = ProcessInfo.processInfo.environment if !isRunningUnderXCTest(env) { PostHogAnalytics.shared.trackDailyActive(reason: "didBecomeActive") @@ -2107,6 +2345,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = false + window.isMovable = false if let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) { window.setFrame(restoredFrame, display: false) } else { @@ -2879,6 +3118,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent static func installWindowResponderSwizzlesForTesting() { _ = didInstallWindowKeyEquivalentSwizzle _ = didInstallWindowFirstResponderSwizzle + _ = didInstallWindowSendEventSwizzle } #if DEBUG @@ -2896,6 +3136,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func installWindowResponderSwizzles() { _ = Self.didInstallWindowKeyEquivalentSwizzle _ = Self.didInstallWindowFirstResponderSwizzle + _ = Self.didInstallWindowSendEventSwizzle } private func installShortcutMonitor() { @@ -3530,6 +3771,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") #endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { + return true + } _ = performSplitShortcut(direction: .right) return true } @@ -3538,6 +3782,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") #endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { + return true + } _ = performSplitShortcut(direction: .down) return true } @@ -3672,6 +3919,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { + guard let tabManager, + let workspace = tabManager.selectedWorkspace, + let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { + return false + } + + let hostedView = terminalPanel.hostedView + let hostedSize = hostedView.bounds.size + let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor + let hostedAttachedToWindow = hostedView.window != nil + let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow + + let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: firstResponderIsWindow, + hostedSize: hostedSize, + hostedHiddenInHierarchy: hostedHiddenInHierarchy, + hostedAttachedToWindow: hostedAttachedToWindow + ) + guard shouldSuppress else { return false } + + tabManager.reconcileFocusedPanelFromFirstResponderForKeyboard() + +#if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + + "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + + "attached=\(hostedAttachedToWindow ? 1 : 0) " + + "frame=\(String(format: "%.1fx%.1f", hostedSize.width, hostedSize.height))" + ) +#endif + return true + } + #if DEBUG private func logBrowserZoomShortcutTrace( stage: String, @@ -5419,6 +5709,36 @@ private extension NSWindow { return cmux_makeFirstResponder(responder) } + @objc func cmux_sendEvent(_ event: NSEvent) { + guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), + let contentView = self.contentView else { + cmux_sendEvent(event) + return + } + + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + let previousMovableState = isMovable + if previousMovableState { + isMovable = false + } + + #if DEBUG + let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" + dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") + #endif + + cmux_sendEvent(event) + + if previousMovableState { + isMovable = previousMovableState + } + + #if DEBUG + dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") + #endif + } + @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6b6546bf..a88e5bd9 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -984,14 +984,6 @@ private func commandPaletteWindowOverlayController(for window: NSWindow) -> Wind return controller } -private struct CommandPaletteRowFramePreferenceKey: PreferenceKey { - static var defaultValue: [Int: CGRect] = [:] - - static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) { - value.merge(nextValue(), uniquingKeysWith: { _, rhs in rhs }) - } -} - enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 @@ -1127,8 +1119,8 @@ struct ContentView: View { @State private var commandPaletteRenameDraft: String = "" @State private var commandPaletteSelectedResultIndex: Int = 0 @State private var commandPaletteHoveredResultIndex: Int? - @State private var commandPaletteLastSelectionIndex: Int = 0 - @State private var commandPaletteRowFrames: [Int: CGRect] = [:] + @State private var commandPaletteScrollTargetIndex: Int? + @State private var commandPaletteScrollTargetAnchor: UnitPoint? @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) @@ -1204,11 +1196,6 @@ struct ContentView: View { case kind } - enum CommandPaletteScrollAnchor: Equatable { - case top - case bottom - } - private struct CommandPaletteTrailingLabel { let text: String let style: CommandPaletteTrailingLabelStyle @@ -1284,6 +1271,10 @@ struct ContentView: View { static let panelHasUnread = "panel.hasUnread" static let updateHasAvailable = "update.hasAvailable" + + static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { + "terminal.openTarget.\(target.rawValue).available" + } } private struct CommandPaletteCommandContribution { @@ -1757,6 +1748,7 @@ struct ContentView: View { WindowDragHandleView() TitlebarLeadingInsetReader(inset: $titlebarLeadingInset) + .allowsHitTesting(false) HStack(spacing: 8) { if isFullScreen && !sidebarState.isVisible { @@ -1772,6 +1764,7 @@ struct ContentView: View { .font(.system(size: 13, weight: .bold)) .foregroundColor(fakeTitlebarTextColor) .lineLimit(1) + .allowsHitTesting(false) Spacer() @@ -1784,9 +1777,6 @@ struct ContentView: View { .frame(height: titlebarPadding) .frame(maxWidth: .infinity) .contentShape(Rectangle()) - .onTapGesture(count: 2) { - NSApp.keyWindow?.zoom(nil) - } .background(fakeTitlebarBackground) .overlay(alignment: .bottom) { Rectangle() @@ -2231,6 +2221,9 @@ struct ContentView: View { // Do not make the entire background draggable; it interferes with drag gestures // like sidebar tab reordering in multi-window mode. window.isMovableByWindowBackground = false + // Keep the window immovable by default so titlebar controls (like the folder icon) + // cannot accidentally initiate native window drags. + window.isMovable = false window.styleMask.insert(.fullSizeContentView) // Track this window for fullscreen notifications @@ -2487,7 +2480,7 @@ struct ContentView: View { private var commandPaletteCommandListView: some View { let visibleResults = Array(commandPaletteResults) let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - let commandPaletteListMaxHeight: CGFloat = 216 + let commandPaletteListMaxHeight: CGFloat = 450 let commandPaletteRowHeight: CGFloat = 24 let commandPaletteEmptyStateHeight: CGFloat = 44 let commandPaletteListContentHeight = visibleResults.isEmpty @@ -2531,133 +2524,85 @@ struct ContentView: View { Divider() - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0) { - if visibleResults.isEmpty { - Text(commandPaletteEmptyStateText) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 12) - } else { - ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in - let isSelected = index == selectedIndex - let isHovered = commandPaletteHoveredResultIndex == index - let rowBackground: Color = isSelected - ? Color.accentColor.opacity(0.12) - : (isHovered ? Color.primary.opacity(0.08) : .clear) + ScrollView { + LazyVStack(spacing: 0) { + if visibleResults.isEmpty { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in + let isSelected = index == selectedIndex + let isHovered = commandPaletteHoveredResultIndex == index + let rowBackground: Color = isSelected + ? Color.accentColor.opacity(0.12) + : (isHovered ? Color.primary.opacity(0.08) : .clear) - Button { - runCommandPaletteCommand(result.command) - } label: { - HStack(spacing: 8) { - commandPaletteHighlightedTitleText( - result.command.title, - matchedIndices: result.titleMatchIndices - ) - .font(.system(size: 13, weight: .regular)) - .lineLimit(1) - Spacer() - - if let trailingLabel = commandPaletteTrailingLabel(for: result.command) { - switch trailingLabel.style { - case .shortcut: - Text(trailingLabel.text) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) - case .kind: - Text(trailingLabel.text) - .font(.system(size: 11, weight: .regular)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - .padding(.horizontal, 9) - .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) - .background(rowBackground) - .background( - GeometryReader { geometry in - Color.clear.preference( - key: CommandPaletteRowFramePreferenceKey.self, - value: [index: geometry.frame(in: .named("commandPaletteListScroll"))] - ) - } + Button { + runCommandPaletteCommand(result.command) + } label: { + HStack(spacing: 8) { + commandPaletteHighlightedTitleText( + result.command.title, + matchedIndices: result.titleMatchIndices ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .id(index) - .onHover { hovering in - if hovering { - commandPaletteHoveredResultIndex = index - } else if commandPaletteHoveredResultIndex == index { - commandPaletteHoveredResultIndex = nil + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + Spacer() + + if let trailingLabel = commandPaletteTrailingLabel(for: result.command) { + switch trailingLabel.style { + case .shortcut: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + case .kind: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } + .padding(.horizontal, 9) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .id(index) + .onHover { hovering in + if hovering { + commandPaletteHoveredResultIndex = index + } else if commandPaletteHoveredResultIndex == index { + commandPaletteHoveredResultIndex = nil + } } } } - // Force a fresh row tree per query so rendered labels/actions stay in lockstep. - .id(commandPaletteQuery) - } - .coordinateSpace(name: "commandPaletteListScroll") - .frame(height: commandPaletteListHeight) - .onChange(of: commandPaletteSelectedResultIndex) { _ in - guard !visibleResults.isEmpty else { return } - let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) - let previousIndex = commandPaletteLastSelectionIndex - defer { commandPaletteLastSelectionIndex = index } - - guard let anchorDecision = Self.commandPaletteScrollAnchor( - selectedIndex: index, - previousIndex: previousIndex, - resultCount: visibleResults.count, - selectedFrame: commandPaletteRowFrames[index], - viewportHeight: commandPaletteListHeight, - contentHeight: commandPaletteListContentHeight - ) else { return } - - let anchor: UnitPoint - switch anchorDecision { - case .top: - anchor = .top - case .bottom: - anchor = .bottom - } - DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.1)) { - proxy.scrollTo(index, anchor: anchor) - } - } - } - .onChange(of: visibleResults.count) { _ in - commandPaletteLastSelectionIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - } - .onPreferenceChange(CommandPaletteRowFramePreferenceKey.self) { frames in - commandPaletteRowFrames = frames - guard !visibleResults.isEmpty else { return } - let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) - guard let anchorDecision = Self.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: index, - resultCount: visibleResults.count, - selectedFrame: frames[index], - viewportHeight: commandPaletteListHeight, - contentHeight: commandPaletteListContentHeight - ) else { return } - let anchor: UnitPoint = anchorDecision == .top ? .top : .bottom - DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.08)) { - proxy.scrollTo(index, anchor: anchor) - } - } } + .scrollTargetLayout() + // Force a fresh row tree per query so rendered labels/actions stay in lockstep. + .id(commandPaletteQuery) + } + .frame(height: commandPaletteListHeight) + .scrollPosition( + id: Binding( + get: { commandPaletteScrollTargetIndex }, + // Ignore passive readback so manual scrolling doesn't mutate selection-follow state. + set: { _ in } + ), + anchor: commandPaletteScrollTargetAnchor + ) + .onChange(of: commandPaletteSelectedResultIndex) { _ in + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: true) } // Keep Esc-to-close behavior without showing footer controls. @@ -2672,20 +2617,19 @@ struct ContentView: View { } .onAppear { commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex - commandPaletteRowFrames = [:] + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) resetCommandPaletteSearchFocus() } .onChange(of: commandPaletteQuery) { _ in commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil syncCommandPaletteDebugStateForObservedWindow() } .onChange(of: visibleResults.count) { _ in commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count { commandPaletteHoveredResultIndex = nil } @@ -3288,18 +3232,29 @@ struct ContentView: View { if let panelContext = focusedPanelContext { let workspace = panelContext.workspace let panelId = panelContext.panelId + let panelIsTerminal = panelContext.panel.panelType == .terminal snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true) snapshot.setString( CommandPaletteContextKeys.panelName, panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle) ) snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) - snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelContext.panel.panelType == .terminal) + snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) + + if panelIsTerminal { + let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + snapshot.setBool( + CommandPaletteContextKeys.terminalOpenTargetAvailable(target), + availableTargets.contains(target) + ) + } + } } if case .updateAvailable = updateViewModel.effectiveState { @@ -3710,15 +3665,20 @@ struct ContentView: View { ) ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.terminalOpenDirectory", - title: constant("Open Current Directory in IDE"), - subtitle: terminalPanelSubtitle, - keywords: ["terminal", "directory", "open", "ide", "code", "default app"], - when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + contributions.append( + CommandPaletteCommandContribution( + commandId: target.commandPaletteCommandId, + title: constant(target.commandPaletteTitle), + subtitle: terminalPanelSubtitle, + keywords: target.commandPaletteKeywords, + when: { context in + context.bool(CommandPaletteContextKeys.panelIsTerminal) + && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(target)) + } + ) ) - ) + } contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalFind", @@ -3981,9 +3941,11 @@ struct ContentView: View { _ = tabManager.createBrowserSplit(direction: .right, url: url) } - registry.register(commandId: "palette.terminalOpenDirectory") { - if !openFocusedDirectoryInDefaultApp() { - NSSound.beep() + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + registry.register(commandId: target.commandPaletteCommandId) { + if !openFocusedDirectory(in: target) { + NSSound.beep() + } } } registry.register(commandId: "palette.terminalFind") { @@ -4047,61 +4009,43 @@ struct ContentView: View { return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1) } - static func commandPaletteScrollAnchor( + static func commandPaletteScrollPositionAnchor( selectedIndex: Int, - previousIndex: Int, - resultCount: Int, - selectedFrame: CGRect?, - viewportHeight: CGFloat, - contentHeight: CGFloat, - epsilon: CGFloat = 0.5 - ) -> CommandPaletteScrollAnchor? { + resultCount: Int + ) -> UnitPoint? { guard resultCount > 0 else { return nil } - guard contentHeight > viewportHeight else { return nil } - - // Always pin edges exactly into view when selection reaches first/last. if selectedIndex <= 0 { - return .top + return UnitPoint.top } if selectedIndex >= resultCount - 1 { - return .bottom + return UnitPoint.bottom } - - if let frame = selectedFrame, - frame.minY >= (0 - epsilon), - frame.maxY <= (viewportHeight + epsilon) { - return nil - } - - return selectedIndex >= previousIndex ? .bottom : .top + return nil } - static func commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: Int, - resultCount: Int, - selectedFrame: CGRect?, - viewportHeight: CGFloat, - contentHeight: CGFloat, - epsilon: CGFloat = 0.5 - ) -> CommandPaletteScrollAnchor? { - guard resultCount > 0 else { return nil } - guard contentHeight > viewportHeight else { return nil } - - let isTop = selectedIndex <= 0 - let isBottom = selectedIndex >= (resultCount - 1) - guard isTop || isBottom else { return nil } - - guard let frame = selectedFrame else { - return isTop ? .top : .bottom + private func updateCommandPaletteScrollTarget(resultCount: Int, animated: Bool) { + guard resultCount > 0 else { + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + return } - if isTop { - let topDelta = abs(frame.minY) - return topDelta > epsilon ? .top : nil - } + let selectedIndex = commandPaletteSelectedIndex(resultCount: resultCount) + commandPaletteScrollTargetAnchor = Self.commandPaletteScrollPositionAnchor( + selectedIndex: selectedIndex, + resultCount: resultCount + ) - let bottomDelta = abs(frame.maxY - viewportHeight) - return bottomDelta > epsilon ? .bottom : nil + let assignTarget = { + commandPaletteScrollTargetIndex = selectedIndex + } + if animated { + withAnimation(.easeOut(duration: 0.1)) { + assignTarget() + } + } else { + assignTarget() + } } private func moveCommandPaletteSelection(by delta: Int) { @@ -4295,8 +4239,8 @@ struct ContentView: View { commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil resetCommandPaletteSearchFocus() syncCommandPaletteDebugStateForObservedWindow() } @@ -4309,8 +4253,8 @@ struct ContentView: View { commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil isCommandPaletteSearchFocused = false isCommandPaletteRenameFocused = false commandPaletteRestoreFocusTarget = nil @@ -4537,9 +4481,22 @@ struct ContentView: View { return NSWorkspace.shared.open(url) } - private func openFocusedDirectoryInDefaultApp() -> Bool { + private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool { guard let directoryURL = focusedTerminalDirectoryURL() else { return false } - return NSWorkspace.shared.open(directoryURL) + return openFocusedDirectory(directoryURL, in: target) + } + + private func openFocusedDirectory(_ directoryURL: URL, in target: TerminalDirectoryOpenTarget) -> Bool { + switch target { + case .finder: + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path) + return true + default: + guard let applicationURL = target.applicationURL() else { return false } + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([directoryURL], withApplicationAt: applicationURL, configuration: configuration) + return true + } } private func focusedTerminalDirectoryURL() -> URL? { @@ -7417,9 +7374,21 @@ private struct DraggableFolderIconRepresentable: NSViewRepresentable { } } -private final class DraggableFolderNSView: NSView, NSDraggingSource { +final class DraggableFolderNSView: NSView, NSDraggingSource { + private final class FolderIconImageView: NSImageView { + override var mouseDownCanMoveWindow: Bool { false } + } + var directory: String - private var imageView: NSImageView! + private var imageView: FolderIconImageView! + private var previousWindowMovableState: Bool? + private weak var suppressedWindow: NSWindow? + private var hasActiveDragSession = false + private var didArmWindowDragSuppression = false + + private func formatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) + } init(directory: String) { self.directory = directory @@ -7435,8 +7404,10 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { NSSize(width: 16, height: 16) } + override var mouseDownCanMoveWindow: Bool { false } + private func setupImageView() { - imageView = NSImageView() + imageView = FolderIconImageView() imageView.imageScaling = .scaleProportionallyDown imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) @@ -7461,9 +7432,40 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { return context == .outsideApplication ? [.copy, .link] : .copy } - override func mouseDown(with event: NSEvent) { + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + hasActiveDragSession = false + restoreWindowMovableStateIfNeeded() #if DEBUG - dlog("folder.dragStart dir=\(directory)") + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + #endif + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + maybeDisableWindowDraggingEarly(trigger: "hitTest") + let hit = super.hitTest(point) + #if DEBUG + let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" + let imageHit = (hit === imageView) + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") + #endif + return self + } + + override func mouseDown(with event: NSEvent) { + maybeDisableWindowDraggingEarly(trigger: "mouseDown") + hasActiveDragSession = false + #if DEBUG + let localPoint = convert(event.locationInWindow, from: nil) + let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") #endif let fileURL = URL(fileURLWithPath: directory) let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) @@ -7472,7 +7474,19 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { iconImage.size = NSSize(width: 32, height: 32) draggingItem.setDraggingFrame(bounds, contents: iconImage) - beginDraggingSession(with: [draggingItem], event: event, source: self) + let session = beginDraggingSession(with: [draggingItem], event: event, source: self) + hasActiveDragSession = true + #if DEBUG + let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 + dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") + #endif + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + if !hasActiveDragSession { + restoreWindowMovableStateIfNeeded() + } } override func rightMouseDown(with event: NSEvent) { @@ -7541,6 +7555,59 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource { // Open "Computer" view in Finder (shows all volumes) NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) } + + private func restoreWindowMovableStateIfNeeded() { + guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } + let targetWindow = suppressedWindow ?? window + let depthAfter = endWindowDragSuppression(window: targetWindow) + restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) + self.previousWindowMovableState = nil + self.suppressedWindow = nil + self.didArmWindowDragSuppression = false + #if DEBUG + let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" + dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") + #endif + } + + private func maybeDisableWindowDraggingEarly(trigger: String) { + guard !didArmWindowDragSuppression else { return } + guard let eventType = NSApp.currentEvent?.type, + eventType == .leftMouseDown || eventType == .leftMouseDragged else { + return + } + guard let currentWindow = window else { return } + + didArmWindowDragSuppression = true + suppressedWindow = currentWindow + let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 + if currentWindow.isMovable { + previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) + } else { + previousWindowMovableState = nil + } + #if DEBUG + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = String(currentWindow.isMovable) + dlog( + "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" + ) + #endif + } +} + +func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { + guard let window else { return nil } + let wasMovable = window.isMovable + if wasMovable { + window.isMovable = false + } + return wasMovable +} + +func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { + guard let window, let previousMovableState else { return } + window.isMovable = previousMovableState } /// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested @@ -7622,11 +7689,16 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable { /// Reads the leading inset required to clear traffic lights + left titlebar accessories. +final class TitlebarLeadingInsetPassthroughView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + private struct TitlebarLeadingInsetReader: NSViewRepresentable { @Binding var inset: CGFloat func makeNSView(context: Context) -> NSView { - let view = NSView() + let view = TitlebarLeadingInsetPassthroughView() view.setFrameSize(.zero) return view } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index ac19b086..f91b71e8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -166,6 +166,35 @@ private extension View { } } +func resolvedBrowserChromeBackgroundColor( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> NSColor { + switch colorScheme { + case .dark, .light: + return themeBackgroundColor + @unknown default: + return themeBackgroundColor + } +} + +func resolvedBrowserOmnibarPillBackgroundColor( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> NSColor { + let darkenMix: CGFloat + switch colorScheme { + case .light: + darkenMix = 0.04 + case .dark: + darkenMix = 0.05 + @unknown default: + darkenMix = 0.04 + } + + return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor +} + /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel @@ -239,14 +268,17 @@ struct BrowserPanelView: View { } private var browserChromeBackgroundColor: NSColor { - switch colorScheme { - case .dark: - return GhosttyApp.shared.defaultBackgroundColor - case .light: - return .windowBackgroundColor - @unknown default: - return .windowBackgroundColor - } + resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor + ) + } + + private var omnibarPillBackgroundColor: NSColor { + resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: browserChromeBackgroundColor + ) } var body: some View { @@ -278,7 +310,7 @@ struct BrowserPanelView: View { } ) .frame(width: omnibarPillFrame.width) - .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6) + .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3) .zIndex(1000) } } @@ -648,7 +680,7 @@ struct BrowserPanelView: View { .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) + .fill(Color(nsColor: omnibarPillBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) @@ -2594,11 +2626,12 @@ private struct OmnibarSuggestionsView: View { let searchSuggestionsEnabled: Bool let onCommit: (OmnibarSuggestion) -> Void let onHighlight: (Int) -> Void + @Environment(\.colorScheme) private var colorScheme - // Keep radii below the smallest rendered heights so corners don't get - // auto-clamped and visually change as popup height changes. - private let popupCornerRadius: CGFloat = 16 - private let rowHighlightCornerRadius: CGFloat = 12 + // Keep radii below half of the smallest rendered heights so this keeps a + // squircle silhouette instead of auto-clamping into a capsule. + private let popupCornerRadius: CGFloat = 12 + private let rowHighlightCornerRadius: CGFloat = 9 private let singleLineRowHeight: CGFloat = 24 private let rowSpacing: CGFloat = 1 private let topInset: CGFloat = 3 @@ -2651,6 +2684,101 @@ private struct OmnibarSuggestionsView: View { contentHeight > maxPopupHeight } + private var listTextColor: Color { + switch colorScheme { + case .light: + return Color(nsColor: .labelColor) + case .dark: + return Color.white.opacity(0.9) + @unknown default: + return Color(nsColor: .labelColor) + } + } + + private var badgeTextColor: Color { + switch colorScheme { + case .light: + return Color(nsColor: .secondaryLabelColor) + case .dark: + return Color.white.opacity(0.72) + @unknown default: + return Color(nsColor: .secondaryLabelColor) + } + } + + private var badgeBackgroundColor: Color { + switch colorScheme { + case .light: + return Color.black.opacity(0.06) + case .dark: + return Color.white.opacity(0.08) + @unknown default: + return Color.black.opacity(0.06) + } + } + + private var rowHighlightColor: Color { + switch colorScheme { + case .light: + return Color.black.opacity(0.07) + case .dark: + return Color.white.opacity(0.12) + @unknown default: + return Color.black.opacity(0.07) + } + } + + private var popupOverlayGradientColors: [Color] { + switch colorScheme { + case .light: + return [ + Color.white.opacity(0.55), + Color.white.opacity(0.2), + ] + case .dark: + return [ + Color.black.opacity(0.26), + Color.black.opacity(0.14), + ] + @unknown default: + return [ + Color.white.opacity(0.55), + Color.white.opacity(0.2), + ] + } + } + + private var popupBorderGradientColors: [Color] { + switch colorScheme { + case .light: + return [ + Color.white.opacity(0.65), + Color.black.opacity(0.12), + ] + case .dark: + return [ + Color.white.opacity(0.22), + Color.white.opacity(0.06), + ] + @unknown default: + return [ + Color.white.opacity(0.65), + Color.black.opacity(0.12), + ] + } + } + + private var popupShadowColor: Color { + switch colorScheme { + case .light: + return Color.black.opacity(0.18) + case .dark: + return Color.black.opacity(0.45) + @unknown default: + return Color.black.opacity(0.18) + } + } + @ViewBuilder private var rowsView: some View { VStack(spacing: rowSpacing) { @@ -2664,18 +2792,18 @@ private struct OmnibarSuggestionsView: View { HStack(spacing: 6) { Text(item.listText) .font(.system(size: 11)) - .foregroundStyle(Color.white.opacity(0.9)) + .foregroundStyle(listTextColor) .lineLimit(1) .truncationMode(.tail) if let badge = item.trailingBadgeText { Text(badge) .font(.system(size: 9.5, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.72)) + .foregroundStyle(badgeTextColor) .padding(.horizontal, 6) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(Color.white.opacity(0.08)) + .fill(badgeBackgroundColor) ) } Spacer(minLength: 0) @@ -2691,7 +2819,7 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous) .fill( idx == selectedIndex - ? Color.white.opacity(0.12) + ? rowHighlightColor : Color.clear ) ) @@ -2746,10 +2874,7 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) .fill( LinearGradient( - colors: [ - Color.black.opacity(0.26), - Color.black.opacity(0.14), - ], + colors: popupOverlayGradientColors, startPoint: .top, endPoint: .bottom ) @@ -2760,18 +2885,16 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) .stroke( LinearGradient( - colors: [ - Color.white.opacity(0.22), - Color.white.opacity(0.06), - ], + colors: popupBorderGradientColors, startPoint: .top, endPoint: .bottom ), lineWidth: 1 ) ) - .shadow(color: Color.black.opacity(0.45), radius: 20, y: 10) - .contentShape(Rectangle()) + .clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) + .shadow(color: popupShadowColor, radius: 20, y: 10) + .contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) .accessibilityElement(children: .contain) .accessibilityRespondsToUserInteraction(true) .accessibilityIdentifier("BrowserOmnibarSuggestions") diff --git a/Sources/SentryHelper.swift b/Sources/SentryHelper.swift new file mode 100644 index 00000000..9877a46c --- /dev/null +++ b/Sources/SentryHelper.swift @@ -0,0 +1,9 @@ +import Sentry + +/// Add a Sentry breadcrumb for user-action context in hang/crash reports. +func sentryBreadcrumb(_ message: String, category: String = "ui", data: [String: Any]? = nil) { + let crumb = Breadcrumb(level: .info, category: category) + crumb.message = message + crumb.data = data + SentrySDK.addBreadcrumb(crumb) +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 10bd2eb4..702f935e 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -567,6 +567,9 @@ class TabManager: ObservableObject { @Published var selectedTabId: UUID? { didSet { guard selectedTabId != oldValue else { return } + sentryBreadcrumb("workspace.switch", data: [ + "tabCount": tabs.count + ]) let previousTabId = oldValue if let previousTabId, let previousPanelId = focusedPanelId(for: previousTabId) { @@ -752,6 +755,7 @@ class TabManager: ObservableObject { @discardableResult func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace { + sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1]) let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal @@ -963,6 +967,7 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace) { guard tabs.count > 1 else { return } + sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) unwireClosedBrowserTracking(for: workspace) @@ -1725,6 +1730,7 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return } + sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index cec6847e..e3f19c3d 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -17,6 +17,12 @@ private func portalDebugToken(_ view: NSView?) -> String { private func portalDebugFrame(_ rect: NSRect) -> String { String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) } + +private func portalDebugFrameInWindow(_ view: NSView?) -> String { + guard let view else { return "nil" } + guard view.window != nil else { return "no-window" } + return portalDebugFrame(view.convert(view.bounds, to: nil)) +} #endif final class WindowTerminalHostView: NSView { @@ -529,6 +535,10 @@ private final class SplitDividerOverlayView: NSView { @MainActor final class WindowTerminalPortal: NSObject { + private static let tinyHideThreshold: CGFloat = 1 + private static let minimumRevealWidth: CGFloat = 24 + private static let minimumRevealHeight: CGFloat = 18 + private weak var window: NSWindow? private let hostView = WindowTerminalHostView(frame: .zero) private let dividerOverlayView = SplitDividerOverlayView(frame: .zero) @@ -538,6 +548,9 @@ final class WindowTerminalPortal: NSObject { private var hasDeferredFullSyncScheduled = false private var hasExternalGeometrySyncScheduled = false private var geometryObservers: [NSObjectProtocol] = [] +#if DEBUG + private var lastLoggedBonsplitContainerSignature: String? +#endif private struct Entry { weak var hostedView: GhosttySurfaceScrollView? @@ -800,6 +813,35 @@ final class WindowTerminalPortal: NSObject { return viewIndex > referenceIndex } +#if DEBUG + private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? { + var current: NSView? = anchorView + while let view = current { + let className = NSStringFromClass(type(of: view)) + if className.contains("PaneDragContainerView") || className.contains("Bonsplit") { + return view + } + current = view.superview + } + return installedReferenceView + } + + private func logBonsplitContainerFrameIfNeeded(anchorView: NSView, hostedView: GhosttySurfaceScrollView) { + guard let container = nearestBonsplitContainer(from: anchorView) else { return } + let containerFrame = container.convert(container.bounds, to: nil) + let signature = "\(ObjectIdentifier(container)):\(portalDebugFrame(containerFrame))" + guard signature != lastLoggedBonsplitContainerSignature else { return } + lastLoggedBonsplitContainerSignature = signature + + let containerClass = NSStringFromClass(type(of: container)) + dlog( + "portal.bonsplit.container hosted=\(portalDebugToken(hostedView)) " + + "class=\(containerClass) frame=\(portalDebugFrame(containerFrame)) " + + "host=\(portalDebugFrameInWindow(hostView)) anchor=\(portalDebugFrameInWindow(anchorView))" + ) + } +#endif + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. /// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when /// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective @@ -1074,6 +1116,9 @@ final class WindowTerminalPortal: NSObject { let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) let frameInHostRaw = hostView.convert(frameInWindow, from: nil) let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) +#if DEBUG + logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView) +#endif let hostBounds = hostView.bounds let hasFiniteHostBounds = hostBounds.origin.x.isFinite && @@ -1093,7 +1138,6 @@ final class WindowTerminalPortal: NSObject { scheduleDeferredFullSynchronizeAll() return } - let hasFiniteFrame = frameInHost.origin.x.isFinite && frameInHost.origin.y.isFinite && @@ -1106,7 +1150,12 @@ final class WindowTerminalPortal: NSObject { clampedFrame.height > 1 let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) - let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1 + let tinyFrame = + targetFrame.width <= Self.tinyHideThreshold || + targetFrame.height <= Self.tinyHideThreshold + let revealReadyForDisplay = + targetFrame.width >= Self.minimumRevealWidth && + targetFrame.height >= Self.minimumRevealHeight let outsideHostBounds = !hasVisibleIntersection let shouldHide = !entry.visibleInUI || @@ -1114,6 +1163,7 @@ final class WindowTerminalPortal: NSObject { tinyFrame || !hasFiniteFrame || outsideHostBounds + let shouldDeferReveal = !shouldHide && hostedView.isHidden && !revealReadyForDisplay let oldFrame = hostedView.frame #if DEBUG @@ -1140,6 +1190,23 @@ final class WindowTerminalPortal: NSObject { ) } #endif + + // Hide before updating the frame when this entry should not be visible. + // This avoids a one-frame flash of unrendered terminal background when a portal + // briefly transitions through offscreen/tiny geometry during rapid split churn. + if shouldHide, !hostedView.isHidden { +#if DEBUG + dlog( + "portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " + + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" + ) +#endif + hostedView.isHidden = true + } + if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() CATransaction.setDisableActions(true) @@ -1159,17 +1226,28 @@ final class WindowTerminalPortal: NSObject { } } - if hostedView.isHidden != shouldHide { + if shouldDeferReveal { +#if DEBUG + if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + dlog( + "portal.hidden.deferReveal hosted=\(portalDebugToken(hostedView)) " + + "frame=\(portalDebugFrame(frameInHost)) min=\(Int(Self.minimumRevealWidth))x\(Int(Self.minimumRevealHeight))" + ) + } +#endif + } + + if !shouldHide, hostedView.isHidden, revealReadyForDisplay { #if DEBUG dlog( - "portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " + + "portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + - "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " + "host=\(portalDebugFrame(hostBounds))" ) #endif - hostedView.isHidden = shouldHide + hostedView.isHidden = false } #if DEBUG diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index e534e1bc..ebc62a05 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -1,6 +1,187 @@ import AppKit +import Bonsplit import SwiftUI +private func windowDragHandleFormatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) +} + +private var windowDragSuppressionDepthKey: UInt8 = 0 + +func beginWindowDragSuppression(window: NSWindow?) -> Int? { + guard let window else { return nil } + let current = windowDragSuppressionDepth(window: window) + let next = current + 1 + objc_setAssociatedObject( + window, + &windowDragSuppressionDepthKey, + NSNumber(value: next), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return next +} + +@discardableResult +func endWindowDragSuppression(window: NSWindow?) -> Int { + guard let window else { return 0 } + let current = windowDragSuppressionDepth(window: window) + let next = max(0, current - 1) + if next == 0 { + objc_setAssociatedObject(window, &windowDragSuppressionDepthKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } else { + objc_setAssociatedObject( + window, + &windowDragSuppressionDepthKey, + NSNumber(value: next), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + return next +} + +func windowDragSuppressionDepth(window: NSWindow?) -> Int { + guard let window, + let value = objc_getAssociatedObject(window, &windowDragSuppressionDepthKey) as? NSNumber else { + return 0 + } + return value.intValue +} + +func isWindowDragSuppressed(window: NSWindow?) -> Bool { + windowDragSuppressionDepth(window: window) > 0 +} + +/// Temporarily enables window movability for explicit drag-handle drags, then +/// restores the previous movability state after `body` finishes. +@discardableResult +func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> Bool? { + guard let window else { + body() + return nil + } + + let previousMovableState = window.isMovable + if !previousMovableState { + window.isMovable = true + } + defer { + if window.isMovable != previousMovableState { + window.isMovable = previousMovableState + } + } + + body() + return previousMovableState +} + +private enum WindowDragHandleHitTestState { + static var isResolvingTopHit = false +} + +/// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty +/// titlebar space. Treat those as pass-through so explicit sibling checks decide. +func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool { + let className = String(describing: type(of: view)) + if className.contains("HostContainerView") + || className.contains("AppKitWindowHostingView") + || className.contains("NSHostingView") { + return true + } + if let window = view.window, view === window.contentView { + return true + } + return false +} + +/// Returns whether the titlebar drag handle should capture a hit at `point`. +/// We only claim the hit when no sibling view already handles it, so interactive +/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures. +func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSView) -> Bool { + if isWindowDragSuppressed(window: dragHandleView.window) { + #if DEBUG + let depth = windowDragSuppressionDepth(window: dragHandleView.window) + dlog( + "titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))" + ) + #endif + return false + } + + guard dragHandleView.bounds.contains(point) else { + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))") + #endif + return false + } + + guard let superview = dragHandleView.superview else { + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))") + #endif + return true + } + + if let window = dragHandleView.window, + let contentView = window.contentView, + !WindowDragHandleHitTestState.isResolvingTopHit { + let pointInWindow = dragHandleView.convert(point, to: nil) + let pointInContent = contentView.convert(pointInWindow, from: nil) + + WindowDragHandleHitTestState.isResolvingTopHit = true + let topHit = contentView.hitTest(pointInContent) + WindowDragHandleHitTestState.isResolvingTopHit = false + + if let topHit { + let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView) + let isPassiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(topHit) + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=\(ownsTopHit) strategy=windowTopHit point=\(windowDragHandleFormatPoint(point)) top=\(type(of: topHit)) passiveHost=\(isPassiveHostHit)" + ) + #endif + if ownsTopHit { + return true + } + if !isPassiveHostHit { + return false + } + } + } + + #if DEBUG + let siblingCount = superview.subviews.count + #endif + + for sibling in superview.subviews.reversed() { + guard sibling !== dragHandleView else { continue } + guard !sibling.isHidden, sibling.alphaValue > 0 else { continue } + + let pointInSibling = dragHandleView.convert(point, to: sibling) + if let hitView = sibling.hitTest(pointInSibling) { + let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView) + if passiveHostHit { + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true" + ) + #endif + continue + } + #if DEBUG + dlog( + "titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false" + ) + #endif + return false + } + } + + #if DEBUG + dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)") + #endif + return true +} + /// A transparent view that enables dragging the window when clicking in empty titlebar space. /// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content /// (e.g. sidebar tab reordering) don't move the whole window. @@ -14,8 +195,53 @@ struct WindowDragHandleView: NSViewRepresentable { } private final class DraggableView: NSView { - override var mouseDownCanMoveWindow: Bool { true } - override func hitTest(_ point: NSPoint) -> NSView? { self } + override var mouseDownCanMoveWindow: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self) + #if DEBUG + dlog( + "titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)" + ) + #endif + return shouldCapture ? self : nil + } + + override func mouseDown(with event: NSEvent) { + #if DEBUG + let point = convert(event.locationInWindow, from: nil) + let depth = windowDragSuppressionDepth(window: window) + dlog( + "titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)" + ) + #endif + + if event.clickCount >= 2 { + window?.zoom(nil) + #if DEBUG + dlog("titlebar.dragHandle.mouseDownDoubleClick zoom=1") + #endif + return + } + + guard !isWindowDragSuppressed(window: window) else { + #if DEBUG + dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed") + #endif + return + } + + if let window { + let previousMovableState = withTemporaryWindowMovableEnabled(window: window) { + window.performDrag(with: event) + } + #if DEBUG + let restored = previousMovableState.map { String($0) } ?? "nil" + dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)") + #endif + } else { + super.mouseDown(with: event) + } + } } } - diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c12d2c08..c875cf11 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -54,6 +54,52 @@ private func installCmuxUnitTestInspectorOverride() { cmuxUnitTestInspectorOverrideInstalled = true } +final class SplitShortcutTransientFocusGuardTests: XCTestCase { + func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() { + XCTAssertTrue( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 79, height: 0), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } + + func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsDetached() { + XCTAssertTrue( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 1051.5, height: 1207), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: false + ) + ) + } + + func testAllowsWhenFirstResponderFallsBackButGeometryIsHealthy() { + XCTAssertFalse( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: true, + hostedSize: CGSize(width: 1051.5, height: 1207), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } + + func testAllowsWhenFirstResponderIsTerminalEvenIfViewIsTiny() { + XCTAssertFalse( + shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: false, + hostedSize: CGSize(width: 79, height: 0), + hostedHiddenInHierarchy: false, + hostedAttachedToWindow: true + ) + ) + } +} + final class CmuxWebViewKeyEquivalentTests: XCTestCase { private final class ActionSpy: NSObject { private(set) var invoked: Bool = false @@ -655,6 +701,78 @@ final class BrowserThemeSettingsTests: XCTestCase { } } +final class BrowserPanelChromeBackgroundColorTests: XCTestCase { + func testLightModeUsesThemeBackgroundColor() { + assertResolvedColorMatchesTheme(for: .light) + } + + func testDarkModeUsesThemeBackgroundColor() { + assertResolvedColorMatchesTheme(for: .dark) + } + + private func assertResolvedColorMatchesTheme( + for colorScheme: ColorScheme, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.13, green: 0.29, blue: 0.47, alpha: 1.0) + + guard + let actual = resolvedBrowserChromeBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expected = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expected.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expected.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expected.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expected.alphaComponent, accuracy: 0.001, file: file, line: line) + } +} + +final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { + func testLightModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) + } + + func testDarkModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) + } + + private func assertResolvedColorMatchesExpectedBlend( + for colorScheme: ColorScheme, + darkenMix: CGFloat, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) + let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground + + guard + let actual = resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground + ).usingColorSpace(.sRGB), + let expectedSRGB = expected.usingColorSpace(.sRGB), + let themeSRGB = themeBackground.usingColorSpace(.sRGB) + else { + XCTFail("Expected sRGB-convertible colors", file: file, line: line) + return + } + + XCTAssertEqual(actual.redComponent, expectedSRGB.redComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.greenComponent, expectedSRGB.greenComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.blueComponent, expectedSRGB.blueComponent, accuracy: 0.001, file: file, line: line) + XCTAssertEqual(actual.alphaComponent, expectedSRGB.alphaComponent, accuracy: 0.001, file: file, line: line) + XCTAssertNotEqual(actual.redComponent, themeSRGB.redComponent, file: file, line: line) + } +} + final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { func testSafariDefaultShortcutForToggleDeveloperTools() { let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut @@ -1491,115 +1609,34 @@ final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { } final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { - func testFirstEntryAlwaysPinsToTopWhenScrollable() { - let anchor = ContentView.commandPaletteScrollAnchor( + func testFirstEntryPinsToTopAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 0, - previousIndex: 1, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 8, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 20 ) - XCTAssertEqual(anchor, .top) + XCTAssertEqual(anchor, UnitPoint.top) } - func testLastEntryAlwaysPinsToBottomWhenScrollable() { - let anchor = ContentView.commandPaletteScrollAnchor( + func testLastEntryPinsToBottomAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 19, - previousIndex: 18, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 188, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 20 ) - XCTAssertEqual(anchor, .bottom) + XCTAssertEqual(anchor, UnitPoint.bottom) } - func testFullyVisibleMiddleEntryDoesNotScroll() { - let anchor = ContentView.commandPaletteScrollAnchor( + func testMiddleEntryUsesNilAnchorForMinimalScroll() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 6, - previousIndex: 5, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 120, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 20 ) XCTAssertNil(anchor) } - func testOutOfViewMiddleEntryUsesDirectionForAnchor() { - let downAnchor = ContentView.commandPaletteScrollAnchor( - selectedIndex: 9, - previousIndex: 8, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 210, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(downAnchor, .bottom) - - let upAnchor = ContentView.commandPaletteScrollAnchor( - selectedIndex: 8, - previousIndex: 9, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: -6, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(upAnchor, .top) - } -} - -final class CommandPaletteEdgeVisibilityCorrectionTests: XCTestCase { - func testTopEdgeReturnsTopWhenNotPinned() { - let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + func testEmptyResultsProduceNoAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 0, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 6, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(anchor, .top) - } - - func testBottomEdgeReturnsBottomWhenNotPinned() { - let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 19, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 170, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(anchor, .bottom) - } - - func testPinnedTopAndBottomReturnNil() { - let topAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 0, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 0, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertNil(topAnchor) - - let bottomAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 19, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 192, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertNil(bottomAnchor) - } - - func testMiddleSelectionNeverForcesCorrection() { - let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 8, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 96, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 0 ) XCTAssertNil(anchor) } @@ -3292,6 +3329,63 @@ final class FinderServicePathResolverTests: XCTestCase { } } +final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { + private func environment( + existingPaths: Set, + homeDirectoryPath: String = "/Users/tester" + ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { + TerminalDirectoryOpenTarget.DetectionEnvironment( + homeDirectoryPath: homeDirectoryPath, + fileExistsAtPath: { existingPaths.contains($0) } + ) + } + + func testAvailableTargetsDetectSystemApplications() { + let env = environment( + existingPaths: [ + "/Applications/Visual Studio Code.app", + "/System/Library/CoreServices/Finder.app", + "/System/Applications/Utilities/Terminal.app", + "/Applications/Zed Preview.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.vscode)) + XCTAssertTrue(availableTargets.contains(.finder)) + XCTAssertTrue(availableTargets.contains(.terminal)) + XCTAssertTrue(availableTargets.contains(.zed)) + XCTAssertFalse(availableTargets.contains(.cursor)) + } + + func testAvailableTargetsFallbackToUserApplications() { + let env = environment( + existingPaths: [ + "/Users/tester/Applications/Cursor.app", + "/Users/tester/Applications/Warp.app", + "/Users/tester/Applications/Android Studio.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.cursor)) + XCTAssertTrue(availableTargets.contains(.warp)) + XCTAssertTrue(availableTargets.contains(.androidStudio)) + XCTAssertFalse(availableTargets.contains(.vscode)) + } + + func testITerm2DetectsLegacyBundleName() { + let env = environment(existingPaths: ["/Applications/iTerm.app"]) + XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env)) + } + + func testCommandPaletteShortcutsExcludeGenericIDEEntry() { + let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets + XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" })) + XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" })) + } +} + final class BrowserSearchEngineTests: XCTestCase { func testGoogleSearchURL() throws { let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world")) @@ -4554,6 +4648,314 @@ final class WindowBrowserHostViewTests: XCTestCase { } } +@MainActor +final class WindowDragHandleHitTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class HostContainerView: NSView {} + private final class PassiveHostContainerView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + return super.hitTest(point) ?? self + } + } + + func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle), + "Empty titlebar space should drag the window" + ) + } + + func testDragHandleYieldsWhenSiblingClaimsPoint() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + container.addSubview(folderIconHost) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle), + "Interactive titlebar controls should receive the mouse event" + ) + XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle)) + } + + func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + hidden.isHidden = true + container.addSubview(hidden) + + XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle)) + } + + func testDragHandleDoesNotCaptureOutsideBounds() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle)) + } + + func testPassiveHostingTopHitClassification() { + XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero))) + XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero))) + } + + func testDragHandleIgnoresPassiveHostSiblingHit() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let passiveHost = PassiveHostContainerView(frame: container.bounds) + container.addSubview(passiveHost) + + XCTAssertTrue( + windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle), + "Passive host wrappers should not block titlebar drag capture" + ) + } + + func testDragHandleRespectsInteractiveChildInsidePassiveHost() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36)) + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let passiveHost = PassiveHostContainerView(frame: container.bounds) + let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16)) + passiveHost.addSubview(folderControl) + container.addSubview(passiveHost) + + XCTAssertFalse( + windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle), + "Interactive controls inside passive host wrappers should still receive hits" + ) + } +} + +@MainActor +final class DraggableFolderHitTests: XCTestCase { + func testFolderHitTestReturnsContainerWhenInsideBounds() { + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) + + guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else { + XCTFail("Expected folder icon to capture inside hit") + return + } + XCTAssertTrue(hit === folderView) + } + + func testFolderHitTestReturnsNilOutsideBounds() { + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16) + + XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8))) + } + + func testFolderIconDisablesWindowMoveBehavior() { + let folderView = DraggableFolderNSView(directory: "/tmp") + XCTAssertFalse(folderView.mouseDownCanMoveWindow) + } +} + +@MainActor +final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { + func testLeadingInsetViewDoesNotParticipateInHitTesting() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10))) + } + + func testLeadingInsetViewCannotMoveWindowViaMouseDown() { + let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) + XCTAssertFalse(view.mouseDownCanMoveWindow) + } +} + +@MainActor +final class FolderWindowMoveSuppressionTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + } + + func testSuppressionDisablesMovableWindow() { + let window = makeWindow() + window.isMovable = true + + let previous = temporarilyDisableWindowDragging(window: window) + + XCTAssertEqual(previous, true) + XCTAssertFalse(window.isMovable) + } + + func testSuppressionPreservesAlreadyImmovableWindow() { + let window = makeWindow() + window.isMovable = false + + let previous = temporarilyDisableWindowDragging(window: window) + + XCTAssertEqual(previous, false) + XCTAssertFalse(window.isMovable) + } + + func testRestoreAppliesPreviousMovableState() { + let window = makeWindow() + window.isMovable = false + + restoreWindowDragging(window: window, previousMovableState: true) + XCTAssertTrue(window.isMovable) + + restoreWindowDragging(window: window, previousMovableState: false) + XCTAssertFalse(window.isMovable) + } + + func testWindowDragSuppressionDepthLifecycle() { + let window = makeWindow() + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(beginWindowDragSuppression(window: window), 1) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 0) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + } + + func testWindowDragSuppressionIsReferenceCounted() { + let window = makeWindow() + XCTAssertEqual(beginWindowDragSuppression(window: window), 1) + XCTAssertEqual(beginWindowDragSuppression(window: window), 2) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 2) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 1) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 1) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + + XCTAssertEqual(endWindowDragSuppression(window: window), 0) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + } + + func testTemporaryWindowMovableEnableRestoresImmovableWindow() { + let window = makeWindow() + window.isMovable = false + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, false) + XCTAssertFalse(window.isMovable) + } + + func testTemporaryWindowMovableEnablePreservesMovableWindow() { + let window = makeWindow() + window.isMovable = true + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, true) + XCTAssertTrue(window.isMovable) + } +} + +@MainActor +final class WindowMoveSuppressionHitPathTests: XCTestCase { + private func makeWindowWithContentView() -> (NSWindow, NSView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = contentView + return (window, contentView) + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + func testSuppressionHitPathRecognizesFolderView() { + let folderView = DraggableFolderNSView(directory: "/tmp") + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView)) + } + + func testSuppressionHitPathRecognizesDescendantOfFolderView() { + let folderView = DraggableFolderNSView(directory: "/tmp") + let child = NSView(frame: .zero) + folderView.addSubview(child) + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child)) + } + + func testSuppressionHitPathIgnoresUnrelatedViews() { + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero))) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil)) + } + + func testSuppressionEventPathRecognizesFolderHitInsideWindow() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let folderView = DraggableFolderNSView(directory: "/tmp") + folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16) + contentView.addSubview(folderView) + + let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window) + + XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event)) + } + + func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + contentView.addSubview(plainView) + + let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down)) + + let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window) + XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged)) + } +} + @MainActor final class GhosttySurfaceOverlayTests: XCTestCase { func testInactiveOverlayVisibilityTracksRequestedState() { @@ -4738,6 +5140,14 @@ final class GhosttySurfaceOverlayTests: XCTestCase { @MainActor final class TerminalWindowPortalLifecycleTests: XCTestCase { + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + func testPortalHostInstallsAboveContentViewForVisibility() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), @@ -4927,6 +5337,50 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "Promoting z-priority should bring an already-visible terminal to front" ) } + + func testHiddenPortalDefersRevealUntilFrameHasUsableSize() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + let portal = WindowTerminalPortal(window: window) + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 40, width: 280, height: 220)) + contentView.addSubview(anchor) + + let hosted = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80)) + ) + portal.bind(hostedView: hosted, to: anchor, visibleInUI: true) + XCTAssertFalse(hosted.isHidden, "Healthy geometry should be visible") + + // Collapse to a tiny frame first. + anchor.frame = NSRect(x: 160.5, y: 1037.0, width: 79.0, height: 0.0) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertTrue(hosted.isHidden, "Tiny geometry should hide the portal-hosted terminal") + + // Then restore to a non-zero but still too-small frame. It should remain hidden. + anchor.frame = NSRect(x: 160.9, y: 1026.5, width: 93.6, height: 10.3) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertTrue( + hosted.isHidden, + "Portal should defer reveal until geometry reaches a usable size" + ) + + // Once the frame is large enough again, reveal should resume. + anchor.frame = NSRect(x: 40, y: 40, width: 180, height: 40) + portal.synchronizeHostedViewForAnchor(anchor) + XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable") + } } @MainActor diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh new file mode 100755 index 00000000..f046141c --- /dev/null +++ b/tests/test_ci_self_hosted_guard.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Regression test for https://github.com/manaflow-ai/cmux/issues/385. +# Ensures self-hosted UI tests are never run for fork pull requests. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml" + +EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository" + +if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then + echo "FAIL: Missing fork pull_request guard for ui-tests in $WORKFLOW_FILE" + echo "Expected line:" + echo " $EXPECTED_IF" + exit 1 +fi + +if ! awk ' + /^ ui-tests:/ { in_ui_tests=1; next } + in_ui_tests && /^ [^[:space:]]/ { in_ui_tests=0 } + in_ui_tests && /runs-on: self-hosted/ { saw_self_hosted=1 } + in_ui_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 } + END { exit !(saw_self_hosted && saw_guard) } +' "$WORKFLOW_FILE"; then + echo "FAIL: ui-tests block must keep both self-hosted and fork guard" + exit 1 +fi + +echo "PASS: ui-tests self-hosted fork guard is present" diff --git a/tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py b/tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py new file mode 100644 index 00000000..bc0cf9f3 --- /dev/null +++ b/tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +Fuzz regression: rapid Cmd+D / Ctrl+D churn must not shift the outer bonsplit container frame. + +This targets the user-reported visual shift/flash while spamming split + close. +We treat any drift in x/y/width/height of the outer container frame as a failure. +""" + +from collections import deque +import os +import random +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +FUZZ_SEED = int(os.environ.get("CMUX_SPLIT_FUZZ_SEED", "424242")) +FUZZ_STEPS = int(os.environ.get("CMUX_SPLIT_FUZZ_STEPS", "1400")) +SAMPLES_PER_STEP = int(os.environ.get("CMUX_SPLIT_FUZZ_SAMPLES", "4")) +SAMPLE_INTERVAL_S = float(os.environ.get("CMUX_SPLIT_FUZZ_SAMPLE_INTERVAL_S", "0.0015")) +ACTION_JITTER_MAX_S = float(os.environ.get("CMUX_SPLIT_FUZZ_ACTION_JITTER_MAX_S", "0.0035")) +BURST_MAX = int(os.environ.get("CMUX_SPLIT_FUZZ_BURST_MAX", "3")) +MAX_PANES = int(os.environ.get("CMUX_SPLIT_FUZZ_MAX_PANES", "10")) +EPSILON = float(os.environ.get("CMUX_SPLIT_FUZZ_EPSILON", "0.0")) +TRACE_TAIL = int(os.environ.get("CMUX_SPLIT_FUZZ_TRACE_TAIL", "40")) +ASSERT_NO_UNDERFLOW = os.environ.get("CMUX_SPLIT_FUZZ_ASSERT_NO_UNDERFLOW", "0") == "1" +ASSERT_NO_EMPTY_PANEL = os.environ.get("CMUX_SPLIT_FUZZ_ASSERT_NO_EMPTY_PANEL", "0") == "1" + + +def _pane_count(layout_payload: dict) -> int: + layout = layout_payload.get("layout") or {} + panes = layout.get("panes") or [] + return len(panes) + + +def _largest_split_frame(layout_payload: dict) -> dict: + selected = layout_payload.get("selectedPanels") or [] + best = None + best_area = -1.0 + + for row in selected: + for split in row.get("splitViews") or []: + frame = split.get("frame") + if not frame: + continue + + try: + x = float(frame.get("x", 0.0)) + y = float(frame.get("y", 0.0)) + width = float(frame.get("width", 0.0)) + height = float(frame.get("height", 0.0)) + except (TypeError, ValueError): + continue + + if width <= 0.0 or height <= 0.0: + continue + + area = width * height + if area > best_area: + best_area = area + best = {"x": x, "y": y, "width": width, "height": height} + + if best is None: + raise cmuxError(f"layout_debug contains no usable split-view frame: {layout_payload}") + return best + + +def _container_frame(layout_payload: dict) -> dict: + container = (layout_payload.get("layout") or {}).get("containerFrame") + if container: + try: + return { + "x": float(container.get("x", 0.0)), + "y": float(container.get("y", 0.0)), + "width": float(container.get("width", 0.0)), + "height": float(container.get("height", 0.0)), + } + except (TypeError, ValueError): + pass + + # Back-compat fallback for older payloads that don't expose containerFrame. + return _largest_split_frame(layout_payload) + + +def _assert_same_frame( + current: dict, + baseline: dict, + *, + step: int, + sample: int, + action: str, + seed: int, + action_index: int, + trace: list[str], +) -> None: + deltas = { + key: abs(float(current[key]) - float(baseline[key])) + for key in ("x", "y", "width", "height") + } + shifted = {k: v for k, v in deltas.items() if v > EPSILON} + if shifted: + raise cmuxError( + "Outer split container shifted during fuzz churn " + f"(step={step}, sample={sample}, action={action}, action_index={action_index}, seed={seed}, " + f"baseline={baseline}, current={current}, deltas={deltas}, epsilon={EPSILON})" + f"; recent_actions={trace}" + ) + + +def _warm_start_split(c: cmux) -> dict: + # Ensure we have at least one split so the container frame exists in layout_debug. + c.simulate_shortcut("cmd+d") + deadline = time.time() + 2.0 + last = None + while time.time() < deadline: + payload = c.layout_debug() + last = payload + if _pane_count(payload) >= 2: + return payload + time.sleep(0.02) + raise cmuxError(f"Timed out waiting for first split to appear: {last}") + + +def main() -> int: + rng = random.Random(FUZZ_SEED) + recent_actions: deque[str] = deque(maxlen=max(8, TRACE_TAIL)) + total_actions = 0 + + with cmux(SOCKET_PATH) as c: + ws = c.new_workspace() + c.select_workspace(ws) + c.activate_app() + time.sleep(0.2) + + c.reset_bonsplit_underflow_count() + c.reset_empty_panel_count() + + initial = _warm_start_split(c) + baseline = _container_frame(initial) + if _pane_count(initial) < 2: + raise cmuxError("Expected at least 2 panes after warm start split") + + for step in range(1, FUZZ_STEPS + 1): + burst = rng.randint(1, max(1, BURST_MAX)) + + for burst_index in range(1, burst + 1): + before = c.layout_debug() + pane_count = _pane_count(before) + + if pane_count <= 2: + action = "cmd+d" + elif pane_count >= MAX_PANES: + action = "ctrl+d" + else: + # Bias toward split to keep churn dense while still frequently collapsing via ctrl+d. + action = "cmd+d" if rng.random() < 0.60 else "ctrl+d" + + if action == "cmd+d": + c.simulate_shortcut("cmd+d") + else: + # Ctrl+D equivalent sent directly to the focused terminal surface. + c.send_ctrl_d() + + total_actions += 1 + recent_actions.append( + f"step={step}/burst={burst_index}/{burst} panes_before={pane_count} action={action}" + ) + + # Random micro-jitter to emulate uneven key-repeat timing while keeping churn fast. + if ACTION_JITTER_MAX_S > 0: + time.sleep(rng.uniform(0.0, ACTION_JITTER_MAX_S)) + + # Sample repeatedly after each burst to catch transient shifts. + for sample in range(0, SAMPLES_PER_STEP + 1): + payload = c.layout_debug() + current = _container_frame(payload) + _assert_same_frame( + current, + baseline, + step=step, + sample=sample, + action="burst", + seed=FUZZ_SEED, + action_index=total_actions, + trace=list(recent_actions), + ) + if SAMPLE_INTERVAL_S > 0: + time.sleep(rng.uniform(0.0, SAMPLE_INTERVAL_S)) + + underflows = c.bonsplit_underflow_count() + if ASSERT_NO_UNDERFLOW and underflows != 0: + raise cmuxError(f"bonsplit arranged-subview underflow observed during fuzz run: {underflows}") + + flashes = c.empty_panel_count() + if ASSERT_NO_EMPTY_PANEL and flashes != 0: + raise cmuxError(f"EmptyPanelView appeared during fuzz run (count={flashes})") + + print( + "PASS: cmd+d/ctrl+d fuzz geometry invariant " + f"(seed={FUZZ_SEED}, steps={FUZZ_STEPS}, samples={SAMPLES_PER_STEP}, burst_max={BURST_MAX}, " + f"actions={total_actions}, epsilon={EPSILON}, underflows={underflows}, empty_panel={flashes})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py b/tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py new file mode 100644 index 00000000..b4413d83 --- /dev/null +++ b/tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +""" +Focused fuzz regression for rapid Cmd+D / Ctrl+D churn in a strict 1<->2 pane loop. + +Intent: + - Keep topology limited to one pane or two left/right panes only. + - Run across multiple fresh workspaces. + - Sample layout as fast as the debug socket allows during transitions/holds. + - Fail immediately if outer container x/y/width/height drifts at any sampled frame. +""" + +from collections import deque +import os +import random +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +FUZZ_SEED = int(os.environ.get("CMUX_SPLIT_2PANE_SEED", "20260223")) +WORKSPACES = int(os.environ.get("CMUX_SPLIT_2PANE_WORKSPACES", "3")) +CYCLES_PER_WORKSPACE = int(os.environ.get("CMUX_SPLIT_2PANE_CYCLES", "220")) +TRANSITION_TIMEOUT_S = float(os.environ.get("CMUX_SPLIT_2PANE_TIMEOUT_S", "2.0")) +HOLD_MIN_S = float(os.environ.get("CMUX_SPLIT_2PANE_HOLD_MIN_S", "0.003")) +HOLD_MAX_S = float(os.environ.get("CMUX_SPLIT_2PANE_HOLD_MAX_S", "0.018")) +PRE_ACTION_JITTER_MAX_S = float(os.environ.get("CMUX_SPLIT_2PANE_ACTION_JITTER_MAX_S", "0.002")) +EPSILON = float(os.environ.get("CMUX_SPLIT_2PANE_EPSILON", "0.0")) +TRACE_TAIL = int(os.environ.get("CMUX_SPLIT_2PANE_TRACE_TAIL", "64")) +LAYOUT_POLL_SLEEP_S = float(os.environ.get("CMUX_SPLIT_2PANE_POLL_SLEEP_S", "0.0008")) +LAYOUT_TIMEOUT_RETRIES = int(os.environ.get("CMUX_SPLIT_2PANE_LAYOUT_TIMEOUT_RETRIES", "4")) +LAYOUT_TIMEOUT_RETRY_SLEEP_S = float(os.environ.get("CMUX_SPLIT_2PANE_LAYOUT_TIMEOUT_RETRY_SLEEP_S", "0.0015")) +MAX_LAYOUT_TIMEOUTS = int(os.environ.get("CMUX_SPLIT_2PANE_MAX_LAYOUT_TIMEOUTS", "80")) +CTRL_D_RETRY_INTERVAL_S = float(os.environ.get("CMUX_SPLIT_2PANE_CTRL_D_RETRY_INTERVAL_S", "0.18")) +CTRL_D_MAX_EXTRA = int(os.environ.get("CMUX_SPLIT_2PANE_CTRL_D_MAX_EXTRA", "6")) + + +def _pane_count(layout_payload: dict) -> int: + layout = layout_payload.get("layout") or {} + return len(layout.get("panes") or []) + + +def _largest_split_frame(layout_payload: dict) -> dict: + selected = layout_payload.get("selectedPanels") or [] + best = None + best_area = -1.0 + for row in selected: + for split in row.get("splitViews") or []: + frame = split.get("frame") + if not frame: + continue + try: + x = float(frame.get("x", 0.0)) + y = float(frame.get("y", 0.0)) + width = float(frame.get("width", 0.0)) + height = float(frame.get("height", 0.0)) + except (TypeError, ValueError): + continue + if width <= 0.0 or height <= 0.0: + continue + area = width * height + if area > best_area: + best_area = area + best = {"x": x, "y": y, "width": width, "height": height} + if best is None: + raise cmuxError(f"layout_debug contains no usable split-view frame: {layout_payload}") + return best + + +def _container_frame(layout_payload: dict) -> dict: + container = (layout_payload.get("layout") or {}).get("containerFrame") + if container: + try: + return { + "x": float(container.get("x", 0.0)), + "y": float(container.get("y", 0.0)), + "width": float(container.get("width", 0.0)), + "height": float(container.get("height", 0.0)), + } + except (TypeError, ValueError): + pass + return _largest_split_frame(layout_payload) + + +def _pane_frames_sorted_x(layout_payload: dict) -> list[dict]: + layout = layout_payload.get("layout") or {} + panes = layout.get("panes") or [] + frames: list[dict] = [] + for pane in panes: + frame = pane.get("frame") or {} + try: + frames.append( + { + "pane_id": str(pane.get("paneId") or ""), + "x": float(frame.get("x", 0.0)), + "y": float(frame.get("y", 0.0)), + "width": float(frame.get("width", 0.0)), + "height": float(frame.get("height", 0.0)), + } + ) + except (TypeError, ValueError): + continue + return sorted(frames, key=lambda p: (p["x"], p["y"])) + + +def _assert_same_frame( + *, + current: dict, + baseline: dict, + workspace_index: int, + cycle: int, + phase: str, + sample: int, + trace: list[str], +) -> None: + deltas = { + key: abs(float(current[key]) - float(baseline[key])) + for key in ("x", "y", "width", "height") + } + shifted = {k: v for k, v in deltas.items() if v > EPSILON} + if shifted: + raise cmuxError( + "Container frame shifted " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sample={sample}, " + f"baseline={baseline}, current={current}, deltas={deltas}, epsilon={EPSILON}); " + f"recent_actions={trace}" + ) + + +def _assert_two_panes_left_right(layout_payload: dict, *, workspace_index: int, cycle: int, trace: list[str]) -> None: + panes = _pane_frames_sorted_x(layout_payload) + if len(panes) != 2: + raise cmuxError( + f"Expected exactly 2 panes in two-pane phase, got {len(panes)} " + f"(workspace={workspace_index}, cycle={cycle}); panes={panes}; recent_actions={trace}" + ) + + left, right = panes[0], panes[1] + if left["width"] <= 0.0 or left["height"] <= 0.0 or right["width"] <= 0.0 or right["height"] <= 0.0: + raise cmuxError( + f"Collapsed pane in two-pane phase (workspace={workspace_index}, cycle={cycle}): " + f"left={left} right={right}; recent_actions={trace}" + ) + + if left["x"] >= right["x"]: + raise cmuxError( + f"Two-pane geometry is not left/right (workspace={workspace_index}, cycle={cycle}): " + f"left={left} right={right}; recent_actions={trace}" + ) + + +def _selected_panel_by_pane(layout_payload: dict) -> dict[str, str]: + out: dict[str, str] = {} + for row in layout_payload.get("selectedPanels") or []: + pane_id = str(row.get("paneId") or "") + panel_id = str(row.get("panelId") or "") + if pane_id and panel_id: + out[pane_id] = panel_id + return out + + +def _rightmost_pane_id(layout_payload: dict) -> str: + panes = _pane_frames_sorted_x(layout_payload) + if len(panes) < 2: + raise cmuxError(f"Expected at least 2 panes to resolve rightmost pane: {panes}") + pane_id = str(panes[-1].get("pane_id") or "") + if not pane_id: + raise cmuxError(f"Rightmost pane is missing pane_id: {panes[-1]}") + return pane_id + + +def _rightmost_panel_id(layout_payload: dict) -> str: + pane_id = _rightmost_pane_id(layout_payload) + selected = _selected_panel_by_pane(layout_payload) + panel_id = str(selected.get(pane_id) or "") + if not panel_id: + raise cmuxError(f"Missing selected panel for rightmost pane: pane_id={pane_id}, selected={selected}") + return panel_id + + +def _safe_layout_debug(c: cmux, *, timeout_state: dict[str, int], context: str) -> dict: + for attempt in range(0, max(0, LAYOUT_TIMEOUT_RETRIES) + 1): + try: + return c.layout_debug() + except cmuxError as exc: + if "timed out waiting for response" not in str(exc).lower(): + raise + + timeout_state["count"] = timeout_state.get("count", 0) + 1 + count = timeout_state["count"] + if count > max(0, MAX_LAYOUT_TIMEOUTS): + raise cmuxError( + f"Exceeded layout_debug timeout budget (count={count}, max={MAX_LAYOUT_TIMEOUTS}, context={context})" + ) from exc + + if attempt >= max(0, LAYOUT_TIMEOUT_RETRIES): + raise cmuxError( + f"layout_debug timed out after retries (attempts={attempt + 1}, count={count}, context={context})" + ) from exc + + if LAYOUT_TIMEOUT_RETRY_SLEEP_S > 0: + time.sleep(LAYOUT_TIMEOUT_RETRY_SLEEP_S) + + raise cmuxError(f"layout_debug retry loop exhausted unexpectedly (context={context})") + + +def _sample_while( + c: cmux, + *, + baseline: dict, + deadline: float, + workspace_index: int, + cycle: int, + phase: str, + trace: list[str], + timeout_state: dict[str, int], +) -> int: + sampled = 0 + while time.time() < deadline: + payload = _safe_layout_debug( + c, + timeout_state=timeout_state, + context=f"sample workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}", + ) + current = _container_frame(payload) + _assert_same_frame( + current=current, + baseline=baseline, + workspace_index=workspace_index, + cycle=cycle, + phase=phase, + sample=sampled, + trace=trace, + ) + + panes_now = _pane_count(payload) + if panes_now > 2: + raise cmuxError( + f"Observed >2 panes in strict two-pane fuzz " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); " + f"recent_actions={trace}" + ) + sampled += 1 + if LAYOUT_POLL_SLEEP_S > 0: + time.sleep(LAYOUT_POLL_SLEEP_S) + return sampled + + +def _wait_for_panes( + c: cmux, + *, + target_panes: int, + baseline: dict, + workspace_index: int, + cycle: int, + phase: str, + timeout_s: float, + trace: list[str], + timeout_state: dict[str, int], +) -> tuple[dict, int]: + deadline = time.time() + timeout_s + sampled = 0 + last = None + + while time.time() < deadline: + payload = _safe_layout_debug( + c, + timeout_state=timeout_state, + context=f"wait workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}", + ) + last = payload + current = _container_frame(payload) + _assert_same_frame( + current=current, + baseline=baseline, + workspace_index=workspace_index, + cycle=cycle, + phase=phase, + sample=sampled, + trace=trace, + ) + + panes_now = _pane_count(payload) + if panes_now > 2: + raise cmuxError( + f"Observed >2 panes in strict two-pane fuzz while waiting " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); " + f"recent_actions={trace}" + ) + if panes_now == target_panes: + return payload, sampled + 1 + sampled += 1 + if LAYOUT_POLL_SLEEP_S > 0: + time.sleep(LAYOUT_POLL_SLEEP_S) + + raise cmuxError( + f"Timed out waiting for {target_panes} panes " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sampled={sampled}, " + f"last_panes={_pane_count(last or {})}, timeout_s={timeout_s}); recent_actions={trace}" + ) + + +def _wait_for_single_pane_after_ctrl_d( + c: cmux, + *, + baseline: dict, + workspace_index: int, + cycle: int, + phase: str, + timeout_s: float, + recent_actions: deque[str], + timeout_state: dict[str, int], +) -> tuple[dict, int, int]: + deadline = time.time() + timeout_s + sampled = 0 + extra_ctrl_d = 0 + last = None + next_retry_at = time.time() + max(0.0, CTRL_D_RETRY_INTERVAL_S) + + while time.time() < deadline: + payload = _safe_layout_debug( + c, + timeout_state=timeout_state, + context=f"wait workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}", + ) + last = payload + current = _container_frame(payload) + trace = list(recent_actions) + _assert_same_frame( + current=current, + baseline=baseline, + workspace_index=workspace_index, + cycle=cycle, + phase=phase, + sample=sampled, + trace=trace, + ) + + panes_now = _pane_count(payload) + if panes_now > 2: + raise cmuxError( + f"Observed >2 panes in strict two-pane fuzz while waiting " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); " + f"recent_actions={trace}" + ) + if panes_now == 1: + return payload, sampled + 1, extra_ctrl_d + + now = time.time() + if panes_now == 2 and extra_ctrl_d < max(0, CTRL_D_MAX_EXTRA) and now >= next_retry_at: + retry_right_panel_id = _rightmost_panel_id(payload) + try: + c.send_key_surface(retry_right_panel_id, "ctrl-d") + except cmuxError as exc: + # Pane/surface can disappear between layout sample and send call under heavy churn. + # Skip this retry tick and re-sample. + if "not_found" in str(exc).lower(): + next_retry_at = now + max(0.0, CTRL_D_RETRY_INTERVAL_S) + sampled += 1 + if LAYOUT_POLL_SLEEP_S > 0: + time.sleep(LAYOUT_POLL_SLEEP_S) + continue + raise + extra_ctrl_d += 1 + recent_actions.append( + f"ws={workspace_index} cycle={cycle} action=ctrl+d(extra:{extra_ctrl_d}/{CTRL_D_MAX_EXTRA},surface={retry_right_panel_id})" + ) + next_retry_at = now + max(0.0, CTRL_D_RETRY_INTERVAL_S) + + sampled += 1 + if LAYOUT_POLL_SLEEP_S > 0: + time.sleep(LAYOUT_POLL_SLEEP_S) + + raise cmuxError( + f"Timed out waiting for 1 pane after ctrl+d " + f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sampled={sampled}, " + f"extra_ctrl_d={extra_ctrl_d}, last_panes={_pane_count(last or {})}, timeout_s={timeout_s}); " + f"recent_actions={list(recent_actions)}" + ) + + +def main() -> int: + rng = random.Random(FUZZ_SEED) + recent_actions: deque[str] = deque(maxlen=max(8, TRACE_TAIL)) + total_samples = 0 + total_cycles = 0 + total_extra_ctrl_d = 0 + timeout_state: dict[str, int] = {"count": 0} + + with cmux(SOCKET_PATH) as c: + c.activate_app() + + for workspace_index in range(1, WORKSPACES + 1): + ws = c.new_workspace() + c.select_workspace(ws) + c.activate_app() + time.sleep(0.08) + + start = _safe_layout_debug(c, timeout_state=timeout_state, context=f"workspace={workspace_index} start") + baseline = _container_frame(start) + start_panes = _pane_count(start) + if start_panes != 1: + raise cmuxError(f"New workspace did not start as single pane (workspace={workspace_index}, panes={start_panes})") + + for cycle in range(1, CYCLES_PER_WORKSPACE + 1): + total_cycles += 1 + + if PRE_ACTION_JITTER_MAX_S > 0: + time.sleep(rng.uniform(0.0, PRE_ACTION_JITTER_MAX_S)) + + recent_actions.append(f"ws={workspace_index} cycle={cycle} action=cmd+d") + c.simulate_shortcut("cmd+d") + + after_split, sampled = _wait_for_panes( + c, + target_panes=2, + baseline=baseline, + workspace_index=workspace_index, + cycle=cycle, + phase="after_cmd+d", + timeout_s=TRANSITION_TIMEOUT_S, + trace=list(recent_actions), + timeout_state=timeout_state, + ) + total_samples += sampled + _assert_two_panes_left_right(after_split, workspace_index=workspace_index, cycle=cycle, trace=list(recent_actions)) + + hold_split = rng.uniform(HOLD_MIN_S, HOLD_MAX_S) + total_samples += _sample_while( + c, + baseline=baseline, + deadline=time.time() + hold_split, + workspace_index=workspace_index, + cycle=cycle, + phase="hold_2pane", + trace=list(recent_actions), + timeout_state=timeout_state, + ) + + if PRE_ACTION_JITTER_MAX_S > 0: + time.sleep(rng.uniform(0.0, PRE_ACTION_JITTER_MAX_S)) + + right_panel_id = _rightmost_panel_id(after_split) + recent_actions.append(f"ws={workspace_index} cycle={cycle} action=ctrl+d(surface={right_panel_id})") + c.send_key_surface(right_panel_id, "ctrl-d") + + _, sampled, extra_ctrl_d = _wait_for_single_pane_after_ctrl_d( + c, + baseline=baseline, + workspace_index=workspace_index, + cycle=cycle, + phase="after_ctrl+d", + timeout_s=TRANSITION_TIMEOUT_S, + recent_actions=recent_actions, + timeout_state=timeout_state, + ) + total_samples += sampled + total_extra_ctrl_d += extra_ctrl_d + + hold_single = rng.uniform(HOLD_MIN_S, HOLD_MAX_S) + total_samples += _sample_while( + c, + baseline=baseline, + deadline=time.time() + hold_single, + workspace_index=workspace_index, + cycle=cycle, + phase="hold_1pane", + trace=list(recent_actions), + timeout_state=timeout_state, + ) + + c.close_workspace(ws) + time.sleep(0.05) + + print( + "PASS: strict two-pane cmd+d/ctrl+d frame guard " + f"(seed={FUZZ_SEED}, workspaces={WORKSPACES}, cycles={total_cycles}, samples={total_samples}, " + f"extra_ctrl_d={total_extra_ctrl_d}, epsilon={EPSILON}, layout_timeouts={timeout_state.get('count', 0)})" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())