cmux/Sources/Panels/CmuxWebView.swift
Austin Wang 04431751ce
Fix file drag-and-drop and file input in browser panel (#214)
* Fix file drag-and-drop and file input in browser panel (#194)

Two fixes for the browser panel:

1. File drag-and-drop from Finder: CmuxWebView previously suppressed ALL
   drag type registration as a no-op to prevent bonsplit tab drags from
   being intercepted. Now it selectively filters out only the text-based
   types that conflict with bonsplit (public.text, public.utf8-plain-text,
   public.plain-text) and the custom tab transfer types, while allowing
   file URL types through so Finder drops work.

2. File <input> elements: Added the WKUIDelegate runOpenPanelWith method
   to BrowserUIDelegate so clicking a file input opens the native macOS
   file picker (NSOpenPanel), with support for multiple selection and
   directory picking as specified by the HTML element.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(claude-opus-4-6): take a look at https://github.com/manaflow-ai/cmux/issues...

* ok

* wok

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:26:23 -08:00

150 lines
6.2 KiB
Swift

import AppKit
import WebKit
/// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W),
/// preventing the app menu/SwiftUI Commands from receiving them. Route menu
/// key equivalents first so app-level shortcuts continue to work when WebKit is
/// the first responder.
final class CmuxWebView: WKWebView {
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not
// route it through app/menu key equivalents, which can trigger unintended actions.
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 {
return false
}
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
return true
}
// Handle app-level shortcuts that are not menu-backed (for example split commands).
// Without this, WebKit can consume Cmd-based shortcuts before the app monitor sees them.
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
return true
}
return super.performKeyEquivalent(with: event)
}
override func keyDown(with event: NSEvent) {
// Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent.
// Route them through the same app-level shortcut handler as a fallback.
if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
return
}
super.keyDown(with: event)
}
// MARK: - Focus on click
// The SwiftUI Color.clear overlay (.onTapGesture) that focuses panes can't receive
// clicks when a WKWebView is underneath AppKit delivers the click to the deepest
// NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so
// bonsplit focus tracks which pane the user clicked in.
override func mouseDown(with event: NSEvent) {
NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self)
super.mouseDown(with: event)
}
// MARK: - Mouse back/forward buttons & middle-click
override func otherMouseDown(with event: NSEvent) {
// Button 3 = back, button 4 = forward (multi-button mice like Logitech).
// Consume the event so WebKit doesn't handle it.
switch event.buttonNumber {
case 3:
goBack()
return
case 4:
goForward()
return
default:
break
}
super.otherMouseDown(with: event)
}
override func otherMouseUp(with event: NSEvent) {
// Middle-click (button 2) on a link opens it in a new tab.
if event.buttonNumber == 2 {
let point = convert(event.locationInWindow, from: nil)
findLinkAtPoint(point) { [weak self] url in
guard let self, let url else { return }
NotificationCenter.default.post(
name: .webViewMiddleClickedLink,
object: self,
userInfo: ["url": url]
)
}
return
}
super.otherMouseUp(with: event)
}
/// Use JavaScript to find the nearest anchor element at the given view-local point.
private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
// WKWebView's coordinate system is flipped (origin top-left for web content).
let flippedY = bounds.height - point.y
let js = """
(() => {
let el = document.elementFromPoint(\(point.x), \(flippedY));
while (el) {
if (el.tagName === 'A' && el.href) return el.href;
el = el.parentElement;
}
return '';
})();
"""
evaluateJavaScript(js) { result, _ in
guard let href = result as? String, !href.isEmpty,
let url = URL(string: href) else {
completion(nil)
return
}
completion(url)
}
}
// MARK: - Drag-and-drop passthrough
// WKWebView inherently calls registerForDraggedTypes with public.text (and others).
// Bonsplit tab drags use NSString (public.utf8-plain-text) which conforms to public.text,
// so AppKit's view-hierarchy-based drag routing delivers the session to WKWebView instead
// of SwiftUI's sibling .onDrop overlays. Rejecting in draggingEntered doesn't help because
// AppKit only bubbles up through superviews, not siblings.
//
// Fix: filter out text-based types that conflict with bonsplit tab drags, but keep
// file URL types so Finder file drops and HTML drag-and-drop work.
private static let blockedDragTypes: Set<NSPasteboard.PasteboardType> = [
.string, // public.utf8-plain-text matches bonsplit's NSString tab drags
NSPasteboard.PasteboardType("public.text"),
NSPasteboard.PasteboardType("public.plain-text"),
NSPasteboard.PasteboardType("com.splittabbar.tabtransfer"),
NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"),
]
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) }
if !filtered.isEmpty {
super.registerForDraggedTypes(filtered)
}
}
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)
for item in menu.items {
// Rename "Open Link in New Window" to "Open Link in New Tab".
// The UIDelegate's createWebViewWith already handles the action
// by opening the link as a new surface in the same pane.
if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow"
|| item.title.contains("Open Link in New Window") {
item.title = "Open Link in New Tab"
}
}
}
}