Fix bonsplit drag routing and pin submodule commit
This commit is contained in:
parent
23979d8c02
commit
cf767cf9af
7 changed files with 623 additions and 62 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745
|
||||
Subproject commit 0c5ef9b8309ecba23fcc5900ccb716481c6252ee
|
||||
Loading…
Add table
Add a link
Reference in a new issue