Merge remote-tracking branch 'origin/main' into feature/sidebar-pr-metadata

# Conflicts:
#	Sources/ContentView.swift
#	Sources/Workspace.swift
This commit is contained in:
Lawrence Chen 2026-02-24 20:49:29 -08:00
commit f28eb00b31
92 changed files with 22498 additions and 734 deletions

File diff suppressed because it is too large Load diff

View file

@ -117,6 +117,9 @@ final class WindowBrowserHostView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
updateDividerCursor(at: point)
if shouldPassThroughToTitlebar(at: point) {
return nil
}
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
@ -127,6 +130,18 @@ final class WindowBrowserHostView: NSView {
return hitView === self ? nil : hitView
}
private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool {
guard let window else { return false }
// Window-level portal hosts sit above SwiftUI content. Never intercept
// hits that land in native titlebar space or the custom titlebar strip
// we reserve directly under it for window drag/double-click behaviors.
let windowPoint = convert(point, to: nil)
let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height
let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight))
let interactionBandMinY = window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5
return windowPoint.y >= interactionBandMinY
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// Browser portal host sits above SwiftUI content. Allow pointer/mouse events
// to reach the SwiftUI sidebar divider resizer zone.
@ -326,6 +341,8 @@ final class WindowBrowserPortal: NSObject {
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var hasDeferredFullSyncScheduled = false
private var hasExternalGeometrySyncScheduled = false
private var geometryObservers: [NSObjectProtocol] = []
private struct Entry {
weak var webView: WKWebView?
@ -345,9 +362,73 @@ final class WindowBrowserPortal: NSObject {
hostView.layer?.masksToBounds = true
hostView.translatesAutoresizingMaskIntoConstraints = true
hostView.autoresizingMask = []
installGeometryObservers(for: window)
_ = ensureInstalled()
}
private func installGeometryObservers(for window: NSWindow) {
guard geometryObservers.isEmpty else { return }
let center = NotificationCenter.default
geometryObservers.append(center.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSWindow.didEndLiveResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSSplitView.didResizeSubviewsNotification,
object: nil,
queue: .main
) { [weak self] notification in
MainActor.assumeIsolated {
guard let self,
let splitView = notification.object as? NSSplitView,
let window = self.window,
splitView.window === window else { return }
self.scheduleExternalGeometrySynchronize()
}
})
}
private func removeGeometryObservers() {
for observer in geometryObservers {
NotificationCenter.default.removeObserver(observer)
}
geometryObservers.removeAll()
}
private func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasExternalGeometrySyncScheduled = false
self.synchronizeAllEntriesFromExternalGeometryChange()
}
}
private func synchronizeAllEntriesFromExternalGeometryChange() {
guard ensureInstalled() else { return }
installedContainerView?.layoutSubtreeIfNeeded()
installedReferenceView?.layoutSubtreeIfNeeded()
hostView.superview?.layoutSubtreeIfNeeded()
hostView.layoutSubtreeIfNeeded()
synchronizeAllWebViews(excluding: nil, source: "externalGeometry")
}
@discardableResult
private func ensureInstalled() -> Bool {
guard let window else { return false }
@ -419,13 +500,32 @@ final class WindowBrowserPortal: NSObject {
return false
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
guard rect.origin.x.isFinite,
rect.origin.y.isFinite,
rect.size.width.isFinite,
rect.size.height.isFinite else {
return rect
}
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
func snap(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
return NSRect(
x: snap(rect.origin.x),
y: snap(rect.origin.y),
width: max(0, snap(rect.size.width)),
height: max(0, snap(rect.size.height))
)
}
private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
frame.minX < bounds.minX - epsilon ||
frame.minY < bounds.minY - epsilon ||
@ -765,7 +865,8 @@ final class WindowBrowserPortal: NSObject {
_ = synchronizeHostFrameToReference()
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
let frameInHost = hostView.convert(frameInWindow, from: nil)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
@ -838,6 +939,8 @@ final class WindowBrowserPortal: NSObject {
CATransaction.setDisableActions(true)
containerView.frame = targetFrame
CATransaction.commit()
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
}
let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size)
@ -952,6 +1055,7 @@ final class WindowBrowserPortal: NSObject {
}
func tearDown() {
removeGeometryObservers()
for webViewId in Array(entriesByWebViewId.keys) {
detachWebView(withId: webViewId)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ enum KeyboardShortcutSettings {
case toggleSidebar
case newTab
case newWindow
case closeWindow
case showNotifications
case jumpToUnread
case triggerFlash
@ -17,6 +18,7 @@ enum KeyboardShortcutSettings {
case prevSurface
case nextSidebarTab
case prevSidebarTab
case renameTab
case renameWorkspace
case closeWorkspace
case newSurface
@ -43,6 +45,7 @@ enum KeyboardShortcutSettings {
case .toggleSidebar: return "Toggle Sidebar"
case .newTab: return "New Workspace"
case .newWindow: return "New Window"
case .closeWindow: return "Close Window"
case .showNotifications: return "Show Notifications"
case .jumpToUnread: return "Jump to Latest Unread"
case .triggerFlash: return "Flash Focused Panel"
@ -50,6 +53,7 @@ enum KeyboardShortcutSettings {
case .prevSurface: return "Previous Surface"
case .nextSidebarTab: return "Next Workspace"
case .prevSidebarTab: return "Previous Workspace"
case .renameTab: return "Rename Tab"
case .renameWorkspace: return "Rename Workspace"
case .closeWorkspace: return "Close Workspace"
case .newSurface: return "New Surface"
@ -72,11 +76,13 @@ enum KeyboardShortcutSettings {
case .toggleSidebar: return "shortcut.toggleSidebar"
case .newTab: return "shortcut.newTab"
case .newWindow: return "shortcut.newWindow"
case .closeWindow: return "shortcut.closeWindow"
case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash"
case .nextSidebarTab: return "shortcut.nextSidebarTab"
case .prevSidebarTab: return "shortcut.prevSidebarTab"
case .renameTab: return "shortcut.renameTab"
case .renameWorkspace: return "shortcut.renameWorkspace"
case .closeWorkspace: return "shortcut.closeWorkspace"
case .focusLeft: return "shortcut.focusLeft"
@ -104,6 +110,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false)
case .newWindow:
return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false)
case .closeWindow:
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:
@ -114,6 +122,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true)
case .prevSidebarTab:
return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true)
case .renameTab:
return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false)
case .renameWorkspace:
return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false)
case .closeWorkspace:

View file

@ -182,11 +182,11 @@ private struct NotificationRow: View {
Button(action: onOpen) {
HStack(alignment: .top, spacing: 12) {
Circle()
.fill(notification.isRead ? Color.clear : Color.accentColor)
.fill(notification.isRead ? Color.clear : cmuxAccentColor())
.frame(width: 8, height: 8)
.overlay(
Circle()
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
.stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
)
.padding(.top, 6)

View file

@ -127,6 +127,9 @@ enum BrowserLinkOpenSettings {
static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser"
static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true
static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser"
static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true
static let browserHostWhitelistKey = "browserHostWhitelist"
static let defaultBrowserHostWhitelist: String = ""
@ -137,6 +140,23 @@ enum BrowserLinkOpenSettings {
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
}
static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil {
return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey)
}
// Migrate existing behavior for users who only had the link-click toggle.
if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) != nil {
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
}
return defaultInterceptTerminalOpenCommandInCmuxBrowser
}
static func initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: UserDefaults = .standard) -> Bool {
interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)
}
static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] {
let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist
return raw
@ -360,6 +380,21 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest {
return preparedRequest
}
private let browserEmbeddedNavigationSchemes: Set<String> = [
"about",
"applewebdata",
"blob",
"data",
"http",
"https",
"javascript",
]
func browserShouldOpenURLExternally(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false }
return !browserEmbeddedNavigationSchemes.contains(scheme)
}
enum BrowserUserAgentSettings {
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
@ -1151,6 +1186,13 @@ final class BrowserPanel: Panel, ObservableObject {
/// Published can go forward state
@Published private(set) var canGoForward: Bool = false
private var nativeCanGoBack: Bool = false
private var nativeCanGoForward: Bool = false
private var usesRestoredSessionHistory: Bool = false
private var restoredBackHistoryStack: [URL] = []
private var restoredForwardHistoryStack: [URL] = []
private var restoredHistoryCurrentURL: URL?
/// Published estimated progress (0.0 - 1.0)
@Published private(set) var estimatedProgress: Double = 0.0
@ -1353,6 +1395,43 @@ final class BrowserPanel: Panel, ObservableObject {
focusFlashToken &+= 1
}
func sessionNavigationHistorySnapshot() -> (
backHistoryURLStrings: [String],
forwardHistoryURLStrings: [String]
) {
if usesRestoredSessionHistory {
let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) }
// `restoredForwardHistoryStack` stores nearest-forward entries at the end.
let forward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) }
return (back, forward)
}
let back = webView.backForwardList.backList.compactMap {
Self.serializableSessionHistoryURLString($0.url)
}
let forward = webView.backForwardList.forwardList.compactMap {
Self.serializableSessionHistoryURLString($0.url)
}
return (back, forward)
}
func restoreSessionNavigationHistory(
backHistoryURLStrings: [String],
forwardHistoryURLStrings: [String],
currentURLString: String?
) {
let restoredBack = Self.sanitizedSessionHistoryURLs(backHistoryURLStrings)
let restoredForward = Self.sanitizedSessionHistoryURLs(forwardHistoryURLStrings)
guard !restoredBack.isEmpty || !restoredForward.isEmpty else { return }
usesRestoredSessionHistory = true
restoredBackHistoryStack = restoredBack
// Store nearest-forward entries at the end to make stack pop operations trivial.
restoredForwardHistoryStack = Array(restoredForward.reversed())
restoredHistoryCurrentURL = Self.sanitizedSessionHistoryURL(currentURLString)
refreshNavigationAvailability()
}
private func setupObservers() {
// URL changes
let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in
@ -1386,7 +1465,9 @@ final class BrowserPanel: Panel, ObservableObject {
// Can go back
let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
self?.canGoBack = webView.canGoBack
guard let self else { return }
self.nativeCanGoBack = webView.canGoBack
self.refreshNavigationAvailability()
}
}
webViewObservers.append(backObserver)
@ -1394,7 +1475,9 @@ final class BrowserPanel: Panel, ObservableObject {
// Can go forward
let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
self?.canGoForward = webView.canGoForward
guard let self else { return }
self.nativeCanGoForward = webView.canGoForward
self.refreshNavigationAvailability()
}
}
webViewObservers.append(forwardObserver)
@ -1612,6 +1695,9 @@ final class BrowserPanel: Panel, ObservableObject {
faviconTask?.cancel()
faviconTask = nil
lastFaviconURLString = nil
// Clear the previous page's favicon so it never persists across navigations.
// The loading spinner covers this gap; didFinish will fetch the new favicon.
faviconPNGData = nil
loadingGeneration &+= 1
loadingEndWorkItem?.cancel()
loadingEndWorkItem = nil
@ -1657,13 +1743,28 @@ final class BrowserPanel: Panel, ObservableObject {
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
}
private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) {
private func navigateWithoutInsecureHTTPPrompt(
to url: URL,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool = false
) {
let request = URLRequest(url: url)
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
navigateWithoutInsecureHTTPPrompt(
request: request,
recordTypedNavigation: recordTypedNavigation,
preserveRestoredSessionHistory: preserveRestoredSessionHistory
)
}
private func navigateWithoutInsecureHTTPPrompt(request: URLRequest, recordTypedNavigation: Bool) {
private func navigateWithoutInsecureHTTPPrompt(
request: URLRequest,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool = false
) {
guard let url = request.url else { return }
if !preserveRestoredSessionHistory {
abandonRestoredSessionHistoryIfNeeded()
}
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
shouldRenderWebView = true
@ -1808,26 +1909,90 @@ extension BrowserPanel {
/// Go back in history
func goBack() {
guard canGoBack else { return }
if usesRestoredSessionHistory {
guard let targetURL = restoredBackHistoryStack.popLast() else {
refreshNavigationAvailability()
return
}
if let current = resolvedCurrentSessionHistoryURL() {
restoredForwardHistoryStack.append(current)
}
restoredHistoryCurrentURL = targetURL
refreshNavigationAvailability()
navigateWithoutInsecureHTTPPrompt(
to: targetURL,
recordTypedNavigation: false,
preserveRestoredSessionHistory: true
)
return
}
webView.goBack()
}
/// Go forward in history
func goForward() {
guard canGoForward else { return }
if usesRestoredSessionHistory {
guard let targetURL = restoredForwardHistoryStack.popLast() else {
refreshNavigationAvailability()
return
}
if let current = resolvedCurrentSessionHistoryURL() {
restoredBackHistoryStack.append(current)
}
restoredHistoryCurrentURL = targetURL
refreshNavigationAvailability()
navigateWithoutInsecureHTTPPrompt(
to: targetURL,
recordTypedNavigation: false,
preserveRestoredSessionHistory: true
)
return
}
webView.goForward()
}
/// Open a link in a new browser surface in the same pane
func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) {
guard let tabManager = AppDelegate.shared?.tabManager,
let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }),
let paneId = workspace.paneId(forPanelId: id) else { return }
#if DEBUG
dlog(
"browser.newTab.open.begin panel=\(id.uuidString.prefix(5)) " +
"workspace=\(workspaceId.uuidString.prefix(5)) url=\(url.absoluteString) " +
"bypass=\(bypassInsecureHTTPHostOnce ?? "nil")"
)
#endif
guard let tabManager = AppDelegate.shared?.tabManager else {
#if DEBUG
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingTabManager")
#endif
return
}
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
#if DEBUG
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=workspaceMissing")
#endif
return
}
guard let paneId = workspace.paneId(forPanelId: id) else {
#if DEBUG
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=paneMissing")
#endif
return
}
workspace.newBrowserSurface(
inPane: paneId,
url: url,
focus: true,
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
)
#if DEBUG
dlog(
"browser.newTab.open.done panel=\(id.uuidString.prefix(5)) " +
"workspace=\(workspace.id.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5))"
)
#endif
}
/// Reload the current page
@ -2097,10 +2262,20 @@ extension BrowserPanel {
}
func beginSuppressWebViewFocusForAddressBar() {
if !suppressWebViewFocusForAddressBar {
#if DEBUG
dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))")
#endif
}
suppressWebViewFocusForAddressBar = true
}
func endSuppressWebViewFocusForAddressBar() {
if suppressWebViewFocusForAddressBar {
#if DEBUG
dlog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))")
#endif
}
suppressWebViewFocusForAddressBar = false
}
@ -2140,6 +2315,64 @@ extension BrowserPanel {
return nil
}
private func resolvedCurrentSessionHistoryURL() -> URL? {
if let webViewURL = webView.url,
Self.serializableSessionHistoryURLString(webViewURL) != nil {
return webViewURL
}
if let currentURL,
Self.serializableSessionHistoryURLString(currentURL) != nil {
return currentURL
}
return restoredHistoryCurrentURL
}
private func refreshNavigationAvailability() {
let resolvedCanGoBack: Bool
let resolvedCanGoForward: Bool
if usesRestoredSessionHistory {
resolvedCanGoBack = !restoredBackHistoryStack.isEmpty
resolvedCanGoForward = !restoredForwardHistoryStack.isEmpty
} else {
resolvedCanGoBack = nativeCanGoBack
resolvedCanGoForward = nativeCanGoForward
}
if canGoBack != resolvedCanGoBack {
canGoBack = resolvedCanGoBack
}
if canGoForward != resolvedCanGoForward {
canGoForward = resolvedCanGoForward
}
}
private func abandonRestoredSessionHistoryIfNeeded() {
guard usesRestoredSessionHistory else { return }
usesRestoredSessionHistory = false
restoredBackHistoryStack.removeAll(keepingCapacity: false)
restoredForwardHistoryStack.removeAll(keepingCapacity: false)
restoredHistoryCurrentURL = nil
refreshNavigationAvailability()
}
private static func serializableSessionHistoryURLString(_ url: URL?) -> String? {
guard let url else { return nil }
let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty, value != "about:blank" else { return nil }
return value
}
private static func sanitizedSessionHistoryURL(_ raw: String?) -> URL? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != "about:blank" else { return nil }
return URL(string: trimmed)
}
private static func sanitizedSessionHistoryURLs(_ values: [String]) -> [URL] {
values.compactMap { sanitizedSessionHistoryURL($0) }
}
}
private extension BrowserPanel {
@ -2459,6 +2692,39 @@ private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate {
// MARK: - Navigation Delegate
func browserNavigationShouldOpenInNewTab(
navigationType: WKNavigationType,
modifierFlags: NSEvent.ModifierFlags,
buttonNumber: Int,
hasRecentMiddleClickIntent: Bool = false,
currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type,
currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber
) -> Bool {
guard navigationType == .linkActivated || navigationType == .other else {
return false
}
if modifierFlags.contains(.command) {
return true
}
if buttonNumber == 2 {
return true
}
// In some WebKit paths, middle-click arrives as buttonNumber=4.
// Recover intent when we just observed a local middle-click.
if buttonNumber == 4, hasRecentMiddleClickIntent {
return true
}
// WebKit can omit buttonNumber for middle-click link activations.
if let currentEventType,
(currentEventType == .otherMouseDown || currentEventType == .otherMouseUp),
currentEventButtonNumber == 2 {
return true
}
return false
}
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
var didFinish: ((WKWebView) -> Void)?
var didFailNavigation: ((WKWebView, String) -> Void)?
@ -2481,6 +2747,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
NSLog("BrowserPanel navigation failed: %@", error.localizedDescription)
// Treat committed-navigation failures the same as provisional ones so
// stale favicon/title state from the prior page gets cleared.
let failedURL = webView.url?.absoluteString ?? ""
didFailNavigation?(webView, failedURL)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
@ -2593,38 +2863,89 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView)
let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab(
navigationType: navigationAction.navigationType,
modifierFlags: navigationAction.modifierFlags,
buttonNumber: navigationAction.buttonNumber,
hasRecentMiddleClickIntent: hasRecentMiddleClickIntent
)
#if DEBUG
let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil"
let navType = String(describing: navigationAction.navigationType)
dlog(
"browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " +
"mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " +
"eventType=\(currentEventType) eventButton=\(currentEventButton) " +
"recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " +
"openInNewTab=\(shouldOpenInNewTab ? 1 : 0)"
)
#endif
if let url = navigationAction.request.url,
navigationAction.targetFrame?.isMainFrame != false,
shouldBlockInsecureHTTPNavigation?(url) == true {
let intent: BrowserInsecureHTTPNavigationIntent
if navigationAction.navigationType == .linkActivated,
navigationAction.modifierFlags.contains(.command) {
if shouldOpenInNewTab {
intent = .newTab
} else {
intent = .currentTab
}
#if DEBUG
dlog(
"browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " +
"url=\(url.absoluteString)"
)
#endif
handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent)
decisionHandler(.cancel)
return
}
// target=_blank or window.open() navigate in the current webview
if navigationAction.targetFrame == nil,
navigationAction.request.url != nil {
webView.load(navigationAction.request)
// WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.).
// Hand these off to macOS so the owning app can handle them.
if let url = navigationAction.request.url,
navigationAction.targetFrame?.isMainFrame != false,
browserShouldOpenURLExternally(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
}
#if DEBUG
dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
decisionHandler(.cancel)
return
}
// Cmd+click on a regular link open in a new tab
if navigationAction.navigationType == .linkActivated,
navigationAction.modifierFlags.contains(.command),
// Cmd+click and middle-click on regular links should always open in a new tab.
if shouldOpenInNewTab,
let url = navigationAction.request.url {
#if DEBUG
dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)")
#endif
openInNewTab?(url)
decisionHandler(.cancel)
return
}
// target=_blank or window.open() without explicit new-tab intent navigate in-place.
if navigationAction.targetFrame == nil,
navigationAction.request.url != nil {
#if DEBUG
let targetURL = navigationAction.request.url?.absoluteString ?? "nil"
dlog("browser.nav.decidePolicy.action kind=loadInPlaceFromNilTarget url=\(targetURL)")
#endif
webView.load(navigationAction.request)
decisionHandler(.cancel)
return
}
#if DEBUG
let targetURL = navigationAction.request.url?.absoluteString ?? "nil"
dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)")
#endif
decisionHandler(.allow)
}
@ -2723,21 +3044,62 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
}
/// Returning nil tells WebKit not to open a new window.
/// Cmd+click opens in a new tab; regular target=_blank navigates in-place.
/// Cmd+click and middle-click open in a new tab; regular target=_blank navigates in-place.
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView)
let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab(
navigationType: navigationAction.navigationType,
modifierFlags: navigationAction.modifierFlags,
buttonNumber: navigationAction.buttonNumber,
hasRecentMiddleClickIntent: hasRecentMiddleClickIntent
)
#if DEBUG
let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil"
let navType = String(describing: navigationAction.navigationType)
dlog(
"browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " +
"mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " +
"eventType=\(currentEventType) eventButton=\(currentEventButton) " +
"recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " +
"openInNewTab=\(shouldOpenInNewTab ? 1 : 0)"
)
#endif
if let url = navigationAction.request.url {
if browserShouldOpenURLExternally(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
}
#if DEBUG
dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
return nil
}
if let requestNavigation {
let intent: BrowserInsecureHTTPNavigationIntent =
navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab
shouldOpenInNewTab ? .newTab : .currentTab
#if DEBUG
dlog(
"browser.nav.createWebView.action kind=requestNavigation intent=\(intent == .newTab ? "newTab" : "currentTab") " +
"url=\(url.absoluteString)"
)
#endif
requestNavigation(navigationAction.request, intent)
} else if navigationAction.modifierFlags.contains(.command) {
} else if shouldOpenInNewTab {
#if DEBUG
dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)")
#endif
openInNewTab?(url)
} else {
#if DEBUG
dlog("browser.nav.createWebView.action kind=loadInPlace url=\(url.absoluteString)")
#endif
webView.load(navigationAction.request)
}
}

