Reassert non-focus split focus after delayed callbacks
This commit is contained in:
parent
4ee6640e35
commit
0dbe95b797
2 changed files with 175 additions and 68 deletions
|
|
@ -962,49 +962,43 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
surfaceIdToPanelId[newTab.id] = newPanel.id
|
||||
let previousFocusedPanelId = focusedPanelId
|
||||
|
||||
// Capture the source terminal's hosted view before bonsplit mutates focusedPaneId,
|
||||
// so we can hand it to focusPanel as the "move focus FROM" view.
|
||||
let previousHostedView = focusedTerminalPanel?.hostedView
|
||||
// Capture the source terminal's hosted view before bonsplit mutates focusedPaneId,
|
||||
// so we can hand it to focusPanel as the "move focus FROM" view.
|
||||
let previousHostedView = focusedTerminalPanel?.hostedView
|
||||
|
||||
// Create the split with the new tab already present in the new pane.
|
||||
isProgrammaticSplit = true
|
||||
defer { isProgrammaticSplit = false }
|
||||
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
|
||||
panels.removeValue(forKey: newPanel.id)
|
||||
panelTitles.removeValue(forKey: newPanel.id)
|
||||
surfaceIdToPanelId.removeValue(forKey: newTab.id)
|
||||
return nil
|
||||
}
|
||||
// Create the split with the new tab already present in the new pane.
|
||||
isProgrammaticSplit = true
|
||||
defer { isProgrammaticSplit = false }
|
||||
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
|
||||
panels.removeValue(forKey: newPanel.id)
|
||||
panelTitles.removeValue(forKey: newPanel.id)
|
||||
surfaceIdToPanelId.removeValue(forKey: newTab.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)")
|
||||
dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)")
|
||||
#endif
|
||||
|
||||
// Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting.
|
||||
// Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view,
|
||||
// stealing focus from the new panel and creating model/surface divergence.
|
||||
if focus {
|
||||
previousHostedView?.suppressReparentFocus()
|
||||
focusPanel(newPanel.id, previousHostedView: previousHostedView)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
previousHostedView?.clearSuppressReparentFocus()
|
||||
}
|
||||
} else {
|
||||
// Bonsplit focuses the newly-created pane by default; restore the caller's
|
||||
// pre-split focus context when this split is explicitly non-focus-intent.
|
||||
if let previousFocusedPanelId, panels[previousFocusedPanelId] != nil {
|
||||
previousHostedView?.suppressReparentFocus()
|
||||
focusPanel(previousFocusedPanelId, previousHostedView: previousHostedView)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
previousHostedView?.clearSuppressReparentFocus()
|
||||
}
|
||||
} else {
|
||||
scheduleFocusReconcile()
|
||||
}
|
||||
}
|
||||
// Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting.
|
||||
// Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view,
|
||||
// stealing focus from the new panel and creating model/surface divergence.
|
||||
if focus {
|
||||
previousHostedView?.suppressReparentFocus()
|
||||
focusPanel(newPanel.id, previousHostedView: previousHostedView)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
previousHostedView?.clearSuppressReparentFocus()
|
||||
}
|
||||
} else {
|
||||
preserveFocusAfterNonFocusSplit(
|
||||
preferredPanelId: previousFocusedPanelId,
|
||||
splitPanelId: newPanel.id,
|
||||
previousHostedView: previousHostedView
|
||||
)
|
||||
}
|
||||
|
||||
return newPanel
|
||||
}
|
||||
return newPanel
|
||||
}
|
||||
|
||||
/// Create a new surface (nested tab) in the specified pane with a terminal panel.
|
||||
/// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior),
|
||||
|
|
@ -1102,38 +1096,32 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
surfaceIdToPanelId[newTab.id] = browserPanel.id
|
||||
let previousFocusedPanelId = focusedPanelId
|
||||
|
||||
// Create the split with the browser tab already present.
|
||||
// Mark this split as programmatic so didSplitPane doesn't auto-create a terminal.
|
||||
isProgrammaticSplit = true
|
||||
defer { isProgrammaticSplit = false }
|
||||
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
|
||||
surfaceIdToPanelId.removeValue(forKey: newTab.id)
|
||||
panels.removeValue(forKey: browserPanel.id)
|
||||
panelTitles.removeValue(forKey: browserPanel.id)
|
||||
return nil
|
||||
}
|
||||
// Create the split with the browser tab already present.
|
||||
// Mark this split as programmatic so didSplitPane doesn't auto-create a terminal.
|
||||
isProgrammaticSplit = true
|
||||
defer { isProgrammaticSplit = false }
|
||||
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
|
||||
surfaceIdToPanelId.removeValue(forKey: newTab.id)
|
||||
panels.removeValue(forKey: browserPanel.id)
|
||||
panelTitles.removeValue(forKey: browserPanel.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting.
|
||||
let previousHostedView = focusedTerminalPanel?.hostedView
|
||||
if focus {
|
||||
previousHostedView?.suppressReparentFocus()
|
||||
focusPanel(browserPanel.id)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
previousHostedView?.clearSuppressReparentFocus()
|
||||
}
|
||||
} else {
|
||||
// Bonsplit focuses the newly-created pane by default; restore the caller's
|
||||
// pre-split focus context when this split is explicitly non-focus-intent.
|
||||
if let previousFocusedPanelId, panels[previousFocusedPanelId] != nil {
|
||||
previousHostedView?.suppressReparentFocus()
|
||||
focusPanel(previousFocusedPanelId, previousHostedView: previousHostedView)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
previousHostedView?.clearSuppressReparentFocus()
|
||||
}
|
||||
} else {
|
||||
scheduleFocusReconcile()
|
||||
}
|
||||
}
|
||||
// See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting.
|
||||
let previousHostedView = focusedTerminalPanel?.hostedView
|
||||
if focus {
|
||||
previousHostedView?.suppressReparentFocus()
|
||||
focusPanel(browserPanel.id)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
previousHostedView?.clearSuppressReparentFocus()
|
||||
}
|
||||
} else {
|
||||
preserveFocusAfterNonFocusSplit(
|
||||
preferredPanelId: previousFocusedPanelId,
|
||||
splitPanelId: browserPanel.id,
|
||||
previousHostedView: previousHostedView
|
||||
)
|
||||
}
|
||||
|
||||
installBrowserPanelSubscription(browserPanel)
|
||||
|
||||
|
|
@ -1559,6 +1547,71 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
// MARK: - Focus Management
|
||||
|
||||
private func preserveFocusAfterNonFocusSplit(
|
||||
preferredPanelId: UUID?,
|
||||
splitPanelId: UUID,
|
||||
previousHostedView: GhosttySurfaceScrollView?
|
||||
) {
|
||||
guard let preferredPanelId, panels[preferredPanelId] != nil else {
|
||||
scheduleFocusReconcile()
|
||||
return
|
||||
}
|
||||
|
||||
// Bonsplit splitPane focuses the newly created pane and may emit one delayed
|
||||
// didSelect/didFocus callback. Re-assert focus over multiple turns so model
|
||||
// focus and AppKit first responder stay aligned with non-focus-intent splits.
|
||||
reassertFocusAfterNonFocusSplit(
|
||||
preferredPanelId: preferredPanelId,
|
||||
splitPanelId: splitPanelId,
|
||||
previousHostedView: previousHostedView,
|
||||
allowPreviousHostedView: true
|
||||
)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.reassertFocusAfterNonFocusSplit(
|
||||
preferredPanelId: preferredPanelId,
|
||||
splitPanelId: splitPanelId,
|
||||
previousHostedView: previousHostedView,
|
||||
allowPreviousHostedView: false
|
||||
)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.reassertFocusAfterNonFocusSplit(
|
||||
preferredPanelId: preferredPanelId,
|
||||
splitPanelId: splitPanelId,
|
||||
previousHostedView: previousHostedView,
|
||||
allowPreviousHostedView: false
|
||||
)
|
||||
self.scheduleFocusReconcile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reassertFocusAfterNonFocusSplit(
|
||||
preferredPanelId: UUID,
|
||||
splitPanelId: UUID,
|
||||
previousHostedView: GhosttySurfaceScrollView?,
|
||||
allowPreviousHostedView: Bool
|
||||
) {
|
||||
guard panels[preferredPanelId] != nil else { return }
|
||||
|
||||
if focusedPanelId == splitPanelId {
|
||||
focusPanel(
|
||||
preferredPanelId,
|
||||
previousHostedView: allowPreviousHostedView ? previousHostedView : nil
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard focusedPanelId == preferredPanelId,
|
||||
let terminalPanel = terminalPanel(for: preferredPanelId) else {
|
||||
return
|
||||
}
|
||||
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId)
|
||||
}
|
||||
|
||||
func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) {
|
||||
#if DEBUG
|
||||
let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
|
||||
|
|
|
|||
|
|
@ -1860,6 +1860,60 @@ final class WorkspacePanelGitBranchTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() {
|
||||
let workspace = Workspace()
|
||||
guard let originalFocusedPanelId = workspace.focusedPanelId else {
|
||||
XCTFail("Expected initial focused panel")
|
||||
return
|
||||
}
|
||||
guard let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else {
|
||||
XCTFail("Expected focused pane for initial panel")
|
||||
return
|
||||
}
|
||||
|
||||
guard let browserSplitPanel = workspace.newBrowserSplit(
|
||||
from: originalFocusedPanelId,
|
||||
orientation: .horizontal,
|
||||
focus: false
|
||||
) else {
|
||||
XCTFail("Expected browser split panel to be created")
|
||||
return
|
||||
}
|
||||
guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id),
|
||||
let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id),
|
||||
let splitTab = workspace.bonsplitController
|
||||
.tabs(inPane: splitPaneId)
|
||||
.first(where: { $0.id == splitTabId }) else {
|
||||
XCTFail("Expected split pane/tab mapping")
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate one delayed stale split-selection callback from bonsplit.
|
||||
DispatchQueue.main.async {
|
||||
workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId)
|
||||
}
|
||||
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
drainMainQueue()
|
||||
|
||||
XCTAssertEqual(
|
||||
workspace.focusedPanelId,
|
||||
originalFocusedPanelId,
|
||||
"Expected non-focus split to reassert the pre-split focused panel"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
workspace.bonsplitController.focusedPaneId,
|
||||
originalPaneId,
|
||||
"Expected focused pane to converge back to the pre-split pane"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id,
|
||||
workspace.surfaceIdFromPanelId(originalFocusedPanelId),
|
||||
"Expected selected tab to converge back to the pre-split focused panel"
|
||||
)
|
||||
}
|
||||
|
||||
func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() {
|
||||
let workspace = Workspace()
|
||||
guard let firstPanelId = workspace.focusedPanelId else {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue