Merge pull request #300 from manaflow-ai/task-browser-view-open-link-default-browser

Browser View: add right-click open link in default browser
This commit is contained in:
Lawrence Chen 2026-02-22 01:26:53 -08:00 committed by GitHub
commit 61c0cd0165
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 131 additions and 1 deletions

View file

@ -20,6 +20,8 @@ final class CmuxWebView: WKWebView {
private static var contextMenuFallbackKey: UInt8 = 0
var onContextMenuDownloadStateChanged: ((Bool) -> Void)?
var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)?
var contextMenuDefaultBrowserOpener: ((URL) -> Bool)?
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not
@ -302,6 +304,27 @@ final class CmuxWebView: WKWebView {
}
}
private func resolveContextMenuLinkURL(at point: NSPoint, completion: @escaping (URL?) -> Void) {
if let contextMenuLinkURLProvider {
contextMenuLinkURLProvider(self, point, completion)
return
}
findLinkURLAtPoint(point, completion: completion)
}
private func canOpenInDefaultBrowser(_ url: URL) -> Bool {
let scheme = url.scheme?.lowercased() ?? ""
return scheme == "http" || scheme == "https"
}
private func openContextMenuLinkInDefaultBrowser(_ url: URL) {
if let contextMenuDefaultBrowserOpener {
_ = contextMenuDefaultBrowserOpener(url)
return
}
_ = NSWorkspace.shared.open(url)
}
private func runContextMenuFallback(action: Selector?, target: AnyObject?, sender: Any?) {
guard let action else { return }
// Guard against accidental self-recursion if fallback gets overwritten.
@ -452,8 +475,22 @@ final class CmuxWebView: WKWebView {
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)
lastContextMenuPoint = convert(event.locationInWindow, from: nil)
var openLinkInsertionIndex: Int?
var hasDefaultBrowserOpenLinkItem = false
for (index, item) in menu.items.enumerated() {
if !hasDefaultBrowserOpenLinkItem,
(item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:))
|| item.title == "Open Link in Default Browser") {
hasDefaultBrowserOpenLinkItem = true
}
if openLinkInsertionIndex == nil,
(item.identifier?.rawValue == "WKMenuItemIdentifierOpenLink"
|| item.title == "Open Link") {
openLinkInsertionIndex = index + 1
}
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.
@ -494,6 +531,25 @@ final class CmuxWebView: WKWebView {
item.action = #selector(contextMenuDownloadLinkedFile(_:))
}
}
if let openLinkInsertionIndex, !hasDefaultBrowserOpenLinkItem {
let item = NSMenuItem(
title: "Open Link in Default Browser",
action: #selector(contextMenuOpenLinkInDefaultBrowser(_:)),
keyEquivalent: ""
)
item.target = self
menu.insertItem(item, at: min(openLinkInsertionIndex, menu.items.count))
}
}
@objc private func contextMenuOpenLinkInDefaultBrowser(_ sender: Any?) {
_ = sender
let point = lastContextMenuPoint
resolveContextMenuLinkURL(at: point) { [weak self] url in
guard let self, let url, self.canOpenInDefaultBrowser(url) else { return }
self.openContextMenuLinkInDefaultBrowser(url)
}
}
@objc private func contextMenuDownloadImage(_ sender: Any?) {

View file

@ -132,6 +132,80 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
}
}
@MainActor
final class CmuxWebViewContextMenuTests: XCTestCase {
private func makeRightMouseDownEvent() -> NSEvent {
guard let event = NSEvent.mouseEvent(
with: .rightMouseDown,
location: .zero,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: 0,
context: nil,
eventNumber: 0,
clickCount: 1,
pressure: 1.0
) else {
fatalError("Failed to create rightMouseDown event")
}
return event
}
func testWillOpenMenuAddsOpenLinkInDefaultBrowserAndRoutesSelectionToDefaultBrowserOpener() {
_ = NSApplication.shared
let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: WKWebViewConfiguration())
let menu = NSMenu()
let openLinkItem = NSMenuItem(title: "Open Link", action: nil, keyEquivalent: "")
openLinkItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierOpenLink")
menu.addItem(openLinkItem)
menu.addItem(NSMenuItem(title: "Copy Link", action: nil, keyEquivalent: ""))
var openedURL: URL?
webView.contextMenuLinkURLProvider = { _, _, completion in
completion(URL(string: "https://example.com/docs")!)
}
webView.contextMenuDefaultBrowserOpener = { url in
openedURL = url
return true
}
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
guard let defaultBrowserItemIndex = menu.items.firstIndex(where: { $0.title == "Open Link in Default Browser" }) else {
XCTFail("Expected Open Link in Default Browser item in context menu")
return
}
guard let openLinkIndex = menu.items.firstIndex(where: { $0.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" }) else {
XCTFail("Expected Open Link item in context menu")
return
}
XCTAssertEqual(defaultBrowserItemIndex, openLinkIndex + 1)
let defaultBrowserItem = menu.items[defaultBrowserItemIndex]
XCTAssertTrue(defaultBrowserItem.target === webView)
XCTAssertNotNil(defaultBrowserItem.action)
let dispatched = NSApp.sendAction(
defaultBrowserItem.action!,
to: defaultBrowserItem.target,
from: defaultBrowserItem
)
XCTAssertTrue(dispatched)
XCTAssertEqual(openedURL?.absoluteString, "https://example.com/docs")
}
func testWillOpenMenuSkipsDefaultBrowserItemWhenContextHasNoOpenLinkEntry() {
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Back", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Forward", action: nil, keyEquivalent: ""))
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" })
}
}
final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {
private func makeIsolatedDefaults() -> UserDefaults {
let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)"