mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 03:25:50 +00:00
macos: cancel deferred tab presentation on close (#12119)
The 👻 Ghost Tab Issue https://github.com/user-attachments/assets/cb91cd85-4a08-4c16-9efb-1a9ab30fc2bc Previous failure scenario (User perspective): 1. Open a new tab 2. Instantly trigger close other tabs (eg. through custom user keyboard shortcut) 3. Now you will see an empty Ghost Tab (Only a window bar with empty content) The previous failure mode is: 1. Create a tab or window now in `newTab(...)` / `newWindow(...)`. 2. Queue its initial show/focus work with `DispatchQueue.main.async`. 3. Close that tab or window with `closeTabImmediately()` / `closeWindowImmediately()` before the queued callback runs. 4. The queued callback still runs anyway and calls `showWindow(...)` / `makeKeyAndOrderFront(...)` on stale state. 5. The tab can be resurrected as a half-closed blank ghost tab. The fix: - Store deferred presentation work in a cancellable DispatchWorkItem and cancel it from the close paths before AppKit finishes tearing down the tab or window. - This prevents the stale show/focus callback from running after close. ## AI Usage I used GPT 5.4 to find the initial issue and fix it. I cleaned up and narrowed down the commit afterwards. ----- Additional Notes: I use `cmd+o` to `close_tab:other` https://github.com/jamylak/dotfiles/blob/main/ghostty/config#L106C1-L106C34 Try it for your self if you want to reproduce, just do a quick `cmd+t` `cmd+o` and you will see
This commit is contained in:
@@ -46,6 +46,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
/// changes in the list.
|
||||
private var tabWindowsHash: Int = 0
|
||||
|
||||
/// The initial window presentation is deferred by one runloop turn in a few places so
|
||||
/// AppKit can settle tab/window state first. Close actions must cancel it to avoid
|
||||
/// re-showing a tab that was already closed.
|
||||
private var pendingInitialPresentation: DispatchWorkItem?
|
||||
|
||||
/// This is set to false by init if the window managed by this controller should not be restorable.
|
||||
/// For example, terminals executing custom scripts are not restorable.
|
||||
private var restorable: Bool = true
|
||||
@@ -140,6 +145,27 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
center.removeObserver(self)
|
||||
}
|
||||
|
||||
private func cancelPendingInitialPresentation() {
|
||||
pendingInitialPresentation?.cancel()
|
||||
pendingInitialPresentation = nil
|
||||
}
|
||||
|
||||
private func scheduleInitialPresentation(_ block: @escaping () -> Void) {
|
||||
cancelPendingInitialPresentation()
|
||||
|
||||
var scheduledWorkItem: DispatchWorkItem?
|
||||
scheduledWorkItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.pendingInitialPresentation = nil }
|
||||
guard scheduledWorkItem?.isCancelled == false else { return }
|
||||
block()
|
||||
}
|
||||
|
||||
let workItem = scheduledWorkItem!
|
||||
pendingInitialPresentation = workItem
|
||||
DispatchQueue.main.async(execute: workItem)
|
||||
}
|
||||
|
||||
// MARK: Base Controller Overrides
|
||||
|
||||
override func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
|
||||
@@ -257,7 +283,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// We're dispatching this async because otherwise the lastCascadePoint doesn't
|
||||
// take effect. Our best theory is there is some next-event-loop-tick logic
|
||||
// that Cocoa is doing that we need to be after.
|
||||
DispatchQueue.main.async {
|
||||
c.scheduleInitialPresentation {
|
||||
c.showWindow(self)
|
||||
|
||||
// Only cascade if we aren't fullscreen.
|
||||
@@ -319,7 +345,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// Calculate the target frame based on the tree's view bounds
|
||||
let treeSize: CGSize? = tree.root?.viewBounds()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
c.scheduleInitialPresentation {
|
||||
c.showWindow(self)
|
||||
if let window = c.window {
|
||||
// If we have a tree size, resize the window's content to match
|
||||
@@ -434,7 +460,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// We're dispatching this async because otherwise the lastCascadePoint doesn't
|
||||
// take effect. Our best theory is there is some next-event-loop-tick logic
|
||||
// that Cocoa is doing that we need to be after.
|
||||
DispatchQueue.main.async {
|
||||
controller.scheduleInitialPresentation {
|
||||
// Only cascade if we aren't fullscreen and are alone in the tab group.
|
||||
if !window.styleMask.contains(.fullScreen) &&
|
||||
window.tabGroup?.windows.count ?? 1 == 1 {
|
||||
@@ -650,6 +676,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
return
|
||||
}
|
||||
|
||||
cancelPendingInitialPresentation()
|
||||
|
||||
// Undo
|
||||
if let undoManager, let undoState {
|
||||
// Register undo action to restore the tab
|
||||
@@ -768,6 +796,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
func closeWindowImmediately() {
|
||||
guard let window = window else { return }
|
||||
|
||||
cancelPendingInitialPresentation()
|
||||
|
||||
registerUndoForCloseWindow()
|
||||
|
||||
if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 {
|
||||
@@ -776,6 +806,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// This prevents unnecessary undos registered since AppKit may
|
||||
// process them on later ticks so we can't just disable undo registration.
|
||||
if let controller = window.windowController as? TerminalController {
|
||||
controller.cancelPendingInitialPresentation()
|
||||
controller.surfaceTree = .init()
|
||||
}
|
||||
|
||||
@@ -1142,6 +1173,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
|
||||
override func windowWillClose(_ notification: Notification) {
|
||||
super.windowWillClose(notification)
|
||||
cancelPendingInitialPresentation()
|
||||
self.relabelTabs()
|
||||
|
||||
// If we remove a window, we reset the cascade point to the key window so that
|
||||
|
||||
Reference in New Issue
Block a user