View file

@ -71,7 +71,7 @@ enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable {
// Matches Bonsplit tab icon tint for active tabs.
return Color(nsColor: .labelColor)
case .accent:
return .accentColor
return cmuxAccentColor()
case .tertiary:
return Color(nsColor: .tertiaryLabelColor)
}
@ -163,6 +163,46 @@ private extension View {
}
}
func resolvedBrowserChromeBackgroundColor(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> NSColor {
switch colorScheme {
case .dark, .light:
return themeBackgroundColor
@unknown default:
return themeBackgroundColor
}
}
func resolvedBrowserChromeColorScheme(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> ColorScheme {
let backgroundColor = resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: themeBackgroundColor
)
return backgroundColor.isLightColor ? .light : .dark
}
func resolvedBrowserOmnibarPillBackgroundColor(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> NSColor {
let darkenMix: CGFloat
switch colorScheme {
case .light:
darkenMix = 0.04
case .dark:
darkenMix = 0.05
@unknown default:
darkenMix = 0.04
}
return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor
}
/// View for rendering a browser panel with address bar
struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel
@ -187,7 +227,7 @@ struct BrowserPanelView: View {
@State private var omnibarHasMarkedText: Bool = false
@State private var suppressNextFocusLostRevert: Bool = false
@State private var focusFlashOpacity: Double = 0.0
@State private var focusFlashFadeWorkItem: DispatchWorkItem?
@State private var focusFlashAnimationGeneration: Int = 0
@State private var omnibarPillFrame: CGRect = .zero
@State private var lastHandledAddressBarFocusRequestId: UUID?
@State private var isBrowserThemeMenuPresented = false
@ -236,14 +276,24 @@ struct BrowserPanelView: View {
}
private var browserChromeBackgroundColor: NSColor {
switch colorScheme {
case .dark:
return GhosttyApp.shared.defaultBackgroundColor
case .light:
return .windowBackgroundColor
@unknown default:
return .windowBackgroundColor
}
resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor
)
}
private var browserChromeColorScheme: ColorScheme {
resolvedBrowserChromeColorScheme(
for: colorScheme,
themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor
)
}
private var omnibarPillBackgroundColor: NSColor {
resolvedBrowserOmnibarPillBackgroundColor(
for: browserChromeColorScheme,
themeBackgroundColor: browserChromeBackgroundColor
)
}
var body: some View {
@ -252,10 +302,10 @@ struct BrowserPanelView: View {
webView
}
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(6)
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}
.overlay(alignment: .topLeading) {
@ -275,8 +325,9 @@ struct BrowserPanelView: View {
}
)
.frame(width: omnibarPillFrame.width)
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6)
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3)
.zIndex(1000)
.environment(\.colorScheme, browserChromeColorScheme)
}
}
.coordinateSpace(name: "BrowserPanelViewSpace")
@ -288,16 +339,15 @@ struct BrowserPanelView: View {
guard let webView = note.object as? CmuxWebView else { return false }
return webView === panel?.webView
}) { _ in
#if DEBUG
dlog(
"browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " +
"isFocused=\(isFocused ? 1 : 0) " +
"addressFocused=\(addressBarFocused ? 1 : 0)"
)
#endif
onRequestPanelFocus()
}
.onReceive(NotificationCenter.default.publisher(for: .webViewMiddleClickedLink).filter { [weak panel] note in
guard let webView = note.object as? CmuxWebView else { return false }
return webView === panel?.webView
}) { note in
if let url = note.userInfo?["url"] as? URL {
panel.openLinkInNewTab(url: url)
}
}
.onAppear {
UserDefaults.standard.register(defaults: [
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
@ -314,6 +364,7 @@ struct BrowserPanelView: View {
syncURLFromPanel()
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
autoFocusOmnibarIfBlank()
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
BrowserHistoryStore.shared.loadIfNeeded()
}
.onChange(of: panel.focusFlashToken) { _ in
@ -353,6 +404,7 @@ struct BrowserPanelView: View {
hideSuggestions()
addressBarFocused = false
}
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
}
.onChange(of: addressBarFocused) { focused in
let urlString = panel.preferredURLStringForOmnibar() ?? ""
@ -380,6 +432,7 @@ struct BrowserPanelView: View {
}
inlineCompletion = nil
}
syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged")
}
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
@ -421,6 +474,7 @@ struct BrowserPanelView: View {
.background(Color(nsColor: browserChromeBackgroundColor))
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
.zIndex(1)
.environment(\.colorScheme, browserChromeColorScheme)
}
private var addressBarButtonBar: some View {
@ -635,11 +689,11 @@ struct BrowserPanelView: View {
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
.fill(Color(nsColor: omnibarPillBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
.stroke(addressBarFocused ? Color.accentColor : Color.clear, lineWidth: 1)
.stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1)
)
.accessibilityElement(children: .contain)
.background {
@ -689,20 +743,42 @@ struct BrowserPanelView: View {
}
private func triggerFocusFlashAnimation() {
focusFlashFadeWorkItem?.cancel()
focusFlashFadeWorkItem = nil
focusFlashAnimationGeneration &+= 1
let generation = focusFlashAnimationGeneration
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
withAnimation(.easeOut(duration: 0.08)) {
focusFlashOpacity = 1.0
}
let item = DispatchWorkItem {
withAnimation(.easeOut(duration: 0.35)) {
focusFlashOpacity = 0.0
for segment in FocusFlashPattern.segments {
DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
guard focusFlashAnimationGeneration == generation else { return }
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
focusFlashOpacity = segment.targetOpacity
}
}
}
focusFlashFadeWorkItem = item
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: item)
}
private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation {
switch curve {
case .easeIn:
return .easeIn(duration: duration)
case .easeOut:
return .easeOut(duration: duration)
}
}
private func syncWebViewResponderPolicyWithViewState(reason: String) {
guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
let next = isFocused && !panel.shouldSuppressWebViewFocus()
if cmuxWebView.allowsFirstResponderAcquisition != next {
#if DEBUG
dlog(
"browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " +
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
"new=\(next ? 1 : 0) reason=\(reason)"
)
#endif
}
cmuxWebView.allowsFirstResponderAcquisition = next
}
private func syncURLFromPanel() {
@ -711,8 +787,32 @@ struct BrowserPanelView: View {
applyOmnibarEffects(effects)
}
private func isCommandPaletteVisibleForPanelWindow() -> Bool {
guard let app = AppDelegate.shared else { return false }
if let window = panel.webView.window, app.isCommandPaletteVisible(for: window) {
return true
}
if let manager = app.tabManagerFor(tabId: panel.workspaceId),
let windowId = app.windowId(for: manager),
let window = app.mainWindow(for: windowId),
app.isCommandPaletteVisible(for: window) {
return true
}
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
return true
}
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
return true
}
return false
}
private func applyPendingAddressBarFocusRequestIfNeeded() {
guard let requestId = panel.pendingAddressBarFocusRequestId else { return }
guard !isCommandPaletteVisibleForPanelWindow() else { return }
guard lastHandledAddressBarFocusRequestId != requestId else { return }
lastHandledAddressBarFocusRequestId = requestId
panel.beginSuppressWebViewFocusForAddressBar()
@ -740,6 +840,7 @@ struct BrowserPanelView: View {
private func autoFocusOmnibarIfBlank() {
guard isFocused else { return }
guard !addressBarFocused else { return }
guard !isCommandPaletteVisibleForPanelWindow() else { return }
// If a test/automation explicitly focused WebKit, don't steal focus back.
guard !panel.shouldSuppressOmnibarAutofocus() else { return }
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
@ -2114,6 +2215,13 @@ struct OmnibarSuggestion: Identifiable, Hashable {
}
}
func browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: Bool,
nextResponderIsOtherTextField: Bool
) -> Bool {
suppressWebViewFocus && !nextResponderIsOtherTextField
}
private final class OmnibarNativeTextField: NSTextField {
var onPointerDown: (() -> Void)?
var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)?
@ -2226,6 +2334,29 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
}
}
private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool {
guard let window, let field = parentField else { return false }
let responder = window.firstResponder
if let editor = responder as? NSTextView,
let delegateField = editor.delegate as? NSTextField {
return delegateField !== field
}
if let textField = responder as? NSTextField {
return textField !== field
}
return false
}
private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool {
return browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: parent.shouldSuppressWebViewFocus(),
nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
)
}
func controlTextDidBeginEditing(_ obj: Notification) {
if !parent.isFocused {
DispatchQueue.main.async {
@ -2238,15 +2369,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
func controlTextDidEndEditing(_ obj: Notification) {
if parent.isFocused {
if parent.shouldSuppressWebViewFocus() {
if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
guard pendingFocusRequest != true else { return }
pendingFocusRequest = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.pendingFocusRequest = nil
guard self.parent.isFocused else { return }
guard self.parent.shouldSuppressWebViewFocus() else { return }
guard let field = self.parentField, let window = field.window else { return }
guard self.shouldReacquireFocusAfterEndEditing(window: window) else {
self.parent.onFieldLostFocus()
return
}
// Check both the field itself AND its field editor (which becomes
// the actual first responder when the text field is being edited).
let fr = window.firstResponder
@ -2559,11 +2693,12 @@ private struct OmnibarSuggestionsView: View {
let searchSuggestionsEnabled: Bool
let onCommit: (OmnibarSuggestion) -> Void
let onHighlight: (Int) -> Void
@Environment(\.colorScheme) private var colorScheme
// Keep radii below the smallest rendered heights so corners don't get
// auto-clamped and visually change as popup height changes.
private let popupCornerRadius: CGFloat = 16
private let rowHighlightCornerRadius: CGFloat = 12
// Keep radii below half of the smallest rendered heights so this keeps a
// squircle silhouette instead of auto-clamping into a capsule.
private let popupCornerRadius: CGFloat = 12
private let rowHighlightCornerRadius: CGFloat = 9
private let singleLineRowHeight: CGFloat = 24
private let rowSpacing: CGFloat = 1
private let topInset: CGFloat = 3
@ -2616,6 +2751,101 @@ private struct OmnibarSuggestionsView: View {
contentHeight > maxPopupHeight
}
private var listTextColor: Color {
switch colorScheme {
case .light:
return Color(nsColor: .labelColor)
case .dark:
return Color.white.opacity(0.9)
@unknown default:
return Color(nsColor: .labelColor)
}
}
private var badgeTextColor: Color {
switch colorScheme {
case .light:
return Color(nsColor: .secondaryLabelColor)
case .dark:
return Color.white.opacity(0.72)
@unknown default:
return Color(nsColor: .secondaryLabelColor)
}
}
private var badgeBackgroundColor: Color {
switch colorScheme {
case .light:
return Color.black.opacity(0.06)
case .dark:
return Color.white.opacity(0.08)
@unknown default:
return Color.black.opacity(0.06)
}
}
private var rowHighlightColor: Color {
switch colorScheme {
case .light:
return Color.black.opacity(0.07)
case .dark:
return Color.white.opacity(0.12)
@unknown default:
return Color.black.opacity(0.07)
}
}
private var popupOverlayGradientColors: [Color] {
switch colorScheme {
case .light:
return [
Color.white.opacity(0.55),
Color.white.opacity(0.2),
]
case .dark:
return [
Color.black.opacity(0.26),
Color.black.opacity(0.14),
]
@unknown default:
return [
Color.white.opacity(0.55),
Color.white.opacity(0.2),
]
}
}
private var popupBorderGradientColors: [Color] {
switch colorScheme {
case .light:
return [
Color.white.opacity(0.65),
Color.black.opacity(0.12),
]
case .dark:
return [
Color.white.opacity(0.22),
Color.white.opacity(0.06),
]
@unknown default:
return [
Color.white.opacity(0.65),
Color.black.opacity(0.12),
]
}
}
private var popupShadowColor: Color {
switch colorScheme {
case .light:
return Color.black.opacity(0.18)
case .dark:
return Color.black.opacity(0.45)
@unknown default:
return Color.black.opacity(0.18)
}
}
@ViewBuilder
private var rowsView: some View {
VStack(spacing: rowSpacing) {
@ -2629,18 +2859,18 @@ private struct OmnibarSuggestionsView: View {
HStack(spacing: 6) {
Text(item.listText)
.font(.system(size: 11))
.foregroundStyle(Color.white.opacity(0.9))
.foregroundStyle(listTextColor)
.lineLimit(1)
.truncationMode(.tail)
if let badge = item.trailingBadgeText {
Text(badge)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(Color.white.opacity(0.72))
.foregroundStyle(badgeTextColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(Color.white.opacity(0.08))
.fill(badgeBackgroundColor)
)
}
Spacer(minLength: 0)
@ -2656,7 +2886,7 @@ private struct OmnibarSuggestionsView: View {
RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous)
.fill(
idx == selectedIndex
? Color.white.opacity(0.12)
? rowHighlightColor
: Color.clear
)
)
@ -2711,10 +2941,7 @@ private struct OmnibarSuggestionsView: View {
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
Color.black.opacity(0.26),
Color.black.opacity(0.14),
],
colors: popupOverlayGradientColors,
startPoint: .top,
endPoint: .bottom
)
@ -2725,18 +2952,16 @@ private struct OmnibarSuggestionsView: View {
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
.stroke(
LinearGradient(
colors: [
Color.white.opacity(0.22),
Color.white.opacity(0.06),
],
colors: popupBorderGradientColors,
startPoint: .top,
endPoint: .bottom
),
lineWidth: 1
)
)
.shadow(color: Color.black.opacity(0.45), radius: 20, y: 10)
.contentShape(Rectangle())
.clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
.shadow(color: popupShadowColor, radius: 20, y: 10)
.contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
.accessibilityElement(children: .contain)
.accessibilityRespondsToUserInteraction(true)
.accessibilityIdentifier("BrowserOmnibarSuggestions")
@ -3035,6 +3260,7 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator: Coordinator,
generation: Int
) {
let retryInterval: TimeInterval = 1.0 / 60.0
// Don't schedule multiple overlapping retries.
guard coordinator.attachRetryWorkItem == nil else { return }
@ -3067,7 +3293,7 @@ struct WebViewRepresentable: NSViewRepresentable {
// Be generous here: bonsplit structural updates can keep a representable
// container off-window longer than a few seconds under load.
if coordinator.attachRetryCount < 400 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) {
scheduleAttachRetry(
webView,
panel: panel,
@ -3104,13 +3330,18 @@ struct WebViewRepresentable: NSViewRepresentable {
}
coordinator.attachRetryWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work)
}
func updateNSView(_ nsView: NSView, context: Context) {
let webView = panel.webView
context.coordinator.panel = panel
context.coordinator.webView = webView
Self.applyWebViewFirstResponderPolicy(
panel: panel,
webView: webView,
isPanelFocused: isPanelFocused
)
let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide()
if shouldUseWindowPortal {
@ -3358,6 +3589,26 @@ struct WebViewRepresentable: NSViewRepresentable {
}
}
private static func applyWebViewFirstResponderPolicy(
panel: BrowserPanel,
webView: WKWebView,
isPanelFocused: Bool
) {
guard let cmuxWebView = webView as? CmuxWebView else { return }
let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
if cmuxWebView.allowsFirstResponderAcquisition != next {
#if DEBUG
dlog(
"browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " +
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
"new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " +
"suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)"
)
#endif
}
cmuxWebView.allowsFirstResponderAcquisition = next
}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.attachRetryWorkItem?.cancel()
coordinator.attachRetryWorkItem = nil

View file

@ -1,4 +1,5 @@
import AppKit
import Bonsplit
import ObjectiveC
import WebKit
@ -7,6 +8,37 @@ import WebKit
/// key equivalents first so app-level shortcuts continue to work when WebKit is
/// the first responder.
final class CmuxWebView: WKWebView {
// Some sites/WebKit paths report middle-click link activations as
// WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local
// middle-click so navigation delegates can recover intent reliably.
private struct MiddleClickIntent {
let webViewID: ObjectIdentifier
let uptime: TimeInterval
}
private static var lastMiddleClickIntent: MiddleClickIntent?
private static let middleClickIntentMaxAge: TimeInterval = 0.8
static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool {
guard let webView = webView as? CmuxWebView else { return false }
guard let intent = lastMiddleClickIntent else { return false }
let age = ProcessInfo.processInfo.systemUptime - intent.uptime
if age > middleClickIntentMaxAge {
lastMiddleClickIntent = nil
return false
}
return intent.webViewID == ObjectIdentifier(webView)
}
private static func recordMiddleClickIntent(for webView: CmuxWebView) {
lastMiddleClickIntent = MiddleClickIntent(
webViewID: ObjectIdentifier(webView),
uptime: ProcessInfo.processInfo.systemUptime
)
}
private final class ContextMenuFallbackBox: NSObject {
weak var target: AnyObject?
let action: Selector?
@ -22,15 +54,78 @@ final class CmuxWebView: WKWebView {
var onContextMenuDownloadStateChanged: ((Bool) -> Void)?
var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)?
var contextMenuDefaultBrowserOpener: ((URL) -> Bool)?
/// Guard against background panes stealing first responder (e.g. page autofocus).
/// BrowserPanelView updates this as pane focus state changes.
var allowsFirstResponderAcquisition: Bool = true
private var pointerFocusAllowanceDepth: Int = 0
var allowsFirstResponderAcquisitionEffective: Bool {
allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0
}
var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth }
override func becomeFirstResponder() -> Bool {
guard allowsFirstResponderAcquisitionEffective else {
#if DEBUG
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
dlog(
"browser.focus.blockedBecome web=\(ObjectIdentifier(self)) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
)
#endif
return false
}
let result = super.becomeFirstResponder()
if result {
NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self)
}
#if DEBUG
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
dlog(
"browser.focus.become web=\(ObjectIdentifier(self)) result=\(result ? 1 : 0) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
)
#endif
return result
}
/// Temporarily permits focus acquisition for explicit pointer-driven interactions
/// (mouse click into this webview) while keeping background autofocus blocked.
func withPointerFocusAllowance(_ body: () -> Void) {
pointerFocusAllowanceDepth += 1
#if DEBUG
dlog(
"browser.focus.pointerAllowance.enter web=\(ObjectIdentifier(self)) " +
"depth=\(pointerFocusAllowanceDepth)"
)
#endif
defer {
pointerFocusAllowanceDepth = max(0, pointerFocusAllowanceDepth - 1)
#if DEBUG
dlog(
"browser.focus.pointerAllowance.exit web=\(ObjectIdentifier(self)) " +
"depth=\(pointerFocusAllowanceDepth)"
)
#endif
}
body()
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not
// route it through app/menu key equivalents, which can trigger unintended actions.
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 {
if event.keyCode == 36 || event.keyCode == 76 {
// Always bypass app/menu key-equivalent routing for Return/Enter so WebKit
// receives the keyDown path used by form submission handlers.
return false
}
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
// Menu/app shortcut routing is only needed for Command equivalents
// (New Tab, Close Tab, tab switching, split commands, etc).
guard flags.contains(.command) else {
return super.performKeyEquivalent(with: event)
}
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
return true
@ -63,20 +158,48 @@ final class CmuxWebView: WKWebView {
// NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so
// bonsplit focus tracks which pane the user clicked in.
override func mouseDown(with event: NSEvent) {
#if DEBUG
let windowNumber = window?.windowNumber ?? -1
let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"browser.focus.mouseDown web=\(ObjectIdentifier(self)) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) win=\(windowNumber) fr=\(firstResponderType)"
)
#endif
NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self)
super.mouseDown(with: event)
withPointerFocusAllowance {
super.mouseDown(with: event)
}
}
// MARK: - Mouse back/forward buttons & middle-click
// MARK: - Mouse back/forward buttons
override func otherMouseDown(with event: NSEvent) {
if event.buttonNumber == 2 {
Self.recordMiddleClickIntent(for: self)
}
#if DEBUG
let point = convert(event.locationInWindow, from: nil)
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
dlog(
"browser.mouse.otherDown web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
)
#endif
// Button 3 = back, button 4 = forward (multi-button mice like Logitech).
// Consume the event so WebKit doesn't handle it.
switch event.buttonNumber {
case 3:
#if DEBUG
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goBack canGoBack=\(canGoBack ? 1 : 0)")
#endif
goBack()
return
case 4:
#if DEBUG
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goForward canGoForward=\(canGoForward ? 1 : 0)")
#endif
goForward()
return
default:
@ -86,25 +209,23 @@ final class CmuxWebView: WKWebView {
}
override func otherMouseUp(with event: NSEvent) {
// Middle-click (button 2) on a link opens it in a new tab.
if event.buttonNumber == 2 {
let point = convert(event.locationInWindow, from: nil)
findLinkAtPoint(point) { [weak self] url in
guard let self, let url else { return }
NotificationCenter.default.post(
name: .webViewMiddleClickedLink,
object: self,
userInfo: ["url": url]
)
}
return
Self.recordMiddleClickIntent(for: self)
}
#if DEBUG
let point = convert(event.locationInWindow, from: nil)
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
dlog(
"browser.mouse.otherUp web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
)
#endif
super.otherMouseUp(with: event)
}
/// Use JavaScript to find the nearest anchor element at the given view-local point.
/// Finds the nearest anchor element at a given view-local point.
/// Used as a context-menu download fallback.
private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
// WKWebView's coordinate system is flipped (origin top-left for web content).
let flippedY = bounds.height - point.y
let js = """
(() => {

View file

@ -7,6 +7,41 @@ public enum PanelType: String, Codable, Sendable {
case browser
}
enum FocusFlashCurve: Equatable {
case easeIn
case easeOut
}
struct FocusFlashSegment: Equatable {
let delay: TimeInterval
let duration: TimeInterval
let targetOpacity: Double
let curve: FocusFlashCurve
}
enum FocusFlashPattern {
static let values: [Double] = [0, 1, 0, 1, 0]
static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1]
static let duration: TimeInterval = 0.9
static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn]
static let ringInset: Double = 6
static let ringCornerRadius: Double = 10
static var segments: [FocusFlashSegment] {
let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1)
return (0..<stepCount).map { index in
let startTime = keyTimes[index]
let endTime = keyTimes[index + 1]
return FocusFlashSegment(
delay: startTime * duration,
duration: (endTime - startTime) * duration,
targetOpacity: values[index + 1],
curve: curves[index]
)
}
}
}
/// Protocol for all panel types (terminal, browser, etc.)
@MainActor
public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUID {
@ -33,6 +68,9 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
/// Unfocus the panel
func unfocus()
/// Trigger a focus flash animation for this panel.
func triggerFlash()
}
/// Extension providing default implementations

View file

@ -83,13 +83,15 @@ final class TerminalPanel: Panel, ObservableObject {
context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: ghostty_surface_config_s? = nil,
workingDirectory: String? = nil,
additionalEnvironment: [String: String] = [:],
portOrdinal: Int = 0
) {
let surface = TerminalSurface(
tabId: workspaceId,
context: context,
configTemplate: configTemplate,
workingDirectory: workingDirectory
workingDirectory: workingDirectory,
additionalEnvironment: additionalEnvironment
)
surface.portOrdinal = portOrdinal
self.init(workspaceId: workspaceId, surface: surface)

View file

@ -0,0 +1,9 @@
import Sentry
/// Add a Sentry breadcrumb for user-action context in hang/crash reports.
func sentryBreadcrumb(_ message: String, category: String = "ui", data: [String: Any]? = nil) {
let crumb = Breadcrumb(level: .info, category: category)
crumb.message = message
crumb.data = data
SentrySDK.addBreadcrumb(crumb)
}

View file

@ -0,0 +1,474 @@
import CoreGraphics
import Foundation
import Bonsplit
enum SessionSnapshotSchema {
static let currentVersion = 1
}
enum SessionPersistencePolicy {
static let defaultSidebarWidth: Double = 200
static let minimumSidebarWidth: Double = 186
static let maximumSidebarWidth: Double = 600
static let minimumWindowWidth: Double = 300
static let minimumWindowHeight: Double = 200
static let autosaveInterval: TimeInterval = 8.0
static let maxWindowsPerSnapshot: Int = 12
static let maxWorkspacesPerWindow: Int = 128
static let maxPanelsPerWorkspace: Int = 512
static let maxScrollbackLinesPerTerminal: Int = 4000
static let maxScrollbackCharactersPerTerminal: Int = 400_000
static func sanitizedSidebarWidth(_ candidate: Double?) -> Double {
let fallback = defaultSidebarWidth
guard let candidate, candidate.isFinite else { return fallback }
return min(max(candidate, minimumSidebarWidth), maximumSidebarWidth)
}
static func truncatedScrollback(_ text: String?) -> String? {
guard let text, !text.isEmpty else { return nil }
if text.count <= maxScrollbackCharactersPerTerminal {
return text
}
let initialStart = text.index(text.endIndex, offsetBy: -maxScrollbackCharactersPerTerminal)
let safeStart = ansiSafeTruncationStart(in: text, initialStart: initialStart)
return String(text[safeStart...])
}
/// If truncation starts in the middle of an ANSI CSI escape sequence, advance
/// to the first printable character after that sequence to avoid replaying
/// malformed control bytes.
private static func ansiSafeTruncationStart(in text: String, initialStart: String.Index) -> String.Index {
guard initialStart > text.startIndex else { return initialStart }
let escape = "\u{001B}"
guard let lastEscape = text[..<initialStart].lastIndex(of: Character(escape)) else {
return initialStart
}
let csiMarker = text.index(after: lastEscape)
guard csiMarker < text.endIndex, text[csiMarker] == "[" else {
return initialStart
}
// If a final CSI byte exists before the truncation boundary, we are not
// inside a partial sequence.
if csiFinalByteIndex(in: text, from: csiMarker, upperBound: initialStart) != nil {
return initialStart
}
// We are inside a CSI sequence. Skip to the first character after the
// sequence terminator if it exists.
guard let final = csiFinalByteIndex(in: text, from: csiMarker, upperBound: text.endIndex) else {
return initialStart
}
let next = text.index(after: final)
return next < text.endIndex ? next : text.endIndex
}
private static func csiFinalByteIndex(
in text: String,
from csiMarker: String.Index,
upperBound: String.Index
) -> String.Index? {
var index = text.index(after: csiMarker)
while index < upperBound {
guard let scalar = text[index].unicodeScalars.first?.value else {
index = text.index(after: index)
continue
}
if scalar >= 0x40, scalar <= 0x7E {
return index
}
index = text.index(after: index)
}
return nil
}
}
enum SessionRestorePolicy {
static func isRunningUnderAutomatedTests(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool {
if environment["CMUX_UI_TEST_MODE"] == "1" {
return true
}
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
return true
}
if environment["XCTestConfigurationFilePath"] != nil {
return true
}
if environment["XCTestBundlePath"] != nil {
return true
}
if environment["XCTestSessionIdentifier"] != nil {
return true
}
if environment["XCInjectBundle"] != nil {
return true
}
if environment["XCInjectBundleInto"] != nil {
return true
}
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
return true
}
return false
}
static func shouldAttemptRestore(
arguments: [String] = CommandLine.arguments,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool {
if environment["CMUX_DISABLE_SESSION_RESTORE"] == "1" {
return false
}
if isRunningUnderAutomatedTests(environment: environment) {
return false
}
let extraArgs = arguments
.dropFirst()
.filter { !$0.hasPrefix("-psn_") }
// Any explicit launch argument is treated as an explicit open intent.
return extraArgs.isEmpty
}
}
struct SessionRectSnapshot: Codable, Equatable, Sendable {
let x: Double
let y: Double
let width: Double
let height: Double
init(x: Double, y: Double, width: Double, height: Double) {
self.x = x
self.y = y
self.width = width
self.height = height
}
init(_ rect: CGRect) {
self.x = Double(rect.origin.x)
self.y = Double(rect.origin.y)
self.width = Double(rect.size.width)
self.height = Double(rect.size.height)
}
var cgRect: CGRect {
CGRect(x: x, y: y, width: width, height: height)
}
}
struct SessionDisplaySnapshot: Codable, Sendable {
var displayID: UInt32?
var frame: SessionRectSnapshot?
var visibleFrame: SessionRectSnapshot?
}
enum SessionSidebarSelection: String, Codable, Sendable, Equatable {
case tabs
case notifications
init(selection: SidebarSelection) {
switch selection {
case .tabs:
self = .tabs
case .notifications:
self = .notifications
}
}
var sidebarSelection: SidebarSelection {
switch self {
case .tabs:
return .tabs
case .notifications:
return .notifications
}
}
}
struct SessionSidebarSnapshot: Codable, Sendable {
var isVisible: Bool
var selection: SessionSidebarSelection
var width: Double?
}
struct SessionStatusEntrySnapshot: Codable, Sendable {
var key: String
var value: String
var icon: String?
var color: String?
var timestamp: TimeInterval
}
struct SessionLogEntrySnapshot: Codable, Sendable {
var message: String
var level: String
var source: String?
var timestamp: TimeInterval
}
struct SessionProgressSnapshot: Codable, Sendable {
var value: Double
var label: String?
}
struct SessionGitBranchSnapshot: Codable, Sendable {
var branch: String
var isDirty: Bool
}
struct SessionTerminalPanelSnapshot: Codable, Sendable {
var workingDirectory: String?
var scrollback: String?
}
struct SessionBrowserPanelSnapshot: Codable, Sendable {
var urlString: String?
var shouldRenderWebView: Bool
var pageZoom: Double
var developerToolsVisible: Bool
var backHistoryURLStrings: [String]?
var forwardHistoryURLStrings: [String]?
}
struct SessionPanelSnapshot: Codable, Sendable {
var id: UUID
var type: PanelType
var title: String?
var customTitle: String?
var directory: String?
var isPinned: Bool
var isManuallyUnread: Bool
var gitBranch: SessionGitBranchSnapshot?
var listeningPorts: [Int]
var ttyName: String?
var terminal: SessionTerminalPanelSnapshot?
var browser: SessionBrowserPanelSnapshot?
}
enum SessionSplitOrientation: String, Codable, Sendable {
case horizontal
case vertical
init(_ orientation: SplitOrientation) {
switch orientation {
case .horizontal:
self = .horizontal
case .vertical:
self = .vertical
}
}
var splitOrientation: SplitOrientation {
switch self {
case .horizontal:
return .horizontal
case .vertical:
return .vertical
}
}
}
struct SessionPaneLayoutSnapshot: Codable, Sendable {
var panelIds: [UUID]
var selectedPanelId: UUID?
}
struct SessionSplitLayoutSnapshot: Codable, Sendable {
var orientation: SessionSplitOrientation
var dividerPosition: Double
var first: SessionWorkspaceLayoutSnapshot
var second: SessionWorkspaceLayoutSnapshot
}
indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable {
case pane(SessionPaneLayoutSnapshot)
case split(SessionSplitLayoutSnapshot)
private enum CodingKeys: String, CodingKey {
case type
case pane
case split
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "pane":
self = .pane(try container.decode(SessionPaneLayoutSnapshot.self, forKey: .pane))
case "split":
self = .split(try container.decode(SessionSplitLayoutSnapshot.self, forKey: .split))
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unsupported layout node type: \(type)")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .pane(let pane):
try container.encode("pane", forKey: .type)
try container.encode(pane, forKey: .pane)
case .split(let split):
try container.encode("split", forKey: .type)
try container.encode(split, forKey: .split)
}
}
}
struct SessionWorkspaceSnapshot: Codable, Sendable {
var processTitle: String
var customTitle: String?
var customColor: String?
var isPinned: Bool
var currentDirectory: String
var focusedPanelId: UUID?
var layout: SessionWorkspaceLayoutSnapshot
var panels: [SessionPanelSnapshot]
var statusEntries: [SessionStatusEntrySnapshot]
var logEntries: [SessionLogEntrySnapshot]
var progress: SessionProgressSnapshot?
var gitBranch: SessionGitBranchSnapshot?
}
struct SessionTabManagerSnapshot: Codable, Sendable {
var selectedWorkspaceIndex: Int?
var workspaces: [SessionWorkspaceSnapshot]
}
struct SessionWindowSnapshot: Codable, Sendable {
var frame: SessionRectSnapshot?
var display: SessionDisplaySnapshot?
var tabManager: SessionTabManagerSnapshot
var sidebar: SessionSidebarSnapshot
}
struct AppSessionSnapshot: Codable, Sendable {
var version: Int
var createdAt: TimeInterval
var windows: [SessionWindowSnapshot]
}
enum SessionPersistenceStore {
static func load(fileURL: URL? = nil) -> AppSessionSnapshot? {
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil }
guard let data = try? Data(contentsOf: fileURL) else { return nil }
let decoder = JSONDecoder()
guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil }
guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil }
guard !snapshot.windows.isEmpty else { return nil }
return snapshot
}
@discardableResult
static func save(_ snapshot: AppSessionSnapshot, fileURL: URL? = nil) -> Bool {
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return false }
let directory = fileURL.deletingLastPathComponent()
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(snapshot)
try data.write(to: fileURL, options: .atomic)
return true
} catch {
return false
}
}
static func removeSnapshot(fileURL: URL? = nil) {
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return }
try? FileManager.default.removeItem(at: fileURL)
}
static func defaultSnapshotFileURL(
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
appSupportDirectory: URL? = nil
) -> URL? {
let resolvedAppSupport: URL
if let appSupportDirectory {
resolvedAppSupport = appSupportDirectory
} else if let discovered = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
resolvedAppSupport = discovered
} else {
return nil
}
let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? bundleIdentifier!
: "com.cmuxterm.app"
let safeBundleId = bundleId.replacingOccurrences(
of: "[^A-Za-z0-9._-]",
with: "_",
options: .regularExpression
)
return resolvedAppSupport
.appendingPathComponent("cmux", isDirectory: true)
.appendingPathComponent("session-\(safeBundleId).json", isDirectory: false)
}
}
enum SessionScrollbackReplayStore {
static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE"
private static let directoryName = "cmux-session-scrollback"
private static let ansiEscape = "\u{001B}"
private static let ansiReset = "\u{001B}[0m"
static func replayEnvironment(
for scrollback: String?,
tempDirectory: URL = FileManager.default.temporaryDirectory
) -> [String: String] {
guard let replayText = normalizedScrollback(scrollback) else { return [:] }
guard let replayFileURL = writeReplayFile(
contents: replayText,
tempDirectory: tempDirectory
) else {
return [:]
}
return [environmentKey: replayFileURL.path]
}
private static func normalizedScrollback(_ scrollback: String?) -> String? {
guard let scrollback else { return nil }
guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil }
guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil }
return ansiSafeReplayText(truncated)
}
/// Preserve ANSI color state safely across replay boundaries.
private static func ansiSafeReplayText(_ text: String) -> String {
guard text.contains(ansiEscape) else { return text }
var output = text
if !output.hasPrefix(ansiReset) {
output = ansiReset + output
}
if !output.hasSuffix(ansiReset) {
output += ansiReset
}
return output
}
private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? {
guard let data = contents.data(using: .utf8) else { return nil }
let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true)
do {
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
let fileURL = directory
.appendingPathComponent(UUID().uuidString, isDirectory: false)
.appendingPathExtension("txt")
try data.write(to: fileURL, options: .atomic)
return fileURL
} catch {
return nil
}
}
}

View file

@ -2,6 +2,9 @@ import SwiftUI
@MainActor
final class SidebarSelectionState: ObservableObject {
@Published var selection: SidebarSelection = .tabs
}
@Published var selection: SidebarSelection
init(selection: SidebarSelection = .tabs) {
self.selection = selection
}
}

View file

@ -163,6 +163,8 @@ struct SocketControlSettings {
static let legacyEnabledKey = "socketControlEnabled"
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
static let launchTagEnvKey = "CMUX_TAG"
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
private static func normalizeMode(_ raw: String) -> String {
raw
@ -211,6 +213,53 @@ struct SocketControlSettings {
#endif
}
static func launchTag(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String? {
guard let raw = environment[launchTagEnvKey] else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func shouldBlockUntaggedDebugLaunch(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
) -> Bool {
guard isDebugBuild else { return false }
if isRunningUnderXCTest(environment: environment) {
return false
}
guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty else {
return false
}
if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") {
return false
}
guard bundleIdentifier == baseDebugBundleIdentifier else {
return false
}
return launchTag(environment: environment) == nil
}
static func isRunningUnderXCTest(environment: [String: String]) -> Bool {
let indicators = [
"XCTestConfigurationFilePath",
"XCTestBundlePath",
"XCTestSessionIdentifier",
"XCInjectBundleInto",
]
return indicators.contains { key in
guard let value = environment[key] else { return false }
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
static func socketPath(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier,

View file

@ -558,6 +558,10 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
@MainActor
class TabManager: ObservableObject {
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
weak var window: NSWindow?
@Published var tabs: [Workspace] = []
@Published private(set) var isWorkspaceCycleHot: Bool = false
@ -567,6 +571,9 @@ class TabManager: ObservableObject {
@Published var selectedTabId: UUID? {
didSet {
guard selectedTabId != oldValue else { return }
sentryBreadcrumb("workspace.switch", data: [
"tabCount": tabs.count
])
let previousTabId = oldValue
if let previousTabId,
let previousPanelId = focusedPanelId(for: previousTabId) {
@ -751,13 +758,24 @@ class TabManager: ObservableObject {
}
@discardableResult
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace {
func addWorkspace(
workingDirectory overrideWorkingDirectory: String? = nil,
select: Bool = true,
placementOverride: NewWorkspacePlacement? = nil
) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal)
let newWorkspace = Workspace(
title: "Terminal \(tabs.count + 1)",
workingDirectory: workingDirectory,
portOrdinal: ordinal,
configTemplate: inheritedConfig
)
wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex()
let insertIndex = newTabInsertIndex(placementOverride: placementOverride)
if insertIndex >= 0 && insertIndex <= tabs.count {
tabs.insert(newWorkspace, at: insertIndex)
} else {
@ -785,6 +803,36 @@ class TabManager: ObservableObject {
@discardableResult
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
guard let workspace = selectedWorkspace else { return nil }
if let focusedTerminal = workspace.focusedTerminalPanel {
return focusedTerminal
}
if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() {
return rememberedTerminal
}
if let focusedPaneId = workspace.bonsplitController.focusedPaneId,
let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) {
return paneTerminal
}
return workspace.terminalPanelForConfigInheritance()
}
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
return cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_TAB
)
}
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints
return config
}
return nil
}
private func normalizedWorkingDirectory(_ directory: String?) -> String? {
guard let directory else { return nil }
let normalized = normalizeDirectory(directory)
@ -792,8 +840,8 @@ class TabManager: ObservableObject {
return trimmed.isEmpty ? nil : normalized
}
private func newTabInsertIndex() -> Int {
let placement = WorkspacePlacementSettings.current()
private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int {
let placement = placementOverride ?? WorkspacePlacementSettings.current()
let pinnedCount = tabs.filter { $0.isPinned }.count
let selectedIndex = selectedTabId.flatMap { tabId in
tabs.firstIndex(where: { $0.id == tabId })
@ -927,6 +975,7 @@ class TabManager: ObservableObject {
func closeWorkspace(_ workspace: Workspace) {
guard tabs.count > 1 else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
unwireClosedBrowserTracking(for: workspace)
@ -1137,11 +1186,24 @@ class TabManager: ObservableObject {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
guard tab.panels[surfaceId] != nil else { return }
#if DEBUG
dlog(
"surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)"
)
#endif
// Keep AppKit first responder in sync with workspace focus before routing the close.
// If split reparenting caused a temporary model/view mismatch, fallback close logic in
// Workspace.closePanel uses focused selection to resolve the correct tab deterministically.
reconcileFocusedPanelFromFirstResponderForKeyboard()
_ = tab.closePanel(surfaceId, force: true)
let closed = tab.closePanel(surfaceId, force: true)
#if DEBUG
dlog(
"surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)"
)
#endif
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId)
}
@ -1153,6 +1215,13 @@ class TabManager: ObservableObject {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
guard tab.panels[surfaceId] != nil else { return }
#if DEBUG
dlog(
"surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count)"
)
#endif
// Child-exit on the last panel should collapse the workspace, matching explicit close
// semantics (and close the window when it was the last workspace).
if tab.panels.count <= 1 {
@ -1435,8 +1504,8 @@ class TabManager: ObservableObject {
private func updateWindowTitle(for tab: Workspace?) {
let title = windowTitle(for: tab)
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first
targetWindow?.title = title
guard let targetWindow = window else { return }
targetWindow.title = title
}
private func windowTitle(for tab: Workspace?) -> String {
@ -1450,7 +1519,11 @@ class TabManager: ObservableObject {
}
func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) {
guard tabs.contains(where: { $0.id == tabId }) else { return }
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
if let surfaceId, tab.panels[surfaceId] != nil {
// Keep selected-surface intent stable across selectedTabId didSet async restore.
lastFocusedPanelByTab[tabId] = surfaceId
}
selectedTabId = tabId
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
@ -1458,10 +1531,15 @@ class TabManager: ObservableObject {
userInfo: [GhosttyNotificationKey.tabId: tabId]
)
DispatchQueue.main.async {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
NSApp.activate(ignoringOtherApps: true)
NSApp.unhide(nil)
if let window = NSApp.keyWindow ?? NSApp.windows.first {
if let app = AppDelegate.shared,
let windowId = app.windowId(for: self),
let window = app.mainWindow(for: windowId) {
window.makeKeyAndOrderFront(nil)
} else if let window = NSApp.keyWindow ?? NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
}
}
@ -1469,7 +1547,7 @@ class TabManager: ObservableObject {
if let surfaceId {
if !suppressFlash {
focusSurface(tabId: tabId, surfaceId: surfaceId)
} else if let tab = tabs.first(where: { $0.id == tabId }) {
} else {
tab.focusPanel(surfaceId)
}
}
@ -1665,6 +1743,7 @@ class TabManager: ObservableObject {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return }
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
}
@ -2732,6 +2811,10 @@ class TabManager: ObservableObject {
let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1"
let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input")
.trimmingCharacters(in: .whitespacesAndNewlines)
let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d"
let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d"
let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger
let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger
let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr")
.trimmingCharacters(in: .whitespacesAndNewlines)
let expectedPanelsAfter = max(
@ -2870,7 +2953,9 @@ class TabManager: ObservableObject {
}
tab.focusPanel(exitPanelId)
try? await Task.sleep(nanoseconds: 100_000_000)
if !useEarlyTrigger {
try? await Task.sleep(nanoseconds: 100_000_000)
}
let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? ""
let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in
@ -2974,21 +3059,31 @@ class TabManager: ObservableObject {
return
}
// Wait for the target panel to be fully attached after split churn.
let readyDeadline = Date().addingTimeInterval(2.0)
let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift
? [.control, .shift]
: [.control]
let shouldWaitForSurface = !useEarlyTrigger
var attachedBeforeTrigger = false
var hasSurfaceBeforeTrigger = false
while Date() < readyDeadline {
guard let panel = tab.terminalPanel(for: exitPanelId) else {
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
return
if shouldWaitForSurface {
// Wait for the target panel to be fully attached after split churn.
let readyDeadline = Date().addingTimeInterval(2.0)
while Date() < readyDeadline {
guard let panel = tab.terminalPanel(for: exitPanelId) else {
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
return
}
attachedBeforeTrigger = panel.hostedView.window != nil
hasSurfaceBeforeTrigger = panel.surface.surface != nil
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
} else if let panel = tab.terminalPanel(for: exitPanelId) {
attachedBeforeTrigger = panel.hostedView.window != nil
hasSurfaceBeforeTrigger = panel.surface.surface != nil
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
write([
"exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0",
@ -3000,7 +3095,7 @@ class TabManager: ObservableObject {
return
}
// Exercise the real key path (ghostty_surface_key for Ctrl+D).
if panel.hostedView.sendSyntheticCtrlDForUITest() {
if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) {
write(["autoTriggerSentCtrlDKey1": "1"])
} else {
write([
@ -3012,13 +3107,20 @@ class TabManager: ObservableObject {
// In strict mode, never mask routing bugs with fallback writes.
if strictKeyOnly {
write(["autoTriggerMode": "strict_ctrl_d"])
let strictModeLabel: String = {
if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" }
if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" }
if triggerUsesShift { return "strict_ctrl_shift_d" }
return "strict_ctrl_d"
}()
write(["autoTriggerMode": strictModeLabel])
return
}
// Non-strict mode keeps one additional Ctrl+D retry for startup timing variance.
try? await Task.sleep(nanoseconds: 450_000_000)
if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest() {
if tab.panels[exitPanelId] != nil,
panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) {
write(["autoTriggerSentCtrlDKey2": "1"])
}
}
@ -3028,6 +3130,75 @@ class TabManager: ObservableObject {
#endif
}
extension TabManager {
func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
let workspaceSnapshots = tabs
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in
tabs.firstIndex(where: { $0.id == selectedTabId })
}
return SessionTabManagerSnapshot(
selectedWorkspaceIndex: selectedWorkspaceIndex,
workspaces: workspaceSnapshots
)
}
func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) {
for tab in tabs {
unwireClosedBrowserTracking(for: tab)
}
tabs.removeAll(keepingCapacity: false)
lastFocusedPanelByTab.removeAll()
pendingPanelTitleUpdates.removeAll()
tabHistory.removeAll()
historyIndex = -1
isNavigatingHistory = false
pendingWorkspaceUnfocusTarget = nil
workspaceCycleCooldownTask?.cancel()
workspaceCycleCooldownTask = nil
isWorkspaceCycleHot = false
selectionSideEffectsGeneration &+= 1
recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
let workspaceSnapshots = snapshot.workspaces
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
for workspaceSnapshot in workspaceSnapshots {
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let workspace = Workspace(
title: workspaceSnapshot.processTitle,
workingDirectory: workspaceSnapshot.currentDirectory,
portOrdinal: ordinal
)
workspace.restoreSessionSnapshot(workspaceSnapshot)
wireClosedBrowserTracking(for: workspace)
tabs.append(workspace)
}
if tabs.isEmpty {
_ = addWorkspace(select: false)
}
selectedTabId = nil
if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex,
tabs.indices.contains(selectedWorkspaceIndex) {
selectedTabId = tabs[selectedWorkspaceIndex].id
} else {
selectedTabId = tabs.first?.id
}
if let selectedTabId {
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: selectedTabId]
)
}
}
}
// MARK: - Direction Types for Backwards Compatibility
/// Split direction for backwards compatibility with old API
@ -3055,15 +3226,22 @@ enum ResizeDirection {
}
extension Notification.Name {
static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested")
static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested")
static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested")
static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested")
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")
static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested")
static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested")
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface")
static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView")
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick")
static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink")
}

View file

@ -45,6 +45,7 @@ class TerminalController {
"browser.focus_webview",
"browser.focus",
"browser.tab.switch",
"debug.command_palette.toggle",
"debug.notification.focus",
"debug.app.activate"
]
@ -1336,6 +1337,28 @@ class TerminalController {
return v2Result(id: id, self.v2DebugType(params: params))
case "debug.app.activate":
return v2Result(id: id, self.v2DebugActivateApp())
case "debug.command_palette.toggle":
return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params))
case "debug.command_palette.rename_tab.open":
return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params))
case "debug.command_palette.visible":
return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params))
case "debug.command_palette.selection":
return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params))
case "debug.command_palette.results":
return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params))
case "debug.command_palette.rename_input.interact":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params))
case "debug.command_palette.rename_input.delete_backward":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params))
case "debug.command_palette.rename_input.selection":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params))
case "debug.command_palette.rename_input.select_all":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params))
case "debug.browser.address_bar_focused":
return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params))
case "debug.sidebar.visible":
return v2Result(id: id, self.v2DebugSidebarVisible(params: params))
case "debug.terminal.is_focused":
return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params))
case "debug.terminal.read_text":
@ -1532,6 +1555,17 @@ class TerminalController {
"debug.shortcut.simulate",
"debug.type",
"debug.app.activate",
"debug.command_palette.toggle",
"debug.command_palette.rename_tab.open",
"debug.command_palette.visible",
"debug.command_palette.selection",
"debug.command_palette.results",
"debug.command_palette.rename_input.interact",
"debug.command_palette.rename_input.delete_backward",
"debug.command_palette.rename_input.selection",
"debug.command_palette.rename_input.select_all",
"debug.browser.address_bar_focused",
"debug.sidebar.visible",
"debug.terminal.is_focused",
"debug.terminal.read_text",
"debug.terminal.render_stats",
@ -3543,6 +3577,154 @@ class TerminalController {
return "OK \(base64)"
}
private struct PasteboardItemSnapshot {
let representations: [(type: NSPasteboard.PasteboardType, data: Data)]
}
nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let url = URL(string: trimmed),
url.isFileURL,
!url.path.isEmpty {
return url.path
}
return trimmed.hasPrefix("/") ? trimmed : nil
}
nonisolated static func shouldRemoveExportedScreenFile(
fileURL: URL,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
) -> Bool {
let standardizedFile = fileURL.standardizedFileURL
let temporary = temporaryDirectory.standardizedFileURL
return standardizedFile.path.hasPrefix(temporary.path + "/")
}
nonisolated static func shouldRemoveExportedScreenDirectory(
fileURL: URL,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
) -> Bool {
let directory = fileURL.deletingLastPathComponent().standardizedFileURL
let temporary = temporaryDirectory.standardizedFileURL
return directory.path.hasPrefix(temporary.path + "/")
}
private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] {
guard let items = pasteboard.pasteboardItems else { return [] }
return items.map { item in
let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in
guard let data = item.data(forType: type) else { return nil }
return (type: type, data: data)
}
return PasteboardItemSnapshot(representations: representations)
}
}
private func restorePasteboardItems(
_ snapshots: [PasteboardItemSnapshot],
to pasteboard: NSPasteboard
) {
_ = pasteboard.clearContents()
guard !snapshots.isEmpty else { return }
let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in
guard !snapshot.representations.isEmpty else { return nil }
let item = NSPasteboardItem()
for representation in snapshot.representations {
item.setData(representation.data, forType: representation.type)
}
return item
}
guard !restoredItems.isEmpty else { return }
_ = pasteboard.writeObjects(restoredItems)
}
private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? {
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL],
let firstURL = urls.first,
firstURL.isFileURL {
return firstURL.path
}
if let value = pasteboard.string(forType: .string) {
return value
}
return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text"))
}
private func readTerminalTextFromVTExportForSnapshot(
terminalPanel: TerminalPanel,
lineLimit: Int?
) -> String? {
// read_text strips style state; VT export keeps ANSI escape sequences.
let pasteboard = NSPasteboard.general
let snapshot = snapshotPasteboardItems(pasteboard)
defer {
restorePasteboardItems(snapshot, to: pasteboard)
}
let initialChangeCount = pasteboard.changeCount
guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else {
return nil
}
guard pasteboard.changeCount != initialChangeCount else {
return nil
}
guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else {
return nil
}
let fileURL = URL(fileURLWithPath: exportedPath)
defer {
if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) {
try? FileManager.default.removeItem(at: fileURL)
if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) {
try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent())
}
}
}
guard let data = try? Data(contentsOf: fileURL),
var output = String(data: data, encoding: .utf8) else {
return nil
}
if let lineLimit {
output = tailTerminalLines(output, maxLines: lineLimit)
}
return output
}
func readTerminalTextForSnapshot(
terminalPanel: TerminalPanel,
includeScrollback: Bool = false,
lineLimit: Int? = nil
) -> String? {
if includeScrollback,
let vtOutput = readTerminalTextFromVTExportForSnapshot(
terminalPanel: terminalPanel,
lineLimit: lineLimit
) {
return vtOutput
}
let response = readTerminalTextBase64(
terminalPanel: terminalPanel,
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
guard response.hasPrefix("OK ") else { return nil }
let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
if base64.isEmpty {
return ""
}
guard let data = Data(base64Encoded: base64),
let decoded = String(data: data, encoding: .utf8) else {
return nil
}
return decoded
}
private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
@ -7621,6 +7803,294 @@ class TerminalController {
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
}
private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow)
}
return result
}
private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: [
"window_id": requestedWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow)
}
return result
}
private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var visible = false
DispatchQueue.main.sync {
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible
])
}
private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var visible = false
var selectedIndex = 0
DispatchQueue.main.sync {
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible,
"selected_index": max(0, selectedIndex)
])
}
private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
let requestedLimit = params["limit"] as? Int
let limit = max(1, min(100, requestedLimit ?? 20))
var visible = false
var selectedIndex = 0
var snapshot = CommandPaletteDebugSnapshot.empty
DispatchQueue.main.sync {
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty
}
let rows = Array(snapshot.results.prefix(limit)).map { row in
[
"command_id": row.commandId,
"title": row.title,
"shortcut_hint": v2OrNull(row.shortcutHint),
"trailing_label": v2OrNull(row.trailingLabel),
"score": row.score
] as [String: Any]
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible,
"selected_index": max(0, selectedIndex),
"query": snapshot.query,
"mode": snapshot.mode,
"results": rows
])
}
private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: [
"window_id": requestedWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow)
}
return result
}
private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: [
"window_id": requestedWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow)
}
return result
}
private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var result: V2CallResult = .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"focused": false,
"selection_location": 0,
"selection_length": 0,
"text_length": 0
])
DispatchQueue.main.sync {
guard let window = AppDelegate.shared?.mainWindow(for: windowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
)
return
}
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else {
return
}
let selectedRange = editor.selectedRange()
let textLength = (editor.string as NSString).length
result = .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"focused": true,
"selection_location": max(0, selectedRange.location),
"selection_length": max(0, selectedRange.length),
"text_length": max(0, textLength)
])
}
return result
}
private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult {
if let rawEnabled = params["enabled"] {
guard let enabled = rawEnabled as? Bool else {
return .err(
code: "invalid_params",
message: "enabled must be a bool",
data: ["enabled": rawEnabled]
)
}
DispatchQueue.main.sync {
UserDefaults.standard.set(
enabled,
forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey
)
}
}
var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
DispatchQueue.main.sync {
enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled()
}
return .ok([
"enabled": enabled
])
}
private func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult {
let requestedSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "panel_id")
var focusedSurfaceId: UUID?
DispatchQueue.main.sync {
focusedSurfaceId = AppDelegate.shared?.focusedBrowserAddressBarPanelId()
}
var payload: [String: Any] = [
"focused_surface_id": v2OrNull(focusedSurfaceId?.uuidString),
"focused_surface_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId),
"focused_panel_id": v2OrNull(focusedSurfaceId?.uuidString),
"focused_panel_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId),
"focused": focusedSurfaceId != nil
]
if let requestedSurfaceId {
payload["surface_id"] = requestedSurfaceId.uuidString
payload["surface_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId)
payload["panel_id"] = requestedSurfaceId.uuidString
payload["panel_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId)
payload["focused"] = (focusedSurfaceId == requestedSurfaceId)
}
return .ok(payload)
}
private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var visibility: Bool?
DispatchQueue.main.sync {
visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId)
}
guard let visible = visibility else {
return .err(
code: "not_found",
message: "Window not found",
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
)
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible
])
}
private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult {
guard let surfaceId = v2String(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
@ -8003,6 +8473,37 @@ class TerminalController {
}
#if DEBUG
private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String {
let snakeCase = action.rawValue.replacingOccurrences(
of: "([a-z0-9])([A-Z])",
with: "$1_$2",
options: .regularExpression
)
return snakeCase.lowercased()
}
private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? {
let normalized = rawName
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "-", with: "_")
for action in KeyboardShortcutSettings.Action.allCases {
let snakeCaseName = debugShortcutName(for: action)
if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") {
return action
}
}
return nil
}
private func debugShortcutSupportedNames() -> String {
KeyboardShortcutSettings.Action.allCases
.map(debugShortcutName(for:))
.sorted()
.joined(separator: ", ")
}
private func setShortcut(_ args: String) -> String {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
@ -8010,29 +8511,15 @@ class TerminalController {
return "ERROR: Usage: set_shortcut <name> <combo|clear>"
}
let name = parts[0].lowercased()
let name = parts[0]
let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
let defaultsKey: String?
switch name {
case "focus_left", "focusleft":
defaultsKey = KeyboardShortcutSettings.focusLeftKey
case "focus_right", "focusright":
defaultsKey = KeyboardShortcutSettings.focusRightKey
case "focus_up", "focusup":
defaultsKey = KeyboardShortcutSettings.focusUpKey
case "focus_down", "focusdown":
defaultsKey = KeyboardShortcutSettings.focusDownKey
default:
defaultsKey = nil
}
guard let defaultsKey else {
return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down"
guard let action = debugShortcutAction(named: name) else {
return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())"
}
if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" {
UserDefaults.standard.removeObject(forKey: defaultsKey)
UserDefaults.standard.removeObject(forKey: action.defaultsKey)
return "OK"
}
@ -8050,7 +8537,7 @@ class TerminalController {
guard let data = try? JSONEncoder().encode(shortcut) else {
return "ERROR: Failed to encode shortcut"
}
UserDefaults.standard.set(data, forKey: defaultsKey)
UserDefaults.standard.set(data, forKey: action.defaultsKey)
return "OK"
}
@ -8069,17 +8556,24 @@ class TerminalController {
var result = "ERROR: Failed to create event"
DispatchQueue.main.sync {
// Tests can run while the app is activating (no keyWindow yet). Prefer a visible
// window to keep input simulation deterministic in debug builds.
let targetWindow = NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first
// Prefer the current active-tab-manager window so shortcut simulation stays
// scoped to the intended window even when NSApp.keyWindow is stale.
let targetWindow: NSWindow? = {
if let activeTabManager = self.tabManager,
let windowId = AppDelegate.shared?.windowId(for: activeTabManager),
let window = AppDelegate.shared?.mainWindow(for: windowId) {
return window
}
return NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first
}()
if let targetWindow {
NSApp.activate(ignoringOtherApps: true)
targetWindow.makeKeyAndOrderFront(nil)
}
let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0
let windowNumber = targetWindow?.windowNumber ?? 0
guard let keyDownEvent = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
@ -8158,20 +8652,20 @@ class TerminalController {
// Socket commands are line-based; allow callers to express control chars with backslash escapes.
let text = unescapeSocketText(raw)
var result = "ERROR: No window"
DispatchQueue.main.sync {
// Like simulate_shortcut, prefer a visible window so debug automation doesn't
// fail during key window transitions.
guard let window = NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first else { return }
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
guard let fr = window.firstResponder else {
result = "ERROR: No first responder"
return
}
var result = "ERROR: No window"
DispatchQueue.main.sync {
// Like simulate_shortcut, prefer a visible window so debug automation doesn't
// fail during key window transitions.
guard let window = NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first else { return }
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
guard let fr = window.firstResponder else {
result = "ERROR: No first responder"
return
}
if let client = fr as? NSTextInputClient {
client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
@ -8179,7 +8673,22 @@ class TerminalController {
return
}
// Fall back to the responder chain insertText action.
// If workspace handoff temporarily leaves a non-terminal first responder,
// route debug typing to the selected terminal's focused panel directly.
if let tabManager,
let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = tab.focusedPanelId,
let terminalPanel = tab.terminalPanel(for: panelId),
!terminalPanel.hostedView.isSurfaceViewFirstResponder() {
// Match Enter semantics expected by tests/debug tooling when bypassing AppKit.
let directText = text.replacingOccurrences(of: "\n", with: "\r")
terminalPanel.surface.sendText(directText)
result = "OK"
return
}
// Fall back to the responder-chain insertText action.
(fr as? NSResponder)?.insertText(text)
result = "OK"
}
@ -8772,6 +9281,10 @@ class TerminalController {
let charactersIgnoringModifiers: String
switch keyToken.lowercased() {
case "esc", "escape":
storedKey = "\u{1b}"
keyCode = UInt16(kVK_Escape)
charactersIgnoringModifiers = storedKey
case "left":
storedKey = ""
keyCode = 123
@ -8792,6 +9305,10 @@ class TerminalController {
storedKey = "\r"
keyCode = UInt16(kVK_Return)
charactersIgnoringModifiers = storedKey
case "backspace", "delete", "del":
storedKey = "\u{7f}"
keyCode = UInt16(kVK_Delete)
charactersIgnoringModifiers = storedKey
default:
let key = keyToken.lowercased()
guard let code = keyCodeForShortcutKey(key) else { return nil }

View file

@ -17,6 +17,12 @@ private func portalDebugToken(_ view: NSView?) -> String {
private func portalDebugFrame(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
}
private func portalDebugFrameInWindow(_ view: NSView?) -> String {
guard let view else { return "nil" }
guard view.window != nil else { return "no-window" }
return portalDebugFrame(view.convert(view.bounds, to: nil))
}
#endif
final class WindowTerminalHostView: NSView {
@ -529,6 +535,10 @@ private final class SplitDividerOverlayView: NSView {
@MainActor
final class WindowTerminalPortal: NSObject {
private static let tinyHideThreshold: CGFloat = 1
private static let minimumRevealWidth: CGFloat = 24
private static let minimumRevealHeight: CGFloat = 18
private weak var window: NSWindow?
private let hostView = WindowTerminalHostView(frame: .zero)
private let dividerOverlayView = SplitDividerOverlayView(frame: .zero)
@ -536,6 +546,11 @@ final class WindowTerminalPortal: NSObject {
private weak var installedReferenceView: NSView?
private var installConstraints: [NSLayoutConstraint] = []
private var hasDeferredFullSyncScheduled = false
private var hasExternalGeometrySyncScheduled = false
private var geometryObservers: [NSObjectProtocol] = []
#if DEBUG
private var lastLoggedBonsplitContainerSignature: String?
#endif
private struct Entry {
weak var hostedView: GhosttySurfaceScrollView?
@ -550,13 +565,141 @@ final class WindowTerminalPortal: NSObject {
init(window: NSWindow) {
self.window = window
super.init()
hostView.wantsLayer = false
hostView.wantsLayer = true
hostView.layer?.masksToBounds = true
hostView.postsFrameChangedNotifications = true
hostView.postsBoundsChangedNotifications = true
hostView.translatesAutoresizingMaskIntoConstraints = false
dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true
dividerOverlayView.autoresizingMask = [.width, .height]
installGeometryObservers(for: window)
_ = ensureInstalled()
}
private func installGeometryObservers(for window: NSWindow) {
guard geometryObservers.isEmpty else { return }
let center = NotificationCenter.default
geometryObservers.append(center.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSWindow.didEndLiveResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSSplitView.didResizeSubviewsNotification,
object: nil,
queue: .main
) { [weak self] notification in
MainActor.assumeIsolated {
guard let self,
let splitView = notification.object as? NSSplitView,
let window = self.window,
splitView.window === window else { return }
self.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSView.frameDidChangeNotification,
object: hostView,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSView.boundsDidChangeNotification,
object: hostView,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
}
private func removeGeometryObservers() {
for observer in geometryObservers {
NotificationCenter.default.removeObserver(observer)
}
geometryObservers.removeAll()
}
private func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasExternalGeometrySyncScheduled = false
self.synchronizeAllEntriesFromExternalGeometryChange()
}
}
private func synchronizeLayoutHierarchy() {
installedContainerView?.layoutSubtreeIfNeeded()
installedReferenceView?.layoutSubtreeIfNeeded()
hostView.superview?.layoutSubtreeIfNeeded()
hostView.layoutSubtreeIfNeeded()
_ = synchronizeHostFrameToReference()
}
@discardableResult
private func synchronizeHostFrameToReference() -> Bool {
guard let container = installedContainerView,
let reference = installedReferenceView else {
return false
}
let frameInContainer = container.convert(reference.bounds, from: reference)
let hasFiniteFrame =
frameInContainer.origin.x.isFinite &&
frameInContainer.origin.y.isFinite &&
frameInContainer.size.width.isFinite &&
frameInContainer.size.height.isFinite
guard hasFiniteFrame else { return false }
if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostView.frame = frameInContainer
CATransaction.commit()
#if DEBUG
dlog(
"portal.hostFrame.update host=\(portalDebugToken(hostView)) " +
"frame=\(portalDebugFrame(frameInContainer))"
)
#endif
}
return frameInContainer.width > 1 && frameInContainer.height > 1
}
private func synchronizeAllEntriesFromExternalGeometryChange() {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
synchronizeAllHostedViews(excluding: nil)
// During live resize, AppKit can deliver frame churn where host/container geometry
// settles a tick before the terminal's own scroll/surface hierarchy. Force a final
// in-place geometry + surface refresh for all visible entries in this window.
for entry in entriesByHostedId.values {
guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue }
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
}
}
private func ensureDividerOverlayOnTop() {
if dividerOverlayView.superview !== hostView {
dividerOverlayView.frame = hostView.bounds
@ -605,6 +748,8 @@ final class WindowTerminalPortal: NSObject {
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
}
synchronizeLayoutHierarchy()
_ = synchronizeHostFrameToReference()
ensureDividerOverlayOnTop()
return true
@ -634,13 +779,32 @@ final class WindowTerminalPortal: NSObject {
return false
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
guard rect.origin.x.isFinite,
rect.origin.y.isFinite,
rect.size.width.isFinite,
rect.size.height.isFinite else {
return rect
}
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
func snap(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
return NSRect(
x: snap(rect.origin.x),
y: snap(rect.origin.y),
width: max(0, snap(rect.size.width)),
height: max(0, snap(rect.size.height))
)
}
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
guard let viewIndex = container.subviews.firstIndex(of: view),
let referenceIndex = container.subviews.firstIndex(of: reference) else {
@ -649,6 +813,87 @@ final class WindowTerminalPortal: NSObject {
return viewIndex > referenceIndex
}
#if DEBUG
private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? {
var current: NSView? = anchorView
while let view = current {
let className = NSStringFromClass(type(of: view))
if className.contains("PaneDragContainerView") || className.contains("Bonsplit") {
return view
}
current = view.superview
}
return installedReferenceView
}
private func logBonsplitContainerFrameIfNeeded(anchorView: NSView, hostedView: GhosttySurfaceScrollView) {
guard let container = nearestBonsplitContainer(from: anchorView) else { return }
let containerFrame = container.convert(container.bounds, to: nil)
let signature = "\(ObjectIdentifier(container)):\(portalDebugFrame(containerFrame))"
guard signature != lastLoggedBonsplitContainerSignature else { return }
lastLoggedBonsplitContainerSignature = signature
let containerClass = NSStringFromClass(type(of: container))
dlog(
"portal.bonsplit.container hosted=\(portalDebugToken(hostedView)) " +
"class=\(containerClass) frame=\(portalDebugFrame(containerFrame)) " +
"host=\(portalDebugFrameInWindow(hostView)) anchor=\(portalDebugFrameInWindow(anchorView))"
)
}
#endif
/// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping.
/// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when
/// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective
/// visible rect that should drive portal geometry.
private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect {
var frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
var current = anchorView.superview
while let ancestor = current {
let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil)
let finiteAncestorBounds =
ancestorBoundsInWindow.origin.x.isFinite &&
ancestorBoundsInWindow.origin.y.isFinite &&
ancestorBoundsInWindow.size.width.isFinite &&
ancestorBoundsInWindow.size.height.isFinite
if finiteAncestorBounds {
frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow)
if frameInWindow.isNull { return .zero }
}
if ancestor === installedReferenceView { break }
current = ancestor.superview
}
return frameInWindow
}
private func seededFrameInHost(for anchorView: NSView) -> NSRect? {
_ = synchronizeHostFrameToReference()
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
let hasFiniteFrame =
frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite
guard hasFiniteFrame else { return nil }
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
if hasFiniteHostBounds {
let clampedFrame = frameInHost.intersection(hostBounds)
if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 {
return clampedFrame
}
}
return frameInHost
}
func detachHostedView(withId hostedId: ObjectIdentifier) {
guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return }
if let anchor = entry.anchorView {
@ -689,6 +934,12 @@ final class WindowTerminalPortal: NSObject {
entriesByHostedId[hostedId] = entry
}
func isHostedViewBoundToAnchor(withId hostedId: ObjectIdentifier, anchorView: NSView) -> Bool {
guard let entry = entriesByHostedId[hostedId],
let boundAnchor = entry.anchorView else { return false }
return boundAnchor === anchorView
}
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard ensureInstalled() else { return }
@ -740,6 +991,32 @@ final class WindowTerminalPortal: NSObject {
}
#endif
_ = synchronizeHostFrameToReference()
// Seed frame/bounds before entering the window so a freshly reparented
// surface doesn't do a transient 800x600 size update on viewDidMoveToWindow.
if let seededFrame = seededFrameInHost(for: anchorView),
seededFrame.width > 0,
seededFrame.height > 0 {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = seededFrame
hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size)
CATransaction.commit()
} else {
// If anchor geometry is still unsettled, keep this hidden/zero-sized until
// synchronizeHostedView resolves a valid target frame on the next layout tick.
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = .zero
hostedView.bounds = .zero
CATransaction.commit()
hostedView.isHidden = true
}
// Keep inner scroll/surface geometry in sync with the seeded outer frame
// before the hosted view enters a window.
hostedView.reconcileGeometryNow()
if hostedView.superview !== hostView {
#if DEBUG
dlog(
@ -765,10 +1042,13 @@ final class WindowTerminalPortal: NSObject {
ensureDividerOverlayOnTop()
synchronizeHostedView(withId: hostedId)
scheduleDeferredFullSynchronizeAll()
pruneDeadEntries()
}
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
pruneDeadEntries()
let anchorId = ObjectIdentifier(anchorView)
let primaryHostedId = hostedByAnchorId[anchorId]
@ -795,6 +1075,7 @@ final class WindowTerminalPortal: NSObject {
private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
pruneDeadEntries()
let hostedIds = Array(entriesByHostedId.keys)
for hostedId in hostedIds {
@ -837,63 +1118,161 @@ final class WindowTerminalPortal: NSObject {
return
}
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
let frameInHost = hostView.convert(frameInWindow, from: nil)
_ = synchronizeHostFrameToReference()
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
#if DEBUG
logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView)
#endif
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1
if !hostBoundsReady {
#if DEBUG
dlog(
"portal.sync.defer hosted=\(portalDebugToken(hostedView)) " +
"reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " +
"anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)"
)
#endif
hostedView.isHidden = true
scheduleDeferredFullSynchronizeAll()
return
}
let hasFiniteFrame =
frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite
let clampedFrame = frameInHost.intersection(hostBounds)
let hasVisibleIntersection =
!clampedFrame.isNull &&
clampedFrame.width > 1 &&
clampedFrame.height > 1
let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1
let outsideHostBounds = !frameInHost.intersects(hostView.bounds)
let tinyFrame =
targetFrame.width <= Self.tinyHideThreshold ||
targetFrame.height <= Self.tinyHideThreshold
let revealReadyForDisplay =
targetFrame.width >= Self.minimumRevealWidth &&
targetFrame.height >= Self.minimumRevealHeight
let outsideHostBounds = !hasVisibleIntersection
let shouldHide =
!entry.visibleInUI ||
anchorHidden ||
tinyFrame ||
!hasFiniteFrame ||
outsideHostBounds
let shouldDeferReveal = !shouldHide && hostedView.isHidden && !revealReadyForDisplay
let oldFrame = hostedView.frame
#if DEBUG
let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame)
if frameWasClamped {
dlog(
"portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " +
"anchor=\(portalDebugToken(anchorView)) " +
"raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
}
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
if collapsedToTiny {
dlog(
"portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))"
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
)
} else if restoredFromTiny {
dlog(
"portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))"
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
)
}
#endif
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
// Hide before updating the frame when this entry should not be visible.
// This avoids a one-frame flash of unrendered terminal background when a portal
// briefly transitions through offscreen/tiny geometry during rapid split churn.
if shouldHide, !hostedView.isHidden {
#if DEBUG
dlog(
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
#endif
hostedView.isHidden = true
}
if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = frameInHost
hostedView.frame = targetFrame
CATransaction.commit()
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
}
if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 ||
abs(oldFrame.size.height - frameInHost.size.height) > 0.5 {
hostedView.reconcileGeometryNow()
if hasFiniteFrame {
let expectedBounds = NSRect(origin: .zero, size: targetFrame.size)
if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.bounds = expectedBounds
CATransaction.commit()
}
}
if hostedView.isHidden != shouldHide {
if shouldDeferReveal {
#if DEBUG
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
dlog(
"portal.hidden.deferReveal hosted=\(portalDebugToken(hostedView)) " +
"frame=\(portalDebugFrame(frameInHost)) min=\(Int(Self.minimumRevealWidth))x\(Int(Self.minimumRevealHeight))"
)
}
#endif
}
if !shouldHide, hostedView.isHidden, revealReadyForDisplay {
#if DEBUG
dlog(
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " +
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))"
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
#endif
hostedView.isHidden = shouldHide
hostedView.isHidden = false
// A reveal can happen without any frame delta (same targetFrame), which means the
// normal frame-change refresh path won't run. Nudge geometry + redraw so newly
// revealed terminals don't sit on a stale/blank IOSurface until later focus churn.
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
}
#if DEBUG
dlog(
"portal.sync.result hosted=\(portalDebugToken(hostedView)) " +
"anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " +
"hostWin=\(hostView.window?.windowNumber ?? -1) " +
"old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " +
"target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
"entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " +
"hostBounds=\(portalDebugFrame(hostBounds))"
)
#endif
ensureDividerOverlayOnTop()
}
@ -927,6 +1306,7 @@ final class WindowTerminalPortal: NSObject {
}
func tearDown() {
removeGeometryObservers()
for hostedId in Array(entriesByHostedId.keys) {
detachHostedView(withId: hostedId)
}
@ -1093,6 +1473,15 @@ enum TerminalWindowPortalRegistry {
portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI)
}
static func isHostedView(_ hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool {
let hostedId = ObjectIdentifier(hostedView)
guard let window = anchorView.window else { return false }
let windowId = ObjectIdentifier(window)
guard hostedToWindowId[hostedId] == windowId,
let portal = portalsByWindowId[windowId] else { return false }
return portal.isHostedViewBoundToAnchor(withId: hostedId, anchorView: anchorView)
}
static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? {
let portal = portal(for: window)
return portal.viewAtWindowPoint(windowPoint)

View file

@ -8,6 +8,8 @@ class UpdateController {
private(set) var updater: SPUUpdater
private let userDriver: UpdateDriver
private var installCancellable: AnyCancellable?
private var attemptInstallCancellable: AnyCancellable?
private var didObserveAttemptUpdateProgress: Bool = false
private var noUpdateDismissCancellable: AnyCancellable?
private var noUpdateDismissWorkItem: DispatchWorkItem?
private var readyCheckWorkItem: DispatchWorkItem?
@ -46,6 +48,7 @@ class UpdateController {
deinit {
installCancellable?.cancel()
attemptInstallCancellable?.cancel()
noUpdateDismissCancellable?.cancel()
noUpdateDismissWorkItem?.cancel()
readyCheckWorkItem?.cancel()
@ -107,6 +110,35 @@ class UpdateController {
}
}
/// Check for updates and auto-confirm install if one is found.
func attemptUpdate() {
stopAttemptUpdateMonitoring()
didObserveAttemptUpdateProgress = false
attemptInstallCancellable = viewModel.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self else { return }
if state.isInstallable || !state.isIdle {
self.didObserveAttemptUpdateProgress = true
}
if case .updateAvailable = state {
UpdateLogStore.shared.append("attemptUpdate auto-confirming available update")
state.confirm()
return
}
guard self.didObserveAttemptUpdateProgress, !state.isInstallable else {
return
}
self.stopAttemptUpdateMonitoring()
}
checkForUpdates()
}
/// Check for updates (used by the menu item).
@objc func checkForUpdates() {
UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))")
@ -175,6 +207,12 @@ class UpdateController {
return true
}
private func stopAttemptUpdateMonitoring() {
attemptInstallCancellable?.cancel()
attemptInstallCancellable = nil
didObserveAttemptUpdateProgress = false
}
private func installNoUpdateDismissObserver() {
noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState)
.receive(on: DispatchQueue.main)

View file

@ -80,7 +80,9 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
}
@MainActor
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {

View file

@ -333,7 +333,7 @@ struct TitlebarControlsView: View {
.foregroundColor(.white)
.frame(width: config.badgeSize, height: config.badgeSize)
.background(
Circle().fill(Color.accentColor)
Circle().fill(cmuxAccentColor())
)
.offset(x: config.badgeOffset.width, y: config.badgeOffset.height)
}
@ -905,11 +905,11 @@ private struct NotificationPopoverRow: View {
Button(action: onOpen) {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(notification.isRead ? Color.clear : Color.accentColor)
.fill(notification.isRead ? Color.clear : cmuxAccentColor())
.frame(width: 8, height: 8)
.overlay(
Circle()
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
.stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
)
.padding(.top, 6)

View file

@ -132,7 +132,7 @@ class UpdateViewModel: ObservableObject {
case .checking:
return .secondary
case .updateAvailable:
return .accentColor
return cmuxAccentColor()
case .downloading, .extracting, .installing:
return .secondary
case .notFound:
@ -147,7 +147,7 @@ class UpdateViewModel: ObservableObject {
case .permissionRequest:
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue)
case .updateAvailable:
return .accentColor
return cmuxAccentColor()
case .notFound:
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue)
case .error:

View file

@ -1,6 +1,246 @@
import AppKit
import Bonsplit
import SwiftUI
private func windowDragHandleFormatPoint(_ point: NSPoint) -> String {
String(format: "(%.1f,%.1f)", point.x, point.y)
}
/// Runs the same action macOS titlebars use for double-click:
/// zoom by default, or minimize when the user preference is set.
@discardableResult
func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool {
guard let window else { return false }
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:]
if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() {
switch action {
case "minimize":
window.miniaturize(nil)
return true
case "none":
return false
case "maximize", "zoom":
window.zoom(nil)
return true
default:
break
}
}
if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool,
miniaturizeOnDoubleClick {
window.miniaturize(nil)
return true
}
window.zoom(nil)
return true
}
private var windowDragSuppressionDepthKey: UInt8 = 0
func beginWindowDragSuppression(window: NSWindow?) -> Int? {
guard let window else { return nil }
let current = windowDragSuppressionDepth(window: window)
let next = current + 1
objc_setAssociatedObject(
window,
&windowDragSuppressionDepthKey,
NSNumber(value: next),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
return next
}
@discardableResult
func endWindowDragSuppression(window: NSWindow?) -> Int {
guard let window else { return 0 }
let current = windowDragSuppressionDepth(window: window)
let next = max(0, current - 1)
if next == 0 {
objc_setAssociatedObject(window, &windowDragSuppressionDepthKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
} else {
objc_setAssociatedObject(
window,
&windowDragSuppressionDepthKey,
NSNumber(value: next),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
return next
}
func windowDragSuppressionDepth(window: NSWindow?) -> Int {
guard let window,
let value = objc_getAssociatedObject(window, &windowDragSuppressionDepthKey) as? NSNumber else {
return 0
}
return value.intValue
}
func isWindowDragSuppressed(window: NSWindow?) -> Bool {
windowDragSuppressionDepth(window: window) > 0
}
@discardableResult
func clearWindowDragSuppression(window: NSWindow?) -> Int {
guard let window else { return 0 }
var depth = windowDragSuppressionDepth(window: window)
while depth > 0 {
depth = endWindowDragSuppression(window: window)
}
return depth
}
/// Temporarily enables window movability for explicit drag-handle drags, then
/// restores the previous movability state after `body` finishes.
@discardableResult
func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> Bool? {
guard let window else {
body()
return nil
}
let previousMovableState = window.isMovable
if !previousMovableState {
window.isMovable = true
}
defer {
if window.isMovable != previousMovableState {
window.isMovable = previousMovableState
}
}
body()
return previousMovableState
}
private enum WindowDragHandleHitTestState {
static var isResolvingTopHit = false
}
/// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty
/// titlebar space. Treat those as pass-through so explicit sibling checks decide.
func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool {
let className = String(describing: type(of: view))
if className.contains("HostContainerView")
|| className.contains("AppKitWindowHostingView")
|| className.contains("NSHostingView") {
return true
}
if let window = view.window, view === window.contentView {
return true
}
return false
}
/// Returns whether the titlebar drag handle should capture a hit at `point`.
/// We only claim the hit when no sibling view already handles it, so interactive
/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures.
func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSView) -> Bool {
if isWindowDragSuppressed(window: dragHandleView.window) {
// Recover from stale suppression if a prior interaction missed cleanup.
// We only keep suppression active while the left mouse button is down.
if (NSEvent.pressedMouseButtons & 0x1) == 0 {
let clearedDepth = clearWindowDragSuppression(window: dragHandleView.window)
#if DEBUG
dlog(
"titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))"
)
#endif
} else {
#if DEBUG
let depth = windowDragSuppressionDepth(window: dragHandleView.window)
dlog(
"titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))"
)
#endif
return false
}
}
guard dragHandleView.bounds.contains(point) else {
#if DEBUG
dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))")
#endif
return false
}
guard let superview = dragHandleView.superview else {
#if DEBUG
dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))")
#endif
return true
}
if let window = dragHandleView.window,
let contentView = window.contentView,
!WindowDragHandleHitTestState.isResolvingTopHit {
let pointInWindow = dragHandleView.convert(point, to: nil)
let pointInContent = contentView.convert(pointInWindow, from: nil)
WindowDragHandleHitTestState.isResolvingTopHit = true
let topHit = contentView.hitTest(pointInContent)
WindowDragHandleHitTestState.isResolvingTopHit = false
if let topHit {
let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView)
let topHitBelongsToTitlebarOverlay = topHit === superview || topHit.isDescendant(of: superview)
let isPassiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(topHit)
#if DEBUG
dlog(
"titlebar.dragHandle.hitTest capture=\(ownsTopHit) strategy=windowTopHit point=\(windowDragHandleFormatPoint(point)) top=\(type(of: topHit)) inTitlebarOverlay=\(topHitBelongsToTitlebarOverlay) passiveHost=\(isPassiveHostHit)"
)
#endif
if ownsTopHit {
return true
}
// Underlay content can transiently overlap titlebar space (notably browser
// chrome/webview layers). Only let top-hits block capture when they belong
// to this titlebar overlay stack.
if topHitBelongsToTitlebarOverlay && !isPassiveHostHit {
return false
}
}
}
#if DEBUG
let siblingCount = superview.subviews.count
#endif
for sibling in superview.subviews.reversed() {
guard sibling !== dragHandleView else { continue }
guard !sibling.isHidden, sibling.alphaValue > 0 else { continue }
let pointInSibling = dragHandleView.convert(point, to: sibling)
if let hitView = sibling.hitTest(pointInSibling) {
let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView)
if passiveHostHit {
#if DEBUG
dlog(
"titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true"
)
#endif
continue
}
#if DEBUG
dlog(
"titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false"
)
#endif
return false
}
}
#if DEBUG
dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)")
#endif
return true
}
/// A transparent view that enables dragging the window when clicking in empty titlebar space.
/// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content
/// (e.g. sidebar tab reordering) don't move the whole window.
@ -14,8 +254,55 @@ struct WindowDragHandleView: NSViewRepresentable {
}
private final class DraggableView: NSView {
override var mouseDownCanMoveWindow: Bool { true }
override func hitTest(_ point: NSPoint) -> NSView? { self }
override var mouseDownCanMoveWindow: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self)
#if DEBUG
dlog(
"titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)"
)
#endif
return shouldCapture ? self : nil
}
override func mouseDown(with event: NSEvent) {
#if DEBUG
let point = convert(event.locationInWindow, from: nil)
let depth = windowDragSuppressionDepth(window: window)
dlog(
"titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)"
)
#endif
if event.clickCount >= 2 {
let handled = performStandardTitlebarDoubleClick(window: window)
#if DEBUG
dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)")
#endif
if handled {
return
}
}
guard !isWindowDragSuppressed(window: window) else {
#if DEBUG
dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed")
#endif
return
}
if let window {
let previousMovableState = withTemporaryWindowMovableEnabled(window: window) {
window.performDrag(with: event)
}
#if DEBUG
let restored = previousMovableState.map { String($0) } ?? "nil"
dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)")
#endif
} else {
super.mouseDown(with: event)
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -9,10 +9,27 @@ struct WorkspaceContentView: View {
let isWorkspaceVisible: Bool
let isWorkspaceInputActive: Bool
let workspacePortalPriority: Int
@State private var config = GhosttyConfig.load()
let onThemeRefreshRequest: ((
_ reason: String,
_ backgroundEventId: UInt64?,
_ backgroundSource: String?,
_ notificationPayloadHex: String?
) -> Void)?
@State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit")
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject var notificationStore: TerminalNotificationStore
static func panelVisibleInUI(
isWorkspaceVisible: Bool,
isSelectedInPane: Bool,
isFocused: Bool
) -> Bool {
guard isWorkspaceVisible else { return false }
// During pane/tab reparenting, Bonsplit can transiently report selected=false
// for the currently focused panel. Keep focused content visible to avoid blank frames.
return isSelectedInPane || isFocused
}
var body: some View {
let appearance = PanelAppearance.fromConfig(config)
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
@ -41,7 +58,11 @@ struct WorkspaceContentView: View {
if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
@ -61,7 +82,7 @@ struct WorkspaceContentView: View {
// indicator and where keyboard input/flash-focus actually lands.
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive else { return }
@ -87,7 +108,7 @@ struct WorkspaceContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
syncBonsplitNotificationBadges()
workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor)
refreshGhosttyAppearanceConfig(reason: "onAppear")
}
.onChange(of: notificationStore.notifications) { _, _ in
syncBonsplitNotificationBadges()
@ -96,18 +117,28 @@ struct WorkspaceContentView: View {
syncBonsplitNotificationBadges()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
refreshGhosttyAppearanceConfig()
refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload")
}
.onChange(of: colorScheme) { _, _ in
.onChange(of: colorScheme) { oldValue, newValue in
// Keep split overlay color/opacity in sync with light/dark theme transitions.
refreshGhosttyAppearanceConfig()
refreshGhosttyAppearanceConfig(reason: "colorSchemeChanged:\(oldValue)->\(newValue)")
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in
if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor {
workspace.applyGhosttyChrome(backgroundColor: backgroundColor)
} else {
workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor)
}
let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value
let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil"
logTheme(
"theme notification workspace=\(workspace.id.uuidString) event=\(eventId.map(String.init) ?? "nil") source=\(source) payload=\(payloadHex) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))"
)
// Payload ordering can lag across rapid config/theme updates.
// Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned
// with Ghostty's current runtime theme.
refreshGhosttyAppearanceConfig(
reason: "ghosttyDefaultBackgroundDidChange",
backgroundEventId: eventId,
backgroundSource: source,
notificationPayloadHex: payloadHex
)
}
}
@ -141,10 +172,95 @@ struct WorkspaceContentView: View {
}
}
private func refreshGhosttyAppearanceConfig() {
let next = GhosttyConfig.load()
config = next
workspace.applyGhosttyChrome(from: next)
static func resolveGhosttyAppearanceConfig(
reason: String = "unspecified",
backgroundOverride: NSColor? = nil,
loadConfig: () -> GhosttyConfig = GhosttyConfig.load,
defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }
) -> GhosttyConfig {
var next = loadConfig()
let loadedBackgroundHex = next.backgroundColor.hexString()
let defaultBackgroundHex: String
let resolvedBackground: NSColor
if let backgroundOverride {
resolvedBackground = backgroundOverride
defaultBackgroundHex = "skipped"
} else {
let fallback = defaultBackground()
resolvedBackground = fallback
defaultBackgroundHex = fallback.hexString()
}
next.backgroundColor = resolvedBackground
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
"theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) theme=\(next.theme ?? "nil")"
)
}
return next
}
private func refreshGhosttyAppearanceConfig(
reason: String,
backgroundOverride: NSColor? = nil,
backgroundEventId: UInt64? = nil,
backgroundSource: String? = nil,
notificationPayloadHex: String? = nil
) {
let previousBackgroundHex = config.backgroundColor.hexString()
let next = Self.resolveGhosttyAppearanceConfig(
reason: reason,
backgroundOverride: backgroundOverride
)
let eventLabel = backgroundEventId.map(String.init) ?? "nil"
let sourceLabel = backgroundSource ?? "nil"
let payloadLabel = notificationPayloadHex ?? "nil"
let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString()
let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear"
logTheme(
"theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")"
)
withTransaction(Transaction(animation: nil)) {
config = next
if shouldRequestTitlebarRefresh {
onThemeRefreshRequest?(
reason,
backgroundEventId,
backgroundSource,
notificationPayloadHex
)
}
}
if !shouldRequestTitlebarRefresh {
logTheme(
"theme refresh titlebar-skip workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString())"
)
}
logTheme(
"theme refresh config-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) configBg=\(config.backgroundColor.hexString())"
)
let chromeReason =
"refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)"
workspace.applyGhosttyChrome(from: next, reason: chromeReason)
if let terminalPanel = workspace.focusedTerminalPanel {
terminalPanel.applyWindowBackgroundIfActive()
logTheme(
"theme refresh terminal-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) panel=\(workspace.focusedPanelId?.uuidString ?? "nil")"
)
} else {
logTheme(
"theme refresh terminal-skipped workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) focusedPanel=\(workspace.focusedPanelId?.uuidString ?? "nil")"
)
}
logTheme(
"theme refresh end workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) chromeBg=\(workspace.bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")"
)
}
private func logTheme(_ message: String) {
guard GhosttyApp.shared.backgroundLogEnabled else { return }
GhosttyApp.shared.logBackground(message)
}
}

View file

@ -35,6 +35,10 @@ struct cmuxApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
if SocketControlSettings.shouldBlockUntaggedDebugLaunch() {
Self.terminateForMissingLaunchTag()
}
Self.configureGhosttyEnvironment()
let startupAppearance = AppearanceSettings.resolvedMode()
@ -58,6 +62,14 @@ struct cmuxApp: App {
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
}
private static func terminateForMissingLaunchTag() -> Never {
let message = "error: refusing to launch untagged cmux DEV; start with ./scripts/reload.sh --tag <name> (or set CMUX_TAG for test harnesses)"
fputs("\(message)\n", stderr)
fflush(stderr)
NSLog("%@", message)
Darwin.exit(64)
}
private static func configureGhosttyEnvironment() {
let fileManager = FileManager.default
let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty"
@ -211,7 +223,7 @@ struct cmuxApp: App {
GhosttyApp.shared.openConfigurationInTextEdit()
}
Button("Reload Configuration") {
GhosttyApp.shared.reloadConfiguration()
GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration")
}
.keyboardShortcut(",", modifiers: [.command, .shift])
Divider()
@ -357,12 +369,37 @@ struct cmuxApp: App {
}
splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).addTab()
if let appDelegate = AppDelegate.shared {
if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil {
#if DEBUG
FocusLogStore.shared.append(
"cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil"
)
#endif
appDelegate.openNewMainWindow(nil)
}
} else {
activeTabManager.addTab()
}
}
}
// Close tab/workspace
CommandGroup(after: .newItem) {
Button("Go to Workspace or Tab…") {
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow)
}
.keyboardShortcut("p", modifiers: [.command])
Button("Command Palette…") {
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow)
}
.keyboardShortcut("p", modifiers: [.command, .shift])
Divider()
// Terminal semantics:
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last
// tab in the last workspace, it closes the window.
@ -378,7 +415,7 @@ struct cmuxApp: App {
}
Button("Reopen Closed Browser Panel") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel()
_ = activeTabManager.reopenMostRecentlyClosedBrowserPanel()
}
.keyboardShortcut("t", modifiers: [.command, .shift])
}
@ -387,95 +424,97 @@ struct cmuxApp: App {
CommandGroup(after: .textEditing) {
Menu("Find") {
Button("Find…") {
(AppDelegate.shared?.tabManager ?? tabManager).startSearch()
activeTabManager.startSearch()
}
.keyboardShortcut("f", modifiers: .command)
Button("Find Next") {
(AppDelegate.shared?.tabManager ?? tabManager).findNext()
activeTabManager.findNext()
}
.keyboardShortcut("g", modifiers: .command)
Button("Find Previous") {
(AppDelegate.shared?.tabManager ?? tabManager).findPrevious()
activeTabManager.findPrevious()
}
.keyboardShortcut("g", modifiers: [.command, .shift])
Divider()
Button("Hide Find Bar") {
(AppDelegate.shared?.tabManager ?? tabManager).hideFind()
activeTabManager.hideFind()
}
.keyboardShortcut("f", modifiers: [.command, .shift])
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible))
.disabled(!(activeTabManager.isFindVisible))
Divider()
Button("Use Selection for Find") {
(AppDelegate.shared?.tabManager ?? tabManager).searchSelection()
activeTabManager.searchSelection()
}
.keyboardShortcut("e", modifiers: .command)
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind))
.disabled(!(activeTabManager.canUseSelectionForFind))
}
}
// Tab navigation
CommandGroup(after: .toolbar) {
splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) {
sidebarState.toggle()
if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true {
sidebarState.toggle()
}
}
Divider()
splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface()
activeTabManager.selectNextSurface()
}
splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface()
activeTabManager.selectPreviousSurface()
}
Button("Back") {
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack()
activeTabManager.focusedBrowserPanel?.goBack()
}
.keyboardShortcut("[", modifiers: .command)
Button("Forward") {
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward()
activeTabManager.focusedBrowserPanel?.goForward()
}
.keyboardShortcut("]", modifiers: .command)
Button("Reload Page") {
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload()
activeTabManager.focusedBrowserPanel?.reload()
}
.keyboardShortcut("r", modifiers: .command)
splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
let manager = activeTabManager
if !manager.toggleDeveloperToolsFocusedBrowser() {
NSSound.beep()
}
}
splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
let manager = activeTabManager
if !manager.showJavaScriptConsoleFocusedBrowser() {
NSSound.beep()
}
}
Button("Zoom In") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser()
_ = activeTabManager.zoomInFocusedBrowser()
}
.keyboardShortcut("=", modifiers: .command)
Button("Zoom Out") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser()
_ = activeTabManager.zoomOutFocusedBrowser()
}
.keyboardShortcut("-", modifiers: .command)
Button("Actual Size") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser()
_ = activeTabManager.resetZoomFocusedBrowser()
}
.keyboardShortcut("0", modifiers: .command)
@ -484,11 +523,11 @@ struct cmuxApp: App {
}
splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextTab()
activeTabManager.selectNextTab()
}
splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab()
activeTabManager.selectPreviousTab()
}
splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) {
@ -518,7 +557,7 @@ struct cmuxApp: App {
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
ForEach(1...9, id: \.self) { number in
Button("Workspace \(number)") {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
let manager = activeTabManager
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) {
manager.selectTab(at: targetIndex)
}
@ -689,6 +728,12 @@ struct cmuxApp: App {
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
}
private var activeTabManager: TabManager {
AppDelegate.shared?.synchronizeActiveMainWindowContext(
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
) ?? tabManager
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
@ -740,11 +785,11 @@ struct cmuxApp: App {
window.performClose(nil)
return
}
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation()
activeTabManager.closeCurrentPanelWithConfirmation()
}
private func closeTabOrWindow() {
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation()
activeTabManager.closeCurrentTabWithConfirmation()
}
private func showNotificationsPopover() {
@ -2533,6 +2578,18 @@ enum QuitWarningSettings {
}
}
enum CommandPaletteRenameSelectionSettings {
static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus"
static let defaultSelectAllOnFocus = true
static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: selectAllOnFocusKey) == nil {
return defaultSelectAllOnFocus
}
return defaults.bool(forKey: selectAllOnFocusKey)
}
}
enum ClaudeCodeIntegrationSettings {
static let hooksEnabledKey = "claudeCodeHooksEnabled"
static let defaultHooksEnabled = true
@ -2559,10 +2616,14 @@ struct SettingsView: View {
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
@AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue()
@AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@ -2765,6 +2826,19 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(
"Rename Selects Existing Name",
subtitle: commandPaletteRenameSelectAllOnFocus
? "Command Palette rename starts with all text selected."
: "Command Palette rename keeps the caret at the end."
) {
Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Sidebar Branch Layout",
subtitle: sidebarBranchVerticalLayout
@ -3094,13 +3168,24 @@ struct SettingsView: View {
.controlSize(.small)
}
if openTerminalLinksInCmuxBrowser {
SettingsCardDivider()
SettingsCardRow(
"Intercept open http(s) in Terminal",
subtitle: "When off, `open https://...` and `open http://...` always use your default browser."
) {
Toggle("", isOn: $interceptTerminalOpenCommandInCmuxBrowser)
.labelsHidden()
.controlSize(.small)
}
if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser {
SettingsCardDivider()
VStack(alignment: .leading, spacing: 6) {
SettingsCardRow(
"Hosts to Open in Embedded Browser",
subtitle: "When you click links in terminal output, only these hosts open in cmux. Other hosts open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all links in cmux."
subtitle: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux."
) {
EmptyView()
}
@ -3362,11 +3447,13 @@ struct SettingsView: View {
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser
browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout