Preserve terminal focus for non-focus split opens

This commit is contained in:
Lawrence Chen 2026-02-22 18:50:01 -08:00
parent 3afa345f3a
commit 4ee6640e35
2 changed files with 84 additions and 2 deletions

View file

@ -960,6 +960,7 @@ final class Workspace: Identifiable, ObservableObject {
isPinned: false
)
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.
@ -989,7 +990,17 @@ final class Workspace: Identifiable, ObservableObject {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
scheduleFocusReconcile()
// 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()
}
}
return newPanel
@ -1089,6 +1100,7 @@ final class Workspace: Identifiable, ObservableObject {
isPinned: false
)
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.
@ -1110,7 +1122,17 @@ final class Workspace: Identifiable, ObservableObject {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
scheduleFocusReconcile()
// 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()
}
}
installBrowserPanelSubscription(browserPanel)

View file

@ -1800,6 +1800,66 @@ final class TabManagerReopenClosedBrowserFocusTests: XCTestCase {
@MainActor
final class WorkspacePanelGitBranchTests: XCTestCase {
private func drainMainQueue() {
let expectation = expectation(description: "drain main queue")
DispatchQueue.main.async {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
}
func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() {
let workspace = Workspace()
guard let originalFocusedPanelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}
guard let browserSplitPanel = workspace.newBrowserSplit(
from: originalFocusedPanelId,
orientation: .horizontal,
focus: false
) else {
XCTFail("Expected browser split panel to be created")
return
}
drainMainQueue()
XCTAssertNotEqual(browserSplitPanel.id, originalFocusedPanelId)
XCTAssertEqual(
workspace.focusedPanelId,
originalFocusedPanelId,
"Expected non-focus browser split to preserve pre-split focus"
)
}
func testTerminalSplitWithFocusFalsePreservesOriginalFocusedPanel() {
let workspace = Workspace()
guard let originalFocusedPanelId = workspace.focusedPanelId else {
XCTFail("Expected initial focused panel")
return
}
guard let terminalSplitPanel = workspace.newTerminalSplit(
from: originalFocusedPanelId,
orientation: .horizontal,
focus: false
) else {
XCTFail("Expected terminal split panel to be created")
return
}
drainMainQueue()
XCTAssertNotEqual(terminalSplitPanel.id, originalFocusedPanelId)
XCTAssertEqual(
workspace.focusedPanelId,
originalFocusedPanelId,
"Expected non-focus terminal split to preserve pre-split focus"
)
}
func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() {
let workspace = Workspace()
guard let firstPanelId = workspace.focusedPanelId else {