Merge remote-tracking branch 'origin/main' into feature/sidebar-pr-metadata
# Conflicts: # Sources/ContentView.swift # Sources/Workspace.swift
This commit is contained in:
commit
f28eb00b31
92 changed files with 22498 additions and 734 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = """
|
||||
(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
9
Sources/SentryHelper.swift
Normal file
9
Sources/SentryHelper.swift
Normal 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)
|
||||
}
|
||||
474
Sources/SessionPersistence.swift
Normal file
474
Sources/SessionPersistence.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue