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:
parent
4220c3808f
commit
9fd3cc2a6c
7 changed files with 398 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <combo> - Simulate a keyDown shortcut (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)
|
||||
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)
|
||||
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)
|
||||
|
|
@ -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 <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 {
|
||||
var out = ""
|
||||
var escaping = false
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
167
tests/test_file_drop_split_targeting.py
Normal file
167
tests/test_file_drop_split_targeting.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue