Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5083006
Add minimal tab strip portal routing regression test
austinywang May 18, 2026
9566f28
Let terminal portal defer to registered minimal tab strip
austinywang May 18, 2026
2c9001d
Merge remote-tracking branch 'origin/main' into issue-4289-minimal-mo…
austinywang May 18, 2026
2d5517d
Add minimal titlebar implicit drag regression test
austinywang May 18, 2026
e794174
Prevent minimal pane tabs from moving window
austinywang May 18, 2026
38e4768
Mark minimal tab chrome drag affordance
austinywang May 18, 2026
a7c020c
Keep pane tab drag suppression active until mouse up
austinywang May 18, 2026
735dde1
Keep titlebar drag handle out of pane tabs
austinywang May 18, 2026
24fa6d4
Reset window move suppression on fresh mouse down
austinywang May 18, 2026
73583e5
Keep suppression cleanup on active sequence path
austinywang May 18, 2026
0180861
Use shared hit testing for suppression debug logs
austinywang May 18, 2026
df19bca
Clean up window move suppression state
austinywang May 18, 2026
21cbeff
Keep minimal tab pixels out of window drag overlay
austinywang May 18, 2026
a0c3dd7
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang May 19, 2026
4cb077d
Make main window movement explicit for pane tabs
austinywang May 19, 2026
a08f374
Update Bonsplit minimal tab hit region
austinywang May 19, 2026
65067cc
Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue…
austinywang May 19, 2026
dc1e31c
test: share Bonsplit tab region helper
austinywang May 19, 2026
2df920f
Fix window move suppression cleanup
austinywang May 19, 2026
f4bdfea
Merge remote-tracking branch 'origin/main' into issue-4289-minimal-mo…
austinywang May 20, 2026
36fc880
Update Bonsplit minimal tab drag hit testing
austinywang May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Sources/App/CmuxMainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ final class MainWindowHostingView<Content: View>: NSHostingView<Content> {
override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero }
override var safeAreaRect: NSRect { bounds }
override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide }
override var mouseDownCanMoveWindow: Bool { false }

required init(rootView: Content) {
super.init(rootView: rootView)
Expand All @@ -27,6 +28,12 @@ final class MainWindowHostingView<Content: View>: NSHostingView<Content> {
}
}

@MainActor
func configureCmuxMainWindowDragBehavior(_ window: NSWindow) {
window.isMovableByWindowBackground = false
window.isMovable = false
}

@MainActor
final class CmuxMainWindow: NSWindow {
private var isSoftHiddenForVisibilityController = false
Expand Down
50 changes: 50 additions & 0 deletions Sources/App/ShortcutRoutingSupport.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import Bonsplit
import Foundation

func browserOmnibarSelectionDeltaForControlNavigation(
Expand Down Expand Up @@ -756,3 +757,52 @@ func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) ->
let hitView = contentView.hitTest(contentPoint)
return shouldSuppressWindowMoveForFolderDrag(hitView: hitView)
}

enum WindowMoveSuppressionReason: String {
case folderDrag
case bonsplitPaneTabDrag
}

func shouldSuppressWindowMoveForBonsplitPaneTabDrag(window: NSWindow, event: NSEvent) -> Bool {
guard event.type == .leftMouseDown else {
return false
}

return BonsplitTabItemHitRegionRegistry.containsWindowPoint(event.locationInWindow, in: window)
}

func windowMoveSuppressionReason(window: NSWindow, event: NSEvent) -> WindowMoveSuppressionReason? {
if shouldSuppressWindowMoveForFolderDrag(window: window, event: event) {
return .folderDrag
}
if shouldSuppressWindowMoveForBonsplitPaneTabDrag(window: window, event: event) {
return .bonsplitPaneTabDrag
}
return nil
}

func beginOrContinueWindowMoveSuppressionSequenceForEvent(
window: NSWindow,
event: NSEvent,
pressedMouseButtons: Int = NSEvent.pressedMouseButtons
) -> WindowMoveSuppressionReason? {
if let activeReason = activeWindowMoveSuppressionSequenceReason(window: window) {
if event.type == .leftMouseDown {
_ = finishWindowMoveSuppressionSequence(window: window)
} else if event.type == .leftMouseUp || event.type == .leftMouseDragged || (pressedMouseButtons & 0x1) != 0 {
ensureWindowMoveSuppressionSequenceIsImmovable(window: window)
return activeReason
} else {
_ = finishWindowMoveSuppressionSequence(window: window)
}
}

guard let reason = windowMoveSuppressionReason(window: window, event: event) else {
return nil
}
return beginWindowMoveSuppressionSequence(window: window, reason: reason)
}

func shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: NSWindow, event: NSEvent) -> Bool {
activeWindowMoveSuppressionSequenceReason(window: window) != nil && event.type == .leftMouseUp
}
39 changes: 23 additions & 16 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7326,13 +7326,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// cmux persists and restores main windows itself. Disable AppKit window
// restoration so the OS cannot resurrect stale duplicate main windows.
window.isRestorable = false
window.isMovableByWindowBackground = false
// Keep background dragging disabled so app content gestures and titlebar
// controls still receive clicks, while the OS-level movable flag lets
// macOS tiling and window-management tools such as Swish treat cmux as
// a movable/resizable window. Empty titlebar drags are routed through
// WindowDragHandleView instead of background dragging.
window.isMovable = true
configureCmuxMainWindowDragBehavior(window)
let explicitInitialFrame = restoredFrame ?? persistedGeometryFrame
if let explicitInitialFrame {
window.setFrame(explicitInitialFrame, display: false)
Expand Down Expand Up @@ -15187,8 +15181,9 @@ private extension NSWindow {
cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber
}

guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event),
let contentView = self.contentView else {
let suppressionReason = beginOrContinueWindowMoveSuppressionSequenceForEvent(window: self, event: event)
let hasActiveSuppressionSequence = activeWindowMoveSuppressionSequenceReason(window: self) != nil
guard suppressionReason != nil || hasActiveSuppressionSequence else {
#if DEBUG
if event.type == .keyDown {
folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0
Expand All @@ -15207,21 +15202,33 @@ private extension NSWindow {
}
let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0
#endif
let shouldFinishSuppression = shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: self, event: event)

let contentPoint = contentView.convert(event.locationInWindow, from: nil)
let hitView = contentView.hitTest(contentPoint)
let previousMovableState = temporarilyDisableWindowDragging(window: self)
#if DEBUG
let hitView = Self.cmuxHitViewForEventDispatch(in: self, event: event)
#endif
defer {
restoreWindowDragging(window: self, previousMovableState: previousMovableState)
let finishedReason: WindowMoveSuppressionReason?
if shouldFinishSuppression {
finishedReason = finishWindowMoveSuppressionSequence(window: self)
} else {
finishedReason = nil
}
#if DEBUG
cmuxDebugLog("window.sendEvent.folderDown restore nowMovable=\(isMovable)")
let reasonDescription = finishedReason?.rawValue ?? suppressionReason?.rawValue ?? "activeSequence"
if shouldFinishSuppression {
cmuxDebugLog("window.sendEvent.\(reasonDescription) finish nowMovable=\(isMovable)")
} else {
cmuxDebugLog("window.sendEvent.\(reasonDescription) keepSuppressed nowMovable=\(isMovable)")
}
#endif
}

#if DEBUG
let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil"
let previousMovableDescription = previousMovableState.map { String($0) } ?? "nil"
cmuxDebugLog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableDescription)")
let depth = windowDragSuppressionDepth(window: self)
let reasonDescription = suppressionReason?.rawValue ?? "activeSequence"
cmuxDebugLog("window.sendEvent.\(reasonDescription) suppress=1 hit=\(hitDesc) movable=\(isMovable) depth=\(depth)")
#endif

cmux_sendEvent(event)
Expand Down
10 changes: 4 additions & 6 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3195,12 +3195,10 @@ struct ContentView: View {
window.isRestorable = false
setMinimalModeSidebarTitlebarControlsAvailable(sidebarState.isVisible, in: window)
window.titlebarAppearsTransparent = true
// Keep background dragging disabled so app content gestures and
// minimal-mode titlebar controls still receive clicks, while the
// window itself stays movable for macOS tiling and third-party
// window managers.
window.isMovableByWindowBackground = false
window.isMovable = true
// Native AppKit titlebar dragging steals pane-tab drags in minimal
// mode. Keep the main window immovable by default; explicit chrome
// drag zones temporarily enable performDrag for real app moves.
configureCmuxMainWindowDragBehavior(window)
window.styleMask.insert(.fullSizeContentView)

// Track this window for fullscreen notifications
Expand Down
3 changes: 3 additions & 0 deletions Sources/TerminalWindowPortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ final class WindowTerminalHostView: NSView {
eventType: eventType
) else { return false }
guard decision.result else { return false }
if decision.registryHit {
return true
}
return hostedTerminalHitView(at: point) == nil
}

Expand Down
96 changes: 93 additions & 3 deletions Sources/WindowDragHandleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,22 @@ func handleTitlebarDoubleClick(

private enum WindowDragHandleAssociatedObjectKeys {
private static let suppressionDepthToken = NSObject()
private static let moveSuppressionSequenceToken = NSObject()

static let suppressionDepth = UnsafeRawPointer(Unmanaged.passUnretained(suppressionDepthToken).toOpaque())
static let moveSuppressionSequence = UnsafeRawPointer(Unmanaged.passUnretained(moveSuppressionSequenceToken).toOpaque())
}

// Stored as an NSWindow associated object and touched only from AppKit's
// main-thread mouse-event dispatch path.
private final class WindowMoveSuppressionSequenceState: @unchecked Sendable {
let reason: WindowMoveSuppressionReason
let previousMovableState: Bool

init(reason: WindowMoveSuppressionReason, previousMovableState: Bool) {
self.reason = reason
self.previousMovableState = previousMovableState
}
}

func beginWindowDragSuppression(window: NSWindow?) -> Int? {
Expand Down Expand Up @@ -282,14 +296,74 @@ func isWindowDragSuppressed(window: NSWindow?) -> Bool {
windowDragSuppressionDepth(window: window) > 0
}

func activeWindowMoveSuppressionSequenceReason(window: NSWindow?) -> WindowMoveSuppressionReason? {
guard let window,
let state = objc_getAssociatedObject(
window,
WindowDragHandleAssociatedObjectKeys.moveSuppressionSequence
) as? WindowMoveSuppressionSequenceState else {
return nil
}
return state.reason
}

@discardableResult
func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? {
func beginWindowMoveSuppressionSequence(
window: NSWindow?,
reason: WindowMoveSuppressionReason
) -> WindowMoveSuppressionReason? {
guard let window else { return nil }
if let activeReason = activeWindowMoveSuppressionSequenceReason(window: window) {
ensureWindowMoveSuppressionSequenceIsImmovable(window: window)
return activeReason
}

let previousMovableState = window.isMovable
if previousMovableState {
_ = beginWindowDragSuppression(window: window)
if window.isMovable {
window.isMovable = false
}
return previousMovableState
let state = WindowMoveSuppressionSequenceState(
reason: reason,
previousMovableState: previousMovableState
)
objc_setAssociatedObject(
window,
WindowDragHandleAssociatedObjectKeys.moveSuppressionSequence,
state,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
return reason
}

func ensureWindowMoveSuppressionSequenceIsImmovable(window: NSWindow?) {
guard let window,
activeWindowMoveSuppressionSequenceReason(window: window) != nil,
window.isMovable else {
return
}
window.isMovable = false
}

@discardableResult
func finishWindowMoveSuppressionSequence(window: NSWindow?) -> WindowMoveSuppressionReason? {
guard let window,
let state = objc_getAssociatedObject(
window,
WindowDragHandleAssociatedObjectKeys.moveSuppressionSequence
) as? WindowMoveSuppressionSequenceState else {
return nil
}

objc_setAssociatedObject(
window,
WindowDragHandleAssociatedObjectKeys.moveSuppressionSequence,
nil,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
_ = endWindowDragSuppression(window: window)
restoreWindowDragging(window: window, previousMovableState: state.previousMovableState)
return state.reason
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Comment thread
cursor[bot] marked this conversation as resolved.
func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) {
Expand All @@ -303,6 +377,9 @@ func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) {
@discardableResult
func clearWindowDragSuppression(window: NSWindow?) -> Int {
guard let window else { return 0 }
if activeWindowMoveSuppressionSequenceReason(window: window) != nil {
_ = finishWindowMoveSuppressionSequence(window: window)
}
var depth = windowDragSuppressionDepth(window: window)
while depth > 0 {
depth = endWindowDragSuppression(window: window)
Expand Down Expand Up @@ -825,6 +902,19 @@ func windowDragHandleShouldCaptureHit(
) -> Bool {
let dragHandleWindow = dragHandleView.window

if let dragHandleWindow,
eventType == .leftMouseDown {
let windowPoint = dragHandleView.convert(point, to: nil)
if BonsplitTabItemHitRegionRegistry.containsWindowPoint(windowPoint, in: dragHandleWindow) {
#if DEBUG
cmuxDebugLog(
"titlebar.dragHandle.hitTest capture=false reason=bonsplitPaneTab point=\(windowDragHandleFormatPoint(point))"
)
#endif
return false
}
}

// Suppression recovery runs first so stale depth is cleared even for
// passive events — the associated-object reads/writes here are pure ObjC
// runtime calls and cannot trigger Swift exclusive-access violations.
Expand Down
60 changes: 59 additions & 1 deletion cmuxTests/PortalTabDragRoutingTests.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import XCTest
import AppKit
import Bonsplit
@testable import Bonsplit
Comment thread
lawrencecchen marked this conversation as resolved.

#if canImport(cmux_DEV)
@testable import cmux_DEV
Expand All @@ -22,6 +22,14 @@ final class PortalTabDragRoutingTests: XCTestCase {
}
}

private func makeHostedTerminalView(frame: NSRect) -> GhosttySurfaceScrollView {
let surfaceView = GhosttyNSView(frame: frame)
let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView)
hostedView.frame = frame
hostedView.autoresizingMask = [.width, .height]
return hostedView
}

private struct TabStripPassThroughFixture {
let host: WindowTerminalHostView
let pointInHost: NSPoint
Expand Down Expand Up @@ -111,6 +119,56 @@ final class PortalTabDragRoutingTests: XCTestCase {
)
}

func testHostViewTrustsRegisteredTabStripRegionAboveHostedTerminal() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView,
let container = contentView.superview else {
XCTFail("Expected window content container")
return
}

let tabStripHeight: CGFloat = 44
let tabStrip = NSView(
frame: NSRect(
x: 0,
y: contentView.bounds.maxY - tabStripHeight,
width: contentView.bounds.width,
height: tabStripHeight
)
)
tabStrip.autoresizingMask = [.width, .minYMargin]
contentView.addSubview(tabStrip)
BonsplitTabBarHitRegionRegistry.register(tabStrip)
defer { BonsplitTabBarHitRegionRegistry.unregister(tabStrip) }

let hostFrame = container.convert(contentView.bounds, from: contentView)
let host = WindowTerminalHostView(frame: hostFrame)
host.autoresizingMask = [.width, .height]
let hostedTerminal = makeHostedTerminalView(frame: host.bounds)
host.addSubview(hostedTerminal)
container.addSubview(host, positioned: .above, relativeTo: contentView)

let titlebarBandHeight = max(28, min(72, window.frame.height - window.contentLayoutRect.height))
let pointInContent = NSPoint(
x: contentView.bounds.midX,
y: contentView.bounds.maxY - titlebarBandHeight - 8
)
let pointInWindow = contentView.convert(pointInContent, to: nil)
let pointInHost = host.convert(pointInWindow, from: nil)
let event = makeMouseEvent(type: .leftMouseDown, at: pointInWindow, window: window)

XCTAssertNil(
host.performHitTest(at: pointInHost, currentEvent: event),
"Terminal portal should defer to the registered minimal tab strip even when a hosted terminal view overlaps it"
)
}

func testHostViewPassesThroughUnderlyingTabStripWithoutCurrentEvent() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
Expand Down
Loading
Loading