diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 062318ad..513f2125 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -164,12 +164,12 @@ enum DragOverlayRoutingPolicy { static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - static func shouldCaptureFileDropOverlay( + static func shouldCaptureFileDropDestination( pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? + hasLocalDraggingSource: Bool ) -> Bool { + guard !hasLocalDraggingSource else { return false } guard let pasteboardTypes, pasteboardTypes.contains(.fileURL) else { return false } - guard isDragMouseEvent(eventType) else { return false } // Prefer explicit non-file drag types so stale fileURL entries cannot hijack // Bonsplit tab drags or sidebar tab reorder drags. @@ -178,6 +178,24 @@ enum DragOverlayRoutingPolicy { return true } + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureFileDropDestination( + pasteboardTypes: pasteboardTypes, + hasLocalDraggingSource: false + ) + } + + static func shouldCaptureFileDropOverlay( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } + guard isDragMouseEvent(eventType) else { return false } + return true + } + static func shouldCaptureSidebarExternalOverlay( draggedTabId: UUID?, pasteboardTypes: [NSPasteboard.PasteboardType]? @@ -278,27 +296,60 @@ final class FileDropOverlayView: NSView { // MARK: NSDraggingDestination – only accept file drops over terminal views. override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - return dragOperationForSender(sender) + return dragOperationForSender(sender, phase: "entered") } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - return dragOperationForSender(sender) + return dragOperationForSender(sender, phase: "updated") } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) +#if DEBUG + if shouldCapture || (types?.contains(.fileURL) ?? false) { + dlog( + "overlay.fileDrop.perform capture=\(shouldCapture ? 1 : 0) " + + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + + "types=\(debugPasteboardTypes(types))" + ) + } +#endif + guard shouldCapture else { return false } guard let terminal = terminalUnderPoint(sender.draggingLocation) else { return false } return terminal.performDragOperation(sender) } - private func dragOperationForSender(_ sender: any NSDraggingInfo) -> NSDragOperation { - guard let types = sender.draggingPasteboard.types, - types.contains(.fileURL), - terminalUnderPoint(sender.draggingLocation) != nil else { - return [] + private func dragOperationForSender(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) +#if DEBUG + if shouldCapture || (types?.contains(.fileURL) ?? false) { + dlog( + "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + + "types=\(debugPasteboardTypes(types))" + ) } +#endif + guard shouldCapture, + terminalUnderPoint(sender.draggingLocation) != nil else { return [] } return .copy } + private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { + guard let types, !types.isEmpty else { return "-" } + return types.map(\.rawValue).joined(separator: ",") + } + /// Hit-tests the window to find the GhosttyNSView under the cursor. func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { if let window, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 5f9b3d50..f3300268 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -484,6 +484,9 @@ class TerminalController { case "seed_drag_pasteboard_sidebar_reorder": return seedDragPasteboardSidebarReorder() + case "seed_drag_pasteboard_types": + return seedDragPasteboardTypes(args) + case "clear_drag_pasteboard": return clearDragPasteboard() @@ -493,6 +496,9 @@ class TerminalController { case "overlay_hit_gate": return overlayHitGate(args) + case "overlay_drop_gate": + return overlayDropGate(args) + case "activate_app": return activateApp() @@ -6284,9 +6290,11 @@ class TerminalController { seed_drag_pasteboard_fileurl - Seed NSDrag pasteboard with public.file-url (test-only) seed_drag_pasteboard_tabtransfer - Seed NSDrag pasteboard with tab transfer type (test-only) seed_drag_pasteboard_sidebar_reorder - Seed NSDrag pasteboard with sidebar reorder type (test-only) + 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) 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) 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) @@ -6515,26 +6523,43 @@ class TerminalController { } private func seedDragPasteboardFileURL() -> String { - DispatchQueue.main.sync { - _ = NSPasteboard(name: .drag).declareTypes([.fileURL], owner: nil) - } - return "OK" + return seedDragPasteboardTypes("fileurl") } private func seedDragPasteboardTabTransfer() -> String { - DispatchQueue.main.sync { - _ = NSPasteboard(name: .drag).declareTypes([ - NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") - ], owner: nil) - } - return "OK" + return seedDragPasteboardTypes("tabtransfer") } private func seedDragPasteboardSidebarReorder() -> String { + return seedDragPasteboardTypes("sidebarreorder") + } + + private func seedDragPasteboardTypes(_ args: String) -> String { + let raw = args.trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty else { + return "ERROR: Usage: seed_drag_pasteboard_types " + } + + let tokens = raw + .split(whereSeparator: { $0 == "," || $0.isWhitespace }) + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !tokens.isEmpty else { + return "ERROR: Usage: seed_drag_pasteboard_types " + } + + var types: [NSPasteboard.PasteboardType] = [] + for token in tokens { + guard let mapped = dragPasteboardType(from: token) else { + return "ERROR: Unknown drag type '\(token)'" + } + if !types.contains(mapped) { + types.append(mapped) + } + } + DispatchQueue.main.sync { - _ = NSPasteboard(name: .drag).declareTypes([ - DragOverlayRoutingPolicy.sidebarTabReorderType - ], owner: nil) + _ = NSPasteboard(name: .drag).declareTypes(types, owner: nil) } return "OK" } @@ -6592,6 +6617,46 @@ class TerminalController { return shouldCapture ? "true" : "false" } + private func overlayDropGate(_ args: String) -> String { + let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let hasLocalDraggingSource: Bool + switch token { + case "", "external": + hasLocalDraggingSource = false + case "local": + hasLocalDraggingSource = true + default: + return "ERROR: Usage: overlay_drop_gate [external|local]" + } + + var shouldCapture = false + DispatchQueue.main.sync { + let pb = NSPasteboard(name: .drag) + shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: pb.types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + } + return shouldCapture ? "true" : "false" + } + + private func dragPasteboardType(from token: String) -> NSPasteboard.PasteboardType? { + let normalized = token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch normalized { + case "fileurl", "file-url", "public.file-url": + return .fileURL + case "tabtransfer", "tab-transfer", "com.splittabbar.tabtransfer": + return DragOverlayRoutingPolicy.bonsplitTabTransferType + case "sidebarreorder", "sidebar-reorder", "sidebar_tab_reorder", + "com.cmux.sidebar-tab-reorder": + return DragOverlayRoutingPolicy.sidebarTabReorderType + default: + // Allow explicit UTI strings for ad-hoc debug probes. + guard token.contains(".") else { return nil } + return NSPasteboard.PasteboardType(token) + } + } + /// Hit-tests the file-drop overlay's coordinate-to-terminal mapping. /// Takes normalised (0-1) x,y within the content area where (0,0) is the /// top-left corner and (1,1) is the bottom-right corner. Returns the diff --git a/tests/cmux.py b/tests/cmux.py index 1be85e10..a9aa9ca3 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -851,6 +851,17 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def seed_drag_pasteboard_types(self, types: List[str]) -> None: + """Seed NSDrag pasteboard with comma/space-separated types in app process.""" + if not types: + raise cmuxError("seed_drag_pasteboard_types requires at least one type") + payload = ",".join(t.strip() for t in types if t and t.strip()) + if not payload: + raise cmuxError("seed_drag_pasteboard_types requires at least one non-empty type") + response = self._send_command(f"seed_drag_pasteboard_types {payload}") + if not response.startswith("OK"): + raise cmuxError(response) + def clear_drag_pasteboard(self) -> None: """Clear NSDrag pasteboard in the app process (debug builds only).""" response = self._send_command("clear_drag_pasteboard") @@ -864,6 +875,13 @@ class cmux: raise cmuxError(response) return response.strip().lower() == "true" + def overlay_drop_gate(self, source: str = "external") -> bool: + """Return whether FileDropOverlayView would capture drag-destination routing.""" + response = self._send_command(f"overlay_drop_gate {source}") + 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. diff --git a/tests/test_bonsplit_tab_drag_overlay_gate.py b/tests/test_bonsplit_tab_drag_overlay_gate.py index 2acd087d..fe8202c1 100644 --- a/tests/test_bonsplit_tab_drag_overlay_gate.py +++ b/tests/test_bonsplit_tab_drag_overlay_gate.py @@ -3,12 +3,12 @@ Regression test: file-drop overlay must not intercept bonsplit tab-transfer drags. This test is socket-only (no System Events / Accessibility permissions required). -It validates the FileDropOverlayView hit-test gate logic: +It validates both FileDropOverlayView hit-test and drag-destination gate logic: -1) tabtransfer pasteboard type never captures hit-testing -2) sidebar reorder pasteboard type never captures hit-testing -3) fileURL pasteboard captures only drag-motion mouse events -4) stale/no-event contexts do not capture hit-testing +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 """ import os @@ -42,6 +42,8 @@ def wait_for_overlay_probe_ready(client: cmux, timeout_s: float = 8.0) -> None: while time.time() - start < timeout_s: try: _ = client.overlay_hit_gate("none") + _ = client.overlay_drop_gate("external") + _ = client.overlay_drop_gate("local") return except Exception as e: last_error = e @@ -57,6 +59,14 @@ def assert_gate(client: cmux, event_type: str, expected: bool, reason: str) -> N ) +def assert_drop_gate(client: cmux, source: str, expected: bool, reason: str) -> None: + got = client.overlay_drop_gate(source) + if got != expected: + raise cmuxError( + f"overlay_drop_gate({source}) expected {expected} got {got} ({reason})" + ) + + def main() -> int: socket_path = cmux.default_socket_path() if not os.path.exists(socket_path): @@ -79,22 +89,42 @@ def main() -> int: client.clear_drag_pasteboard() for event in DRAG_EVENTS + NON_DRAG_EVENTS + ["none"]: 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") client.seed_drag_pasteboard_tabtransfer() 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") client.seed_drag_pasteboard_sidebar_reorder() 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") client.seed_drag_pasteboard_fileurl() for event in DRAG_EVENTS: assert_gate(client, event, expected=True, reason="file URL drag should be captured") for event in NON_DRAG_EVENTS + ["none"]: 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") - print("PASS: overlay hit-test gate preserves bonsplit tab drags and file-drop behavior") + 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") + + 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") + + print("PASS: overlay hit/drop gates preserve bonsplit drags and external file-drop behavior") return 0 finally: try: