Merge pull request #1145 from manaflow-ai/issue-1144-browser-address-bar-disappears
Fix browser omnibar disappearing after Cmd+Shift+Enter
This commit is contained in:
commit
22ae558b6b
7 changed files with 608 additions and 2 deletions
|
|
@ -6241,6 +6241,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)
|
||||
|
|
@ -7544,6 +7618,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSplitZoom)) {
|
||||
_ = tabManager?.toggleFocusedSplitZoom()
|
||||
#if DEBUG
|
||||
recordGotoSplitZoomIfNeeded()
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2856,6 +2856,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 +2888,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] = [:]
|
||||
|
||||
|
|
@ -3038,6 +3057,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
|
||||
|
|
|
|||
|
|
@ -6382,6 +6382,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
|
||||
|
|
|
|||
|
|
@ -1720,7 +1720,13 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
let inWindow: Bool
|
||||
let area: CGFloat
|
||||
}
|
||||
private struct PortalHostLock {
|
||||
let hostId: ObjectIdentifier
|
||||
let paneId: UUID
|
||||
}
|
||||
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?
|
||||
|
|
@ -1773,6 +1779,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,
|
||||
|
|
@ -1788,6 +1810,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
|
||||
|
|
@ -1795,12 +1822,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)) " +
|
||||
|
|
@ -1820,7 +1882,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
|
||||
|
|
@ -1842,6 +1905,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)) " +
|
||||
|
|
|
|||
|
|
@ -3247,9 +3247,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
|
||||
}
|
||||
|
||||
|
|
@ -3512,6 +3531,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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,10 @@ struct WorkspaceContentView: View {
|
|||
workspace.bonsplitController.focusPane(paneId)
|
||||
}
|
||||
}
|
||||
// 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 +178,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,
|
||||
|
|
|
|||
|
|
@ -554,6 +554,150 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testCmdShiftEnterKeepsBrowserOmnibarHittableAcrossZoomRoundTripWhenWebViewFocused() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0),
|
||||
"Expected goto_split setup data to be written"
|
||||
)
|
||||
|
||||
guard let setup = loadData() else {
|
||||
XCTFail("Missing goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
guard let browserPanelId = setup["browserPanelId"] else {
|
||||
XCTFail("Missing browserPanelId in goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test")
|
||||
|
||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||
let pill = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarPill").firstMatch
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field before zoom")
|
||||
XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill before zoom")
|
||||
|
||||
// Reproduce the loaded-page state from the bug report before toggling zoom.
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForElementToBecomeHittable(pill, timeout: 6.0), "Expected browser omnibar pill before navigation")
|
||||
pill.click()
|
||||
app.typeKey("a", modifierFlags: [.command])
|
||||
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||
app.typeText(zoomRoundTripPageURL)
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0),
|
||||
"Expected browser to finish navigating to the regression page before zoom. value=\(String(describing: omnibar.value))"
|
||||
)
|
||||
|
||||
let browserPane = app.otherElements["BrowserPanelContent.\(browserPanelId)"].firstMatch
|
||||
XCTAssertTrue(browserPane.waitForExistence(timeout: 6.0), "Expected browser pane content before zoom")
|
||||
browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 8.0) { data in
|
||||
data["splitZoomedAfterToggle"] == "true" &&
|
||||
data["otherTerminalHostHiddenAfterToggle"] == "true" &&
|
||||
data["otherTerminalVisibleFlagAfterToggle"] == "false"
|
||||
},
|
||||
"Expected Cmd+Shift+Enter zoom-in to hide the non-browser terminal portal. data=\(loadData() ?? [:])"
|
||||
)
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 8.0) { data in
|
||||
data["splitZoomedAfterToggle"] == "false" &&
|
||||
data["otherTerminalHostHiddenAfterToggle"] == "false" &&
|
||||
data["otherTerminalVisibleFlagAfterToggle"] == "true"
|
||||
},
|
||||
"Expected Cmd+Shift+Enter zoom-out to restore the non-browser terminal portal. data=\(loadData() ?? [:])"
|
||||
)
|
||||
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field after Cmd+Shift+Enter zoom round-trip")
|
||||
XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill after Cmd+Shift+Enter zoom round-trip")
|
||||
XCTAssertTrue(
|
||||
waitForElementToBecomeHittable(pill, timeout: 6.0),
|
||||
"Expected browser omnibar to stay hittable after Cmd+Shift+Enter zoom round-trip"
|
||||
)
|
||||
let page = app.webViews.firstMatch
|
||||
XCTAssertTrue(page.waitForExistence(timeout: 6.0), "Expected browser web area after Cmd+Shift+Enter")
|
||||
XCTAssertLessThanOrEqual(
|
||||
pill.frame.maxY,
|
||||
page.frame.minY + 12,
|
||||
"Expected browser omnibar to remain above the web content after Cmd+Shift+Enter. pill=\(pill.frame) page=\(page.frame)"
|
||||
)
|
||||
|
||||
pill.click()
|
||||
app.typeKey("a", modifierFlags: [.command])
|
||||
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||
app.typeText("issue1144")
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForOmnibarToContain(omnibar, value: "issue1144", timeout: 4.0),
|
||||
"Expected browser omnibar to stay editable after Cmd+Shift+Enter. value=\(String(describing: omnibar.value))"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdShiftEnterHidesBrowserPortalWhenTerminalPaneZooms() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["terminalPaneId", "browserPanelId", "webViewFocused"], timeout: 10.0),
|
||||
"Expected goto_split setup data to be written"
|
||||
)
|
||||
|
||||
guard let setup = loadData() else {
|
||||
XCTFail("Missing goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
guard let expectedTerminalPaneId = setup["terminalPaneId"] else {
|
||||
XCTFail("Missing terminalPaneId in goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
app.typeKey("h", modifierFlags: [.command, .control])
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 5.0) { data in
|
||||
data["focusedPaneId"] == expectedTerminalPaneId && data["focusedPanelKind"] == "terminal"
|
||||
},
|
||||
"Expected Cmd+Ctrl+H to focus the terminal pane before zoom. data=\(loadData() ?? [:])"
|
||||
)
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 8.0) { data in
|
||||
data["splitZoomedAfterToggle"] == "true" &&
|
||||
data["browserContainerHiddenAfterToggle"] == "true" &&
|
||||
data["browserVisibleFlagAfterToggle"] == "false"
|
||||
},
|
||||
"Expected Cmd+Shift+Enter zoom-in on the terminal pane to hide the browser portal. data=\(loadData() ?? [:])"
|
||||
)
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 8.0) { data in
|
||||
data["splitZoomedAfterToggle"] == "false" &&
|
||||
data["browserContainerHiddenAfterToggle"] == "false" &&
|
||||
data["browserVisibleFlagAfterToggle"] == "true"
|
||||
},
|
||||
"Expected Cmd+Shift+Enter zoom-out from the terminal pane to restore the browser portal. data=\(loadData() ?? [:])"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdDSplitsRightWhenOmnibarFocused() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
|
|
@ -806,10 +950,25 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
return value.contains(expectedSubstring)
|
||||
}
|
||||
|
||||
private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if element.exists && element.isHittable {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return element.exists && element.isHittable
|
||||
}
|
||||
|
||||
private var autofocusRacePageURL: String {
|
||||
"data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E"
|
||||
}
|
||||
|
||||
private var zoomRoundTripPageURL: String {
|
||||
"data:text/html,%3Ctitle%3EIssue%201144%3C/title%3E%3Cbody%20style%3D%22margin:0;background:%231d1f24;color:white;font-family:system-ui;height:2200px%22%3E%3Cmain%20style%3D%22padding:32px%22%3E%3Ch1%3EIssue%201144%20Regression%20Page%3C/h1%3E%3Cp%3EZoom%20should%20not%20leave%20stale%20split%20chrome%20above%20the%20browser%20omnibar.%3C/p%3E%3C/main%3E%3C/body%3E"
|
||||
}
|
||||
|
||||
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue