Fix #2347 terminal focus and surface recovery (#2354)

* Add regressions for issue #2347 terminal focus loss

* Fix issue #2347 terminal focus and surface recovery

* Add regressions for missing-surface recovery review cases

* Fix missing-surface recovery lifecycle and focus replay
This commit is contained in:
Austin Wang 2026-03-30 03:01:39 -07:00 committed by GitHub
parent 29c0f525db
commit ae59e571a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 337 additions and 2 deletions

View file

@ -3394,6 +3394,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
private func allowsRuntimeSurfaceCreation() -> Bool {
portalLifecycleState == .live
}
func beginPortalCloseLifecycle(reason: String) {
guard portalLifecycleState != .closed else { return }
guard portalLifecycleState != .closing else { return }
@ -3467,6 +3471,10 @@ final class TerminalSurface: Identifiable, ObservableObject {
(lastPixelWidth, lastPixelHeight)
}
func debugDesiredFocusState() -> Bool {
desiredFocusState
}
private static func surfaceLog(_ message: String) {
let timestamp = ISO8601DateFormatter().string(from: Date())
let line = "[\(timestamp)] \(message)\n"
@ -3564,6 +3572,15 @@ final class TerminalSurface: Identifiable, ObservableObject {
// If surface doesn't exist yet, create it once the view is in a real window so
// content scale and pixel geometry are derived from the actual backing context.
if surface == nil {
guard allowsRuntimeSurfaceCreation() else {
#if DEBUG
dlog(
"surface.attach.skip surface=\(id.uuidString.prefix(5)) " +
"reason=lifecycle.\(portalLifecycleState.rawValue)"
)
#endif
return
}
guard view.window != nil else {
#if DEBUG
dlog(
@ -3593,6 +3610,18 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
private func createSurface(for view: GhosttyNSView) {
guard allowsRuntimeSurfaceCreation() else {
#if DEBUG
dlog(
"surface.create.skip surface=\(id.uuidString.prefix(5)) " +
"reason=lifecycle.\(portalLifecycleState.rawValue)"
)
Self.surfaceLog(
"createSurface SKIPPED surface=\(id.uuidString) tab=\(tabId.uuidString) lifecycle=\(portalLifecycleState.rawValue)"
)
#endif
return
}
#if DEBUG
let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap { String(cString: $0) } ?? "(unset)"
let terminfo = getenv("TERMINFO").flatMap { String(cString: $0) } ?? "(unset)"
@ -4140,6 +4169,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
return
}
guard allowsRuntimeSurfaceCreation() else { return }
guard surface == nil, attachedView != nil else { return }
guard !backgroundSurfaceStartQueued else { return }
backgroundSurfaceStartQueued = true
@ -4147,6 +4177,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.backgroundSurfaceStartQueued = false
guard self.allowsRuntimeSurfaceCreation() else { return }
guard self.surface == nil, let view = self.attachedView else { return }
#if DEBUG
let startedAt = ProcessInfo.processInfo.systemUptime
@ -5030,6 +5061,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return surface
}
private func requestInputRecoveryAfterSurfaceMiss(reason: String) {
terminalSurface?.requestBackgroundSurfaceStartIfNeeded()
#if DEBUG
dlog(
"focus.input_recovery surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"reason=\(reason) inWindow=\(window != nil ? 1 : 0)"
)
#endif
}
func performBindingAction(_ action: String) -> Bool {
guard let surface = surface else { return false }
return action.withCString { cString in
@ -5460,12 +5501,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
let result = super.resignFirstResponder()
if result {
desiredFocus = false
terminalSurface?.recordExternalFocusState(false)
}
if result, let surface = surface {
let now = CACurrentMediaTime()
let deltaMs = (now - lastScrollEventTime) * 1000
Self.focusLog("resignFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))")
terminalSurface?.recordExternalFocusState(false)
ghostty_surface_set_focus(surface, false)
}
return result
@ -5689,6 +5730,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime
#endif
guard let surface = ensureSurfaceReadyForInput() else {
requestInputRecoveryAfterSurfaceMiss(reason: "keyDown.missingSurface")
#if DEBUG
ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0
#endif
@ -8892,6 +8934,33 @@ final class GhosttySurfaceScrollView: NSView {
func clearSuppressReparentFocus() {
surfaceView.suppressingReparentFocus = false
let hasUsablePortalGeometry: Bool = {
let size = bounds.size
return size.width > 1 && size.height > 1
}()
let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
guard surfaceView.desiredFocus else { return }
guard isSurfaceViewFirstResponder() else { return }
guard isActive else { return }
guard surfaceView.isVisibleInUI else { return }
guard let window, window.isKeyWindow else { return }
guard !isHiddenForFocus, hasUsablePortalGeometry else {
#if DEBUG
dlog(
"focus.reparent.resume.defer surface=\(surfaceShort) " +
"reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " +
"frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))"
)
#endif
scheduleAutomaticFirstResponderApply(reason: "clearSuppressReparentFocus.hiddenOrTiny")
return
}
#if DEBUG
dlog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))")
#endif
reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus")
}
/// Returns true if the terminal's actual Ghostty surface view is (or contains) the window first responder.
@ -8918,6 +8987,9 @@ final class GhosttySurfaceScrollView: NSView {
private func reassertTerminalSurfaceFocus(reason: String) {
guard let terminalSurface = surfaceView.terminalSurface else { return }
if terminalSurface.surface == nil {
terminalSurface.requestBackgroundSurfaceStartIfNeeded()
}
#if DEBUG
dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
#endif

View file

@ -1301,6 +1301,10 @@ final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase {
@MainActor
final class TerminalNotificationDirectInteractionTests: XCTestCase {
private final class FocusProbeView: NSView {
override var acceptsFirstResponder: Bool { true }
}
private func makeWindow() -> NSWindow {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
@ -1497,6 +1501,207 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase {
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1)
}
func testKeyDownRecoversReleasedSurfaceWhileHostedViewIsDetached() throws {
#if DEBUG
let window = makeWindow()
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
hostedView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else {
XCTFail("Expected terminal surface view")
return
}
XCTAssertNotNil(surface.surface, "Expected runtime surface before simulating the detach race")
surface.releaseSurfaceForTesting()
XCTAssertNil(surface.surface, "Expected runtime surface to be released for the regression setup")
hostedView.removeFromSuperview()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertNil(surfaceView.window, "Expected hosted terminal view to be detached from any window")
let event = makeKeyEvent(characters: "a", keyCode: 0, window: window)
surfaceView.keyDown(with: event)
let recovered = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
surface.surface != nil
},
object: NSObject()
)
wait(for: [recovered], timeout: 1.0)
XCTAssertNotNil(
surface.surface,
"Missing-surface keyDown should request background surface recreation instead of leaving terminal input dead"
)
#else
throw XCTSkip("Debug-only regression test")
#endif
}
func testKeyDownRecoveryDoesNotReplayFocusAfterResponderMovesAway() throws {
#if DEBUG
let window = makeWindow()
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
let otherResponder = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
contentView.addSubview(otherResponder)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
hostedView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else {
XCTFail("Expected terminal surface view")
return
}
XCTAssertTrue(window.makeFirstResponder(surfaceView))
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(surface.debugDesiredFocusState(), "Focused terminal should start with desired Ghostty focus")
surface.releaseSurfaceForTesting()
XCTAssertNil(surface.surface, "Expected runtime surface to be released for the regression setup")
hostedView.removeFromSuperview()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertNil(surfaceView.window, "Expected hosted terminal view to be detached from any window")
XCTAssertTrue(
(window.firstResponder as? NSView) === surfaceView,
"Expected the detached Ghostty view to remain the stale first responder during the regression setup"
)
let event = makeKeyEvent(characters: "a", keyCode: 0, window: window)
surfaceView.keyDown(with: event)
XCTAssertTrue(window.makeFirstResponder(otherResponder))
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(
(window.firstResponder as? NSView) === otherResponder,
"Expected focus to move to the replacement responder"
)
XCTAssertFalse(
surface.debugDesiredFocusState(),
"Responder loss after a missing-surface keyDown should clear desired Ghostty focus before recovery completes"
)
let recovered = XCTNSPredicateExpectation(
predicate: NSPredicate { _, _ in
surface.surface != nil
},
object: NSObject()
)
wait(for: [recovered], timeout: 1.0)
XCTAssertNotNil(surface.surface, "Expected missing-surface recovery to still recreate the runtime surface")
XCTAssertFalse(
surface.debugDesiredFocusState(),
"Recovered runtime surface should not restore focus after the pane already lost first responder"
)
#else
throw XCTSkip("Debug-only regression test")
#endif
}
func testKeyDownRecoveryDoesNotRecreateClosedSurface() throws {
#if DEBUG
let window = makeWindow()
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
hostedView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else {
XCTFail("Expected terminal surface view")
return
}
XCTAssertNotNil(surface.surface, "Expected runtime surface before simulating close lifecycle teardown")
surface.beginPortalCloseLifecycle(reason: "test.close")
surface.teardownSurface()
XCTAssertNil(surface.surface, "Teardown should release the runtime surface")
XCTAssertEqual(surface.portalBindingStateLabel(), "closed")
hostedView.removeFromSuperview()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertNil(surfaceView.window, "Expected hosted terminal view to be detached from any window")
let event = makeKeyEvent(characters: "a", keyCode: 0, window: window)
surfaceView.keyDown(with: event)
let drained = expectation(description: "background recovery drained")
DispatchQueue.main.async { drained.fulfill() }
wait(for: [drained], timeout: 1.0)
XCTAssertNil(
surface.surface,
"Missing-surface keyDown should not recreate a Ghostty runtime surface after close lifecycle teardown"
)
#else
throw XCTSkip("Debug-only regression test")
#endif
}
}

View file

@ -1351,6 +1351,64 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase {
"Expected the clicked split pane to become first responder"
)
}
func testClearSuppressReparentFocusReassertsGhosttyFocusForCurrentFirstResponder() throws {
#if DEBUG
let workspace = Workspace()
guard let leftPanelId = workspace.focusedPanelId,
let leftPanel = workspace.terminalPanel(for: leftPanelId),
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
XCTFail("Expected split terminal panels")
return
}
let window = makeWindow()
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220)
rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220)
contentView.addSubview(leftPanel.hostedView)
contentView.addSubview(rightPanel.hostedView)
leftPanel.hostedView.setVisibleInUI(true)
rightPanel.hostedView.setVisibleInUI(true)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
guard let leftSurfaceView = surfaceView(in: leftPanel.hostedView) else {
XCTFail("Expected left terminal surface view")
return
}
leftPanel.surface.setFocus(false)
rightPanel.surface.setFocus(true)
leftPanel.hostedView.suppressReparentFocus()
XCTAssertTrue(window.makeFirstResponder(leftSurfaceView))
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertFalse(
leftPanel.surface.debugDesiredFocusState(),
"Suppressed reparent focus should not immediately flip the Ghostty focus bit"
)
leftPanel.hostedView.clearSuppressReparentFocus()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(
leftPanel.surface.debugDesiredFocusState(),
"Clearing reparent-focus suppression should reassert Ghostty focus when the surface still owns first responder"
)
#else
throw XCTSkip("Debug-only regression test")
#endif
}
}