Fix terminal blanking after full-surface tab drops

This commit is contained in:
austinpower1258 2026-02-24 16:00:00 -08:00
parent 8df13d10b0
commit f33d65f986
5 changed files with 84 additions and 21 deletions

View file

@ -1825,6 +1825,12 @@ final class TerminalSurface: Identifiable, ObservableObject {
flushPendingTextIfNeeded()
// Kick an initial draw after creation/size setup. On some startup paths Ghostty can
// miss the first vsync callback and sit on a blank frame until another focus/visibility
// transition nudges the renderer.
view.forceRefreshSurface()
ghostty_surface_refresh(createdSurface)
#if DEBUG
let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map {
String(format: "%.2f", $0)
@ -1886,14 +1892,15 @@ final class TerminalSurface: Identifiable, ObservableObject {
/// Force a full size recalculation and surface redraw.
func forceRefresh() {
let hasSurface = surface != nil
let viewState: String
if let view = attachedView {
let inWindow = view.window != nil
let bounds = view.bounds
let metalOK = (view.layer as? CAMetalLayer) != nil
viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK)"
viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)"
} else {
viewState = "NO_ATTACHED_VIEW"
viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)"
}
#if DEBUG
let ts = ISO8601DateFormatter().string(from: Date())
@ -1907,7 +1914,8 @@ final class TerminalSurface: Identifiable, ObservableObject {
FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8))
}
#endif
guard let view = attachedView,
guard let surface,
let view = attachedView,
view.window != nil,
view.bounds.width > 0,
view.bounds.height > 0 else {

View file

@ -8409,20 +8409,20 @@ class TerminalController {
// Socket commands are line-based; allow callers to express control chars with backslash escapes.
let text = unescapeSocketText(raw)
var result = "ERROR: No window"
DispatchQueue.main.sync {
// Like simulate_shortcut, prefer a visible window so debug automation doesn't
// fail during key window transitions.
guard let window = NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first else { return }
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
guard let fr = window.firstResponder else {
result = "ERROR: No first responder"
return
}
var result = "ERROR: No window"
DispatchQueue.main.sync {
// Like simulate_shortcut, prefer a visible window so debug automation doesn't
// fail during key window transitions.
guard let window = NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first else { return }
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
guard let fr = window.firstResponder else {
result = "ERROR: No first responder"
return
}
if let client = fr as? NSTextInputClient {
client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
@ -8430,7 +8430,22 @@ class TerminalController {
return
}
// Fall back to the responder chain insertText action.
// If workspace handoff temporarily leaves a non-terminal first responder,
// route debug typing to the selected terminal's focused panel directly.
if let tabManager,
let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = tab.focusedPanelId,
let terminalPanel = tab.terminalPanel(for: panelId),
!terminalPanel.hostedView.isSurfaceViewFirstResponder() {
// Match Enter semantics expected by tests/debug tooling when bypassing AppKit.
let directText = text.replacingOccurrences(of: "\n", with: "\r")
terminalPanel.surface.sendText(directText)
result = "OK"
return
}
// Fall back to the responder-chain insertText action.
(fr as? NSResponder)?.insertText(text)
result = "OK"
}

View file

@ -1254,6 +1254,11 @@ final class WindowTerminalPortal: NSObject {
)
#endif
hostedView.isHidden = false
// A reveal can happen without any frame delta (same targetFrame), which means the
// normal frame-change refresh path won't run. Nudge geometry + redraw so newly
// revealed terminals don't sit on a stale/blank IOSurface until later focus churn.
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
}
#if DEBUG

View file

@ -2265,7 +2265,11 @@ final class Workspace: Identifiable, ObservableObject {
}
hostedView.reconcileGeometryNow()
terminalPanel.surface.forceRefresh()
if hasSurface {
terminalPanel.surface.forceRefresh()
} else if isAttached && hasUsableBounds {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
}
return needsFollowUpPass
@ -2306,6 +2310,31 @@ final class Workspace: Identifiable, ObservableObject {
}
}
private func scheduleMovedTerminalRefresh(panelId: UUID) {
guard terminalPanel(for: panelId) != nil else { return }
// Force an NSViewRepresentable update after drag/move reparenting. This keeps
// portal host binding current when a pane auto-closes during tab moves.
terminalPanel(for: panelId)?.requestViewReattach()
let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
guard let self, let panel = self.terminalPanel(for: panelId) else { return }
panel.hostedView.reconcileGeometryNow()
if panel.surface.surface != nil {
panel.surface.forceRefresh()
} else {
panel.surface.requestBackgroundSurfaceStartIfNeeded()
}
}
}
// Run once immediately and once on the next turn so rapid split close/reparent
// sequences still get a post-layout redraw.
runRefreshPass(0)
runRefreshPass(0.03)
}
private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) {
for tabId in tabIds {
if skipPinned,
@ -2928,12 +2957,18 @@ extension Workspace: BonsplitDelegate {
)
#endif
applyTabSelection(tabId: tab.id, inPane: destination)
#if DEBUG
let movedPanelIdAfter = panelIdFromSurfaceId(tab.id)
#endif
if let movedPanelId = panelIdFromSurfaceId(tab.id) {
scheduleMovedTerminalRefresh(panelId: movedPanelId)
}
#if DEBUG
let selectedAfter = controller.selectedTab(inPane: destination)
.map { String(String(describing: $0.id).prefix(5)) } ?? "nil"
let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil"
let movedPanelFocused = (movedPanelId != nil && movedPanelId == focusedPanelId) ? 1 : 0
let movedPanelFocused = (movedPanelIdAfter != nil && movedPanelIdAfter == focusedPanelId) ? 1 : 0
dlog(
"split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " +
"destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " +

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit f24ba9222651ecc170869662eec9a5880404a82c
Subproject commit 21db26f8a6a0c7707af10da672c0d7cf07076c66