Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; };
A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; };
A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; };
A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; };
A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; };
A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; };
Expand Down Expand Up @@ -177,6 +178,7 @@
A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = "<group>"; };
A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; };
A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; };
A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = "<group>"; };
A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = "<group>"; };
A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = "<group>"; };
A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -381,6 +383,7 @@
A5001412 /* BrowserPanel.swift */,
A5001413 /* TerminalPanelView.swift */,
A5001414 /* BrowserPanelView.swift */,
A5007421 /* BrowserPopupWindowController.swift */,
A5001418 /* MarkdownPanel.swift */,
A5001419 /* MarkdownPanelView.swift */,
A5001510 /* CmuxWebView.swift */,
Expand Down Expand Up @@ -650,6 +653,7 @@
A5001402 /* BrowserPanel.swift in Sources */,
A5001403 /* TerminalPanelView.swift in Sources */,
A5001404 /* BrowserPanelView.swift in Sources */,
A5007420 /* BrowserPopupWindowController.swift in Sources */,
A5001420 /* MarkdownPanel.swift in Sources */,
A5001421 /* MarkdownPanelView.swift in Sources */,
A5001500 /* CmuxWebView.swift in Sources */,
Expand Down
17 changes: 17 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -7208,6 +7208,23 @@
}
}
},
"browser.popup.loadingTitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Loading\u2026"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "\u8aad\u307f\u8fbc\u307f\u4e2d\u2026"
}
}
}
},
"browser.proceedInCmux": {
"extractionState": "manual",
"localizations": {
Expand Down
13 changes: 12 additions & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7388,7 +7388,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
event: event,
shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false)
) {
if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
// Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel.
// This AppDelegate path is a fallback for cases where AppKit routes the
// event through the global shortcut handler first.
if let targetWindow = [NSApp.keyWindow, event.window]
.compactMap({ $0 })
.first(where: { $0.identifier?.rawValue == "cmux.browser-popup" }) {
#if DEBUG
dlog("shortcut.cmdW route=browserPopup")
#endif
targetWindow.performClose(nil)
return true
} else if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
targetWindow.identifier?.rawValue == "cmux.settings" {
targetWindow.performClose(nil)
} else {
Expand Down
124 changes: 105 additions & 19 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,9 @@ final class BrowserPanel: Panel, ObservableObject {
/// Shared process pool for cookie sharing across all browser panels
private static let sharedProcessPool = WKProcessPool()

/// Popup windows owned by this panel (for lifecycle cleanup)
private var popupControllers: [BrowserPopupWindowController] = []

static let telemetryHookBootstrapScriptSource = """
(() => {
if (window.__cmuxHooksInstalled) return true;
Expand Down Expand Up @@ -1912,6 +1915,9 @@ final class BrowserPanel: Panel, ObservableObject {
self?.endDownloadActivity()
}
}
webView.onContextMenuOpenLinkInNewTab = { [weak self] url in
self?.openLinkInNewTab(url: url)
}
webView.navigationDelegate = navigationDelegate
webView.uiDelegate = uiDelegate
setupObservers(for: webView)
Expand Down Expand Up @@ -1988,6 +1994,9 @@ final class BrowserPanel: Panel, ObservableObject {
browserUIDelegate.requestNavigation = { [weak self] request, intent in
self?.requestNavigation(request, intent: intent)
}
browserUIDelegate.openPopup = { [weak self] configuration, windowFeatures in
self?.createFloatingPopup(configuration: configuration, windowFeatures: windowFeatures)
}
self.uiDelegate = browserUIDelegate

bindWebView(webView)
Expand Down Expand Up @@ -2248,6 +2257,17 @@ final class BrowserPanel: Panel, ObservableObject {
// Ensure we don't keep a hidden WKWebView (or its content view) as first responder while
// bonsplit/SwiftUI reshuffles views during close.
unfocus()

// Snapshot first: popup close unregisters itself from popupControllers.
let popupsToClose = popupControllers
popupControllers.removeAll()

// Close all owned popup windows before tearing down delegates
for popup in popupsToClose {
popup.closeAllChildPopups()
popup.closePopup()
}

webView.stopLoading()
webView.navigationDelegate = nil
webView.uiDelegate = nil
Expand All @@ -2259,6 +2279,25 @@ final class BrowserPanel: Panel, ObservableObject {
faviconTask = nil
}

// MARK: - Popup window management

func createFloatingPopup(
configuration: WKWebViewConfiguration,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
let controller = BrowserPopupWindowController(
configuration: configuration,
windowFeatures: windowFeatures,
openerPanel: self
)
popupControllers.append(controller)
return controller.webView
}

func removePopupController(_ controller: BrowserPopupWindowController) {
popupControllers.removeAll { $0 === controller }
}

private func refreshFavicon(from webView: WKWebView) {
faviconTask?.cancel()
faviconTask = nil
Expand Down Expand Up @@ -3686,7 +3725,7 @@ private extension NSObject {

/// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI
/// during WebKit callbacks), then showing NSSavePanel after the download finishes.
private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate {
class BrowserDownloadDelegate: NSObject, WKDownloadDelegate {
private struct DownloadState {
let tempURL: URL
let suggestedFilename: String
Expand Down Expand Up @@ -3844,6 +3883,25 @@ func browserNavigationShouldOpenInNewTab(
return false
}

func browserNavigationShouldCreatePopup(
navigationType: WKNavigationType,
modifierFlags: NSEvent.ModifierFlags,
buttonNumber: Int,
hasRecentMiddleClickIntent: Bool = false,
currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type,
currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber
) -> Bool {
let isUserNewTab = browserNavigationShouldOpenInNewTab(
navigationType: navigationType,
modifierFlags: modifierFlags,
buttonNumber: buttonNumber,
hasRecentMiddleClickIntent: hasRecentMiddleClickIntent,
currentEventType: currentEventType,
currentEventButtonNumber: currentEventButtonNumber
)
return navigationType == .other && !isUserNewTab
}

private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
var didFinish: ((WKWebView) -> Void)?
var didFailNavigation: ((WKWebView, String) -> Void)?
Expand Down Expand Up @@ -4081,7 +4139,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
return
}

// target=_blank or window.open() — open in a new tab.
// Catch-all for nil-target navigations where createWebViewWith
// returned nil: external URLs, user-initiated new-window actions
// (target=_blank, context menu) that fall through the classifier,
// or when popup creation is unavailable.
if navigationAction.targetFrame == nil,
let url = navigationAction.request.url {
#if DEBUG
Expand Down Expand Up @@ -4173,6 +4234,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
private class BrowserUIDelegate: NSObject, WKUIDelegate {
var openInNewTab: ((URL) -> Void)?
var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)?
var openPopup: ((WKWebViewConfiguration, WKWindowFeatures) -> WKWebView?)?

private func javaScriptDialogTitle(for webView: WKWebView) -> String {
if let absolute = webView.url?.absoluteString, !absolute.isEmpty {
Expand All @@ -4193,39 +4255,63 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
completion(alert.runModal())
}

/// Returning nil tells WebKit not to open a new window.
/// createWebViewWith is only called when the page requests a new window
/// (window.open(), target=_blank, etc.). Always open in a new tab.
/// Called when the page requests a new window (window.open(), target=_blank, etc.).
///
/// Returns a live popup WKWebView created with WebKit's supplied configuration
/// to preserve popup browsing-context semantics (window.opener, postMessage).
/// Falls back to new-tab behavior only if popup creation is unavailable.
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
// createWebViewWith is only called when the page requests a new window,
// so always treat as new-tab intent regardless of modifiers/button.
#if DEBUG
let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil"
let navType = String(describing: navigationAction.navigationType)
dlog(
"browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " +
"mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " +
"eventType=\(currentEventType) eventButton=\(currentEventButton) " +
"openInNewTab=1"
"eventType=\(currentEventType) eventButton=\(currentEventButton)"
)
#endif
if let url = navigationAction.request.url {
if browserShouldOpenURLExternally(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
}
#if DEBUG
dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
return nil
// External URL schemes → hand off to macOS, don't create a popup
if let url = navigationAction.request.url,
browserShouldOpenURLExternally(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
}
#if DEBUG
dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
return nil
}

// Classifier: only scripted requests (window.open()) get popup windows.
// User-initiated actions (link clicks, context menu "Open Link in New Tab",
// Cmd+click, middle-click) fall through to existing new-tab behavior.
//
// WebKit sometimes delivers .other for Cmd+click / middle-click, so we
// reuse browserNavigationShouldOpenInNewTab to recover user intent before
// treating .other as a scripted popup.
let isScriptedPopup = browserNavigationShouldCreatePopup(
navigationType: navigationAction.navigationType,
modifierFlags: navigationAction.modifierFlags,
buttonNumber: navigationAction.buttonNumber,
hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView)
)

if isScriptedPopup, let popupWebView = openPopup?(configuration, windowFeatures) {
#if DEBUG
dlog("browser.nav.createWebView.action kind=popup")
#endif
return popupWebView
}

// Fallback: open in new tab (no opener linkage)
if let url = navigationAction.request.url {
if let requestNavigation {
let intent: BrowserInsecureHTTPNavigationIntent = .newTab
#if DEBUG
Expand Down
Loading