Merge branch 'main' into issue-151-ssh-remote-port-proxying

This commit is contained in:
Lawrence Chen 2026-03-11 15:56:47 -07:00
commit d67090994e
61 changed files with 11220 additions and 614 deletions

View file

@ -307,6 +307,9 @@ final class VSCodeServeWebController {
private var isLaunching = false
private var activeLaunchGeneration: UInt64?
private var lifecycleGeneration: UInt64 = 0
#if DEBUG
private var testingTrackedProcesses: [Process] = []
#endif
private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) {
self.launchProcessOverride = launchProcessOverride
@ -318,6 +321,26 @@ final class VSCodeServeWebController {
) -> VSCodeServeWebController {
VSCodeServeWebController(launchProcessOverride: launchProcessOverride)
}
func trackConnectionTokenFileForTesting(
_ connectionTokenFileURL: URL,
setAsLaunchingProcess: Bool = false,
setAsServeWebProcess: Bool = false
) {
let process = Process()
queue.sync {
if setAsLaunchingProcess {
self.launchingProcess = process
}
if setAsServeWebProcess {
self.serveWebProcess = process
}
if !setAsLaunchingProcess && !setAsServeWebProcess {
self.testingTrackedProcesses.append(process)
}
self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL
}
}
#endif
func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) {
@ -420,6 +443,9 @@ final class VSCodeServeWebController {
}
self.serveWebProcess = nil
self.launchingProcess = nil
#if DEBUG
self.testingTrackedProcesses.removeAll()
#endif
var tokenFileURLs = processes.compactMap {
self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0))
}
@ -1490,6 +1516,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}
struct ScriptableMainWindowState {
let windowId: UUID
let tabManager: TabManager
let window: NSWindow?
}
struct SessionDisplayGeometry {
let displayID: UInt32?
let frame: CGRect
@ -3414,6 +3446,95 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
windowForMainWindowId(windowId)
}
func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? {
for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) {
if let window = context.window ?? windowForMainWindowId(context.windowId) {
return window
}
}
return nil
}
func scriptableMainWindows() -> [ScriptableMainWindowState] {
var results: [ScriptableMainWindowState] = []
var seen: Set<UUID> = []
for window in NSApp.orderedWindows {
guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue }
guard seen.insert(context.windowId).inserted else { continue }
results.append(
ScriptableMainWindowState(
windowId: context.windowId,
tabManager: context.tabManager,
window: context.window ?? windowForMainWindowId(context.windowId)
)
)
}
let remaining = mainWindowContexts.values
.sorted { $0.windowId.uuidString < $1.windowId.uuidString }
.filter { seen.insert($0.windowId).inserted }
for context in remaining {
results.append(
ScriptableMainWindowState(
windowId: context.windowId,
tabManager: context.tabManager,
window: context.window ?? windowForMainWindowId(context.windowId)
)
)
}
return results
}
func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? {
guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else {
return nil
}
return ScriptableMainWindowState(
windowId: context.windowId,
tabManager: context.tabManager,
window: context.window ?? windowForMainWindowId(context.windowId)
)
}
func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? {
guard let context = contextContainingTabId(tabId) else { return nil }
return ScriptableMainWindowState(
windowId: context.windowId,
tabManager: context.tabManager,
window: context.window ?? windowForMainWindowId(context.windowId)
)
}
@discardableResult
func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool {
guard let state = scriptableMainWindow(windowId: windowId),
let window = state.window else {
return false
}
setActiveMainWindow(window)
if shouldBringToFront {
bringToFront(window)
}
return true
}
@discardableResult
func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? {
guard let state = scriptableMainWindow(windowId: windowId) else { return nil }
if shouldBringToFront, let window = state.window {
setActiveMainWindow(window)
bringToFront(window)
}
let workspace = state.tabManager.addWorkspace(
workingDirectory: workingDirectory,
select: shouldBringToFront
)
return workspace.id
}
private func markCommandPaletteOpenRequested(for window: NSWindow?) {
guard let window,
let windowId = mainWindowId(for: window) else { return }
@ -4785,6 +4906,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
updateController.checkForUpdates()
}
func openWelcomeWorkspace() {
guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else {
return
}
if let window = context.window ?? windowForMainWindowId(context.windowId) {
setActiveMainWindow(window)
bringToFront(window)
}
let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false)
sendWelcomeCommandWhenReady(to: workspace)
}
func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) {
sendTextWhenReady("cmux welcome\n", to: workspace) {
if markShownOnSend {
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
}
}
}
@objc func applyUpdateIfAvailable(_ sender: Any?) {
updateViewModel.overrideState = nil
updateController.installUpdate()
@ -4914,7 +5055,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
SettingsWindowController.shared.show(navigationTarget: target)
},
activateApplication: @MainActor () -> Void = {
NSApp.activate(ignoringOtherApps: true)
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
}
) {
#if DEBUG
@ -4922,6 +5063,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
#endif
showFallbackSettingsWindow(navigationTarget)
activateApplication()
if let window = SettingsWindowController.shared.window {
window.orderFrontRegardless()
window.makeKeyAndOrderFront(nil)
DispatchQueue.main.async {
window.orderFrontRegardless()
window.makeKeyAndOrderFront(nil)
}
}
#if DEBUG
dlog("settings.open.present activate=1")
#endif
@ -5019,6 +5168,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
pasteboard.setString(payload, forType: .string)
}
private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0, beforeSend: (() -> Void)? = nil) {
let maxAttempts = 60
if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil {
beforeSend?()
terminalPanel.sendText(text)
return
}
guard attempt < maxAttempts else {
NSLog("Command send: surface not ready after \(maxAttempts) attempts")
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1, beforeSend: beforeSend)
}
}
#if DEBUG
private let debugColorWorkspaceTitlePrefix = "Debug Color - "
private let debugPerfWorkspaceTitlePrefix = "Debug Perf - "
@ -5352,21 +5517,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
}
private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) {
let maxAttempts = 60
if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil {
terminalPanel.sendText(text)
return
}
guard attempt < maxAttempts else {
NSLog("Debug scrollback: surface not ready after \(maxAttempts) attempts")
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1)
}
}
@objc func triggerSentryTestCrash(_ sender: Any?) {
SentrySDK.crash()
}
@ -6129,6 +6279,80 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
writeGotoSplitTestData(updates)
}
private func recordGotoSplitZoomIfNeeded() {
guard isGotoSplitUITestRecordingEnabled() else { return }
recordGotoSplitZoomRetry(attempt: 0)
}
private func recordGotoSplitZoomRetry(attempt: Int) {
let delays: [Double] = [0.05, 0.1, 0.2, 0.35, 0.5]
let delay = attempt < delays.count ? delays[attempt] : delays.last!
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self,
let workspace = self.tabManager?.selectedWorkspace else { return }
let browserPanel = workspace.panels.values.compactMap { $0 as? BrowserPanel }.first
let otherTerminal = workspace.panels.values.compactMap { $0 as? TerminalPanel }.first
let browserSnapshot = browserPanel.flatMap {
BrowserWindowPortalRegistry.debugSnapshot(for: $0.webView)
}
var updates = self.gotoSplitFindStateSnapshot(for: workspace)
updates["splitZoomedAfterToggle"] = workspace.bonsplitController.isSplitZoomed ? "true" : "false"
updates["zoomedPaneIdAfterToggle"] = workspace.bonsplitController.zoomedPaneId?.description ?? ""
updates["browserPanelIdAfterToggle"] = browserPanel?.id.uuidString ?? ""
updates["browserContainerHiddenAfterToggle"] = browserSnapshot.map { $0.containerHidden ? "true" : "false" } ?? ""
updates["browserVisibleFlagAfterToggle"] = browserSnapshot.map { $0.visibleInUI ? "true" : "false" } ?? ""
updates["browserFrameAfterToggle"] = browserSnapshot.map {
String(
format: "%.1f,%.1f %.1fx%.1f",
$0.frameInWindow.origin.x,
$0.frameInWindow.origin.y,
$0.frameInWindow.size.width,
$0.frameInWindow.size.height
)
} ?? ""
updates["otherTerminalPanelIdAfterToggle"] = otherTerminal?.id.uuidString ?? ""
updates["otherTerminalHostHiddenAfterToggle"] = otherTerminal.map { $0.hostedView.isHidden ? "true" : "false" } ?? ""
updates["otherTerminalVisibleFlagAfterToggle"] = otherTerminal.map { $0.hostedView.debugPortalVisibleInUI ? "true" : "false" } ?? ""
updates["otherTerminalFrameAfterToggle"] = otherTerminal.map {
let frame = $0.hostedView.debugPortalFrameInWindow
return String(
format: "%.1f,%.1f %.1fx%.1f",
frame.origin.x,
frame.origin.y,
frame.size.width,
frame.size.height
)
} ?? ""
let settled: Bool = {
if workspace.bonsplitController.isSplitZoomed {
if let focusedPanelId = workspace.focusedPanelId,
workspace.terminalPanel(for: focusedPanelId) != nil {
guard let browserSnapshot else { return false }
return browserSnapshot.containerHidden && !browserSnapshot.visibleInUI
}
guard let otherTerminal else { return true }
return otherTerminal.hostedView.isHidden && !otherTerminal.hostedView.debugPortalVisibleInUI
}
let browserRestored = browserSnapshot.map { !$0.containerHidden && $0.visibleInUI } ?? true
let terminalRestored = otherTerminal.map {
!$0.hostedView.isHidden && $0.hostedView.debugPortalVisibleInUI
} ?? true
return browserRestored && terminalRestored
}()
if !settled && attempt < delays.count - 1 {
self.recordGotoSplitZoomRetry(attempt: attempt + 1)
return
}
self.writeGotoSplitTestData(updates)
}
}
private func writeGotoSplitTestData(_ updates: [String: String]) {
guard let path = gotoSplitUITestDataPath() else { return }
var payload = loadGotoSplitTestData(at: path)
@ -7432,6 +7656,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSplitZoom)) {
_ = tabManager?.toggleFocusedSplitZoom()
#if DEBUG
recordGotoSplitZoomIfNeeded()
#endif
return true
}
@ -10138,7 +10365,14 @@ private extension NSWindow {
}
if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"),
let portalWebView = cmuxUniqueBrowserWebView(in: candidate) {
return portalWebView
// Portal-hosted browser chrome (for example the Cmd+F overlay) is a
// sibling of the hosted WKWebView inside WindowBrowserSlotView, not a
// descendant of it. Treating every view in that slot as "web-owned"
// blocks legitimate first-responder changes to overlay text fields.
if view === portalWebView || view.isDescendant(of: portalWebView) {
return portalWebView
}
return nil
}
current = candidate.superview
}

View file

@ -0,0 +1,705 @@
import AppKit
private enum AppleScriptStrings {
static let disabled = String(
localized: "applescript.error.disabled",
defaultValue: "AppleScript is disabled by the macos-applescript configuration."
)
static let missingAction = String(
localized: "applescript.error.missingAction",
defaultValue: "Missing action string."
)
static let missingInputText = String(
localized: "applescript.error.missingInputText",
defaultValue: "Missing input text."
)
static let missingTerminalTarget = String(
localized: "applescript.error.missingTerminalTarget",
defaultValue: "Missing terminal target."
)
static let missingSplitDirection = String(
localized: "applescript.error.missingSplitDirection",
defaultValue: "Missing or unknown split direction."
)
static let windowUnavailable = String(
localized: "applescript.error.windowUnavailable",
defaultValue: "Window is no longer available."
)
static let workspaceUnavailable = String(
localized: "applescript.error.workspaceUnavailable",
defaultValue: "Workspace is no longer available."
)
static let terminalUnavailable = String(
localized: "applescript.error.terminalUnavailable",
defaultValue: "Terminal is no longer available."
)
static let failedToCreateWindow = String(
localized: "applescript.error.failedToCreateWindow",
defaultValue: "Failed to create window."
)
static let failedToCreateWorkspace = String(
localized: "applescript.error.failedToCreateWorkspace",
defaultValue: "Failed to create workspace."
)
static let failedToCreateSplit = String(
localized: "applescript.error.failedToCreateSplit",
defaultValue: "Failed to create split."
)
}
private extension String {
var fourCharCode: UInt32 {
utf8.reduce(0) { ($0 << 8) + UInt32($1) }
}
}
private extension Workspace {
func scriptingTerminalPanels() -> [TerminalPanel] {
var results: [TerminalPanel] = []
var seen: Set<UUID> = []
for panelId in sidebarOrderedPanelIds() {
guard seen.insert(panelId).inserted,
let terminal = terminalPanel(for: panelId) else {
continue
}
results.append(terminal)
}
let remaining = panels.values
.compactMap { $0 as? TerminalPanel }
.sorted { $0.id.uuidString < $1.id.uuidString }
for terminal in remaining where seen.insert(terminal.id).inserted {
results.append(terminal)
}
return results
}
}
@MainActor
extension NSApplication {
var isAppleScriptEnabled: Bool {
GhosttyApp.shared.appleScriptAutomationEnabled()
}
@discardableResult
func validateScript(command: NSScriptCommand) -> Bool {
guard isAppleScriptEnabled else {
command.scriptErrorNumber = errAEEventNotPermitted
command.scriptErrorString = AppleScriptStrings.disabled
return false
}
return true
}
@objc(scriptWindows)
var scriptWindows: [ScriptWindow] {
guard isAppleScriptEnabled,
let appDelegate = AppDelegate.shared else {
return []
}
return appDelegate.scriptableMainWindows().map { ScriptWindow(windowId: $0.windowId) }
}
@objc(frontWindow)
var frontWindow: ScriptWindow? {
scriptWindows.first
}
@objc(valueInScriptWindowsWithUniqueID:)
func valueInScriptWindows(uniqueID: String) -> ScriptWindow? {
guard isAppleScriptEnabled,
let windowId = UUID(uuidString: uniqueID),
let appDelegate = AppDelegate.shared,
appDelegate.scriptableMainWindow(windowId: windowId) != nil else {
return nil
}
return ScriptWindow(windowId: windowId)
}
@objc(terminals)
var terminals: [ScriptTerminal] {
guard isAppleScriptEnabled,
let appDelegate = AppDelegate.shared else {
return []
}
return appDelegate.scriptableMainWindows()
.flatMap { state in
state.tabManager.tabs.flatMap { workspace in
workspace.scriptingTerminalPanels().map {
ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id)
}
}
}
}
@objc(valueInTerminalsWithUniqueID:)
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
guard isAppleScriptEnabled,
let terminalId = UUID(uuidString: uniqueID),
let appDelegate = AppDelegate.shared else {
return nil
}
for state in appDelegate.scriptableMainWindows() {
for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil {
return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId)
}
}
return nil
}
@objc(handlePerformActionScriptCommand:)
func handlePerformActionScriptCommand(_ command: NSScriptCommand) -> NSNumber? {
guard validateScript(command: command) else { return nil }
guard let action = command.directParameter as? String else {
command.scriptErrorNumber = errAEParamMissed
command.scriptErrorString = AppleScriptStrings.missingAction
return nil
}
guard let terminal = command.evaluatedArguments?["on"] as? ScriptTerminal else {
command.scriptErrorNumber = errAEParamMissed
command.scriptErrorString = AppleScriptStrings.missingTerminalTarget
return nil
}
return NSNumber(value: terminal.perform(action: action))
}
@objc(handleNewWindowScriptCommand:)
func handleNewWindowScriptCommand(_ command: NSScriptCommand) -> ScriptWindow? {
guard validateScript(command: command) else { return nil }
guard let appDelegate = AppDelegate.shared else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.failedToCreateWindow
return nil
}
let windowId = appDelegate.createMainWindow()
return ScriptWindow(windowId: windowId)
}
@objc(handleNewTabScriptCommand:)
func handleNewTabScriptCommand(_ command: NSScriptCommand) -> ScriptTab? {
guard validateScript(command: command) else { return nil }
guard let appDelegate = AppDelegate.shared else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace
return nil
}
if let targetWindow = command.evaluatedArguments?["window"] as? ScriptWindow {
guard let workspaceId = appDelegate.addWorkspace(windowId: targetWindow.windowId, bringToFront: false) else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace
return nil
}
return ScriptTab(windowId: targetWindow.windowId, tabId: workspaceId)
}
if let frontWindow = scriptWindows.first,
let workspaceId = appDelegate.addWorkspace(windowId: frontWindow.windowId, bringToFront: false) {
return ScriptTab(windowId: frontWindow.windowId, tabId: workspaceId)
}
let windowId = appDelegate.createMainWindow()
return ScriptWindow(windowId: windowId).selectedTab
}
@objc(handleQuitScriptCommand:)
func handleQuitScriptCommand(_ command: NSScriptCommand) {
guard validateScript(command: command) else { return }
terminate(nil)
}
}
@MainActor
@objc(CmuxScriptWindow)
final class ScriptWindow: NSObject {
let windowId: UUID
init(windowId: UUID) {
self.windowId = windowId
}
private var state: AppDelegate.ScriptableMainWindowState? {
AppDelegate.shared?.scriptableMainWindow(windowId: windowId)
}
@objc(id)
var idValue: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return windowId.uuidString
}
@objc(title)
var title: String {
guard NSApp.isAppleScriptEnabled,
let state else {
return ""
}
let windowTitle = state.window?.title.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !windowTitle.isEmpty {
return windowTitle
}
return state.tabManager.selectedWorkspace?.title ?? ""
}
@objc(tabs)
var tabs: [ScriptTab] {
guard NSApp.isAppleScriptEnabled,
let state else {
return []
}
return state.tabManager.tabs.map { ScriptTab(windowId: windowId, tabId: $0.id) }
}
@objc(selectedTab)
var selectedTab: ScriptTab? {
guard NSApp.isAppleScriptEnabled,
let selectedId = state?.tabManager.selectedTabId else {
return nil
}
return ScriptTab(windowId: windowId, tabId: selectedId)
}
@objc(terminals)
var terminals: [ScriptTerminal] {
guard NSApp.isAppleScriptEnabled,
let state else {
return []
}
return state.tabManager.tabs.flatMap { workspace in
workspace.scriptingTerminalPanels().map {
ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id)
}
}
}
@objc(valueInTabsWithUniqueID:)
func valueInTabs(uniqueID: String) -> ScriptTab? {
guard NSApp.isAppleScriptEnabled,
let tabId = UUID(uuidString: uniqueID),
let state,
state.tabManager.tabs.contains(where: { $0.id == tabId }) else {
return nil
}
return ScriptTab(windowId: windowId, tabId: tabId)
}
@objc(valueInTerminalsWithUniqueID:)
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
guard NSApp.isAppleScriptEnabled,
let terminalId = UUID(uuidString: uniqueID),
let state else {
return nil
}
for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil {
return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId)
}
return nil
}
@objc(handleActivateWindowCommand:)
func handleActivateWindow(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard AppDelegate.shared?.focusScriptableMainWindow(windowId: windowId, bringToFront: true) == true else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.windowUnavailable
return nil
}
return nil
}
@objc(handleCloseWindowCommand:)
func handleCloseWindow(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let window = state?.window else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.windowUnavailable
return nil
}
window.performClose(nil)
return nil
}
override var objectSpecifier: NSScriptObjectSpecifier? {
guard NSApp.isAppleScriptEnabled,
let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
return nil
}
return NSUniqueIDSpecifier(
containerClassDescription: appClassDescription,
containerSpecifier: nil,
key: "scriptWindows",
uniqueID: windowId.uuidString
)
}
}
@MainActor
@objc(CmuxScriptTab)
final class ScriptTab: NSObject {
let windowId: UUID
let tabId: UUID
init(windowId: UUID, tabId: UUID) {
self.windowId = windowId
self.tabId = tabId
}
private var state: AppDelegate.ScriptableMainWindowState? {
AppDelegate.shared?.scriptableMainWindow(windowId: windowId)
}
private var workspace: Workspace? {
state?.tabManager.tabs.first(where: { $0.id == tabId })
}
private var window: ScriptWindow {
ScriptWindow(windowId: windowId)
}
@objc(id)
var idValue: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return tabId.uuidString
}
@objc(title)
var title: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return workspace?.title ?? ""
}
@objc(index)
var index: Int {
guard NSApp.isAppleScriptEnabled,
let state,
let idx = state.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else {
return 0
}
return idx + 1
}
@objc(selected)
var selected: Bool {
guard NSApp.isAppleScriptEnabled else { return false }
return state?.tabManager.selectedTabId == tabId
}
@objc(focusedTerminal)
var focusedTerminal: ScriptTerminal? {
guard NSApp.isAppleScriptEnabled,
let terminalId = workspace?.focusedTerminalPanel?.id else {
return nil
}
return ScriptTerminal(workspaceId: tabId, terminalId: terminalId)
}
@objc(terminals)
var terminals: [ScriptTerminal] {
guard NSApp.isAppleScriptEnabled,
let workspace else {
return []
}
return workspace.scriptingTerminalPanels().map {
ScriptTerminal(workspaceId: tabId, terminalId: $0.id)
}
}
@objc(valueInTerminalsWithUniqueID:)
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
guard NSApp.isAppleScriptEnabled,
let workspace,
let terminalId = UUID(uuidString: uniqueID),
workspace.terminalPanel(for: terminalId) != nil else {
return nil
}
return ScriptTerminal(workspaceId: tabId, terminalId: terminalId)
}
@objc(handleSelectTabCommand:)
func handleSelectTab(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let state,
let workspace else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.workspaceUnavailable
return nil
}
state.tabManager.selectWorkspace(workspace)
return nil
}
@objc(handleCloseTabCommand:)
func handleCloseTab(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let state,
let workspace else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.workspaceUnavailable
return nil
}
if state.tabManager.tabs.count > 1 {
state.tabManager.closeWorkspace(workspace)
return nil
}
guard let window = state.window else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.windowUnavailable
return nil
}
window.performClose(nil)
return nil
}
override var objectSpecifier: NSScriptObjectSpecifier? {
guard NSApp.isAppleScriptEnabled,
let windowClassDescription = window.classDescription as? NSScriptClassDescription,
let windowSpecifier = window.objectSpecifier else {
return nil
}
return NSUniqueIDSpecifier(
containerClassDescription: windowClassDescription,
containerSpecifier: windowSpecifier,
key: "tabs",
uniqueID: tabId.uuidString
)
}
}
@MainActor
@objc(CmuxScriptTerminal)
final class ScriptTerminal: NSObject {
let workspaceId: UUID
let terminalId: UUID
init(workspaceId: UUID, terminalId: UUID) {
self.workspaceId = workspaceId
self.terminalId = terminalId
}
private var state: AppDelegate.ScriptableMainWindowState? {
AppDelegate.shared?.scriptableMainWindowForTab(workspaceId)
}
private var workspace: Workspace? {
state?.tabManager.tabs.first(where: { $0.id == workspaceId })
}
private var terminal: TerminalPanel? {
workspace?.terminalPanel(for: terminalId)
}
@objc(id)
var stableID: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return terminalId.uuidString
}
@objc(title)
var title: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return terminal?.displayTitle ?? ""
}
@objc(workingDirectory)
var workingDirectory: String {
guard NSApp.isAppleScriptEnabled else { return "" }
return terminal?.directory ?? ""
}
func input(text: String) -> Bool {
guard NSApp.isAppleScriptEnabled,
let terminal else {
return false
}
terminal.sendText(text)
return true
}
func perform(action: String) -> Bool {
guard NSApp.isAppleScriptEnabled else { return false }
return terminal?.performBindingAction(action) ?? false
}
@objc(handleSplitCommand:)
func handleSplit(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let directionCode = command.evaluatedArguments?["direction"] as? UInt32,
let direction = ScriptSplitDirection(code: directionCode)?.splitDirection else {
command.scriptErrorNumber = errAEParamMissed
command.scriptErrorString = AppleScriptStrings.missingSplitDirection
return nil
}
guard let state,
let workspace,
terminal != nil else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
return nil
}
guard let newPanelId = state.tabManager.newSplit(tabId: workspaceId, surfaceId: terminalId, direction: direction),
workspace.terminalPanel(for: newPanelId) != nil else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.failedToCreateSplit
return nil
}
return ScriptTerminal(workspaceId: workspaceId, terminalId: newPanelId)
}
@objc(handleFocusCommand:)
func handleFocus(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let state,
let workspace,
terminal != nil else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
return nil
}
if let app = AppDelegate.shared {
_ = app.focusScriptableMainWindow(windowId: state.windowId, bringToFront: true)
}
state.tabManager.selectWorkspace(workspace)
workspace.focusPanel(terminalId)
return nil
}
@objc(handleCloseCommand:)
func handleClose(_ command: NSScriptCommand) -> Any? {
guard NSApp.validateScript(command: command) else { return nil }
guard let state,
let workspace,
terminal != nil else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
return nil
}
if workspace.panels.count == 1 {
if state.tabManager.tabs.count > 1 {
state.tabManager.closeWorkspace(workspace)
return nil
}
guard let window = state.window else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.windowUnavailable
return nil
}
window.performClose(nil)
return nil
}
guard workspace.closePanel(terminalId, force: true) else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
return nil
}
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspaceId, surfaceId: terminalId)
return nil
}
override var objectSpecifier: NSScriptObjectSpecifier? {
guard NSApp.isAppleScriptEnabled,
let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
return nil
}
return NSUniqueIDSpecifier(
containerClassDescription: appClassDescription,
containerSpecifier: nil,
key: "terminals",
uniqueID: terminalId.uuidString
)
}
}
@MainActor
@objc(CmuxScriptInputTextCommand)
final class ScriptInputTextCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
guard NSApp.validateScript(command: self) else { return nil }
guard let text = directParameter as? String else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = AppleScriptStrings.missingInputText
return nil
}
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
scriptErrorNumber = errAEParamMissed
scriptErrorString = AppleScriptStrings.missingTerminalTarget
return nil
}
guard terminal.input(text: text) else {
scriptErrorNumber = errAEEventFailed
scriptErrorString = AppleScriptStrings.terminalUnavailable
return nil
}
return nil
}
}
private enum ScriptSplitDirection {
case right
case left
case down
case up
init?(code: UInt32) {
switch code {
case "GSrt".fourCharCode: self = .right
case "GSlf".fourCharCode: self = .left
case "GSdn".fourCharCode: self = .down
case "GSup".fourCharCode: self = .up
default: return nil
}
}
var splitDirection: SplitDirection {
switch self {
case .right: return .right
case .left: return .left
case .down: return .down
case .up: return .up
}
}
}

