Add file drop support from Finder into terminal splits

Nested NSHostingController layers (from bonsplit's SinglePaneWrapper)
prevent AppKit's NSDraggingDestination routing from reaching terminal
views. Install a transparent FileDropOverlayView on the window's theme
frame that intercepts file drags and forwards drops to the GhosttyNSView
under the cursor. Mouse events pass through via a hide-send-unhide
pattern.

Fix y-axis inversion in split targeting: hitTest expects coordinates in
the receiver's superview's coordinate system, not the receiver's own.
Converting to contentView's coords flipped y because NSHostingView is
flipped, causing top/bottom split drops to land in the wrong terminal.

Also adds bonsplit onFileDrop API, PaneDragContainerView, and
drop_hit_test socket command for testing coordinate-to-terminal mapping.
This commit is contained in:
Lawrence Chen 2026-02-17 21:55:31 -08:00
parent 4220c3808f
commit 9fd3cc2a6c
7 changed files with 398 additions and 2 deletions

View file

@ -723,6 +723,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
sidebarState: sidebarState, sidebarState: sidebarState,
sidebarSelectionState: sidebarSelectionState sidebarSelectionState: sidebarSelectionState
) )
installFileDropOverlay(on: window, tabManager: tabManager)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
setActiveMainWindow(window) setActiveMainWindow(window)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)

View file

@ -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 { struct ContentView: View {
@ObservedObject var updateViewModel: UpdateViewModel @ObservedObject var updateViewModel: UpdateViewModel
let windowId: UUID let windowId: UUID
@ -498,6 +613,7 @@ struct ContentView: View {
sidebarState: sidebarState, sidebarState: sidebarState,
sidebarSelectionState: sidebarSelectionState sidebarSelectionState: sidebarSelectionState
) )
installFileDropOverlay(on: window, tabManager: tabManager)
}) })
} }

View file

@ -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 var result = value
for char in shellEscapeCharacters { for char in shellEscapeCharacters {
result = result.replacingOccurrences(of: String(char), with: "\\\(char)") result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
@ -2531,6 +2531,18 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// MARK: NSDraggingDestination // MARK: NSDraggingDestination
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { 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 [] } guard let types = sender.draggingPasteboard.types else { return [] }
if Set(types).isDisjoint(with: Self.dropTypes) { if Set(types).isDisjoint(with: Self.dropTypes) {
return [] return []
@ -2976,7 +2988,23 @@ final class GhosttySurfaceScrollView: NSView {
#endif #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. /// 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), /// This exercises the same key path as real keyboard input (ghostty_surface_key),
/// unlike `sendText`, which bypasses key translation. /// unlike `sendText`, which bypasses key translation.

View file

@ -352,6 +352,9 @@ class TerminalController {
case "simulate_file_drop": case "simulate_file_drop":
return simulateFileDrop(args) return simulateFileDrop(args)
case "drop_hit_test":
return dropHitTest(args)
case "activate_app": case "activate_app":
return activateApp() return activateApp()
@ -6165,6 +6168,7 @@ class TerminalController {
simulate_shortcut <combo> - Simulate a keyDown shortcut (test-only) simulate_shortcut <combo> - Simulate a keyDown shortcut (test-only)
simulate_type <text> - Insert text into the current first responder (test-only) simulate_type <text> - Insert text into the current first responder (test-only)
simulate_file_drop <id|idx> <path[|path...]> - Simulate dropping file path(s) on terminal (test-only) simulate_file_drop <id|idx> <path[|path...]> - Simulate dropping file path(s) on terminal (test-only)
drop_hit_test <x 0-1> <y 0-1> - Hit-test file-drop overlay at normalised coords (test-only)
activate_app - Bring app + main window to front (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) 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) read_terminal_text [id|idx] - Read visible terminal text (base64, test-only)
@ -6373,6 +6377,62 @@ class TerminalController {
return result 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 <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 }
// 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 { private func unescapeSocketText(_ input: String) -> String {
var out = "" var out = ""
var escaping = false var escaping = false

View file

@ -18,6 +18,19 @@ struct WorkspaceContentView: View {
// AppKit-backed views can still intercept drags. Disable drop acceptance for them. // AppKit-backed views can still intercept drags. Disable drop acceptance for them.
let _ = { workspace.bonsplitController.isInteractive = isTabActive }() 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 BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
// Content for each tab in bonsplit // Content for each tab in bonsplit
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace) let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)

View file

@ -811,6 +811,17 @@ class cmux:
if not response.startswith("OK"): if not response.startswith("OK"):
raise cmuxError(response) 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: def activate_app(self) -> None:
"""Bring app + main window to front (debug builds only).""" """Bring app + main window to front (debug builds only)."""
response = self._send_command("activate_app") response = self._send_command("activate_app")

View file

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