diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 625bc5a4..cfa3b4a8 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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 } @@ -3459,7 +3463,7 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - #if DEBUG +#if DEBUG private static let surfaceLogPath = "/tmp/cmux-ghostty-surface.log" private static let sizeLogPath = "/tmp/cmux-ghostty-size.log" @@ -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 diff --git a/cmuxTests/TerminalAndGhosttyTests.swift b/cmuxTests/TerminalAndGhosttyTests.swift index 5ff4a9cc..9918602c 100644 --- a/cmuxTests/TerminalAndGhosttyTests.swift +++ b/cmuxTests/TerminalAndGhosttyTests.swift @@ -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 + } } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index c420b49e..8ddb9085 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -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 + } }