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

@ -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(