diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cce5d5bd..5a79df65 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -723,6 +723,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState ) + installFileDropOverlay(on: window, tabManager: tabManager) window.makeKeyAndOrderFront(nil) setActiveMainWindow(window) NSApp.activate(ignoringOtherApps: true) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6993ff69..b7df084d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -158,6 +158,121 @@ final class SidebarState: ObservableObject { } } +// MARK: - File Drop Overlay + +/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to +/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's +/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply +/// embedded terminal views. This overlay sits above the entire content view hierarchy and +/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor. +/// +/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, +/// scrolls, and other interactions pass through normally. +final class FileDropOverlayView: NSView { + /// Fallback handler when no terminal is found under the drop point. + var onDrop: (([URL]) -> Bool)? + + override var acceptsFirstResponder: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([.fileURL, .URL, .string]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } + + // MARK: Mouse forwarding – hide self so the event reaches views below. + + private func forwardEvent(_ event: NSEvent) { + isHidden = true + window?.sendEvent(event) + isHidden = false + } + + override func mouseDown(with event: NSEvent) { forwardEvent(event) } + override func mouseUp(with event: NSEvent) { forwardEvent(event) } + override func mouseDragged(with event: NSEvent) { forwardEvent(event) } + override func rightMouseDown(with event: NSEvent) { forwardEvent(event) } + override func rightMouseUp(with event: NSEvent) { forwardEvent(event) } + override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) } + override func otherMouseDown(with event: NSEvent) { forwardEvent(event) } + override func otherMouseUp(with event: NSEvent) { forwardEvent(event) } + override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } + override func scrollWheel(with event: NSEvent) { forwardEvent(event) } + + // MARK: NSDraggingDestination – only accept drops over terminal views. + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + return dragOperationForSender(sender) + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + return dragOperationForSender(sender) + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + 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(where: { $0 == .fileURL || $0 == .URL || $0 == .string }), + terminalUnderPoint(sender.draggingLocation) != nil else { + return [] + } + return .copy + } + + /// Temporarily hides self, hit-tests the window to find the GhosttyNSView under the cursor. + private func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + guard let window, let contentView = window.contentView, + let themeFrame = contentView.superview else { return nil } + isHidden = true + // hitTest expects the point in the receiver's superview's coordinate system. + // Converting to contentView's own coords would flip y (NSHostingView is flipped) + // causing top/bottom split targeting to be inverted. + let point = themeFrame.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + isHidden = false + + var current: NSView? = hitView + while let view = current { + if let terminal = view as? GhosttyNSView { return terminal } + current = view.superview + } + return nil + } +} + +var fileDropOverlayKey: UInt8 = 0 + +/// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support. +func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) { + guard objc_getAssociatedObject(window, &fileDropOverlayKey) == nil, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return } + + let overlay = FileDropOverlayView(frame: contentView.frame) + overlay.translatesAutoresizingMaskIntoConstraints = false + overlay.onDrop = { [weak tabManager] urls in + MainActor.assumeIsolated { + guard let tabManager, let terminal = tabManager.selectedWorkspace?.focusedTerminalPanel else { return false } + return terminal.hostedView.handleDroppedURLs(urls) + } + } + + themeFrame.addSubview(overlay, positioned: .above, relativeTo: contentView) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: contentView.topAnchor), + overlay.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + ]) + + objc_setAssociatedObject(window, &fileDropOverlayKey, overlay, .OBJC_ASSOCIATION_RETAIN) +} + struct ContentView: View { @ObservedObject var updateViewModel: UpdateViewModel let windowId: UUID @@ -498,6 +613,7 @@ struct ContentView: View { sidebarState: sidebarState, sidebarSelectionState: sidebarSelectionState ) + installFileDropOverlay(on: window, tabManager: tabManager) }) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index e81c4969..b65a2452 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2475,7 +2475,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } } - private static func escapeDropForShell(_ value: String) -> String { + fileprivate static func escapeDropForShell(_ value: String) -> String { var result = value for char in shellEscapeCharacters { result = result.replacingOccurrences(of: String(char), with: "\\\(char)") @@ -2531,6 +2531,18 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // MARK: NSDraggingDestination override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + #if DEBUG + let types = sender.draggingPasteboard.types ?? [] + dlog("terminal.draggingEntered 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 [] + } + return .copy + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { guard let types = sender.draggingPasteboard.types else { return [] } if Set(types).isDisjoint(with: Self.dropTypes) { return [] @@ -2976,7 +2988,23 @@ final class GhosttySurfaceScrollView: NSView { #endif - #if DEBUG + /// Handle file/URL drops from SwiftUI's .onDrop, forwarding to the terminal as shell-escaped paths. + func handleDroppedURLs(_ urls: [URL]) -> Bool { + guard !urls.isEmpty else { return false } + let content = urls + .map { GhosttyNSView.escapeDropForShell($0.path) } + .joined(separator: " ") + #if DEBUG + dlog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))") + #endif + if let window = surfaceView.window { + window.makeFirstResponder(surfaceView) + } + surfaceView.sendTextToSurface(content) + return true + } + +#if DEBUG /// Sends a synthetic Ctrl+D key press directly to the surface view. /// This exercises the same key path as real keyboard input (ghostty_surface_key), /// unlike `sendText`, which bypasses key translation. diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c4487325..2d2c27f0 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -352,6 +352,9 @@ class TerminalController { case "simulate_file_drop": return simulateFileDrop(args) + case "drop_hit_test": + return dropHitTest(args) + case "activate_app": return activateApp() @@ -6165,6 +6168,7 @@ class TerminalController { simulate_shortcut - Simulate a keyDown shortcut (test-only) simulate_type - Insert text into the current first responder (test-only) simulate_file_drop - Simulate dropping file path(s) on terminal (test-only) + drop_hit_test - Hit-test file-drop overlay at normalised coords (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) @@ -6373,6 +6377,62 @@ class TerminalController { return result } + /// 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 + /// surface UUID of the terminal under that point, or "none". + private func dropHitTest(_ 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: drop_hit_test " + } + + 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 } + + // Compute the point in contentView's own coordinate system. + // NSHostingView is flipped: (0,0) = top-left, matching our API. + let contentPoint = NSPoint( + x: contentView.bounds.width * nx, + y: contentView.bounds.height * ny + ) + + // hitTest expects the point in the receiver's superview's (themeFrame's) + // coordinate system. Use convert to handle the coordinate transform. + let hitPoint = contentView.convert(contentPoint, to: themeFrame) + + // Temporarily hide the overlay so it doesn't intercept the hit test. + let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView + overlay?.isHidden = true + + let hitView = contentView.hitTest(hitPoint) + + overlay?.isHidden = false + + var current: NSView? = hitView + while let view = current { + if let terminal = view as? GhosttyNSView, + let surfaceId = terminal.terminalSurface?.id { + result = surfaceId.uuidString.uppercased() + return + } + current = view.superview + } + result = "none" + } + return result + } + private func unescapeSocketText(_ input: String) -> String { var out = "" var escaping = false diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index ea60d8e9..bd2f6ccf 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -18,6 +18,19 @@ struct WorkspaceContentView: View { // AppKit-backed views can still intercept drags. Disable drop acceptance for them. let _ = { workspace.bonsplitController.isInteractive = isTabActive }() + // Wire up file drop handling so bonsplit's PaneDragContainerView can forward + // Finder file drops to the correct terminal panel. + let _ = { + workspace.bonsplitController.onFileDrop = { [weak workspace] urls, paneId in + guard let workspace else { return false } + // Find the focused panel in this pane and drop the files into it. + guard let tabId = workspace.bonsplitController.selectedTab(inPane: paneId)?.id, + let panelId = workspace.panelIdFromSurfaceId(tabId), + let panel = workspace.panels[panelId] as? TerminalPanel else { return false } + return panel.hostedView.handleDroppedURLs(urls) + } + }() + BonsplitView(controller: workspace.bonsplitController) { tab, paneId in // Content for each tab in bonsplit let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) diff --git a/tests/cmux.py b/tests/cmux.py index 57cedf94..22d61fa1 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -811,6 +811,17 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def drop_hit_test(self, x: float, y: float) -> Optional[str]: + """Hit-test the file-drop overlay at normalised (0-1) coords. + + Returns the surface UUID string if a terminal is under the point, or None. + """ + response = self._send_command(f"drop_hit_test {x} {y}") + if response.startswith("ERROR"): + raise cmuxError(response) + val = response.strip() + return None if val == "none" else val + 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_file_drop_split_targeting.py b/tests/test_file_drop_split_targeting.py new file mode 100644 index 00000000..b186ce2e --- /dev/null +++ b/tests/test_file_drop_split_targeting.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Regression test: file drops in vertical splits target the correct terminal. + +When the window has a vertical split (top/bottom), a file drop over the top +terminal must be routed to the top terminal (not the bottom one), and vice +versa. A coordinate-system bug (y-axis inversion in hitTest) previously +caused drops to land in the wrong pane. +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux + + +def surface_ids_from_layout(layout: dict): + """Extract panel IDs keyed by vertical position from layout_debug output. + + Returns (top_surface_id, bottom_surface_id) based on pane frame y-origins. + Bonsplit's pane frames use a top-left origin (flipped) coordinate system, + so smaller y = higher on screen = top pane. + """ + panels = layout.get("selectedPanels", []) + if len(panels) < 2: + return None, None + + def y_origin(p): + frame = p.get("paneFrame") + if frame is None: + return 0 + return frame.get("y", 0) + + # Sort ascending by y: smallest y = top pane visually + sorted_panels = sorted(panels, key=y_origin) + top_id = sorted_panels[0].get("panelId") + bottom_id = sorted_panels[1].get("panelId") + return top_id, bottom_id + + +def main() -> int: + with cmux() as client: + try: + client.activate_app() + except Exception: + pass + + # Start with a single terminal surface. + surfaces = client.list_surfaces() + if not surfaces: + client.new_workspace() + time.sleep(0.3) + surfaces = client.list_surfaces() + if not surfaces: + print("FAIL: no surfaces available") + return 1 + + # Create a vertical (top/bottom) split. + client.new_split("down") + time.sleep(0.5) + + layout = client.layout_debug() + top_panel_id, bottom_panel_id = surface_ids_from_layout(layout) + if not top_panel_id or not bottom_panel_id: + print("FAIL: could not determine top/bottom panel IDs from layout") + print(f"layout: {layout}") + return 1 + + if top_panel_id == bottom_panel_id: + print("FAIL: top and bottom panel IDs are the same") + return 1 + + # Test the hit-test mapping directly: given a point in the top/bottom + # half, does it resolve to *different* terminals in the expected order? + # drop_hit_test uses content-area coordinates: (0,0)=top-left, (1,1)=bottom-right. + + # Hit-test near the vertical centre of the top pane (y ≈ 0.25). + top_hit = client.drop_hit_test(0.5, 0.25) + # Hit-test near the vertical centre of the bottom pane (y ≈ 0.75). + bottom_hit = client.drop_hit_test(0.5, 0.75) + + if top_hit is None: + print("FAIL: drop_hit_test returned 'none' for top region") + return 1 + if bottom_hit is None: + print("FAIL: drop_hit_test returned 'none' for bottom region") + return 1 + if top_hit == bottom_hit: + print("FAIL: top and bottom hit test returned the same surface") + print(f" top_hit={top_hit} bottom_hit={bottom_hit}") + return 1 + + # Verify the mapping is not inverted: the top hit should correspond to + # the top pane and the bottom hit to the bottom pane. + # Cross-check via layout_debug pane frames (flipped coords: smaller y = top). + panels = layout.get("selectedPanels", []) + panel_to_y = {} + for p in panels: + pid = p.get("panelId") + frame = p.get("paneFrame") + if pid and frame: + panel_to_y[pid] = frame.get("y", 0) + + # drop_hit_test returns uppercase UUIDs; panelId may differ in case. + def normalise(uuid_str): + return uuid_str.upper() if uuid_str else "" + + top_y = panel_to_y.get(normalise(top_hit), panel_to_y.get(top_hit)) + bottom_y = panel_to_y.get(normalise(bottom_hit), panel_to_y.get(bottom_hit)) + + if top_y is None or bottom_y is None: + print("FAIL: could not find hit-test surface IDs in layout panel map") + print(f" top_hit={top_hit} bottom_hit={bottom_hit}") + print(f" panel_to_y={panel_to_y}") + return 1 + + # In flipped coords: top pane has smaller y + if top_y >= bottom_y: + print("FAIL: y-axis is inverted — top hit resolved to bottom pane") + print(f" top_hit={top_hit} (y={top_y}) bottom_hit={bottom_hit} (y={bottom_y})") + return 1 + + print("PASS: vertical split drop targeting is correct") + print(f" top_hit={top_hit} bottom_hit={bottom_hit}") + + # Also test horizontal split targeting. + # Close the bottom pane and create a horizontal split instead. + # First, close all extra surfaces to get back to 1. + surfaces = client.list_surfaces() + if len(surfaces) > 1: + # Focus and close the non-first surface + for _, sid, is_focused in surfaces[1:]: + try: + client.close_surface(sid) + except Exception: + pass + time.sleep(0.3) + + client.new_split("right") + time.sleep(0.5) + + # Hit-test left half and right half + left_hit = client.drop_hit_test(0.25, 0.5) + right_hit = client.drop_hit_test(0.75, 0.5) + + if left_hit is None: + print("FAIL: drop_hit_test returned 'none' for left region") + return 1 + if right_hit is None: + print("FAIL: drop_hit_test returned 'none' for right region") + return 1 + if left_hit == right_hit: + print("FAIL: left and right hit test returned the same surface") + print(f" left_hit={left_hit} right_hit={right_hit}") + return 1 + + print("PASS: horizontal split drop targeting is correct") + print(f" left_hit={left_hit} right_hit={right_hit}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())