From 5d23d6a50913df5a8cd87a721244592a43eabffb Mon Sep 17 00:00:00 2001 From: Kenneth Ng Date: Thu, 30 Jul 2026 14:01:16 +0800 Subject: [PATCH 01/16] Make sidebar empty area a window-drag region The empty space below the last workspace group did not move the window when dragged, though the titlebar strip above the sidebar did. macOS users expect background-drag anywhere in a sidebar, and issue #3119 records the non-minimal sidebar as already behaving that way. Track the press synchronously from mouseDown with nextEvent(matching:), the same shape as SidebarDividerTrackingView, rather than through an NSGestureRecognizer. NSWindow.performDrag(with:) runs its own modal tracking loop and consumes the terminating mouse-up, so a recognizer that calls it never receives the event that would drive it back to .possible: it parks in a terminal state, reset() never runs, and it stops recognizing for the rest of the window's life. A press that never travels 4pt is handed back intact - the mouse-up is reposted before falling through, because NSTableView's own mouseDown tracking loop blocks waiting for it. Double-clicks bypass the drag path entirely so doubleClickEmptyArea() still fires, and the table view gates on row(at:) < 0 so rows keep their existing click handling. Fixes #9203 --- .../SidebarWorkspaceTableClipView.swift | 6 + .../SidebarWorkspaceTableViewImpl.swift | 6 + ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 91 +++++++++++- .../TitlebarInteractiveControlTests.swift | 131 ++++++++++++++++++ 4 files changed, 232 insertions(+), 2 deletions(-) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift index f4698171ebf..97c75845aa6 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift @@ -6,6 +6,12 @@ final class SidebarWorkspaceTableClipView: NSClipView { weak var workspaceController: SidebarWorkspaceTableController? override func mouseDown(with event: NSEvent) { + // Presses only reach the clip view when they miss the table's frame + // entirely, so anything landing here is empty space by construction. + if event.clickCount == 1, + SidebarEmptyAreaWindowDrag.perform(with: event, in: self) { + return + } super.mouseDown(with: event) if event.clickCount == 2 { workspaceController?.doubleClickEmptyArea() diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift index 5fa7f686c37..6dbf4fd55f9 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift @@ -55,6 +55,12 @@ final class SidebarWorkspaceTableViewImpl: NSTableView { workspaceController?.prepareForMouseDown() let point = convert(event.locationInWindow, from: nil) let clickedRow = row(at: point) + // Below the last row the press is window-drag territory. Single clicks + // only: a double-click still belongs to doubleClickEmptyArea(). + if clickedRow < 0, event.clickCount == 1, + SidebarEmptyAreaWindowDrag.perform(with: event, in: self) { + return + } // No selection paint on press: the highlight applies on down-then-up // (owner ruling). The action fires on mouse-up and paints the // optimistic treatment there, so a press that becomes a drag or a diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index 405e0544fb6..b8fa17e39bc 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -554,6 +554,93 @@ struct SidebarDevFooter: View { } #endif +/// Window dragging from empty sidebar space. +/// +/// Tracking runs synchronously from `mouseDown` with `nextEvent(matching:)`, +/// the same shape as ``SidebarDividerTrackingView``, rather than through an +/// `NSGestureRecognizer`. `NSWindow.performDrag(with:)` runs its own modal +/// tracking loop and consumes the terminating mouse-up, so a recognizer that +/// calls it never receives the event that would drive it back to `.possible`: +/// it stays parked in a terminal state, `reset()` never runs, and it silently +/// stops recognizing for the rest of the window's life. +enum SidebarEmptyAreaWindowDrag { + /// Pointer travel, in window points, before a press becomes a window drag. + /// Below this a press stays a click so selection and menus are unaffected. + static let dragThreshold: CGFloat = 4 + + /// Production event pump: block in `.eventTracking` until the press + /// resolves into either movement or a mouse-up. + @MainActor + static func pumpTrackingEvent(from window: NSWindow) -> NSEvent? { + window.nextEvent( + matching: [.leftMouseDragged, .leftMouseUp], + until: .distantFuture, + inMode: .eventTracking, + dequeue: true + ) + } + + /// Consumes `event` as a window drag when the press turns into one. + /// + /// Returns `true` when the window was dragged and the caller must not run + /// its normal `mouseDown` handling. Returns `false` for a press that never + /// passed ``dragThreshold``, having pushed the terminating mouse-up back + /// onto the queue first — `NSTableView`'s own `mouseDown` tracking loop + /// waits for that event, and swallowing it would hang the click. + @MainActor + static func perform( + with event: NSEvent, + in view: NSView, + nextEvent: (NSWindow) -> NSEvent? = pumpTrackingEvent + ) -> Bool { + guard let window = view.window else { return false } + guard !isWindowDragSuppressed(window: window) else { return false } + + let start = event.locationInWindow + + while let next = nextEvent(window) { + if next.type == .leftMouseUp { + window.postEvent(next, atStart: true) + return false + } + + let location = next.locationInWindow + let distance = hypot(location.x - start.x, location.y - start.y) + guard distance >= dragThreshold else { continue } + + withTemporaryWindowMovableEnabled(window: window) { + window.performDrag(with: event) + } + return true + } + + return false + } +} + +/// Backing view for the empty region below the workspace list. +/// +/// Presses that turn into drags move the window; presses that do not fall +/// through to normal handling untouched. +private final class SidebarEmptyAreaWindowDragNSView: NSView { + override func mouseDown(with event: NSEvent) { + guard event.clickCount == 1 else { + super.mouseDown(with: event) + return + } + if SidebarEmptyAreaWindowDrag.perform(with: event, in: self) { return } + super.mouseDown(with: event) + } +} + +private struct SidebarEmptyAreaWindowDragView: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + SidebarEmptyAreaWindowDragNSView() + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} + struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager let rowSpacing: CGFloat @@ -629,11 +716,11 @@ struct SidebarEmptyArea: View { @ViewBuilder private var hitTarget: some View { if expandsVertically { - Color.clear + SidebarEmptyAreaWindowDragView() .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) } else { - Color.clear + SidebarEmptyAreaWindowDragView() .frame(maxWidth: .infinity, minHeight: minimumHeight ?? 0) .contentShape(Rectangle()) } diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 97a2d5d3325..1b30b4e06a2 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -246,3 +246,134 @@ struct TitlebarInteractiveControlTests { ) } } + +@MainActor +@Suite("Sidebar empty-area window drag") +struct SidebarEmptyAreaWindowDragTests { + private final class RecordingDragWindow: NSWindow { + var performDragCallCount = 0 + var isMovableDuringPerformDrag: Bool? + + override func performDrag(with event: NSEvent) { + performDragCallCount += 1 + isMovableDuringPerformDrag = isMovable + } + } + + private static 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: 1, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Expected to create sidebar empty-area mouse event") + } + return event + } + + private static func makeWindow() -> RecordingDragWindow { + _ = NSApplication.shared + let window = RecordingDragWindow( + contentRect: NSRect(x: 0, y: 0, width: 240, height: 320), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + window.contentView = NSView(frame: NSRect(x: 0, y: 0, width: 240, height: 320)) + return window + } + + /// Feeds a fixed script of events in place of the real tracking pump so the + /// loop is exercised without a run loop. + private static func pump(_ events: [NSEvent]) -> (NSWindow) -> NSEvent? { + var remaining = events + return { _ in remaining.isEmpty ? nil : remaining.removeFirst() } + } + + @Test func dragPastThresholdMovesWindowAndRestoresMovability() { + let window = Self.makeWindow() + defer { window.orderOut(nil) } + window.isMovable = false + guard let view = window.contentView else { return } + + let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) + let dragged = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 100), window: window) + + let handled = SidebarEmptyAreaWindowDrag.perform( + with: down, + in: view, + nextEvent: Self.pump([dragged]) + ) + + #expect(handled) + #expect(window.performDragCallCount == 1) + #expect(window.isMovableDuringPerformDrag == true) + #expect(!window.isMovable) + } + + @Test func pressWithoutMovementIsNotADrag() { + let window = Self.makeWindow() + defer { window.orderOut(nil) } + guard let view = window.contentView else { return } + + let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) + let up = Self.makeMouseEvent(type: .leftMouseUp, location: NSPoint(x: 40, y: 80), window: window) + + let handled = SidebarEmptyAreaWindowDrag.perform( + with: down, + in: view, + nextEvent: Self.pump([up]) + ) + + #expect(!handled) + #expect(window.performDragCallCount == 0) + } + + @Test func movementBelowThresholdStaysAClick() { + let window = Self.makeWindow() + defer { window.orderOut(nil) } + guard let view = window.contentView else { return } + + let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) + let jitter = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 41, y: 81), window: window) + let up = Self.makeMouseEvent(type: .leftMouseUp, location: NSPoint(x: 41, y: 81), window: window) + + let handled = SidebarEmptyAreaWindowDrag.perform( + with: down, + in: view, + nextEvent: Self.pump([jitter, up]) + ) + + #expect(!handled) + #expect(window.performDragCallCount == 0) + } + + @Test func viewWithoutWindowIsNotADrag() { + _ = NSApplication.shared + let window = Self.makeWindow() + defer { window.orderOut(nil) } + let detached = NSView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + + let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 4, y: 4), window: window) + let dragged = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 40, y: 40), window: window) + + let handled = SidebarEmptyAreaWindowDrag.perform( + with: down, + in: detached, + nextEvent: Self.pump([dragged]) + ) + + #expect(!handled) + #expect(window.performDragCallCount == 0) + } +} From a8e3111b2453a001f84b42f098d0afce00a47cd2 Mon Sep 17 00:00:00 2001 From: Kenneth Ng Date: Thu, 30 Jul 2026 15:52:45 +0800 Subject: [PATCH 02/16] Keep the full-height sidebar background a SwiftUI hit target The vertically expanding SidebarEmptyArea is mounted as a full-height background behind the rows in workspaceScrollContent, where a SwiftUI shield sized to the rows stops end-of-list interactions falling through. That shield cannot block an AppKit view, so a representable there would out-hit-test it and turn row presses into window drags. The AppKit sidebar covers this region through the table and clip views, so only the legacy SwiftUI sidebar loses empty-area window drag. --- Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index b8fa17e39bc..8a63ef4637f 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -716,7 +716,9 @@ struct SidebarEmptyArea: View { @ViewBuilder private var hitTarget: some View { if expandsVertically { - SidebarEmptyAreaWindowDragView() + // Full-height background behind the rows: an AppKit view here would + // out-hit-test the SwiftUI shield and steal row presses. + Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) } else { From 31471546c3e5cadd534d4c6ebce700a956d4e4ba Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 17:23:39 -0700 Subject: [PATCH 03/16] Address sidebar window-drag review feedback --- .../SidebarWorkspaceTableClipView.swift | 4 +- .../SidebarWorkspaceTableViewImpl.swift | 4 +- ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 56 +++++++++++------- .../TitlebarInteractiveControlTests.swift | 57 +++++++++++-------- 4 files changed, 73 insertions(+), 48 deletions(-) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift index 97c75845aa6..3b00ddee78c 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift @@ -4,12 +4,14 @@ import AppKit @MainActor final class SidebarWorkspaceTableClipView: NSClipView { weak var workspaceController: SidebarWorkspaceTableController? + private let emptyAreaWindowDragController = SidebarEmptyAreaWindowDragController() + /// Preserves empty-space clicks while promoting threshold-crossing presses to window drags. override func mouseDown(with event: NSEvent) { // Presses only reach the clip view when they miss the table's frame // entirely, so anything landing here is empty space by construction. if event.clickCount == 1, - SidebarEmptyAreaWindowDrag.perform(with: event, in: self) { + emptyAreaWindowDragController.perform(with: event, in: self) { return } super.mouseDown(with: event) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift index 6dbf4fd55f9..9abab480573 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift @@ -8,6 +8,7 @@ final class SidebarWorkspaceTableViewImpl: NSTableView { /// dismantles its representable. Keep the controller alive until the /// terminal source callback arrives. var activeWorkspaceDragController: SidebarWorkspaceTableController? + private let emptyAreaWindowDragController = SidebarEmptyAreaWindowDragController() private var pointerTrackingArea: NSTrackingArea? private(set) var lastPointerWindowLocation: NSPoint? @@ -51,6 +52,7 @@ final class SidebarWorkspaceTableViewImpl: NSTableView { workspaceController?.middleClick(row: row) } + /// Preserves row clicks while promoting threshold-crossing empty-area presses to window drags. override func mouseDown(with event: NSEvent) { workspaceController?.prepareForMouseDown() let point = convert(event.locationInWindow, from: nil) @@ -58,7 +60,7 @@ final class SidebarWorkspaceTableViewImpl: NSTableView { // Below the last row the press is window-drag territory. Single clicks // only: a double-click still belongs to doubleClickEmptyArea(). if clickedRow < 0, event.clickCount == 1, - SidebarEmptyAreaWindowDrag.perform(with: event, in: self) { + emptyAreaWindowDragController.perform(with: event, in: self) { return } // No selection paint on press: the highlight applies on down-then-up diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index 8a63ef4637f..bc80dbdeaaf 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -554,7 +554,7 @@ struct SidebarDevFooter: View { } #endif -/// Window dragging from empty sidebar space. +/// Coordinates window dragging from empty sidebar space. /// /// Tracking runs synchronously from `mouseDown` with `nextEvent(matching:)`, /// the same shape as ``SidebarDividerTrackingView``, rather than through an @@ -563,35 +563,41 @@ struct SidebarDevFooter: View { /// calls it never receives the event that would drive it back to `.possible`: /// it stays parked in a terminal state, `reset()` never runs, and it silently /// stops recognizing for the rest of the window's life. -enum SidebarEmptyAreaWindowDrag { - /// Pointer travel, in window points, before a press becomes a window drag. - /// Below this a press stays a click so selection and menus are unaffected. - static let dragThreshold: CGFloat = 4 +@MainActor +struct SidebarEmptyAreaWindowDragController { + private let dragThreshold: CGFloat + private let nextEvent: (NSWindow) -> NSEvent? - /// Production event pump: block in `.eventTracking` until the press + /// Creates a controller with its pointer threshold and event source. + /// + /// The default event source blocks in `.eventTracking` until the press /// resolves into either movement or a mouse-up. - @MainActor - static func pumpTrackingEvent(from window: NSWindow) -> NSEvent? { - window.nextEvent( - matching: [.leftMouseDragged, .leftMouseUp], - until: .distantFuture, - inMode: .eventTracking, - dequeue: true - ) + init( + dragThreshold: CGFloat = 4, + nextEvent: @escaping (NSWindow) -> NSEvent? = { window in + window.nextEvent( + matching: [.leftMouseDragged, .leftMouseUp], + until: .distantFuture, + inMode: .eventTracking, + dequeue: true + ) + } + ) { + self.dragThreshold = dragThreshold + self.nextEvent = nextEvent } /// Consumes `event` as a window drag when the press turns into one. /// /// Returns `true` when the window was dragged and the caller must not run /// its normal `mouseDown` handling. Returns `false` for a press that never - /// passed ``dragThreshold``, having pushed the terminating mouse-up back - /// onto the queue first — `NSTableView`'s own `mouseDown` tracking loop - /// waits for that event, and swallowing it would hang the click. - @MainActor - static func perform( + /// passed the configured drag threshold, having pushed the terminating + /// mouse-up back onto the queue first — `NSTableView`'s own `mouseDown` + /// tracking loop waits for that event, and swallowing it would hang the + /// click. + func perform( with event: NSEvent, - in view: NSView, - nextEvent: (NSWindow) -> NSEvent? = pumpTrackingEvent + in view: NSView ) -> Bool { guard let window = view.window else { return false } guard !isWindowDragSuppressed(window: window) else { return false } @@ -623,21 +629,27 @@ enum SidebarEmptyAreaWindowDrag { /// Presses that turn into drags move the window; presses that do not fall /// through to normal handling untouched. private final class SidebarEmptyAreaWindowDragNSView: NSView { + private let windowDragController = SidebarEmptyAreaWindowDragController() + + /// Routes single presses through threshold-based window dragging. override func mouseDown(with event: NSEvent) { guard event.clickCount == 1 else { super.mouseDown(with: event) return } - if SidebarEmptyAreaWindowDrag.perform(with: event, in: self) { return } + if windowDragController.perform(with: event, in: self) { return } super.mouseDown(with: event) } } +/// Hosts the compact sidebar's native empty-area drag target in SwiftUI. private struct SidebarEmptyAreaWindowDragView: NSViewRepresentable { + /// Creates the AppKit hit target used by the compact sidebar empty area. func makeNSView(context: Context) -> NSView { SidebarEmptyAreaWindowDragNSView() } + /// The hit target has no SwiftUI state to synchronize after creation. func updateNSView(_ nsView: NSView, context: Context) {} } diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 1b30b4e06a2..5bcf6db5cfc 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -254,12 +254,14 @@ struct SidebarEmptyAreaWindowDragTests { var performDragCallCount = 0 var isMovableDuringPerformDrag: Bool? + /// Records drag invocation and the movability state visible to AppKit. override func performDrag(with event: NSEvent) { performDragCallCount += 1 isMovableDuringPerformDrag = isMovable } } + /// Creates a synthetic pointer event associated with the supplied window. private static func makeMouseEvent( type: NSEvent.EventType, location: NSPoint, @@ -281,6 +283,7 @@ struct SidebarEmptyAreaWindowDragTests { return event } + /// Creates a window whose explicit drag calls can be inspected. private static func makeWindow() -> RecordingDragWindow { _ = NSApplication.shared let window = RecordingDragWindow( @@ -300,20 +303,18 @@ struct SidebarEmptyAreaWindowDragTests { return { _ in remaining.isEmpty ? nil : remaining.removeFirst() } } - @Test func dragPastThresholdMovesWindowAndRestoresMovability() { + /// A threshold-crossing press invokes AppKit exactly once and restores window state. + @Test func dragPastThresholdMovesWindowAndRestoresMovability() throws { let window = Self.makeWindow() defer { window.orderOut(nil) } window.isMovable = false - guard let view = window.contentView else { return } + let view = try #require(window.contentView) let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) let dragged = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 100), window: window) - let handled = SidebarEmptyAreaWindowDrag.perform( - with: down, - in: view, - nextEvent: Self.pump([dragged]) - ) + let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([dragged])) + let handled = controller.perform(with: down, in: view) #expect(handled) #expect(window.performDragCallCount == 1) @@ -321,43 +322,54 @@ struct SidebarEmptyAreaWindowDragTests { #expect(!window.isMovable) } - @Test func pressWithoutMovementIsNotADrag() { + /// A stationary click remains unhandled and replays its terminating mouse-up. + @Test func pressWithoutMovementIsNotADrag() throws { let window = Self.makeWindow() defer { window.orderOut(nil) } - guard let view = window.contentView else { return } + let view = try #require(window.contentView) let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) let up = Self.makeMouseEvent(type: .leftMouseUp, location: NSPoint(x: 40, y: 80), window: window) - let handled = SidebarEmptyAreaWindowDrag.perform( - with: down, - in: view, - nextEvent: Self.pump([up]) + let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([up])) + let handled = controller.perform(with: down, in: view) + let replayed = window.nextEvent( + matching: [.leftMouseUp], + until: .now, + inMode: .eventTracking, + dequeue: true ) #expect(!handled) #expect(window.performDragCallCount == 0) + #expect(replayed === up) } - @Test func movementBelowThresholdStaysAClick() { + /// Sub-threshold pointer jitter remains a click and replays its mouse-up. + @Test func movementBelowThresholdStaysAClick() throws { let window = Self.makeWindow() defer { window.orderOut(nil) } - guard let view = window.contentView else { return } + let view = try #require(window.contentView) let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) let jitter = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 41, y: 81), window: window) let up = Self.makeMouseEvent(type: .leftMouseUp, location: NSPoint(x: 41, y: 81), window: window) - let handled = SidebarEmptyAreaWindowDrag.perform( - with: down, - in: view, - nextEvent: Self.pump([jitter, up]) + let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([jitter, up])) + let handled = controller.perform(with: down, in: view) + let replayed = window.nextEvent( + matching: [.leftMouseUp], + until: .now, + inMode: .eventTracking, + dequeue: true ) #expect(!handled) #expect(window.performDragCallCount == 0) + #expect(replayed === up) } + /// A detached view declines the drag without consulting the injected event source. @Test func viewWithoutWindowIsNotADrag() { _ = NSApplication.shared let window = Self.makeWindow() @@ -367,11 +379,8 @@ struct SidebarEmptyAreaWindowDragTests { let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 4, y: 4), window: window) let dragged = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 40, y: 40), window: window) - let handled = SidebarEmptyAreaWindowDrag.perform( - with: down, - in: detached, - nextEvent: Self.pump([dragged]) - ) + let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([dragged])) + let handled = controller.perform(with: down, in: detached) #expect(!handled) #expect(window.performDragCallCount == 0) From 7f0f9bfe456c422a6196243c7443064f346d9448 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 17:49:38 -0700 Subject: [PATCH 04/16] Test replayed mouse-up payload --- .../TitlebarInteractiveControlTests.swift | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 5bcf6db5cfc..16a80c2e902 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -303,6 +303,28 @@ struct SidebarEmptyAreaWindowDragTests { return { _ in remaining.isEmpty ? nil : remaining.removeFirst() } } + /// Verifies that AppKit replays the same mouse-up payload after queueing it. + private static func expectReplayedMouseUp(in window: NSWindow, matches original: NSEvent) throws { + let replayed = try #require( + window.nextEvent( + matching: [.leftMouseUp], + until: .now, + inMode: .eventTracking, + dequeue: true + ) + ) + + // postEvent may reconstitute the NSEvent, so compare the event identity + // fields instead of requiring the dequeued object to be pointer-identical. + #expect(replayed.type == .leftMouseUp) + #expect(replayed.windowNumber == original.windowNumber) + #expect(replayed.eventNumber == original.eventNumber) + #expect(replayed.timestamp == original.timestamp) + #expect(replayed.locationInWindow == original.locationInWindow) + #expect(replayed.clickCount == original.clickCount) + #expect(replayed.modifierFlags == original.modifierFlags) + } + /// A threshold-crossing press invokes AppKit exactly once and restores window state. @Test func dragPastThresholdMovesWindowAndRestoresMovability() throws { let window = Self.makeWindow() @@ -333,16 +355,10 @@ struct SidebarEmptyAreaWindowDragTests { let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([up])) let handled = controller.perform(with: down, in: view) - let replayed = window.nextEvent( - matching: [.leftMouseUp], - until: .now, - inMode: .eventTracking, - dequeue: true - ) #expect(!handled) #expect(window.performDragCallCount == 0) - #expect(replayed === up) + try Self.expectReplayedMouseUp(in: window, matches: up) } /// Sub-threshold pointer jitter remains a click and replays its mouse-up. @@ -357,16 +373,10 @@ struct SidebarEmptyAreaWindowDragTests { let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([jitter, up])) let handled = controller.perform(with: down, in: view) - let replayed = window.nextEvent( - matching: [.leftMouseUp], - until: .now, - inMode: .eventTracking, - dequeue: true - ) #expect(!handled) #expect(window.performDragCallCount == 0) - #expect(replayed === up) + try Self.expectReplayedMouseUp(in: window, matches: up) } /// A detached view declines the drag without consulting the injected event source. From 23639bb42fe063e680e62f896611c65c2e9f7efe Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 18:07:56 -0700 Subject: [PATCH 05/16] Separate sidebar drag ownership types --- ...SidebarEmptyAreaWindowDragController.swift | 71 +++++++++++++ .../SidebarEmptyAreaWindowDragNSView.swift | 20 ++++ .../SidebarEmptyAreaWindowDragView.swift | 12 +++ ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 99 ------------------- cmux.xcodeproj/project.pbxproj | 16 ++- 5 files changed, 117 insertions(+), 101 deletions(-) create mode 100644 Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift create mode 100644 Sources/Sidebar/SidebarEmptyAreaWindowDragNSView.swift create mode 100644 Sources/Sidebar/SidebarEmptyAreaWindowDragView.swift diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift new file mode 100644 index 00000000000..e2a3f9e2f37 --- /dev/null +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift @@ -0,0 +1,71 @@ +import AppKit + +/// Coordinates window dragging from empty sidebar space. +/// +/// Tracking runs synchronously from `mouseDown` with `nextEvent(matching:)`, +/// the same shape as ``SidebarDividerTrackingView``, rather than through an +/// `NSGestureRecognizer`. `NSWindow.performDrag(with:)` runs its own modal +/// tracking loop and consumes the terminating mouse-up, so a recognizer that +/// calls it never receives the event that would drive it back to `.possible`: +/// it stays parked in a terminal state, `reset()` never runs, and it silently +/// stops recognizing for the rest of the window's life. +@MainActor +struct SidebarEmptyAreaWindowDragController { + private let dragThreshold: CGFloat + private let nextEvent: (NSWindow) -> NSEvent? + + /// Creates a controller with its pointer threshold and event source. + /// + /// The default event source blocks in `.eventTracking` until the press + /// resolves into either movement or a mouse-up. + init( + dragThreshold: CGFloat = 4, + nextEvent: @escaping (NSWindow) -> NSEvent? = { window in + window.nextEvent( + matching: [.leftMouseDragged, .leftMouseUp], + until: .distantFuture, + inMode: .eventTracking, + dequeue: true + ) + } + ) { + self.dragThreshold = dragThreshold + self.nextEvent = nextEvent + } + + /// Consumes `event` as a window drag when the press turns into one. + /// + /// Returns `true` when the window was dragged and the caller must not run + /// its normal `mouseDown` handling. Returns `false` for a press that never + /// passed the configured drag threshold, having pushed the terminating + /// mouse-up back onto the queue first — `NSTableView`'s own `mouseDown` + /// tracking loop waits for that event, and swallowing it would hang the + /// click. + func perform( + with event: NSEvent, + in view: NSView + ) -> Bool { + guard let window = view.window else { return false } + guard !isWindowDragSuppressed(window: window) else { return false } + + let start = event.locationInWindow + + while let next = nextEvent(window) { + if next.type == .leftMouseUp { + window.postEvent(next, atStart: true) + return false + } + + let location = next.locationInWindow + let distance = hypot(location.x - start.x, location.y - start.y) + guard distance >= dragThreshold else { continue } + + withTemporaryWindowMovableEnabled(window: window) { + window.performDrag(with: event) + } + return true + } + + return false + } +} diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragNSView.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragNSView.swift new file mode 100644 index 00000000000..8cc62081f31 --- /dev/null +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragNSView.swift @@ -0,0 +1,20 @@ +import AppKit + +/// Backing view for the empty region below the workspace list. +/// +/// Presses that turn into drags move the window; presses that do not fall +/// through to normal handling untouched. +@MainActor +final class SidebarEmptyAreaWindowDragNSView: NSView { + private let windowDragController = SidebarEmptyAreaWindowDragController() + + /// Routes single presses through threshold-based window dragging. + override func mouseDown(with event: NSEvent) { + guard event.clickCount == 1 else { + super.mouseDown(with: event) + return + } + if windowDragController.perform(with: event, in: self) { return } + super.mouseDown(with: event) + } +} diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragView.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragView.swift new file mode 100644 index 00000000000..2cd9218ac60 --- /dev/null +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragView.swift @@ -0,0 +1,12 @@ +import SwiftUI + +/// Hosts the compact sidebar's native empty-area drag target in SwiftUI. +struct SidebarEmptyAreaWindowDragView: NSViewRepresentable { + /// Creates the AppKit hit target used by the compact sidebar empty area. + func makeNSView(context: Context) -> SidebarEmptyAreaWindowDragNSView { + SidebarEmptyAreaWindowDragNSView() + } + + /// The hit target has no SwiftUI state to synchronize after creation. + func updateNSView(_ nsView: SidebarEmptyAreaWindowDragNSView, context: Context) {} +} diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index bc80dbdeaaf..cb34e1008cd 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -554,105 +554,6 @@ struct SidebarDevFooter: View { } #endif -/// Coordinates window dragging from empty sidebar space. -/// -/// Tracking runs synchronously from `mouseDown` with `nextEvent(matching:)`, -/// the same shape as ``SidebarDividerTrackingView``, rather than through an -/// `NSGestureRecognizer`. `NSWindow.performDrag(with:)` runs its own modal -/// tracking loop and consumes the terminating mouse-up, so a recognizer that -/// calls it never receives the event that would drive it back to `.possible`: -/// it stays parked in a terminal state, `reset()` never runs, and it silently -/// stops recognizing for the rest of the window's life. -@MainActor -struct SidebarEmptyAreaWindowDragController { - private let dragThreshold: CGFloat - private let nextEvent: (NSWindow) -> NSEvent? - - /// Creates a controller with its pointer threshold and event source. - /// - /// The default event source blocks in `.eventTracking` until the press - /// resolves into either movement or a mouse-up. - init( - dragThreshold: CGFloat = 4, - nextEvent: @escaping (NSWindow) -> NSEvent? = { window in - window.nextEvent( - matching: [.leftMouseDragged, .leftMouseUp], - until: .distantFuture, - inMode: .eventTracking, - dequeue: true - ) - } - ) { - self.dragThreshold = dragThreshold - self.nextEvent = nextEvent - } - - /// Consumes `event` as a window drag when the press turns into one. - /// - /// Returns `true` when the window was dragged and the caller must not run - /// its normal `mouseDown` handling. Returns `false` for a press that never - /// passed the configured drag threshold, having pushed the terminating - /// mouse-up back onto the queue first — `NSTableView`'s own `mouseDown` - /// tracking loop waits for that event, and swallowing it would hang the - /// click. - func perform( - with event: NSEvent, - in view: NSView - ) -> Bool { - guard let window = view.window else { return false } - guard !isWindowDragSuppressed(window: window) else { return false } - - let start = event.locationInWindow - - while let next = nextEvent(window) { - if next.type == .leftMouseUp { - window.postEvent(next, atStart: true) - return false - } - - let location = next.locationInWindow - let distance = hypot(location.x - start.x, location.y - start.y) - guard distance >= dragThreshold else { continue } - - withTemporaryWindowMovableEnabled(window: window) { - window.performDrag(with: event) - } - return true - } - - return false - } -} - -/// Backing view for the empty region below the workspace list. -/// -/// Presses that turn into drags move the window; presses that do not fall -/// through to normal handling untouched. -private final class SidebarEmptyAreaWindowDragNSView: NSView { - private let windowDragController = SidebarEmptyAreaWindowDragController() - - /// Routes single presses through threshold-based window dragging. - override func mouseDown(with event: NSEvent) { - guard event.clickCount == 1 else { - super.mouseDown(with: event) - return - } - if windowDragController.perform(with: event, in: self) { return } - super.mouseDown(with: event) - } -} - -/// Hosts the compact sidebar's native empty-area drag target in SwiftUI. -private struct SidebarEmptyAreaWindowDragView: NSViewRepresentable { - /// Creates the AppKit hit target used by the compact sidebar empty area. - func makeNSView(context: Context) -> NSView { - SidebarEmptyAreaWindowDragNSView() - } - - /// The hit target has no SwiftUI state to synchronize after creation. - func updateNSView(_ nsView: NSView, context: Context) {} -} - struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager let rowSpacing: CGFloat diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3b0afe17ded..0e6ff39d683 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2120,7 +2120,10 @@ D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000004 /* SidebarDirectoryText.swift */; }; B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0270000000000000027 /* SidebarDividerTrackingView.swift */; }; - A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FD1001 /* SidebarFileDropFindRoutingTests.swift */; }; + A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FD1001 /* SidebarFileDropFindRoutingTests.swift */; }; + 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */; }; + 9212A0029212A0029212A002 /* SidebarEmptyAreaWindowDragNSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0029212B0029212B002 /* SidebarEmptyAreaWindowDragNSView.swift */; }; + 9212A0039212A0039212A003 /* SidebarEmptyAreaWindowDragView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0039212B0039212B003 /* SidebarEmptyAreaWindowDragView.swift */; }; B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */; }; A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */; }; 8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */; }; @@ -5093,7 +5096,10 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = ""; }; EA1F00000000000000000004 /* SidebarDirectoryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDirectoryText.swift; sourceTree = ""; }; B804B0270000000000000027 /* SidebarDividerTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDividerTrackingView.swift; sourceTree = ""; }; - A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = ""; }; + A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = ""; }; + 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragController.swift; sourceTree = ""; }; + 9212B0029212B0029212B002 /* SidebarEmptyAreaWindowDragNSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragNSView.swift; sourceTree = ""; }; + 9212B0039212B0039212B003 /* SidebarEmptyAreaWindowDragView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragView.swift; sourceTree = ""; }; B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFocusBoundaryLifecycleTests.swift; sourceTree = ""; }; A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = ""; }; 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGitProcessCompositionTests.swift; sourceTree = ""; }; @@ -6572,6 +6578,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */, EA1F00000000000000000002 /* SidebarPathFormatter.swift */, EA1F00000000000000000004 /* SidebarDirectoryText.swift */, + 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */, + 9212B0029212B0029212B002 /* SidebarEmptyAreaWindowDragNSView.swift */, + 9212B0039212B0039212B003 /* SidebarEmptyAreaWindowDragView.swift */, D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */, C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, C0DE1A020000000000000002 /* SavedLayoutStore.swift */, @@ -10867,6 +10876,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */, EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */, B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */, + 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */, + 9212A0029212A0029212A002 /* SidebarEmptyAreaWindowDragNSView.swift in Sources */, + 9212A0039212A0039212A003 /* SidebarEmptyAreaWindowDragView.swift in Sources */, A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */, F10753000000000000000001 /* SidebarGroupHeaderRowActions+NotificationState.swift in Sources */, B804A0200000000000000020 /* SidebarGroupHeaderRowModel.swift in Sources */, From 722cd35155a3f423f10d3fa94f16b06800b19627 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 18:50:50 -0700 Subject: [PATCH 06/16] Make replay timestamp assertion precision-aware --- cmuxTests/TitlebarInteractiveControlTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 16a80c2e902..e9a1fb72985 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -319,7 +319,9 @@ struct SidebarEmptyAreaWindowDragTests { #expect(replayed.type == .leftMouseUp) #expect(replayed.windowNumber == original.windowNumber) #expect(replayed.eventNumber == original.eventNumber) - #expect(replayed.timestamp == original.timestamp) + // AppKit can round the event timestamp while rebuilding the queued + // NSEvent, so compare at microsecond precision instead of bit-for-bit. + #expect(abs(replayed.timestamp - original.timestamp) < 0.000_001) #expect(replayed.locationInWindow == original.locationInWindow) #expect(replayed.clickCount == original.clickCount) #expect(replayed.modifierFlags == original.modifierFlags) From 79211a8ff9c1c3c13972076a81379a20fa0aedd5 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 18:54:48 -0700 Subject: [PATCH 07/16] Eliminate sidebar drag Swift warnings --- Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift | 4 ++-- cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift | 3 ++- cmuxTests/TitlebarInteractiveControlTests.swift | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift index e2a3f9e2f37..a4fe9145638 100644 --- a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift @@ -12,7 +12,7 @@ import AppKit @MainActor struct SidebarEmptyAreaWindowDragController { private let dragThreshold: CGFloat - private let nextEvent: (NSWindow) -> NSEvent? + private let nextEvent: @MainActor (NSWindow) -> NSEvent? /// Creates a controller with its pointer threshold and event source. /// @@ -20,7 +20,7 @@ struct SidebarEmptyAreaWindowDragController { /// resolves into either movement or a mouse-up. init( dragThreshold: CGFloat = 4, - nextEvent: @escaping (NSWindow) -> NSEvent? = { window in + nextEvent: @escaping @MainActor (NSWindow) -> NSEvent? = { window in window.nextEvent( matching: [.leftMouseDragged, .leftMouseUp], until: .distantFuture, diff --git a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift index 4107cb07a13..8bac2dfcb3f 100644 --- a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift +++ b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift @@ -4998,7 +4998,8 @@ final class AppDelegateEqualizeSplitsShortcutTests { XCTFail("Expected an unvisited terminal") return } - weak var weakRemovablePanel = removablePanel + weak var weakRemovablePanel: TerminalPanel? + weakRemovablePanel = removablePanel workspace.panels.removeValue(forKey: removablePanelId) removablePanel = nil diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index e9a1fb72985..6a9ab3e6b82 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -298,7 +298,7 @@ struct SidebarEmptyAreaWindowDragTests { /// Feeds a fixed script of events in place of the real tracking pump so the /// loop is exercised without a run loop. - private static func pump(_ events: [NSEvent]) -> (NSWindow) -> NSEvent? { + private static func pump(_ events: [NSEvent]) -> @MainActor (NSWindow) -> NSEvent? { var remaining = events return { _ in remaining.isEmpty ? nil : remaining.removeFirst() } } From cbb5efcd24f0570ffd81ac27dbd11347e1d2afea Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 19:35:29 -0700 Subject: [PATCH 08/16] Remove unreachable SwiftUI sidebar drag bridge --- .../SidebarEmptyAreaWindowDragNSView.swift | 20 ------------------- .../SidebarEmptyAreaWindowDragView.swift | 12 ----------- ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 7 ++++--- cmux.xcodeproj/project.pbxproj | 10 +--------- 4 files changed, 5 insertions(+), 44 deletions(-) delete mode 100644 Sources/Sidebar/SidebarEmptyAreaWindowDragNSView.swift delete mode 100644 Sources/Sidebar/SidebarEmptyAreaWindowDragView.swift diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragNSView.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragNSView.swift deleted file mode 100644 index 8cc62081f31..00000000000 --- a/Sources/Sidebar/SidebarEmptyAreaWindowDragNSView.swift +++ /dev/null @@ -1,20 +0,0 @@ -import AppKit - -/// Backing view for the empty region below the workspace list. -/// -/// Presses that turn into drags move the window; presses that do not fall -/// through to normal handling untouched. -@MainActor -final class SidebarEmptyAreaWindowDragNSView: NSView { - private let windowDragController = SidebarEmptyAreaWindowDragController() - - /// Routes single presses through threshold-based window dragging. - override func mouseDown(with event: NSEvent) { - guard event.clickCount == 1 else { - super.mouseDown(with: event) - return - } - if windowDragController.perform(with: event, in: self) { return } - super.mouseDown(with: event) - } -} diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragView.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragView.swift deleted file mode 100644 index 2cd9218ac60..00000000000 --- a/Sources/Sidebar/SidebarEmptyAreaWindowDragView.swift +++ /dev/null @@ -1,12 +0,0 @@ -import SwiftUI - -/// Hosts the compact sidebar's native empty-area drag target in SwiftUI. -struct SidebarEmptyAreaWindowDragView: NSViewRepresentable { - /// Creates the AppKit hit target used by the compact sidebar empty area. - func makeNSView(context: Context) -> SidebarEmptyAreaWindowDragNSView { - SidebarEmptyAreaWindowDragNSView() - } - - /// The hit target has no SwiftUI state to synchronize after creation. - func updateNSView(_ nsView: SidebarEmptyAreaWindowDragNSView, context: Context) {} -} diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index cb34e1008cd..fe146c79e02 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -629,13 +629,14 @@ struct SidebarEmptyArea: View { @ViewBuilder private var hitTarget: some View { if expandsVertically { - // Full-height background behind the rows: an AppKit view here would - // out-hit-test the SwiftUI shield and steal row presses. + // This full-height background extends behind the rows. Keep it + // SwiftUI-only so native hit testing cannot steal row presses; + // the AppKit table and clip view own native-sidebar window drags. Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) } else { - SidebarEmptyAreaWindowDragView() + Color.clear .frame(maxWidth: .infinity, minHeight: minimumHeight ?? 0) .contentShape(Rectangle()) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 0e6ff39d683..86e23ae9009 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2122,8 +2122,6 @@ B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0270000000000000027 /* SidebarDividerTrackingView.swift */; }; A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FD1001 /* SidebarFileDropFindRoutingTests.swift */; }; 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */; }; - 9212A0029212A0029212A002 /* SidebarEmptyAreaWindowDragNSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0029212B0029212B002 /* SidebarEmptyAreaWindowDragNSView.swift */; }; - 9212A0039212A0039212A003 /* SidebarEmptyAreaWindowDragView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0039212B0039212B003 /* SidebarEmptyAreaWindowDragView.swift */; }; B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */; }; A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */; }; 8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */; }; @@ -5098,8 +5096,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B804B0270000000000000027 /* SidebarDividerTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDividerTrackingView.swift; sourceTree = ""; }; A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = ""; }; 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragController.swift; sourceTree = ""; }; - 9212B0029212B0029212B002 /* SidebarEmptyAreaWindowDragNSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragNSView.swift; sourceTree = ""; }; - 9212B0039212B0039212B003 /* SidebarEmptyAreaWindowDragView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragView.swift; sourceTree = ""; }; B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFocusBoundaryLifecycleTests.swift; sourceTree = ""; }; A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = ""; }; 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGitProcessCompositionTests.swift; sourceTree = ""; }; @@ -6578,9 +6574,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */, EA1F00000000000000000002 /* SidebarPathFormatter.swift */, EA1F00000000000000000004 /* SidebarDirectoryText.swift */, - 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */, - 9212B0029212B0029212B002 /* SidebarEmptyAreaWindowDragNSView.swift */, - 9212B0039212B0039212B003 /* SidebarEmptyAreaWindowDragView.swift */, + 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */, D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */, C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, C0DE1A020000000000000002 /* SavedLayoutStore.swift */, @@ -10877,8 +10871,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */, B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */, 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */, - 9212A0029212A0029212A002 /* SidebarEmptyAreaWindowDragNSView.swift in Sources */, - 9212A0039212A0039212A003 /* SidebarEmptyAreaWindowDragView.swift in Sources */, A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */, F10753000000000000000001 /* SidebarGroupHeaderRowActions+NotificationState.swift in Sources */, B804A0200000000000000020 /* SidebarGroupHeaderRowModel.swift in Sources */, From ba98a0b0aec5643bdd3c1d3a7aa5832fceb3479b Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 19:50:24 -0700 Subject: [PATCH 09/16] Complete sidebar drag event lifecycle --- .../SidebarWorkspaceTableClipView.swift | 5 +- .../SidebarWorkspaceTableViewImpl.swift | 2 +- ...SidebarEmptyAreaWindowDragController.swift | 89 +++++++++++++------ .../TitlebarInteractiveControlTests.swift | 69 ++++++++++---- 4 files changed, 122 insertions(+), 43 deletions(-) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift index 3b00ddee78c..bb35ff843e2 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift @@ -6,12 +6,15 @@ final class SidebarWorkspaceTableClipView: NSClipView { weak var workspaceController: SidebarWorkspaceTableController? private let emptyAreaWindowDragController = SidebarEmptyAreaWindowDragController() + /// Lets an inactive window begin dragging from empty viewport space. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + /// Preserves empty-space clicks while promoting threshold-crossing presses to window drags. override func mouseDown(with event: NSEvent) { // Presses only reach the clip view when they miss the table's frame // entirely, so anything landing here is empty space by construction. if event.clickCount == 1, - emptyAreaWindowDragController.perform(with: event, in: self) { + emptyAreaWindowDragController.perform(with: event, in: self) != .passThrough { return } super.mouseDown(with: event) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift index 9abab480573..140571f6fda 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift @@ -60,7 +60,7 @@ final class SidebarWorkspaceTableViewImpl: NSTableView { // Below the last row the press is window-drag territory. Single clicks // only: a double-click still belongs to doubleClickEmptyArea(). if clickedRow < 0, event.clickCount == 1, - emptyAreaWindowDragController.perform(with: event, in: self) { + emptyAreaWindowDragController.perform(with: event, in: self) != .passThrough { return } // No selection paint on press: the highlight applies on down-then-up diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift index a4fe9145638..14ff5289d8c 100644 --- a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift @@ -11,8 +11,28 @@ import AppKit /// stops recognizing for the rest of the window's life. @MainActor struct SidebarEmptyAreaWindowDragController { + /// Result of tracking one empty-area mouse-down sequence. + enum Outcome: Equatable { + /// The caller should continue its normal `mouseDown` handling. + case passThrough + /// AppKit took ownership of the sequence to move the window. + case dragged + /// AppKit ended the sequence without a mouse-up to replay. + case cancelled + } + + /// Event-source values consumed by the synchronous tracking loop. + enum TrackingEvent { + /// Pointer movement that may cross the drag threshold. + case dragged(location: NSPoint) + /// The terminating event that normal click handling still needs. + case mouseUp(NSEvent) + /// A system cancellation that terminates the sequence without mouse-up. + case cancelled + } + private let dragThreshold: CGFloat - private let nextEvent: @MainActor (NSWindow) -> NSEvent? + private let nextEvent: @MainActor (NSWindow) -> TrackingEvent? /// Creates a controller with its pointer threshold and event source. /// @@ -20,13 +40,25 @@ struct SidebarEmptyAreaWindowDragController { /// resolves into either movement or a mouse-up. init( dragThreshold: CGFloat = 4, - nextEvent: @escaping @MainActor (NSWindow) -> NSEvent? = { window in - window.nextEvent( - matching: [.leftMouseDragged, .leftMouseUp], + nextEvent: @escaping @MainActor (NSWindow) -> TrackingEvent? = { window in + var eventMask: NSEvent.EventTypeMask = [.leftMouseDragged, .leftMouseUp] + if #available(macOS 26.0, *) { + eventMask.insert(.mouseCancelled) + } + guard let event = window.nextEvent( + matching: eventMask, until: .distantFuture, inMode: .eventTracking, dequeue: true - ) + ) else { return nil } + + if #available(macOS 26.0, *), event.type == .mouseCancelled { + return .cancelled + } + if event.type == .leftMouseUp { + return .mouseUp(event) + } + return .dragged(location: event.locationInWindow) } ) { self.dragThreshold = dragThreshold @@ -35,37 +67,42 @@ struct SidebarEmptyAreaWindowDragController { /// Consumes `event` as a window drag when the press turns into one. /// - /// Returns `true` when the window was dragged and the caller must not run - /// its normal `mouseDown` handling. Returns `false` for a press that never - /// passed the configured drag threshold, having pushed the terminating - /// mouse-up back onto the queue first — `NSTableView`'s own `mouseDown` - /// tracking loop waits for that event, and swallowing it would hang the - /// click. + /// Returns `.dragged` when AppKit moved the window and `.cancelled` when + /// the system ended the sequence without a mouse-up; callers consume both. + /// Returns `.passThrough` for a press that never passed the configured drag + /// threshold, having pushed the terminating mouse-up back onto the queue + /// first — `NSTableView`'s own `mouseDown` tracking loop waits for that + /// event, and swallowing it would hang the click. func perform( with event: NSEvent, in view: NSView - ) -> Bool { - guard let window = view.window else { return false } - guard !isWindowDragSuppressed(window: window) else { return false } + ) -> Outcome { + guard let window = view.window else { return .passThrough } + guard !isWindowDragSuppressed(window: window) else { return .passThrough } let start = event.locationInWindow while let next = nextEvent(window) { - if next.type == .leftMouseUp { - window.postEvent(next, atStart: true) - return false - } - - let location = next.locationInWindow - let distance = hypot(location.x - start.x, location.y - start.y) - guard distance >= dragThreshold else { continue } + switch next { + case let .mouseUp(mouseUp): + window.postEvent(mouseUp, atStart: true) + return .passThrough + case .cancelled: + return .cancelled + case let .dragged(location): + let distance = hypot(location.x - start.x, location.y - start.y) + guard distance >= dragThreshold else { continue } - withTemporaryWindowMovableEnabled(window: window) { - window.performDrag(with: event) + withTemporaryWindowMovableEnabled(window: window) { + window.performDrag(with: event) + } + return .dragged } - return true } - return false + // A source that ends without mouse-up cannot safely fall through to + // AppKit's tracking loop, which would wait for an event that may never + // arrive. Treat it as a consumed cancellation. + return .cancelled } } diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 6a9ab3e6b82..6100388b33f 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -296,9 +296,11 @@ struct SidebarEmptyAreaWindowDragTests { return window } - /// Feeds a fixed script of events in place of the real tracking pump so the - /// loop is exercised without a run loop. - private static func pump(_ events: [NSEvent]) -> @MainActor (NSWindow) -> NSEvent? { + /// Feeds a fixed script in place of the real tracking pump so the loop is + /// exercised without a run loop. + private static func pump( + _ events: [SidebarEmptyAreaWindowDragController.TrackingEvent] + ) -> @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragController.TrackingEvent? { var remaining = events return { _ in remaining.isEmpty ? nil : remaining.removeFirst() } } @@ -337,10 +339,12 @@ struct SidebarEmptyAreaWindowDragTests { let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) let dragged = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 100), window: window) - let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([dragged])) - let handled = controller.perform(with: down, in: view) + let controller = SidebarEmptyAreaWindowDragController( + nextEvent: Self.pump([.dragged(location: dragged.locationInWindow)]) + ) + let outcome = controller.perform(with: down, in: view) - #expect(handled) + #expect(outcome == .dragged) #expect(window.performDragCallCount == 1) #expect(window.isMovableDuringPerformDrag == true) #expect(!window.isMovable) @@ -355,10 +359,10 @@ struct SidebarEmptyAreaWindowDragTests { let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) let up = Self.makeMouseEvent(type: .leftMouseUp, location: NSPoint(x: 40, y: 80), window: window) - let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([up])) - let handled = controller.perform(with: down, in: view) + let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([.mouseUp(up)])) + let outcome = controller.perform(with: down, in: view) - #expect(!handled) + #expect(outcome == .passThrough) #expect(window.performDragCallCount == 0) try Self.expectReplayedMouseUp(in: window, matches: up) } @@ -373,10 +377,15 @@ struct SidebarEmptyAreaWindowDragTests { let jitter = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 41, y: 81), window: window) let up = Self.makeMouseEvent(type: .leftMouseUp, location: NSPoint(x: 41, y: 81), window: window) - let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([jitter, up])) - let handled = controller.perform(with: down, in: view) + let controller = SidebarEmptyAreaWindowDragController( + nextEvent: Self.pump([ + .dragged(location: jitter.locationInWindow), + .mouseUp(up), + ]) + ) + let outcome = controller.perform(with: down, in: view) - #expect(!handled) + #expect(outcome == .passThrough) #expect(window.performDragCallCount == 0) try Self.expectReplayedMouseUp(in: window, matches: up) } @@ -391,10 +400,40 @@ struct SidebarEmptyAreaWindowDragTests { let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 4, y: 4), window: window) let dragged = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 40, y: 40), window: window) - let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([dragged])) - let handled = controller.perform(with: down, in: detached) + let controller = SidebarEmptyAreaWindowDragController( + nextEvent: Self.pump([.dragged(location: dragged.locationInWindow)]) + ) + let outcome = controller.perform(with: down, in: detached) + + #expect(outcome == .passThrough) + #expect(window.performDragCallCount == 0) + } + + /// A system cancellation consumes the sequence without starting a drag. + @Test func cancelledSequenceDoesNotFallThrough() throws { + let window = Self.makeWindow() + defer { window.orderOut(nil) } + let view = try #require(window.contentView) + + let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 40, y: 80), window: window) + let controller = SidebarEmptyAreaWindowDragController(nextEvent: Self.pump([.cancelled])) - #expect(!handled) + let outcome = controller.perform(with: down, in: view) + + #expect(outcome == .cancelled) #expect(window.performDragCallCount == 0) } + + /// Both native empty-area owners receive the initial press in an inactive window. + @Test func nativeEmptyAreaOwnersAcceptFirstMouse() { + _ = NSApplication.shared + + let clipView = SidebarWorkspaceTableClipView() + let tableView = SidebarWorkspaceTableViewImpl() + + #expect(clipView.acceptsFirstMouse(for: nil)) + // NSTableView already accepts click-through via NSControl; retain that + // policy for rows while the clip view opts in for empty viewport space. + #expect(tableView.acceptsFirstMouse(for: nil)) + } } From 1973c2bb4d3dab82c35f3be5591f68aae99832f1 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 20:02:14 -0700 Subject: [PATCH 10/16] Split sidebar drag lifecycle types --- ...SidebarEmptyAreaWindowDragController.swift | 26 +++---------------- .../SidebarEmptyAreaWindowDragOutcome.swift | 9 +++++++ ...ebarEmptyAreaWindowDragTrackingEvent.swift | 11 ++++++++ cmux.xcodeproj/project.pbxproj | 8 ++++++ .../TitlebarInteractiveControlTests.swift | 4 +-- 5 files changed, 33 insertions(+), 25 deletions(-) create mode 100644 Sources/Sidebar/SidebarEmptyAreaWindowDragOutcome.swift create mode 100644 Sources/Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift index 14ff5289d8c..bd075db1e82 100644 --- a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift @@ -11,28 +11,8 @@ import AppKit /// stops recognizing for the rest of the window's life. @MainActor struct SidebarEmptyAreaWindowDragController { - /// Result of tracking one empty-area mouse-down sequence. - enum Outcome: Equatable { - /// The caller should continue its normal `mouseDown` handling. - case passThrough - /// AppKit took ownership of the sequence to move the window. - case dragged - /// AppKit ended the sequence without a mouse-up to replay. - case cancelled - } - - /// Event-source values consumed by the synchronous tracking loop. - enum TrackingEvent { - /// Pointer movement that may cross the drag threshold. - case dragged(location: NSPoint) - /// The terminating event that normal click handling still needs. - case mouseUp(NSEvent) - /// A system cancellation that terminates the sequence without mouse-up. - case cancelled - } - private let dragThreshold: CGFloat - private let nextEvent: @MainActor (NSWindow) -> TrackingEvent? + private let nextEvent: @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragTrackingEvent? /// Creates a controller with its pointer threshold and event source. /// @@ -40,7 +20,7 @@ struct SidebarEmptyAreaWindowDragController { /// resolves into either movement or a mouse-up. init( dragThreshold: CGFloat = 4, - nextEvent: @escaping @MainActor (NSWindow) -> TrackingEvent? = { window in + nextEvent: @escaping @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragTrackingEvent? = { window in var eventMask: NSEvent.EventTypeMask = [.leftMouseDragged, .leftMouseUp] if #available(macOS 26.0, *) { eventMask.insert(.mouseCancelled) @@ -76,7 +56,7 @@ struct SidebarEmptyAreaWindowDragController { func perform( with event: NSEvent, in view: NSView - ) -> Outcome { + ) -> SidebarEmptyAreaWindowDragOutcome { guard let window = view.window else { return .passThrough } guard !isWindowDragSuppressed(window: window) else { return .passThrough } diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragOutcome.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragOutcome.swift new file mode 100644 index 00000000000..68029468768 --- /dev/null +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragOutcome.swift @@ -0,0 +1,9 @@ +/// Result of tracking one empty-area mouse-down sequence. +enum SidebarEmptyAreaWindowDragOutcome: Equatable { + /// The caller should continue its normal `mouseDown` handling. + case passThrough + /// AppKit took ownership of the sequence to move the window. + case dragged + /// AppKit ended the sequence without a mouse-up to replay. + case cancelled +} diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift new file mode 100644 index 00000000000..5db90b89b09 --- /dev/null +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift @@ -0,0 +1,11 @@ +import AppKit + +/// Event-source value consumed by the synchronous empty-area tracking loop. +enum SidebarEmptyAreaWindowDragTrackingEvent { + /// Pointer movement that may cross the drag threshold. + case dragged(location: NSPoint) + /// The terminating event that normal click handling still needs. + case mouseUp(NSEvent) + /// A system cancellation that terminates the sequence without mouse-up. + case cancelled +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 86e23ae9009..eeed98b6e1e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2122,6 +2122,8 @@ B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0270000000000000027 /* SidebarDividerTrackingView.swift */; }; A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FD1001 /* SidebarFileDropFindRoutingTests.swift */; }; 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */; }; + 9212A0049212A0049212A004 /* SidebarEmptyAreaWindowDragOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0049212B0049212B004 /* SidebarEmptyAreaWindowDragOutcome.swift */; }; + 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */; }; B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */; }; A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */; }; 8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */; }; @@ -5096,6 +5098,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B804B0270000000000000027 /* SidebarDividerTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDividerTrackingView.swift; sourceTree = ""; }; A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = ""; }; 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragController.swift; sourceTree = ""; }; + 9212B0049212B0049212B004 /* SidebarEmptyAreaWindowDragOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragOutcome.swift; sourceTree = ""; }; + 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift; sourceTree = ""; }; B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFocusBoundaryLifecycleTests.swift; sourceTree = ""; }; A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = ""; }; 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGitProcessCompositionTests.swift; sourceTree = ""; }; @@ -6575,6 +6579,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef EA1F00000000000000000002 /* SidebarPathFormatter.swift */, EA1F00000000000000000004 /* SidebarDirectoryText.swift */, 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */, + 9212B0049212B0049212B004 /* SidebarEmptyAreaWindowDragOutcome.swift */, + 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */, D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */, C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, C0DE1A020000000000000002 /* SavedLayoutStore.swift */, @@ -10871,6 +10877,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */, B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */, 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */, + 9212A0049212A0049212A004 /* SidebarEmptyAreaWindowDragOutcome.swift in Sources */, + 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */, A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */, F10753000000000000000001 /* SidebarGroupHeaderRowActions+NotificationState.swift in Sources */, B804A0200000000000000020 /* SidebarGroupHeaderRowModel.swift in Sources */, diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 6100388b33f..281d3cb8e1a 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -299,8 +299,8 @@ struct SidebarEmptyAreaWindowDragTests { /// Feeds a fixed script in place of the real tracking pump so the loop is /// exercised without a run loop. private static func pump( - _ events: [SidebarEmptyAreaWindowDragController.TrackingEvent] - ) -> @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragController.TrackingEvent? { + _ events: [SidebarEmptyAreaWindowDragTrackingEvent] + ) -> @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragTrackingEvent? { var remaining = events return { _ in remaining.isEmpty ? nil : remaining.removeFirst() } } From 02ff8bad2eb334c267ff4e7faaa55006d23ff5db Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 21:47:41 -0700 Subject: [PATCH 11/16] Clarify sidebar drag event ownership --- .../Sidebar/SidebarEmptyAreaWindowDragController.swift | 2 ++ cmuxTests/TitlebarInteractiveControlTests.swift | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift index bd075db1e82..9686f1825a7 100644 --- a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift @@ -74,6 +74,8 @@ struct SidebarEmptyAreaWindowDragController { guard distance >= dragThreshold else { continue } withTemporaryWindowMovableEnabled(window: window) { + // AppKit requires the original mouse-down event; the + // tracked location only decides when to hand off. window.performDrag(with: event) } return .dragged diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 281d3cb8e1a..3f5c39ae6fe 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -398,15 +398,19 @@ struct SidebarEmptyAreaWindowDragTests { let detached = NSView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) let down = Self.makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 4, y: 4), window: window) - let dragged = Self.makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 40, y: 40), window: window) + var nextEventCallCount = 0 let controller = SidebarEmptyAreaWindowDragController( - nextEvent: Self.pump([.dragged(location: dragged.locationInWindow)]) + nextEvent: { _ in + nextEventCallCount += 1 + return nil + } ) let outcome = controller.perform(with: down, in: detached) #expect(outcome == .passThrough) #expect(window.performDragCallCount == 0) + #expect(nextEventCallCount == 0) } /// A system cancellation consumes the sequence without starting a drag. From 03011952e47f55468b368c03df3ac47b97cc69a1 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 22:23:44 -0700 Subject: [PATCH 12/16] Test invalid sidebar drag thresholds --- .../TitlebarInteractiveControlTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 3f5c39ae6fe..4f9e04ca3d6 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -390,6 +390,25 @@ struct SidebarEmptyAreaWindowDragTests { try Self.expectReplayedMouseUp(in: window, matches: up) } + /// Invalid thresholds fail at construction instead of encoding an unusable drag policy. + @Test func invalidThresholdsFailFast() async { + await #expect(processExitsWith: .failure) { + await MainActor.run { + _ = SidebarEmptyAreaWindowDragController(dragThreshold: -1) + } + } + await #expect(processExitsWith: .failure) { + await MainActor.run { + _ = SidebarEmptyAreaWindowDragController(dragThreshold: .infinity) + } + } + await #expect(processExitsWith: .failure) { + await MainActor.run { + _ = SidebarEmptyAreaWindowDragController(dragThreshold: .nan) + } + } + } + /// A detached view declines the drag without consulting the injected event source. @Test func viewWithoutWindowIsNotADrag() { _ = NSApplication.shared From c001376168ce7ceeddaac52c6e259c2cddc3ec81 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 22:52:00 -0700 Subject: [PATCH 13/16] Reject invalid sidebar drag thresholds --- Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift index 9686f1825a7..93a1a7fa6db 100644 --- a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift @@ -41,6 +41,10 @@ struct SidebarEmptyAreaWindowDragController { return .dragged(location: event.locationInWindow) } ) { + precondition( + dragThreshold.isFinite && dragThreshold >= 0, + "Sidebar drag threshold must be finite and non-negative" + ) self.dragThreshold = dragThreshold self.nextEvent = nextEvent } From 7ad21254b2ee3bf365b004fe0ae3175899da71ff Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Thu, 6 Aug 2026 23:09:39 -0700 Subject: [PATCH 14/16] Gate sidebar drag APIs for older toolchains --- Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift | 4 ++++ cmuxTests/TitlebarInteractiveControlTests.swift | 2 ++ 2 files changed, 6 insertions(+) diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift index 93a1a7fa6db..607f89e442e 100644 --- a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift @@ -22,9 +22,11 @@ struct SidebarEmptyAreaWindowDragController { dragThreshold: CGFloat = 4, nextEvent: @escaping @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragTrackingEvent? = { window in var eventMask: NSEvent.EventTypeMask = [.leftMouseDragged, .leftMouseUp] + #if compiler(>=6.2) if #available(macOS 26.0, *) { eventMask.insert(.mouseCancelled) } + #endif guard let event = window.nextEvent( matching: eventMask, until: .distantFuture, @@ -32,9 +34,11 @@ struct SidebarEmptyAreaWindowDragController { dequeue: true ) else { return nil } + #if compiler(>=6.2) if #available(macOS 26.0, *), event.type == .mouseCancelled { return .cancelled } + #endif if event.type == .leftMouseUp { return .mouseUp(event) } diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 4f9e04ca3d6..8cb518ab48e 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -390,6 +390,7 @@ struct SidebarEmptyAreaWindowDragTests { try Self.expectReplayedMouseUp(in: window, matches: up) } + #if compiler(>=6.2) /// Invalid thresholds fail at construction instead of encoding an unusable drag policy. @Test func invalidThresholdsFailFast() async { await #expect(processExitsWith: .failure) { @@ -408,6 +409,7 @@ struct SidebarEmptyAreaWindowDragTests { } } } + #endif /// A detached view declines the drag without consulting the injected event source. @Test func viewWithoutWindowIsNotADrag() { From 86fd425decd8c8b087cb71798636f66f3df8d496 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 1 Sep 2026 03:26:31 -0700 Subject: [PATCH 15/16] Reconcile sidebar drag branch with current main --- cmux.xcodeproj/project.pbxproj | 8 ++++---- cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index eeed98b6e1e..4a9c414303d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2120,10 +2120,10 @@ D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000004 /* SidebarDirectoryText.swift */; }; B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0270000000000000027 /* SidebarDividerTrackingView.swift */; }; - A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FD1001 /* SidebarFileDropFindRoutingTests.swift */; }; - 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */; }; + 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */; }; 9212A0049212A0049212A004 /* SidebarEmptyAreaWindowDragOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0049212B0049212B004 /* SidebarEmptyAreaWindowDragOutcome.swift */; }; 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */; }; + A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FD1001 /* SidebarFileDropFindRoutingTests.swift */; }; B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */; }; A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */; }; 8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */; }; @@ -5096,10 +5096,10 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = ""; }; EA1F00000000000000000004 /* SidebarDirectoryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDirectoryText.swift; sourceTree = ""; }; B804B0270000000000000027 /* SidebarDividerTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDividerTrackingView.swift; sourceTree = ""; }; - A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = ""; }; - 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragController.swift; sourceTree = ""; }; + 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragController.swift; sourceTree = ""; }; 9212B0049212B0049212B004 /* SidebarEmptyAreaWindowDragOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragOutcome.swift; sourceTree = ""; }; 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift; sourceTree = ""; }; + A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = ""; }; B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFocusBoundaryLifecycleTests.swift; sourceTree = ""; }; A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = ""; }; 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGitProcessCompositionTests.swift; sourceTree = ""; }; diff --git a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift index 8bac2dfcb3f..4107cb07a13 100644 --- a/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift +++ b/cmuxTests/AppDelegateEqualizeSplitsShortcutTests.swift @@ -4998,8 +4998,7 @@ final class AppDelegateEqualizeSplitsShortcutTests { XCTFail("Expected an unvisited terminal") return } - weak var weakRemovablePanel: TerminalPanel? - weakRemovablePanel = removablePanel + weak var weakRemovablePanel = removablePanel workspace.panels.removeValue(forKey: removablePanelId) removablePanel = nil From 04d29299a66ab0fa36452977d27074ad7b0da105 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 1 Sep 2026 03:35:18 -0700 Subject: [PATCH 16/16] Keep sidebar drag policy platform-safe --- ...SidebarEmptyAreaWindowDragController.swift | 13 +++------- .../TitlebarInteractiveControlTests.swift | 25 +++---------------- 2 files changed, 7 insertions(+), 31 deletions(-) diff --git a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift index 607f89e442e..b7a8a125880 100644 --- a/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift +++ b/Sources/Sidebar/SidebarEmptyAreaWindowDragController.swift @@ -11,15 +11,15 @@ import AppKit /// stops recognizing for the rest of the window's life. @MainActor struct SidebarEmptyAreaWindowDragController { - private let dragThreshold: CGFloat + /// Pointer travel, in window points, before a press becomes a window drag. + private static let dragThreshold: CGFloat = 4 private let nextEvent: @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragTrackingEvent? - /// Creates a controller with its pointer threshold and event source. + /// Creates a controller with an injected event source. /// /// The default event source blocks in `.eventTracking` until the press /// resolves into either movement or a mouse-up. init( - dragThreshold: CGFloat = 4, nextEvent: @escaping @MainActor (NSWindow) -> SidebarEmptyAreaWindowDragTrackingEvent? = { window in var eventMask: NSEvent.EventTypeMask = [.leftMouseDragged, .leftMouseUp] #if compiler(>=6.2) @@ -45,11 +45,6 @@ struct SidebarEmptyAreaWindowDragController { return .dragged(location: event.locationInWindow) } ) { - precondition( - dragThreshold.isFinite && dragThreshold >= 0, - "Sidebar drag threshold must be finite and non-negative" - ) - self.dragThreshold = dragThreshold self.nextEvent = nextEvent } @@ -79,7 +74,7 @@ struct SidebarEmptyAreaWindowDragController { return .cancelled case let .dragged(location): let distance = hypot(location.x - start.x, location.y - start.y) - guard distance >= dragThreshold else { continue } + guard distance >= Self.dragThreshold else { continue } withTemporaryWindowMovableEnabled(window: window) { // AppKit requires the original mouse-down event; the diff --git a/cmuxTests/TitlebarInteractiveControlTests.swift b/cmuxTests/TitlebarInteractiveControlTests.swift index 8cb518ab48e..24865e41b21 100644 --- a/cmuxTests/TitlebarInteractiveControlTests.swift +++ b/cmuxTests/TitlebarInteractiveControlTests.swift @@ -324,7 +324,9 @@ struct SidebarEmptyAreaWindowDragTests { // AppKit can round the event timestamp while rebuilding the queued // NSEvent, so compare at microsecond precision instead of bit-for-bit. #expect(abs(replayed.timestamp - original.timestamp) < 0.000_001) - #expect(replayed.locationInWindow == original.locationInWindow) + // AppKit may reconstruct the queued point relative to the window's + // current screen origin; that display geometry is not part of replay + // identity and varies across macOS versions and window placement. #expect(replayed.clickCount == original.clickCount) #expect(replayed.modifierFlags == original.modifierFlags) } @@ -390,27 +392,6 @@ struct SidebarEmptyAreaWindowDragTests { try Self.expectReplayedMouseUp(in: window, matches: up) } - #if compiler(>=6.2) - /// Invalid thresholds fail at construction instead of encoding an unusable drag policy. - @Test func invalidThresholdsFailFast() async { - await #expect(processExitsWith: .failure) { - await MainActor.run { - _ = SidebarEmptyAreaWindowDragController(dragThreshold: -1) - } - } - await #expect(processExitsWith: .failure) { - await MainActor.run { - _ = SidebarEmptyAreaWindowDragController(dragThreshold: .infinity) - } - } - await #expect(processExitsWith: .failure) { - await MainActor.run { - _ = SidebarEmptyAreaWindowDragController(dragThreshold: .nan) - } - } - } - #endif - /// A detached view declines the drag without consulting the injected event source. @Test func viewWithoutWindowIsNotADrag() { _ = NSApplication.shared