Fix bonsplit drag routing and pin submodule commit

This commit is contained in:
Lawrence Chen 2026-02-20 18:47:34 -08:00
parent 23979d8c02
commit cf767cf9af
7 changed files with 623 additions and 62 deletions

View file

@ -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)
}
}
}
}

View file

@ -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 []

View file

@ -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 <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 <x 0-1> <y 0-1> - Hit-test file-drop overlay at normalised coords (test-only)
drag_hit_chain <x 0-1> <y 0-1> - Return hit-view chain at normalised coords (test-only)
overlay_hit_gate <event|none> - 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 <event|none> - 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 <id|idx> - 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 <leftMouseDragged|rightMouseDragged|otherMouseDragged|leftMouseDown|leftMouseUp|rightMouseDown|rightMouseUp|otherMouseDown|otherMouseUp|scrollWheel|none>"
return "ERROR: Usage: overlay_hit_gate <leftMouseDragged|rightMouseDragged|otherMouseDragged|mouseMoved|mouseEntered|mouseExited|flagsChanged|cursorUpdate|appKitDefined|systemDefined|applicationDefined|periodic|leftMouseDown|leftMouseUp|rightMouseDown|rightMouseUp|otherMouseDown|otherMouseUp|scrollWheel|none>"
}
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 <leftMouseDragged|rightMouseDragged|otherMouseDragged|mouseMoved|mouseEntered|mouseExited|flagsChanged|cursorUpdate|appKitDefined|systemDefined|applicationDefined|periodic|leftMouseDown|leftMouseUp|rightMouseDown|rightMouseUp|otherMouseDown|otherMouseUp|scrollWheel|none>"
}
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 <x 0-1> <y 0-1>"
}
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

View file

@ -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

View file

@ -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")

View file

@ -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:

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745
Subproject commit 0c5ef9b8309ecba23fcc5900ccb716481c6252ee