From 03e67b84be87cb9ea3ecd3f50adecc99ec96860e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:31:21 -0700 Subject: [PATCH 01/12] feat: preview terminal links in embedded browser --- .../Keys/BrowserCatalogSection.swift | 12 + .../CuratedSettingEntry+Default.swift | 7 + .../Sections/BrowserSection.swift | 42 ++- .../SettingsRowAnchorResolutionTests.swift | 1 + Resources/Localizable.xcstrings | 68 +++++ Sources/CmuxSettingsJSONPathSupport.swift | 1 + .../DockSplitStore+TerminalLinkOpening.swift | 15 +- Sources/GhosttyTerminalView.swift | 8 + Sources/GhosttyTerminalViewSupport.swift | 279 ++++++++++++++++-- ...rdShortcutSettingsFileStore+Template.swift | 1 + .../KeyboardShortcutSettingsFileStore.swift | 9 + ...rowserPanel+PrewarmedWebViewAdoption.swift | 7 +- Sources/Panels/BrowserPanel.swift | 36 ++- .../Panels/BrowserPrewarmedWebViewPool.swift | 125 ++++++-- Sources/SettingsNavigation.swift | 2 + Sources/SettingsSearchAliases.swift | 1 + Sources/TerminalLinkOpenContainer.swift | 1 + Sources/TerminalLinkOpenCoordinator.swift | 23 ++ Sources/TerminalLinkPreviewController.swift | 175 +++++++++++ Sources/Workspace+TerminalLinkOpening.swift | 27 +- Sources/Workspace.swift | 2 +- cmux.xcodeproj/project.pbxproj | 8 + .../BrowserPrewarmedWebViewPoolTests.swift | 176 ++++++++++- .../TerminalLinkOpenCoordinatorTests.swift | 88 ++++++ .../TerminalLinkPreviewSettingsTests.swift | 117 ++++++++ docs/ghostty-fork.md | 32 +- ghostty | 2 +- skills/cmux-settings/references/all-keys.md | 1 + web/data/cmux.schema.json | 8 + web/messages/en.json | 3 +- web/messages/ja.json | 3 +- 31 files changed, 1193 insertions(+), 87 deletions(-) create mode 100644 Sources/TerminalLinkPreviewController.swift create mode 100644 cmuxTests/TerminalLinkPreviewSettingsTests.swift diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift index 353dbfb0e080..2b3fa931a994 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift @@ -2,6 +2,12 @@ import Foundation /// Settings under the dotted-id prefix `browser.*`. public struct BrowserCatalogSection: SettingCatalogSection { + /// The dwell range accepted before a terminal link preview appears. + public static let terminalLinkPreviewHoverDelayMillisecondsRange = 200...2_000 + + /// The default dwell balances deliberate intent against a responsive preview. + public static let defaultTerminalLinkPreviewHoverDelayMilliseconds = 650 + public let defaultSearchEngine = DefaultsKey( id: "browser.defaultSearchEngine", defaultValue: BrowserSearchSettingsStore.defaultSearchEngine, @@ -58,6 +64,12 @@ public struct BrowserCatalogSection: SettingCatalogSection { userDefaultsKey: "browserOpenTerminalLinksInCmuxBrowser" ) + public let terminalLinkPreviewHoverDelayMilliseconds = DefaultsKey( + id: "browser.terminalLinkPreviewHoverDelayMilliseconds", + defaultValue: Self.defaultTerminalLinkPreviewHoverDelayMilliseconds, + userDefaultsKey: "browserTerminalLinkPreviewHoverDelayMilliseconds" + ) + public let interceptTerminalOpenCommandInCmuxBrowser = DefaultsKey( id: "browser.interceptTerminalOpenCommandInCmuxBrowser", defaultValue: true, diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index e21cac1e3b31..6f18b6abae91 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -288,6 +288,13 @@ extension Array where Element == CuratedSettingEntry { synonyms: String(localized: "settings.search.alias.setting.browser.ask-where-to-save-downloads", defaultValue: "browser.askWhereToSaveDownloads downloads save panel folder attachments files pdf gmail") ), .init(section: .browser, id: "terminal-links", title: "Open Terminal Links in cmux Browser", synonyms: "browser.openTerminalLinksInCmuxBrowser click url terminal links open in browser href"), + .init( + section: .browser, + id: "terminal-link-preview-delay", + title: String(localized: "settings.browser.terminalLinkPreviewDelay", defaultValue: "Terminal Link Preview Delay"), + detailText: String(localized: "settings.browser.terminalLinkPreviewDelay.subtitle", defaultValue: "How long the pointer must rest on a terminal link before its live page preview appears."), + synonyms: String(localized: "settings.search.alias.setting.browser.terminal-link-preview-delay", defaultValue: "browser.terminalLinkPreviewHoverDelayMilliseconds terminal url link hover preview dwell delay milliseconds thumbnail web page") + ), .init(section: .browser, id: "intercept-open", title: "Intercept open http(s) in Terminal", synonyms: "browser.interceptTerminalOpenCommandInCmuxBrowser open command http https url terminal intercept"), .init(section: .browser, id: "host-whitelist", title: "Hosts to Open in Embedded Browser", synonyms: "browser.hostsToOpenInEmbeddedBrowser allowlist whitelist host wildcard domain embedded browser"), .init(section: .browser, id: "external-patterns", title: "URLs to Always Open Externally", synonyms: "browser.urlsToAlwaysOpenExternally denylist blocklist regex rules external default browser"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift index 9c38b72bbe78..d550a8989252 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift @@ -7,7 +7,7 @@ import SwiftUI /// row-for-row inside a single `SettingsCard`: Enable cmux Browser, /// Default Search Engine, conditional Custom Search Engine fields, /// Show Search Suggestions, Browser Theme, Browser Memory Saver + -/// Memory Saver Delay, Open Terminal Links / Intercept open, +/// Memory Saver Delay, Open Terminal Links + preview delay / Intercept open, /// conditional Hosts / External Patterns text editors, HTTP Hosts /// Allowed in Embedded Browser editor, Import Browser Data /// subsection, React Grab Version, Browsing History. @@ -27,6 +27,7 @@ public struct BrowserSection: View { @State private var discardDelay: DefaultsValueModel @State private var askWhereToSaveDownloads: DefaultsValueModel @State private var openTermLinks: DefaultsValueModel + @State private var terminalLinkPreviewDelay: DefaultsValueModel @State private var interceptOpen: DefaultsValueModel @State private var hosts: DefaultsValueModel @State private var external: DefaultsValueModel @@ -58,6 +59,7 @@ public struct BrowserSection: View { _discardDelay = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.hiddenWebViewDiscardDelaySeconds)) _askWhereToSaveDownloads = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.askWhereToSaveDownloads)) _openTermLinks = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.openTerminalLinksInCmuxBrowser)) + _terminalLinkPreviewDelay = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.terminalLinkPreviewHoverDelayMilliseconds)) _interceptOpen = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.interceptTerminalOpenCommandInCmuxBrowser)) _hosts = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.hostsToOpenInEmbeddedBrowser)) _external = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.urlsToAlwaysOpenExternally)) @@ -85,7 +87,7 @@ public struct BrowserSection: View { Button(String(localized: "settings.browser.history.clearDialog.cancel", defaultValue: "Cancel"), role: .cancel) {} } message: { Text(String(localized: "settings.browser.history.clearDialog.message", defaultValue: "This removes visited-page suggestions from the browser omnibar.")) - }.task { startSettingsObservation([disabled, engine, customName, customURL, suggestions, theme, discardEnabled, discardDelay, askWhereToSaveDownloads, openTermLinks, interceptOpen, hosts, external, httpAllowlist, importHint, reactGrab]) } + }.task { startSettingsObservation([disabled, engine, customName, customURL, suggestions, theme, discardEnabled, discardDelay, askWhereToSaveDownloads, openTermLinks, terminalLinkPreviewDelay, interceptOpen, hosts, external, httpAllowlist, importHint, reactGrab]) } } @ViewBuilder @@ -241,6 +243,34 @@ public struct BrowserSection: View { } SettingsCardDivider() + // Terminal Link Preview Delay + SettingsCardRow( + configurationReview: .json("browser.terminalLinkPreviewHoverDelayMilliseconds"), + String(localized: "settings.browser.terminalLinkPreviewDelay", defaultValue: "Terminal Link Preview Delay"), + subtitle: String(localized: "settings.browser.terminalLinkPreviewDelay.subtitle", defaultValue: "How long the pointer must rest on a terminal link before its live page preview appears."), + controlWidth: Self.columnWidth + ) { + HStack(spacing: 8) { + Text(formatTerminalLinkPreviewDelay(terminalLinkPreviewDelay.current)) + .cmuxFont(.body, design: .monospaced) + .monospacedDigit() + .frame(width: 64, alignment: .trailing) + Stepper( + "", + value: Binding( + get: { terminalLinkPreviewDelay.current }, + set: { terminalLinkPreviewDelay.set($0) } + ), + in: BrowserCatalogSection.terminalLinkPreviewHoverDelayMillisecondsRange, + step: 50 + ) + .labelsHidden() + } + .disabled(disabled.current || !openTermLinks.current) + .accessibilityIdentifier("SettingsBrowserTerminalLinkPreviewDelayStepper") + } + SettingsCardDivider() + // Intercept open SettingsCardRow( configurationReview: .json("browser.interceptTerminalOpenCommandInCmuxBrowser"), @@ -564,4 +594,12 @@ public struct BrowserSection: View { let format = String(localized: "settings.browser.hiddenWebViewDiscardDelay.minutesSeconds", defaultValue: "%lldm %llds") return String.localizedStringWithFormat(format, Int64(total / 60), Int64(total % 60)) } + + private func formatTerminalLinkPreviewDelay(_ milliseconds: Int) -> String { + let format = String( + localized: "settings.browser.terminalLinkPreviewDelay.milliseconds", + defaultValue: "%lld ms" + ) + return String.localizedStringWithFormat(format, Int64(milliseconds)) + } } diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 8fbc87182c93..bcdc2f639745 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -74,6 +74,7 @@ struct SettingsRowAnchorResolutionTests { "browser.hostsToOpenInEmbeddedBrowser", "browser.interceptTerminalOpenCommandInCmuxBrowser", "browser.openTerminalLinksInCmuxBrowser", + "browser.terminalLinkPreviewHoverDelayMilliseconds", "browser.reactGrabVersion", "browser.showSearchSuggestions", "browser.theme", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a47156acd108..a9612a90468a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -170589,6 +170589,57 @@ } } }, + "settings.browser.terminalLinkPreviewDelay": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal Link Preview Delay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルリンクのプレビュー遅延" + } + } + } + }, + "settings.browser.terminalLinkPreviewDelay.milliseconds": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld ms" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lldミリ秒" + } + } + } + }, + "settings.browser.terminalLinkPreviewDelay.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "How long the pointer must rest on a terminal link before its live page preview appears." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルリンクにポインタを置いてからライブページのプレビューが表示されるまでの時間です。" + } + } + } + }, "settings.browser.theme": { "extractionState": "manual", "localizations": { @@ -188035,6 +188086,23 @@ } } }, + "settings.search.alias.setting.browser.terminal-link-preview-delay": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "browser.terminalLinkPreviewHoverDelayMilliseconds terminal url link hover preview dwell delay milliseconds thumbnail web page" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "browser.terminalLinkPreviewHoverDelayMilliseconds ターミナル URL リンク ホバー プレビュー 待機 遅延 ミリ秒 サムネイル ウェブページ" + } + } + } + }, "settings.search.alias.setting.browser.terminal-links": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 024951974862..aee3b25eb9ac 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -506,6 +506,7 @@ extension CmuxSettingsFileStore { "browser.hiddenWebViewDiscardDelaySeconds", "browser.askWhereToSaveDownloads", "browser.openTerminalLinksInCmuxBrowser", + "browser.terminalLinkPreviewHoverDelayMilliseconds", "browser.interceptTerminalOpenCommandInCmuxBrowser", "browser.hostsToOpenInEmbeddedBrowser", "browser.urlsToAlwaysOpenExternally", diff --git a/Sources/DockSplitStore+TerminalLinkOpening.swift b/Sources/DockSplitStore+TerminalLinkOpening.swift index 89163a271159..62b075f5d1c3 100644 --- a/Sources/DockSplitStore+TerminalLinkOpening.swift +++ b/Sources/DockSplitStore+TerminalLinkOpening.swift @@ -14,6 +14,11 @@ extension DockSplitStore: TerminalLinkOpenContainer { detachedSurfaceTransfersByPanelId[sourcePanelId]?.isRemoteTerminal == true } + func terminalLinkBrowserProfileID(for sourcePanelId: UUID) -> UUID? { + guard paneId(forPanelId: sourcePanelId) != nil else { return nil } + return BrowserPanel.resolvedProfileID(requested: nil) + } + func deferTerminalFileLinkOpen( sourcePanelId _: UUID, filePath _: String, @@ -26,6 +31,12 @@ extension DockSplitStore: TerminalLinkOpenContainer { func openTerminalBrowserLink(url: URL, sourcePanelId: UUID) -> Bool { guard let sourcePane = paneId(forPanelId: sourcePanelId) else { return false } + let intendedProfileID = terminalLinkBrowserProfileID(for: sourcePanelId) + let preferredProfileID = intendedProfileID.flatMap { profileID in + BrowserPrewarmedWebViewPool.shared.hasEntry(url: url, profileID: profileID) + ? profileID + : nil + } if let targetPane = BrowserRightSidePaneResolver().preferredPane( from: sourcePane, in: bonsplitController @@ -34,7 +45,8 @@ extension DockSplitStore: TerminalLinkOpenContainer { kind: .browser, inPane: targetPane, url: url, - focus: true + focus: true, + preferredProfileID: preferredProfileID ) != nil } return newSplit( @@ -43,6 +55,7 @@ extension DockSplitStore: TerminalLinkOpenContainer { insertFirst: false, sourcePanelId: sourcePanelId, url: url, + preferredProfileID: preferredProfileID, focus: true ) != nil } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 20c2af815f51..9fbc1ad16472 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4120,6 +4120,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } else { surface.reconcileAttachedWindowIfNeeded(for: self) } + if let ghosttySurface = surface.surface { + ghostty_surface_set_unmodified_link_previews(ghosttySurface, true) + } surface.setKeyboardCopyModeActive(keyboardCopyModeActive) if !isAlreadyAttached { updateSurfaceSize() @@ -8231,6 +8234,7 @@ final class GhosttySurfaceScrollView: NSView { private let keyboardCopyModeBadgeIconView: NSImageView private let keyboardCopyModeBadgeLabel: NSTextField let linkHoverIndicatorView: TerminalLinkHoverIndicatorView + var terminalLinkPreviewController: TerminalLinkPreviewController? private let imageTransferIndicatorContainerView: NSView private let imageTransferIndicatorView: NSVisualEffectView private let imageTransferIndicatorSpinner: NSProgressIndicator @@ -8496,6 +8500,7 @@ final class GhosttySurfaceScrollView: NSView { documentView.addSubview(surfaceView) super.init(frame: .zero) + terminalLinkPreviewController = TerminalLinkPreviewController(view: linkHoverIndicatorView) wantsLayer = true layer?.masksToBounds = true @@ -8836,6 +8841,9 @@ final class GhosttySurfaceScrollView: NSView { observers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.forEach { NotificationCenter.default.removeObserver($0) } dropZoneOverlayView.removeFromSuperview() + MainActor.assumeIsolated { + terminalLinkPreviewController?.invalidate() + } cancelFocusRequest() } diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index 38bdf342283e..a93ad40e2ee6 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -11,8 +11,31 @@ final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { } final class TerminalLinkHoverIndicatorView: NSView { - private let backdrop = GhosttyPassthroughVisualEffectView(frame: .zero) - private let label = NSTextField(labelWithString: "") + private enum Metrics { + static let edgeInset: CGFloat = 12 + static let pointerGap: CGFloat = 16 + static let preferredWidth: CGFloat = 420 + static let preferredHeight: CGFloat = 286 + static let minimumWidth: CGFloat = 280 + static let minimumHeight: CGFloat = 180 + static let footerHeight: CGFloat = 34 + static let cornerRadius: CGFloat = 12 + } + + private let statusBackdrop = GhosttyPassthroughVisualEffectView(frame: .zero) + private let statusLabel = NSTextField(labelWithString: "") + private let previewShadowView = NSView(frame: .zero) + private let previewBackdrop = GhosttyPassthroughVisualEffectView(frame: .zero) + private let webViewHost = NSView(frame: .zero) + private let loadingBackdrop = NSView(frame: .zero) + private let loadingSpinner = NSProgressIndicator(frame: .zero) + private let footerIcon = NSImageView(frame: .zero) + private let footerLabel = NSTextField(labelWithString: "") + private var previewAnchor = NSPoint.zero + private(set) var previewURL: URL? + + var previewWebViewHost: NSView { webViewHost } + var isPreviewVisible: Bool { !previewShadowView.isHidden } override var acceptsFirstResponder: Bool { false } @@ -24,46 +47,223 @@ final class TerminalLinkHoverIndicatorView: NSView { super.init(frame: frameRect) isHidden = true - backdrop.translatesAutoresizingMaskIntoConstraints = false - backdrop.material = .hudWindow - backdrop.blendingMode = .withinWindow - backdrop.state = .active - backdrop.wantsLayer = true - backdrop.layer?.cornerRadius = 6 - backdrop.layer?.masksToBounds = true - backdrop.layer?.borderWidth = 1 - backdrop.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor - backdrop.alphaValue = 0.96 - - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .monospacedSystemFont(ofSize: 11, weight: .medium) - label.textColor = .labelColor - label.lineBreakMode = .byTruncatingMiddle - label.maximumNumberOfLines = 1 - label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - addSubview(backdrop) - backdrop.addSubview(label) + statusBackdrop.translatesAutoresizingMaskIntoConstraints = false + statusBackdrop.material = .hudWindow + statusBackdrop.blendingMode = .withinWindow + statusBackdrop.state = .active + statusBackdrop.wantsLayer = true + statusBackdrop.layer?.cornerRadius = 6 + statusBackdrop.layer?.masksToBounds = true + statusBackdrop.layer?.borderWidth = 1 + statusBackdrop.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor + statusBackdrop.alphaValue = 0.96 + + statusLabel.translatesAutoresizingMaskIntoConstraints = false + statusLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium) + statusLabel.textColor = .labelColor + statusLabel.lineBreakMode = .byTruncatingMiddle + statusLabel.maximumNumberOfLines = 1 + statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + addSubview(statusBackdrop) + statusBackdrop.addSubview(statusLabel) NSLayoutConstraint.activate([ - backdrop.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), - backdrop.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), - backdrop.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), - label.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor, constant: 8), - label.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor, constant: -8), - label.topAnchor.constraint(equalTo: backdrop.topAnchor, constant: 5), - label.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor, constant: -5), + statusBackdrop.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + statusBackdrop.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + statusBackdrop.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), + statusLabel.leadingAnchor.constraint(equalTo: statusBackdrop.leadingAnchor, constant: 8), + statusLabel.trailingAnchor.constraint(equalTo: statusBackdrop.trailingAnchor, constant: -8), + statusLabel.topAnchor.constraint(equalTo: statusBackdrop.topAnchor, constant: 5), + statusLabel.bottomAnchor.constraint(equalTo: statusBackdrop.bottomAnchor, constant: -5), ]) + + configurePreviewCard() } required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } + override func layout() { + super.layout() + guard previewURL != nil else { return } + layoutPreviewCard(at: previewAnchor) + } + func setURL(_ url: String?) { let url = url?.isEmpty == false ? url : nil - label.stringValue = url ?? "" - label.setAccessibilityLabel(url) + statusLabel.stringValue = url ?? "" + statusLabel.setAccessibilityLabel(url) + statusBackdrop.isHidden = url == nil || isPreviewVisible isHidden = url == nil + if url == nil { + dismissPreview(animated: false) + } + } + + @discardableResult + func preparePreview(url: URL, at anchor: NSPoint) -> Bool { + guard availablePreviewSize() != nil else { return false } + previewURL = url + previewAnchor = anchor + footerLabel.stringValue = url.absoluteString + footerLabel.setAccessibilityLabel(url.absoluteString) + footerIcon.image = NSImage( + systemSymbolName: url.scheme?.lowercased() == "https" ? "lock.fill" : "network", + accessibilityDescription: nil + ) + layoutPreviewCard(at: anchor) + isHidden = false + statusBackdrop.isHidden = true + previewShadowView.layer?.removeAllAnimations() + previewShadowView.alphaValue = 0 + previewShadowView.isHidden = false + setPreviewLoadState(.loading) + guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { + previewShadowView.alphaValue = 1 + return true + } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.14 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + previewShadowView.animator().alphaValue = 1 + } + return true + } + + func setPreviewLoadState(_ state: BrowserPrewarmedWebViewPool.LoadState) { + let isLoading = state == .loading + loadingBackdrop.isHidden = !isLoading + if isLoading { + loadingSpinner.startAnimation(nil) + } else { + loadingSpinner.stopAnimation(nil) + } + } + + func dismissPreview(animated: Bool = true) { + previewURL = nil + loadingSpinner.stopAnimation(nil) + statusBackdrop.isHidden = statusLabel.stringValue.isEmpty + guard !previewShadowView.isHidden else { return } + previewShadowView.layer?.removeAllAnimations() + guard animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { + previewShadowView.alphaValue = 0 + previewShadowView.isHidden = true + return + } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.1 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + previewShadowView.animator().alphaValue = 0 + } completionHandler: { [weak self] in + guard let self, self.previewURL == nil else { return } + self.previewShadowView.isHidden = true + } + } + + private func configurePreviewCard() { + previewShadowView.wantsLayer = true + previewShadowView.layer?.shadowColor = NSColor.black.cgColor + previewShadowView.layer?.shadowOpacity = 0.34 + previewShadowView.layer?.shadowRadius = 18 + previewShadowView.layer?.shadowOffset = NSSize(width: 0, height: -5) + previewShadowView.isHidden = true + previewShadowView.alphaValue = 0 + previewShadowView.setAccessibilityIdentifier("TerminalLinkPreviewCard") + + previewBackdrop.material = .popover + previewBackdrop.blendingMode = .withinWindow + previewBackdrop.state = .active + previewBackdrop.wantsLayer = true + previewBackdrop.layer?.cornerRadius = Metrics.cornerRadius + previewBackdrop.layer?.cornerCurve = .continuous + previewBackdrop.layer?.masksToBounds = true + previewBackdrop.layer?.borderWidth = 1 + previewBackdrop.layer?.borderColor = NSColor.white.withAlphaComponent(0.16).cgColor + + webViewHost.wantsLayer = true + webViewHost.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + webViewHost.setAccessibilityIdentifier("TerminalLinkPreviewWebViewHost") + + loadingBackdrop.wantsLayer = true + loadingBackdrop.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.82).cgColor + + loadingSpinner.style = .spinning + loadingSpinner.controlSize = .small + loadingSpinner.isIndeterminate = true + + footerIcon.image = NSImage(systemSymbolName: "lock.fill", accessibilityDescription: nil) + footerIcon.contentTintColor = .secondaryLabelColor + footerIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 10, weight: .medium) + + footerLabel.font = .systemFont(ofSize: 11, weight: .medium) + footerLabel.textColor = .secondaryLabelColor + footerLabel.lineBreakMode = .byTruncatingMiddle + footerLabel.maximumNumberOfLines = 1 + + addSubview(previewShadowView) + previewShadowView.addSubview(previewBackdrop) + previewBackdrop.addSubview(webViewHost) + previewBackdrop.addSubview(footerIcon) + previewBackdrop.addSubview(footerLabel) + previewBackdrop.addSubview(loadingBackdrop) + loadingBackdrop.addSubview(loadingSpinner) + } + + private func availablePreviewSize() -> NSSize? { + let width = min(Metrics.preferredWidth, bounds.width - Metrics.edgeInset * 2) + let height = min(Metrics.preferredHeight, bounds.height - Metrics.edgeInset * 2) + guard width >= Metrics.minimumWidth, height >= Metrics.minimumHeight else { return nil } + return NSSize(width: width, height: height) + } + + private func layoutPreviewCard(at anchor: NSPoint) { + guard let size = availablePreviewSize() else { + dismissPreview(animated: false) + return + } + + let usableBounds = bounds.insetBy(dx: Metrics.edgeInset, dy: Metrics.edgeInset) + var x = anchor.x + Metrics.pointerGap + if x + size.width > usableBounds.maxX { + x = anchor.x - Metrics.pointerGap - size.width + } + x = min(max(x, usableBounds.minX), usableBounds.maxX - size.width) + + var y = anchor.y + Metrics.pointerGap + if y + size.height > usableBounds.maxY { + y = anchor.y - Metrics.pointerGap - size.height + } + y = min(max(y, usableBounds.minY), usableBounds.maxY - size.height) + + previewShadowView.frame = NSRect(origin: NSPoint(x: x, y: y), size: size) + previewShadowView.layer?.shadowPath = CGPath( + roundedRect: previewShadowView.bounds, + cornerWidth: Metrics.cornerRadius, + cornerHeight: Metrics.cornerRadius, + transform: nil + ) + previewBackdrop.frame = previewShadowView.bounds + webViewHost.frame = NSRect( + x: 0, + y: Metrics.footerHeight, + width: size.width, + height: size.height - Metrics.footerHeight + ) + loadingBackdrop.frame = webViewHost.frame + loadingSpinner.sizeToFit() + loadingSpinner.frame.origin = NSPoint( + x: loadingBackdrop.bounds.midX - loadingSpinner.frame.width / 2, + y: loadingBackdrop.bounds.midY - loadingSpinner.frame.height / 2 + ) + footerIcon.frame = NSRect(x: 12, y: 10, width: 12, height: 12) + footerLabel.frame = NSRect( + x: 30, + y: 8, + width: max(0, size.width - 42), + height: 17 + ) } } @@ -78,7 +278,22 @@ extension GhosttySurfaceScrollView { DispatchQueue.main.async { [weak self] in self?.setLinkHoverURL(url) } return } - linkHoverIndicatorView.setURL(url) + let anchor: NSPoint + if let window { + anchor = linkHoverIndicatorView.convert( + window.mouseLocationOutsideOfEventStream, + from: nil + ) + } else { + anchor = NSPoint(x: linkHoverIndicatorView.bounds.midX, y: linkHoverIndicatorView.bounds.midY) + } + let terminalSurface = surfaceView.terminalSurface + terminalLinkPreviewController?.update( + rawURL: url, + sourceWorkspaceId: terminalSurface?.tabId, + sourcePanelId: terminalSurface?.id, + anchorPoint: anchor + ) } } diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 55415e2b4ca8..c1cb8014c88d 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -206,6 +206,7 @@ extension CmuxSettingsFileStore { "hiddenWebViewDiscardDelaySeconds": BrowserHiddenWebViewDiscardPolicy.defaultHiddenDelay, "askWhereToSaveDownloads": SettingCatalog().browser.askWhereToSaveDownloads.defaultValue, "openTerminalLinksInCmuxBrowser": BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser, + "terminalLinkPreviewHoverDelayMilliseconds": BrowserLinkOpenSettings.defaultTerminalLinkPreviewHoverDelayMilliseconds, "interceptTerminalOpenCommandInCmuxBrowser": BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser, "hostsToOpenInEmbeddedBrowser": [String](), "urlsToAlwaysOpenExternally": [String](), diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 14ac91f0a3ca..0bcb8098edcc 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -908,6 +908,15 @@ final class CmuxSettingsFileStore { } snapshot.managedUserDefaults[BrowserHiddenWebViewDiscardPolicy.hiddenDelayKey] = .double(delay) } + if let value = jsonInt(section["terminalLinkPreviewHoverDelayMilliseconds"]) { + guard BrowserCatalogSection.terminalLinkPreviewHoverDelayMillisecondsRange.contains(value) else { + logInvalid("browser.terminalLinkPreviewHoverDelayMilliseconds", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[ + BrowserCatalogSection().terminalLinkPreviewHoverDelayMilliseconds.userDefaultsKey + ] = .int(value) + } applyNormalizedStringArraySettings(BrowserSettingsFileMapping.stringArraySettings, from: section, sourcePath: sourcePath, snapshot: &snapshot) } diff --git a/Sources/Panels/BrowserPanel+PrewarmedWebViewAdoption.swift b/Sources/Panels/BrowserPanel+PrewarmedWebViewAdoption.swift index e291d013a8ba..aa16b15f1932 100644 --- a/Sources/Panels/BrowserPanel+PrewarmedWebViewAdoption.swift +++ b/Sources/Panels/BrowserPanel+PrewarmedWebViewAdoption.swift @@ -16,8 +16,9 @@ extension BrowserPanel { } /// A prewarmed webview matching this panel's initial navigation exactly, - /// or nil for a normal cold load. Remote workspaces, request-based - /// navigations, and render-deferred panels never adopt. + /// including its current load state, or nil for a normal cold load. Remote + /// workspaces, request-based navigations, and render-deferred panels never + /// adopt. static func claimedPrewarmedWebView( isRemoteWorkspace: Bool, initialRequest: URLRequest?, @@ -25,7 +26,7 @@ extension BrowserPanel { initialURL: URL?, profileID: UUID, websiteDataStore: WKWebsiteDataStore - ) -> CmuxWebView? { + ) -> BrowserPrewarmedWebViewPool.Claim? { guard !isRemoteWorkspace, initialRequest == nil, renderInitialNavigation, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index a98b538fd0a7..498b7f0fb140 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -443,6 +443,10 @@ final class BrowserProfileStore: ObservableObject { enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true + static let terminalLinkPreviewHoverDelayMillisecondsKey = + BrowserCatalogSection().terminalLinkPreviewHoverDelayMilliseconds.userDefaultsKey + static let defaultTerminalLinkPreviewHoverDelayMilliseconds = + BrowserCatalogSection.defaultTerminalLinkPreviewHoverDelayMilliseconds static let openSidebarPullRequestLinksInCmuxBrowserKey = "browserOpenSidebarPullRequestLinksInCmuxBrowser" static let defaultOpenSidebarPullRequestLinksInCmuxBrowser: Bool = true @@ -466,6 +470,20 @@ enum BrowserLinkOpenSettings { return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) } + static func terminalLinkPreviewHoverDelayMilliseconds( + defaults: UserDefaults = .standard + ) -> Int { + guard let stored = defaults.object( + forKey: terminalLinkPreviewHoverDelayMillisecondsKey + ) as? NSNumber else { + return defaultTerminalLinkPreviewHoverDelayMilliseconds + } + let value = stored.intValue + return BrowserCatalogSection.terminalLinkPreviewHoverDelayMillisecondsRange.contains(value) + ? value + : defaultTerminalLinkPreviewHoverDelayMilliseconds + } + static func openSidebarPullRequestLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { guard BrowserAvailabilitySettings.isEnabled(defaults: defaults) else { return false } if defaults.object(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) == nil { @@ -4141,7 +4159,7 @@ final class BrowserPanel: Panel, ObservableObject { self.preservesExplicitEphemeralWebsiteDataStore = preservesExplicitEphemeralWebsiteDataStore let webView: CmuxWebView - var adoptedPrewarmedWebView = false + let adoptedPrewarmedLoadState: BrowserPrewarmedWebViewPool.LoadState? if let prewarmed = Self.claimedPrewarmedWebView( isRemoteWorkspace: isRemoteWorkspace, initialRequest: initialRequest, @@ -4150,13 +4168,14 @@ final class BrowserPanel: Panel, ObservableObject { profileID: resolvedProfileID, websiteDataStore: websiteDataStore ) { - webView = prewarmed - adoptedPrewarmedWebView = true + webView = prewarmed.webView + adoptedPrewarmedLoadState = prewarmed.loadState } else { webView = Self.makeWebView( profileID: resolvedProfileID, websiteDataStore: websiteDataStore ) + adoptedPrewarmedLoadState = nil } self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } @@ -4404,12 +4423,13 @@ final class BrowserPanel: Panel, ObservableObject { currentURL = url shouldRenderWebView = renderInitialNavigation guard renderInitialNavigation else { return } - if adoptedPrewarmedWebView { - // Already navigated while hidden; record for recovery paths. + if let adoptedPrewarmedLoadState { + // Navigation already started while hidden or in the terminal + // preview. Record it for recovery paths and let the newly bound + // panel delegate receive the remaining navigation callbacks. navigationDelegate?.recordAttemptedRequest(URLRequest(url: url), displayURL: url) - // The pool only vends finished loads; seed the committed flag so - // blank-shell healing never reloads the adopted page on reveal. - hasCommittedDocumentSinceWebViewReplacement = true + hasCommittedDocumentSinceWebViewReplacement = + adoptedPrewarmedLoadState == .finished || webView.url != nil refreshBackgroundAppearance() } else { navigate(to: url) diff --git a/Sources/Panels/BrowserPrewarmedWebViewPool.swift b/Sources/Panels/BrowserPrewarmedWebViewPool.swift index bbea1e659c16..8605d1d462aa 100644 --- a/Sources/Panels/BrowserPrewarmedWebViewPool.swift +++ b/Sources/Panels/BrowserPrewarmedWebViewPool.swift @@ -11,28 +11,46 @@ import WebKit /// of starting a cold WebKit process launch plus network load, so the panel /// shows the finished page immediately. /// -/// The pool never renders on screen: the webview lives in an offscreen, -/// non-activating borderless window (the same hosting recipe as -/// `BrowserPanel.ensureBackgroundPreloadHostIfNeeded`). The entry expires -/// `timeToLive` after the last prewarm request and is discarded on load -/// failure or web-content process termination, so a hover that never becomes -/// a click costs one background page load and is reclaimed. +/// The webview normally lives in an offscreen, non-activating borderless +/// window (the same hosting recipe as +/// `BrowserPanel.ensureBackgroundPreloadHostIfNeeded`). A terminal link +/// preview may temporarily attach that exact webview to an on-screen preview +/// host without consuming it. A matching ``BrowserPanel`` can then claim the +/// view, including while navigation is still in flight, so opening a previewed +/// link never starts a second WebKit process or network load. +/// +/// The entry expires `timeToLive` after the last prewarm request and is +/// discarded on load failure or web-content process termination, so a hover +/// that never becomes a click costs one background page load and is reclaimed. @MainActor final class BrowserPrewarmedWebViewPool: NSObject { static let shared = BrowserPrewarmedWebViewPool() - private enum LoadState { + enum LoadState: Equatable { case loading case finished case failed } + struct Claim { + let webView: CmuxWebView + let loadState: LoadState + } + + struct PreviewAttachment: Equatable { + let id: UUID + let loadState: LoadState + } + private struct Entry { let webView: CmuxWebView let url: URL let profileID: UUID let hostWindow: NSWindow var loadState: LoadState + var previewAttachmentID: UUID? + var previewStateDidChange: (@MainActor (LoadState) -> Void)? + var previewDidDismiss: (@MainActor () -> Void)? } private var entry: Entry? @@ -88,6 +106,7 @@ final class BrowserPrewarmedWebViewPool: NSObject { discard(reason: "replaced") let webView = makeWebView(profileID) + _ = webView.cmuxSetPageAudioMuted(true) webView.navigationDelegate = self let hostWindow = Self.makeHiddenHostWindow(for: webView) entry = Entry( @@ -95,7 +114,10 @@ final class BrowserPrewarmedWebViewPool: NSObject { url: url, profileID: profileID, hostWindow: hostWindow, - loadState: .loading + loadState: .loading, + previewAttachmentID: nil, + previewStateDidChange: nil, + previewDidDismiss: nil ) startLoad(webView, URLRequest(url: url)) scheduleExpiry() @@ -104,44 +126,95 @@ final class BrowserPrewarmedWebViewPool: NSObject { #endif } + /// Temporarily renders a matching entry inside a non-interactive preview + /// host. The entry remains claimable by a real browser panel. + func attachPreview( + url: URL, + profileID: UUID, + to hostView: NSView, + stateDidChange: @escaping @MainActor (LoadState) -> Void, + didDismiss: @escaping @MainActor () -> Void + ) -> PreviewAttachment? { + guard var entry, + entry.url.absoluteString == url.absoluteString, + entry.profileID == profileID, + entry.loadState != .failed else { + return nil + } + + entry.previewDidDismiss?() + let attachmentID = UUID() + entry.previewAttachmentID = attachmentID + entry.previewStateDidChange = stateDidChange + entry.previewDidDismiss = didDismiss + Self.host(entry.webView, in: hostView) + self.entry = entry + scheduleExpiry() + stateDidChange(entry.loadState) +#if DEBUG + cmuxDebugLog("browser.prewarmPool.preview.attach url=\(url.absoluteString)") +#endif + return PreviewAttachment(id: attachmentID, loadState: entry.loadState) + } + + /// Returns a previewed view to its hidden host without discarding the + /// loaded page. A later click may still claim it until the TTL expires. + func detachPreview(_ attachment: PreviewAttachment) { + guard var entry, entry.previewAttachmentID == attachment.id else { return } + entry.previewAttachmentID = nil + entry.previewStateDidChange = nil + entry.previewDidDismiss = nil + if let hiddenHost = entry.hostWindow.contentView { + Self.host(entry.webView, in: hiddenHost) + entry.hostWindow.orderFrontRegardless() + } + self.entry = entry +#if DEBUG + cmuxDebugLog("browser.prewarmPool.preview.detach url=\(entry.url.absoluteString)") +#endif + } + /// Hands the prewarmed webview to a panel when it matches the requested - /// navigation, or returns nil for a normal cold load. The entry is - /// consumed either way: once a matching panel is being created, a - /// still-loading or failed entry is useless and would otherwise linger. - func claim(url: URL, profileID: UUID, websiteDataStore: WKWebsiteDataStore) -> CmuxWebView? { + /// navigation, or returns nil for a normal cold load. Both finished and + /// in-flight navigations are adopted so a click during preview loading does + /// not start over. + func claim(url: URL, profileID: UUID, websiteDataStore: WKWebsiteDataStore) -> Claim? { guard let entry, entry.url.absoluteString == url.absoluteString, entry.profileID == profileID else { return nil } - guard entry.loadState == .finished, + guard entry.loadState != .failed, entry.webView.configuration.websiteDataStore === websiteDataStore else { - discard(reason: entry.loadState == .finished ? "datastore-mismatch" : "not-finished") + discard(reason: entry.loadState == .failed ? "failed" : "datastore-mismatch") return nil } let webView = entry.webView + self.entry = nil + expiryTask?.cancel() + expiryTask = nil + entry.previewDidDismiss?() webView.navigationDelegate = nil + _ = webView.cmuxSetPageAudioMuted(false) webView.removeFromSuperview() webView.browserPortalPrepareForHiddenHostAdoption() entry.hostWindow.close() - self.entry = nil - expiryTask?.cancel() - expiryTask = nil #if DEBUG - cmuxDebugLog("browser.prewarmPool.claim url=\(url.absoluteString)") + cmuxDebugLog("browser.prewarmPool.claim url=\(url.absoluteString) state=\(entry.loadState)") #endif - return webView + return Claim(webView: webView, loadState: entry.loadState) } func discard(reason: String) { expiryTask?.cancel() expiryTask = nil guard let entry else { return } + self.entry = nil + entry.previewDidDismiss?() entry.webView.navigationDelegate = nil entry.webView.stopLoading() entry.webView.removeFromSuperview() entry.hostWindow.close() - self.entry = nil #if DEBUG cmuxDebugLog("browser.prewarmPool.discard reason=\(reason)") #endif @@ -188,18 +261,24 @@ final class BrowserPrewarmedWebViewPool: NSObject { window.isExcludedFromWindowsMenu = true let contentView = NSView(frame: frame) - webView.frame = contentView.bounds - webView.autoresizingMask = [.width, .height] - contentView.addSubview(webView) + host(webView, in: contentView) window.contentView = contentView window.orderFrontRegardless() return window } + private static func host(_ webView: WKWebView, in hostView: NSView) { + webView.removeFromSuperview() + webView.frame = hostView.bounds + webView.autoresizingMask = [.width, .height] + hostView.addSubview(webView) + } + private func updateLoadState(for webView: WKWebView, to state: LoadState) { guard var entry, entry.webView === webView else { return } entry.loadState = state self.entry = entry + entry.previewStateDidChange?(state) } } diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 01b48692a9fc..4c7ed851f231 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -455,6 +455,7 @@ enum SettingsSearchIndex { setting(.browser, "hidden-webview-discard-delay", String(localized: "settings.browser.hiddenWebViewDiscardDelay", defaultValue: "Hidden WebView Discard Delay"), "memory hidden tabs delay seconds discard"), setting(.browser, "ask-where-to-save-downloads", String(localized: "settings.browser.askWhereToSaveDownloads", defaultValue: "Ask Where to Save Downloads"), "downloads save panel download folder attachments files pdf gmail"), setting(.browser, "terminal-links", String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in cmux Browser"), "click links browser"), + setting(.browser, "terminal-link-preview-delay", String(localized: "settings.browser.terminalLinkPreviewDelay", defaultValue: "Terminal Link Preview Delay"), "url hover preview dwell delay milliseconds thumbnail web page"), setting(.browser, "intercept-open", String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"), "open command urls"), setting(.browser, "host-whitelist", String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), "hosts wildcard terminal links"), setting(.browser, "external-patterns", String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), "regex url rules default browser"), @@ -595,6 +596,7 @@ enum SettingsSearchIndex { "browser.hiddenWebViewDiscardDelaySeconds": settingID(for: .browser, idSuffix: "hidden-webview-discard-delay"), "browser.askWhereToSaveDownloads": settingID(for: .browser, idSuffix: "ask-where-to-save-downloads"), "browser.openTerminalLinksInCmuxBrowser": settingID(for: .browser, idSuffix: "terminal-links"), + "browser.terminalLinkPreviewHoverDelayMilliseconds": settingID(for: .browser, idSuffix: "terminal-link-preview-delay"), "browser.interceptTerminalOpenCommandInCmuxBrowser": settingID(for: .browser, idSuffix: "intercept-open"), "browser.hostsToOpenInEmbeddedBrowser": settingID(for: .browser, idSuffix: "host-whitelist"), "browser.urlsToAlwaysOpenExternally": settingID(for: .browser, idSuffix: "external-patterns"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index a355034c3e25..4b1580d8aeed 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -165,6 +165,7 @@ enum SettingsSearchAliasIndex { "browser:hidden-webview-discard-delay": localized("settings.search.alias.setting.browser.hidden-webview-discard-delay", defaultValue: "browser.hiddenWebViewDiscardDelaySeconds memory hidden tabs delay seconds discard unload"), "browser:ask-where-to-save-downloads": localized("settings.search.alias.setting.browser.ask-where-to-save-downloads", defaultValue: "browser.askWhereToSaveDownloads downloads save panel folder attachments files pdf gmail"), "browser:terminal-links": localized("settings.search.alias.setting.browser.terminal-links", defaultValue: "browser.openTerminalLinksInCmuxBrowser click url terminal links open in browser href"), + "browser:terminal-link-preview-delay": localized("settings.search.alias.setting.browser.terminal-link-preview-delay", defaultValue: "browser.terminalLinkPreviewHoverDelayMilliseconds terminal url link hover preview dwell delay milliseconds thumbnail web page"), "browser:intercept-open": localized("settings.search.alias.setting.browser.intercept-open", defaultValue: "browser.interceptTerminalOpenCommandInCmuxBrowser open command http https url terminal intercept"), "browser:host-whitelist": localized("settings.search.alias.setting.browser.host-whitelist", defaultValue: "browser.hostsToOpenInEmbeddedBrowser allowlist whitelist host wildcard domain embedded browser"), "browser:external-patterns": localized("settings.search.alias.setting.browser.external-patterns", defaultValue: "browser.urlsToAlwaysOpenExternally denylist blocklist regex rules external default browser"), diff --git a/Sources/TerminalLinkOpenContainer.swift b/Sources/TerminalLinkOpenContainer.swift index 02554bd417fe..b72705acbd1e 100644 --- a/Sources/TerminalLinkOpenContainer.swift +++ b/Sources/TerminalLinkOpenContainer.swift @@ -8,6 +8,7 @@ protocol TerminalLinkOpenContainer: AnyObject { func terminalLinkWorkingDirectory(for sourcePanelId: UUID) -> String? func terminalLinkIsRemoteTerminal(_ sourcePanelId: UUID) -> Bool + func terminalLinkBrowserProfileID(for sourcePanelId: UUID) -> UUID? @discardableResult func deferTerminalFileLinkOpen( diff --git a/Sources/TerminalLinkOpenCoordinator.swift b/Sources/TerminalLinkOpenCoordinator.swift index eb4965fe323c..3db721b3f432 100644 --- a/Sources/TerminalLinkOpenCoordinator.swift +++ b/Sources/TerminalLinkOpenCoordinator.swift @@ -7,6 +7,11 @@ import Foundation /// panel container currently owns the source terminal. @MainActor struct TerminalLinkOpenCoordinator { + struct PreviewTarget: Equatable { + let url: URL + let profileID: UUID + } + private let defaults: UserDefaults private let containerResolver: @MainActor (UUID?, UUID?) -> (any TerminalLinkOpenContainer)? private let externalOpen: @MainActor @Sendable (URL) -> Bool @@ -103,6 +108,24 @@ struct TerminalLinkOpenCoordinator { } } + /// Resolves a terminal hover to a page that can be preloaded locally and + /// later adopted by the exact embedded-browser route used on click. + func previewTarget(for request: TerminalLinkOpenRequest) -> PreviewTarget? { + guard BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults), + case let .embeddedBrowser(url)? = resolveTerminalOpenURLTarget(request.rawValue), + !BrowserLinkOpenSettings.shouldOpenExternally(url, defaults: defaults), + let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? ""), + BrowserLinkOpenSettings.hostMatchesWhitelist(host, defaults: defaults), + !browserShouldBlockInsecureHTTPURL(url, defaults: defaults), + let sourcePanelId = request.sourcePanelId, + let container = containerResolver(request.sourceWorkspaceId, sourcePanelId), + !container.terminalLinkIsRemoteTerminal(sourcePanelId), + let profileID = container.terminalLinkBrowserProfileID(for: sourcePanelId) else { + return nil + } + return PreviewTarget(url: url, profileID: profileID) + } + private func routeLocalFile( _ fileURL: URL, request: TerminalLinkOpenRequest, diff --git a/Sources/TerminalLinkPreviewController.swift b/Sources/TerminalLinkPreviewController.swift new file mode 100644 index 000000000000..b5b106fa4a26 --- /dev/null +++ b/Sources/TerminalLinkPreviewController.swift @@ -0,0 +1,175 @@ +import AppKit +import Foundation + +/// Turns Ghostty's URL-hover stream into one delayed, cancellable live-page +/// preview. The controller owns timing and policy; the pool owns the exact +/// webview that may later move into a real browser panel. +@MainActor +final class TerminalLinkPreviewController { + typealias TargetResolver = @MainActor (TerminalLinkOpenRequest) -> TerminalLinkOpenCoordinator.PreviewTarget? + typealias Prewarm = @MainActor (URL, UUID) -> Void + typealias Attach = @MainActor ( + URL, + UUID, + NSView, + @escaping @MainActor (BrowserPrewarmedWebViewPool.LoadState) -> Void, + @escaping @MainActor () -> Void + ) -> BrowserPrewarmedWebViewPool.PreviewAttachment? + typealias Detach = @MainActor (BrowserPrewarmedWebViewPool.PreviewAttachment) -> Void + typealias Sleep = @Sendable (Duration) async throws -> Void + + private weak var view: TerminalLinkHoverIndicatorView? + private let targetResolver: TargetResolver + private let prewarm: Prewarm + private let attach: Attach + private let detach: Detach + private let delayMilliseconds: @MainActor () -> Int + private let sleep: Sleep + + private var currentRawURL: String? + private var currentTarget: TerminalLinkOpenCoordinator.PreviewTarget? + private var currentAnchorPoint = NSPoint.zero + private var dwellTask: Task? + private var attachment: BrowserPrewarmedWebViewPool.PreviewAttachment? + private var generation: UInt64 = 0 + + init( + view: TerminalLinkHoverIndicatorView, + defaults: UserDefaults = .standard, + targetResolver: TargetResolver? = nil, + prewarm: @escaping Prewarm = { url, profileID in + BrowserPrewarmedWebViewPool.shared.prewarm(url: url, profileID: profileID) + }, + attach: @escaping Attach = { url, profileID, host, stateDidChange, didDismiss in + BrowserPrewarmedWebViewPool.shared.attachPreview( + url: url, + profileID: profileID, + to: host, + stateDidChange: stateDidChange, + didDismiss: didDismiss + ) + }, + detach: @escaping Detach = { attachment in + BrowserPrewarmedWebViewPool.shared.detachPreview(attachment) + }, + delayMilliseconds: (@MainActor () -> Int)? = nil, + sleep: @escaping Sleep = { duration in try await Task.sleep(for: duration) } + ) { + self.view = view + self.targetResolver = targetResolver ?? { request in + TerminalLinkOpenCoordinator(defaults: defaults).previewTarget(for: request) + } + self.prewarm = prewarm + self.attach = attach + self.detach = detach + self.delayMilliseconds = delayMilliseconds ?? { + BrowserLinkOpenSettings.terminalLinkPreviewHoverDelayMilliseconds(defaults: defaults) + } + self.sleep = sleep + } + + func update( + rawURL: String?, + sourceWorkspaceId: UUID?, + sourcePanelId: UUID?, + anchorPoint: NSPoint + ) { + let normalizedRawURL = rawURL?.isEmpty == false ? rawURL : nil + view?.setURL(normalizedRawURL) + + guard let normalizedRawURL else { + clear() + return + } + + let request = TerminalLinkOpenRequest( + rawValue: normalizedRawURL, + sourceWorkspaceId: sourceWorkspaceId, + sourcePanelId: sourcePanelId, + workingDirectory: nil + ) + let target = targetResolver(request) + + if currentRawURL == normalizedRawURL, currentTarget == target { + if view?.isPreviewVisible != true { + currentAnchorPoint = anchorPoint + } + return + } + + resetPreviewState() + currentRawURL = normalizedRawURL + currentTarget = target + currentAnchorPoint = anchorPoint + guard target != nil else { return } + + generation &+= 1 + let scheduledGeneration = generation + let duration = Duration.milliseconds(delayMilliseconds()) + let sleep = sleep + dwellTask = Task { @MainActor [weak self] in + do { + try await sleep(duration) + } catch { + return + } + guard !Task.isCancelled else { return } + self?.showPreview(generation: scheduledGeneration) + } + } + + func invalidate() { + clear() + } + + private func showPreview(generation scheduledGeneration: UInt64) { + guard generation == scheduledGeneration, + let target = currentTarget, + let view, + view.preparePreview(url: target.url, at: currentAnchorPoint) else { + return + } + + prewarm(target.url, target.profileID) + let attachment = attach( + target.url, + target.profileID, + view.previewWebViewHost, + { [weak self] state in + self?.view?.setPreviewLoadState(state) + }, + { [weak self] in + self?.poolDidDismiss(generation: scheduledGeneration) + } + ) + guard let attachment else { + view.dismissPreview() + return + } + self.attachment = attachment + } + + private func poolDidDismiss(generation dismissedGeneration: UInt64) { + guard generation == dismissedGeneration else { return } + attachment = nil + view?.dismissPreview(animated: false) + } + + private func clear() { + resetPreviewState() + currentRawURL = nil + currentTarget = nil + view?.setURL(nil) + } + + private func resetPreviewState() { + generation &+= 1 + dwellTask?.cancel() + dwellTask = nil + view?.dismissPreview(animated: false) + if let attachment { + self.attachment = nil + detach(attachment) + } + } +} diff --git a/Sources/Workspace+TerminalLinkOpening.swift b/Sources/Workspace+TerminalLinkOpening.swift index e8922c2f5055..dab447b8715d 100644 --- a/Sources/Workspace+TerminalLinkOpening.swift +++ b/Sources/Workspace+TerminalLinkOpening.swift @@ -22,6 +22,17 @@ extension Workspace: TerminalLinkOpenContainer { ) } + func terminalLinkBrowserProfileID(for sourcePanelId: UUID) -> UUID? { + guard let target = surfaceOwnershipTarget(for: sourcePanelId) else { return nil } + let profileSourcePanelID: UUID? + if let targetPane = preferredRightSideTargetPane(fromPanelId: target.containerPanelID) { + profileSourcePanelID = effectiveSelectedPanelId(inPane: targetPane) + } else { + profileSourcePanelID = target.containerPanelID + } + return resolvedNewBrowserProfileID(sourcePanelId: profileSourcePanelID) + } + func deferTerminalFileLinkOpen( sourcePanelId: UUID, filePath: String, @@ -40,13 +51,25 @@ extension Workspace: TerminalLinkOpenContainer { func openTerminalBrowserLink(url: URL, sourcePanelId: UUID) -> Bool { guard let target = surfaceOwnershipTarget(for: sourcePanelId) else { return false } + let intendedProfileID = terminalLinkBrowserProfileID(for: sourcePanelId) + let preferredProfileID = intendedProfileID.flatMap { profileID in + BrowserPrewarmedWebViewPool.shared.hasEntry(url: url, profileID: profileID) + ? profileID + : nil + } if let targetPane = preferredRightSideTargetPane(fromPanelId: target.containerPanelID) { - return newBrowserSurface(inPane: targetPane, url: url, focus: true) != nil + return newBrowserSurface( + inPane: targetPane, + url: url, + focus: true, + preferredProfileID: preferredProfileID + ) != nil } return newBrowserSplit( from: target.containerPanelID, orientation: .horizontal, - url: url + url: url, + preferredProfileID: preferredProfileID ) != nil } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 52c0a22cac41..5a3974425675 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4146,7 +4146,7 @@ final class Workspace: Identifiable, ObservableObject { preferredBrowserProfileID = profileID } - private func resolvedNewBrowserProfileID( + func resolvedNewBrowserProfileID( preferredProfileID: UUID? = nil, sourcePanelId: UUID? = nil ) -> UUID { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8df38f6166e0..b6a68f3b0a62 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2242,6 +2242,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 859110000000000000000007 /* TerminalLinkOpenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859110000000000000000008 /* TerminalLinkOpenCoordinator.swift */; }; 859100000000000000000001 /* TerminalLinkOpenCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859100000000000000000002 /* TerminalLinkOpenCoordinatorTests.swift */; }; 859110000000000000000009 /* TerminalLinkOpenRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85911000000000000000000A /* TerminalLinkOpenRequest.swift */; }; + 859120000000000000000007 /* TerminalLinkPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859120000000000000000008 /* TerminalLinkPreviewController.swift */; }; + 859130000000000000000001 /* TerminalLinkPreviewSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859130000000000000000002 /* TerminalLinkPreviewSettingsTests.swift */; }; D79020000000000000000001 /* TerminalNotification+NavigationSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D79020000000000000000002 /* TerminalNotification+NavigationSnapshot.swift */; }; A5F10000000000000000000B /* TerminalNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10000000000000000000C /* TerminalNotification.swift */; }; A5C41101A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C41102A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift */; }; @@ -4811,6 +4813,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 859110000000000000000008 /* TerminalLinkOpenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenCoordinator.swift; sourceTree = ""; }; 859100000000000000000002 /* TerminalLinkOpenCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenCoordinatorTests.swift; sourceTree = ""; }; 85911000000000000000000A /* TerminalLinkOpenRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkOpenRequest.swift; sourceTree = ""; }; + 859120000000000000000008 /* TerminalLinkPreviewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkPreviewController.swift; sourceTree = ""; }; + 859130000000000000000002 /* TerminalLinkPreviewSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalLinkPreviewSettingsTests.swift; sourceTree = ""; }; D79020000000000000000002 /* TerminalNotification+NavigationSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalNotification+NavigationSnapshot.swift"; sourceTree = ""; }; A5F10000000000000000000C /* TerminalNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotification.swift; sourceTree = ""; }; A5C41102A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationCallerResolver.swift; sourceTree = ""; }; @@ -6121,6 +6125,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 859110000000000000000004 /* DockSplitStore+TerminalLinkOpening.swift */, 859110000000000000000006 /* TerminalLinkOpenContainer.swift */, 859110000000000000000008 /* TerminalLinkOpenCoordinator.swift */, + 859120000000000000000008 /* TerminalLinkPreviewController.swift */, C86270000000000000000002 /* TerminalHTMLFileBrowserAction.swift */, 85911000000000000000000A /* TerminalLinkOpenRequest.swift */, D86862000000000000000002 /* TerminalWorkingDirectoryResolver.swift */, @@ -7323,6 +7328,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C58410010000000000000002 /* RenderableSystemSymbolTests.swift */, DCDC0000000000000000B002 /* DockControlDefinitionDecodingTests.swift */, 859100000000000000000002 /* TerminalLinkOpenCoordinatorTests.swift */, + 859130000000000000000002 /* TerminalLinkPreviewSettingsTests.swift */, D7529002A1B2C3D4E5F60718 /* DockPaneDropUnfocusedRoutingTests.swift */, D77800010000000000000002 /* DockPortalReconcileTests.swift */, 8777D0028777D0028777D002 /* DockRuntimeParityTests.swift */, @@ -9765,6 +9771,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 859110000000000000000005 /* TerminalLinkOpenContainer.swift in Sources */, 859110000000000000000007 /* TerminalLinkOpenCoordinator.swift in Sources */, 859110000000000000000009 /* TerminalLinkOpenRequest.swift in Sources */, + 859120000000000000000007 /* TerminalLinkPreviewController.swift in Sources */, D79020000000000000000001 /* TerminalNotification+NavigationSnapshot.swift in Sources */, A5F10000000000000000000B /* TerminalNotification.swift in Sources */, A5C41101A1B2C3D4E5F60718 /* TerminalNotificationCallerResolver.swift in Sources */, @@ -10797,6 +10804,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 6190C0126190C0126190C012 /* TerminalCopyOnSelectManagedConfigLayeringTests.swift in Sources */, 851500000000000000000001 /* TerminalFontZoomSessionPersistenceTests.swift in Sources */, 859100000000000000000001 /* TerminalLinkOpenCoordinatorTests.swift in Sources */, + 859130000000000000000001 /* TerminalLinkPreviewSettingsTests.swift in Sources */, A5C41103A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift in Sources */, A5A5A507A1B2C3D4E5F60718 /* TerminalNotificationClearAllTests.swift in Sources */, A5A5A505A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift in Sources */, diff --git a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift index fc17786d4daa..88851343d1ab 100644 --- a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift +++ b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing import WebKit @@ -83,9 +84,10 @@ struct BrowserPrewarmedWebViewPoolTests { harness.pool.discard(reason: "test-teardown") } - @Test func claimBeforeLoadFinishesReturnsNilAndConsumesEntry() { + @Test func claimBeforeLoadFinishesTransfersTheInFlightWebView() { let harness = PrewarmPoolHarness() harness.pool.prewarm(url: pricingURL, profileID: profileID) + let webView = harness.madeWebViews[0] let claimed = harness.pool.claim( url: pricingURL, @@ -93,7 +95,9 @@ struct BrowserPrewarmedWebViewPoolTests { websiteDataStore: harness.dataStore ) - #expect(claimed == nil) + #expect(claimed?.webView === webView) + #expect(claimed?.loadState == .loading) + #expect(claimed?.webView.window == nil) #expect(!harness.pool.hasEntry(url: pricingURL, profileID: profileID)) } @@ -109,17 +113,173 @@ struct BrowserPrewarmedWebViewPoolTests { websiteDataStore: harness.dataStore ) - #expect(claimed === webView) - #expect(claimed?.window == nil) - #expect(claimed?.superview == nil) - #expect(claimed?.navigationDelegate == nil) + #expect(claimed?.webView === webView) + #expect(claimed?.loadState == .finished) + #expect(claimed?.webView.window == nil) + #expect(claimed?.webView.superview == nil) + #expect(claimed?.webView.navigationDelegate == nil) // The portal's first-attach refresh only fires the WebKit reattach // selectors for webviews marked hidden; without this the adopted view // keeps the prewarm-sized layer tree (#7554 dogfood round 1). - #expect(claimed?.browserPortalRequiresRenderingStateReattach == true) + #expect(claimed?.webView.browserPortalRequiresRenderingStateReattach == true) #expect(!harness.pool.hasEntry(url: pricingURL, profileID: profileID)) } + @Test func previewAttachmentHostsAndThenClaimsTheExactInFlightWebView() { + let harness = PrewarmPoolHarness() + harness.pool.prewarm(url: pricingURL, profileID: profileID) + let webView = harness.madeWebViews[0] + let previewHost = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 252)) + var observedStates: [BrowserPrewarmedWebViewPool.LoadState] = [] + var dismissCount = 0 + + let attachment = harness.pool.attachPreview( + url: pricingURL, + profileID: profileID, + to: previewHost, + stateDidChange: { observedStates.append($0) }, + didDismiss: { dismissCount += 1 } + ) + + #expect(attachment?.loadState == .loading) + #expect(webView.superview === previewHost) + #expect(webView.frame == previewHost.bounds) + #expect(observedStates == [.loading]) + + let claimed = harness.pool.claim( + url: pricingURL, + profileID: profileID, + websiteDataStore: harness.dataStore + ) + + #expect(claimed?.webView === webView) + #expect(claimed?.loadState == .loading) + #expect(dismissCount == 1) + #expect(webView.superview == nil) + } + + @Test func detachingPreviewReturnsWebViewToHiddenHostAndKeepsItClaimable() throws { + let harness = PrewarmPoolHarness() + harness.pool.prewarm(url: pricingURL, profileID: profileID) + let webView = harness.madeWebViews[0] + let previewHost = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 252)) + let attachment = try #require(harness.pool.attachPreview( + url: pricingURL, + profileID: profileID, + to: previewHost, + stateDidChange: { _ in }, + didDismiss: {} + )) + + harness.pool.detachPreview(attachment) + + #expect(webView.superview !== previewHost) + #expect(webView.window != nil) + #expect(harness.pool.hasEntry(url: pricingURL, profileID: profileID)) + let claimed = harness.pool.claim( + url: pricingURL, + profileID: profileID, + websiteDataStore: harness.dataStore + ) + #expect(claimed?.webView === webView) + } + + @Test func previewReceivesFinishedLoadStateWithoutReplacingItsWebView() { + let harness = PrewarmPoolHarness() + harness.pool.prewarm(url: pricingURL, profileID: profileID) + let webView = harness.madeWebViews[0] + let previewHost = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 252)) + var observedStates: [BrowserPrewarmedWebViewPool.LoadState] = [] + let attachment = harness.pool.attachPreview( + url: pricingURL, + profileID: profileID, + to: previewHost, + stateDidChange: { observedStates.append($0) }, + didDismiss: {} + ) + + harness.pool.webView(webView, didFinish: nil) + + #expect(observedStates == [.loading, .finished]) + #expect(webView.superview === previewHost) + if let attachment { + harness.pool.detachPreview(attachment) + } + harness.pool.discard(reason: "test-teardown") + } + + @Test func delayedPreviewStartsAfterDwellAndLeavingFirstCancelsIt() async throws { + let url = try #require(URL(string: "https://example.com/preview")) + let target = TerminalLinkOpenCoordinator.PreviewTarget(url: url, profileID: profileID) + let view = TerminalLinkHoverIndicatorView( + frame: NSRect(x: 0, y: 0, width: 900, height: 650) + ) + var prewarmedURLs: [URL] = [] + let controller = TerminalLinkPreviewController( + view: view, + targetResolver: { _ in target }, + prewarm: { url, _ in prewarmedURLs.append(url) }, + attach: { _, _, _, stateDidChange, _ in + stateDidChange(.loading) + return .init(id: UUID(), loadState: .loading) + }, + detach: { _ in }, + delayMilliseconds: { 650 }, + sleep: { _ in } + ) + + controller.update( + rawURL: url.absoluteString, + sourceWorkspaceId: UUID(), + sourcePanelId: UUID(), + anchorPoint: NSPoint(x: 450, y: 325) + ) + #expect(prewarmedURLs.isEmpty) + #expect(!view.isPreviewVisible) + await Task.yield() + await Task.yield() + #expect(prewarmedURLs == [url]) + #expect(view.previewURL == url) + + controller.update( + rawURL: nil, + sourceWorkspaceId: nil, + sourcePanelId: nil, + anchorPoint: .zero + ) + #expect(!view.isPreviewVisible) + + let cancelledView = TerminalLinkHoverIndicatorView( + frame: NSRect(x: 0, y: 0, width: 900, height: 650) + ) + var cancelledPrewarms = 0 + let cancelledController = TerminalLinkPreviewController( + view: cancelledView, + targetResolver: { _ in target }, + prewarm: { _, _ in cancelledPrewarms += 1 }, + attach: { _, _, _, _, _ in nil }, + detach: { _ in }, + delayMilliseconds: { 650 }, + sleep: { _ in } + ) + cancelledController.update( + rawURL: url.absoluteString, + sourceWorkspaceId: UUID(), + sourcePanelId: UUID(), + anchorPoint: NSPoint(x: 450, y: 325) + ) + cancelledController.update( + rawURL: nil, + sourceWorkspaceId: nil, + sourcePanelId: nil, + anchorPoint: .zero + ) + await Task.yield() + await Task.yield() + #expect(cancelledPrewarms == 0) + #expect(!cancelledView.isPreviewVisible) + } + @Test func claimForDifferentURLKeepsEntry() { let harness = PrewarmPoolHarness() harness.pool.prewarm(url: pricingURL, profileID: profileID) @@ -141,7 +301,7 @@ struct BrowserPrewarmedWebViewPoolTests { profileID: profileID, websiteDataStore: harness.dataStore ) - #expect(match === webView) + #expect(match?.webView === webView) } @Test func claimForDifferentProfileKeepsEntry() { diff --git a/cmuxTests/TerminalLinkOpenCoordinatorTests.swift b/cmuxTests/TerminalLinkOpenCoordinatorTests.swift index ee762ee9c0f3..35a0d45d0bc2 100644 --- a/cmuxTests/TerminalLinkOpenCoordinatorTests.swift +++ b/cmuxTests/TerminalLinkOpenCoordinatorTests.swift @@ -9,6 +9,28 @@ import struct CmuxSettings.AppCatalogSection @testable import cmux #endif +@MainActor +private final class TerminalLinkPreviewContainerStub: TerminalLinkOpenContainer { + let isRemote: Bool + let profileID: UUID + + init(isRemote: Bool, profileID: UUID = UUID()) { + self.isRemote = isRemote + self.profileID = profileID + } + + var terminalLinkContainerDebugName: String { "preview-test" } + func terminalLinkWorkingDirectory(for sourcePanelId: UUID) -> String? { nil } + func terminalLinkIsRemoteTerminal(_ sourcePanelId: UUID) -> Bool { isRemote } + func terminalLinkBrowserProfileID(for sourcePanelId: UUID) -> UUID? { profileID } + func deferTerminalFileLinkOpen( + sourcePanelId: UUID, + filePath: String, + fallback: @escaping @MainActor @Sendable () -> Void + ) -> Bool { false } + func openTerminalBrowserLink(url: URL, sourcePanelId: UUID) -> Bool { false } +} + @Suite("Terminal link open coordinator", .serialized) struct TerminalLinkOpenCoordinatorTests { private func makeDefaults() -> UserDefaults { @@ -24,6 +46,72 @@ struct TerminalLinkOpenCoordinatorTests { return defaults } + @Test("Preview resolves only links eligible for the local embedded-browser click route") + @MainActor + func previewTargetMatchesEmbeddedBrowserPolicy() throws { + let defaults = makeDefaults() + let panelID = UUID() + let container = TerminalLinkPreviewContainerStub(isRemote: false) + let coordinator = TerminalLinkOpenCoordinator( + defaults: defaults, + containerResolver: { _, resolvedPanelID in + resolvedPanelID == panelID ? container : nil + } + ) + let url = try #require(URL(string: "https://example.com/docs")) + let request = TerminalLinkOpenRequest( + rawValue: url.absoluteString, + sourceWorkspaceId: UUID(), + sourcePanelId: panelID, + workingDirectory: nil + ) + + #expect( + coordinator.previewTarget(for: request) + == TerminalLinkOpenCoordinator.PreviewTarget( + url: url, + profileID: container.profileID + ) + ) + + defaults.set( + "example.com/docs", + forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey + ) + #expect(coordinator.previewTarget(for: request) == nil) + + defaults.removeObject(forKey: BrowserLinkOpenSettings.browserExternalOpenPatternsKey) + defaults.set( + "internal.example", + forKey: BrowserLinkOpenSettings.browserHostWhitelistKey + ) + #expect(coordinator.previewTarget(for: request) == nil) + + defaults.removeObject(forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + defaults.set(true, forKey: BrowserAvailabilitySettings.disabledKey) + #expect(coordinator.previewTarget(for: request) == nil) + } + + @Test("Preview never preloads a remote terminal link on the local machine") + @MainActor + func previewTargetRejectsRemoteTerminal() throws { + let defaults = makeDefaults() + let panelID = UUID() + let container = TerminalLinkPreviewContainerStub(isRemote: true) + let coordinator = TerminalLinkOpenCoordinator( + defaults: defaults, + containerResolver: { _, _ in container } + ) + let url = try #require(URL(string: "https://example.com/private")) + + #expect(coordinator.previewTarget(for: TerminalLinkOpenRequest( + rawValue: url.absoluteString, + sourceWorkspaceId: UUID(), + sourcePanelId: panelID, + workingDirectory: nil + )) == nil) + } + @Test("Embedded URL without an owning container falls back externally") @MainActor func unresolvedSourceFallsBackExternally() throws { diff --git a/cmuxTests/TerminalLinkPreviewSettingsTests.swift b/cmuxTests/TerminalLinkPreviewSettingsTests.swift new file mode 100644 index 000000000000..64fc98f15357 --- /dev/null +++ b/cmuxTests/TerminalLinkPreviewSettingsTests.swift @@ -0,0 +1,117 @@ +import CmuxSettings +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite("Terminal link preview settings", .serialized) +struct TerminalLinkPreviewSettingsTests { + private let settingsFileBackupsDefaultsKey = "cmux.settingsFile.backups.v1" + private let importedManagedDefaultsKey = "cmux.settingsFile.importedManagedDefaults.v1" + + @Test + func runtimeUsesDefaultValidValueAndRejectsOutOfRangeValues() { + let suiteName = "terminal-link-preview-settings-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + #expect( + BrowserLinkOpenSettings.terminalLinkPreviewHoverDelayMilliseconds(defaults: defaults) + == BrowserCatalogSection.defaultTerminalLinkPreviewHoverDelayMilliseconds + ) + + defaults.set(900, forKey: BrowserLinkOpenSettings.terminalLinkPreviewHoverDelayMillisecondsKey) + #expect( + BrowserLinkOpenSettings.terminalLinkPreviewHoverDelayMilliseconds(defaults: defaults) + == 900 + ) + + for invalidValue in [199, 2_001] { + defaults.set( + invalidValue, + forKey: BrowserLinkOpenSettings.terminalLinkPreviewHoverDelayMillisecondsKey + ) + #expect( + BrowserLinkOpenSettings.terminalLinkPreviewHoverDelayMilliseconds(defaults: defaults) + == BrowserCatalogSection.defaultTerminalLinkPreviewHoverDelayMilliseconds + ) + } + } + + @Test(arguments: [200, 650, 2_000]) + func settingsFileAcceptsSupportedDelay(value: Int) throws { + try loadSettingsFile(value: value) { defaults, key in + #expect(defaults.object(forKey: key) as? Int == value) + } + } + + @Test(arguments: [199, 2_001]) + func settingsFileRejectsOutOfRangeDelay(value: Int) throws { + try loadSettingsFile(value: value) { defaults, key in + #expect(defaults.object(forKey: key) == nil) + } + } + + private func loadSettingsFile( + value: Int, + verify: (UserDefaults, String) throws -> Void + ) throws { + let defaults = UserDefaults.standard + let key = BrowserCatalogSection().terminalLinkPreviewHoverDelayMilliseconds.userDefaultsKey + try preservingDefaults(keys: [ + key, + settingsFileBackupsDefaultsKey, + importedManagedDefaultsKey, + ]) { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try """ + { + "browser": { + "terminalLinkPreviewHoverDelayMilliseconds": \(value) + } + } + """.write(to: settingsFileURL, atomically: true, encoding: .utf8) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + additionalFallbackPaths: [], + startWatching: false + ) + + try verify(defaults, key) + } + } + + private func preservingDefaults(keys: [String], _ body: () throws -> Void) throws { + let defaults = UserDefaults.standard + let saved = keys.map { ($0, defaults.object(forKey: $0)) } + for key in keys { defaults.removeObject(forKey: key) } + defer { + for (key, value) in saved { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + try body() + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-terminal-link-preview-settings-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index a7e7cb56f38c..5b80b3399fa6 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,10 +12,12 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -The submodule pinned by this branch is `da1ddcf41`, the fork-main merge of -https://github.com/manaflow-ai/ghostty/pull/176. It descends from the previously -documented `36a46414a` renderer/font integration and adds terminal-owned -semantic-prompt row lifecycle enforcement through `2d6e944e3`. +The submodule pinned by this branch is `aade8166b`, the tip of +https://github.com/manaflow-ai/ghostty/tree/feat-unmodified-link-previews. It +adds an embedder-only unmodified link-preview path on top of `da1ddcf41`, the +fork-main merge of https://github.com/manaflow-ai/ghostty/pull/176. It descends +from the previously documented `36a46414a` renderer/font integration and adds +terminal-owned semantic-prompt row lifecycle enforcement through `2d6e944e3`. The earlier integration combines the hidden-renderer reclamation and retry-deadline line through `4d6f0014f` with the resolved font-binding action callbacks originally ending at `80d7fb35a`. @@ -42,6 +44,28 @@ The seven PRs landed in merge commits `1e86b46e2`, `4dab6fd6c`, The final font integration landed in merge commits `23003282d` and `36a46414a`. +### Unmodified embedder link previews + +- Branch: + - https://github.com/manaflow-ai/ghostty/tree/feat-unmodified-link-previews +- Commit: + - `aade8166b` (feat: expose unmodified link previews to embedders) +- Files: + - `include/ghostty.h` + - `src/Surface.zig` + - `src/apprt/embedded.zig` +- Summary: + - Adds an opt-in embedded-surface API that resolves link-preview callbacks + under an unmodified pointer. + - Keeps preview notification state separate from modifier-gated link + highlighting and click activation, so plain clicks retain terminal text + selection behavior. + - Preserves `link-previews=false`, `link-previews=osc8`, mouse-reporting, + drag cancellation, and the existing Command-click or Control-click rules. + - Conflict note: preserve separate preview and activation state when + changing mouse hover resolution. The embedder flag may bypass matcher + modifiers only for preview callbacks, never for link activation. + ### Semantic prompt row lifecycle - Pull request: diff --git a/ghostty b/ghostty index da1ddcf41f6f..aade8166b05e 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit da1ddcf41f6fd763c39bde4c69d1ac7323cb9bd0 +Subproject commit aade8166b05e0b00a0499c3c24bb5d013149b264 diff --git a/skills/cmux-settings/references/all-keys.md b/skills/cmux-settings/references/all-keys.md index fae881c79feb..0913f1ad40c5 100644 --- a/skills/cmux-settings/references/all-keys.md +++ b/skills/cmux-settings/references/all-keys.md @@ -124,6 +124,7 @@ Embedded browser settings from Settings > Browser. | `browser.showSearchSuggestions` | boolean | `true` | Show omnibar search suggestions. | | `browser.theme` | `"system"` or `"light"` or `"dark"` | `"system"` | Embedded browser theme. | | `browser.openTerminalLinksInCmuxBrowser` | boolean | `true` | Open clicked terminal links in the embedded browser. | +| `browser.terminalLinkPreviewHoverDelayMilliseconds` | integer | `650` | Milliseconds the pointer must remain over a terminal link before its live page preview appears. | | `browser.interceptTerminalOpenCommandInCmuxBrowser` | boolean | `true` | Intercept terminal open http(s) commands and route them through the embedded browser. | | `browser.hostsToOpenInEmbeddedBrowser` | array | `[]` | Allowlist of hosts that should stay inside the embedded browser. | | `browser.urlsToAlwaysOpenExternally` | array | `[]` | Rules that always open matching URLs in the system browser. | diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index e6a7a4284345..df260eb74a4b 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1407,6 +1407,14 @@ "default": true, "description": "Open clicked terminal links in the embedded browser." }, + "terminalLinkPreviewHoverDelayMilliseconds": { + "type": "integer", + "minimum": 200, + "maximum": 2000, + "default": 650, + "descriptionKey": "schemaDescriptions.browser.terminalLinkPreviewHoverDelayMilliseconds", + "description": "Milliseconds the pointer must remain over a terminal link before its live page preview appears." + }, "interceptTerminalOpenCommandInCmuxBrowser": { "type": "boolean", "default": true, diff --git a/web/messages/en.json b/web/messages/en.json index db537e3dd61b..c507ac2430a1 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1837,7 +1837,8 @@ "defaultSearchEngine": "Default search engine for non-URL browser address bar queries. Use custom with customSearchEngineURLTemplate for arbitrary providers.", "customSearchEngineName": "Display name used when defaultSearchEngine is custom.", "customSearchEngineURLTemplate": "Search URL used when defaultSearchEngine is custom. Include the query placeholder or %s for the encoded query. If omitted, cmux appends q= to the URL.", - "askWhereToSaveDownloads": "Show a save panel for browser downloads instead of saving directly to Downloads." + "askWhereToSaveDownloads": "Show a save panel for browser downloads instead of saving directly to Downloads.", + "terminalLinkPreviewHoverDelayMilliseconds": "Milliseconds the pointer must remain over a terminal link before its live page preview appears." }, "workspaceGroups": { "newWorkspacePlacement": "Global default for where Cmd-N inside a group, the group header + button, and configured group actions place the new workspace: `afterCurrent` (after the active in-group workspace, falling back to top), `top` (second slot, just after the anchor), or `end` (after the last member).", diff --git a/web/messages/ja.json b/web/messages/ja.json index bc7663179dc9..9f049cc3aa9a 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -1760,7 +1760,8 @@ "defaultSearchEngine": "ブラウザのアドレスバーでURLではない入力に使う既定の検索エンジンです。任意のプロバイダには custom と customSearchEngineURLTemplate を使います。", "customSearchEngineName": "defaultSearchEngine が custom のときに使う表示名です。", "customSearchEngineURLTemplate": "defaultSearchEngine が custom のときに使う検索URLです。エンコード済み検索語にはクエリ用プレースホルダーまたは %s を含めます。省略すると cmux が URL に q= を追加します。", - "askWhereToSaveDownloads": "ブラウザのダウンロードを直接「ダウンロード」に保存せず、保存パネルを表示します。" + "askWhereToSaveDownloads": "ブラウザのダウンロードを直接「ダウンロード」に保存せず、保存パネルを表示します。", + "terminalLinkPreviewHoverDelayMilliseconds": "ターミナルリンクにポインタを置いてからライブページのプレビューが表示されるまでのミリ秒数です。" }, "workspaceGroups": { "newWorkspacePlacement": "グループ内で Cmd-N、グループヘッダーの + ボタン、設定済みグループアクションが新しいワークスペースを配置する場所のグローバル既定値です: `afterCurrent` (アクティブなグループ内ワークスペースの直後。ない場合は top)、`top` (アンカー直後の2番目の位置)、`end` (最後のメンバーの後)。", From 2a31ce6105ba3d3bec87aebb0e7b8ca125e2e1c9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:45:01 -0700 Subject: [PATCH 02/12] test: pin terminal link preview framework --- .../Panels/BrowserPrewarmedWebViewPool.swift | 11 ++-- .../BrowserPrewarmedWebViewPoolTests.swift | 66 +++++++++++++++++++ docs/ghostty-fork.md | 3 + scripts/ghosttykit-checksums.txt | 1 + 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/Sources/Panels/BrowserPrewarmedWebViewPool.swift b/Sources/Panels/BrowserPrewarmedWebViewPool.swift index 8605d1d462aa..52826cee3c84 100644 --- a/Sources/Panels/BrowserPrewarmedWebViewPool.swift +++ b/Sources/Panels/BrowserPrewarmedWebViewPool.swift @@ -4,12 +4,11 @@ import WebKit /// Single-slot pool of a hidden, pre-navigated browser webview. /// -/// Upgrade entrypoints call ``prewarm(url:profileID:)`` on hover so the -/// pricing page is already loaded by the time the user clicks. The -/// ``BrowserPanel`` initializer claims a matching entry via -/// ``claim(url:profileID:websiteDataStore:)`` and adopts the webview instead -/// of starting a cold WebKit process launch plus network load, so the panel -/// shows the finished page immediately. +/// Callers use ``prewarm(url:profileID:)`` so a page is already loading by the +/// time the user opens it. The ``BrowserPanel`` initializer claims a matching +/// entry via ``claim(url:profileID:websiteDataStore:)`` and adopts the webview +/// instead of starting a cold WebKit process launch plus network load, so the +/// panel shows the finished page immediately. /// /// The webview normally lives in an offscreen, non-activating borderless /// window (the same hosting recipe as diff --git a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift index 88851343d1ab..7a1f79078356 100644 --- a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift +++ b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift @@ -47,6 +47,23 @@ private let pricingURL = URL(string: "https://cmux.com/app-pricing?appearance=da private let otherURL = URL(string: "https://cmux.com/docs")! private let profileID = UUID() +private final class TerminalLinkPreviewSleepCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + lock.lock() + count += 1 + lock.unlock() + } + + func value() -> Int { + lock.lock() + defer { lock.unlock() } + return count + } +} + @MainActor struct BrowserPrewarmedWebViewPoolTests { @Test func prewarmLoadsURLInHiddenHostedWebView() { @@ -280,6 +297,55 @@ struct BrowserPrewarmedWebViewPoolTests { #expect(!cancelledView.isPreviewVisible) } + @Test func movingWithinTheSameURLDoesNotRestartDwell() async throws { + let url = try #require(URL(string: "https://example.com/steady-hover")) + let target = TerminalLinkOpenCoordinator.PreviewTarget(url: url, profileID: profileID) + let view = TerminalLinkHoverIndicatorView( + frame: NSRect(x: 0, y: 0, width: 900, height: 650) + ) + let sleepCounter = TerminalLinkPreviewSleepCounter() + var prewarmCount = 0 + let controller = TerminalLinkPreviewController( + view: view, + targetResolver: { _ in target }, + prewarm: { _, _ in prewarmCount += 1 }, + attach: { _, _, _, _, _ in + .init(id: UUID(), loadState: .loading) + }, + detach: { _ in }, + delayMilliseconds: { 650 }, + sleep: { _ in + sleepCounter.increment() + try await Task.sleep(for: .milliseconds(30)) + } + ) + + controller.update( + rawURL: url.absoluteString, + sourceWorkspaceId: UUID(), + sourcePanelId: UUID(), + anchorPoint: NSPoint(x: 300, y: 300) + ) + var remainingYields = 1_000 + while sleepCounter.value() == 0, remainingYields > 0 { + remainingYields -= 1 + await Task.yield() + } + #expect(sleepCounter.value() == 1) + + controller.update( + rawURL: url.absoluteString, + sourceWorkspaceId: UUID(), + sourcePanelId: UUID(), + anchorPoint: NSPoint(x: 340, y: 300) + ) + try await Task.sleep(for: .milliseconds(60)) + + #expect(sleepCounter.value() == 1) + #expect(prewarmCount == 1) + #expect(view.previewURL == url) + } + @Test func claimForDifferentURLKeepsEntry() { let harness = PrewarmPoolHarness() harness.pool.prewarm(url: pricingURL, profileID: profileID) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 5b80b3399fa6..4bbb82d71cf6 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -50,6 +50,9 @@ The final font integration landed in merge commits `23003282d` and - https://github.com/manaflow-ai/ghostty/tree/feat-unmodified-link-previews - Commit: - `aade8166b` (feat: expose unmodified link previews to embedders) +- Release: + - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-aade8166b05e0b00a0499c3c24bb5d013149b264-crashsubdir-cmux-crash-v1 + - Archive SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`. - Files: - `include/ghostty.h` - `src/Surface.zig` diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 2c020855fc00..391412f098c2 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -109,3 +109,4 @@ cd1f8e0120f534cabc7d89257baccc42c166d369 578864e66ed7483d8c28fd2418d6b9961467d4a 6143bac777724854e5a3bab8c7a4e47735f17113 e4c976b234dac24f6261f27af64fbf1b013d618d5b9c903ac876d9046678ff17 abcf5697d4fcd05e29a83ccfc090d6e234952849 61f0034da86c4907d6b1c607fcfd6810425cd02406dcc7a0652d4289c9b530ea da1ddcf41f6fd763c39bde4c69d1ac7323cb9bd0 51bb73625dd8e53a98675fb75dc573931ab3b65646e02e5f0ef6bf7db89308da +aade8166b05e0b00a0499c3c24bb5d013149b264 e19d7adec601bddd18bf8109c75723307ae5b2ce3cd400e9d0ce1d05b530fcf1 From 3ab61ecf4192408af4dd0d4f14719ef210b8a417 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:26:46 -0700 Subject: [PATCH 03/12] build: pin merged terminal link preview framework --- scripts/ghosttykit-checksums.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 5790c17173a0..baf25fbcdaf4 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -112,3 +112,4 @@ abcf5697d4fcd05e29a83ccfc090d6e234952849 61f0034da86c4907d6b1c607fcfd6810425cd02 da1ddcf41f6fd763c39bde4c69d1ac7323cb9bd0 51bb73625dd8e53a98675fb75dc573931ab3b65646e02e5f0ef6bf7db89308da aade8166b05e0b00a0499c3c24bb5d013149b264 e19d7adec601bddd18bf8109c75723307ae5b2ce3cd400e9d0ce1d05b530fcf1 88357634c4dbadc87981e2ebb64eb599c53aa012 0448351c3f8b07fd2698c905260a97d064e4e186d0544766965effb41aedfbd5 +f0af83afe3c806b756f4aa0847507969f873b9ae b6ee5a44b98ca77265664addab73881e01250c4ff426bb08a8e05bbce9170ab5 From 676e822509a3f65ba220a62f95ee11052e180912 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:53:06 -0700 Subject: [PATCH 04/12] test: require interactive terminal link previews --- .../BrowserPrewarmedWebViewPoolTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift index 7a1f79078356..23d5e9dc09ae 100644 --- a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift +++ b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift @@ -225,6 +225,24 @@ struct BrowserPrewarmedWebViewPoolTests { harness.pool.discard(reason: "test-teardown") } + @Test func previewCardRoutesHitsToWebContentWhileBackgroundStaysPassthrough() throws { + let url = try #require(URL(string: "https://example.com/interactive-preview")) + let root = NSView(frame: NSRect(x: 0, y: 0, width: 900, height: 650)) + let view = TerminalLinkHoverIndicatorView(frame: root.bounds) + root.addSubview(view) + #expect(view.preparePreview(url: url, at: NSPoint(x: 450, y: 325))) + + let webContent = NSButton(frame: view.previewWebViewHost.bounds) + view.previewWebViewHost.addSubview(webContent) + let webContentPoint = webContent.convert( + NSPoint(x: webContent.bounds.midX, y: webContent.bounds.midY), + to: root + ) + + #expect(view.hitTest(webContentPoint) === webContent) + #expect(view.hitTest(NSPoint(x: 20, y: 20)) == nil) + } + @Test func delayedPreviewStartsAfterDwellAndLeavingFirstCancelsIt() async throws { let url = try #require(URL(string: "https://example.com/preview")) let target = TerminalLinkOpenCoordinator.PreviewTarget(url: url, profileID: profileID) From 789285bb29fc0816fdeee1a5b85071094401b292 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:13:30 -0700 Subject: [PATCH 05/12] feat: make terminal link previews interactive --- Sources/GhosttyTerminalViewSupport.swift | 147 ++++++++++++++-- .../Panels/BrowserPrewarmedWebViewPool.swift | 2 +- Sources/TerminalLinkPreviewController.swift | 143 ++++++++++++++- .../BrowserPrewarmedWebViewPoolTests.swift | 164 ++++++++++++++++++ 4 files changed, 438 insertions(+), 18 deletions(-) diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index a93ad40e2ee6..62d7689d8292 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -25,22 +25,32 @@ final class TerminalLinkHoverIndicatorView: NSView { private let statusBackdrop = GhosttyPassthroughVisualEffectView(frame: .zero) private let statusLabel = NSTextField(labelWithString: "") private let previewShadowView = NSView(frame: .zero) - private let previewBackdrop = GhosttyPassthroughVisualEffectView(frame: .zero) + private let previewBackdrop = NSVisualEffectView(frame: .zero) private let webViewHost = NSView(frame: .zero) private let loadingBackdrop = NSView(frame: .zero) private let loadingSpinner = NSProgressIndicator(frame: .zero) private let footerIcon = NSImageView(frame: .zero) private let footerLabel = NSTextField(labelWithString: "") private var previewAnchor = NSPoint.zero + private var previewTrackingArea: NSTrackingArea? + private var pointerDownMonitor: Any? + private var windowResignObserver: NSObjectProtocol? + private var dismissalGeneration: UInt64 = 0 private(set) var previewURL: URL? var previewWebViewHost: NSView { webViewHost } var isPreviewVisible: Bool { !previewShadowView.isHidden } + var onPreviewPointerChange: ((Bool) -> Void)? + var onPreviewPointerDown: ((Bool) -> Void)? + var onPreviewWindowResignedKey: (() -> Void)? override var acceptsFirstResponder: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { - nil + guard !isHidden, + !previewShadowView.isHidden, + previewShadowView.frame.contains(point) else { return nil } + return previewShadowView.hitTest(convert(point, to: previewShadowView)) } override init(frame frameRect: NSRect) { @@ -78,12 +88,53 @@ final class TerminalLinkHoverIndicatorView: NSView { ]) configurePreviewCard() + installPointerDownMonitor() } required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } + deinit { + if let pointerDownMonitor { + NSEvent.removeMonitor(pointerDownMonitor) + } + if let windowResignObserver { + NotificationCenter.default.removeObserver(windowResignObserver) + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let windowResignObserver { + NotificationCenter.default.removeObserver(windowResignObserver) + self.windowResignObserver = nil + } + guard let window else { return } + windowResignObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard self?.isPreviewVisible == true else { return } + self?.onPreviewWindowResignedKey?() + } + } + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + guard event.trackingArea === previewTrackingArea else { return } + onPreviewPointerChange?(true) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + guard event.trackingArea === previewTrackingArea else { return } + onPreviewPointerChange?(false) + } + override func layout() { super.layout() guard previewURL != nil else { return } @@ -95,15 +146,13 @@ final class TerminalLinkHoverIndicatorView: NSView { statusLabel.stringValue = url ?? "" statusLabel.setAccessibilityLabel(url) statusBackdrop.isHidden = url == nil || isPreviewVisible - isHidden = url == nil - if url == nil { - dismissPreview(animated: false) - } + isHidden = url == nil && !isPreviewVisible } @discardableResult func preparePreview(url: URL, at anchor: NSPoint) -> Bool { guard availablePreviewSize() != nil else { return false } + dismissalGeneration &+= 1 previewURL = url previewAnchor = anchor footerLabel.stringValue = url.absoluteString @@ -131,6 +180,15 @@ final class TerminalLinkHoverIndicatorView: NSView { return true } + func cancelPreviewDismissal() { + guard previewURL != nil, !previewShadowView.isHidden else { return } + dismissalGeneration &+= 1 + previewShadowView.layer?.removeAllAnimations() + previewShadowView.alphaValue = 1 + statusBackdrop.isHidden = true + isHidden = false + } + func setPreviewLoadState(_ state: BrowserPrewarmedWebViewPool.LoadState) { let isLoading = state == .loading loadingBackdrop.isHidden = !isLoading @@ -141,27 +199,41 @@ final class TerminalLinkHoverIndicatorView: NSView { } } - func dismissPreview(animated: Bool = true) { - previewURL = nil + func dismissPreview(animated: Bool = true, completion: (() -> Void)? = nil) { + dismissalGeneration &+= 1 + let scheduledGeneration = dismissalGeneration loadingSpinner.stopAnimation(nil) - statusBackdrop.isHidden = statusLabel.stringValue.isEmpty - guard !previewShadowView.isHidden else { return } + guard !previewShadowView.isHidden else { + finishPreviewDismissal(generation: scheduledGeneration, completion: completion) + return + } previewShadowView.layer?.removeAllAnimations() guard animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { - previewShadowView.alphaValue = 0 - previewShadowView.isHidden = true + finishPreviewDismissal(generation: scheduledGeneration, completion: completion) return } NSAnimationContext.runAnimationGroup { context in - context.duration = 0.1 + context.duration = 0.14 context.timingFunction = CAMediaTimingFunction(name: .easeIn) previewShadowView.animator().alphaValue = 0 } completionHandler: { [weak self] in - guard let self, self.previewURL == nil else { return } - self.previewShadowView.isHidden = true + self?.finishPreviewDismissal( + generation: scheduledGeneration, + completion: completion + ) } } + var previewOwnsFirstResponder: Bool { + guard let responderView = window?.firstResponder as? NSView else { return false } + return responderView === previewShadowView || responderView.isDescendant(of: previewShadowView) + } + + func resignPreviewFirstResponderIfNeeded() { + guard previewOwnsFirstResponder else { return } + window?.makeFirstResponder(nil) + } + private func configurePreviewCard() { previewShadowView.wantsLayer = true previewShadowView.layer?.shadowColor = NSColor.black.cgColor @@ -209,6 +281,51 @@ final class TerminalLinkHoverIndicatorView: NSView { previewBackdrop.addSubview(footerLabel) previewBackdrop.addSubview(loadingBackdrop) loadingBackdrop.addSubview(loadingSpinner) + + let trackingArea = NSTrackingArea( + rect: .zero, + options: [ + .mouseEnteredAndExited, + .activeInKeyWindow, + .inVisibleRect, + .enabledDuringMouseDrag, + ], + owner: self, + userInfo: nil + ) + previewShadowView.addTrackingArea(trackingArea) + previewTrackingArea = trackingArea + } + + private func installPointerDownMonitor() { + pointerDownMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] + ) { [weak self] event in + guard let self, self.isPreviewVisible else { return event } + let isInsidePreview: Bool + if event.window === self.window { + let point = self.convert(event.locationInWindow, from: nil) + isInsidePreview = self.previewShadowView.frame.contains(point) + } else { + isInsidePreview = false + } + self.onPreviewPointerDown?(isInsidePreview) + return event + } + } + + private func finishPreviewDismissal( + generation scheduledGeneration: UInt64, + completion: (() -> Void)? + ) { + guard dismissalGeneration == scheduledGeneration else { return } + previewURL = nil + previewShadowView.layer?.removeAllAnimations() + previewShadowView.alphaValue = 0 + previewShadowView.isHidden = true + statusBackdrop.isHidden = statusLabel.stringValue.isEmpty + isHidden = statusLabel.stringValue.isEmpty + completion?() } private func availablePreviewSize() -> NSSize? { diff --git a/Sources/Panels/BrowserPrewarmedWebViewPool.swift b/Sources/Panels/BrowserPrewarmedWebViewPool.swift index 52826cee3c84..484c330cf4eb 100644 --- a/Sources/Panels/BrowserPrewarmedWebViewPool.swift +++ b/Sources/Panels/BrowserPrewarmedWebViewPool.swift @@ -125,7 +125,7 @@ final class BrowserPrewarmedWebViewPool: NSObject { #endif } - /// Temporarily renders a matching entry inside a non-interactive preview + /// Temporarily renders a matching entry inside an interactive preview /// host. The entry remains claimable by a real browser panel. func attachPreview( url: URL, diff --git a/Sources/TerminalLinkPreviewController.swift b/Sources/TerminalLinkPreviewController.swift index b5b106fa4a26..4a510b4282d4 100644 --- a/Sources/TerminalLinkPreviewController.swift +++ b/Sources/TerminalLinkPreviewController.swift @@ -24,12 +24,19 @@ final class TerminalLinkPreviewController { private let attach: Attach private let detach: Detach private let delayMilliseconds: @MainActor () -> Int + private let dismissalGraceMilliseconds: Int + private let animateDismissal: Bool private let sleep: Sleep private var currentRawURL: String? private var currentTarget: TerminalLinkOpenCoordinator.PreviewTarget? private var currentAnchorPoint = NSPoint.zero + private var isSourceLinkHovered = false + private var isPointerInsidePreview = false + private var isInteractionPinned = false + private var isDismissalAnimating = false private var dwellTask: Task? + private var dismissalTask: Task? private var attachment: BrowserPrewarmedWebViewPool.PreviewAttachment? private var generation: UInt64 = 0 @@ -53,6 +60,8 @@ final class TerminalLinkPreviewController { BrowserPrewarmedWebViewPool.shared.detachPreview(attachment) }, delayMilliseconds: (@MainActor () -> Int)? = nil, + dismissalGraceMilliseconds: Int = 200, + animateDismissal: Bool = true, sleep: @escaping Sleep = { duration in try await Task.sleep(for: duration) } ) { self.view = view @@ -65,7 +74,18 @@ final class TerminalLinkPreviewController { self.delayMilliseconds = delayMilliseconds ?? { BrowserLinkOpenSettings.terminalLinkPreviewHoverDelayMilliseconds(defaults: defaults) } + self.dismissalGraceMilliseconds = dismissalGraceMilliseconds + self.animateDismissal = animateDismissal self.sleep = sleep + view.onPreviewPointerChange = { [weak self] isInside in + self?.previewPointerDidChange(isInside: isInside) + } + view.onPreviewPointerDown = { [weak self] isInside in + self?.previewPointerDidPress(isInside: isInside) + } + view.onPreviewWindowResignedKey = { [weak self] in + self?.previewWindowDidResignKey() + } } func update( @@ -75,13 +95,16 @@ final class TerminalLinkPreviewController { anchorPoint: NSPoint ) { let normalizedRawURL = rawURL?.isEmpty == false ? rawURL : nil + isSourceLinkHovered = normalizedRawURL != nil view?.setURL(normalizedRawURL) guard let normalizedRawURL else { - clear() + sourceLinkHoverDidEnd() return } + cancelPendingDismissalAndRestorePreview() + let request = TerminalLinkOpenRequest( rawValue: normalizedRawURL, sourceWorkspaceId: sourceWorkspaceId, @@ -120,9 +143,48 @@ final class TerminalLinkPreviewController { func invalidate() { clear() + view?.onPreviewPointerChange = nil + view?.onPreviewPointerDown = nil + view?.onPreviewWindowResignedKey = nil + } + + func previewPointerDidChange(isInside: Bool) { + isPointerInsidePreview = isInside + guard view?.isPreviewVisible == true else { return } + if isInside { + // The card is separated from the source link by a gap, so these + // two hover regions cannot be active at the same time. Do not + // depend on Ghostty delivering a final nil event after AppKit has + // routed pointer events into the webview. + isSourceLinkHovered = false + view?.setURL(nil) + cancelPendingDismissalAndRestorePreview() + } else if !isSourceLinkHovered, + !isInteractionPinned, + view?.previewOwnsFirstResponder != true { + schedulePreviewDismissal() + } + } + + func previewPointerDidPress(isInside: Bool) { + guard view?.isPreviewVisible == true else { return } + if isInside { + isInteractionPinned = true + cancelPendingDismissalAndRestorePreview() + } else { + isInteractionPinned = false + beginPreviewDismissal() + } + } + + func previewWindowDidResignKey() { + guard view?.isPreviewVisible == true else { return } + isInteractionPinned = false + beginPreviewDismissal() } private func showPreview(generation scheduledGeneration: UInt64) { + dwellTask = nil guard generation == scheduledGeneration, let target = currentTarget, let view, @@ -143,7 +205,7 @@ final class TerminalLinkPreviewController { } ) guard let attachment else { - view.dismissPreview() + beginPreviewDismissal() return } self.attachment = attachment @@ -152,20 +214,97 @@ final class TerminalLinkPreviewController { private func poolDidDismiss(generation dismissedGeneration: UInt64) { guard generation == dismissedGeneration else { return } attachment = nil + dismissalTask?.cancel() + dismissalTask = nil + isDismissalAnimating = false view?.dismissPreview(animated: false) } private func clear() { + isSourceLinkHovered = false resetPreviewState() currentRawURL = nil currentTarget = nil view?.setURL(nil) } + private func sourceLinkHoverDidEnd() { + dwellTask?.cancel() + dwellTask = nil + guard view?.isPreviewVisible == true else { + clear() + return + } + guard !isPointerInsidePreview, + !isInteractionPinned, + view?.previewOwnsFirstResponder != true else { return } + schedulePreviewDismissal() + } + + private func schedulePreviewDismissal() { + guard view?.isPreviewVisible == true, dismissalTask == nil, !isDismissalAnimating else { return } + let scheduledGeneration = generation + let duration = Duration.milliseconds(max(0, dismissalGraceMilliseconds)) + let sleep = sleep + dismissalTask = Task { @MainActor [weak self] in + do { + try await sleep(duration) + } catch { + return + } + guard !Task.isCancelled else { return } + self?.beginPreviewDismissal(generation: scheduledGeneration) + } + } + + private func cancelPendingDismissalAndRestorePreview() { + dismissalTask?.cancel() + dismissalTask = nil + if isDismissalAnimating { + isDismissalAnimating = false + view?.cancelPreviewDismissal() + } + } + + private func beginPreviewDismissal(generation scheduledGeneration: UInt64? = nil) { + if let scheduledGeneration, scheduledGeneration != generation { return } + guard view?.isPreviewVisible == true else { return } + dismissalTask?.cancel() + dismissalTask = nil + isDismissalAnimating = true + let activeGeneration = generation + view?.dismissPreview(animated: animateDismissal) { [weak self] in + self?.completePreviewDismissal(generation: activeGeneration) + } + } + + private func completePreviewDismissal(generation dismissedGeneration: UInt64) { + guard generation == dismissedGeneration, isDismissalAnimating else { return } + isDismissalAnimating = false + view?.resignPreviewFirstResponderIfNeeded() + if let attachment { + self.attachment = nil + detach(attachment) + } + isPointerInsidePreview = false + isInteractionPinned = false + generation &+= 1 + if !isSourceLinkHovered { + currentRawURL = nil + currentTarget = nil + view?.setURL(nil) + } + } + private func resetPreviewState() { generation &+= 1 dwellTask?.cancel() dwellTask = nil + dismissalTask?.cancel() + dismissalTask = nil + isPointerInsidePreview = false + isInteractionPinned = false + isDismissalAnimating = false view?.dismissPreview(animated: false) if let attachment { self.attachment = nil diff --git a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift index 23d5e9dc09ae..3145156acc14 100644 --- a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift +++ b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift @@ -231,6 +231,7 @@ struct BrowserPrewarmedWebViewPoolTests { let view = TerminalLinkHoverIndicatorView(frame: root.bounds) root.addSubview(view) #expect(view.preparePreview(url: url, at: NSPoint(x: 450, y: 325))) + view.setPreviewLoadState(.finished) let webContent = NSButton(frame: view.previewWebViewHost.bounds) view.previewWebViewHost.addSubview(webContent) @@ -260,6 +261,7 @@ struct BrowserPrewarmedWebViewPoolTests { }, detach: { _ in }, delayMilliseconds: { 650 }, + animateDismissal: false, sleep: { _ in } ) @@ -282,6 +284,9 @@ struct BrowserPrewarmedWebViewPoolTests { sourcePanelId: nil, anchorPoint: .zero ) + #expect(view.isPreviewVisible) + await Task.yield() + await Task.yield() #expect(!view.isPreviewVisible) let cancelledView = TerminalLinkHoverIndicatorView( @@ -315,6 +320,165 @@ struct BrowserPrewarmedWebViewPoolTests { #expect(!cancelledView.isPreviewVisible) } + @Test func enteringPreviewDuringExitGraceKeepsAttachmentUntilPointerLeaves() async throws { + let url = try #require(URL(string: "https://example.com/crossing-gap")) + let target = TerminalLinkOpenCoordinator.PreviewTarget(url: url, profileID: profileID) + let view = TerminalLinkHoverIndicatorView( + frame: NSRect(x: 0, y: 0, width: 900, height: 650) + ) + var detachCount = 0 + let controller = TerminalLinkPreviewController( + view: view, + targetResolver: { _ in target }, + prewarm: { _, _ in }, + attach: { _, _, _, _, _ in + .init(id: UUID(), loadState: .finished) + }, + detach: { _ in detachCount += 1 }, + delayMilliseconds: { 650 }, + animateDismissal: false, + sleep: { _ in } + ) + + controller.update( + rawURL: url.absoluteString, + sourceWorkspaceId: UUID(), + sourcePanelId: UUID(), + anchorPoint: NSPoint(x: 450, y: 325) + ) + await Task.yield() + await Task.yield() + #expect(view.isPreviewVisible) + + controller.update( + rawURL: nil, + sourceWorkspaceId: nil, + sourcePanelId: nil, + anchorPoint: .zero + ) + controller.previewPointerDidChange(isInside: true) + await Task.yield() + await Task.yield() + + #expect(view.isPreviewVisible) + #expect(detachCount == 0) + + controller.previewPointerDidChange(isInside: false) + await Task.yield() + await Task.yield() + + #expect(!view.isPreviewVisible) + #expect(detachCount == 1) + } + + @Test func interactingWithPreviewPinsItUntilAnOutsidePress() async throws { + let url = try #require(URL(string: "https://example.com/pinned-preview")) + let target = TerminalLinkOpenCoordinator.PreviewTarget(url: url, profileID: profileID) + let view = TerminalLinkHoverIndicatorView( + frame: NSRect(x: 0, y: 0, width: 900, height: 650) + ) + var detachCount = 0 + let controller = TerminalLinkPreviewController( + view: view, + targetResolver: { _ in target }, + prewarm: { _, _ in }, + attach: { _, _, _, _, _ in + .init(id: UUID(), loadState: .finished) + }, + detach: { _ in detachCount += 1 }, + delayMilliseconds: { 650 }, + animateDismissal: false, + sleep: { _ in } + ) + + controller.update( + rawURL: url.absoluteString, + sourceWorkspaceId: UUID(), + sourcePanelId: UUID(), + anchorPoint: NSPoint(x: 450, y: 325) + ) + await Task.yield() + await Task.yield() + controller.previewPointerDidPress(isInside: true) + controller.update( + rawURL: nil, + sourceWorkspaceId: nil, + sourcePanelId: nil, + anchorPoint: .zero + ) + controller.previewPointerDidChange(isInside: false) + await Task.yield() + await Task.yield() + + #expect(view.isPreviewVisible) + #expect(detachCount == 0) + + controller.previewPointerDidPress(isInside: false) + + #expect(!view.isPreviewVisible) + #expect(detachCount == 1) + } + + @Test func focusedPreviewSurvivesPointerExitAndReleasesFocusOnOutsidePress() async throws { + let url = try #require(URL(string: "https://example.com/focused-preview")) + let target = TerminalLinkOpenCoordinator.PreviewTarget(url: url, profileID: profileID) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 650), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + defer { window.close() } + let view = TerminalLinkHoverIndicatorView(frame: window.contentView?.bounds ?? .zero) + window.contentView = view + var detachCount = 0 + let controller = TerminalLinkPreviewController( + view: view, + targetResolver: { _ in target }, + prewarm: { _, _ in }, + attach: { _, _, _, _, _ in + .init(id: UUID(), loadState: .finished) + }, + detach: { _ in detachCount += 1 }, + delayMilliseconds: { 650 }, + animateDismissal: false, + sleep: { _ in } + ) + + controller.update( + rawURL: url.absoluteString, + sourceWorkspaceId: UUID(), + sourcePanelId: UUID(), + anchorPoint: NSPoint(x: 450, y: 325) + ) + await Task.yield() + await Task.yield() + + let focusedControl = NSTextField(frame: view.previewWebViewHost.bounds) + view.previewWebViewHost.addSubview(focusedControl) + #expect(window.makeFirstResponder(focusedControl)) + #expect(view.previewOwnsFirstResponder) + + controller.update( + rawURL: nil, + sourceWorkspaceId: nil, + sourcePanelId: nil, + anchorPoint: .zero + ) + controller.previewPointerDidChange(isInside: false) + await Task.yield() + await Task.yield() + + #expect(view.isPreviewVisible) + #expect(detachCount == 0) + + controller.previewPointerDidPress(isInside: false) + + #expect(!view.isPreviewVisible) + #expect(!view.previewOwnsFirstResponder) + #expect(detachCount == 1) + } + @Test func movingWithinTheSameURLDoesNotRestartDwell() async throws { let url = try #require(URL(string: "https://example.com/steady-hover")) let target = TerminalLinkOpenCoordinator.PreviewTarget(url: url, profileID: profileID) From 52d6ec67b541eb7c17028e47aad5ea871bd19a45 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:21:43 -0700 Subject: [PATCH 06/12] fix: keep preview interactive during fades --- Sources/GhosttyTerminalViewSupport.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index 62d7689d8292..fff642c3014a 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -50,7 +50,10 @@ final class TerminalLinkHoverIndicatorView: NSView { guard !isHidden, !previewShadowView.isHidden, previewShadowView.frame.contains(point) else { return nil } - return previewShadowView.hitTest(convert(point, to: previewShadowView)) + // NSView suppresses its own hit testing while its presentation alpha + // is near zero. Route through the card content so the webview is live + // throughout both fades, including when a re-entry cancels dismissal. + return previewBackdrop.hitTest(convert(point, to: previewBackdrop)) ?? previewShadowView } override init(frame frameRect: NSRect) { From 060726ee65a5d52f707e6ee88cbccc970f3297ab Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:32:17 -0700 Subject: [PATCH 07/12] test: isolate preview focus lifecycle --- Sources/TerminalLinkPreviewController.swift | 18 ++++++++++--- .../BrowserPrewarmedWebViewPoolTests.swift | 27 +++++++++---------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/Sources/TerminalLinkPreviewController.swift b/Sources/TerminalLinkPreviewController.swift index 4a510b4282d4..4827e6467b98 100644 --- a/Sources/TerminalLinkPreviewController.swift +++ b/Sources/TerminalLinkPreviewController.swift @@ -17,6 +17,8 @@ final class TerminalLinkPreviewController { ) -> BrowserPrewarmedWebViewPool.PreviewAttachment? typealias Detach = @MainActor (BrowserPrewarmedWebViewPool.PreviewAttachment) -> Void typealias Sleep = @Sendable (Duration) async throws -> Void + typealias PreviewOwnsFocus = @MainActor () -> Bool + typealias ReleasePreviewFocus = @MainActor () -> Void private weak var view: TerminalLinkHoverIndicatorView? private let targetResolver: TargetResolver @@ -26,6 +28,8 @@ final class TerminalLinkPreviewController { private let delayMilliseconds: @MainActor () -> Int private let dismissalGraceMilliseconds: Int private let animateDismissal: Bool + private let previewOwnsFocus: PreviewOwnsFocus + private let releasePreviewFocus: ReleasePreviewFocus private let sleep: Sleep private var currentRawURL: String? @@ -62,6 +66,8 @@ final class TerminalLinkPreviewController { delayMilliseconds: (@MainActor () -> Int)? = nil, dismissalGraceMilliseconds: Int = 200, animateDismissal: Bool = true, + previewOwnsFocus: PreviewOwnsFocus? = nil, + releasePreviewFocus: ReleasePreviewFocus? = nil, sleep: @escaping Sleep = { duration in try await Task.sleep(for: duration) } ) { self.view = view @@ -76,6 +82,12 @@ final class TerminalLinkPreviewController { } self.dismissalGraceMilliseconds = dismissalGraceMilliseconds self.animateDismissal = animateDismissal + self.previewOwnsFocus = previewOwnsFocus ?? { [weak view] in + view?.previewOwnsFirstResponder == true + } + self.releasePreviewFocus = releasePreviewFocus ?? { [weak view] in + view?.resignPreviewFirstResponderIfNeeded() + } self.sleep = sleep view.onPreviewPointerChange = { [weak self] isInside in self?.previewPointerDidChange(isInside: isInside) @@ -161,7 +173,7 @@ final class TerminalLinkPreviewController { cancelPendingDismissalAndRestorePreview() } else if !isSourceLinkHovered, !isInteractionPinned, - view?.previewOwnsFirstResponder != true { + !previewOwnsFocus() { schedulePreviewDismissal() } } @@ -237,7 +249,7 @@ final class TerminalLinkPreviewController { } guard !isPointerInsidePreview, !isInteractionPinned, - view?.previewOwnsFirstResponder != true else { return } + !previewOwnsFocus() else { return } schedulePreviewDismissal() } @@ -281,7 +293,7 @@ final class TerminalLinkPreviewController { private func completePreviewDismissal(generation dismissedGeneration: UInt64) { guard generation == dismissedGeneration, isDismissalAnimating else { return } isDismissalAnimating = false - view?.resignPreviewFirstResponderIfNeeded() + releasePreviewFocus() if let attachment { self.attachment = nil detach(attachment) diff --git a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift index 3145156acc14..33cb2f793132 100644 --- a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift +++ b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift @@ -422,16 +422,12 @@ struct BrowserPrewarmedWebViewPoolTests { @Test func focusedPreviewSurvivesPointerExitAndReleasesFocusOnOutsidePress() async throws { let url = try #require(URL(string: "https://example.com/focused-preview")) let target = TerminalLinkOpenCoordinator.PreviewTarget(url: url, profileID: profileID) - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 900, height: 650), - styleMask: [.titled], - backing: .buffered, - defer: false - ) - defer { window.close() } - let view = TerminalLinkHoverIndicatorView(frame: window.contentView?.bounds ?? .zero) - window.contentView = view + let view = TerminalLinkHoverIndicatorView( + frame: NSRect(x: 0, y: 0, width: 900, height: 650) + ) var detachCount = 0 + var ownsPreviewFocus = true + var releaseFocusCount = 0 let controller = TerminalLinkPreviewController( view: view, targetResolver: { _ in target }, @@ -442,6 +438,11 @@ struct BrowserPrewarmedWebViewPoolTests { detach: { _ in detachCount += 1 }, delayMilliseconds: { 650 }, animateDismissal: false, + previewOwnsFocus: { ownsPreviewFocus }, + releasePreviewFocus: { + ownsPreviewFocus = false + releaseFocusCount += 1 + }, sleep: { _ in } ) @@ -454,11 +455,6 @@ struct BrowserPrewarmedWebViewPoolTests { await Task.yield() await Task.yield() - let focusedControl = NSTextField(frame: view.previewWebViewHost.bounds) - view.previewWebViewHost.addSubview(focusedControl) - #expect(window.makeFirstResponder(focusedControl)) - #expect(view.previewOwnsFirstResponder) - controller.update( rawURL: nil, sourceWorkspaceId: nil, @@ -475,7 +471,8 @@ struct BrowserPrewarmedWebViewPoolTests { controller.previewPointerDidPress(isInside: false) #expect(!view.isPreviewVisible) - #expect(!view.previewOwnsFirstResponder) + #expect(!ownsPreviewFocus) + #expect(releaseFocusCount == 1) #expect(detachCount == 1) } From bd053bc640d13f1d9c4aeb890b32ac966a5a784d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:05:52 -0700 Subject: [PATCH 08/12] test: require menu-style preview hover bridge --- .../BrowserPrewarmedWebViewPoolTests.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift index 33cb2f793132..84e8c33c46a3 100644 --- a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift +++ b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift @@ -244,6 +244,35 @@ struct BrowserPrewarmedWebViewPoolTests { #expect(view.hitTest(NSPoint(x: 20, y: 20)) == nil) } + @Test func previewCardOpensBesidePointerWithPassthroughHoverBridge() throws { + let url = try #require(URL(string: "https://example.com/menu-style-preview")) + let anchor = NSPoint(x: 450, y: 325) + let root = NSView(frame: NSRect(x: 0, y: 0, width: 900, height: 650)) + let view = TerminalLinkHoverIndicatorView(frame: root.bounds) + root.addSubview(view) + #expect(view.preparePreview(url: url, at: anchor)) + + let card = try #require(view.previewWebViewHost.superview?.superview) + let cardFrame = card.convert(card.bounds, to: view) + let nearestCardPoint = NSPoint( + x: min(max(anchor.x, cardFrame.minX), cardFrame.maxX), + y: min(max(anchor.y, cardFrame.minY), cardFrame.maxY) + ) + let pointerDistance = hypot( + nearestCardPoint.x - anchor.x, + nearestCardPoint.y - anchor.y + ) + let bridgePoint = NSPoint( + x: (anchor.x + nearestCardPoint.x) / 2, + y: (anchor.y + nearestCardPoint.y) / 2 + ) + + #expect(pointerDistance <= 8) + #expect(view.trackingAreas.contains { $0.rect.contains(bridgePoint) }) + #expect(!cardFrame.contains(bridgePoint)) + #expect(view.hitTest(bridgePoint) == nil) + } + @Test func delayedPreviewStartsAfterDwellAndLeavingFirstCancelsIt() async throws { let url = try #require(URL(string: "https://example.com/preview")) let target = TerminalLinkOpenCoordinator.PreviewTarget(url: url, profileID: profileID) From e77392839a3708e8e10256c5336de4ebe18ee28e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:20:54 -0700 Subject: [PATCH 09/12] fix: add menu-style preview hover bridge --- Sources/GhosttyTerminalViewSupport.swift | 68 +++++++++++++++++------- 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index fff642c3014a..d33a996b1af1 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -13,7 +13,9 @@ final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { final class TerminalLinkHoverIndicatorView: NSView { private enum Metrics { static let edgeInset: CGFloat = 12 - static let pointerGap: CGFloat = 16 + static let pointerGap: CGFloat = 8 + static let pointerInsetFromTopEdge: CGFloat = 24 + static let hoverHitSlop: CGFloat = 16 static let preferredWidth: CGFloat = 420 static let preferredHeight: CGFloat = 286 static let minimumWidth: CGFloat = 280 @@ -138,6 +140,12 @@ final class TerminalLinkHoverIndicatorView: NSView { onPreviewPointerChange?(false) } + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + guard event.trackingArea === previewTrackingArea else { return } + onPreviewPointerChange?(true) + } + override func layout() { super.layout() guard previewURL != nil else { return } @@ -284,20 +292,6 @@ final class TerminalLinkHoverIndicatorView: NSView { previewBackdrop.addSubview(footerLabel) previewBackdrop.addSubview(loadingBackdrop) loadingBackdrop.addSubview(loadingSpinner) - - let trackingArea = NSTrackingArea( - rect: .zero, - options: [ - .mouseEnteredAndExited, - .activeInKeyWindow, - .inVisibleRect, - .enabledDuringMouseDrag, - ], - owner: self, - userInfo: nil - ) - previewShadowView.addTrackingArea(trackingArea) - previewTrackingArea = trackingArea } private func installPointerDownMonitor() { @@ -326,6 +320,7 @@ final class TerminalLinkHoverIndicatorView: NSView { previewShadowView.layer?.removeAllAnimations() previewShadowView.alphaValue = 0 previewShadowView.isHidden = true + removePreviewTrackingArea() statusBackdrop.isHidden = statusLabel.stringValue.isEmpty isHidden = statusLabel.stringValue.isEmpty completion?() @@ -351,10 +346,7 @@ final class TerminalLinkHoverIndicatorView: NSView { } x = min(max(x, usableBounds.minX), usableBounds.maxX - size.width) - var y = anchor.y + Metrics.pointerGap - if y + size.height > usableBounds.maxY { - y = anchor.y - Metrics.pointerGap - size.height - } + var y = anchor.y - size.height + Metrics.pointerInsetFromTopEdge y = min(max(y, usableBounds.minY), usableBounds.maxY - size.height) previewShadowView.frame = NSRect(origin: NSPoint(x: x, y: y), size: size) @@ -384,6 +376,44 @@ final class TerminalLinkHoverIndicatorView: NSView { width: max(0, size.width - 42), height: 17 ) + updatePreviewTrackingArea() + } + + private func updatePreviewTrackingArea() { + // The card is closer to the pointer than this halo is wide, and its + // vertical edge straddles the pointer. The expanded rect therefore + // forms one continuous, menu-like hover bridge into the live webview. + // hitTest still accepts only the visible card, so the bridge does not + // consume terminal selection or clicks. + let trackingRect = previewShadowView.frame + .insetBy(dx: -Metrics.hoverHitSlop, dy: -Metrics.hoverHitSlop) + .intersection(bounds) + guard !trackingRect.isNull, !trackingRect.isEmpty else { + removePreviewTrackingArea() + return + } + guard previewTrackingArea?.rect != trackingRect else { return } + + removePreviewTrackingArea() + let trackingArea = NSTrackingArea( + rect: trackingRect, + options: [ + .mouseEnteredAndExited, + .mouseMoved, + .activeInKeyWindow, + .enabledDuringMouseDrag, + ], + owner: self, + userInfo: nil + ) + addTrackingArea(trackingArea) + previewTrackingArea = trackingArea + } + + private func removePreviewTrackingArea() { + guard let previewTrackingArea else { return } + removeTrackingArea(previewTrackingArea) + self.previewTrackingArea = nil } } From 468e0329d85d1667936eca6c8353ee16b6a902c7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:47:08 -0700 Subject: [PATCH 10/12] fix: anchor previews to terminal pointer events --- Sources/GhosttyTerminalView.swift | 15 +++++++++++++ Sources/GhosttyTerminalViewSupport.swift | 4 +++- .../BrowserPrewarmedWebViewPoolTests.swift | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 9fbc1ad16472..adfb6f5a64d0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -6768,6 +6768,21 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { lastKnownMousePointInView = point } + /// Ghostty reports link hover asynchronously, so use the point captured + /// from the source mouse event instead of resampling the window afterward. + func terminalLinkPreviewAnchor(in previewView: NSView) -> NSPoint? { + guard let point = lastKnownMousePointInView, + let window, + previewView.window === window else { return nil } + return previewView.convert(point, from: self) + } + +#if DEBUG + func setTerminalLinkPreviewPointerPointForTesting(_ point: NSPoint?) { + lastKnownMousePointInView = point + } +#endif + private func preferredPointerPoint(from eventPoint: NSPoint? = nil) -> NSPoint? { if let eventPoint, pointIsUsableForWordResolution(eventPoint) { lastKnownMousePointInView = eventPoint diff --git a/Sources/GhosttyTerminalViewSupport.swift b/Sources/GhosttyTerminalViewSupport.swift index d33a996b1af1..36e447b7304c 100644 --- a/Sources/GhosttyTerminalViewSupport.swift +++ b/Sources/GhosttyTerminalViewSupport.swift @@ -429,7 +429,9 @@ extension GhosttySurfaceScrollView { return } let anchor: NSPoint - if let window { + if let trackedAnchor = surfaceView.terminalLinkPreviewAnchor(in: linkHoverIndicatorView) { + anchor = trackedAnchor + } else if let window { anchor = linkHoverIndicatorView.convert( window.mouseLocationOutsideOfEventStream, from: nil diff --git a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift index 84e8c33c46a3..a1684d005de5 100644 --- a/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift +++ b/cmuxTests/BrowserPrewarmedWebViewPoolTests.swift @@ -273,6 +273,27 @@ struct BrowserPrewarmedWebViewPoolTests { #expect(view.hitTest(bridgePoint) == nil) } + @Test func previewAnchorUsesTheTerminalSourceEventPoint() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 650), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + let root = try #require(window.contentView) + let surface = GhosttyNSView(frame: NSRect(x: 80, y: 60, width: 700, height: 500)) + let preview = TerminalLinkHoverIndicatorView( + frame: NSRect(x: 20, y: 10, width: 800, height: 600) + ) + root.addSubview(surface) + root.addSubview(preview) + surface.setTerminalLinkPreviewPointerPointForTesting(NSPoint(x: 140, y: 410)) + + let anchor = try #require(surface.terminalLinkPreviewAnchor(in: preview)) + + #expect(anchor == NSPoint(x: 200, y: 460)) + } + @Test func delayedPreviewStartsAfterDwellAndLeavingFirstCancelsIt() async throws { let url = try #require(URL(string: "https://example.com/preview")) let target = TerminalLinkOpenCoordinator.PreviewTarget(url: url, profileID: profileID) From 7e0a61d4bc6172a4c8cf540b66c41a5c7bb17d57 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:07:11 -0700 Subject: [PATCH 11/12] test: preserve preview web input focus --- .../ForeignFirstResponderPolicyTests.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cmuxTests/ForeignFirstResponderPolicyTests.swift b/cmuxTests/ForeignFirstResponderPolicyTests.swift index ef0988ef3974..3f275385fa02 100644 --- a/cmuxTests/ForeignFirstResponderPolicyTests.swift +++ b/cmuxTests/ForeignFirstResponderPolicyTests.swift @@ -1,5 +1,6 @@ import AppKit import Testing +import WebKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -77,6 +78,26 @@ import Testing #expect(shouldRespectForeignFirstResponder(view, in: window, isRightSidebarOwner: alwaysSidebarOwner)) } + @Test func respectsInWindowWebContentResponder() throws { + let window = makeWindow() + let contentView = try #require(window.contentView) + let webView = CmuxWebView( + frame: contentView.bounds, + configuration: WKWebViewConfiguration() + ) + let webContentResponder = NSView(frame: webView.bounds) + contentView.addSubview(webView) + webView.addSubview(webContentResponder) + + #expect( + shouldRespectForeignFirstResponder( + webContentResponder, + in: window, + isRightSidebarOwner: neverSidebarOwner + ) + ) + } + /// The #5269 regression for the sidebar/dock flavor: a sidebar host stranded in another window /// must NOT be respected. Without the window-membership check this returns `true`. @Test func reclaimsFromStrandedRightSidebarOwner() { From d1146e6db5abdca87c3a359c3d25e62e22ccfcdf Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:18:07 -0700 Subject: [PATCH 12/12] fix: preserve interactive web input focus --- Sources/App/ForeignFirstResponderPolicy.swift | 33 ++++++++++++++++--- Sources/Panels/CmuxWebView.swift | 7 +++- .../ForeignFirstResponderPolicyTests.swift | 21 ++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/Sources/App/ForeignFirstResponderPolicy.swift b/Sources/App/ForeignFirstResponderPolicy.swift index b845ae529f36..e582096428a1 100644 --- a/Sources/App/ForeignFirstResponderPolicy.swift +++ b/Sources/App/ForeignFirstResponderPolicy.swift @@ -1,14 +1,35 @@ import AppKit +/// Marks an interactive view subtree whose active responder must keep keyboard +/// focus even while a terminal remains the selected panel. This is intentionally +/// a shared opt-in instead of a preview-specific check so any embedded surface +/// can participate without teaching terminal focus repair about its concrete type. +protocol TerminalFocusRepairExclusionRegion: AnyObject { + var excludesTerminalFocusRepair: Bool { get } +} + +@MainActor +private func isInsideTerminalFocusRepairExclusionRegion(_ responder: NSResponder) -> Bool { + guard var view = responder as? NSView else { return false } + while true { + if let region = view as? TerminalFocusRepairExclusionRegion, + region.excludesTerminalFocusRepair { + return true + } + guard let superview = view.superview else { return false } + view = superview + } +} + /// Whether an active terminal surface should *yield* to `firstResponder` instead of taking first /// responder itself when reconciling focus (used by `GhosttySurfaceScrollView.ensureFocus` and the /// find-overlay focus apply). /// /// A terminal yields only to a *legitimate* focus owner: a focused text editor (`NSText` field -/// editor) or a right-sidebar / dock / feed host whose window is either `window` or an attached -/// child of `window`. AppKit hosts popover field editors in an `_NSPopoverWindow` child while -/// leaving the main window as key, so child-window membership is the designed arrangement for -/// popover typing. +/// editor), a responder inside a ``TerminalFocusRepairExclusionRegion``, or a right-sidebar / dock / +/// feed host whose window is either `window` or an attached child of `window`. AppKit hosts popover +/// field editors in an `_NSPopoverWindow` child while leaving the main window as key, so child-window +/// membership is the designed arrangement for popover typing. /// /// cmux hosts terminal surfaces through a portal that reparents views between windows; a focus owner /// can be reparented out of a window without resigning, leaving `window.firstResponder` pointing at @@ -44,7 +65,9 @@ func shouldRespectForeignFirstResponder( // belongs to this window and must not block the terminal from reclaiming first responder. guard let responderWindow = (firstResponder as? NSView)?.window, window.containsAttachedWindow(responderWindow) else { return false } - return firstResponder is NSText || isRightSidebarOwner(firstResponder) + return firstResponder is NSText + || isInsideTerminalFocusRepairExclusionRegion(firstResponder) + || isRightSidebarOwner(firstResponder) } fileprivate extension NSWindow { diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 57b75b938749..fb2933a3b5af 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -9,7 +9,7 @@ import WebKit /// Route app/menu shortcuts first, but allow browser content to try browser-local /// Find shortcuts. The configured shortcut stays app-owned so cmux can choose browser /// find or right-sidebar file search from the current focus owner. -final class CmuxWebView: WKWebView { +final class CmuxWebView: WKWebView, TerminalFocusRepairExclusionRegion { var browserViewportModel: BrowserViewportModel? var onBrowserViewportHierarchyChanged: (() -> Void)? @@ -313,6 +313,11 @@ final class CmuxWebView: WKWebView { var allowsFirstResponderAcquisitionEffective: Bool { allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0 } + /// Keep terminal key repair aligned with the same focus policy that admits + /// WebKit first responder changes. Unfocused/background panes remain reclaimable. + var excludesTerminalFocusRepair: Bool { + allowsFirstResponderAcquisitionEffective + } var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth } override init(frame: NSRect, configuration: WKWebViewConfiguration) { diff --git a/cmuxTests/ForeignFirstResponderPolicyTests.swift b/cmuxTests/ForeignFirstResponderPolicyTests.swift index 3f275385fa02..1ccb82876892 100644 --- a/cmuxTests/ForeignFirstResponderPolicyTests.swift +++ b/cmuxTests/ForeignFirstResponderPolicyTests.swift @@ -98,6 +98,27 @@ import WebKit ) } + @Test func reclaimsFromWebContentThatCannotAcquireFocus() throws { + let window = makeWindow() + let contentView = try #require(window.contentView) + let webView = CmuxWebView( + frame: contentView.bounds, + configuration: WKWebViewConfiguration() + ) + let webContentResponder = NSView(frame: webView.bounds) + webView.allowsFirstResponderAcquisition = false + contentView.addSubview(webView) + webView.addSubview(webContentResponder) + + #expect( + !shouldRespectForeignFirstResponder( + webContentResponder, + in: window, + isRightSidebarOwner: neverSidebarOwner + ) + ) + } + /// The #5269 regression for the sidebar/dock flavor: a sidebar host stranded in another window /// must NOT be respected. Without the window-membership check this returns `true`. @Test func reclaimsFromStrandedRightSidebarOwner() {