diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 239c641a..7ee2d00a 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -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?) { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0b48bb1d..cc8f5395 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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)"