Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -180,6 +181,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 @@ -387,6 +389,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 @@ -659,6 +662,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 @@ -7276,6 +7276,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 @@ -8350,7 +8350,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,
cmuxWindowShouldOwnCloseShortcut(targetWindow) {
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 @@ -1268,6 +1268,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 @@ -2014,6 +2017,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 @@ -2097,6 +2103,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 @@ -2373,6 +2382,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 @@ -2384,6 +2404,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 @@ -4438,7 +4477,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 @@ -4596,6 +4635,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 @@ -4833,7 +4891,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 @@ -4925,6 +4986,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 @@ -4945,39 +5007,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
Loading