From cf767cf9af439e99d56ceab3c6d84ee0568343ce Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:47:34 -0800 Subject: [PATCH] Fix bonsplit drag routing and pin submodule commit --- Sources/ContentView.swift | 260 +++++++++++++++++-- Sources/GhosttyTerminalView.swift | 4 + Sources/TerminalController.swift | 199 ++++++++++++-- Sources/TerminalWindowPortal.swift | 89 +++++++ tests/cmux.py | 21 ++ tests/test_bonsplit_tab_drag_overlay_gate.py | 110 +++++++- vendor/bonsplit | 2 +- 7 files changed, 623 insertions(+), 62 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index dabfbe24..015ba8e6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -165,17 +165,32 @@ enum DragOverlayRoutingPolicy { static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) + static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(bonsplitTabTransferType) + } + + static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(sidebarTabReorderType) + } + + static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(.fileURL) + } + static func shouldCaptureFileDropDestination( pasteboardTypes: [NSPasteboard.PasteboardType]?, hasLocalDraggingSource: Bool ) -> Bool { guard !hasLocalDraggingSource else { return false } - guard let pasteboardTypes, pasteboardTypes.contains(.fileURL) else { return false } + guard hasFileURL(pasteboardTypes) else { return false } // Prefer explicit non-file drag types so stale fileURL entries cannot hijack // Bonsplit tab drags or sidebar tab reorder drags. - if pasteboardTypes.contains(bonsplitTabTransferType) { return false } - if pasteboardTypes.contains(sidebarTabReorderType) { return false } + if hasBonsplitTabTransfer(pasteboardTypes) { return false } + if hasSidebarTabReorder(pasteboardTypes) { return false } return true } @@ -197,13 +212,30 @@ enum DragOverlayRoutingPolicy { return true } + static func shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: Bool, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + guard hasSidebarDragState else { return false } + return hasSidebarTabReorder(pasteboardTypes) + } + static func shouldCaptureSidebarExternalOverlay( draggedTabId: UUID?, pasteboardTypes: [NSPasteboard.PasteboardType]? ) -> Bool { - guard draggedTabId != nil else { return false } - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(sidebarTabReorderType) + shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: draggedTabId != nil, + pasteboardTypes: pasteboardTypes + ) + } + + static func shouldPassThroughPortalHitTesting( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard isPortalDragEvent(eventType) else { return false } + return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) } private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { @@ -211,6 +243,27 @@ enum DragOverlayRoutingPolicy { || eventType == .rightMouseDragged || eventType == .otherMouseDragged } + + private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { + // NSDraggingDestination hit-testing can occur with no current NSEvent. + // Treat nil as drag-routing context so portal-hosted terminals do not + // swallow Bonsplit/sidebar drag payloads. + guard let eventType else { return true } + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + case .flagsChanged: + // Real tab drags can briefly report flagsChanged while modifiers + // are sampled; still treat as drag-routing context. + return true + case .mouseMoved, .mouseEntered, .mouseExited, .cursorUpdate: + return true + case .appKitDefined, .systemDefined, .applicationDefined, .periodic: + return true + default: + return false + } + } } /// Transparent NSView installed on the window's theme frame (above the NSHostingView) to @@ -228,6 +281,8 @@ final class FileDropOverlayView: NSView { /// The WKWebView currently receiving forwarded drag events, so we can /// synthesize draggingExited/draggingEntered as the cursor moves. private weak var activeDragWebView: WKWebView? + private var lastHitTestLogSignature: String? + private var lastDragRouteLogSignatureByPhase: [String: String] = [:] override var acceptsFirstResponder: Bool { false } @@ -243,10 +298,19 @@ final class FileDropOverlayView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { let pb = NSPasteboard(name: .drag) - guard DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( + let eventType = NSApp.currentEvent?.type + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( pasteboardTypes: pb.types, - eventType: NSApp.currentEvent?.type - ) else { return nil } + eventType: eventType + ) +#if DEBUG + logHitTestDecision( + pasteboardTypes: pb.types, + eventType: eventType, + shouldCapture: shouldCapture + ) +#endif + guard shouldCapture else { return nil } return super.hitTest(point) } @@ -328,19 +392,22 @@ final class FileDropOverlayView: NSView { ) let webView = activeDragWebView activeDragWebView = nil + let terminal = terminalUnderPoint(sender.draggingLocation) + let hasTerminalTarget = terminal != nil #if DEBUG - dlog( - "overlay.fileDrop.perform capture=\(shouldCapture ? 1 : 0) " + - "localSource=\(hasLocalDraggingSource ? 1 : 0) " + - "hasWebView=\(webView != nil ? 1 : 0) " + - "types=\(debugPasteboardTypes(types))" + logDragRouteDecision( + phase: "perform", + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget ) #endif guard shouldCapture else { return false } if let webView { return webView.performDragOperation(sender) } - guard let terminal = terminalUnderPoint(sender.draggingLocation) else { return false } + guard let terminal else { return false } return terminal.performDragOperation(sender) } @@ -369,12 +436,12 @@ final class FileDropOverlayView: NSView { let hasTerminalTarget = terminalUnderPoint(loc) != nil #if DEBUG - dlog( - "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + - "localSource=\(hasLocalDraggingSource ? 1 : 0) " + - "hasWebView=\(webView != nil ? 1 : 0) " + - "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + - "types=\(debugPasteboardTypes(types))" + logDragRouteDecision( + phase: phase, + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget ) #endif guard shouldCapture, hasTerminalTarget else { return [] } @@ -402,6 +469,135 @@ final class FileDropOverlayView: NSView { return nil } + private func debugTopHitViewForCurrentEvent() -> String { + guard let window, + let currentEvent = NSApp.currentEvent, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return "-" } + + let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) + isHidden = true + defer { isHidden = false } + + guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } + var chain: [String] = [] + var current: NSView? = hit + var depth = 0 + while let view = current, depth < 6 { + chain.append(debugHitViewDescriptor(view)) + current = view.superview + depth += 1 + } + return chain.joined(separator: "->") + } + + private func debugHitViewDescriptor(_ view: NSView) -> String { + let className = String(describing: type(of: view)) + let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque()) + let dragTypes = debugRegisteredDragTypes(view) + return "\(className)@\(ptr){dragTypes=\(dragTypes)}" + } + + private func debugRegisteredDragTypes(_ view: NSView) -> String { + let types = view.registeredDraggedTypes + guard !types.isEmpty else { return "-" } + + let interestingTypes = types.filter { type in + let raw = type.rawValue + return raw == NSPasteboard.PasteboardType.fileURL.rawValue + || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue + || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue + || raw.contains("public.text") + || raw.contains("public.url") + || raw.contains("public.data") + } + let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes + let rendered = selected.map(\.rawValue).joined(separator: ",") + if selected.count < types.count { + return "\(rendered),+\(types.count - selected.count)" + } + return rendered + } + + private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { + guard let types else { return false } + return types.contains(.fileURL) + || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) + || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) + } + + private func debugEventName(_ eventType: NSEvent.EventType?) -> String { + guard let eventType else { return "none" } + switch eventType { + case .cursorUpdate: return "cursorUpdate" + case .appKitDefined: return "appKitDefined" + case .systemDefined: return "systemDefined" + case .applicationDefined: return "applicationDefined" + case .periodic: return "periodic" + case .mouseMoved: return "mouseMoved" + case .mouseEntered: return "mouseEntered" + case .mouseExited: return "mouseExited" + case .flagsChanged: return "flagsChanged" + case .leftMouseDown: return "leftMouseDown" + case .leftMouseUp: return "leftMouseUp" + case .leftMouseDragged: return "leftMouseDragged" + case .rightMouseDown: return "rightMouseDown" + case .rightMouseUp: return "rightMouseUp" + case .rightMouseDragged: return "rightMouseDragged" + case .otherMouseDown: return "otherMouseDown" + case .otherMouseUp: return "otherMouseUp" + case .otherMouseDragged: return "otherMouseDragged" + case .scrollWheel: return "scrollWheel" + default: return "other(\(eventType.rawValue))" + } + } + +#if DEBUG + private func logHitTestDecision( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType?, + shouldCapture: Bool + ) { + let isDragEvent = eventType == .leftMouseDragged + || eventType == .rightMouseDragged + || eventType == .otherMouseDragged + guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return } + + let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" + guard lastHitTestLogSignature != signature else { return } + lastHitTestLogSignature = signature + dlog( + "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + + "event=\(debugEventName(eventType)) " + + "topHit=\(debugTopHitViewForCurrentEvent()) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } + + private func logDragRouteDecision( + phase: String, + pasteboardTypes: [NSPasteboard.PasteboardType]?, + shouldCapture: Bool, + hasLocalDraggingSource: Bool, + hasTerminalTarget: Bool + ) { + guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } + let signature = [ + shouldCapture ? "1" : "0", + hasLocalDraggingSource ? "1" : "0", + hasTerminalTarget ? "1" : "0", + debugPasteboardTypes(pasteboardTypes) + ].joined(separator: "|") + guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } + lastDragRouteLogSignatureByPhase[phase] = signature + dlog( + "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + + "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } +#endif /// Hit-tests the window to find the GhosttyNSView under the cursor. func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { if let window, @@ -1522,13 +1718,21 @@ private struct SidebarExternalDropOverlay: View { draggedTabId: draggedTabId, pasteboardTypes: dragPasteboardTypes ) - Color.clear - .contentShape(Rectangle()) - .allowsHitTesting(shouldCapture) - .onDrop( - of: [SidebarTabDragPayload.typeIdentifier], - delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) - ) + Group { + if shouldCapture { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(true) + .onDrop( + of: [SidebarTabDragPayload.typeIdentifier], + delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) + ) + } else { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(false) + } + } } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 5f24ffe3..8122eee8 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2818,6 +2818,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + #if DEBUG + let types = sender.draggingPasteboard.types ?? [] + dlog("terminal.draggingUpdated surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") + #endif guard let types = sender.draggingPasteboard.types else { return [] } if Set(types).isDisjoint(with: Self.dropTypes) { return [] diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ebd0d673..7c3f36a3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -496,12 +496,21 @@ class TerminalController { case "drop_hit_test": return dropHitTest(args) + case "drag_hit_chain": + return dragHitChain(args) + case "overlay_hit_gate": return overlayHitGate(args) case "overlay_drop_gate": return overlayDropGate(args) + case "portal_hit_gate": + return portalHitGate(args) + + case "sidebar_overlay_gate": + return sidebarOverlayGate(args) + case "activate_app": return activateApp() @@ -6966,8 +6975,11 @@ class TerminalController { seed_drag_pasteboard_types - Seed NSDrag pasteboard with comma/space-separated types (fileurl, tabtransfer, sidebarreorder, or raw UTI) clear_drag_pasteboard - Clear NSDrag pasteboard (test-only) drop_hit_test - Hit-test file-drop overlay at normalised coords (test-only) + drag_hit_chain - Return hit-view chain at normalised coords (test-only) overlay_hit_gate - Return true/false if file-drop overlay would capture hit-testing for event type (test-only) overlay_drop_gate [external|local] - Return true/false if file-drop overlay would capture drag destination routing (test-only) + portal_hit_gate - Return true/false if terminal portal should pass hit-testing to SwiftUI drag targets (test-only) + sidebar_overlay_gate [active|inactive] - Return true/false if sidebar outside-drop overlay would capture (test-only) activate_app - Bring app + main window to front (test-only) is_terminal_focused - Return true/false if terminal surface is first responder (test-only) read_terminal_text [id|idx] - Read visible terminal text (base64, test-only) @@ -7246,36 +7258,14 @@ class TerminalController { private func overlayHitGate(_ args: String) -> String { let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !token.isEmpty else { - return "ERROR: Usage: overlay_hit_gate " + return "ERROR: Usage: overlay_hit_gate " } - let eventType: NSEvent.EventType? - switch token { - case "leftmousedragged": - eventType = .leftMouseDragged - case "rightmousedragged": - eventType = .rightMouseDragged - case "othermousedragged": - eventType = .otherMouseDragged - case "leftmousedown": - eventType = .leftMouseDown - case "leftmouseup": - eventType = .leftMouseUp - case "rightmousedown": - eventType = .rightMouseDown - case "rightmouseup": - eventType = .rightMouseUp - case "othermousedown": - eventType = .otherMouseDown - case "othermouseup": - eventType = .otherMouseUp - case "scrollwheel": - eventType = .scrollWheel - case "none": - eventType = nil - default: + let parsedEvent = parseOverlayEventType(token) + guard parsedEvent.isKnown else { return "ERROR: Unknown event type '\(args.trimmingCharacters(in: .whitespacesAndNewlines))'" } + let eventType = parsedEvent.eventType var shouldCapture = false DispatchQueue.main.sync { @@ -7312,6 +7302,98 @@ class TerminalController { return shouldCapture ? "true" : "false" } + private func portalHitGate(_ args: String) -> String { + let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !token.isEmpty else { + return "ERROR: Usage: portal_hit_gate " + } + let parsedEvent = parseOverlayEventType(token) + guard parsedEvent.isKnown else { + return "ERROR: Unknown event type '\(args.trimmingCharacters(in: .whitespacesAndNewlines))'" + } + let eventType = parsedEvent.eventType + + var shouldPassThrough = false + DispatchQueue.main.sync { + let pb = NSPasteboard(name: .drag) + shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting( + pasteboardTypes: pb.types, + eventType: eventType + ) + } + return shouldPassThrough ? "true" : "false" + } + + private func sidebarOverlayGate(_ args: String) -> String { + let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let hasSidebarDragState: Bool + switch token { + case "", "active": + hasSidebarDragState = true + case "inactive": + hasSidebarDragState = false + default: + return "ERROR: Usage: sidebar_overlay_gate [active|inactive]" + } + + var shouldCapture = false + DispatchQueue.main.sync { + let pb = NSPasteboard(name: .drag) + shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: hasSidebarDragState, + pasteboardTypes: pb.types + ) + } + return shouldCapture ? "true" : "false" + } + + private func parseOverlayEventType(_ token: String) -> (isKnown: Bool, eventType: NSEvent.EventType?) { + switch token { + case "leftmousedragged": + return (true, .leftMouseDragged) + case "rightmousedragged": + return (true, .rightMouseDragged) + case "othermousedragged": + return (true, .otherMouseDragged) + case "mousemove", "mousemoved": + return (true, .mouseMoved) + case "mouseentered": + return (true, .mouseEntered) + case "mouseexited": + return (true, .mouseExited) + case "flagschanged": + return (true, .flagsChanged) + case "cursorupdate": + return (true, .cursorUpdate) + case "appkitdefined": + return (true, .appKitDefined) + case "systemdefined": + return (true, .systemDefined) + case "applicationdefined": + return (true, .applicationDefined) + case "periodic": + return (true, .periodic) + case "leftmousedown": + return (true, .leftMouseDown) + case "leftmouseup": + return (true, .leftMouseUp) + case "rightmousedown": + return (true, .rightMouseDown) + case "rightmouseup": + return (true, .rightMouseUp) + case "othermousedown": + return (true, .otherMouseDown) + case "othermouseup": + return (true, .otherMouseUp) + case "scrollwheel": + return (true, .scrollWheel) + case "none": + return (true, nil) + default: + return (false, nil) + } + } + private func dragPasteboardType(from token: String) -> NSPasteboard.PasteboardType? { let normalized = token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() switch normalized { @@ -7371,6 +7453,71 @@ class TerminalController { return result } + /// Return the hit-test chain at normalized (0-1) coordinates in the main window's + /// content area. Used by regression tests to detect root-level drag destinations + /// shadowing pane-local Bonsplit drop targets. + private func dragHitChain(_ args: String) -> String { + let parts = args.split(separator: " ").map(String.init) + guard parts.count == 2, + let nx = Double(parts[0]), let ny = Double(parts[1]), + (0...1).contains(nx), (0...1).contains(ny) else { + return "ERROR: Usage: drag_hit_chain " + } + + var result = "ERROR: No window" + DispatchQueue.main.sync { + guard let window = NSApp.mainWindow + ?? NSApp.keyWindow + ?? NSApp.windows.first(where: { win in + guard let raw = win.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + }), + let contentView = window.contentView, + let themeFrame = contentView.superview else { return } + + let pointInTheme = NSPoint( + x: contentView.frame.minX + (contentView.bounds.width * nx), + y: contentView.frame.maxY - (contentView.bounds.height * ny) + ) + + let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView + if let overlay { overlay.isHidden = true } + defer { overlay?.isHidden = false } + + guard let hit = themeFrame.hitTest(pointInTheme) else { + result = "none" + return + } + + var chain: [String] = [] + var current: NSView? = hit + var depth = 0 + while let view = current, depth < 8 { + chain.append(debugDragHitViewDescriptor(view)) + current = view.superview + depth += 1 + } + result = chain.joined(separator: "->") + } + return result + } + + private func debugDragHitViewDescriptor(_ view: NSView) -> String { + let className = String(describing: type(of: view)) + let pointer = String(describing: Unmanaged.passUnretained(view).toOpaque()) + let types = view.registeredDraggedTypes + let renderedTypes: String + if types.isEmpty { + renderedTypes = "-" + } else { + let raw = types.map(\.rawValue) + renderedTypes = raw.count <= 4 + ? raw.joined(separator: ",") + : raw.prefix(4).joined(separator: ",") + ",+\(raw.count - 4)" + } + return "\(className)@\(pointer){dragTypes=\(renderedTypes)}" + } + private func unescapeSocketText(_ input: String) -> String { var out = "" var escaping = false diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 3f4e4dd0..af6b0d72 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -21,12 +21,42 @@ private func portalDebugFrame(_ rect: NSRect) -> String { final class WindowTerminalHostView: NSView { override var isOpaque: Bool { false } +#if DEBUG + private var lastDragRouteSignature: String? +#endif override func hitTest(_ point: NSPoint) -> NSView? { if shouldPassThroughToSplitDivider(at: point) { return nil } + + let dragPasteboardTypes = NSPasteboard(name: .drag).types + let eventType = NSApp.currentEvent?.type + let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting( + pasteboardTypes: dragPasteboardTypes, + eventType: eventType + ) + if shouldPassThrough { +#if DEBUG + logDragRouteDecision( + passThrough: true, + eventType: eventType, + pasteboardTypes: dragPasteboardTypes, + hitView: nil + ) +#endif + return nil + } + let hitView = super.hitTest(point) +#if DEBUG + logDragRouteDecision( + passThrough: false, + eventType: eventType, + pasteboardTypes: dragPasteboardTypes, + hitView: hitView + ) +#endif return hitView === self ? nil : hitView } @@ -87,6 +117,65 @@ final class WindowTerminalHostView: NSView { return false } + +#if DEBUG + private func logDragRouteDecision( + passThrough: Bool, + eventType: NSEvent.EventType?, + pasteboardTypes: [NSPasteboard.PasteboardType]?, + hitView: NSView? + ) { + let hasRelevantTypes = DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes) + || DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes) + guard passThrough || hasRelevantTypes else { return } + + let targetClass = hitView.map { NSStringFromClass(type(of: $0)) } ?? "nil" + let signature = [ + passThrough ? "1" : "0", + debugEventName(eventType), + debugPasteboardTypes(pasteboardTypes), + targetClass, + ].joined(separator: "|") + guard lastDragRouteSignature != signature else { return } + lastDragRouteSignature = signature + + dlog( + "portal.dragRoute passThrough=\(passThrough ? 1 : 0) " + + "event=\(debugEventName(eventType)) target=\(targetClass) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } + + private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { + guard let types, !types.isEmpty else { return "-" } + return types.map(\.rawValue).joined(separator: ",") + } + + private func debugEventName(_ eventType: NSEvent.EventType?) -> String { + guard let eventType else { return "none" } + switch eventType { + case .cursorUpdate: return "cursorUpdate" + case .appKitDefined: return "appKitDefined" + case .systemDefined: return "systemDefined" + case .applicationDefined: return "applicationDefined" + case .periodic: return "periodic" + case .mouseMoved: return "mouseMoved" + case .mouseEntered: return "mouseEntered" + case .mouseExited: return "mouseExited" + case .flagsChanged: return "flagsChanged" + case .leftMouseDragged: return "leftMouseDragged" + case .rightMouseDragged: return "rightMouseDragged" + case .otherMouseDragged: return "otherMouseDragged" + case .leftMouseDown: return "leftMouseDown" + case .leftMouseUp: return "leftMouseUp" + case .rightMouseDown: return "rightMouseDown" + case .rightMouseUp: return "rightMouseUp" + case .otherMouseDown: return "otherMouseDown" + case .otherMouseUp: return "otherMouseUp" + default: return "other(\(eventType.rawValue))" + } + } +#endif } @MainActor diff --git a/tests/cmux.py b/tests/cmux.py index a9aa9ca3..23c1f4b7 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -882,6 +882,20 @@ class cmux: raise cmuxError(response) return response.strip().lower() == "true" + def portal_hit_gate(self, event_type: str) -> bool: + """Return whether terminal portal hit-testing should pass through to SwiftUI drag targets.""" + response = self._send_command(f"portal_hit_gate {event_type}") + if response.startswith("ERROR"): + raise cmuxError(response) + return response.strip().lower() == "true" + + def sidebar_overlay_gate(self, state: str = "active") -> bool: + """Return whether sidebar outside-drop overlay would capture for drag state.""" + response = self._send_command(f"sidebar_overlay_gate {state}") + if response.startswith("ERROR"): + raise cmuxError(response) + return response.strip().lower() == "true" + def drop_hit_test(self, x: float, y: float) -> Optional[str]: """Hit-test the file-drop overlay at normalised (0-1) coords. @@ -893,6 +907,13 @@ class cmux: val = response.strip() return None if val == "none" else val + def drag_hit_chain(self, x: float, y: float) -> str: + """Return hit-view chain at normalised (0-1) coordinates.""" + response = self._send_command(f"drag_hit_chain {x} {y}") + if response.startswith("ERROR"): + raise cmuxError(response) + return response.strip() + def activate_app(self) -> None: """Bring app + main window to front (debug builds only).""" response = self._send_command("activate_app") diff --git a/tests/test_bonsplit_tab_drag_overlay_gate.py b/tests/test_bonsplit_tab_drag_overlay_gate.py index fe8202c1..1d988e04 100644 --- a/tests/test_bonsplit_tab_drag_overlay_gate.py +++ b/tests/test_bonsplit_tab_drag_overlay_gate.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 """ -Regression test: file-drop overlay must not intercept bonsplit tab-transfer drags. +Regression test: drag-routing policy must keep drag/drop features isolated. This test is socket-only (no System Events / Accessibility permissions required). -It validates both FileDropOverlayView hit-test and drag-destination gate logic: +It validates: -1) tabtransfer/sidebar-reorder payloads never capture -2) fileURL captures only valid external file-drop paths -3) local drags are never captured by file-drop destination routing -4) mixed payloads (fileURL + tabtransfer/sidebar) are never captured +1) FileDropOverlayView hit-test and drag-destination gates +2) Terminal portal pass-through policy for Bonsplit/sidebar drags +3) Sidebar outside-drop overlay gate +4) Mixed payload behavior (fileURL + tabtransfer/sidebar) +5) Hit-test routing reaches pane-local Bonsplit drop targets (not a root overlay) """ import os @@ -25,7 +26,29 @@ DRAG_EVENTS = [ "otherMouseDragged", ] +PORTAL_PASS_THROUGH_EVENTS = DRAG_EVENTS + [ + "mouseMoved", + "mouseEntered", + "mouseExited", + "flagsChanged", + "cursorUpdate", + "appKitDefined", + "systemDefined", + "applicationDefined", + "periodic", + "none", +] + NON_DRAG_EVENTS = [ + "mouseMoved", + "mouseEntered", + "mouseExited", + "flagsChanged", + "cursorUpdate", + "appKitDefined", + "systemDefined", + "applicationDefined", + "periodic", "leftMouseDown", "leftMouseUp", "rightMouseDown", @@ -67,6 +90,39 @@ def assert_drop_gate(client: cmux, source: str, expected: bool, reason: str) -> ) +def assert_portal_gate(client: cmux, event_type: str, expected: bool, reason: str) -> None: + got = client.portal_hit_gate(event_type) + if got != expected: + raise cmuxError( + f"portal_hit_gate({event_type}) expected {expected} got {got} ({reason})" + ) + + +def assert_sidebar_gate(client: cmux, state: str, expected: bool, reason: str) -> None: + got = client.sidebar_overlay_gate(state) + if got != expected: + raise cmuxError( + f"sidebar_overlay_gate({state}) expected {expected} got {got} ({reason})" + ) + + +def assert_hit_chain_routes_to_pane( + client: cmux, + x: float = 0.75, + y: float = 0.50, + reason: str = "", +) -> None: + chain = client.drag_hit_chain(x, y) + if chain == "none": + raise cmuxError( + f"drag_hit_chain({x},{y}) returned none ({reason})" + ) + if "PaneContainerView" not in chain: + raise cmuxError( + f"drag_hit_chain({x},{y}) missing PaneContainerView ({reason}); chain={chain}" + ) + + def main() -> int: socket_path = cmux.default_socket_path() if not os.path.exists(socket_path): @@ -91,18 +147,42 @@ def main() -> int: assert_gate(client, event, expected=False, reason="empty drag pasteboard") assert_drop_gate(client, "external", expected=False, reason="empty pasteboard") assert_drop_gate(client, "local", expected=False, reason="empty pasteboard") + for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]: + assert_portal_gate(client, event, expected=False, reason="empty drag pasteboard") + assert_sidebar_gate(client, "active", expected=False, reason="empty pasteboard") + assert_sidebar_gate(client, "inactive", expected=False, reason="empty pasteboard") client.seed_drag_pasteboard_tabtransfer() + assert_hit_chain_routes_to_pane( + client, + reason="tabtransfer drag must route into pane-local Bonsplit drop host", + ) for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]: assert_gate(client, event, expected=False, reason="tabtransfer drag must pass through") assert_drop_gate(client, "external", expected=False, reason="tabtransfer drag must pass through") assert_drop_gate(client, "local", expected=False, reason="tabtransfer drag must pass through") + for event in PORTAL_PASS_THROUGH_EVENTS: + assert_portal_gate(client, event, expected=True, reason="tabtransfer should pass through terminal portal") + for event in ["leftMouseDown", "leftMouseUp", "rightMouseDown", "rightMouseUp", "otherMouseDown", "otherMouseUp", "scrollWheel"]: + assert_portal_gate(client, event, expected=False, reason="non-drag events should not pass through portal") + assert_sidebar_gate(client, "active", expected=False, reason="tabtransfer is not a sidebar drag payload") + assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state") client.seed_drag_pasteboard_sidebar_reorder() + assert_hit_chain_routes_to_pane( + client, + reason="inactive sidebar reorder payload must not route to root outside-drop overlay", + ) for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]: assert_gate(client, event, expected=False, reason="sidebar reorder drag must pass through") assert_drop_gate(client, "external", expected=False, reason="sidebar reorder drag must pass through") assert_drop_gate(client, "local", expected=False, reason="sidebar reorder drag must pass through") + for event in PORTAL_PASS_THROUGH_EVENTS: + assert_portal_gate(client, event, expected=True, reason="sidebar reorder should pass through terminal portal") + for event in ["leftMouseDown", "leftMouseUp", "rightMouseDown", "rightMouseUp", "otherMouseDown", "otherMouseUp", "scrollWheel"]: + assert_portal_gate(client, event, expected=False, reason="non-drag events should not pass through portal") + assert_sidebar_gate(client, "active", expected=True, reason="active sidebar drag should capture outside overlay") + assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state") client.seed_drag_pasteboard_fileurl() for event in DRAG_EVENTS: @@ -111,20 +191,36 @@ def main() -> int: assert_gate(client, event, expected=False, reason="non-drag events should pass through") assert_drop_gate(client, "external", expected=True, reason="external file drags should be captured") assert_drop_gate(client, "local", expected=False, reason="local drags must not be captured") + for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]: + assert_portal_gate(client, event, expected=False, reason="file drag should not trigger portal pass-through policy") + assert_sidebar_gate(client, "active", expected=False, reason="file drag is not sidebar reorder payload") + assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state") client.seed_drag_pasteboard_types(["fileurl", "tabtransfer"]) for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]: assert_gate(client, event, expected=False, reason="fileurl+tabtransfer must pass through") assert_drop_gate(client, "external", expected=False, reason="fileurl+tabtransfer must pass through") assert_drop_gate(client, "local", expected=False, reason="fileurl+tabtransfer must pass through") + for event in PORTAL_PASS_THROUGH_EVENTS: + assert_portal_gate(client, event, expected=True, reason="mixed fileurl+tabtransfer should still pass through portal") + for event in ["leftMouseDown", "leftMouseUp", "rightMouseDown", "rightMouseUp", "otherMouseDown", "otherMouseUp", "scrollWheel"]: + assert_portal_gate(client, event, expected=False, reason="non-drag events should not pass through portal") + assert_sidebar_gate(client, "active", expected=False, reason="tabtransfer mix is not sidebar reorder payload") + assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state") client.seed_drag_pasteboard_types(["fileurl", "sidebarreorder"]) for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]: assert_gate(client, event, expected=False, reason="fileurl+sidebarreorder must pass through") assert_drop_gate(client, "external", expected=False, reason="fileurl+sidebarreorder must pass through") assert_drop_gate(client, "local", expected=False, reason="fileurl+sidebarreorder must pass through") + for event in PORTAL_PASS_THROUGH_EVENTS: + assert_portal_gate(client, event, expected=True, reason="mixed fileurl+sidebarreorder should still pass through portal") + for event in ["leftMouseDown", "leftMouseUp", "rightMouseDown", "rightMouseUp", "otherMouseDown", "otherMouseUp", "scrollWheel"]: + assert_portal_gate(client, event, expected=False, reason="non-drag events should not pass through portal") + assert_sidebar_gate(client, "active", expected=True, reason="sidebar reorder mix should keep sidebar outside overlay active") + assert_sidebar_gate(client, "inactive", expected=False, reason="inactive sidebar drag state") - print("PASS: overlay hit/drop gates preserve bonsplit drags and external file-drop behavior") + print("PASS: drag routing policy matrix preserves bonsplit/sidebar drags and external file-drop behavior") return 0 finally: try: diff --git a/vendor/bonsplit b/vendor/bonsplit index 6ac667d3..0c5ef9b8 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745 +Subproject commit 0c5ef9b8309ecba23fcc5900ccb716481c6252ee