From aeabcdd58352c3ade82d69e914ad568dbca72578 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:13:33 -0800 Subject: [PATCH 01/17] Fix titlebar folder icon drag hit-testing --- Sources/WindowDragHandleView.swift | 25 ++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 55 +++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index e534e1bc..da9127e4 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -1,6 +1,26 @@ import AppKit import SwiftUI +/// 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 { + guard dragHandleView.bounds.contains(point) else { return false } + guard let superview = dragHandleView.superview else { return true } + + 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 sibling.hitTest(pointInSibling) != nil { + return false + } + } + + 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. @@ -15,7 +35,8 @@ struct WindowDragHandleView: NSViewRepresentable { private final class DraggableView: NSView { override var mouseDownCanMoveWindow: Bool { true } - override func hitTest(_ point: NSPoint) -> NSView? { self } + override func hitTest(_ point: NSPoint) -> NSView? { + windowDragHandleShouldCaptureHit(point, in: self) ? self : nil + } } } - diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 45e2a54e..9a2fa303 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3816,6 +3816,61 @@ 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 + } + } + + 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)) + } +} + @MainActor final class GhosttySurfaceOverlayTests: XCTestCase { func testInactiveOverlayVisibilityTracksRequestedState() { From df779d32eaf3d5d3ac3bcd0b9edb0c88cafcea36 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:54:02 -0800 Subject: [PATCH 02/17] Portal: hide terminal view before hidden-frame updates --- Sources/TerminalWindowPortal.swift | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 07831c71..0fbde41d 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -870,6 +870,22 @@ 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) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" + ) +#endif + hostedView.isHidden = true + } + if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { CATransaction.begin() CATransaction.setDisableActions(true) @@ -877,21 +893,22 @@ final class WindowTerminalPortal: NSObject { CATransaction.commit() if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || - abs(oldFrame.size.height - frameInHost.size.height) > 0.5 { + abs(oldFrame.size.height - frameInHost.size.height) > 0.5, + !shouldHide { hostedView.reconcileGeometryNow() } } - if hostedView.isHidden != shouldHide { + if !shouldHide, hostedView.isHidden { #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) " + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" ) #endif - hostedView.isHidden = shouldHide + hostedView.isHidden = false } ensureDividerOverlayOnTop() From b34b3a530ae93eb839eabcab9e0b956f9999f168 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:57:23 -0800 Subject: [PATCH 03/17] Portal: log Bonsplit container frame changes --- Sources/TerminalWindowPortal.swift | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 0fbde41d..a2f0c8c8 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 { @@ -536,6 +542,9 @@ final class WindowTerminalPortal: NSObject { private weak var installedReferenceView: NSView? private var installConstraints: [NSLayoutConstraint] = [] private var hasDeferredFullSyncScheduled = false +#if DEBUG + private var lastLoggedBonsplitContainerSignature: String? +#endif private struct Entry { weak var hostedView: GhosttySurfaceScrollView? @@ -649,6 +658,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 + func detachHostedView(withId hostedId: ObjectIdentifier) { guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } if let anchor = entry.anchorView { @@ -839,6 +877,9 @@ final class WindowTerminalPortal: NSObject { let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) let frameInHost = hostView.convert(frameInWindow, from: nil) +#if DEBUG + logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView) +#endif let hasFiniteFrame = frameInHost.origin.x.isFinite && frameInHost.origin.y.isFinite && From f3fc8804684a81b10d48da4a4f01dac6f5838c85 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:58:17 -0800 Subject: [PATCH 04/17] Guard self-hosted CI from fork pull requests --- .github/workflows/ci.yml | 11 +++++++++++ tests/test_ci_self_hosted_guard.sh | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100755 tests/test_ci_self_hosted_guard.sh 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/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" From c5d20ae0320f9a981e835691b7a16ac7b19f0c53 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:02:48 -0800 Subject: [PATCH 05/17] Add Cmd+P open-directory shortcuts for installed apps (#368) * Add smart Cmd+P directory-open app shortcuts * Fix command palette scroll snap and jank * Fix command palette selection-follow scrolling * Use scrollPosition for command palette list scrolling * Remove generic IDE directory command from Cmd+P * Increase command palette max height to 450px --- Sources/AppDelegate.swift | 182 +++++++++ Sources/ContentView.swift | 359 ++++++++---------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 166 ++++---- 3 files changed, 410 insertions(+), 297 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 11fca42f..ccf70a6a 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. diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2325908c..51c4c694 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -977,14 +977,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 @@ -1120,8 +1112,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) @@ -1197,11 +1189,6 @@ struct ContentView: View { case kind } - enum CommandPaletteScrollAnchor: Equatable { - case top - case bottom - } - private struct CommandPaletteTrailingLabel { let text: String let style: CommandPaletteTrailingLabelStyle @@ -1277,6 +1264,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 { @@ -2444,7 +2435,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 @@ -2488,133 +2479,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. @@ -2629,20 +2572,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 } @@ -3245,18 +3187,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 { @@ -3667,15 +3620,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", @@ -3938,9 +3896,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") { @@ -4004,61 +3964,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) { @@ -4252,8 +4194,8 @@ struct ContentView: View { commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil resetCommandPaletteSearchFocus() syncCommandPaletteDebugStateForObservedWindow() } @@ -4266,8 +4208,8 @@ struct ContentView: View { commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil isCommandPaletteSearchFocused = false isCommandPaletteRenameFocused = false commandPaletteRestoreFocusTarget = nil @@ -4494,9 +4436,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? { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c12d2c08..ba914a50 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1491,115 +1491,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 +3211,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")) From 310d807767f7e21f450a4ca70a0e31628efc740a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:28:23 -0800 Subject: [PATCH 06/17] Add VM split-churn fuzz tests and harden portal reveal gating --- Sources/TerminalWindowPortal.swift | 32 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 52 ++ .../test_split_cmd_d_ctrl_d_geometry_fuzz.py | 211 ++++++++ ...split_cmd_d_ctrl_d_two_pane_frame_guard.py | 487 ++++++++++++++++++ 4 files changed, 777 insertions(+), 5 deletions(-) create mode 100644 tests_v2/test_split_cmd_d_ctrl_d_geometry_fuzz.py create mode 100644 tests_v2/test_split_cmd_d_ctrl_d_two_pane_frame_guard.py diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index a2f0c8c8..4c0dc9c3 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -535,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) @@ -886,7 +890,12 @@ final class WindowTerminalPortal: NSObject { frameInHost.size.width.isFinite && frameInHost.size.height.isFinite let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) - let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1 + let tinyFrame = + frameInHost.width <= Self.tinyHideThreshold || + frameInHost.height <= Self.tinyHideThreshold + let revealReadyForDisplay = + frameInHost.width >= Self.minimumRevealWidth && + frameInHost.height >= Self.minimumRevealHeight let outsideHostBounds = !frameInHost.intersects(hostView.bounds) let shouldHide = !entry.visibleInUI || @@ -894,6 +903,7 @@ final class WindowTerminalPortal: NSObject { tinyFrame || !hasFiniteFrame || outsideHostBounds + let shouldDeferReveal = !shouldHide && hostedView.isHidden && !revealReadyForDisplay let oldFrame = hostedView.frame #if DEBUG @@ -920,7 +930,7 @@ final class WindowTerminalPortal: NSObject { dlog( "portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " + "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(frameInHost))" ) #endif @@ -935,17 +945,29 @@ final class WindowTerminalPortal: NSObject { if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || abs(oldFrame.size.height - frameInHost.size.height) > 0.5, - !shouldHide { + !shouldHide, + (!hostedView.isHidden || revealReadyForDisplay) { hostedView.reconcileGeometryNow() } } - if !shouldHide, hostedView.isHidden { + 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=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(frameInHost))" ) #endif diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8341c5f1..92e0fcd0 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -4364,6 +4364,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), @@ -4553,6 +4561,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_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()) From 561f052fdd26f81338feefe704fdd1f9da63ea23 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:31:24 -0800 Subject: [PATCH 07/17] Use theme background for browser omnibar chrome in light mode --- Sources/Panels/BrowserPanelView.swift | 24 ++++++++----- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index ac19b086..f3e7e861 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -166,6 +166,18 @@ private extension View { } } +func resolvedBrowserChromeBackgroundColor( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor +) -> NSColor { + switch colorScheme { + case .dark, .light: + return themeBackgroundColor + @unknown default: + return themeBackgroundColor + } +} + /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel @@ -239,14 +251,10 @@ 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 + ) } var body: some View { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index ba914a50..e7205bb1 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -655,6 +655,40 @@ 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 BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { func testSafariDefaultShortcutForToggleDeveloperTools() { let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut From 0eef387d5dfab57a2c598c4ec14715a7582459c9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:55:01 -0800 Subject: [PATCH 08/17] Tint browser omnibar pill with theme accent --- Sources/Panels/BrowserPanelView.swift | 28 ++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 40 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f3e7e861..abe6975d 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -178,6 +178,24 @@ func resolvedBrowserChromeBackgroundColor( } } +func resolvedBrowserOmnibarPillBackgroundColor( + for colorScheme: ColorScheme, + themeBackgroundColor: NSColor, + accentColor: NSColor +) -> NSColor { + let accentMix: CGFloat + switch colorScheme { + case .light: + accentMix = 0.08 + case .dark: + accentMix = 0.12 + @unknown default: + accentMix = 0.08 + } + + return themeBackgroundColor.blended(withFraction: accentMix, of: accentColor) ?? themeBackgroundColor +} + /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel @@ -257,6 +275,14 @@ struct BrowserPanelView: View { ) } + private var omnibarPillBackgroundColor: NSColor { + resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: browserChromeBackgroundColor, + accentColor: .controlAccentColor + ) + } + var body: some View { VStack(spacing: 0) { addressBar @@ -656,7 +682,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) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index e7205bb1..201942d3 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -689,6 +689,46 @@ final class BrowserPanelChromeBackgroundColorTests: XCTestCase { } } +final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { + func testLightModeUsesSubtleAccentTintOverThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .light, accentMix: 0.08) + } + + func testDarkModeUsesSlightlyStrongerAccentTintOverThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .dark, accentMix: 0.12) + } + + private func assertResolvedColorMatchesExpectedBlend( + for colorScheme: ColorScheme, + accentMix: CGFloat, + file: StaticString = #filePath, + line: UInt = #line + ) { + let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) + let accent = NSColor(srgbRed: 0.25, green: 0.47, blue: 0.92, alpha: 1.0) + let expected = themeBackground.blended(withFraction: accentMix, of: accent) ?? themeBackground + + guard + let actual = resolvedBrowserOmnibarPillBackgroundColor( + for: colorScheme, + themeBackgroundColor: themeBackground, + accentColor: accent + ).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 From 41b24b788316c4cc154a6248636f3e476bdf0a92 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:01:35 -0800 Subject: [PATCH 09/17] Guard split shortcuts during transient focus fallback --- Sources/AppDelegate.swift | 60 +++++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 46 ++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f6811ae8..6d789500 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -183,6 +183,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, @@ -2425,11 +2436,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Split actions: Cmd+D / Cmd+Shift+D if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) { + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { + return true + } _ = performSplitShortcut(direction: .right) return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) { + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { + return true + } _ = performSplitShortcut(direction: .down) return true } @@ -2564,6 +2581,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, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 92e0fcd0..682e33c8 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 From 0d03b58be8f58c2870e4e19b4e85aac2e65bc390 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:02:19 -0800 Subject: [PATCH 10/17] Tune omnibar pill tint toward theme background --- Sources/Panels/BrowserPanelView.swift | 6 +++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index abe6975d..7aa2a29b 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -186,11 +186,11 @@ func resolvedBrowserOmnibarPillBackgroundColor( let accentMix: CGFloat switch colorScheme { case .light: - accentMix = 0.08 + accentMix = 0.02 case .dark: - accentMix = 0.12 + accentMix = 0.03 @unknown default: - accentMix = 0.08 + accentMix = 0.02 } return themeBackgroundColor.blended(withFraction: accentMix, of: accentColor) ?? themeBackgroundColor diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 201942d3..0bce884b 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -691,11 +691,11 @@ final class BrowserPanelChromeBackgroundColorTests: XCTestCase { final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { func testLightModeUsesSubtleAccentTintOverThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .light, accentMix: 0.08) + assertResolvedColorMatchesExpectedBlend(for: .light, accentMix: 0.02) } func testDarkModeUsesSlightlyStrongerAccentTintOverThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .dark, accentMix: 0.12) + assertResolvedColorMatchesExpectedBlend(for: .dark, accentMix: 0.03) } private func assertResolvedColorMatchesExpectedBlend( From 0d98db72774eb58625c824df7b4e4299f21b5100 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:03:55 -0800 Subject: [PATCH 11/17] Fix titlebar text dragging while preserving folder icon drag --- Sources/AppDelegate.swift | 66 +++++ Sources/ContentView.swift | 137 +++++++++- Sources/WindowDragHandleView.swift | 215 ++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 253 ++++++++++++++++++ 4 files changed, 656 insertions(+), 15 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7bafbc8e..936f5475 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -193,6 +193,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? @@ -288,6 +311,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 @@ -787,6 +820,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = false + window.isMovable = false window.center() window.contentView = NSHostingView(rootView: root) @@ -1546,11 +1580,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent static func installWindowResponderSwizzlesForTesting() { _ = didInstallWindowKeyEquivalentSwizzle _ = didInstallWindowFirstResponderSwizzle + _ = didInstallWindowSendEventSwizzle } private func installWindowResponderSwizzles() { _ = Self.didInstallWindowKeyEquivalentSwizzle _ = Self.didInstallWindowFirstResponderSwizzle + _ = Self.didInstallWindowSendEventSwizzle } private func installShortcutMonitor() { @@ -3869,6 +3905,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 7a8cb326..0d216936 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1211,6 +1211,7 @@ struct ContentView: View { WindowDragHandleView() TitlebarLeadingInsetReader(inset: $titlebarLeadingInset) + .allowsHitTesting(false) HStack(spacing: 8) { if isFullScreen && !sidebarState.isVisible { @@ -1226,6 +1227,7 @@ struct ContentView: View { .font(.system(size: 13, weight: .bold)) .foregroundColor(fakeTitlebarTextColor) .lineLimit(1) + .allowsHitTesting(false) Spacer() @@ -1238,9 +1240,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() @@ -1540,6 +1539,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 @@ -4042,9 +4044,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 @@ -4060,8 +4074,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) @@ -4086,9 +4102,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) @@ -4097,7 +4144,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) { @@ -4166,6 +4225,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 @@ -4247,11 +4359,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/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index da9127e4..ebc62a05 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -1,23 +1,184 @@ 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 { - guard dragHandleView.bounds.contains(point) else { return false } - guard let superview = dragHandleView.superview else { return true } + 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 sibling.hitTest(pointInSibling) != nil { + 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 } @@ -34,9 +195,53 @@ struct WindowDragHandleView: NSViewRepresentable { } private final class DraggableView: NSView { - override var mouseDownCanMoveWindow: Bool { true } + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { - windowDragHandleShouldCaptureHit(point, in: self) ? self : nil + 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 9a2fa303..f97bc015 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -3824,6 +3824,14 @@ final class WindowDragHandleHitTests: XCTestCase { } } + 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) @@ -3869,6 +3877,251 @@ final class WindowDragHandleHitTests: XCTestCase { 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 From cfce7e93e0e3f44843d5559549ecbce9bb890fbb Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:08:46 -0800 Subject: [PATCH 12/17] Darken omnibar pill relative to theme background --- Sources/Panels/BrowserPanelView.swift | 16 +++++++--------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 16 +++++++--------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 7aa2a29b..f91855dd 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -180,20 +180,19 @@ func resolvedBrowserChromeBackgroundColor( func resolvedBrowserOmnibarPillBackgroundColor( for colorScheme: ColorScheme, - themeBackgroundColor: NSColor, - accentColor: NSColor + themeBackgroundColor: NSColor ) -> NSColor { - let accentMix: CGFloat + let darkenMix: CGFloat switch colorScheme { case .light: - accentMix = 0.02 + darkenMix = 0.04 case .dark: - accentMix = 0.03 + darkenMix = 0.05 @unknown default: - accentMix = 0.02 + darkenMix = 0.04 } - return themeBackgroundColor.blended(withFraction: accentMix, of: accentColor) ?? themeBackgroundColor + return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor } /// View for rendering a browser panel with address bar @@ -278,8 +277,7 @@ struct BrowserPanelView: View { private var omnibarPillBackgroundColor: NSColor { resolvedBrowserOmnibarPillBackgroundColor( for: colorScheme, - themeBackgroundColor: browserChromeBackgroundColor, - accentColor: .controlAccentColor + themeBackgroundColor: browserChromeBackgroundColor ) } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0bce884b..c3a0ef37 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -690,29 +690,27 @@ final class BrowserPanelChromeBackgroundColorTests: XCTestCase { } final class BrowserPanelOmnibarPillBackgroundColorTests: XCTestCase { - func testLightModeUsesSubtleAccentTintOverThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .light, accentMix: 0.02) + func testLightModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .light, darkenMix: 0.04) } - func testDarkModeUsesSlightlyStrongerAccentTintOverThemeBackground() { - assertResolvedColorMatchesExpectedBlend(for: .dark, accentMix: 0.03) + func testDarkModeSlightlyDarkensThemeBackground() { + assertResolvedColorMatchesExpectedBlend(for: .dark, darkenMix: 0.05) } private func assertResolvedColorMatchesExpectedBlend( for colorScheme: ColorScheme, - accentMix: CGFloat, + darkenMix: CGFloat, file: StaticString = #filePath, line: UInt = #line ) { let themeBackground = NSColor(srgbRed: 0.94, green: 0.93, blue: 0.91, alpha: 1.0) - let accent = NSColor(srgbRed: 0.25, green: 0.47, blue: 0.92, alpha: 1.0) - let expected = themeBackground.blended(withFraction: accentMix, of: accent) ?? themeBackground + let expected = themeBackground.blended(withFraction: darkenMix, of: .black) ?? themeBackground guard let actual = resolvedBrowserOmnibarPillBackgroundColor( for: colorScheme, - themeBackgroundColor: themeBackground, - accentColor: accent + themeBackgroundColor: themeBackground ).usingColorSpace(.sRGB), let expectedSRGB = expected.usingColorSpace(.sRGB), let themeSRGB = themeBackground.usingColorSpace(.sRGB) From 53ef6a5f7dddb79b035de2e92d30f20fcea0c391 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:11:01 -0800 Subject: [PATCH 13/17] Upgrade Sentry: tracing, breadcrumbs, dSYM upload (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Upgrade Sentry: tracing, breadcrumbs, dSYM upload - Enhanced Sentry SDK init with performance tracing (10% sample), explicit app hang timeout, stack trace attachment, and HTTP failure capture - Added breadcrumbs for key user actions: workspace switch/create/close, split creation, command palette open/close, app focus — these give context to hang/crash reports - Added dSYM upload step to nightly and release CI workflows so hang stacks are fully symbolicated (requires SENTRY_AUTH_TOKEN secret) - Created SentryHelper.swift with lightweight breadcrumb helper Closes https://github.com/manaflow-ai/cmux/issues/365 * Remove command palette breadcrumbs Not useful for hang diagnosis — keep only workspace/tab/split/focus breadcrumbs that correlate with heavy operations. --- .github/workflows/nightly.yml | 13 +++++++++++++ .github/workflows/release.yml | 14 ++++++++++++++ GhosttyTabs.xcodeproj/project.pbxproj | 4 ++++ Sources/AppDelegate.swift | 12 ++++++++++++ Sources/SentryHelper.swift | 9 +++++++++ Sources/TabManager.swift | 6 ++++++ 6 files changed, 58 insertions(+) create mode 100644 Sources/SentryHelper.swift 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 58641e08..3448b298 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 */; }; @@ -146,6 +147,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 = ""; }; @@ -322,6 +324,7 @@ A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, + A5001600 /* SentryHelper.swift */, A5001090 /* AppDelegate.swift */, A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, @@ -551,6 +554,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 5fc94aa0..ef7215c0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -699,6 +699,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 { @@ -804,6 +813,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") 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 0e38e366..5a59b82a 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) } From ed0dd1ccb71f7cfc8f3e7bc476baa5beb3b82da5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:39:58 -0800 Subject: [PATCH 14/17] Make omnibar suggestions popup/rows squircle --- Sources/Panels/BrowserPanelView.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f91855dd..c98e913a 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2627,10 +2627,10 @@ private struct OmnibarSuggestionsView: View { let onCommit: (OmnibarSuggestion) -> Void let onHighlight: (Int) -> Void - // 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 @@ -2802,8 +2802,9 @@ private struct OmnibarSuggestionsView: View { lineWidth: 1 ) ) + .clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) .shadow(color: Color.black.opacity(0.45), radius: 20, y: 10) - .contentShape(Rectangle()) + .contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) .accessibilityElement(children: .contain) .accessibilityRespondsToUserInteraction(true) .accessibilityIdentifier("BrowserOmnibarSuggestions") From b87d4fecda028ba96850b081ef5b0b711eafbd82 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:43:39 -0800 Subject: [PATCH 15/17] Move omnibar suggestions popover up by 2px --- Sources/Panels/BrowserPanelView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index c98e913a..5da2592a 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -310,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 + 4) .zIndex(1000) } } From 82ef5b8f6ee574035b2c1d0b99fcbf01d9f1a6cd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:51:32 -0800 Subject: [PATCH 16/17] Move omnibar suggestions popover up 1px --- Sources/Panels/BrowserPanelView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 5da2592a..88cbfb56 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -310,7 +310,7 @@ struct BrowserPanelView: View { } ) .frame(width: omnibarPillFrame.width) - .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 4) + .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3) .zIndex(1000) } } From 05101a1a104e3c544eb52d752330ce94f1ec0d09 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:06:50 -0800 Subject: [PATCH 17/17] Fix light theme omnibar suggestions popover styling --- Sources/Panels/BrowserPanelView.swift | 116 +++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 13 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 88cbfb56..f91b71e8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -2626,6 +2626,7 @@ private struct OmnibarSuggestionsView: View { let searchSuggestionsEnabled: Bool let onCommit: (OmnibarSuggestion) -> Void let onHighlight: (Int) -> Void + @Environment(\.colorScheme) private var colorScheme // Keep radii below half of the smallest rendered heights so this keeps a // squircle silhouette instead of auto-clamping into a capsule. @@ -2683,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) { @@ -2696,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) @@ -2723,7 +2819,7 @@ private struct OmnibarSuggestionsView: View { RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous) .fill( idx == selectedIndex - ? Color.white.opacity(0.12) + ? rowHighlightColor : Color.clear ) ) @@ -2778,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 ) @@ -2792,10 +2885,7 @@ 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 ), @@ -2803,7 +2893,7 @@ private struct OmnibarSuggestionsView: View { ) ) .clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) - .shadow(color: Color.black.opacity(0.45), radius: 20, y: 10) + .shadow(color: popupShadowColor, radius: 20, y: 10) .contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)) .accessibilityElement(children: .contain) .accessibilityRespondsToUserInteraction(true)