diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 76986942a8bd..55b7d4135836 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -18,6 +18,14 @@ 36CE99ED050785B5E96B72BB /* AuthCallbackRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A19A4145F034965110CF87 /* AuthCallbackRouter.swift */; }; 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */; }; 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; + D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */; }; + D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */; }; + D0B10004A1B2C3D4E5F60001 /* WorkspacePortalPaneDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10005A1B2C3D4E5F60001 /* WorkspacePortalPaneDrop.swift */; }; + D0B10006A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10007A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift */; }; + D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */; }; + D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */; }; + D0B1000CA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */; }; + D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000FA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift */; }; 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 29813FE5A6CBC1019289A251 /* CMUXAuthCore */; }; AA11BB22CC33DD44EE550001 /* CMUXWorkstream in Frameworks */ = {isa = PBXBuildFile; productRef = AA11BB22CC33DD44EE550002 /* CMUXWorkstream */; }; FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F001 /* FeedCoordinator.swift */; }; @@ -273,6 +281,9 @@ /* Begin PBXFileReference section */ 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; + D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortalTabDragRoutingTests.swift; sourceTree = ""; }; + D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewDragRoutingTests.swift; sourceTree = ""; }; + D0B1000FA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneDropRoutingTests.swift; sourceTree = ""; }; 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerSessionSnapshotTests.swift; sourceTree = ""; }; 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePullRequestSidebarTests.swift; sourceTree = ""; }; 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceManualUnreadTests.swift; sourceTree = ""; }; @@ -331,6 +342,8 @@ E30750000000000000000001 /* WorkspaceTabColorResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabColorResolution.swift; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; + D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewSupport.swift; sourceTree = ""; }; + D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneDropTargetView.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; @@ -367,6 +380,7 @@ A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; + D0B10005A1B2C3D4E5F60001 /* WorkspacePortalPaneDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacePortalPaneDrop.swift; sourceTree = ""; }; C0DE32470000000000000004 /* WorkspaceSurfaceIdentifierClipboardText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSurfaceIdentifierClipboardText.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; E30760000000000000000001 /* TmuxWorkspacePaneOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TmuxWorkspacePaneOverlayView.swift; sourceTree = ""; }; @@ -376,7 +390,9 @@ A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; + D0B10007A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortalDebug.swift; sourceTree = ""; }; A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; + D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabBarPassThrough.swift; sourceTree = ""; }; A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = ""; }; A5001544 /* TerminalImageTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalImageTransfer.swift; sourceTree = ""; }; A5001545 /* TerminalSSHSessionDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSSHSessionDetector.swift; sourceTree = ""; }; @@ -645,13 +661,18 @@ A5001511 /* UITestRecorder.swift */, A5001520 /* PostHogAnalytics.swift */, A5001416 /* Workspace.swift */, + D0B10005A1B2C3D4E5F60001 /* WorkspacePortalPaneDrop.swift */, C0DE32470000000000000004 /* WorkspaceSurfaceIdentifierClipboardText.swift */, A5001417 /* WorkspaceContentView.swift */, E30760000000000000000001 /* TmuxWorkspacePaneOverlayView.swift */, A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, + D0B1000BA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift */, + D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */, A5001531 /* TerminalWindowPortal.swift */, + D0B10007A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift */, A5001533 /* BrowserWindowPortal.swift */, + D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */, A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001544 /* TerminalImageTransfer.swift */, @@ -762,7 +783,10 @@ A50019B3 /* SettingsSearchIndexTests.swift */, 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, + D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */, + D0B1000FA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift */, 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */, + D0B10009A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift */, 71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */, BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */, 6083A7DAD962E287FC2FFE94 /* ShortcutAndCommandPaletteTests.swift */, @@ -1049,13 +1073,18 @@ A5001501 /* UITestRecorder.swift in Sources */, A5001521 /* PostHogAnalytics.swift in Sources */, A5001406 /* Workspace.swift in Sources */, + D0B10004A1B2C3D4E5F60001 /* WorkspacePortalPaneDrop.swift in Sources */, C0DE32470000000000000003 /* WorkspaceSurfaceIdentifierClipboardText.swift in Sources */, A5001407 /* WorkspaceContentView.swift in Sources */, E30760000000000000000002 /* TmuxWorkspacePaneOverlayView.swift in Sources */, A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, + D0B1000AA1B2C3D4E5F60001 /* GhosttyTerminalViewSupport.swift in Sources */, + D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, + D0B10006A1B2C3D4E5F60001 /* TerminalWindowPortalDebug.swift in Sources */, A5001534 /* BrowserWindowPortal.swift in Sources */, + D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001542 /* TerminalImageTransfer.swift in Sources */, @@ -1200,7 +1229,10 @@ A50019B2 /* SettingsSearchIndexTests.swift in Sources */, E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, + D0B1000CA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift in Sources */, + D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */, 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */, + D0B10008A1B2C3D4E5F60001 /* PortalTabDragRoutingTests.swift in Sources */, 6B524A0BA34FD46A771335AB /* WorkspaceUnitTests.swift in Sources */, 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */, 1521D55DC63D5E5FC4955E31 /* ShortcutAndCommandPaletteTests.swift in Sources */, diff --git a/Sources/BonsplitTabBarPassThrough.swift b/Sources/BonsplitTabBarPassThrough.swift new file mode 100644 index 000000000000..321f81313aea --- /dev/null +++ b/Sources/BonsplitTabBarPassThrough.swift @@ -0,0 +1,115 @@ +import AppKit +import Bonsplit + +/// Shared helpers for portal hosts that must defer to the minimal-mode +/// Bonsplit tab strip rendered underneath them. +enum BonsplitTabBarPassThrough { + static func isPassThroughPointerEvent(_ eventType: NSEvent.EventType?) -> Bool { + switch eventType { + case nil: + // Unit tests can call hitTest directly without an active AppKit event. + return true + case .leftMouseDown, .leftMouseUp, .leftMouseDragged, + .rightMouseDown, .rightMouseUp, .rightMouseDragged, + .otherMouseDown, .otherMouseUp, .otherMouseDragged, + .mouseMoved, .mouseEntered, + .mouseExited, .cursorUpdate, + .appKitDefined, .applicationDefined, .systemDefined, .periodic: + return true + default: + return false + } + } + + static func titlebarInteractionBandMinY(in window: NSWindow) -> CGFloat { + let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height + let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight)) + return window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5 + } + + // The minimal-mode tab strip lives just under the titlebar. Anything more + // than this many points below the content top can't overlap it, so we skip + // the recursive subtree scan on the pointer-event hot path. + private static let tabStripScanBandHeight: CGFloat = 200 + + static func shouldPassThroughToPaneTabBar( + windowPoint: NSPoint, + below portalHost: NSView + ) -> (result: Bool, registryHit: Bool) { + let registryHit = portalHost.window.map { + BonsplitTabBarHitRegionRegistry.containsWindowPoint(windowPoint, in: $0) + } ?? false + if registryHit { + return (true, true) + } + + // High-frequency pointer events (mouseMoved/cursorUpdate) flow through + // here on every hover; cap the recursive view-tree walk to the top + // band where the tab strip can actually live. + if let window = portalHost.window { + let scanFloor = window.contentLayoutRect.maxY - tabStripScanBandHeight + if windowPoint.y < scanFloor { + return (false, false) + } + } + + let fallbackHit = hasUnderlyingBonsplitTabBarBackground( + at: windowPoint, + below: portalHost + ) + return (fallbackHit, false) + } + + static func passThroughDecision( + at point: NSPoint, + in portalHost: NSView, + eventType: NSEvent.EventType? + ) -> (windowPoint: NSPoint, result: Bool, registryHit: Bool)? { + guard isPassThroughPointerEvent(eventType) else { return nil } + let windowPoint = portalHost.convert(point, to: nil) + let decision = shouldPassThroughToPaneTabBar(windowPoint: windowPoint, below: portalHost) + return (windowPoint, decision.result, decision.registryHit) + } + + static func hasBonsplitTabBarBackground(at windowPoint: NSPoint, in view: NSView) -> Bool { + guard !view.isHidden, view.alphaValue > 0 else { return false } + + // NSView subviews are not clipped to parent bounds by default, and the + // minimal tab strip can render outside its immediate container. + let className = NSStringFromClass(type(of: view)) + if className.contains("TabBarBackgroundNSView") { + let pointInView = view.convert(windowPoint, from: nil) + if view.bounds.contains(pointInView) { + return true + } + } + + for subview in view.subviews.reversed() { + if hasBonsplitTabBarBackground(at: windowPoint, in: subview) { + return true + } + } + return false + } + + static func hasUnderlyingBonsplitTabBarBackground( + at windowPoint: NSPoint, + below portalHost: NSView + ) -> Bool { + // Only walk siblings rendered below the host. Falling back to the full + // window content tree when the host has no superview would risk a + // false-positive pass-through against a tab bar painted above an + // unparented host. + guard let container = portalHost.superview, + let hostIndex = container.subviews.firstIndex(of: portalHost) else { + return false + } + for sibling in container.subviews[.. 0 else { continue } + if hasBonsplitTabBarBackground(at: windowPoint, in: sibling) { + return true + } + } + return false + } +} diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 37f8df4bcb09..5f3c485d70e0 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -10,118 +10,6 @@ private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0 private var cmuxBrowserPortalNeedsRenderingStateReattachKey: UInt8 = 0 private var cmuxWindowInteractiveSplitDividerDragKey: UInt8 = 0 -/// Shared helpers for portal hosts that must defer to the minimal-mode -/// Bonsplit tab strip rendered underneath them. -enum BonsplitTabBarPassThrough { - static func isPassThroughPointerEvent(_ eventType: NSEvent.EventType?) -> Bool { - switch eventType { - case nil: - // Unit tests can call hitTest directly without an active AppKit event. - return true - case .leftMouseDown, .leftMouseUp, - .rightMouseDown, .rightMouseUp, - .otherMouseDown, .otherMouseUp, - .mouseMoved, .mouseEntered, - .mouseExited, .cursorUpdate: - return true - default: - return false - } - } - - static func titlebarInteractionBandMinY(in window: NSWindow) -> CGFloat { - let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height - let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight)) - return window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5 - } - - // The minimal-mode tab strip lives just under the titlebar. Anything more - // than this many points below the content top can't overlap it, so we skip - // the recursive subtree scan on the pointer-event hot path. - private static let tabStripScanBandHeight: CGFloat = 200 - - static func shouldPassThroughToPaneTabBar( - windowPoint: NSPoint, - below portalHost: NSView - ) -> (result: Bool, registryHit: Bool) { - let registryHit = portalHost.window.map { - BonsplitTabBarHitRegionRegistry.containsWindowPoint(windowPoint, in: $0) - } ?? false - if registryHit { - return (true, true) - } - - // High-frequency pointer events (mouseMoved/cursorUpdate) flow through - // here on every hover; cap the recursive view-tree walk to the top - // band where the tab strip can actually live. - if let window = portalHost.window { - let scanFloor = window.contentLayoutRect.maxY - tabStripScanBandHeight - if windowPoint.y < scanFloor { - return (false, false) - } - } - - let fallbackHit = hasUnderlyingBonsplitTabBarBackground( - at: windowPoint, - below: portalHost - ) - return (fallbackHit, false) - } - - static func passThroughDecision( - at point: NSPoint, - in portalHost: NSView, - eventType: NSEvent.EventType? - ) -> (windowPoint: NSPoint, result: Bool, registryHit: Bool)? { - guard isPassThroughPointerEvent(eventType) else { return nil } - let windowPoint = portalHost.convert(point, to: nil) - let decision = shouldPassThroughToPaneTabBar(windowPoint: windowPoint, below: portalHost) - return (windowPoint, decision.result, decision.registryHit) - } - - static func hasBonsplitTabBarBackground(at windowPoint: NSPoint, in view: NSView) -> Bool { - guard !view.isHidden, view.alphaValue > 0 else { return false } - - // NSView subviews are not clipped to parent bounds by default, and the - // minimal tab strip can render outside its immediate container. - let className = NSStringFromClass(type(of: view)) - if className.contains("TabBarBackgroundNSView") { - let pointInView = view.convert(windowPoint, from: nil) - if view.bounds.contains(pointInView) { - return true - } - } - - for subview in view.subviews.reversed() { - if hasBonsplitTabBarBackground(at: windowPoint, in: subview) { - return true - } - } - return false - } - - static func hasUnderlyingBonsplitTabBarBackground( - at windowPoint: NSPoint, - below portalHost: NSView - ) -> Bool { - // Only walk siblings rendered below the host. Falling back to the full - // window content tree when the host has no superview would risk a - // false-positive pass-through against a tab bar painted *above* an - // unparented host. - guard let container = portalHost.superview, - let hostIndex = container.subviews.firstIndex(of: portalHost) else { - return false - } - for sibling in container.subviews[.. 0 else { continue } - if hasBonsplitTabBarBackground(at: windowPoint, in: sibling) { - return true - } - } - return false - } -} - #if DEBUG private func browserPortalDebugToken(_ view: NSView?) -> String { guard let view else { return "nil" } @@ -1596,6 +1484,9 @@ final class BrowserPaneDropTargetView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { guard bounds.contains(point), dropContext != nil else { return nil } + if shouldDeferToPaneTabBar(at: point) { + return nil + } let pasteboardTypes = NSPasteboard(name: .drag).types let eventType = NSApp.currentEvent?.type @@ -1688,6 +1579,12 @@ final class BrowserPaneDropTargetView: NSView { } private func updateDragState(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { + let location = convert(sender.draggingLocation, from: nil) + if shouldDeferToPaneTabBar(at: location) { + clearDragState(phase: "\(phase).tabBar") + return [] + } + guard let dropContext, let transfer = BrowserPaneDragTransfer.decode(from: sender.draggingPasteboard), transfer.isFromCurrentProcess else { @@ -1695,7 +1592,6 @@ final class BrowserPaneDropTargetView: NSView { return [] } - let location = convert(sender.draggingLocation, from: nil) let zone = BrowserPaneDropRouting.zone( for: location, in: bounds.size, @@ -1712,6 +1608,13 @@ final class BrowserPaneDropTargetView: NSView { return .move } + func shouldDeferToPaneTabBar(at point: NSPoint) -> Bool { + let windowPoint = convert(point, to: nil) + return BonsplitTabBarPassThrough + .shouldPassThroughToPaneTabBar(windowPoint: windowPoint, below: self) + .result + } + private func clearDragState(phase: String) { guard activeZone != nil else { return } activeZone = nil diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index abcd6f780328..c0148749eb41 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9327,36 +9327,6 @@ private final class GhosttyFlashOverlayView: NSView { } } -private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { - override var acceptsFirstResponder: Bool { false } - - override func hitTest(_ point: NSPoint) -> NSView? { - nil - } -} - -func shouldAllowEnsureFocusWindowActivation( - activeTabManager: TabManager?, - targetTabManager: TabManager, - keyWindow: NSWindow?, - mainWindow: NSWindow?, - targetWindow: NSWindow -) -> Bool { - guard activeTabManager === targetTabManager || (keyWindow == nil && mainWindow == nil) else { - return false - } - - if let keyWindow { - return keyWindow === targetWindow - } - - if let mainWindow { - return mainWindow === targetWindow - } - - return true -} - final class GhosttySurfaceScrollView: NSView { enum FlashStyle { case navigation @@ -9393,6 +9363,7 @@ final class GhosttySurfaceScrollView: NSView { private let surfaceView: GhosttyNSView private let inactiveOverlayView: GhosttyFlashOverlayView private let dropZoneOverlayView: GhosttyFlashOverlayView + private let paneDropTargetView = TerminalPaneDropTargetView(frame: .zero) private let notificationRingOverlayView: GhosttyFlashOverlayView private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView @@ -9665,6 +9636,8 @@ final class GhosttySurfaceScrollView: NSView { backgroundView.layer?.isOpaque = false addSubview(backgroundView) addSubview(scrollView) + paneDropTargetView.hostedView = self + addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) synchronizeScrollbarAppearance() inactiveOverlayView.wantsLayer = true inactiveOverlayView.layer?.backgroundColor = NSColor.clear.cgColor @@ -10006,6 +9979,8 @@ final class GhosttySurfaceScrollView: NSView { override func layout() { super.layout() synchronizeGeometryAndContent() + _ = setFrameIfNeeded(paneDropTargetView, to: bounds) + bringPaneDropTargetToFrontIfNeeded() } override func viewDidMoveToSuperview() { @@ -10067,6 +10042,7 @@ final class GhosttySurfaceScrollView: NSView { ) _ = setFrameIfNeeded(documentView, to: targetDocumentFrame) _ = setFrameIfNeeded(inactiveOverlayView, to: bounds) + _ = setFrameIfNeeded(paneDropTargetView, to: bounds) if let zone = activeDropZone { attachDropZoneOverlayIfNeeded() _ = setFrameIfNeeded( @@ -10091,6 +10067,7 @@ final class GhosttySurfaceScrollView: NSView { if let overlay = searchOverlayHostingView { _ = setFrameIfNeeded(overlay, to: bounds) } + bringPaneDropTargetToFrontIfNeeded() // NSScrollView can defer clip-view/content-size updates until its own layout pass, // which makes interactive width changes arrive a queue turn late on Sequoia. if didScrollbarAppearanceChange { @@ -10125,6 +10102,12 @@ final class GhosttySurfaceScrollView: NSView { superview ?? self } + private func bringPaneDropTargetToFrontIfNeeded() { + if paneDropTargetView.superview !== self || subviews.last !== paneDropTargetView { + addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) + } + } + private func attachDropZoneOverlayIfNeeded() { // Keep the hover indicator outside the hosted terminal subtree so it stays purely additive // and cannot invalidate the scroll/surface layout that Ghostty renders into. @@ -10857,6 +10840,13 @@ final class GhosttySurfaceScrollView: NSView { } } + func setPaneDropContext(_ context: TerminalPaneDropContext?) { + paneDropTargetView.dropContext = context + if context == nil { + paneDropTargetView.draggingExited(nil) + } + } + #if DEBUG private func logDropZoneOverlay(event: String, zone: DropZone?, frame: CGRect?) { let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" @@ -13102,6 +13092,11 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) if hostOwnsPortalNow { + hostedView.setPaneDropContext(TerminalPaneDropContext( + workspaceId: terminalSurface.tabId, + panelId: terminalSurface.id, + paneId: paneId + )) hostedView.setInactiveOverlay( color: inactiveOverlayColor, opacity: CGFloat(inactiveOverlayOpacity), diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift new file mode 100644 index 000000000000..c594250b31c7 --- /dev/null +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -0,0 +1,31 @@ +import AppKit + +final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { + override var acceptsFirstResponder: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + +func shouldAllowEnsureFocusWindowActivation( + activeTabManager: TabManager?, + targetTabManager: TabManager, + keyWindow: NSWindow?, + mainWindow: NSWindow?, + targetWindow: NSWindow +) -> Bool { + guard activeTabManager === targetTabManager || (keyWindow == nil && mainWindow == nil) else { + return false + } + + if let keyWindow { + return keyWindow === targetWindow + } + + if let mainWindow { + return mainWindow === targetWindow + } + + return true +} diff --git a/Sources/TerminalPaneDropTargetView.swift b/Sources/TerminalPaneDropTargetView.swift new file mode 100644 index 000000000000..9d7a058a0945 --- /dev/null +++ b/Sources/TerminalPaneDropTargetView.swift @@ -0,0 +1,270 @@ +import AppKit +import Bonsplit +import Foundation + +struct TerminalPaneDropContext: Equatable { + let workspaceId: UUID + let panelId: UUID + let paneId: PaneID +} + +struct TerminalPaneDragTransfer: Equatable { + let tabId: UUID + let sourcePaneId: UUID + let sourceProcessId: Int32 + + var isFromCurrentProcess: Bool { + sourceProcessId == Int32(ProcessInfo.processInfo.processIdentifier) + } + + static func decode(from pasteboard: NSPasteboard) -> TerminalPaneDragTransfer? { + if let data = pasteboard.data(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) { + return decode(from: data) + } + if let raw = pasteboard.string(forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) { + return decode(from: Data(raw.utf8)) + } + return nil + } + + static func decode(from data: Data) -> TerminalPaneDragTransfer? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tab = json["tab"] as? [String: Any], + let tabIdRaw = tab["id"] as? String, + let tabId = UUID(uuidString: tabIdRaw), + let sourcePaneIdRaw = json["sourcePaneId"] as? String, + let sourcePaneId = UUID(uuidString: sourcePaneIdRaw) else { + return nil + } + + let sourceProcessId = (json["sourceProcessId"] as? NSNumber)?.int32Value ?? -1 + return TerminalPaneDragTransfer( + tabId: tabId, + sourcePaneId: sourcePaneId, + sourceProcessId: sourceProcessId + ) + } +} + +enum TerminalPaneDropRouting { + static func zone(for location: CGPoint, in size: CGSize) -> DropZone { + let edgeRatio: CGFloat = 0.25 + let horizontalEdge = max(80, size.width * edgeRatio) + let verticalEdge = max(80, size.height * edgeRatio) + + if location.x < horizontalEdge { + return .left + } else if location.x > size.width - horizontalEdge { + return .right + } else if location.y > size.height - verticalEdge { + return .top + } else if location.y < verticalEdge { + return .bottom + } else { + return .center + } + } +} + +final class TerminalPaneDropTargetView: NSView { + weak var hostedView: GhosttySurfaceScrollView? + var dropContext: TerminalPaneDropContext? + private var activeZone: DropZone? +#if DEBUG + private var lastHitTestSignature: String? +#endif + + override var acceptsFirstResponder: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([DragOverlayRoutingPolicy.bonsplitTabTransferType]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + + static func shouldCaptureHitTesting( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) else { return false } + guard let eventType else { return false } + + switch eventType { + case .cursorUpdate, + .mouseEntered, + .mouseExited, + .mouseMoved, + .leftMouseDragged, + .rightMouseDragged, + .otherMouseDragged, + .appKitDefined, + .applicationDefined, + .systemDefined, + .periodic: + return true + default: + return false + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point), dropContext != nil else { return nil } + if shouldDeferToPaneTabBar(at: point) { + return nil + } + + let pasteboardTypes = NSPasteboard(name: .drag).types + let eventType = NSApp.currentEvent?.type + let capture = Self.shouldCaptureHitTesting( + pasteboardTypes: pasteboardTypes, + eventType: eventType + ) +#if DEBUG + logHitTestDecision(capture: capture, pasteboardTypes: pasteboardTypes, eventType: eventType) +#endif + return capture ? self : nil + } + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateDragState(sender, phase: "entered") + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateDragState(sender, phase: "updated") + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + clearDragState(phase: "exited") + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + defer { + clearDragState(phase: "perform.clear") + } + + guard let dropContext, + let transfer = TerminalPaneDragTransfer.decode(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess, + let workspace = AppDelegate.shared?.workspaceFor(tabId: dropContext.workspaceId) else { +#if DEBUG + cmuxDebugLog("terminal.paneDrop.perform allowed=0 reason=missingTransfer") +#endif + return false + } + + let zone = resolvedZone(for: sender, transfer: transfer, context: dropContext, workspace: workspace) + let handled = workspace.performPortalPaneDrop( + tabId: transfer.tabId, + sourcePaneId: transfer.sourcePaneId, + targetPane: dropContext.paneId, + zone: zone + ) +#if DEBUG + cmuxDebugLog( + "terminal.paneDrop.perform panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone) " + + "pane=\(dropContext.paneId.id.uuidString.prefix(5)) handled=\(handled ? 1 : 0)" + ) +#endif + return handled + } + + private func updateDragState(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { + let location = convert(sender.draggingLocation, from: nil) + if shouldDeferToPaneTabBar(at: location) { + clearDragState(phase: "\(phase).tabBar") + return [] + } + + guard let dropContext, + let transfer = TerminalPaneDragTransfer.decode(from: sender.draggingPasteboard), + transfer.isFromCurrentProcess, + let workspace = AppDelegate.shared?.workspaceFor(tabId: dropContext.workspaceId) else { + clearDragState(phase: "\(phase).reject") + return [] + } + + let zone = resolvedZone( + for: sender, + transfer: transfer, + context: dropContext, + workspace: workspace + ) + activeZone = zone + hostedView?.setDropZoneOverlay(zone: zone) +#if DEBUG + cmuxDebugLog( + "terminal.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) " + + "tab=\(transfer.tabId.uuidString.prefix(5)) zone=\(zone)" + ) +#endif + return .move + } + + private func resolvedZone( + for sender: any NSDraggingInfo, + transfer: TerminalPaneDragTransfer, + context: TerminalPaneDropContext, + workspace: Workspace + ) -> DropZone { + let location = convert(sender.draggingLocation, from: nil) + let proposedZone = TerminalPaneDropRouting.zone(for: location, in: bounds.size) + return workspace.portalPaneDropZone( + tabId: transfer.tabId, + sourcePaneId: transfer.sourcePaneId, + targetPane: context.paneId, + proposedZone: proposedZone + ) + } + + func shouldDeferToPaneTabBar(at point: NSPoint) -> Bool { + let windowPoint = convert(point, to: nil) + return BonsplitTabBarPassThrough + .shouldPassThroughToPaneTabBar(windowPoint: windowPoint, below: self) + .result + } + + private func clearDragState(phase: String) { + guard activeZone != nil else { return } + activeZone = nil + hostedView?.setDropZoneOverlay(zone: nil) +#if DEBUG + if let dropContext { + cmuxDebugLog( + "terminal.paneDrop.\(phase) panel=\(dropContext.panelId.uuidString.prefix(5)) zone=none" + ) + } +#endif + } + +#if DEBUG + private func logHitTestDecision( + capture: Bool, + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) { + let hasTransferType = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + guard hasTransferType || capture else { return } + + let signature = [ + capture ? "1" : "0", + hasTransferType ? "1" : "0", + String(describing: dropContext != nil), + eventType.map { String($0.rawValue) } ?? "nil", + ].joined(separator: "|") + guard lastHitTestSignature != signature else { return } + lastHitTestSignature = signature + + let types = pasteboardTypes?.map(\.rawValue).joined(separator: ",") ?? "-" + cmuxDebugLog( + "terminal.paneDrop.hitTest capture=\(capture ? 1 : 0) " + + "hasTransfer=\(hasTransferType ? 1 : 0) context=\(dropContext != nil ? 1 : 0) " + + "event=\(eventType.map { String($0.rawValue) } ?? "nil") types=\(types)" + ) + } +#endif +} diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 93b22bdf1c14..b185339d13df 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -7,24 +7,6 @@ import Bonsplit private var cmuxWindowTerminalPortalKey: UInt8 = 0 private var cmuxWindowTerminalPortalCloseObserverKey: UInt8 = 0 -#if DEBUG -private func portalDebugToken(_ view: NSView?) -> String { - guard let view else { return "nil" } - let ptr = Unmanaged.passUnretained(view).toOpaque() - return String(describing: ptr) -} - -private func portalDebugFrame(_ rect: NSRect) -> String { - String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) -} - -private func portalDebugFrameInWindow(_ view: NSView?) -> String { - guard let view else { return "nil" } - guard view.window != nil else { return "no-window" } - return portalDebugFrame(view.convert(view.bounds, to: nil)) -} -#endif - final class WindowTerminalHostView: NSView { private struct DividerRegion { let rectInWindow: NSRect @@ -140,17 +122,9 @@ final class WindowTerminalHostView: NSView { // `NSApp.currentEvent`; tests can call this directly with a synthetic // pointer event so the typing-latency guard doesn't gate them out. func performHitTest(at point: NSPoint, currentEvent: NSEvent?) -> NSView? { - let isPointerEvent: Bool - switch currentEvent?.type { - case .mouseMoved, .mouseEntered, .mouseExited, - .leftMouseDown, .leftMouseUp, .leftMouseDragged, - .rightMouseDown, .rightMouseUp, .rightMouseDragged, - .otherMouseDown, .otherMouseUp, .otherMouseDragged, - .scrollWheel, .cursorUpdate: - isPointerEvent = true - default: - isPointerEvent = false - } + let eventType = currentEvent?.type + let isPointerEvent = eventType == .scrollWheel + || BonsplitTabBarPassThrough.isPassThroughPointerEvent(eventType) if isPointerEvent { if shouldPassThroughToTitlebar(at: point) { @@ -188,6 +162,18 @@ final class WindowTerminalHostView: NSView { eventType: eventType ) if shouldPassThrough { + let hitView = super.hitTest(point) + if hitView is TerminalPaneDropTargetView { +#if DEBUG + logDragRouteDecision( + passThrough: false, + eventType: eventType, + pasteboardTypes: dragPasteboardTypes, + hitView: hitView + ) +#endif + return hitView + } #if DEBUG logDragRouteDecision( passThrough: true, diff --git a/Sources/TerminalWindowPortalDebug.swift b/Sources/TerminalWindowPortalDebug.swift new file mode 100644 index 000000000000..927fa6a6834b --- /dev/null +++ b/Sources/TerminalWindowPortalDebug.swift @@ -0,0 +1,19 @@ +import AppKit + +#if DEBUG +func portalDebugToken(_ view: NSView?) -> String { + guard let view else { return "nil" } + let ptr = Unmanaged.passUnretained(view).toOpaque() + return String(describing: ptr) +} + +func portalDebugFrame(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) +} + +func portalDebugFrameInWindow(_ view: NSView?) -> String { + guard let view else { return "nil" } + guard view.window != nil else { return "no-window" } + return portalDebugFrame(view.convert(view.bounds, to: nil)) +} +#endif diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5e3df718044a..78883effb0f6 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7442,7 +7442,7 @@ final class Workspace: Identifiable, ObservableObject { private var processTitle: String - private enum SurfaceKind { + enum SurfaceKind { static let terminal = "terminal" static let browser = "browser" static let markdown = "markdown" @@ -12426,7 +12426,7 @@ final class Workspace: Identifiable, ObservableObject { return newPanel } - private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { + func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool { // Session-index drag → spawn a brand new terminal at the destination instead // of moving an existing tab. if let entry = SessionDragRegistry.shared.consume(id: request.tabId.uuid) { diff --git a/Sources/WorkspacePortalPaneDrop.swift b/Sources/WorkspacePortalPaneDrop.swift new file mode 100644 index 000000000000..820c5b965cab --- /dev/null +++ b/Sources/WorkspacePortalPaneDrop.swift @@ -0,0 +1,60 @@ +import Bonsplit +import Foundation + +extension Workspace { + func portalPaneDropZone( + tabId: UUID, + sourcePaneId: UUID, + targetPane paneId: PaneID, + proposedZone: DropZone + ) -> DropZone { + let sourcePane = PaneID(id: sourcePaneId) + guard sourcePane != paneId, + bonsplitController.tab(TabID(uuid: tabId))?.kind == SurfaceKind.terminal else { + return proposedZone + } + + if proposedZone == .left, + bonsplitController.adjacentPane(to: sourcePane, direction: .right) == paneId { + return .center + } + if proposedZone == .right, + bonsplitController.adjacentPane(to: sourcePane, direction: .left) == paneId { + return .center + } + return proposedZone + } + + @discardableResult + func performPortalPaneDrop( + tabId: UUID, + sourcePaneId: UUID, + targetPane paneId: PaneID, + zone: DropZone + ) -> Bool { + let sourcePane = PaneID(id: sourcePaneId) + if zone == .center, sourcePane == paneId { + return true + } + + let destination: BonsplitController.ExternalTabDropRequest.Destination + switch zone { + case .center: + destination = .insert(targetPane: paneId, targetIndex: nil) + case .left: + destination = .split(targetPane: paneId, orientation: .horizontal, insertFirst: true) + case .right: + destination = .split(targetPane: paneId, orientation: .horizontal, insertFirst: false) + case .top: + destination = .split(targetPane: paneId, orientation: .vertical, insertFirst: true) + case .bottom: + destination = .split(targetPane: paneId, orientation: .vertical, insertFirst: false) + } + + return handleExternalTabDrop(BonsplitController.ExternalTabDropRequest( + tabId: TabID(uuid: tabId), + sourcePaneId: sourcePane, + destination: destination + )) + } +} diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 914f79c7905b..70389c670417 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -3254,23 +3254,6 @@ final class BrowserHistoryStoreTests: XCTestCase { } } - -@MainActor -final class CmuxWebViewDragRoutingTests: XCTestCase { - func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() { - XCTAssertTrue( - CmuxWebView.shouldRejectInternalPaneDrag([ - DragOverlayRoutingPolicy.bonsplitTabTransferType, - NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"), - ]) - ) - } - - func testAllowsRegularExternalFileDrops() { - XCTAssertFalse(CmuxWebView.shouldRejectInternalPaneDrag([.fileURL])) - } -} - final class BrowserLinkOpenSettingsTests: XCTestCase { private var suiteName: String! private var defaults: UserDefaults! diff --git a/cmuxTests/BrowserPaneDropRoutingTests.swift b/cmuxTests/BrowserPaneDropRoutingTests.swift new file mode 100644 index 000000000000..292dcb71afb2 --- /dev/null +++ b/cmuxTests/BrowserPaneDropRoutingTests.swift @@ -0,0 +1,148 @@ +import XCTest +import AppKit +import Bonsplit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class BrowserPaneDropRoutingTests: XCTestCase { + func testVerticalZonesFollowAppKitCoordinates() { + let size = CGSize(width: 240, height: 180) + + XCTAssertEqual( + BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size), + .top + ) + XCTAssertEqual( + BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size), + .bottom + ) + } + + func testTopChromeHeightPushesTopSplitThresholdIntoWebView() { + let size = CGSize(width: 240, height: 180) + + XCTAssertEqual( + BrowserPaneDropRouting.zone( + for: CGPoint(x: size.width * 0.5, y: 110), + in: size, + topChromeHeight: 36 + ), + .center + ) + XCTAssertEqual( + BrowserPaneDropRouting.zone( + for: CGPoint(x: size.width * 0.5, y: 150), + in: size, + topChromeHeight: 36 + ), + .top + ) + } + + func testHitTestingCapturesOnlyForRelevantDragEvents() { + XCTAssertTrue( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .cursorUpdate + ) + ) + XCTAssertFalse( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], + eventType: .leftMouseDown + ) + ) + XCTAssertFalse( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: [.fileURL], + eventType: .cursorUpdate + ) + ) + + let externalPayloads: [[NSPasteboard.PasteboardType]] = [ + [.fileURL], + [.URL], + [.png], + [.tiff], + [.html], + [.string], + [.fileURL, .png], + ] + + for pasteboardTypes in externalPayloads { + XCTAssertFalse( + BrowserPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: pasteboardTypes, + eventType: .cursorUpdate + ), + "Browser pane drop target should not capture external drag payload: \(pasteboardTypes)" + ) + } + } + + func testCenterDropOnSamePaneIsNoOp() { + let paneId = PaneID(id: UUID()) + let target = BrowserPaneDropContext( + workspaceId: UUID(), + panelId: UUID(), + paneId: paneId + ) + let transfer = BrowserPaneDragTransfer( + tabId: UUID(), + sourcePaneId: paneId.id, + sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) + ) + + XCTAssertEqual( + BrowserPaneDropRouting.action(for: transfer, target: target, zone: .center), + .noOp + ) + } + + func testRightEdgeDropBuildsSplitMoveAction() { + let paneId = PaneID(id: UUID()) + let target = BrowserPaneDropContext( + workspaceId: UUID(), + panelId: UUID(), + paneId: paneId + ) + let tabId = UUID() + let transfer = BrowserPaneDragTransfer( + tabId: tabId, + sourcePaneId: UUID(), + sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) + ) + + XCTAssertEqual( + BrowserPaneDropRouting.action(for: transfer, target: target, zone: .right), + .move( + tabId: tabId, + targetWorkspaceId: target.workspaceId, + targetPane: paneId, + splitTarget: BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false) + ) + ) + } + + func testDecodeTransferPayloadReadsTabAndSourcePane() { + let tabId = UUID() + let sourcePaneId = UUID() + let payload = try! JSONSerialization.data( + withJSONObject: [ + "tab": ["id": tabId.uuidString], + "sourcePaneId": sourcePaneId.uuidString, + "sourceProcessId": ProcessInfo.processInfo.processIdentifier, + ] + ) + + let transfer = BrowserPaneDragTransfer.decode(from: payload) + + XCTAssertEqual(transfer?.tabId, tabId) + XCTAssertEqual(transfer?.sourcePaneId, sourcePaneId) + XCTAssertTrue(transfer?.isFromCurrentProcess == true) + } +} diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 2b3e35f081cf..51c99d159a4b 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -696,12 +696,25 @@ final class WindowBrowserHostViewTests: XCTestCase { } func testDragHoverEventsDoNotPassThroughForUnrelatedPasteboardTypes() { - XCTAssertFalse( - WindowBrowserHostView.shouldPassThroughToDragTargets( - pasteboardTypes: [.fileURL], - eventType: .cursorUpdate + let externalPayloads: [[NSPasteboard.PasteboardType]] = [ + [.fileURL], + [.URL], + [.png], + [.tiff], + [.html], + [.string], + [.fileURL, .png], + ] + + for pasteboardTypes in externalPayloads { + XCTAssertFalse( + WindowBrowserHostView.shouldPassThroughToDragTargets( + pasteboardTypes: pasteboardTypes, + eventType: .cursorUpdate + ), + "Browser host should keep external drag payload in WebKit: \(pasteboardTypes)" ) - ) + } } func testHostViewKeepsHostedInspectorDividerInteractive() { @@ -1958,127 +1971,6 @@ final class BrowserPanelHostContainerViewTests: XCTestCase { } -@MainActor -final class BrowserPaneDropRoutingTests: XCTestCase { - func testVerticalZonesFollowAppKitCoordinates() { - let size = CGSize(width: 240, height: 180) - - XCTAssertEqual( - BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: size.height - 8), in: size), - .top - ) - XCTAssertEqual( - BrowserPaneDropRouting.zone(for: CGPoint(x: size.width * 0.5, y: 8), in: size), - .bottom - ) - } - - func testTopChromeHeightPushesTopSplitThresholdIntoWebView() { - let size = CGSize(width: 240, height: 180) - - XCTAssertEqual( - BrowserPaneDropRouting.zone( - for: CGPoint(x: size.width * 0.5, y: 110), - in: size, - topChromeHeight: 36 - ), - .center - ) - XCTAssertEqual( - BrowserPaneDropRouting.zone( - for: CGPoint(x: size.width * 0.5, y: 150), - in: size, - topChromeHeight: 36 - ), - .top - ) - } - - func testHitTestingCapturesOnlyForRelevantDragEvents() { - XCTAssertTrue( - BrowserPaneDropTargetView.shouldCaptureHitTesting( - pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], - eventType: .cursorUpdate - ) - ) - XCTAssertFalse( - BrowserPaneDropTargetView.shouldCaptureHitTesting( - pasteboardTypes: [DragOverlayRoutingPolicy.bonsplitTabTransferType], - eventType: .leftMouseDown - ) - ) - XCTAssertFalse( - BrowserPaneDropTargetView.shouldCaptureHitTesting( - pasteboardTypes: [.fileURL], - eventType: .cursorUpdate - ) - ) - } - - func testCenterDropOnSamePaneIsNoOp() { - let paneId = PaneID(id: UUID()) - let target = BrowserPaneDropContext( - workspaceId: UUID(), - panelId: UUID(), - paneId: paneId - ) - let transfer = BrowserPaneDragTransfer( - tabId: UUID(), - sourcePaneId: paneId.id, - sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) - ) - - XCTAssertEqual( - BrowserPaneDropRouting.action(for: transfer, target: target, zone: .center), - .noOp - ) - } - - func testRightEdgeDropBuildsSplitMoveAction() { - let paneId = PaneID(id: UUID()) - let target = BrowserPaneDropContext( - workspaceId: UUID(), - panelId: UUID(), - paneId: paneId - ) - let tabId = UUID() - let transfer = BrowserPaneDragTransfer( - tabId: tabId, - sourcePaneId: UUID(), - sourceProcessId: Int32(ProcessInfo.processInfo.processIdentifier) - ) - - XCTAssertEqual( - BrowserPaneDropRouting.action(for: transfer, target: target, zone: .right), - .move( - tabId: tabId, - targetWorkspaceId: target.workspaceId, - targetPane: paneId, - splitTarget: BrowserPaneSplitTarget(orientation: .horizontal, insertFirst: false) - ) - ) - } - - func testDecodeTransferPayloadReadsTabAndSourcePane() { - let tabId = UUID() - let sourcePaneId = UUID() - let payload = try! JSONSerialization.data( - withJSONObject: [ - "tab": ["id": tabId.uuidString], - "sourcePaneId": sourcePaneId.uuidString, - "sourceProcessId": ProcessInfo.processInfo.processIdentifier, - ] - ) - - let transfer = BrowserPaneDragTransfer.decode(from: payload) - - XCTAssertEqual(transfer?.tabId, tabId) - XCTAssertEqual(transfer?.sourcePaneId, sourcePaneId) - XCTAssertTrue(transfer?.isFromCurrentProcess == true) - } -} - - @MainActor final class WindowBrowserSlotViewTests: XCTestCase { private final class CapturingView: NSView { diff --git a/cmuxTests/CmuxWebViewDragRoutingTests.swift b/cmuxTests/CmuxWebViewDragRoutingTests.swift new file mode 100644 index 000000000000..5b1c17bfb7a9 --- /dev/null +++ b/cmuxTests/CmuxWebViewDragRoutingTests.swift @@ -0,0 +1,256 @@ +import XCTest +import AppKit +import WebKit +import ObjectiveC.runtime + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +private var cmuxUnitTestWKWebViewDragLifecycleOverrideInstalled = false +private var cmuxUnitTestWKWebViewDragLifecycleEvents: [String]? + +extension WKWebView { + @objc func cmuxUnitTest_draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + if cmuxUnitTestWKWebViewDragLifecycleEvents != nil { + cmuxUnitTestWKWebViewDragLifecycleEvents?.append("entered") + return .copy + } + return cmuxUnitTest_draggingEntered(sender) + } + + @objc func cmuxUnitTest_draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + if cmuxUnitTestWKWebViewDragLifecycleEvents != nil { + cmuxUnitTestWKWebViewDragLifecycleEvents?.append("updated") + return .copy + } + return cmuxUnitTest_draggingUpdated(sender) + } + + @objc func cmuxUnitTest_prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { + if cmuxUnitTestWKWebViewDragLifecycleEvents != nil { + cmuxUnitTestWKWebViewDragLifecycleEvents?.append("prepare") + return true + } + return cmuxUnitTest_prepareForDragOperation(sender) + } + + @objc func cmuxUnitTest_performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + if cmuxUnitTestWKWebViewDragLifecycleEvents != nil { + cmuxUnitTestWKWebViewDragLifecycleEvents?.append("perform") + return true + } + return cmuxUnitTest_performDragOperation(sender) + } + + @objc func cmuxUnitTest_concludeDragOperation(_ sender: (any NSDraggingInfo)?) { + if cmuxUnitTestWKWebViewDragLifecycleEvents != nil { + cmuxUnitTestWKWebViewDragLifecycleEvents?.append("conclude") + return + } + cmuxUnitTest_concludeDragOperation(sender) + } +} + +private func installCmuxUnitTestWKWebViewDragLifecycleOverride() { + guard !cmuxUnitTestWKWebViewDragLifecycleOverrideInstalled else { return } + + func swizzle(_ originalSelector: Selector, _ swizzledSelector: Selector) { + guard let originalMethod = class_getInstanceMethod(WKWebView.self, originalSelector), + let swizzledMethod = class_getInstanceMethod(WKWebView.self, swizzledSelector) else { + fatalError("Unable to locate WKWebView drag lifecycle methods for swizzling") + } + + let didAddMethod = class_addMethod( + WKWebView.self, + originalSelector, + method_getImplementation(swizzledMethod), + method_getTypeEncoding(swizzledMethod) + ) + + if didAddMethod { + class_replaceMethod( + WKWebView.self, + swizzledSelector, + method_getImplementation(originalMethod), + method_getTypeEncoding(originalMethod) + ) + } else { + method_exchangeImplementations(originalMethod, swizzledMethod) + } + } + + swizzle( + #selector(NSView.draggingEntered(_:)), + #selector(WKWebView.cmuxUnitTest_draggingEntered(_:)) + ) + swizzle( + #selector(NSView.draggingUpdated(_:)), + #selector(WKWebView.cmuxUnitTest_draggingUpdated(_:)) + ) + swizzle( + #selector(NSView.prepareForDragOperation(_:)), + #selector(WKWebView.cmuxUnitTest_prepareForDragOperation(_:)) + ) + swizzle( + #selector(NSView.performDragOperation(_:)), + #selector(WKWebView.cmuxUnitTest_performDragOperation(_:)) + ) + swizzle( + #selector(NSView.concludeDragOperation(_:)), + #selector(WKWebView.cmuxUnitTest_concludeDragOperation(_:)) + ) + + cmuxUnitTestWKWebViewDragLifecycleOverrideInstalled = true +} + +private final class MockDraggingInfo: NSObject, NSDraggingInfo { + let draggingDestinationWindow: NSWindow? + let draggingSourceOperationMask: NSDragOperation = .copy + let draggingLocation = NSPoint(x: 10, y: 10) + let draggedImageLocation = NSPoint(x: 10, y: 10) + let draggedImage: NSImage? = nil + let draggingPasteboard: NSPasteboard + let draggingSource: Any? = nil + let draggingSequenceNumber = 1 + var draggingFormation: NSDraggingFormation = .default + var animatesToDestination = false + var numberOfValidItemsForDrop = 1 + let springLoadingHighlight: NSSpringLoadingHighlight = .none + + init(pasteboard: NSPasteboard, window: NSWindow? = nil) { + self.draggingPasteboard = pasteboard + self.draggingDestinationWindow = window + } + + func slideDraggedImage(to screenPoint: NSPoint) {} + + override func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? { + nil + } + + func enumerateDraggingItems( + options enumOpts: NSDraggingItemEnumerationOptions = [], + for view: NSView?, + classes classArray: [AnyClass], + searchOptions: [NSPasteboard.ReadingOptionKey: Any] = [:], + using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void + ) {} + + func resetSpringLoading() {} +} + +@MainActor +final class CmuxWebViewDragRoutingTests: XCTestCase { + override func setUp() { + super.setUp() + installCmuxUnitTestWKWebViewDragLifecycleOverride() + cmuxUnitTestWKWebViewDragLifecycleEvents = nil + } + + override func tearDown() { + cmuxUnitTestWKWebViewDragLifecycleEvents = nil + super.tearDown() + } + + func testRejectsInternalPaneDragEvenWhenFilePromiseTypesArePresent() { + XCTAssertTrue( + CmuxWebView.shouldRejectInternalPaneDrag([ + DragOverlayRoutingPolicy.bonsplitTabTransferType, + NSPasteboard.PasteboardType("com.apple.pasteboard.promised-file-url"), + ]) + ) + } + + func testAllowsRegularExternalFileImageAndURLDrops() { + let externalPayloads: [[NSPasteboard.PasteboardType]] = [ + [.fileURL], + [.URL], + [.png], + [.tiff], + [.html], + [.string], + [.fileURL, .png], + ] + + for pasteboardTypes in externalPayloads { + XCTAssertFalse( + CmuxWebView.shouldRejectInternalPaneDrag(pasteboardTypes), + "Browser web view should not reject external drag payload: \(pasteboardTypes)" + ) + } + } + + func testRegisterForDraggedTypesKeepsExternalFileImageAndURLTypes() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let externalTypes: [NSPasteboard.PasteboardType] = [ + .fileURL, + .URL, + .png, + .tiff, + .html, + ] + + webView.registerForDraggedTypes([ + .string, + DragOverlayRoutingPolicy.bonsplitTabTransferType, + DragOverlayRoutingPolicy.sidebarTabReorderType, + ] + externalTypes) + + let registeredTypes = Set(webView.registeredDraggedTypes) + for pasteboardType in externalTypes { + XCTAssertTrue( + registeredTypes.contains(pasteboardType), + "Browser web view should keep external drag type registered: \(pasteboardType)" + ) + } + XCTAssertFalse(registeredTypes.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType)) + XCTAssertFalse(registeredTypes.contains(DragOverlayRoutingPolicy.sidebarTabReorderType)) + XCTAssertFalse(registeredTypes.contains(.string)) + } + + func testWebsiteDragPayloadReachesWebKitDragLifecycle() { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("cmux.web-drag.\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("file:///tmp/site-drop.png", forType: .fileURL) + pasteboard.setString("https://example.com/site-drop.png", forType: .URL) + pasteboard.setString("", forType: .html) + pasteboard.setData(Data("png".utf8), forType: .png) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let dragInfo = MockDraggingInfo(pasteboard: pasteboard) + + cmuxUnitTestWKWebViewDragLifecycleEvents = [] + XCTAssertEqual(webView.draggingEntered(dragInfo), .copy) + XCTAssertEqual(webView.draggingUpdated(dragInfo), .copy) + XCTAssertTrue(webView.prepareForDragOperation(dragInfo)) + XCTAssertTrue(webView.performDragOperation(dragInfo)) + webView.concludeDragOperation(dragInfo) + + XCTAssertEqual( + cmuxUnitTestWKWebViewDragLifecycleEvents, + ["entered", "updated", "prepare", "perform", "conclude"] + ) + } + + func testInternalPaneDragDoesNotReachWebKitDragLifecycle() { + let pasteboard = NSPasteboard(name: NSPasteboard.Name("cmux.internal-drag.\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("tab-transfer", forType: DragOverlayRoutingPolicy.bonsplitTabTransferType) + pasteboard.setString("tab-title", forType: .string) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let dragInfo = MockDraggingInfo(pasteboard: pasteboard) + + cmuxUnitTestWKWebViewDragLifecycleEvents = [] + XCTAssertEqual(webView.draggingEntered(dragInfo), []) + XCTAssertEqual(webView.draggingUpdated(dragInfo), []) + XCTAssertFalse(webView.prepareForDragOperation(dragInfo)) + XCTAssertFalse(webView.performDragOperation(dragInfo)) + webView.concludeDragOperation(dragInfo) + + XCTAssertEqual(cmuxUnitTestWKWebViewDragLifecycleEvents, []) + } +} diff --git a/cmuxTests/PortalTabDragRoutingTests.swift b/cmuxTests/PortalTabDragRoutingTests.swift new file mode 100644 index 000000000000..909fc7a34074 --- /dev/null +++ b/cmuxTests/PortalTabDragRoutingTests.swift @@ -0,0 +1,192 @@ +import XCTest +import AppKit +import Bonsplit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class PortalTabDragRoutingTests: XCTestCase { + private final class CapturingView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private final class FakeTabBarBackgroundNSView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(point) ? self : nil + } + } + + private struct TabStripPassThroughFixture { + let host: WindowTerminalHostView + let pointInHost: NSPoint + let pointInWindow: NSPoint + } + + private func installTabStripPassThroughFixture(in window: NSWindow) -> TabStripPassThroughFixture? { + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected window content container") + return nil + } + + let tabStripHeight: CGFloat = 44 + let tabStrip = FakeTabBarBackgroundNSView( + frame: NSRect( + x: 0, + y: contentView.bounds.maxY - tabStripHeight, + width: contentView.bounds.width, + height: tabStripHeight + ) + ) + tabStrip.autoresizingMask = [.width, .minYMargin] + contentView.addSubview(tabStrip) + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowTerminalHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + let child = CapturingView(frame: host.bounds) + child.autoresizingMask = [.width, .height] + host.addSubview(child) + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let titlebarBandHeight = max(28, min(72, window.frame.height - window.contentLayoutRect.height)) + let pointInContent = NSPoint( + x: contentView.bounds.midX, + y: contentView.bounds.maxY - titlebarBandHeight - 8 + ) + let pointInWindow = contentView.convert(pointInContent, to: nil) + let pointInHost = host.convert(pointInWindow, from: nil) + return TabStripPassThroughFixture(host: host, pointInHost: pointInHost, pointInWindow: pointInWindow) + } + + private func makeMouseEvent( + type: NSEvent.EventType, + at locationInWindow: NSPoint, + window: NSWindow + ) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: locationInWindow, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) event") + } + return event + } + + func testHostViewPassesThroughUnderlyingTabStripDuringMouseDrag() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let fixture = installTabStripPassThroughFixture(in: window) else { + return + } + + let event = makeMouseEvent( + type: .leftMouseDragged, + at: fixture.pointInWindow, + window: window + ) + + XCTAssertNil( + fixture.host.performHitTest(at: fixture.pointInHost, currentEvent: event), + "Terminal portal should defer to the minimal tab strip while a Bonsplit tab is being dragged" + ) + } + + func testHostViewPassesThroughUnderlyingTabStripWithoutCurrentEvent() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let fixture = installTabStripPassThroughFixture(in: window) else { + return + } + + XCTAssertNil( + fixture.host.performHitTest(at: fixture.pointInHost, currentEvent: nil), + "Terminal portal should keep the shared no-event tab-strip pass-through path" + ) + } + + func testTabStripPassThroughTreatsAppKitDragRoutingAsPointerEvents() { + XCTAssertTrue(BonsplitTabBarPassThrough.isPassThroughPointerEvent(.appKitDefined)) + XCTAssertTrue(BonsplitTabBarPassThrough.isPassThroughPointerEvent(.applicationDefined)) + XCTAssertTrue(BonsplitTabBarPassThrough.isPassThroughPointerEvent(.systemDefined)) + XCTAssertTrue(BonsplitTabBarPassThrough.isPassThroughPointerEvent(.periodic)) + } + + func testTerminalPaneDropTargetDefersToUnderlyingTabStrip() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected window content view") + return + } + + let tabStrip = FakeTabBarBackgroundNSView( + frame: NSRect(x: 0, y: contentView.bounds.maxY - 44, width: contentView.bounds.width, height: 44) + ) + tabStrip.autoresizingMask = [.width, .minYMargin] + contentView.addSubview(tabStrip) + + let dropTarget = TerminalPaneDropTargetView(frame: contentView.bounds) + dropTarget.autoresizingMask = [.width, .height] + contentView.addSubview(dropTarget, positioned: .above, relativeTo: tabStrip) + + let point = NSPoint(x: contentView.bounds.midX, y: tabStrip.frame.midY) + XCTAssertTrue( + dropTarget.shouldDeferToPaneTabBar(at: point), + "Terminal pane drop target should not steal Bonsplit tab-strip drags" + ) + } + + func testTerminalPaneDropTargetIgnoresExternalFileImageAndBrowserDrags() { + let externalPayloads: [[NSPasteboard.PasteboardType]] = [ + [.fileURL], + [.URL], + [.png], + [.tiff], + [.html], + [.string], + [.fileURL, .png], + ] + + for pasteboardTypes in externalPayloads { + XCTAssertFalse( + TerminalPaneDropTargetView.shouldCaptureHitTesting( + pasteboardTypes: pasteboardTypes, + eventType: .leftMouseDragged + ), + "Terminal pane drop target should not capture external drag payload: \(pasteboardTypes)" + ) + } + } +} diff --git a/cmuxUITests/BonsplitTabDragUITests.swift b/cmuxUITests/BonsplitTabDragUITests.swift index 6226696c1bb8..3134bb5bbc70 100644 --- a/cmuxUITests/BonsplitTabDragUITests.swift +++ b/cmuxUITests/BonsplitTabDragUITests.swift @@ -1,6 +1,5 @@ import XCTest import Foundation -import AppKit import CoreGraphics final class BonsplitTabDragUITests: XCTestCase { @@ -39,7 +38,6 @@ final class BonsplitTabDragUITests: XCTestCase { let window = app.windows.element(boundBy: 0) let alphaTab = app.buttons[alphaTitle] let betaTab = app.buttons[betaTitle] - let dropIndicator = app.descendants(matching: .any).matching(identifier: "paneTabBar.dropIndicator").firstMatch let initialOrder = "\(alphaTitle)|\(betaTitle)" let reorderedOrder = "\(betaTitle)|\(alphaTitle)" @@ -53,26 +51,7 @@ final class BonsplitTabDragUITests: XCTestCase { XCTAssertLessThan(alphaTab.frame.minX, betaTab.frame.minX, "Expected beta tab to start to the right of alpha") let windowFrameBeforeDrag = window.frame - let start = CGPoint(x: betaTab.frame.midX, y: betaTab.frame.midY) - let destination = CGPoint(x: alphaTab.frame.midX - 14, y: alphaTab.frame.midY) - guard let dragSession = beginMouseDrag( - fromAccessibilityPoint: start, - holdDuration: 0.20 - ) else { - XCTFail("Expected raw mouse drag session to start") - return - } - continueMouseDrag( - dragSession, - toAccessibilityPoint: destination, - steps: 28, - dragDuration: 0.45 - ) - XCTAssertTrue( - waitForCondition(timeout: 2.0) { dropIndicator.exists }, - "Expected dragging beta onto alpha to reveal the Bonsplit drop indicator." - ) - endMouseDrag(dragSession, atAccessibilityPoint: destination) + dragTab(betaTab, before: alphaTab) XCTAssertTrue( waitForJSONKey("trackedPaneTabTitles", equals: reorderedOrder, atPath: dataPath, timeout: 5.0) != nil, @@ -701,57 +680,6 @@ final class BonsplitTabDragUITests: XCTestCase { return min(gapIfOriginIsBottomLeft, gapIfOriginIsTopLeft) } - private struct RawMouseDragSession { - let source: CGEventSource - } - - private func beginMouseDrag( - fromAccessibilityPoint start: CGPoint, - holdDuration: TimeInterval = 0.15 - ) -> RawMouseDragSession? { - let source = CGEventSource(stateID: .hidSystemState) - XCTAssertNotNil(source, "Expected CGEventSource for raw mouse drag") - guard let source else { return nil } - - let quartzStart = quartzPoint(fromAccessibilityPoint: start) - - postMouseEvent(type: .mouseMoved, at: quartzStart, source: source) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - - postMouseEvent(type: .leftMouseDown, at: quartzStart, source: source) - RunLoop.current.run(until: Date().addingTimeInterval(holdDuration)) - return RawMouseDragSession(source: source) - } - - private func continueMouseDrag( - _ session: RawMouseDragSession, - toAccessibilityPoint end: CGPoint, - steps: Int = 20, - dragDuration: TimeInterval = 0.30 - ) { - let currentLocation = NSEvent.mouseLocation - let quartzEnd = quartzPoint(fromAccessibilityPoint: end) - let clampedSteps = max(2, steps) - for step in 1...clampedSteps { - let progress = CGFloat(step) / CGFloat(clampedSteps) - let point = CGPoint( - x: currentLocation.x + ((quartzEnd.x - currentLocation.x) * progress), - y: currentLocation.y + ((quartzEnd.y - currentLocation.y) * progress) - ) - postMouseEvent(type: .leftMouseDragged, at: point, source: session.source) - RunLoop.current.run(until: Date().addingTimeInterval(dragDuration / Double(clampedSteps))) - } - } - - private func endMouseDrag( - _ session: RawMouseDragSession, - atAccessibilityPoint end: CGPoint - ) { - let quartzEnd = quartzPoint(fromAccessibilityPoint: end) - postMouseEvent(type: .leftMouseUp, at: quartzEnd, source: session.source) - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - } - private func doubleClick(in window: XCUIElement, atAccessibilityPoint point: CGPoint) { let target = window.coordinate(withNormalizedOffset: .zero).withOffset( CGVector( @@ -765,45 +693,9 @@ final class BonsplitTabDragUITests: XCTestCase { RunLoop.current.run(until: Date().addingTimeInterval(0.2)) } - private func click(atAccessibilityPoint point: CGPoint) { - let source = CGEventSource(stateID: .hidSystemState) - XCTAssertNotNil(source, "Expected CGEventSource for raw mouse click") - guard let source else { return } - let quartzPoint = quartzPoint(fromAccessibilityPoint: point) - postMouseEvent(type: .mouseMoved, at: quartzPoint, source: source) - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - postMouseEvent(type: .leftMouseDown, at: quartzPoint, source: source) - RunLoop.current.run(until: Date().addingTimeInterval(0.04)) - postMouseEvent(type: .leftMouseUp, at: quartzPoint, source: source) - RunLoop.current.run(until: Date().addingTimeInterval(0.2)) - } - - private func postMouseEvent( - type: CGEventType, - at point: CGPoint, - source: CGEventSource, - clickState: Int = 1 - ) { - guard let event = CGEvent( - mouseEventSource: source, - mouseType: type, - mouseCursorPosition: point, - mouseButton: .left - ) else { - XCTFail("Expected CGEvent for mouse type \(type.rawValue) at \(point)") - return - } - - event.setIntegerValueField(.mouseEventClickState, value: Int64(clickState)) - event.post(tap: .cghidEventTap) - } - - private func quartzPoint(fromAccessibilityPoint point: CGPoint) -> CGPoint { - let desktopBounds = NSScreen.screens.reduce(CGRect.null) { partialResult, screen in - partialResult.union(screen.frame) - } - XCTAssertFalse(desktopBounds.isNull, "Expected at least one screen when converting raw mouse coordinates") - guard !desktopBounds.isNull else { return point } - return CGPoint(x: point.x, y: desktopBounds.maxY - point.y) + private func dragTab(_ sourceTab: XCUIElement, before targetTab: XCUIElement) { + let source = sourceTab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + let target = targetTab.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.5)) + source.press(forDuration: 0.25, thenDragTo: target) } }