View file

@ -6,6 +6,7 @@ import WebKit
private var cmuxWindowBrowserPortalKey: UInt8 = 0
private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0
private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0
#if DEBUG
private func browserPortalDebugToken(_ view: NSView?) -> String {
@ -31,6 +32,17 @@ private extension NSObject {
}
}
private extension NSResponder {
var browserPortalOwningView: NSView? {
if let editor = self as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
return editedView
}
return self as? NSView
}
}
private extension WKWebView {
func browserPortalNotifyHidden(reason: String) {
let firedSelectors = ["viewDidHide", "_exitInWindow"].filter {
@ -81,6 +93,115 @@ private extension WKWebView {
}
}
enum HostedInspectorDockSide {
case leading
case trailing
static func resolve(
pageFrame: NSRect,
inspectorFrame: NSRect,
epsilon: CGFloat = 1
) -> Self? {
if pageFrame.maxX <= inspectorFrame.minX + epsilon {
return .trailing
}
if inspectorFrame.maxX <= pageFrame.minX + epsilon {
return .leading
}
return nil
}
func dividerX(pageFrame: NSRect, inspectorFrame: NSRect) -> CGFloat {
switch self {
case .leading:
return inspectorFrame.maxX
case .trailing:
return inspectorFrame.minX
}
}
func dividerHitRect(
in bounds: NSRect,
pageFrame: NSRect,
inspectorFrame: NSRect,
expansion: CGFloat
) -> NSRect {
let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY))
let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY))
return NSRect(
x: dividerX(pageFrame: pageFrame, inspectorFrame: inspectorFrame) - expansion,
y: minY,
width: expansion * 2,
height: max(0, maxY - minY)
)
}
func clampedDividerX(
_ proposedDividerX: CGFloat,
containerBounds: NSRect,
pageFrame: NSRect,
minimumInspectorWidth: CGFloat
) -> CGFloat {
switch self {
case .leading:
let minDividerX = min(containerBounds.maxX, containerBounds.minX + minimumInspectorWidth)
let maxDividerX = max(minDividerX, min(containerBounds.maxX, pageFrame.maxX))
return max(minDividerX, min(maxDividerX, proposedDividerX))
case .trailing:
let minDividerX = max(containerBounds.minX, pageFrame.minX)
let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth)
return max(minDividerX, min(maxDividerX, proposedDividerX))
}
}
func inspectorWidth(forDividerX dividerX: CGFloat, in containerBounds: NSRect) -> CGFloat {
switch self {
case .leading:
return max(0, dividerX - containerBounds.minX)
case .trailing:
return max(0, containerBounds.maxX - dividerX)
}
}
func resizedFrames(
preferredWidth: CGFloat,
in containerBounds: NSRect,
pageFrame: NSRect,
inspectorFrame: NSRect,
minimumInspectorWidth _: CGFloat
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
switch self {
case .leading:
let maximumInspectorWidth = max(0, containerBounds.width)
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
let dividerX = min(containerBounds.maxX, containerBounds.minX + clampedInspectorWidth)
var nextPageFrame = pageFrame
nextPageFrame.origin.x = dividerX
nextPageFrame.size.width = max(0, containerBounds.maxX - dividerX)
var nextInspectorFrame = inspectorFrame
nextInspectorFrame.origin.x = containerBounds.minX
nextInspectorFrame.size.width = max(0, dividerX - containerBounds.minX)
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
case .trailing:
let maximumInspectorWidth = max(0, containerBounds.width)
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
let dividerX = max(containerBounds.minX, containerBounds.maxX - clampedInspectorWidth)
var nextPageFrame = pageFrame
nextPageFrame.origin.x = containerBounds.minX
nextPageFrame.size.width = max(0, dividerX - containerBounds.minX)
var nextInspectorFrame = inspectorFrame
nextInspectorFrame.origin.x = dividerX
nextInspectorFrame.size.width = max(0, containerBounds.maxX - dividerX)
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
}
}
}
final class WindowBrowserHostView: NSView {
private struct DividerRegion {
let rectInWindow: NSRect
@ -97,6 +218,7 @@ final class WindowBrowserHostView: NSView {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
let dockSide: HostedInspectorDockSide
}
private struct HostedInspectorDividerDragState {
@ -104,6 +226,7 @@ final class WindowBrowserHostView: NSView {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
let dockSide: HostedInspectorDockSide
let initialWindowX: CGFloat
let initialPageFrame: NSRect
let initialInspectorFrame: NSRect
@ -131,6 +254,7 @@ final class WindowBrowserHostView: NSView {
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
private var lastHostedInspectorLayoutBoundsSize: NSSize?
deinit {
if let trackingArea {
@ -200,6 +324,11 @@ final class WindowBrowserHostView: NSView {
override func layout() {
super.layout()
if let previousSize = lastHostedInspectorLayoutBoundsSize,
Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) {
return
}
lastHostedInspectorLayoutBoundsSize = bounds.size
reapplyHostedInspectorDividersIfNeeded(reason: "host.layout")
}
@ -378,11 +507,13 @@ final class WindowBrowserHostView: NSView {
return
}
hostedInspectorHit.slotView.isHostedInspectorDividerDragActive = true
hostedInspectorDividerDrag = HostedInspectorDividerDragState(
slotView: hostedInspectorHit.slotView,
containerView: hostedInspectorHit.containerView,
pageView: hostedInspectorHit.pageView,
inspectorView: hostedInspectorHit.inspectorView,
dockSide: hostedInspectorHit.dockSide,
initialWindowX: event.locationInWindow.x,
initialPageFrame: hostedInspectorHit.pageView.frame,
initialInspectorFrame: hostedInspectorHit.inspectorView.frame
@ -404,6 +535,7 @@ final class WindowBrowserHostView: NSView {
return
}
guard dragState.slotView.window === window else {
dragState.slotView.isHostedInspectorDividerDragActive = false
hostedInspectorDividerDrag = nil
super.mouseDragged(with: event)
return
@ -414,20 +546,31 @@ final class WindowBrowserHostView: NSView {
Self.minimumHostedInspectorWidth,
max(60, dragState.initialInspectorFrame.width)
)
let minDividerX = max(containerBounds.minX, dragState.initialPageFrame.minX)
let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth)
let proposedDividerX = dragState.initialInspectorFrame.minX + (event.locationInWindow.x - dragState.initialWindowX)
let clampedDividerX = max(minDividerX, min(maxDividerX, proposedDividerX))
let inspectorWidth = max(0, containerBounds.maxX - clampedDividerX)
let initialDividerX = dragState.dockSide.dividerX(
pageFrame: dragState.initialPageFrame,
inspectorFrame: dragState.initialInspectorFrame
)
let proposedDividerX = initialDividerX + (event.locationInWindow.x - dragState.initialWindowX)
let clampedDividerX = dragState.dockSide.clampedDividerX(
proposedDividerX,
containerBounds: containerBounds,
pageFrame: dragState.initialPageFrame,
minimumInspectorWidth: minimumInspectorWidth
)
let inspectorWidth = dragState.dockSide.inspectorWidth(
forDividerX: clampedDividerX,
in: containerBounds
)
dragState.slotView.preferredHostedInspectorWidth = inspectorWidth
dragState.slotView.recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds)
let appliedFrames = applyHostedInspectorDividerWidth(
inspectorWidth,
to: HostedInspectorDividerHit(
slotView: dragState.slotView,
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView
inspectorView: dragState.inspectorView,
dockSide: dragState.dockSide
),
reason: "drag"
)
@ -438,7 +581,8 @@ final class WindowBrowserHostView: NSView {
slotView: dragState.slotView,
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView
inspectorView: dragState.inspectorView,
dockSide: dragState.dockSide
)
)
#if DEBUG
@ -453,6 +597,7 @@ final class WindowBrowserHostView: NSView {
override func mouseUp(with event: NSEvent) {
if let dragState = hostedInspectorDividerDrag {
dragState.slotView.isHostedInspectorDividerDragActive = false
#if DEBUG
dlog(
"browser.portal.manualInspectorDrag stage=end slot=\(browserPortalDebugToken(dragState.slotView)) " +
@ -710,22 +855,31 @@ final class WindowBrowserHostView: NSView {
while let inspectorView = current, inspectorView !== slot {
guard let containerView = inspectorView.superview else { break }
let pageCandidates = containerView.subviews.filter { candidate in
guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return false }
guard candidate !== inspectorView else { return false }
guard candidate.frame.maxX <= inspectorView.frame.minX + 1 else { return false }
return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8
let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in
guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return nil }
guard candidate !== inspectorView else { return nil }
guard Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else {
return nil
}
guard let dockSide = HostedInspectorDockSide.resolve(
pageFrame: candidate.frame,
inspectorFrame: inspectorView.frame
) else {
return nil
}
return (view: candidate, dockSide: dockSide)
}
if let pageView = pageCandidates.max(by: {
hostedInspectorPageCandidateScore($0, inspectorView: inspectorView)
< hostedInspectorPageCandidateScore($1, inspectorView: inspectorView)
if let pageCandidate = pageCandidates.max(by: {
hostedInspectorPageCandidateScore($0.view, inspectorView: inspectorView)
< hostedInspectorPageCandidateScore($1.view, inspectorView: inspectorView)
}) {
bestHit = HostedInspectorDividerHit(
slotView: slot,
containerView: containerView,
pageView: pageView,
inspectorView: inspectorView
pageView: pageCandidate.view,
inspectorView: inspectorView,
dockSide: pageCandidate.dockSide
)
}
@ -739,13 +893,11 @@ final class WindowBrowserHostView: NSView {
let slotBounds = hit.slotView.bounds
let pageFrame = hit.slotView.convert(hit.pageView.bounds, from: hit.pageView)
let inspectorFrame = hit.slotView.convert(hit.inspectorView.bounds, from: hit.inspectorView)
let minY = max(slotBounds.minY, min(pageFrame.minY, inspectorFrame.minY))
let maxY = min(slotBounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY))
return NSRect(
x: inspectorFrame.minX - Self.hostedInspectorDividerHitExpansion,
y: minY,
width: Self.hostedInspectorDividerHitExpansion * 2,
height: max(0, maxY - minY)
return hit.dockSide.dividerHitRect(
in: slotBounds,
pageFrame: pageFrame,
inspectorFrame: inspectorFrame,
expansion: Self.hostedInspectorDividerHitExpansion
)
}
@ -779,10 +931,24 @@ final class WindowBrowserHostView: NSView {
}
}
fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) {
guard let preferredWidth = slot.preferredHostedInspectorWidth else { return }
guard let hit = hostedInspectorDividerCandidate(in: slot) else { return }
@discardableResult
fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) -> Bool {
guard !slot.isHostedInspectorDividerDragActive else {
#if DEBUG
dlog(
"browser.portal.manualInspectorDrag stage=skipReapply slot=\(browserPortalDebugToken(slot)) " +
"reason=\(reason)"
)
#endif
return false
}
guard let preferredWidth = slot.resolvedPreferredHostedInspectorWidth(in: slot.bounds) else { return false }
guard let hit = hostedInspectorDividerCandidate(in: slot) else { return false }
let oldPageFrame = hit.pageView.frame
let oldInspectorFrame = hit.inspectorView.frame
_ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
return !Self.rectApproximatelyEqual(oldPageFrame, hit.pageView.frame, epsilon: 0.5) ||
!Self.rectApproximatelyEqual(oldInspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
}
@discardableResult
@ -792,19 +958,20 @@ final class WindowBrowserHostView: NSView {
reason: String
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
let containerBounds = hit.containerView.bounds
let maximumInspectorWidth = max(0, containerBounds.maxX - hit.pageView.frame.minX)
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
let dividerX = max(hit.pageView.frame.minX, containerBounds.maxX - clampedInspectorWidth)
let nextFrames = hit.dockSide.resizedFrames(
preferredWidth: preferredWidth,
in: containerBounds,
pageFrame: hit.pageView.frame,
inspectorFrame: hit.inspectorView.frame,
minimumInspectorWidth: 0
)
let pageFrame = nextFrames.pageFrame
let inspectorFrame = nextFrames.inspectorFrame
var pageFrame = hit.pageView.frame
pageFrame.size.width = max(0, dividerX - pageFrame.minX)
var inspectorFrame = hit.inspectorView.frame
inspectorFrame.origin.x = dividerX
inspectorFrame.size.width = max(0, containerBounds.maxX - dividerX)
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5)
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
let oldPageFrame = hit.pageView.frame
let oldInspectorFrame = hit.inspectorView.frame
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5)
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5)
guard pageChanged || inspectorChanged else {
return (pageFrame, inspectorFrame)
}
@ -817,15 +984,23 @@ final class WindowBrowserHostView: NSView {
CATransaction.commit()
hit.slotView.isApplyingHostedInspectorLayout = false
hit.pageView.needsLayout = true
hit.inspectorView.needsLayout = true
hit.containerView.needsLayout = true
hit.slotView.needsLayout = true
let isLiveDrag = reason == "drag"
hit.pageView.needsDisplay = true
hit.pageView.setNeedsDisplay(hit.pageView.bounds)
hit.inspectorView.needsDisplay = true
hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds)
hit.containerView.needsDisplay = true
hit.containerView.setNeedsDisplay(hit.containerView.bounds)
hit.slotView.needsDisplay = true
hit.slotView.setNeedsDisplay(hit.slotView.bounds)
#if DEBUG
dlog(
"browser.portal.manualInspectorDrag stage=reapply slot=\(browserPortalDebugToken(hit.slotView)) " +
"container=\(browserPortalDebugToken(hit.containerView)) reason=\(reason) " +
"preferredWidth=\(String(format: "%.1f", preferredWidth)) " +
"liveDrag=\(isLiveDrag ? 1 : 0) " +
"pageChanged=\(pageChanged ? 1 : 0) inspectorChanged=\(inspectorChanged ? 1 : 0) " +
"oldPageFrame=\(browserPortalDebugFrame(oldPageFrame)) oldInspectorFrame=\(browserPortalDebugFrame(oldInspectorFrame)) " +
"pageFrame=\(browserPortalDebugFrame(pageFrame)) " +
"inspectorFrame=\(browserPortalDebugFrame(inspectorFrame))"
)
@ -903,6 +1078,11 @@ final class WindowBrowserHostView: NSView {
abs(lhs.size.height - rhs.size.height) <= epsilon
}
private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.width - rhs.width) <= epsilon &&
abs(lhs.height - rhs.height) <= epsilon
}
private static func visibleDescendants(in root: NSView) -> [NSView] {
var descendants: [NSView] = []
var stack = Array(root.subviews.reversed())
@ -978,9 +1158,12 @@ private final class BrowserDropZoneOverlayView: NSView {
struct BrowserPortalSearchOverlayConfiguration {
let panelId: UUID
let searchState: BrowserSearchState
let focusRequestGeneration: UInt64
let canApplyFocusRequest: (UInt64) -> Bool
let onNext: () -> Void
let onPrevious: () -> Void
let onClose: () -> Void
let onFieldDidFocus: () -> Void
}
struct BrowserPaneDropContext: Equatable {
@ -1359,8 +1542,11 @@ final class WindowBrowserSlotView: NSView {
private var isRefreshingInteractionLayers = false
private var paneTopChromeHeight: CGFloat = 0
var preferredHostedInspectorWidth: CGFloat?
private var preferredHostedInspectorWidthFraction: CGFloat?
fileprivate var isHostedInspectorDividerDragActive = false
var onHostedInspectorLayout: ((WindowBrowserSlotView) -> Void)?
fileprivate var isApplyingHostedInspectorLayout = false
private var lastHostedInspectorLayoutBoundsSize: NSSize?
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
@ -1390,6 +1576,11 @@ final class WindowBrowserSlotView: NSView {
paneDropTargetView.frame = bounds
applyResolvedDropZoneOverlay()
guard !isApplyingHostedInspectorLayout else { return }
if let previousSize = lastHostedInspectorLayoutBoundsSize,
Self.sizeApproximatelyEqual(previousSize, bounds.size) {
return
}
lastHostedInspectorLayoutBoundsSize = bounds.size
onHostedInspectorLayout?(self)
}
@ -1399,6 +1590,27 @@ final class WindowBrowserSlotView: NSView {
applyResolvedDropZoneOverlay()
}
func recordPreferredHostedInspectorWidth(_ width: CGFloat, containerBounds: NSRect) {
preferredHostedInspectorWidth = width
guard containerBounds.width > 0 else {
preferredHostedInspectorWidthFraction = nil
return
}
preferredHostedInspectorWidthFraction = width / containerBounds.width
}
func resolvedPreferredHostedInspectorWidth(in containerBounds: NSRect) -> CGFloat? {
if let preferredHostedInspectorWidthFraction, containerBounds.width > 0 {
return max(0, containerBounds.width * preferredHostedInspectorWidthFraction)
}
return preferredHostedInspectorWidth
}
private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.5) -> Bool {
abs(lhs.width - rhs.width) <= epsilon &&
abs(lhs.height - rhs.height) <= epsilon
}
func setDropZoneOverlay(zone: DropZone?) {
forwardedDropZone = zone
applyResolvedDropZoneOverlay()
@ -1420,23 +1632,63 @@ final class WindowBrowserSlotView: NSView {
applyResolvedDropZoneOverlay()
}
private func logSearchOverlayEvent(_ action: String, panelId: UUID?) {
#if DEBUG
let firstResponderSummary: String = {
guard let firstResponder = window?.firstResponder else { return "nil" }
if let editor = firstResponder as? NSTextView, editor.isFieldEditor {
let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil"
return "fieldEditor(delegate=\(delegateSummary))"
}
return String(describing: type(of: firstResponder))
}()
dlog(
"browser.findbar.portal action=\(action) " +
"panel=\(panelId?.uuidString.prefix(5) ?? "nil") " +
"window=\(window?.windowNumber ?? -1) " +
"firstResponder=\(firstResponderSummary) " +
"hasOverlay=\(searchOverlayHostingView != nil ? 1 : 0)"
)
#endif
}
func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) {
guard let configuration else {
logSearchOverlayEvent("remove", panelId: nil)
if let overlay = searchOverlayHostingView {
objc_setAssociatedObject(
overlay,
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
nil,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
searchOverlayHostingView?.removeFromSuperview()
searchOverlayHostingView = nil
return
}
logSearchOverlayEvent("set", panelId: configuration.panelId)
let rootView = BrowserSearchOverlay(
panelId: configuration.panelId,
searchState: configuration.searchState,
focusRequestGeneration: configuration.focusRequestGeneration,
canApplyFocusRequest: configuration.canApplyFocusRequest,
onNext: configuration.onNext,
onPrevious: configuration.onPrevious,
onClose: configuration.onClose
onClose: configuration.onClose,
onFieldDidFocus: configuration.onFieldDidFocus
)
if let overlay = searchOverlayHostingView {
logSearchOverlayEvent("updateExisting", panelId: configuration.panelId)
overlay.rootView = rootView
objc_setAssociatedObject(
overlay,
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
configuration.panelId,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
if overlay.superview !== self {
overlay.removeFromSuperview()
addSubview(overlay)
@ -1452,6 +1704,12 @@ final class WindowBrowserSlotView: NSView {
let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
objc_setAssociatedObject(
overlay,
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
configuration.panelId,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
@ -1460,36 +1718,79 @@ final class WindowBrowserSlotView: NSView {
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
searchOverlayHostingView = overlay
logSearchOverlayEvent("create", panelId: configuration.panelId)
}
func searchOverlayPanelId(for responder: NSResponder) -> UUID? {
guard let overlay = searchOverlayHostingView,
let view = responder.browserPortalOwningView,
view.isDescendant(of: overlay) else {
return nil
}
return objc_getAssociatedObject(overlay, &cmuxBrowserSearchOverlayPanelIdAssociationKey) as? UUID
}
@discardableResult
func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool {
guard let firstResponder = window.firstResponder,
searchOverlayPanelId(for: firstResponder) == panelId else {
return false
}
return window.makeFirstResponder(nil)
}
func pinHostedWebView(_ webView: WKWebView) {
guard webView.superview === self else { return }
let needsNewConstraints =
let needsPlainWebViewFrameReset =
!Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) &&
Self.frameDiffersFromBounds(webView.frame, bounds: bounds)
let needsFrameHosting =
hostedWebView !== webView ||
hostedWebViewConstraints.isEmpty ||
webView.translatesAutoresizingMaskIntoConstraints
guard needsNewConstraints else {
!hostedWebViewConstraints.isEmpty ||
needsPlainWebViewFrameReset ||
!webView.translatesAutoresizingMaskIntoConstraints ||
webView.autoresizingMask != [.width, .height]
guard needsFrameHosting else {
needsLayout = true
layoutSubtreeIfNeeded()
return
}
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
hostedWebViewConstraints = []
hostedWebView = webView
webView.translatesAutoresizingMaskIntoConstraints = false
webView.autoresizingMask = []
hostedWebViewConstraints = [
webView.topAnchor.constraint(equalTo: topAnchor),
webView.bottomAnchor.constraint(equalTo: bottomAnchor),
webView.leadingAnchor.constraint(equalTo: leadingAnchor),
webView.trailingAnchor.constraint(equalTo: trailingAnchor),
]
NSLayoutConstraint.activate(hostedWebViewConstraints)
// Attached Web Inspector mutates the moved WKWebView's frame directly.
// Re-pin plain web views after cross-host reattach, but preserve the
// WebKit-managed split frame when docked DevTools siblings are present.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = bounds
needsLayout = true
layoutSubtreeIfNeeded()
}
private static func frameDiffersFromBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
abs(frame.minX - bounds.minX) > epsilon ||
abs(frame.minY - bounds.minY) > epsilon ||
abs(frame.width - bounds.width) > epsilon ||
abs(frame.height - bounds.height) > epsilon
}
private static func hasWebKitCompanionSubview(in host: NSView, primaryWebView: WKWebView) -> Bool {
var stack = host.subviews.filter { $0 !== primaryWebView }
while let current = stack.popLast() {
if current.isDescendant(of: primaryWebView) {
continue
}
if String(describing: type(of: current)).contains("WK") {
return true
}
stack.append(contentsOf: current.subviews)
}
return false
}
func effectivePaneTopChromeHeight() -> CGFloat {
paneTopChromeHeight
}
@ -1687,6 +1988,18 @@ final class WindowBrowserPortal: NSObject {
_ = ensureInstalled()
}
static func shouldTreatSplitResizeAsExternalGeometry(
_ splitView: NSSplitView,
window: NSWindow,
hostView: WindowBrowserHostView
) -> Bool {
guard splitView.window === window else { return false }
// WebKit's attached DevTools uses internal NSSplitView instances for the
// side/bottom inspector layout. Those resizes are local to hosted content
// and should not trigger a full portal re-sync/refresh pass.
return !splitView.isDescendant(of: hostView)
}
private func installGeometryObservers(for window: NSWindow) {
guard geometryObservers.isEmpty else { return }
@ -1718,7 +2031,11 @@ final class WindowBrowserPortal: NSObject {
guard let self,
let splitView = notification.object as? NSSplitView,
let window = self.window,
splitView.window === window else { return }
Self.shouldTreatSplitResizeAsExternalGeometry(
splitView,
window: window,
hostView: self.hostView
) else { return }
self.scheduleExternalGeometrySynchronize()
}
})
@ -1753,6 +2070,7 @@ final class WindowBrowserPortal: NSObject {
guard let webView = entry.webView,
let containerView = entry.containerView,
!containerView.isHidden else { continue }
guard webView.superview === containerView else { continue }
refreshHostedWebViewPresentation(
webView,
in: containerView,
@ -1872,7 +2190,9 @@ final class WindowBrowserPortal: NSObject {
case (nil, nil):
return true
case let (lhs?, rhs?):
return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState
return lhs.panelId == rhs.panelId &&
lhs.searchState === rhs.searchState &&
lhs.focusRequestGeneration == rhs.focusRequestGeneration
default:
return false
}
@ -1966,6 +2286,15 @@ final class WindowBrowserPortal: NSObject {
phase: String
) {
guard !containerView.isHidden else { return }
guard !containerView.isHostedInspectorDividerDragActive else {
#if DEBUG
dlog(
"browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(containerView)) reason=\(reason) phase=\(phase) drag=1"
)
#endif
return
}
containerView.needsLayout = true
containerView.needsDisplay = true
@ -2043,7 +2372,12 @@ final class WindowBrowserPortal: NSObject {
// UI state does not get orphaned in the old host during split churn.
let relatedSubviews = sourceSuperview.subviews.filter { view in
if view === primaryWebView { return true }
return String(describing: type(of: view)).contains("WK")
let className = String(describing: type(of: view))
guard className.contains("WK") else { return false }
if className.contains("WKInspector") {
return !view.isHidden && view.alphaValue > 0 && view.frame.width > 1 && view.frame.height > 1
}
return true
}
guard !relatedSubviews.isEmpty else { return }
#if DEBUG
@ -2144,6 +2478,26 @@ final class WindowBrowserPortal: NSObject {
entry.containerView?.setSearchOverlay(configuration)
}
func searchOverlayPanelId(for responder: NSResponder) -> UUID? {
for entry in entriesByWebViewId.values {
if let panelId = entry.containerView?.searchOverlayPanelId(for: responder) {
return panelId
}
}
return nil
}
@discardableResult
func yieldSearchOverlayFocusIfOwned(by panelId: UUID) -> Bool {
guard let window else { return false }
for entry in entriesByWebViewId.values {
if entry.containerView?.yieldSearchOverlayFocusIfOwned(by: panelId, in: window) == true {
return true
}
}
return false
}
func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) {
guard var entry = entriesByWebViewId[webViewId] else { return }
let resolvedHeight = max(0, height)
@ -2406,7 +2760,7 @@ final class WindowBrowserPortal: NSObject {
containerView.setPaneTopChromeHeight(0)
containerView.setSearchOverlay(nil)
containerView.setDropZoneOverlay(zone: nil)
if !containerView.isHidden {
if !containerView.isHidden, webView.superview === containerView {
webView.browserPortalNotifyHidden(reason: reason)
}
containerView.isHidden = true
@ -2508,7 +2862,18 @@ final class WindowBrowserPortal: NSObject {
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
refreshReasons.append("syncAttachContainer")
}
if webView.superview !== containerView {
let shouldPreserveExternalHostForHiddenEntry =
!entry.visibleInUI &&
webView.superview !== containerView
if shouldPreserveExternalHostForHiddenEntry {
#if DEBUG
dlog(
"browser.portal.reparent.skip web=\(browserPortalDebugToken(webView)) " +
"reason=hiddenEntryExternalHost super=\(browserPortalDebugToken(webView.superview)) " +
"container=\(browserPortalDebugToken(containerView))"
)
#endif
} else if webView.superview !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
@ -2699,15 +3064,16 @@ final class WindowBrowserPortal: NSObject {
refreshReasons.append("bounds")
}
let containerOwnsWebView = webView.superview === containerView
let containerBounds = containerView.bounds
let preNormalizeWebFrame = webView.frame
let preNormalizeWebFrame = containerOwnsWebView ? webView.frame : .zero
let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height)
let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY)
let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow)
#if DEBUG
let inspectorSubviews = Self.inspectorSubviewCount(in: containerView)
#endif
if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
if containerOwnsWebView && Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
let oldWebFrame = preNormalizeWebFrame
CATransaction.begin()
CATransaction.setDisableActions(true)
@ -2766,14 +3132,31 @@ final class WindowBrowserPortal: NSObject {
if transientRecoveryReason == nil {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
if !shouldHide, !refreshReasons.isEmpty {
refreshHostedWebViewPresentation(
webView,
in: containerView,
reason: "\(source):" + refreshReasons.joined(separator: ",")
)
let hostedInspectorAdjustedDuringSync =
containerOwnsWebView &&
hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty {
if hostedInspectorAdjustedDuringSync {
#if DEBUG
dlog(
"browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(containerView)) reason=\(source):" +
"\(refreshReasons.joined(separator: ",")) adjustedDuringSync=1"
)
#endif
} else {
refreshHostedWebViewPresentation(
webView,
in: containerView,
reason: "\(source):" + refreshReasons.joined(separator: ",")
)
}
}
if containerOwnsWebView, !hostedInspectorAdjustedDuringSync {
// Keep the existing post-sync pass for cases where the inspector candidate
// appears only after WebKit settles, but avoid a second apply when sync already clamped it.
_ = hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync.postRefresh")
}
hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
#if DEBUG
dlog(
"browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " +
@ -2783,6 +3166,8 @@ final class WindowBrowserPortal: NSObject {
"old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " +
"target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
"entryVisible=\(entry.visibleInUI ? 1 : 0) " +
"containerOwnsWeb=\(containerOwnsWebView ? 1 : 0) " +
"inspectorAdjusted=\(hostedInspectorAdjustedDuringSync ? 1 : 0) " +
"containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " +
"containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " +
"preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " +
@ -2856,6 +3241,19 @@ final class WindowBrowserPortal: NSObject {
}
#endif
func debugSnapshot(forWebViewId webViewId: ObjectIdentifier) -> BrowserWindowPortalRegistry.DebugSnapshot? {
guard let entry = entriesByWebViewId[webViewId] else { return nil }
let frameInWindow: CGRect = {
guard let container = entry.containerView, container.window != nil else { return .zero }
return container.convert(container.bounds, to: nil)
}()
return BrowserWindowPortalRegistry.DebugSnapshot(
visibleInUI: entry.visibleInUI,
containerHidden: entry.containerView?.isHidden ?? true,
frameInWindow: frameInWindow
)
}
func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? {
guard ensureInstalled() else { return nil }
let point = hostView.convert(windowPoint, from: nil)
@ -2875,6 +3273,12 @@ final class WindowBrowserPortal: NSObject {
@MainActor
enum BrowserWindowPortalRegistry {
struct DebugSnapshot {
let visibleInUI: Bool
let containerHidden: Bool
let frameInWindow: CGRect
}
private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:]
private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
@ -3012,6 +3416,19 @@ enum BrowserWindowPortalRegistry {
portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration)
}
static func searchOverlayPanelId(for responder: NSResponder, in window: NSWindow) -> UUID? {
let windowId = ObjectIdentifier(window)
guard let portal = portalsByWindowId[windowId] else { return nil }
return portal.searchOverlayPanelId(for: responder)
}
@discardableResult
static func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool {
let windowId = ObjectIdentifier(window)
guard let portal = portalsByWindowId[windowId] else { return false }
return portal.yieldSearchOverlayFocusIfOwned(by: panelId)
}
static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
@ -3038,6 +3455,13 @@ enum BrowserWindowPortalRegistry {
portal.forceRefreshWebView(withId: webViewId, reason: reason)
}
static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId[webViewId],
let portal = portalsByWindowId[windowId] else { return nil }
return portal.debugSnapshot(forWebViewId: webViewId)
}
#if DEBUG
static func debugPortalCount() -> Int {
portalsByWindowId.count

View file

@ -82,6 +82,40 @@ func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor {
let clampedOpacity = max(0, min(opacity, 1))
return NSColor.white.withAlphaComponent(clampedOpacity)
}
#if compiler(>=6.2)
@available(macOS 26.0, *)
enum InternalTabDragConfigurationProvider {
// These drags only make sense inside cmux. Outside the app, Finder should
// reject them instead of materializing placeholder files from the payload.
static let value = DragConfiguration(
operationsWithinApp: .init(allowCopy: false, allowMove: true, allowDelete: false),
operationsOutsideApp: .init(allowCopy: false, allowMove: false, allowDelete: false)
)
}
#endif
private struct InternalTabDragConfigurationModifier: ViewModifier {
@ViewBuilder
func body(content: Content) -> some View {
#if compiler(>=6.2)
if #available(macOS 26.0, *) {
content.dragConfiguration(InternalTabDragConfigurationProvider.value)
} else {
content
}
#else
content
#endif
}
}
extension View {
func internalOnlyTabDrag() -> some View {
modifier(InternalTabDragConfigurationModifier())
}
}
struct ShortcutHintPillBackground: View {
var emphasis: Double = 1.0
@ -1398,15 +1432,10 @@ struct ContentView: View {
}
}
private enum CommandPaletteRestoreFocusIntent {
case panel
case browserAddressBar
}
private struct CommandPaletteRestoreFocusTarget {
let workspaceId: UUID
let panelId: UUID
let intent: CommandPaletteRestoreFocusIntent
let intent: PanelFocusIntent
}
private enum CommandPaletteInputFocusTarget {
@ -5337,7 +5366,7 @@ struct ContentView: View {
static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
focusedPanelIsBrowser: Bool,
focusedBrowserAddressBarPanelId: UUID?,
focusedPanelId: UUID
focusedPanelId: UUID?
) -> Bool {
focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId
}
@ -5383,15 +5412,10 @@ struct ContentView: View {
private func presentCommandPalette(initialQuery: String) {
if let panelContext = focusedPanelContext {
let shouldRestoreBrowserAddressBar = Self.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
focusedPanelIsBrowser: panelContext.panel.panelType == .browser,
focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(),
focusedPanelId: panelContext.panelId
)
commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget(
workspaceId: panelContext.workspace.id,
panelId: panelContext.panelId,
intent: shouldRestoreBrowserAddressBar ? .browserAddressBar : .panel
intent: panelContext.panel.captureFocusIntent(in: observedWindow)
)
} else {
commandPaletteRestoreFocusTarget = nil
@ -5468,7 +5492,7 @@ struct ContentView: View {
if let clickedFocusTarget {
dlog(
"palette.dismiss.backdrop focusTarget panel=\(clickedFocusTarget.panelId.uuidString.prefix(5)) " +
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(clickedFocusTarget.intent == .browserAddressBar ? "addressBar" : "panel")"
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(debugCommandPaletteFocusIntent(clickedFocusTarget.intent))"
)
} else {
dlog("palette.dismiss.backdrop focusTarget=nil")
@ -5507,10 +5531,11 @@ struct ContentView: View {
let workspaceId = terminalView.tabId,
let panelId = terminalView.terminalSurface?.id,
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
return CommandPaletteRestoreFocusTarget(
return commandPaletteRestoreFocusTarget(
workspaceId: workspaceId,
panelId: panelId,
intent: .panel
fallbackIntent: .terminal(.surface),
in: window
)
}
@ -5522,10 +5547,11 @@ struct ContentView: View {
let workspaceId = terminalView.tabId,
let panelId = terminalView.terminalSurface?.id,
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
return CommandPaletteRestoreFocusTarget(
return commandPaletteRestoreFocusTarget(
workspaceId: workspaceId,
panelId: panelId,
intent: .panel
fallbackIntent: .terminal(.surface),
in: observedWindow
)
}
@ -5563,16 +5589,35 @@ struct ContentView: View {
continue
}
return CommandPaletteRestoreFocusTarget(
return commandPaletteRestoreFocusTarget(
workspaceId: workspace.id,
panelId: panelId,
intent: .panel
fallbackIntent: .browser(.webView),
in: observedWindow
)
}
return nil
}
private func commandPaletteRestoreFocusTarget(
workspaceId: UUID,
panelId: UUID,
fallbackIntent: PanelFocusIntent,
in window: NSWindow?
) -> CommandPaletteRestoreFocusTarget {
let intent = tabManager.tabs
.first(where: { $0.id == workspaceId })?
.panels[panelId]?
.captureFocusIntent(in: window) ?? fallbackIntent
return CommandPaletteRestoreFocusTarget(
workspaceId: workspaceId,
panelId: panelId,
intent: intent
)
}
private func restoreCommandPaletteFocus(
target: CommandPaletteRestoreFocusTarget,
attemptsRemaining: Int
@ -5588,8 +5633,9 @@ struct ContentView: View {
if let context = focusedPanelContext,
context.workspace.id == target.workspaceId,
context.panelId == target.panelId {
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
return
if context.panel.restoreFocusIntent(target.intent) {
return
}
}
guard attemptsRemaining > 0 else { return }
@ -5598,33 +5644,32 @@ struct ContentView: View {
if let context = focusedPanelContext,
context.workspace.id == target.workspaceId,
context.panelId == target.panelId {
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
return
if context.panel.restoreFocusIntent(target.intent) {
return
}
}
restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1)
}
}
private func restoreCommandPaletteInputFocusIfNeeded(
target: CommandPaletteRestoreFocusTarget,
attemptsRemaining: Int
) {
guard !isCommandPalettePresented else { return }
guard target.intent == .browserAddressBar else { return }
guard attemptsRemaining > 0 else { return }
guard let appDelegate = AppDelegate.shared else { return }
if appDelegate.requestBrowserAddressBarFocus(panelId: target.panelId) {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
restoreCommandPaletteInputFocusIfNeeded(
target: target,
attemptsRemaining: attemptsRemaining - 1
)
#if DEBUG
private func debugCommandPaletteFocusIntent(_ intent: PanelFocusIntent) -> String {
switch intent {
case .panel:
return "panel"
case .terminal(.surface):
return "terminal.surface"
case .terminal(.findField):
return "terminal.findField"
case .browser(.webView):
return "browser.webView"
case .browser(.addressBar):
return "browser.addressBar"
case .browser(.findField):
return "browser.findField"
}
}
#endif
private func resetCommandPaletteSearchFocus() {
applyCommandPaletteInputFocusPolicy(.search)
@ -8079,6 +8124,7 @@ private enum SidebarHelpMenuAction {
case githubIssues
case checkForUpdates
case sendFeedback
case welcome
}
private struct SidebarFeedbackComposerSheet: View {
@ -8455,6 +8501,122 @@ private struct SidebarFeedbackComposerSheet: View {
}
}
enum FeedbackComposerBridgeError: LocalizedError {
case invalidEmail
case emptyMessage
case messageTooLong
case tooManyImages
case invalidImagePath(String)
case submissionFailed(String)
var errorDescription: String? {
switch self {
case .invalidEmail:
return "Enter a valid email address."
case .emptyMessage:
return "Enter a message before sending."
case .messageTooLong:
return "Your message is too long."
case .tooManyImages:
return "You can attach up to 10 images."
case .invalidImagePath(let path):
return "Could not attach image: \(path)"
case .submissionFailed(let message):
return message
}
}
}
enum FeedbackComposerBridge {
static func openComposer(in window: NSWindow? = NSApp.keyWindow ?? NSApp.mainWindow) {
NotificationCenter.default.post(name: .feedbackComposerRequested, object: window)
}
static func submit(
email: String,
message: String,
imagePaths: [String]
) async throws -> Int {
let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard isValidEmail(trimmedEmail) else {
throw FeedbackComposerBridgeError.invalidEmail
}
guard normalizedMessage.isEmpty == false else {
throw FeedbackComposerBridgeError.emptyMessage
}
guard message.count <= FeedbackComposerSettings.maxMessageLength else {
throw FeedbackComposerBridgeError.messageTooLong
}
guard imagePaths.count <= FeedbackComposerSettings.maxAttachmentCount else {
throw FeedbackComposerBridgeError.tooManyImages
}
let attachments = try imagePaths.map { rawPath in
let resolvedURL = URL(fileURLWithPath: rawPath).standardizedFileURL
do {
return try FeedbackComposerAttachment(url: resolvedURL)
} catch {
throw FeedbackComposerBridgeError.invalidImagePath(resolvedURL.path)
}
}
do {
try await FeedbackComposerClient.submit(
email: trimmedEmail,
message: normalizedMessage,
attachments: attachments
)
} catch {
throw FeedbackComposerBridgeError.submissionFailed(userFacingMessage(for: error))
}
UserDefaults.standard.set(trimmedEmail, forKey: FeedbackComposerSettings.storedEmailKey)
return attachments.count
}
private static func isValidEmail(_ rawValue: String) -> Bool {
let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard email.isEmpty == false else { return false }
let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"#
return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email)
}
private static func userFacingMessage(for error: Error) -> String {
guard let submissionError = error as? FeedbackComposerSubmissionError else {
return "Couldn't send feedback. Please try again."
}
switch submissionError {
case .invalidEndpoint:
return "Feedback is unavailable right now. Email founders@manaflow.com instead."
case .invalidResponse:
return "Couldn't send feedback. Please try again."
case .attachmentReadFailed:
return "One of the selected files could not be attached."
case .attachmentPreparationFailed:
return "These images are too large to send together. Remove a few and try again."
case .transport(let transportError):
if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost {
return "Couldn't send feedback. Check your connection and try again."
}
return "Couldn't send feedback. Please try again."
case .rejected(let statusCode):
switch statusCode {
case 400, 413, 415:
return "Check your message and attachments, then try again."
case 429:
return "Too many feedback attempts. Please try again later."
case 500...599:
return "Feedback is unavailable right now. Email founders@manaflow.com instead."
default:
return "Couldn't send feedback. Please try again."
}
}
}
}
private struct SidebarHelpMenuButton: View {
private let docsURL = URL(string: "https://cmux.dev/docs")
private let changelogURL = URL(string: "https://cmux.dev/docs/changelog")
@ -8503,6 +8665,12 @@ private struct SidebarHelpMenuButton: View {
private var helpPopover: some View {
VStack(alignment: .leading, spacing: 2) {
helpOptionButton(
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome"),
action: .welcome,
accessibilityIdentifier: "SidebarHelpMenuOptionWelcome",
isExternalLink: false
)
helpOptionButton(
title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"),
action: .sendFeedback,
@ -8614,14 +8782,17 @@ private struct SidebarHelpMenuButton: View {
private func perform(_ action: SidebarHelpMenuAction) {
switch action {
case .keyboardShortcuts:
Task { @MainActor in
if let appDelegate = AppDelegate.shared {
appDelegate.openPreferencesWindow(
debugSource: "sidebarHelpMenu.keyboardShortcuts",
navigationTarget: .keyboardShortcuts
)
} else {
AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts)
isPopoverPresented = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
Task { @MainActor in
if let appDelegate = AppDelegate.shared {
appDelegate.openPreferencesWindow(
debugSource: "sidebarHelpMenu.keyboardShortcuts",
navigationTarget: .keyboardShortcuts
)
} else {
AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts)
}
}
}
case .docs:
@ -8643,6 +8814,13 @@ private struct SidebarHelpMenuButton: View {
case .sendFeedback:
isPopoverPresented = false
onSendFeedback()
case .welcome:
isPopoverPresented = false
Task { @MainActor in
if let appDelegate = AppDelegate.shared {
appDelegate.openWelcomeWorkspace()
}
}
}
}
@ -9538,6 +9716,7 @@ private struct TabItemView: View {
dropIndicator = nil
return SidebarTabDragPayload.provider(for: tab.id)
}
.internalOnlyTabDrag()
.onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate(
targetTabId: tab.id,
tabManager: tabManager,

View file

@ -1,12 +1,16 @@
import AppKit
import Bonsplit
import SwiftUI
struct BrowserSearchOverlay: View {
let panelId: UUID
@ObservedObject var searchState: BrowserSearchState
let focusRequestGeneration: UInt64
let canApplyFocusRequest: (UInt64) -> Bool
let onNext: () -> Void
let onPrevious: () -> Void
let onClose: () -> Void
let onFieldDidFocus: () -> Void
@State private var corner: Corner = .topRight
@State private var dragOffset: CGSize = .zero
@State private var barSize: CGSize = .zero
@ -14,12 +18,58 @@ struct BrowserSearchOverlay: View {
private let padding: CGFloat = 8
private func requestSearchFieldFocus(maxAttempts: Int = 3) {
#if DEBUG
private func debugFirstResponderSummary() -> String {
guard let window = NSApp.keyWindow else { return "nil" }
guard let firstResponder = window.firstResponder else { return "nil" }
if let editor = firstResponder as? NSTextView, editor.isFieldEditor {
let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil"
return "fieldEditor(delegate=\(delegateSummary))"
}
return String(describing: type(of: firstResponder))
}
#endif
private func logFocusState(_ event: String) {
#if DEBUG
let keyWindow = NSApp.keyWindow
dlog(
"browser.findbar.focus panel=\(panelId.uuidString.prefix(5)) " +
"event=\(event) keyWindow=\(keyWindow?.windowNumber ?? -1) " +
"firstResponder=\(debugFirstResponderSummary()) " +
"focused=\(isSearchFieldFocused ? 1 : 0)"
)
#endif
}
private func requestSearchFieldFocus(maxAttempts: Int = 3, origin: String) {
guard maxAttempts > 0 else { return }
guard canApplyFocusRequest(focusRequestGeneration) else {
#if DEBUG
logFocusState("request.skip origin=\(origin) generation=\(focusRequestGeneration)")
#endif
return
}
logFocusState("request.begin origin=\(origin) remaining=\(maxAttempts)")
isSearchFieldFocused = true
#if DEBUG
DispatchQueue.main.async {
guard canApplyFocusRequest(focusRequestGeneration) else {
logFocusState("request.skipAsync origin=\(origin) generation=\(focusRequestGeneration)")
return
}
logFocusState("request.afterAsync origin=\(origin) remaining=\(maxAttempts)")
}
#endif
guard maxAttempts > 1 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
requestSearchFieldFocus(maxAttempts: maxAttempts - 1)
guard canApplyFocusRequest(focusRequestGeneration) else {
#if DEBUG
logFocusState("request.skipRetry origin=\(origin) generation=\(focusRequestGeneration)")
#endif
return
}
requestSearchFieldFocus(maxAttempts: maxAttempts - 1, origin: origin)
}
}
@ -102,16 +152,24 @@ struct BrowserSearchOverlay: View {
.clipShape(clipShape)
.shadow(radius: 4)
.onAppear {
#if DEBUG
#if DEBUG
dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))")
#endif
requestSearchFieldFocus()
#endif
logFocusState("appear")
requestSearchFieldFocus(origin: "appear")
}
.onChange(of: isSearchFieldFocused) { _, focused in
logFocusState("focusState.change next=\(focused ? 1 : 0)")
if focused {
onFieldDidFocus()
}
}
.onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in
guard let notifiedPanelId = notification.object as? UUID,
notifiedPanelId == panelId else { return }
logFocusState("notification.received")
DispatchQueue.main.async {
requestSearchFieldFocus()
requestSearchFieldFocus(origin: "notification")
}
}
.background(

View file

@ -328,10 +328,19 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable {
field.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === field
#if DEBUG
dlog("find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) alreadyFocused=\(alreadyFocused)")
dlog(
"find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " +
"alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: fr))"
)
#endif
guard !alreadyFocused else { return }
window.makeFirstResponder(field)
let result = window.makeFirstResponder(field)
#if DEBUG
dlog(
"find.nativeField.searchFocusApply surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " +
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
)
#endif
}
return field

View file

@ -708,6 +708,7 @@ class GhosttyApp {
private let backgroundLogLock = NSLock()
private var backgroundLogSequence: UInt64 = 0
private var appObservers: [NSObjectProtocol] = []
private var bellAudioSound: NSSound?
private var backgroundEventCounter: UInt64 = 0
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
private var defaultBackgroundScopeSource: String = "initialize"
@ -1524,6 +1525,75 @@ class GhosttyApp {
return found && enabled
}
func appleScriptAutomationEnabled() -> Bool {
guard let config else { return false }
var enabled = false
let key = "macos-applescript"
_ = ghostty_config_get(config, &enabled, key, UInt(key.lengthOfBytes(using: .utf8)))
return enabled
}
fileprivate func shellIntegrationMode() -> String {
guard let config else { return "detect" }
var value: UnsafePointer<Int8>?
let key = "shell-integration"
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
let value else {
return "detect"
}
return String(cString: value)
}
private func bellFeatures() -> CUnsignedInt {
guard let config else { return 0 }
var features: CUnsignedInt = 0
let key = "bell-features"
_ = ghostty_config_get(config, &features, key, UInt(key.lengthOfBytes(using: .utf8)))
return features
}
private func bellAudioPath() -> String? {
guard let config else { return nil }
var value = ghostty_config_path_s()
let key = "bell-audio-path"
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
let rawPath = value.path else {
return nil
}
let path = String(cString: rawPath)
return path.isEmpty ? nil : path
}
private func bellAudioVolume() -> Float {
guard let config else { return 0.5 }
var value: Double = 0.5
let key = "bell-audio-volume"
_ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8)))
return Float(min(1.0, max(0.0, value)))
}
private func ringBell() {
let features = bellFeatures()
if (features & (1 << 0)) != 0 {
NSSound.beep()
}
if (features & (1 << 1)) != 0,
let path = bellAudioPath(),
let sound = NSSound(contentsOfFile: path, byReference: false) {
sound.volume = bellAudioVolume()
bellAudioSound = sound
if !sound.play() {
bellAudioSound = nil
}
}
if (features & (1 << 2)) != 0 {
NSApp.requestUserAttention(.informationalRequest)
}
}
private func applyDefaultBackground(
color: NSColor,
opacity: Double,
@ -1690,6 +1760,13 @@ class GhosttyApp {
}
}
if action.tag == GHOSTTY_ACTION_RING_BELL {
performOnMain {
self.ringBell()
}
return true
}
if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG {
let soft = action.action.reload_config.soft
logThemeAction("reload request target=app soft=\(soft)")
@ -1797,6 +1874,11 @@ class GhosttyApp {
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
}
case GHOSTTY_ACTION_RING_BELL:
performOnMain {
self.ringBell()
}
return true
case GHOSTTY_ACTION_GOTO_SPLIT:
guard let tabId = surfaceView.tabId,
let surfaceId = surfaceView.terminalSurface?.id,
@ -2766,6 +2848,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
?? "/bin/zsh"
let shellName = URL(fileURLWithPath: shell).lastPathComponent
if shellName == "zsh" {
if GhosttyApp.shared.shellIntegrationMode() != "none" {
env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1"
}
let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil)
?? getenv("ZDOTDIR").map { String(cString: $0) }
?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil)
@ -3310,9 +3395,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var eventMonitor: Any?
private var trackingArea: NSTrackingArea?
private var windowObserver: NSObjectProtocol?
private var lastScrollEventTime: CFTimeInterval = 0
private var lastScrollEventTime: CFTimeInterval = 0
private var visibleInUI: Bool = true
private var pendingSurfaceSize: CGSize?
private var deferredSurfaceSizeRetryQueued = false
private var lastDrawableSize: CGSize = .zero
private var isFindEscapeSuppressionArmed = false
#if DEBUG
@ -3610,11 +3696,39 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return currentBounds
}
private static func hasActiveTabDragPasteboard() -> Bool {
private static func hasTabDragPasteboardTypes() -> Bool {
let types = NSPasteboard(name: .drag).types ?? []
return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType)
}
private static func isDragResizeEvent(_ eventType: NSEvent.EventType?) -> Bool {
switch eventType {
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
return true
default:
return false
}
}
private static func shouldDeferSurfaceResizeForActiveDrag() -> Bool {
// The drag pasteboard can retain tab-transfer UTIs briefly after a split command
// or other layout churn. Only defer terminal resizes while an actual drag event
// is in flight; otherwise pre-existing panes can stay stuck at their old size.
guard hasTabDragPasteboardTypes() else { return false }
return isDragResizeEvent(NSApp.currentEvent?.type)
}
private func scheduleDeferredSurfaceSizeRetryIfNeeded() {
guard window != nil else { return }
guard !deferredSurfaceSizeRetryQueued else { return }
deferredSurfaceSizeRetryQueued = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.deferredSurfaceSizeRetryQueued = false
_ = self.updateSurfaceSize()
}
}
@discardableResult
private func updateSurfaceSize(size: CGSize? = nil) -> Bool {
guard let terminalSurface = terminalSurface else { return false }
@ -3634,7 +3748,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return false
}
pendingSurfaceSize = size
guard !Self.hasActiveTabDragPasteboard() else {
guard !Self.shouldDeferSurfaceResizeForActiveDrag() else {
scheduleDeferredSurfaceSizeRetryIfNeeded()
#if DEBUG
let signature = "tabDrag-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))"
if lastSizeSkipSignature != signature {
@ -4376,6 +4491,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
super.keyDown(with: event)
return
}
if let terminalSurface {
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
tabId: terminalSurface.tabId,
surfaceId: terminalSurface.id
)
}
if event.keyCode != 53 {
endFindEscapeSuppression()
}
@ -4537,6 +4658,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// Use accumulated text from insertText (for IME), or compute text for key
let accumulatedText = keyTextAccumulator ?? []
var shouldRefreshAfterTextInput = false
if !accumulatedText.isEmpty {
// Accumulated text comes from insertText (IME composition result).
// These never have "composing" set to true because these are the
@ -4544,6 +4666,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
keyEvent.composing = false
for text in accumulatedText {
if shouldSendText(text) {
shouldRefreshAfterTextInput = true
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
@ -4564,6 +4687,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
)
if let text = textForKeyEvent(translationEvent) {
if shouldSendText(text), !suppressShiftSpaceFallbackText {
shouldRefreshAfterTextInput = true
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
@ -4578,6 +4702,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
}
if shouldRefreshAfterTextInput {
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
}
// Rendering is driven by Ghostty's wakeups/renderer.
}
@ -4817,11 +4945,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
#endif
private func requestPointerFocusRecovery() {
#if DEBUG
dlog("focus.pointerDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
#endif
onFocus?()
}
override func mouseDown(with event: NSEvent) {
#if DEBUG
let debugPoint = convert(event.locationInWindow, from: nil)
dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))")
#endif
// Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus`
// callback. Treat pointer-down as explicit focus intent so clicking a ghost pane still
// repairs workspace/pane active state before key routing runs.
requestPointerFocusRecovery()
window?.makeFirstResponder(self)
if let terminalSurface {
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
@ -4846,10 +4985,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
override func rightMouseDown(with event: NSEvent) {
guard let surface = surface else { return }
if !ghostty_surface_mouse_captured(surface) {
requestPointerFocusRecovery()
super.rightMouseDown(with: event)
return
}
requestPointerFocusRecovery()
window?.makeFirstResponder(self)
let point = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
@ -4871,6 +5012,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
super.otherMouseDown(with: event)
return
}
requestPointerFocusRecovery()
window?.makeFirstResponder(self)
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
@ -5330,6 +5472,7 @@ final class GhosttySurfaceScrollView: NSView {
private var isLiveScrolling = false
private var lastSentRow: Int?
private var isActive = true
private var lastFocusRefreshAt: CFTimeInterval = 0
private var activeDropZone: DropZone?
private var pendingDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
@ -6286,6 +6429,15 @@ final class GhosttySurfaceScrollView: NSView {
}
}
var debugPortalVisibleInUI: Bool {
surfaceView.isVisibleInUI
}
var debugPortalFrameInWindow: CGRect {
guard window != nil else { return .zero }
return convert(bounds, to: nil)
}
func setActive(_ active: Bool) {
let wasActive = isActive
isActive = active
@ -6301,10 +6453,8 @@ final class GhosttySurfaceScrollView: NSView {
#endif
if active {
scheduleAutomaticFirstResponderApply(reason: "setActive")
} else if let window,
let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
window.makeFirstResponder(nil)
} else {
resignOwnedFirstResponderIfNeeded(reason: "setActive(false)")
}
}
@ -6354,15 +6504,29 @@ final class GhosttySurfaceScrollView: NSView {
#if DEBUG
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
dlog("find.moveFocus to=\(surfaceShort) searchState=\(searchActive ? "active" : "nil")")
dlog(
"find.moveFocus to=\(surfaceShort) " +
"from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"searchState=\(searchActive ? "active" : "nil") " +
"delayMs=\(Int((delay ?? 0) * 1000))"
)
#endif
let work = { [weak self] in
guard let self else { return }
guard let window = self.window else { return }
#if DEBUG
let before = String(describing: window.firstResponder)
#endif
if let previous, previous !== self {
_ = previous.surfaceView.resignFirstResponder()
}
window.makeFirstResponder(self.surfaceView)
let result = window.makeFirstResponder(self.surfaceView)
#if DEBUG
dlog(
"find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))"
)
#endif
}
if let delay, delay > 0 {
@ -6506,6 +6670,12 @@ final class GhosttySurfaceScrollView: NSView {
guard isActive else { return }
guard let window else { return }
guard surfaceView.isVisibleInUI else {
#if DEBUG
dlog(
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"reason=not_visible attempts=\(attemptsRemaining)"
)
#endif
retry()
return
}
@ -6545,25 +6715,59 @@ final class GhosttySurfaceScrollView: NSView {
// Search focus restoration only after confirming this is the active tab/pane.
if surfaceView.terminalSurface?.searchState != nil {
#if DEBUG
dlog(
"focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
"attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))"
)
#endif
restoreSearchFocus(window: window)
return
}
if let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder")
return
}
if !window.isKeyWindow {
window.makeKeyAndOrderFront(nil)
}
_ = window.makeFirstResponder(surfaceView)
let result = window.makeFirstResponder(surfaceView)
#if DEBUG
dlog(
"focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " +
"attempts=\(attemptsRemaining)"
)
#endif
if !isSurfaceViewFirstResponder() {
retry()
} else {
reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder")
}
}
private func matchesCurrentTerminalFocusTarget(tabId: UUID, surfaceId: UUID) -> Bool {
guard let delegate = AppDelegate.shared,
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
tabManager.selectedTabId == tabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId),
let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
}) else {
return false
}
return tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface &&
tab.bonsplitController.focusedPaneId == paneId
}
/// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during
/// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles.
func suppressReparentFocus() {
@ -6596,6 +6800,33 @@ final class GhosttySurfaceScrollView: NSView {
}
}
private func reassertTerminalSurfaceFocus(reason: String) {
guard let terminalSurface = surfaceView.terminalSurface else { return }
#if DEBUG
dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
#endif
terminalSurface.setFocus(true)
refreshSurfaceAfterFocusIfNeeded(reason: reason)
}
private func refreshSurfaceAfterFocusIfNeeded(reason: String) {
guard let terminalSurface = surfaceView.terminalSurface,
isActive,
let window,
window.isKeyWindow,
surfaceView.isVisibleInUI else { return }
let now = CACurrentMediaTime()
if now - lastFocusRefreshAt < 0.05 {
return
}
lastFocusRefreshAt = now
#if DEBUG
dlog("focus.surface.refresh surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
#endif
terminalSurface.forceRefresh(reason: "focus.surface.\(reason)")
}
private func applyFirstResponderIfNeeded() {
let hasUsablePortalGeometry: Bool = {
let size = bounds.size
@ -6616,6 +6847,14 @@ final class GhosttySurfaceScrollView: NSView {
return
}
guard let window, window.isKeyWindow else { return }
guard let tabId = surfaceView.tabId,
let panelId = surfaceView.terminalSurface?.id,
matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else {
#if DEBUG
dlog("focus.apply.skip surface=\(surfaceShort) reason=stale_target")
#endif
return
}
if surfaceView.terminalSurface?.searchState != nil {
// Find bar is open. Restore focus based on what the user last intended.
restoreSearchFocus(window: window)
@ -6623,6 +6862,7 @@ final class GhosttySurfaceScrollView: NSView {
}
if let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.alreadyFirstResponder")
return
}
// Don't steal focus from a search overlay on another surface in this window.
@ -6636,6 +6876,9 @@ final class GhosttySurfaceScrollView: NSView {
dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))")
#endif
window.makeFirstResponder(surfaceView)
if isSurfaceViewFirstResponder() {
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder")
}
}
/// Restore focus when window becomes key and the find bar is open.
@ -6644,6 +6887,18 @@ final class GhosttySurfaceScrollView: NSView {
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
switch searchFocusTarget {
case .searchField:
if let firstResponder = window.firstResponder,
isSearchOverlayOrDescendant(firstResponder),
!isCurrentSurfaceSearchResponder(firstResponder) {
surfaceView.terminalSurface?.setFocus(false)
#if DEBUG
dlog(
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
"reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))"
)
#endif
return
}
// Explicitly unfocus the terminal so cursor stops blinking immediately.
// The notification observer also does this, but it runs async when posted from main.
surfaceView.terminalSurface?.setFocus(false)
@ -6653,16 +6908,152 @@ final class GhosttySurfaceScrollView: NSView {
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
}
#if DEBUG
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=searchField via=notification")
dlog(
"find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
"via=notification firstResponder=\(String(describing: window.firstResponder))"
)
#endif
case .terminal:
window.makeFirstResponder(surfaceView)
let result = window.makeFirstResponder(surfaceView)
#if DEBUG
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=terminal")
dlog(
"find.restoreSearchFocus surface=\(surfaceShort) target=terminal " +
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
)
#endif
}
}
func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent {
if surfaceView.terminalSurface?.searchState != nil {
if let firstResponder = window?.firstResponder as? NSView,
(firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) {
return .surface
}
if let firstResponder = window?.firstResponder,
isCurrentSurfaceSearchResponder(firstResponder) {
return .findField
}
if searchFocusTarget == .searchField {
return .findField
}
}
return .surface
}
func preferredPanelFocusIntentForActivation() -> TerminalPanelFocusIntent {
if surfaceView.terminalSurface?.searchState != nil, searchFocusTarget == .searchField {
return .findField
}
return .surface
}
func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) {
switch intent {
case .surface:
searchFocusTarget = .terminal
case .findField:
guard surfaceView.terminalSurface?.searchState != nil else { return }
searchFocusTarget = .searchField
}
#if DEBUG
dlog(
"find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"target=\(intent == .findField ? "searchField" : "terminal")"
)
#endif
}
@discardableResult
func restorePanelFocusIntent(_ intent: TerminalPanelFocusIntent) -> Bool {
switch intent {
case .surface:
searchFocusTarget = .terminal
setActive(true)
applyFirstResponderIfNeeded()
return true
case .findField:
guard let terminalSurface = surfaceView.terminalSurface,
terminalSurface.searchState != nil else {
return false
}
searchFocusTarget = .searchField
setActive(true)
if let window {
restoreSearchFocus(window: window)
} else {
terminalSurface.setFocus(false)
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
}
#if DEBUG
dlog(
"find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"target=searchField firstResponder=\(String(describing: window?.firstResponder))"
)
#endif
return true
}
}
func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? {
if isCurrentSurfaceSearchResponder(responder) {
return .findField
}
let resolvedResponder: NSResponder
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
resolvedResponder = editedView
} else {
resolvedResponder = responder
}
guard let view = resolvedResponder as? NSView else { return nil }
if view === surfaceView || view.isDescendant(of: surfaceView) {
return .surface
}
return nil
}
@discardableResult
func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool {
guard let firstResponder = window.firstResponder,
ownedPanelFocusIntent(for: firstResponder) == intent else {
return false
}
surfaceView.terminalSurface?.setFocus(false)
resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent")
#if DEBUG
dlog(
"focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"target=\(intent == .findField ? "searchField" : "terminal")"
)
#endif
return true
}
private func resignOwnedFirstResponderIfNeeded(reason: String) {
guard let window,
let firstResponder = window.firstResponder else { return }
let ownsSurfaceResponder: Bool = {
guard let view = firstResponder as? NSView else { return false }
return view === surfaceView || view.isDescendant(of: surfaceView)
}()
guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return }
#if DEBUG
dlog(
"focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"reason=\(reason) firstResponder=\(String(describing: firstResponder))"
)
#endif
window.makeFirstResponder(nil)
}
/// Check if a responder is inside a search overlay hosting view.
/// Handles the AppKit field-editor case: when an NSTextField is being edited,
/// window.firstResponder is the shared NSTextView field editor, not the text field.
@ -6678,11 +7069,27 @@ final class GhosttySurfaceScrollView: NSView {
var current: NSView? = view
while let v = current {
if v is NSHostingView<SurfaceSearchOverlay> { return true }
let typeName = String(describing: type(of: v))
if typeName.contains("BrowserSearchOverlay") { return true }
current = v.superview
}
return false
}
private func isCurrentSurfaceSearchResponder(_ responder: NSResponder) -> Bool {
let resolvedResponder: NSResponder
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
resolvedResponder = editedView
} else {
resolvedResponder = responder
}
guard let view = resolvedResponder as? NSView else { return false }
return view.isDescendant(of: self)
}
#if DEBUG
struct DebugRenderStats {
let drawCount: Int

View file

@ -155,10 +155,20 @@ struct NotificationsPage: View {
}
}
private struct ShortcutAnnotation: View {
struct ShortcutAnnotation: View {
let text: String
var accessibilityIdentifier: String? = nil
@ViewBuilder
var body: some View {
if let accessibilityIdentifier {
badge.accessibilityIdentifier(accessibilityIdentifier)
} else {
badge
}
}
private var badge: some View {
Text(text)
.font(.system(size: 10, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)

View file

@ -1705,10 +1705,17 @@ final class BrowserPanel: Panel, ObservableObject {
/// cleared only after BrowserPanelView acknowledges handling it.
@Published private(set) var pendingAddressBarFocusRequestId: UUID?
/// Semantic in-panel focus target used by split switching and transient overlays.
private(set) var preferredFocusIntent: BrowserPanelFocusIntent = .webView
/// Incremented whenever async browser find focus ownership changes.
@Published private(set) var searchFocusRequestGeneration: UInt64 = 0
/// Find-in-page state. Non-nil when the find bar is visible.
@Published var searchState: BrowserSearchState? = nil {
didSet {
if let searchState {
preferredFocusIntent = .findField
NSLog("Find: browser search state created panel=%@", id.uuidString)
searchNeedleCancellable = searchState.$needle
.removeDuplicates()
@ -1728,6 +1735,10 @@ final class BrowserPanel: Panel, ObservableObject {
}
} else if oldValue != nil {
searchNeedleCancellable = nil
if preferredFocusIntent == .findField {
preferredFocusIntent = .webView
}
invalidateSearchFocusRequests(reason: "searchStateCleared")
NSLog("Find: browser search state cleared panel=%@", id.uuidString)
executeFindClear()
}
@ -1741,7 +1752,18 @@ final class BrowserPanel: Panel, ObservableObject {
let inWindow: Bool
let area: CGFloat
}
private struct PortalHostLock {
let hostId: ObjectIdentifier
let paneId: UUID
}
private enum DeveloperToolsPresentation {
case unknown
case attached
case detached
}
private var activePortalHostLease: PortalHostLease?
private var pendingDistinctPortalHostReplacementPaneId: UUID?
private var lockedPortalHost: PortalHostLock?
private var webViewCancellables = Set<AnyCancellable>()
private var navigationDelegate: BrowserNavigationDelegate?
private var uiDelegate: BrowserUIDelegate?
@ -1765,7 +1787,8 @@ final class BrowserPanel: Panel, ObservableObject {
private var insecureHTTPAlertFactory: () -> NSAlert
private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow }
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
private var preferredDeveloperToolsVisible: Bool = false
@Published private(set) var preferredDeveloperToolsVisible: Bool = false
private var preferredDeveloperToolsPresentation: DeveloperToolsPresentation = .unknown
private var forceDeveloperToolsRefreshOnNextAttach: Bool = false
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
private var developerToolsRestoreRetryAttempt: Int = 0
@ -1773,6 +1796,11 @@ final class BrowserPanel: Panel, ObservableObject {
private let developerToolsRestoreRetryMaxAttempts: Int = 40
private var remoteProxyEndpoint: BrowserProxyEndpoint?
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35
private var developerToolsDetachedOpenGraceDeadline: Date?
private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol?
private var preferredAttachedDeveloperToolsWidth: CGFloat?
private var preferredAttachedDeveloperToolsWidthFraction: CGFloat?
private var browserThemeMode: BrowserThemeMode
var displayTitle: String {
@ -1796,6 +1824,22 @@ final class BrowserPanel: Panel, ObservableObject {
lease.inWindow && lease.area > portalHostAreaThreshold
}
func preparePortalHostReplacementForNextDistinctClaim(
inPane paneId: PaneID,
reason: String
) {
pendingDistinctPortalHostReplacementPaneId = paneId.id
if lockedPortalHost?.paneId == paneId.id {
lockedPortalHost = nil
}
#if DEBUG
dlog(
"browser.portal.host.rearm panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) pane=\(paneId.id.uuidString.prefix(5))"
)
#endif
}
func claimPortalHost(
hostId: ObjectIdentifier,
paneId: PaneID,
@ -1811,6 +1855,11 @@ final class BrowserPanel: Panel, ObservableObject {
)
if let current = activePortalHostLease {
if let lock = lockedPortalHost,
(lock.hostId != current.hostId || lock.paneId != current.paneId) {
lockedPortalHost = nil
}
if current.hostId == hostId {
activePortalHostLease = next
return true
@ -1818,12 +1867,47 @@ final class BrowserPanel: Panel, ObservableObject {
let currentUsable = Self.portalHostIsUsable(current)
let nextUsable = Self.portalHostIsUsable(next)
let isSamePaneReplacement = current.paneId == paneId.id
let shouldForceDistinctReplacement =
isSamePaneReplacement &&
pendingDistinctPortalHostReplacementPaneId == paneId.id &&
inWindow
if shouldForceDistinctReplacement {
#if DEBUG
dlog(
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " +
"replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area)) " +
"forced=1"
)
#endif
activePortalHostLease = next
pendingDistinctPortalHostReplacementPaneId = nil
lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id)
return true
}
let lockBlocksSamePaneReplacement =
isSamePaneReplacement &&
currentUsable &&
lockedPortalHost?.hostId == current.hostId &&
lockedPortalHost?.paneId == current.paneId
let shouldReplace =
current.paneId != paneId.id ||
!currentUsable ||
(nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio))
(
!lockBlocksSamePaneReplacement &&
nextUsable &&
next.area > (current.area * Self.portalHostReplacementAreaGainRatio)
)
if shouldReplace {
if lockedPortalHost?.hostId == current.hostId &&
lockedPortalHost?.paneId == current.paneId {
lockedPortalHost = nil
}
#if DEBUG
dlog(
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
@ -1843,7 +1927,8 @@ final class BrowserPanel: Panel, ObservableObject {
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " +
"ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area))"
"ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area)) " +
"locked=\(lockBlocksSamePaneReplacement ? 1 : 0)"
)
#endif
return false
@ -1865,6 +1950,9 @@ final class BrowserPanel: Panel, ObservableObject {
func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool {
guard let current = activePortalHostLease, current.hostId == hostId else { return false }
activePortalHostLease = nil
if lockedPortalHost?.hostId == hostId {
lockedPortalHost = nil
}
#if DEBUG
dlog(
"browser.portal.host.release panel=\(id.uuidString.prefix(5)) " +
@ -2023,6 +2111,7 @@ final class BrowserPanel: Panel, ObservableObject {
self.uiDelegate = browserUIDelegate
bindWebView(webView)
installDetachedDeveloperToolsWindowCloseObserver()
applyBrowserThemeModeIfNeeded()
insecureHTTPAlertWindowProvider = { [weak self] in
self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
@ -2298,12 +2387,16 @@ final class BrowserPanel: Panel, ObservableObject {
}
if Self.responderChainContains(window.firstResponder, target: webView) {
noteWebViewFocused()
return
}
window.makeFirstResponder(webView)
if window.makeFirstResponder(webView) {
noteWebViewFocused()
}
}
func unfocus() {
invalidateSearchFocusRequests(reason: "panelUnfocus")
guard let window = webView.window else { return }
if Self.responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(nil)
@ -2700,6 +2793,9 @@ final class BrowserPanel: Panel, ObservableObject {
deinit {
developerToolsRestoreRetryWorkItem?.cancel()
developerToolsRestoreRetryWorkItem = nil
if let detachedDeveloperToolsWindowCloseObserver {
NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver)
}
let webView = webView
Task { @MainActor in
BrowserWindowPortalRegistry.detach(webView: webView)
@ -2847,6 +2943,160 @@ extension BrowserPanel {
webView.stopLoading()
}
private static func windowContainsInspectorViews(_ root: NSView) -> Bool {
if String(describing: type(of: root)).contains("WKInspector") {
return true
}
for subview in root.subviews where windowContainsInspectorViews(subview) {
return true
}
return false
}
private static func isDetachedInspectorWindow(_ window: NSWindow) -> Bool {
guard window.title.hasPrefix("Web Inspector") else { return false }
guard let contentView = window.contentView else { return false }
return windowContainsInspectorViews(contentView)
}
private func detachedDeveloperToolsWindows() -> [NSWindow] {
let mainWindow = webView.window
return NSApp.windows.filter { candidate in
if let mainWindow, candidate === mainWindow {
return false
}
return Self.isDetachedInspectorWindow(candidate)
}
}
private func hasAttachedDeveloperToolsLayout() -> Bool {
guard let container = webView.superview else { return false }
return Self.visibleDescendants(in: container)
.contains { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) }
}
private func setPreferredDeveloperToolsPresentation(_ next: DeveloperToolsPresentation) {
guard preferredDeveloperToolsPresentation != next else { return }
preferredDeveloperToolsPresentation = next
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
}
}
private func syncDeveloperToolsPresentationPreferenceFromUI() {
if !detachedDeveloperToolsWindows().isEmpty {
setPreferredDeveloperToolsPresentation(.detached)
} else if hasAttachedDeveloperToolsLayout() {
setPreferredDeveloperToolsPresentation(.attached)
developerToolsDetachedOpenGraceDeadline = nil
}
}
private func installDetachedDeveloperToolsWindowCloseObserver() {
guard detachedDeveloperToolsWindowCloseObserver == nil else { return }
detachedDeveloperToolsWindowCloseObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let self,
let window = notification.object as? NSWindow else { return }
let isDetachedInspectorWindow = MainActor.assumeIsolated {
Self.isDetachedInspectorWindow(window)
}
guard isDetachedInspectorWindow else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard self.preferredDeveloperToolsPresentation == .detached else { return }
guard self.preferredDeveloperToolsVisible else { return }
guard !self.isDeveloperToolsVisible() else { return }
self.developerToolsDetachedOpenGraceDeadline = nil
self.preferredDeveloperToolsVisible = false
self.cancelDeveloperToolsRestoreRetry()
#if DEBUG
dlog(
"browser.devtools detachedClose.manual panel=\(self.id.uuidString.prefix(5)) " +
"\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())"
)
#endif
}
}
}
private func shouldDismissDetachedDeveloperToolsWindows() -> Bool {
preferredDeveloperToolsPresentation == .attached
}
private func dismissDetachedDeveloperToolsWindowsIfNeeded() {
guard shouldDismissDetachedDeveloperToolsWindows() else { return }
guard preferredDeveloperToolsVisible || isDeveloperToolsVisible(),
let mainWindow = webView.window else { return }
for window in NSApp.windows where window !== mainWindow && Self.isDetachedInspectorWindow(window) {
#if DEBUG
dlog(
"browser.devtools strayWindow.close panel=\(id.uuidString.prefix(5)) " +
"title=\(window.title) frame=\(NSStringFromRect(window.frame))"
)
#endif
window.close()
}
}
private func scheduleDetachedDeveloperToolsWindowDismissal() {
guard shouldDismissDetachedDeveloperToolsWindows() else { return }
for delay in [0.0, 0.15] {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.dismissDetachedDeveloperToolsWindowsIfNeeded()
}
}
}
private func prepareDeveloperToolsForRevealIfNeeded(_ inspector: NSObject) {
guard preferredDeveloperToolsPresentation == .unknown else { return }
let attachSelector = NSSelectorFromString("attach")
guard inspector.responds(to: attachSelector) else { return }
inspector.cmuxCallVoid(selector: attachSelector)
}
@discardableResult
private func revealDeveloperTools(_ inspector: NSObject) -> Bool {
let isVisibleSelector = NSSelectorFromString("isVisible")
if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false {
developerToolsDetachedOpenGraceDeadline = nil
return true
}
prepareDeveloperToolsForRevealIfNeeded(inspector)
let showSelector = NSSelectorFromString("show")
guard inspector.responds(to: showSelector) else { return false }
inspector.cmuxCallVoid(selector: showSelector)
let visibleAfterShow = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
if preferredDeveloperToolsPresentation == .detached {
developerToolsDetachedOpenGraceDeadline = visibleAfterShow
? nil
: Date().addingTimeInterval(developerToolsDetachedOpenGracePeriod)
} else {
developerToolsDetachedOpenGraceDeadline = nil
}
return visibleAfterShow
}
@discardableResult
private func concealDeveloperTools(_ inspector: NSObject) -> Bool {
let isVisibleSelector = NSSelectorFromString("isVisible")
guard inspector.cmuxCallBool(selector: isVisibleSelector) ?? false else { return true }
for rawSelector in ["hide", "close"] {
let selector = NSSelectorFromString(rawSelector)
guard inspector.responds(to: selector) else { continue }
inspector.cmuxCallVoid(selector: selector)
return true
}
return false
}
@discardableResult
func toggleDeveloperTools() -> Bool {
#if DEBUG
@ -2859,14 +3109,20 @@ extension BrowserPanel {
let isVisibleSelector = NSSelectorFromString("isVisible")
let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
let targetVisible = !visible
let selector = NSSelectorFromString(targetVisible ? "show" : "close")
guard inspector.responds(to: selector) else { return false }
inspector.cmuxCallVoid(selector: selector)
if targetVisible {
_ = revealDeveloperTools(inspector)
} else {
syncDeveloperToolsPresentationPreferenceFromUI()
guard concealDeveloperTools(inspector) else { return false }
developerToolsDetachedOpenGraceDeadline = nil
}
preferredDeveloperToolsVisible = targetVisible
if targetVisible {
let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
if visibleAfterToggle {
syncDeveloperToolsPresentationPreferenceFromUI()
cancelDeveloperToolsRestoreRetry()
scheduleDetachedDeveloperToolsWindowDismissal()
} else {
developerToolsRestoreRetryAttempt = 0
scheduleDeveloperToolsRestoreRetry()
@ -2896,13 +3152,13 @@ extension BrowserPanel {
guard let inspector = webView.cmuxInspectorObject() else { return false }
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if !visible {
let showSelector = NSSelectorFromString("show")
guard inspector.responds(to: showSelector) else { return false }
inspector.cmuxCallVoid(selector: showSelector)
guard revealDeveloperTools(inspector) else { return false }
}
preferredDeveloperToolsVisible = true
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
syncDeveloperToolsPresentationPreferenceFromUI()
cancelDeveloperToolsRestoreRetry()
scheduleDetachedDeveloperToolsWindowDismissal()
} else {
scheduleDeveloperToolsRestoreRetry()
}
@ -2934,6 +3190,8 @@ extension BrowserPanel {
guard let inspector = webView.cmuxInspectorObject() else { return }
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return }
if visible {
developerToolsDetachedOpenGraceDeadline = nil
syncDeveloperToolsPresentationPreferenceFromUI()
preferredDeveloperToolsVisible = true
cancelDeveloperToolsRestoreRetry()
return
@ -2962,6 +3220,8 @@ extension BrowserPanel {
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visible {
developerToolsDetachedOpenGraceDeadline = nil
syncDeveloperToolsPresentationPreferenceFromUI()
#if DEBUG
if shouldForceRefresh {
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
@ -2971,26 +3231,37 @@ extension BrowserPanel {
return
}
let selector = NSSelectorFromString("show")
guard inspector.responds(to: selector) else {
let detachedOpenStillSettling = developerToolsDetachedOpenGraceDeadline.map { $0 > Date() } ?? false
if preferredDeveloperToolsPresentation == .detached && !detachedOpenStillSettling {
preferredDeveloperToolsVisible = false
developerToolsDetachedOpenGraceDeadline = nil
cancelDeveloperToolsRestoreRetry()
#if DEBUG
dlog(
"browser.devtools detachedClose.consume panel=\(id.uuidString.prefix(5)) " +
"\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
)
#endif
return
}
#if DEBUG
if shouldForceRefresh {
dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
}
#endif
// WebKit inspector "show" can trigger transient first-responder churn while
// WebKit inspector show can trigger transient first-responder churn while
// panel attachment is still stabilizing. Keep this auto-restore path from
// mutating first responder so AppKit doesn't walk tearing-down responder chains.
cmuxWithWindowFirstResponderBypass {
inspector.cmuxCallVoid(selector: selector)
_ = revealDeveloperTools(inspector)
}
preferredDeveloperToolsVisible = true
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visibleAfterShow {
syncDeveloperToolsPresentationPreferenceFromUI()
cancelDeveloperToolsRestoreRetry()
scheduleDetachedDeveloperToolsWindowDismissal()
} else {
scheduleDeveloperToolsRestoreRetry()
}
@ -3007,11 +3278,11 @@ extension BrowserPanel {
guard let inspector = webView.cmuxInspectorObject() else { return false }
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visible {
let selector = NSSelectorFromString("close")
guard inspector.responds(to: selector) else { return false }
inspector.cmuxCallVoid(selector: selector)
syncDeveloperToolsPresentationPreferenceFromUI()
guard concealDeveloperTools(inspector) else { return false }
}
preferredDeveloperToolsVisible = false
developerToolsDetachedOpenGraceDeadline = nil
forceDeveloperToolsRefreshOnNextAttach = false
cancelDeveloperToolsRestoreRetry()
return true
@ -3036,6 +3307,38 @@ extension BrowserPanel {
forceDeveloperToolsRefreshOnNextAttach
}
func shouldPreserveDeveloperToolsIntentWhileDetached() -> Bool {
preferredDeveloperToolsVisible &&
(
forceDeveloperToolsRefreshOnNextAttach ||
developerToolsRestoreRetryWorkItem != nil ||
webView.superview == nil ||
webView.window == nil
)
}
func shouldUseLocalInlineDeveloperToolsHosting() -> Bool {
guard preferredDeveloperToolsVisible || isDeveloperToolsVisible() else { return false }
if preferredDeveloperToolsPresentation == .detached {
return false
}
return detachedDeveloperToolsWindows().isEmpty
}
func recordPreferredAttachedDeveloperToolsWidth(_ width: CGFloat, containerBounds: NSRect) {
let normalizedWidth = max(0, width)
preferredAttachedDeveloperToolsWidth = normalizedWidth
guard containerBounds.width > 0 else {
preferredAttachedDeveloperToolsWidthFraction = nil
return
}
preferredAttachedDeveloperToolsWidthFraction = normalizedWidth / containerBounds.width
}
func preferredAttachedDeveloperToolsWidthState() -> (width: CGFloat?, widthFraction: CGFloat?) {
(preferredAttachedDeveloperToolsWidth, preferredAttachedDeveloperToolsWidthFraction)
}
@discardableResult
func zoomIn() -> Bool {
applyPageZoom(webView.pageZoom + pageZoomStep)
@ -3082,21 +3385,52 @@ extension BrowserPanel {
// MARK: - Find in Page
func startFind() {
if searchState == nil {
preferredFocusIntent = .findField
let created = searchState == nil
if created {
searchState = BrowserSearchState()
}
postBrowserSearchFocusNotification()
let generation = beginSearchFocusRequest(reason: "startFind")
#if DEBUG
let window = webView.window
dlog(
"browser.find.start panel=\(id.uuidString.prefix(5)) " +
"created=\(created ? 1 : 0) render=\(shouldRenderWebView ? 1 : 0) " +
"generation=\(generation) " +
"window=\(window?.windowNumber ?? -1) key=\(NSApp.keyWindow === window ? 1 : 0) " +
"firstResponder=\(String(describing: window?.firstResponder))"
)
#endif
postBrowserSearchFocusNotification(reason: "immediate", generation: generation)
// Focus notification can race with portal overlay mount. Re-post on the
// next runloop and shortly after so the find field can claim first responder.
DispatchQueue.main.async { [weak self] in
self?.postBrowserSearchFocusNotification()
self?.postBrowserSearchFocusNotification(reason: "async0", generation: generation)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.postBrowserSearchFocusNotification()
self?.postBrowserSearchFocusNotification(reason: "async50ms", generation: generation)
}
}
private func postBrowserSearchFocusNotification() {
private func postBrowserSearchFocusNotification(reason: String, generation: UInt64) {
guard canApplySearchFocusRequest(generation) else {
#if DEBUG
dlog(
"browser.find.focusNotification.skip panel=\(id.uuidString.prefix(5)) " +
"reason=\(reason) generation=\(generation)"
)
#endif
return
}
#if DEBUG
let window = webView.window
dlog(
"browser.find.focusNotification panel=\(id.uuidString.prefix(5)) " +
"generation=\(generation) " +
"reason=\(reason) window=\(window?.windowNumber ?? -1) " +
"firstResponder=\(String(describing: window?.firstResponder))"
)
#endif
NotificationCenter.default.post(name: .browserSearchFocus, object: id)
}
@ -3117,6 +3451,7 @@ extension BrowserPanel {
}
func hideFind() {
invalidateSearchFocusRequests(reason: "hideFind")
searchState = nil
}
@ -3127,7 +3462,10 @@ extension BrowserPanel {
if replaySearch, !state.needle.isEmpty {
executeFindSearch(state.needle)
}
postBrowserSearchFocusNotification()
postBrowserSearchFocusNotification(
reason: "restoreAfterNavigation",
generation: searchFocusRequestGeneration
)
}
private func executeFindSearch(_ needle: String) {
@ -3254,6 +3592,8 @@ extension BrowserPanel {
@discardableResult
func requestAddressBarFocus() -> UUID {
preferredFocusIntent = .addressBar
invalidateSearchFocusRequests(reason: "requestAddressBarFocus")
beginSuppressWebViewFocusForAddressBar()
if let pendingAddressBarFocusRequestId {
#if DEBUG
@ -3275,6 +3615,173 @@ extension BrowserPanel {
return requestId
}
func noteWebViewFocused() {
guard searchState == nil else { return }
guard preferredFocusIntent != .webView else { return }
preferredFocusIntent = .webView
invalidateSearchFocusRequests(reason: "webViewFocused")
}
func noteAddressBarFocused() {
guard preferredFocusIntent != .addressBar else { return }
preferredFocusIntent = .addressBar
invalidateSearchFocusRequests(reason: "addressBarFocused")
}
func noteFindFieldFocused() {
guard preferredFocusIntent != .findField else { return }
preferredFocusIntent = .findField
}
func canApplySearchFocusRequest(_ generation: UInt64) -> Bool {
generation != 0 &&
generation == searchFocusRequestGeneration &&
searchState != nil &&
preferredFocusIntent == .findField
}
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
if pendingAddressBarFocusRequestId != nil || AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id {
return .browser(.addressBar)
}
if searchState != nil && preferredFocusIntent == .findField {
return .browser(.findField)
}
if let window,
Self.responderChainContains(window.firstResponder, target: webView) {
return .browser(.webView)
}
return .browser(preferredFocusIntent)
}
func preferredFocusIntentForActivation() -> PanelFocusIntent {
if pendingAddressBarFocusRequestId != nil {
return .browser(.addressBar)
}
if searchState != nil && preferredFocusIntent == .findField {
return .browser(.findField)
}
return .browser(preferredFocusIntent)
}
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
guard case .browser(let target) = intent else { return }
switch target {
case .webView:
preferredFocusIntent = .webView
invalidateSearchFocusRequests(reason: "prepareWebView")
endSuppressWebViewFocusForAddressBar()
case .addressBar:
preferredFocusIntent = .addressBar
invalidateSearchFocusRequests(reason: "prepareAddressBar")
beginSuppressWebViewFocusForAddressBar()
case .findField:
preferredFocusIntent = .findField
}
#if DEBUG
dlog(
"browser.focus.prepare panel=\(id.uuidString.prefix(5)) " +
"target=\(String(describing: target)) suppressWeb=\(shouldSuppressWebViewFocus() ? 1 : 0)"
)
#endif
}
@discardableResult
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
guard case .browser(let target) = intent else { return false }
switch target {
case .webView:
noteWebViewFocused()
focus()
return true
case .addressBar:
let requestId = requestAddressBarFocus()
NotificationCenter.default.post(name: .browserFocusAddressBar, object: id)
#if DEBUG
dlog(
"browser.focus.restore panel=\(id.uuidString.prefix(5)) " +
"target=addressBar request=\(requestId.uuidString.prefix(8))"
)
#endif
return true
case .findField:
startFind()
return true
}
}
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
if AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id {
return .browser(.addressBar)
}
if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) == id {
return .browser(.findField)
}
if Self.responderChainContains(responder, target: webView) {
return .browser(.webView)
}
return nil
}
@discardableResult
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
guard case .browser(let target) = intent else { return false }
switch target {
case .findField:
invalidateSearchFocusRequests(reason: "yieldFindField")
let yielded = BrowserWindowPortalRegistry.yieldSearchOverlayFocusIfOwned(by: id, in: window)
#if DEBUG
if yielded {
dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=browserFind")
}
#endif
return yielded
case .addressBar:
guard AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id else { return false }
let yielded = window.makeFirstResponder(nil)
#if DEBUG
if yielded {
dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=addressBar")
}
#endif
return yielded
case .webView:
guard Self.responderChainContains(window.firstResponder, target: webView) else { return false }
return window.makeFirstResponder(nil)
}
}
@discardableResult
private func beginSearchFocusRequest(reason: String) -> UInt64 {
searchFocusRequestGeneration &+= 1
#if DEBUG
dlog(
"browser.find.focusLease.begin panel=\(id.uuidString.prefix(5)) " +
"generation=\(searchFocusRequestGeneration) reason=\(reason)"
)
#endif
return searchFocusRequestGeneration
}
private func invalidateSearchFocusRequests(reason: String) {
searchFocusRequestGeneration &+= 1
#if DEBUG
dlog(
"browser.find.focusLease.invalidate panel=\(id.uuidString.prefix(5)) " +
"generation=\(searchFocusRequestGeneration) reason=\(reason)"
)
#endif
}
func acknowledgeAddressBarFocusRequest(_ requestId: UUID) {
guard pendingAddressBarFocusRequestId == requestId else {
#if DEBUG
@ -3767,7 +4274,7 @@ private extension BrowserPanel {
}
}
private extension WKWebView {
extension WKWebView {
func cmuxInspectorObject() -> NSObject? {
let selector = NSSelectorFromString("_inspector")
guard responds(to: selector),
@ -3776,6 +4283,16 @@ private extension WKWebView {
}
return inspector
}
func cmuxInspectorFrontendWebView() -> WKWebView? {
guard let inspector = cmuxInspectorObject() else { return nil }
let selector = NSSelectorFromString("inspectorWebView")
guard inspector.responds(to: selector),
let inspectorWebView = inspector.perform(selector)?.takeUnretainedValue() as? WKWebView else {
return nil
}
return inspectorWebView
}
}
private extension NSObject {

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import Foundation
import Combine
import AppKit
/// Type of panel content
public enum PanelType: String, Codable, Sendable {
@ -8,6 +9,23 @@ public enum PanelType: String, Codable, Sendable {
case markdown
}
public enum TerminalPanelFocusIntent: Equatable {
case surface
case findField
}
public enum BrowserPanelFocusIntent: Equatable {
case webView
case addressBar
case findField
}
public enum PanelFocusIntent: Equatable {
case panel
case terminal(TerminalPanelFocusIntent)
case browser(BrowserPanelFocusIntent)
}
enum FocusFlashCurve: Equatable {
case easeIn
case easeOut
@ -72,10 +90,63 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
/// Trigger a focus flash animation for this panel.
func triggerFlash()
/// Capture the panel-local focus target that should be restored later.
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent
/// Return the best focus target to restore when this panel becomes active again.
func preferredFocusIntentForActivation() -> PanelFocusIntent
/// Prime panel-local focus state before activation side effects run.
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent)
/// Restore a previously captured focus target.
@discardableResult
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool
/// Return the semantic focus target currently owned by this panel, if any.
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent?
/// Explicitly yield a previously owned focus target before another panel restores focus.
@discardableResult
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool
}
/// Extension providing default implementations
extension Panel {
public var displayIcon: String? { nil }
public var isDirty: Bool { false }
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
_ = window
return preferredFocusIntentForActivation()
}
func preferredFocusIntentForActivation() -> PanelFocusIntent {
.panel
}
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
_ = intent
}
@discardableResult
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
guard intent == .panel else { return false }
focus()
return true
}
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
_ = responder
_ = window
return nil
}
@discardableResult
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
_ = intent
_ = window
return false
}
}

View file

@ -222,4 +222,42 @@ final class TerminalPanel: Panel, ObservableObject {
func applyWindowBackgroundIfActive() {
surface.applyWindowBackgroundIfActive()
}
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
.terminal(hostedView.capturePanelFocusIntent(in: window))
}
func preferredFocusIntentForActivation() -> PanelFocusIntent {
.terminal(hostedView.preferredPanelFocusIntentForActivation())
}
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
guard case .terminal(let target) = intent else { return }
hostedView.preparePanelFocusIntentForActivation(target)
}
@discardableResult
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
switch intent {
case .panel:
focus()
return true
case .terminal(let target):
return hostedView.restorePanelFocusIntent(target)
default:
return false
}
}
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
_ = window
guard let intent = hostedView.ownedPanelFocusIntent(for: responder) else { return nil }
return .terminal(intent)
}
@discardableResult
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
guard case .terminal(let target) = intent else { return false }
return hostedView.yieldPanelFocusIntent(target, in: window)
}
}

View file

@ -558,6 +558,11 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
@MainActor
class TabManager: ObservableObject {
private struct InitialWorkspaceGitMetadataSnapshot: Equatable {
let branch: String?
let isDirty: Bool
}
/// 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?
@ -569,6 +574,7 @@ class TabManager: ObservableObject {
/// Global monotonically increasing counter for CMUX_PORT ordinal assignment.
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
private static var nextPortOrdinal: Int = 0
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
@Published var selectedTabId: UUID? {
didSet {
guard selectedTabId != oldValue else { return }
@ -624,6 +630,12 @@ class TabManager: ObservableObject {
private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:]
private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
private let initialWorkspaceGitProbeQueue = DispatchQueue(
label: "com.cmux.initial-workspace-git-probe",
qos: .utility
)
private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:]
private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:]
// Recent tab history for back/forward navigation (like browser history)
private var tabHistory: [UUID] = []
@ -771,7 +783,8 @@ class TabManager: ObservableObject {
initialTerminalEnvironment: [String: String] = [:],
select: Bool = true,
eagerLoadTerminal: Bool = false,
placementOverride: NewWorkspacePlacement? = nil
placementOverride: NewWorkspacePlacement? = nil,
autoWelcomeIfNeeded: Bool = true
) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
@ -811,9 +824,209 @@ class TabManager: ObservableObject {
"selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "")
])
#endif
if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) {
if let appDelegate = AppDelegate.shared {
appDelegate.sendWelcomeCommandWhenReady(to: newWorkspace, markShownOnSend: true)
} else {
sendWelcomeWhenReady(to: newWorkspace)
}
}
return newWorkspace
}
private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) {
let maxAttempts = 60
if let terminalPanel = workspace.focusedTerminalPanel,
terminalPanel.surface.surface != nil {
// Wait a bit more for the shell prompt to be ready
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
terminalPanel.sendText("cmux welcome\n")
}
return
}
guard attempt < maxAttempts else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1)
}
}
private func scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: UUID,
panelId: UUID,
directory: String
) {
let normalizedDirectory = normalizeDirectory(directory)
let generation = UUID()
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation
#if DEBUG
dlog(
"workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory)"
)
#endif
let delays = Self.initialWorkspaceGitProbeDelays
var timers: [DispatchSourceTimer] = []
for (index, delay) in delays.enumerated() {
let isLastAttempt = index == delays.count - 1
let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue)
timer.schedule(deadline: .now() + delay, repeating: .never)
timer.setEventHandler { [weak self] in
let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory)
Task { @MainActor [weak self] in
self?.applyInitialWorkspaceGitMetadataSnapshot(
snapshot,
generation: generation,
workspaceId: workspaceId,
panelId: panelId,
expectedDirectory: normalizedDirectory,
isLastAttempt: isLastAttempt
)
}
}
timers.append(timer)
timer.resume()
}
initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers
}
private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) {
guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else {
return
}
for timer in timers {
timer.setEventHandler {}
timer.cancel()
}
}
private func clearInitialWorkspaceGitProbe(workspaceId: UUID) {
initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId)
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
}
private func applyInitialWorkspaceGitMetadataSnapshot(
_ snapshot: InitialWorkspaceGitMetadataSnapshot,
generation: UUID,
workspaceId: UUID,
panelId: UUID,
expectedDirectory: String,
isLastAttempt: Bool
) {
defer {
if isLastAttempt,
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
}
}
guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return }
guard let workspace = tabs.first(where: { $0.id == workspaceId }) else {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
return
}
guard workspace.panels[panelId] != nil else {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
return
}
let currentDirectory = normalizedWorkingDirectory(
workspace.panelDirectories[panelId] ?? workspace.currentDirectory
)
if let currentDirectory, currentDirectory != expectedDirectory {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
#if DEBUG
dlog(
"workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=directoryChanged " +
"expected=\(expectedDirectory) current=\(currentDirectory)"
)
#endif
return
}
workspace.updatePanelDirectory(panelId: panelId, directory: expectedDirectory)
let previousBranch = Self.normalizedBranchName(workspace.panelGitBranches[panelId]?.branch)
let nextBranch = snapshot.branch
if let nextBranch {
workspace.updatePanelGitBranch(panelId: panelId, branch: nextBranch, isDirty: snapshot.isDirty)
} else {
workspace.clearPanelGitBranch(panelId: panelId)
}
if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
workspace.clearPanelPullRequest(panelId: panelId)
}
#if DEBUG
let branchLabel = snapshot.branch ?? "none"
dlog(
"workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)"
)
#endif
}
private nonisolated static func initialWorkspaceGitMetadataSnapshot(
for directory: String
) -> InitialWorkspaceGitMetadataSnapshot {
let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"]))
guard let branch else {
return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false)
}
let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"])
let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty)
}
private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? {
let process = Process()
let stdout = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["git", "-C", directory] + arguments
process.standardOutput = stdout
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
return nil
}
// Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer.
let data = stdout.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
return nil
}
return String(data: data, encoding: .utf8)
}
private nonisolated static func normalizedBranchName(_ branch: String?) -> String? {
let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return }
}
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return }
}
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
pendingBackgroundWorkspaceLoadIds = pruned
}
// Keep addTab as convenience alias
@discardableResult
func addTab(select: Bool = true, eagerLoadTerminal: Bool = false) -> Workspace {
@ -1000,20 +1213,6 @@ class TabManager: ObservableObject {
return trimmed
}
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
_ = pendingBackgroundWorkspaceLoadIds.insert(workspaceId)
}
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
pendingBackgroundWorkspaceLoadIds.remove(workspaceId)
}
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
pendingBackgroundWorkspaceLoadIds = pruned
}
func closeWorkspace(_ workspace: Workspace) {
guard tabs.count > 1 else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])

View file

@ -1742,6 +1742,16 @@ class TerminalController {
case "workspace.remote.status":
return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params))
// Settings
case "settings.open":
return v2Result(id: id, self.v2SettingsOpen(params: params))
// Feedback
case "feedback.open":
return v2Result(id: id, self.v2FeedbackOpen(params: params))
case "feedback.submit":
return v2Result(id: id, self.v2FeedbackSubmit(params: params))
// Surfaces / input
case "surface.list":
@ -2093,6 +2103,9 @@ class TerminalController {
"workspace.remote.reconnect",
"workspace.remote.disconnect",
"workspace.remote.status",
"settings.open",
"feedback.open",
"feedback.submit",
"surface.list",
"surface.current",
"surface.focus",
@ -3956,6 +3969,9 @@ class TerminalController {
"index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]),
"selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id])
]
if let browserPanel = panel as? BrowserPanel {
item["developer_tools_visible"] = browserPanel.isDeveloperToolsVisible()
}
return item
}
@ -5608,6 +5624,109 @@ class TerminalController {
return .ok([:])
}
private func v2FeedbackOpen(params: [String: Any]) -> V2CallResult {
let workspaceId = v2UUID(params, "workspace_id")
let windowId = v2UUID(params, "window_id")
let shouldActivate = v2Bool(params, "activate") ?? false
DispatchQueue.main.async {
let targetWindow: NSWindow?
if let windowId, let app = AppDelegate.shared {
targetWindow = app.mainWindow(for: windowId)
} else if let workspaceId, let app = AppDelegate.shared {
targetWindow = app.mainWindowContainingWorkspace(workspaceId)
} else {
targetWindow = nil
}
if shouldActivate {
if let targetWindow {
targetWindow.makeKeyAndOrderFront(nil)
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
} else {
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
}
}
FeedbackComposerBridge.openComposer(in: targetWindow)
}
return .ok(["opened": true])
}
private func v2SettingsOpen(params: [String: Any]) -> V2CallResult {
let targetRaw = v2String(params, "target")
let shouldActivate = v2Bool(params, "activate") ?? true
let navigationTarget: SettingsNavigationTarget?
switch targetRaw {
case nil:
navigationTarget = nil
case SettingsNavigationTarget.keyboardShortcuts.rawValue:
navigationTarget = .keyboardShortcuts
default:
return .err(code: "invalid_params", message: "Unknown settings target", data: ["target": targetRaw ?? ""])
}
DispatchQueue.main.async {
if shouldActivate {
AppDelegate.presentPreferencesWindow(navigationTarget: navigationTarget)
} else {
SettingsWindowController.shared.show(navigationTarget: navigationTarget)
}
}
return .ok([
"opened": true,
"target": navigationTarget?.rawValue ?? "general",
])
}
private func v2FeedbackSubmit(params: [String: Any]) -> V2CallResult {
guard let email = params["email"] as? String else {
return .err(code: "invalid_params", message: "Missing email", data: ["field": "email"])
}
guard let body = params["body"] as? String else {
return .err(code: "invalid_params", message: "Missing body", data: ["field": "body"])
}
let imagePaths = params["image_paths"] as? [String] ?? []
let semaphore = DispatchSemaphore(value: 0)
var result: V2CallResult = .err(code: "internal_error", message: "Feedback submission failed", data: nil)
Task {
let resolved: V2CallResult
do {
let attachmentCount = try await FeedbackComposerBridge.submit(
email: email,
message: body,
imagePaths: imagePaths
)
resolved = .ok([
"submitted": true,
"attachment_count": attachmentCount,
])
} catch let error as FeedbackComposerBridgeError {
let code: String
switch error {
case .invalidEmail, .emptyMessage, .messageTooLong, .tooManyImages, .invalidImagePath:
code = "invalid_params"
case .submissionFailed:
code = "request_failed"
}
resolved = .err(code: code, message: error.localizedDescription, data: nil)
} catch {
resolved = .err(code: "internal_error", message: error.localizedDescription, data: nil)
}
result = resolved
semaphore.signal()
}
if semaphore.wait(timeout: .now() + 35) == .timedOut {
return .err(code: "timeout", message: "Feedback submission timed out", data: nil)
}
return result
}
// MARK: - V2 App Focus Methods
private func v2AppFocusOverride(params: [String: Any]) -> V2CallResult {

View file

@ -901,6 +901,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
private struct NotificationsPopoverView: View {
@ObservedObject var notificationStore: TerminalNotificationStore
@AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data()
let onDismiss: () -> Void
var body: some View {
@ -909,12 +910,28 @@ private struct NotificationsPopoverView: View {
Text(String(localized: "notifications.title", defaultValue: "Notifications"))
.font(.headline)
Spacer()
if !notificationStore.notifications.isEmpty {
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
notificationStore.clearAll()
Button(action: jumpToLatestUnread) {
HStack(spacing: 6) {
Text(String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest"))
Text(jumpToUnreadShortcut.displayString)
}
.buttonStyle(.bordered)
}
.buttonStyle(.bordered)
.accessibilityIdentifier("notificationsPopover.jumpToLatest")
.accessibilityValue(jumpToUnreadShortcut.displayString)
.safeHelp(
KeyboardShortcutSettings.Action.jumpToUnread.tooltip(
String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest")
)
)
.disabled(!hasUnreadNotifications)
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
notificationStore.clearAll()
}
.buttonStyle(.bordered)
.accessibilityIdentifier("notificationsPopover.clearAll")
.disabled(notificationStore.notifications.isEmpty)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
@ -957,6 +974,32 @@ private struct NotificationsPopoverView: View {
AppDelegate.shared?.tabTitle(for: tabId)
}
private var jumpToUnreadShortcut: StoredShortcut {
decodeShortcut(
from: jumpToUnreadShortcutData,
fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut
)
}
private var hasUnreadNotifications: Bool {
notificationStore.notifications.contains(where: { !$0.isRead })
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
private func jumpToLatestUnread() {
DispatchQueue.main.async {
AppDelegate.shared?.jumpToLatestUnread()
onDismiss()
}
}
private func open(_ notification: TerminalNotification) {
// SwiftUI action closures are not guaranteed to run on the main actor.
// Ensure window focus + tab selection happens on the main thread.

View file

@ -3555,7 +3555,14 @@ final class Workspace: Identifiable, ObservableObject {
private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:]
private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:]
private var isApplyingTabSelection = false
private var pendingTabSelection: (tabId: TabID, pane: PaneID)?
private struct PendingTabSelectionRequest {
let tabId: TabID
let pane: PaneID
let reassertAppKitFocus: Bool
let focusIntent: PanelFocusIntent?
let previousTerminalHostedView: GhosttySurfaceScrollView?
}
private var pendingTabSelection: PendingTabSelectionRequest?
private var isReconcilingFocusState = false
private var focusReconcileScheduled = false
#if DEBUG
@ -5604,6 +5611,19 @@ final class Workspace: Identifiable, ObservableObject {
}()
let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged
#if DEBUG
let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
let selectedTabShort = bonsplitController.focusedPaneId
.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
.map { String($0.uuid.uuidString.prefix(5)) } ?? "nil"
let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"focus.panel.begin workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " +
"targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " +
"converged=\(selectionAlreadyConverged ? 1 : 0) " +
"currentPanel=\(currentPanelShort)"
)
if shouldSuppressReentrantRefocus {
dlog(
"focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " +
@ -5613,34 +5633,65 @@ final class Workspace: Identifiable, ObservableObject {
#endif
if let targetPaneId, !selectionAlreadyConverged {
#if DEBUG
dlog(
"focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))"
)
#endif
bonsplitController.focusPane(targetPaneId)
}
if !selectionAlreadyConverged {
#if DEBUG
dlog(
"focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))"
)
#endif
bonsplitController.selectTab(tabId)
}
// Also focus the underlying panel
if let panel = panels[panelId] {
if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus {
panel.focus()
}
if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel {
// Avoid re-entrant focus loops when focus was initiated by AppKit first-responder
// (becomeFirstResponder -> onFocus -> focusPanel).
if !terminalPanel.hostedView.isSurfaceViewFirstResponder() {
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
}
}
}
if let targetPaneId, !shouldSuppressReentrantRefocus {
applyTabSelection(tabId: tabId, inPane: targetPaneId)
if let targetPaneId {
let activationIntent = panels[panelId]?.preferredFocusIntentForActivation()
applyTabSelection(
tabId: tabId,
inPane: targetPaneId,
reassertAppKitFocus: !shouldSuppressReentrantRefocus,
focusIntent: activationIntent,
previousTerminalHostedView: previousTerminalHostedView
)
}
if let browserPanel = panels[panelId] as? BrowserPanel {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
}
if trigger == .terminalFirstResponder,
panels[panelId] is TerminalPanel {
scheduleTerminalFirstResponderReassert(panelId: panelId)
}
}
/// A terminal click can arrive while AppKit and bonsplit already look converged, which takes
/// the re-entrant focus path and skips the normal explicit `ensureFocus` call. Re-assert focus
/// on the next couple of turns so stale callbacks from split churn can't leave keyboard input
/// attached to the wrong surface (#1147).
private func scheduleTerminalFirstResponderReassert(panelId: UUID, remainingPasses: Int = 2) {
guard remainingPasses > 0 else { return }
DispatchQueue.main.async { [weak self] in
guard let self,
self.focusedPanelId == panelId,
let terminalPanel = self.terminalPanel(for: panelId) else {
return
}
terminalPanel.hostedView.ensureFocus(for: self.id, surfaceId: panelId)
self.scheduleTerminalFirstResponderReassert(
panelId: panelId,
remainingPasses: remainingPasses - 1
)
}
}
private func maybeAutoFocusBrowserAddressBarOnPanelFocus(
@ -5753,9 +5804,28 @@ final class Workspace: Identifiable, ObservableObject {
@discardableResult
func toggleSplitZoom(panelId: UUID) -> Bool {
let wasSplitZoomed = bonsplitController.isSplitZoomed
guard let paneId = paneId(forPanelId: panelId) else { return false }
guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false }
focusPanel(panelId)
reconcileTerminalPortalVisibilityForCurrentRenderedLayout()
reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom")
scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: 4)
scheduleBrowserPortalVisibilityReconcileAfterSplitZoom(
remainingPasses: 4,
reason: "workspace.toggleSplitZoom"
)
scheduleTerminalGeometryReconcile()
if let browserPanel = browserPanel(for: panelId) {
browserPanel.preparePortalHostReplacementForNextDistinctClaim(
inPane: paneId,
reason: "workspace.toggleSplitZoom"
)
scheduleBrowserPortalReconcileAfterSplitZoom(panelId: panelId, remainingPasses: 4)
if wasSplitZoomed && !bonsplitController.isSplitZoomed {
scheduleBrowserSplitZoomExitFocusReassert(panelId: panelId, remainingPasses: 4)
}
}
return true
}
@ -6012,6 +6082,248 @@ final class Workspace: Identifiable, ObservableObject {
}
}
private func renderedVisiblePanelIdsForCurrentLayout() -> Set<UUID> {
let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds
var visiblePanelIds: Set<UUID> = []
for paneId in renderedPaneIds {
let selectedTab = bonsplitController.selectedTab(inPane: paneId) ?? bonsplitController.tabs(inPane: paneId).first
guard let selectedTab,
let panelId = panelIdFromSurfaceId(selectedTab.id),
panels[panelId] != nil else {
continue
}
visiblePanelIds.insert(panelId)
}
if let focusedPanelId,
panels[focusedPanelId] != nil,
let focusedPaneId = paneId(forPanelId: focusedPanelId),
renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) {
visiblePanelIds.insert(focusedPanelId)
}
return visiblePanelIds
}
private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() {
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
for panel in panels.values {
guard let terminalPanel = panel as? TerminalPanel else { continue }
let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
terminalPanel.hostedView.setVisibleInUI(shouldBeVisible)
terminalPanel.hostedView.setActive(shouldBeVisible && focusedPanelId == terminalPanel.id)
TerminalWindowPortalRegistry.updateEntryVisibility(
for: terminalPanel.hostedView,
visibleInUI: shouldBeVisible
)
}
}
private func terminalPortalVisibilityNeedsFollowUp() -> Bool {
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
for panel in panels.values {
guard let terminalPanel = panel as? TerminalPanel else { continue }
let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
let hostedView = terminalPanel.hostedView
if shouldBeVisible {
if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil {
return true
}
} else if !hostedView.isHidden {
return true
}
}
return false
}
private func scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: Int) {
guard remainingPasses > 0 else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
for window in NSApp.windows {
window.contentView?.layoutSubtreeIfNeeded()
window.contentView?.displayIfNeeded()
}
self.reconcileTerminalPortalVisibilityForCurrentRenderedLayout()
if self.terminalPortalVisibilityNeedsFollowUp(), remainingPasses > 1 {
self.scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(
remainingPasses: remainingPasses - 1
)
}
}
}
private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) {
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
for panel in panels.values {
guard let browserPanel = panel as? BrowserPanel else { continue }
let shouldBeVisible = visiblePanelIds.contains(browserPanel.id)
if shouldBeVisible {
BrowserWindowPortalRegistry.updateEntryVisibility(
for: browserPanel.webView,
visibleInUI: true,
zPriority: 2
)
let anchorView = browserPanel.portalAnchorView
let anchorReady =
anchorView.window != nil &&
anchorView.superview != nil &&
anchorView.bounds.width > 1 &&
anchorView.bounds.height > 1
if anchorReady {
BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView)
BrowserWindowPortalRegistry.refresh(
webView: browserPanel.webView,
reason: reason
)
}
} else {
BrowserWindowPortalRegistry.updateEntryVisibility(
for: browserPanel.webView,
visibleInUI: false,
zPriority: 0
)
BrowserWindowPortalRegistry.hide(
webView: browserPanel.webView,
source: reason
)
}
}
}
private func browserPortalVisibilityNeedsFollowUp() -> Bool {
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
for panel in panels.values {
guard let browserPanel = panel as? BrowserPanel else { continue }
guard visiblePanelIds.contains(browserPanel.id) else { continue }
let anchorView = browserPanel.portalAnchorView
let anchorReady =
anchorView.window != nil &&
anchorView.superview != nil &&
anchorView.bounds.width > 1 &&
anchorView.bounds.height > 1
if !anchorReady ||
browserPanel.webView.window == nil ||
browserPanel.webView.superview == nil ||
!BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: anchorView) {
return true
}
}
return false
}
private func scheduleBrowserPortalVisibilityReconcileAfterSplitZoom(
remainingPasses: Int,
reason: String
) {
guard remainingPasses > 0 else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
for window in NSApp.windows {
window.contentView?.layoutSubtreeIfNeeded()
window.contentView?.displayIfNeeded()
}
self.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason)
if self.browserPortalVisibilityNeedsFollowUp(), remainingPasses > 1 {
self.scheduleBrowserPortalVisibilityReconcileAfterSplitZoom(
remainingPasses: remainingPasses - 1,
reason: reason
)
}
}
}
// Browser panes host WKWebView in the window portal. After pane zoom toggles,
// force a few post-layout sync passes so the portal does not outlive the omnibar chrome.
private func scheduleBrowserPortalReconcileAfterSplitZoom(panelId: UUID, remainingPasses: Int) {
guard remainingPasses > 0 else { return }
DispatchQueue.main.async { [weak self] in
guard let self, let browserPanel = self.browserPanel(for: panelId) else { return }
for window in NSApp.windows {
window.contentView?.layoutSubtreeIfNeeded()
window.contentView?.displayIfNeeded()
}
let anchorView = browserPanel.portalAnchorView
let anchorReady =
anchorView.window != nil &&
anchorView.superview != nil &&
anchorView.bounds.width > 1 &&
anchorView.bounds.height > 1
if anchorReady {
BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView)
BrowserWindowPortalRegistry.refresh(
webView: browserPanel.webView,
reason: "workspace.toggleSplitZoom"
)
}
let portalNeedsFollowUpPass =
!anchorReady ||
browserPanel.webView.window == nil ||
browserPanel.webView.superview == nil
if portalNeedsFollowUpPass {
self.scheduleBrowserPortalReconcileAfterSplitZoom(
panelId: panelId,
remainingPasses: remainingPasses - 1
)
}
}
}
// Browser panes can briefly keep the portal-hosted WKWebView visible while Bonsplit is
// still rebuilding the unzoomed pane host. Reassert pane/tab selection after layout settles
// so the SwiftUI chrome does not remain hidden until another browser focus command runs.
private func scheduleBrowserSplitZoomExitFocusReassert(panelId: UUID, remainingPasses: Int) {
guard remainingPasses > 0 else { return }
DispatchQueue.main.async { [weak self] in
guard let self, self.browserPanel(for: panelId) != nil else { return }
guard let paneId = self.paneId(forPanelId: panelId),
let tabId = self.surfaceIdFromPanelId(panelId) else { return }
let selectionConverged =
self.bonsplitController.focusedPaneId == paneId &&
self.bonsplitController.selectedTab(inPane: paneId)?.id == tabId
let anchorReady: Bool = {
guard let browserPanel = self.browserPanel(for: panelId) else { return false }
let anchorView = browserPanel.portalAnchorView
return
anchorView.window != nil &&
anchorView.superview != nil &&
anchorView.bounds.width > 1 &&
anchorView.bounds.height > 1
}()
if !selectionConverged {
self.focusPanel(panelId)
self.scheduleFocusReconcile()
}
if !selectionConverged || !anchorReady {
self.scheduleBrowserSplitZoomExitFocusReassert(
panelId: panelId,
remainingPasses: remainingPasses - 1
)
}
}
}
private func scheduleMovedTerminalRefresh(panelId: UUID) {
guard terminalPanel(for: panelId) != nil else { return }
@ -6280,8 +6592,20 @@ extension Workspace: BonsplitDelegate {
/// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state).
/// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab).
private func applyTabSelection(tabId: TabID, inPane pane: PaneID) {
pendingTabSelection = (tabId: tabId, pane: pane)
private func applyTabSelection(
tabId: TabID,
inPane pane: PaneID,
reassertAppKitFocus: Bool = true,
focusIntent: PanelFocusIntent? = nil,
previousTerminalHostedView: GhosttySurfaceScrollView? = nil
) {
pendingTabSelection = PendingTabSelectionRequest(
tabId: tabId,
pane: pane,
reassertAppKitFocus: reassertAppKitFocus,
focusIntent: focusIntent,
previousTerminalHostedView: previousTerminalHostedView
)
guard !isApplyingTabSelection else { return }
isApplyingTabSelection = true
defer {
@ -6294,12 +6618,36 @@ extension Workspace: BonsplitDelegate {
pendingTabSelection = nil
iterations += 1
if iterations > 8 { break }
applyTabSelectionNow(tabId: request.tabId, inPane: request.pane)
applyTabSelectionNow(
tabId: request.tabId,
inPane: request.pane,
reassertAppKitFocus: request.reassertAppKitFocus,
focusIntent: request.focusIntent,
previousTerminalHostedView: request.previousTerminalHostedView
)
}
}
private func applyTabSelectionNow(tabId: TabID, inPane pane: PaneID) {
private func applyTabSelectionNow(
tabId: TabID,
inPane pane: PaneID,
reassertAppKitFocus: Bool,
focusIntent: PanelFocusIntent?,
previousTerminalHostedView: GhosttySurfaceScrollView?
) {
let previousFocusedPanelId = focusedPanelId
#if DEBUG
let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
let selectedTabBefore = bonsplitController.focusedPaneId
.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
.map { String($0.uuid.uuidString.prefix(5)) } ?? "nil"
dlog(
"focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " +
"pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " +
"focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " +
"reassert=\(reassertAppKitFocus ? 1 : 0)"
)
#endif
if bonsplitController.allPaneIds.contains(pane) {
if bonsplitController.focusedPaneId != pane {
bonsplitController.focusPane(pane)
@ -6334,6 +6682,8 @@ extension Workspace: BonsplitDelegate {
if shouldTreatCurrentEventAsExplicitFocusIntent() {
markExplicitFocusIntent(on: panelId)
}
let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation()
panel.prepareFocusIntentForActivation(activationIntent)
syncPinnedStateForTab(selectedTabId, panelId: panelId)
syncUnreadBadgeStateForPanel(panelId)
@ -6343,11 +6693,24 @@ extension Workspace: BonsplitDelegate {
p.unfocus()
}
panel.focus()
if let focusWindow = activationWindow(for: panel) {
yieldForeignOwnedFocusIfNeeded(
in: focusWindow,
targetPanelId: panelId,
targetIntent: activationIntent
)
}
activatePanel(
panel,
focusIntent: activationIntent,
reassertAppKitFocus: reassertAppKitFocus
)
let focusIntentAllowsBrowserOmnibarAutofocus =
shouldTreatCurrentEventAsExplicitFocusIntent() ||
TerminalController.socketCommandAllowsInAppFocusMutations()
if let browserPanel = panel as? BrowserPanel,
shouldAllowBrowserOmnibarAutofocus(for: activationIntent),
previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard)
}
@ -6375,10 +6738,33 @@ extension Workspace: BonsplitDelegate {
// Converge AppKit first responder with bonsplit's selected tab in the focused pane.
// Without this, keyboard input can remain on a different terminal than the blue tab indicator.
if let terminalPanel = panel as? TerminalPanel {
if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel {
if shouldMoveTerminalSurfaceFocus(for: activationIntent),
!terminalPanel.hostedView.isSurfaceViewFirstResponder() {
#if DEBUG
let previousExists = previousTerminalHostedView != nil ? 1 : 0
dlog(
"focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " +
"to=\(panelId.uuidString.prefix(5))"
)
#endif
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
}
#if DEBUG
dlog(
"focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " +
"tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))"
)
#endif
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId)
}
if shouldRestoreFocusIntentAfterActivation(activationIntent) {
_ = panel.restoreFocusIntent(activationIntent)
}
// Update current directory if this is a terminal
if let dir = panelDirectories[panelId] {
currentDirectory = dir
@ -6395,6 +6781,108 @@ extension Workspace: BonsplitDelegate {
GhosttyNotificationKey.surfaceId: panelId
]
)
#if DEBUG
let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
dlog(
"focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " +
"focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " +
"prevPanel=\(prevPanelShort)"
)
#endif
}
private func activatePanel(
_ panel: any Panel,
focusIntent: PanelFocusIntent,
reassertAppKitFocus: Bool
) {
if let terminalPanel = panel as? TerminalPanel {
let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent)
terminalPanel.surface.setFocus(shouldFocusTerminalSurface)
terminalPanel.hostedView.setActive(true)
if reassertAppKitFocus && shouldFocusTerminalSurface {
terminalPanel.focus()
}
return
}
if let browserPanel = panel as? BrowserPanel {
guard shouldFocusBrowserWebView(for: focusIntent) else { return }
browserPanel.focus()
return
}
if reassertAppKitFocus {
panel.focus()
}
}
private func activationWindow(for panel: any Panel) -> NSWindow? {
if let terminalPanel = panel as? TerminalPanel {
return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
}
if let browserPanel = panel as? BrowserPanel {
return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
}
return NSApp.keyWindow ?? NSApp.mainWindow
}
private func yieldForeignOwnedFocusIfNeeded(
in window: NSWindow,
targetPanelId: UUID,
targetIntent: PanelFocusIntent
) {
guard let firstResponder = window.firstResponder else { return }
for (panelId, panel) in panels where panelId != targetPanelId {
guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue }
#if DEBUG
dlog(
"focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " +
"fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " +
"fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))"
)
#endif
_ = panel.yieldFocusIntent(ownedIntent, in: window)
return
}
}
private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool {
switch intent {
case .terminal(.findField):
return false
default:
return true
}
}
private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool {
switch intent {
case .browser(.addressBar), .browser(.findField):
return false
default:
return true
}
}
private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool {
switch intent {
case .browser(.webView), .panel:
return true
default:
return false
}
}
private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool {
switch intent {
case .browser(.addressBar), .browser(.findField), .terminal(.findField):
return true
case .panel, .browser(.webView), .terminal(.surface):
return false
}
}
private func beginNonFocusSplitFocusReassert(

View file

@ -106,6 +106,11 @@ struct WorkspaceContentView: View {
workspace.bonsplitController.focusPane(paneId)
}
}
.internalOnlyTabDrag()
// Split zoom swaps Bonsplit between the full split tree and a single pane view.
// Recreate the Bonsplit subtree on zoom enter/exit so stale pre-zoom pane chrome
// cannot remain stacked above portal-hosted browser content.
.id(splitZoomRenderIdentity)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
syncBonsplitNotificationBadges()
@ -174,6 +179,10 @@ struct WorkspaceContentView: View {
}
}
private var splitZoomRenderIdentity: String {
workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed"
}
static func resolveGhosttyAppearanceConfig(
reason: String = "unspecified",
backgroundOverride: NSColor? = nil,

View file

@ -2815,6 +2815,10 @@ enum ClaudeCodeIntegrationSettings {
}
}
enum WelcomeSettings {
static let shownKey = "cmuxWelcomeShown"
}
enum TelemetrySettings {
static let sendAnonymousTelemetryKey = "sendAnonymousTelemetry"
static let defaultSendAnonymousTelemetry = true
@ -2833,6 +2837,7 @@ enum TelemetrySettings {
struct SettingsView: View {
private let contentTopInset: CGFloat = 8
private let pickerColumnWidth: CGFloat = 196
private let notificationSoundControlWidth: CGFloat = 280
@AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
@ -3321,7 +3326,8 @@ struct SettingsView: View {
SettingsCardRow(
String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"),
subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives.")
subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."),
controlWidth: notificationSoundControlWidth
) {
VStack(alignment: .trailing, spacing: 6) {
HStack(spacing: 6) {
@ -3381,6 +3387,7 @@ struct SettingsView: View {
}
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
SettingsCardDivider()