From 493b1f53506263c11d763ae19a0ca83f1b8bd0e2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Jun 2025 15:04:11 -0700 Subject: [PATCH 01/27] wip: undo --- macos/Ghostty.xcodeproj/project.pbxproj | 8 ++ macos/Sources/App/macOS/AppDelegate.swift | 10 +++ macos/Sources/App/macOS/MainMenu.xib | 15 ++++ .../Terminal/BaseTerminalController.swift | 74 ++++++++++++++++--- macos/Sources/Helpers/ExpiringTarget.swift | 53 +++++++++++++ .../Extensions/Duration+Extension.swift | 8 ++ 6 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 macos/Sources/Helpers/ExpiringTarget.swift create mode 100644 macos/Sources/Helpers/Extensions/Duration+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 62cb079bf..153ec8e6f 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -62,6 +62,8 @@ A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; }; A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; }; A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; + A586366D2DF25C2500E04A10 /* ExpiringTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */; }; + A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; }; A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; @@ -168,6 +170,8 @@ A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = ""; }; A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = ""; }; A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; + A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringTarget.swift; sourceTree = ""; }; + A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -290,6 +294,7 @@ A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( + A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */, A58636692DF0A98100E04A10 /* Extensions */, A5874D9B2DAD781100E83852 /* Private */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, @@ -432,6 +437,7 @@ isa = PBXGroup; children = ( A586366A2DF0A98900E04A10 /* Array+Extension.swift */, + A586366E2DF25D8300E04A10 /* Duration+Extension.swift */, A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */, A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, @@ -686,6 +692,7 @@ A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, + A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */, A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */, A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, @@ -713,6 +720,7 @@ A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, + A586366D2DF25C2500E04A10 /* ExpiringTarget.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index fd25ef358..d12b2efd2 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -36,6 +36,8 @@ class AppDelegate: NSObject, @IBOutlet private var menuCloseWindow: NSMenuItem? @IBOutlet private var menuCloseAllWindows: NSMenuItem? + @IBOutlet private var menuUndo: NSMenuItem? + @IBOutlet private var menuRedo: NSMenuItem? @IBOutlet private var menuCopy: NSMenuItem? @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPasteSelection: NSMenuItem? @@ -88,6 +90,9 @@ class AppDelegate: NSObject, /// Manages our terminal windows. let terminalManager: TerminalManager + /// The global undo manager for app-level state such as window restoration. + lazy var undoManager = UndoManager() + /// Our quick terminal. This starts out uninitialized and only initializes if used. private var quickController: QuickTerminalController? = nil @@ -393,6 +398,11 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) + // TODO: sync + menuUndo?.keyEquivalent = "z" + menuUndo?.keyEquivalentModifierMask = [.command] + menuRedo?.keyEquivalent = "z" + menuRedo?.keyEquivalentModifierMask = [.command, .shift] syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 828e82bd0..7130d544e 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -40,6 +40,7 @@ + @@ -57,6 +58,7 @@ + @@ -204,6 +206,19 @@ + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index ea849bb4a..cd7ceffbb 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -75,6 +75,13 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// The undo manager for this controller is the undo manager of the window, + /// which we set via the delegate method. + override var undoManager: UndoManager? { + // This should be set via the delegate method windowWillReturnUndoManager + window?.undoManager + } + struct SavedFrame { let window: NSRect let screen: NSRect @@ -261,6 +268,9 @@ class BaseTerminalController: NSWindowController, let oldFocused = focusedSurface let focused = node.contains { $0 == focusedSurface } + // Keep track of the old tree for undo management. + let oldTree = surfaceTree + // Remove the node from the tree surfaceTree = surfaceTree.remove(node) @@ -270,6 +280,32 @@ class BaseTerminalController: NSWindowController, Ghostty.moveFocus(to: nextTarget, from: oldFocused) } } + + // Setup our undo + if let undoManager { + undoManager.setActionName("Close Terminal") + undoManager.registerUndo(withTarget: ExpiringTarget( + with: .seconds(5), + in: undoManager, + )) { [weak self] v in + guard let self else { return } + self.surfaceTree = oldTree + if let oldFocused { + DispatchQueue.main.async { + Ghostty.moveFocus(to: oldFocused, from: self.focusedSurface) + } + } + + undoManager.registerUndo(withTarget: NSObject()) { [weak self] _ in + self?.closeSurface( + node.leftmostLeaf(), + withConfirmation: node.contains { + $0.needsConfirmQuit + } + ) + } + } + } } // MARK: Notifications @@ -346,19 +382,25 @@ class BaseTerminalController: NSWindowController, } @objc private func ghosttyDidCloseSurface(_ notification: Notification) { - // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } + closeSurface( + target, + withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false, + ) + } + + /// Close a surface view, requesting confirmation if necessary. + /// + /// This will also insert the proper undo stack information in. + private func closeSurface( + _ target: Ghostty.SurfaceView, + withConfirmation: Bool = true, + ) { + // The target must be within our tree guard let node = surfaceTree.root?.node(view: target) else { return } - var processAlive = false - if let valueAny = notification.userInfo?["process_alive"] { - if let value = valueAny as? Bool { - processAlive = value - } - } - // If the child process is not alive, then we exit immediately - guard processAlive else { + guard withConfirmation else { removeSurfaceAndMoveFocus(node) return } @@ -405,7 +447,8 @@ class BaseTerminalController: NSWindowController, // Do the split do { - surfaceTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection) + let newTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection) + surfaceTree = newTree } catch { // If splitting fails for any reason (it should not), then we just log // and return. The new view we created will be deinitialized and its @@ -414,6 +457,7 @@ class BaseTerminalController: NSWindowController, return } + // Once we've split, we need to move focus to the new split Ghostty.moveFocus(to: newView, from: oldView) } @@ -732,6 +776,11 @@ class BaseTerminalController: NSWindowController, // MARK: NSWindowController override func windowDidLoad() { + super.windowDidLoad() + + // Setup our undo manager. + + // Everything beyond here is setting up the window guard let window else { return } // If there is a hardcoded title in the configuration, we set that @@ -818,6 +867,11 @@ class BaseTerminalController: NSWindowController, windowFrameDidChange() } + func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } + return appDelegate.undoManager + } + // MARK: First Responder @IBAction func close(_ sender: Any) { diff --git a/macos/Sources/Helpers/ExpiringTarget.swift b/macos/Sources/Helpers/ExpiringTarget.swift new file mode 100644 index 000000000..d24021495 --- /dev/null +++ b/macos/Sources/Helpers/ExpiringTarget.swift @@ -0,0 +1,53 @@ +import AppKit + +/// A target object for UndoManager that automatically expires after a specified duration. +/// +/// ExpiringTarget holds a reference to a target object and removes all undo actions +/// associated with itself from the UndoManager when the timer expires. This is useful +/// for creating temporary undo operations that should not persist beyond a certain time. +/// +/// The parameter T can be used to retain a reference to some target value +/// that can be used in the undo operation. The target is released when the timer expires. +/// +/// - Parameter T: The type of the target object, constrained to AnyObject +class ExpiringTarget { + private(set) var target: T? + private var timer: Timer? + private weak var undoManager: UndoManager? + + /// Creates an expiring target that will automatically remove undo actions after the specified duration. + /// + /// - Parameters: + /// - target: The target object to hold weakly. Defaults to nil. + /// - duration: The time after which the target should expire + /// - undoManager: The UndoManager from which to remove actions when expired + init(_ target: T? = nil, with duration: Duration, in undoManager: UndoManager) { + self.target = target + self.undoManager = undoManager + self.timer = Timer.scheduledTimer( + withTimeInterval: duration.timeInterval, + repeats: false) { _ in + self.expire() + } + } + + /// Manually expires the target, removing all associated undo actions and invalidating the timer. + /// + /// This method is called automatically when the timer fires, but can also be called manually + /// to expire the target before the timer duration has elapsed. + func expire() { + target = nil + undoManager?.removeAllActions(withTarget: self) + timer?.invalidate() + } + + deinit { + expire() + } +} + +extension ExpiringTarget where T == NSObject { + convenience init(with duration: Duration, in undoManager: UndoManager) { + self.init(nil, with: duration, in: undoManager) + } +} diff --git a/macos/Sources/Helpers/Extensions/Duration+Extension.swift b/macos/Sources/Helpers/Extensions/Duration+Extension.swift new file mode 100644 index 000000000..43eca6b79 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/Duration+Extension.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Duration { + var timeInterval: TimeInterval { + return TimeInterval(self.components.seconds) + + TimeInterval(self.components.attoseconds) / 1_000_000_000_000_000_000 + } +} From 6d32b01c6498a4fab34293cd673dc4437798ede8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Jun 2025 21:28:49 -0700 Subject: [PATCH 02/27] macos: implement a custom ExpiringUndoManager, setup undo for new/close --- macos/Ghostty.xcodeproj/project.pbxproj | 8 +- macos/Sources/App/macOS/AppDelegate.swift | 2 +- .../Terminal/BaseTerminalController.swift | 79 ++++++++-- .../Sources/Ghostty/SurfaceView_AppKit.swift | 2 + macos/Sources/Helpers/ExpiringTarget.swift | 53 ------- .../Sources/Helpers/ExpiringUndoManager.swift | 137 ++++++++++++++++++ 6 files changed, 207 insertions(+), 74 deletions(-) delete mode 100644 macos/Sources/Helpers/ExpiringTarget.swift create mode 100644 macos/Sources/Helpers/ExpiringUndoManager.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 153ec8e6f..67f1784ac 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -62,8 +62,8 @@ A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586365E2DEE6C2100E04A10 /* SplitTree.swift */; }; A58636662DEF964100E04A10 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */; }; A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; - A586366D2DF25C2500E04A10 /* ExpiringTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */; }; A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; }; + A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */; }; A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; @@ -170,8 +170,8 @@ A586365E2DEE6C2100E04A10 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitTree.swift; sourceTree = ""; }; A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = ""; }; A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; - A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringTarget.swift; sourceTree = ""; }; A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = ""; }; + A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -294,7 +294,6 @@ A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( - A586366C2DF25C1C00E04A10 /* ExpiringTarget.swift */, A58636692DF0A98100E04A10 /* Extensions */, A5874D9B2DAD781100E83852 /* Private */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, @@ -303,6 +302,7 @@ A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, + A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, @@ -708,6 +708,7 @@ A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, + A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, @@ -720,7 +721,6 @@ A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, - A586366D2DF25C2500E04A10 /* ExpiringTarget.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index d12b2efd2..eae8dd121 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -91,7 +91,7 @@ class AppDelegate: NSObject, let terminalManager: TerminalManager /// The global undo manager for app-level state such as window restoration. - lazy var undoManager = UndoManager() + lazy var undoManager = ExpiringUndoManager() /// Our quick terminal. This starts out uninitialized and only initializes if used. private var quickController: QuickTerminalController? = nil diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index cd7ceffbb..6cc6b2ec8 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -75,11 +75,25 @@ class BaseTerminalController: NSWindowController, /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] + /// The time that undo/redo operations that contain running ptys are valid for. + private var undoExpiration: Duration { + .seconds(5) + } + /// The undo manager for this controller is the undo manager of the window, /// which we set via the delegate method. - override var undoManager: UndoManager? { + override var undoManager: ExpiringUndoManager? { // This should be set via the delegate method windowWillReturnUndoManager - window?.undoManager + if let result = window?.undoManager as? ExpiringUndoManager { + return result + } + + // If the window one isn't set, we fallback to our global one. + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + return appDelegate.undoManager + } + + return nil } struct SavedFrame { @@ -173,7 +187,7 @@ class BaseTerminalController: NSWindowController, deinit { NotificationCenter.default.removeObserver(self) - + undoManager?.removeAllActions(withTarget: self) if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } @@ -284,20 +298,20 @@ class BaseTerminalController: NSWindowController, // Setup our undo if let undoManager { undoManager.setActionName("Close Terminal") - undoManager.registerUndo(withTarget: ExpiringTarget( - with: .seconds(5), - in: undoManager, - )) { [weak self] v in - guard let self else { return } - self.surfaceTree = oldTree + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration) { target in + target.surfaceTree = oldTree if let oldFocused { DispatchQueue.main.async { - Ghostty.moveFocus(to: oldFocused, from: self.focusedSurface) + Ghostty.moveFocus(to: oldFocused, from: target.focusedSurface) } } - undoManager.registerUndo(withTarget: NSObject()) { [weak self] _ in - self?.closeSurface( + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration) { target in + target.closeSurface( node.leftmostLeaf(), withConfirmation: node.contains { $0.needsConfirmQuit @@ -446,9 +460,12 @@ class BaseTerminalController: NSWindowController, let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config) // Do the split + let newTree: SplitTree do { - let newTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection) - surfaceTree = newTree + newTree = try surfaceTree.insert( + view: newView, + at: oldView, + direction: splitDirection) } catch { // If splitting fails for any reason (it should not), then we just log // and return. The new view we created will be deinitialized and its @@ -457,9 +474,36 @@ class BaseTerminalController: NSWindowController, return } + // Keep track of the old tree for undo + let oldTree = surfaceTree - // Once we've split, we need to move focus to the new split - Ghostty.moveFocus(to: newView, from: oldView) + // Setup our new split tree + surfaceTree = newTree + DispatchQueue.main.async { + Ghostty.moveFocus(to: newView, from: oldView) + } + + // Setup our undo + if let undoManager { + undoManager.setActionName("New Split") + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration) { target in + target.surfaceTree = oldTree + DispatchQueue.main.async { + Ghostty.moveFocus(to: oldView, from: target.focusedSurface) + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration) { target in + target.surfaceTree = newTree + DispatchQueue.main.async { + Ghostty.moveFocus(to: newView, from: target.focusedSurface) + } + } + } + } } @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { @@ -836,6 +880,9 @@ class BaseTerminalController: NSWindowController, // the view and the window so we had to nil this out to break it but I think this // may now be resolved. We should verify that no memory leaks and we can remove this. window.contentView = nil + + // Make sure we clean up all our undos + window.undoManager?.removeAllActions(withTarget: self) } func windowDidBecomeKey(_ notification: Notification) { diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 682efa947..6e35f40d1 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -287,6 +287,8 @@ extension Ghostty { if let surface = self.surface { ghostty_surface_free(surface) } + + Ghostty.logger.warning("WOW close") } func focusDidChange(_ focused: Bool) { diff --git a/macos/Sources/Helpers/ExpiringTarget.swift b/macos/Sources/Helpers/ExpiringTarget.swift deleted file mode 100644 index d24021495..000000000 --- a/macos/Sources/Helpers/ExpiringTarget.swift +++ /dev/null @@ -1,53 +0,0 @@ -import AppKit - -/// A target object for UndoManager that automatically expires after a specified duration. -/// -/// ExpiringTarget holds a reference to a target object and removes all undo actions -/// associated with itself from the UndoManager when the timer expires. This is useful -/// for creating temporary undo operations that should not persist beyond a certain time. -/// -/// The parameter T can be used to retain a reference to some target value -/// that can be used in the undo operation. The target is released when the timer expires. -/// -/// - Parameter T: The type of the target object, constrained to AnyObject -class ExpiringTarget { - private(set) var target: T? - private var timer: Timer? - private weak var undoManager: UndoManager? - - /// Creates an expiring target that will automatically remove undo actions after the specified duration. - /// - /// - Parameters: - /// - target: The target object to hold weakly. Defaults to nil. - /// - duration: The time after which the target should expire - /// - undoManager: The UndoManager from which to remove actions when expired - init(_ target: T? = nil, with duration: Duration, in undoManager: UndoManager) { - self.target = target - self.undoManager = undoManager - self.timer = Timer.scheduledTimer( - withTimeInterval: duration.timeInterval, - repeats: false) { _ in - self.expire() - } - } - - /// Manually expires the target, removing all associated undo actions and invalidating the timer. - /// - /// This method is called automatically when the timer fires, but can also be called manually - /// to expire the target before the timer duration has elapsed. - func expire() { - target = nil - undoManager?.removeAllActions(withTarget: self) - timer?.invalidate() - } - - deinit { - expire() - } -} - -extension ExpiringTarget where T == NSObject { - convenience init(with duration: Duration, in undoManager: UndoManager) { - self.init(nil, with: duration, in: undoManager) - } -} diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift new file mode 100644 index 000000000..3eda56182 --- /dev/null +++ b/macos/Sources/Helpers/ExpiringUndoManager.swift @@ -0,0 +1,137 @@ +/// An UndoManager subclass that supports registering undo operations that automatically expire after a specified duration. +/// +/// This class extends the standard UndoManager to add time-based expiration for undo operations. +/// When an undo operation expires, it is automatically removed from the undo stack and cannot be invoked. +/// +/// Example usage: +/// ```swift +/// let undoManager = ExpiringUndoManager() +/// undoManager.registerUndo(withTarget: myObject, expiresAfter: .seconds(30)) { target in +/// // Undo operation that expires after 30 seconds +/// target.restorePreviousState() +/// } +/// ``` +class ExpiringUndoManager: UndoManager { + /// The set of expiring targets so we can properly clean them up when removeAllActions + /// is called with the real target. + private lazy var expiringTargets: Set = [] + + /// Registers an undo operation that automatically expires after the specified duration. + /// + /// - Parameters: + /// - target: The target object for the undo operation. The undo operation will be removed + /// if this object is deallocated before the operation is invoked. + /// - duration: The duration after which the undo operation should expire and be removed from the undo stack. + /// - handler: The closure to execute when the undo operation is invoked. The closure receives + /// the target object as its parameter. + func registerUndo( + withTarget target: TargetType, + expiresAfter duration: Duration, + handler: @escaping (TargetType) -> Void + ) { + let expiringTarget = ExpiringTarget( + target, + expiresAfter: duration, + in: self) + expiringTargets.insert(expiringTarget) + + super.registerUndo(withTarget: expiringTarget) { [weak self] expiringTarget in + self?.expiringTargets.remove(expiringTarget) + guard let target = expiringTarget.target as? TargetType else { return } + handler(target) + } + } + + /// Removes all undo and redo operations from the undo manager. + /// + /// This override ensures that all expiring targets are also cleared when + /// the undo manager is reset. + override func removeAllActions() { + super.removeAllActions() + expiringTargets = [] + } + + /// Removes all undo and redo operations involving the specified target. + /// + /// This override ensures that when actions are removed for a target, any associated + /// expiring targets are also properly cleaned up. + /// + /// - Parameter target: The target object whose actions should be removed. + override func removeAllActions(withTarget target: Any) { + // Call super to handle standard removal + super.removeAllActions(withTarget: target) + + if !(target is ExpiringTarget) { + // Find and remove any ExpiringTarget instances that wrap this target. + expiringTargets + .filter { $0.target == nil || $0.target === (target as AnyObject) } + .forEach { + // Technically they'll always expire when they get deinitialized + // but we want to make sure it happens right now. + $0.expire() + expiringTargets.remove($0) + } + } + } +} + +/// A target object for ExpiringUndoManager that removes itself from the +/// undo manager after it expires. +/// +/// This class acts as a proxy for the real target object in undo operations. +/// It holds a weak reference to the actual target and automatically removes +/// all associated undo operations when either: +/// - The specified duration expires +/// - The ExpiringTarget instance is deallocated +/// - The expire() method is called manually +private class ExpiringTarget { + /// The actual target object for the undo operation, held weakly to avoid retain cycles. + private(set) weak var target: AnyObject? + + /// Timer that triggers expiration after the specified duration. + private var timer: Timer? + + /// The undo manager from which to remove actions when this target expires. + private weak var undoManager: UndoManager? + + /// Creates an expiring target that will automatically remove undo actions after the specified duration. + /// + /// - Parameters: + /// - target: The target object to hold weakly. + /// - duration: The time after which the target should expire. + /// - undoManager: The UndoManager from which to remove actions when expired. + init(_ target: AnyObject? = nil, expiresAfter duration: Duration, in undoManager: UndoManager) { + self.target = target + self.undoManager = undoManager + self.timer = Timer.scheduledTimer( + withTimeInterval: duration.timeInterval, + repeats: false) { [weak self] _ in + self?.expire() + } + } + + /// Manually expires the target, removing all associated undo actions and invalidating the timer. + /// + /// This method is called automatically when the timer fires, but can also be called manually + /// to expire the target before the timer duration has elapsed. + func expire() { + target = nil + undoManager?.removeAllActions(withTarget: self) + timer?.invalidate() + timer = nil + } + + deinit { + expire() + } +} + +extension ExpiringTarget: Hashable, Equatable { + static func == (lhs: ExpiringTarget, rhs: ExpiringTarget) -> Bool { + return lhs === rhs + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} From f571519157bc2eaf3dcf7995989c7d8266a8ddc6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Jun 2025 21:43:41 -0700 Subject: [PATCH 03/27] macos: setup undo responders at the AppDelegate level --- macos/Sources/App/macOS/AppDelegate.swift | 24 +++++++++++++++++++ .../Features/Terminal/TerminalManager.swift | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index eae8dd121..1fce7d665 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -892,6 +892,14 @@ class AppDelegate: NSObject, NSApplication.shared.arrangeInFront(sender) } + @IBAction func undo(_ sender: Any?) { + undoManager.undo() + } + + @IBAction func redo(_ sender: Any?) { + undoManager.redo() + } + private struct DerivedConfig { let initialWindow: Bool let shouldQuitAfterLastWindowClosed: Bool @@ -981,6 +989,22 @@ extension AppDelegate: NSMenuItemValidation { // terminal window (not quick terminal). return NSApp.keyWindow is TerminalWindow + case #selector(undo(_:)): + if undoManager.canUndo { + item.title = "Undo \(undoManager.undoActionName)" + } else { + item.title = "Undo" + } + return undoManager.canUndo + + case #selector(redo(_:)): + if undoManager.canRedo { + item.title = "Redo \(undoManager.redoActionName)" + } else { + item.title = "Redo" + } + return undoManager.canRedo + default: return true } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 805ae6e93..050bc5563 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -228,7 +228,7 @@ class TerminalManager { // Ensure any publishers we have are cancelled w.closePublisher.cancel() - + // If we remove a window, we reset the cascade point to the key window so that // the next window cascade's from that one. if let focusedWindow = NSApplication.shared.keyWindow { From 104cc2adfee94062c735611b0dbacf7332dff58d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 07:52:31 -0700 Subject: [PATCH 04/27] macos: basic undo close window, not very robust yet --- macos/Sources/Features/Splits/SplitTree.swift | 38 ++++++++ .../Terminal/BaseTerminalController.swift | 31 +++--- .../Terminal/TerminalController.swift | 95 ++++++++++++++++++- 3 files changed, 147 insertions(+), 17 deletions(-) diff --git a/macos/Sources/Features/Splits/SplitTree.swift b/macos/Sources/Features/Splits/SplitTree.swift index cbd440124..394cd1089 100644 --- a/macos/Sources/Features/Splits/SplitTree.swift +++ b/macos/Sources/Features/Splits/SplitTree.swift @@ -107,6 +107,18 @@ extension SplitTree { self.init(root: .leaf(view: view), zoomed: nil) } + /// Checks if the tree contains the specified node. + /// + /// Note that SplitTree implements Sequence on views so there's already a `contains` + /// for views too. + /// + /// - Parameter node: The node to search for in the tree + /// - Returns: True if the node exists in the tree, false otherwise + func contains(_ node: Node) -> Bool { + guard let root else { return false } + return root.path(to: node) != nil + } + /// Insert a new view at the given view point by creating a split in the given direction. /// This will always reset the zoomed state of the tree. func insert(view: ViewType, at: ViewType, direction: NewDirection) throws -> Self { @@ -1078,3 +1090,29 @@ extension SplitTree.Node: Sequence { return leaves().makeIterator() } } + +// MARK: SplitTree Collection + +extension SplitTree: Collection { + typealias Index = Int + typealias Element = ViewType + + var startIndex: Int { + return 0 + } + + var endIndex: Int { + return root?.leaves().count ?? 0 + } + + subscript(position: Int) -> ViewType { + precondition(position >= 0 && position < endIndex, "Index out of bounds") + let leaves = root?.leaves() ?? [] + return leaves[position] + } + + func index(after i: Int) -> Int { + precondition(i < endIndex, "Cannot increment index beyond endIndex") + return i + 1 + } +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 6cc6b2ec8..e34a44941 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -76,7 +76,7 @@ class BaseTerminalController: NSWindowController, private var focusedSurfaceCancellables: Set = [] /// The time that undo/redo operations that contain running ptys are valid for. - private var undoExpiration: Duration { + var undoExpiration: Duration { .seconds(5) } @@ -277,7 +277,11 @@ class BaseTerminalController: NSWindowController, } /// Remove a node from the surface tree and move focus appropriately. - private func removeSurfaceAndMoveFocus(_ node: SplitTree.Node) { + /// + /// This also updates the undo manager to support restoring this node. + /// + /// This does no confirmation and assumes confirmation is already done. + private func removeSurfaceNode(_ node: SplitTree.Node) { let nextTarget = findNextFocusTargetAfterClosing(node: node) let oldFocused = focusedSurface let focused = node.contains { $0 == focusedSurface } @@ -311,8 +315,8 @@ class BaseTerminalController: NSWindowController, undoManager.registerUndo( withTarget: target, expiresAfter: target.undoExpiration) { target in - target.closeSurface( - node.leftmostLeaf(), + target.closeSurfaceNode( + node, withConfirmation: node.contains { $0.needsConfirmQuit } @@ -397,25 +401,26 @@ class BaseTerminalController: NSWindowController, @objc private func ghosttyDidCloseSurface(_ notification: Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } - closeSurface( - target, + guard let node = surfaceTree.root?.node(view: target) else { return } + closeSurfaceNode( + node, withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false, ) } - /// Close a surface view, requesting confirmation if necessary. + /// Close a surface node (which may contain splits), requesting confirmation if necessary. /// /// This will also insert the proper undo stack information in. - private func closeSurface( - _ target: Ghostty.SurfaceView, + func closeSurfaceNode( + _ node: SplitTree.Node, withConfirmation: Bool = true, ) { - // The target must be within our tree - guard let node = surfaceTree.root?.node(view: target) else { return } + // This node must be part of our tree + guard surfaceTree.contains(node) else { return } // If the child process is not alive, then we exit immediately guard withConfirmation else { - removeSurfaceAndMoveFocus(node) + removeSurfaceNode(node) return } @@ -429,7 +434,7 @@ class BaseTerminalController: NSWindowController, informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." ) { [weak self] in if let self { - self.removeSurfaceAndMoveFocus(node) + self.removeSurfaceNode(node) } } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index baae90068..554f7699b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -386,6 +386,93 @@ class TerminalController: BaseTerminalController { return frame } + /// This is called anytime a node in the surface tree is being removed. + override func closeSurfaceNode( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // If this isn't the root then we're dealing with a split closure. + if surfaceTree.root != node { + super.closeSurfaceNode(node, withConfirmation: withConfirmation) + return + } + + // More than 1 window means we have tabs and we're closing a tab + if window?.tabGroup?.windows.count ?? 0 > 1 { + closeTab(nil) + return + } + + // 1 window, closing the window + closeWindow(nil) + } + + /// Closes the current window (including any other tabs) immediately and without + /// confirmation. This will setup proper undo state so the action can be undone. + private func closeWindowImmediately(_ sender: Any?) { + guard let window = window else { return } + + // Regardless of tabs vs no tabs, what we want to do here is keep + // track of the window frame to restore, the surface tree, and the + // the focused surface. We want to restore that with undo even + // if we end up closing. + if let undoManager { + // Capture current state for undo + let currentFrame = window.frame + let currentSurfaceTree = surfaceTree + let currentFocusedSurface = focusedSurface + + // Register undo action to restore the window + undoManager.setActionName("Close Window") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration) { ghostty in + + // Create a new window controller with the saved state + let newController = TerminalController( + ghostty, + withSurfaceTree: currentSurfaceTree + ) + + // Show the window and restore its frame + newController.showWindow(nil) + if let newWindow = newController.window { + newWindow.setFrame(currentFrame, display: true) + + // Restore focus to the previously focused surface + if let focusTarget = currentFocusedSurface { + DispatchQueue.main.async { + Ghostty.moveFocus(to: focusTarget, from: nil) + } + } + } + + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration) { target in + // For redo, we close the window again + target.closeWindowImmediately(sender) + } + } + } + + guard let tabGroup = window.tabGroup else { + // No tabs, no tab group, just perform a normal close. + window.close() + return + } + + // If have one window then we just do a normal close + if tabGroup.windows.count == 1 { + window.close() + return + } + + + tabGroup.windows.forEach { $0.close() } + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -635,13 +722,13 @@ class TerminalController: BaseTerminalController { guard let window = window else { return } guard let tabGroup = window.tabGroup else { // No tabs, no tab group, just perform a normal close. - window.performClose(sender) + closeWindowImmediately(sender) return } // If have one window then we just do a normal close if tabGroup.windows.count == 1 { - window.performClose(sender) + closeWindowImmediately(sender) return } @@ -655,7 +742,7 @@ class TerminalController: BaseTerminalController { // If none need confirmation then we can just close all the windows. if !needsConfirm { - tabGroup.windows.forEach { $0.close() } + closeWindowImmediately(sender) return } @@ -663,7 +750,7 @@ class TerminalController: BaseTerminalController { messageText: "Close Window?", informativeText: "All terminal sessions in this window will be terminated." ) { - tabGroup.windows.forEach { $0.close() } + self.closeWindowImmediately(sender) } } From 5f74445b141a14d5a0d8705a38161f242edf1ec6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 11:05:06 -0700 Subject: [PATCH 05/27] macos: basic undo tab, not quite working --- .../Terminal/TerminalController.swift | 163 ++++++++++++------ 1 file changed, 115 insertions(+), 48 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 554f7699b..b7b2fcd89 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -407,52 +407,82 @@ class TerminalController: BaseTerminalController { closeWindow(nil) } - /// Closes the current window (including any other tabs) immediately and without - /// confirmation. This will setup proper undo state so the action can be undone. - private func closeWindowImmediately(_ sender: Any?) { + private func closeTabImmediately() { guard let window = window else { return } - - // Regardless of tabs vs no tabs, what we want to do here is keep - // track of the window frame to restore, the surface tree, and the - // the focused surface. We want to restore that with undo even - // if we end up closing. - if let undoManager { - // Capture current state for undo - let currentFrame = window.frame - let currentSurfaceTree = surfaceTree - let currentFocusedSurface = focusedSurface - - // Register undo action to restore the window - undoManager.setActionName("Close Window") + guard let tabGroup = window.tabGroup, + tabGroup.windows.count > 1 else { + closeWindowImmediately() + return + } + + // Undo + if let undoManager, let undoState { + // Get the current tab index before closing + let tabIndex = tabGroup.windows.firstIndex(of: window) ?? 0 + + // Register undo action to restore the tab + undoManager.setActionName("Close Tab") undoManager.registerUndo( withTarget: ghostty, expiresAfter: undoExpiration) { ghostty in - - // Create a new window controller with the saved state - let newController = TerminalController( - ghostty, - withSurfaceTree: currentSurfaceTree - ) - // Show the window and restore its frame - newController.showWindow(nil) + // Create a new window controller with the saved state + let newController = TerminalController(ghostty, with: undoState) + if let newWindow = newController.window { - newWindow.setFrame(currentFrame, display: true) - - // Restore focus to the previously focused surface - if let focusTarget = currentFocusedSurface { - DispatchQueue.main.async { - Ghostty.moveFocus(to: focusTarget, from: nil) - } + // Add the window back to the tab group at the correct position + if let targetWindow = tabGroup.windows.dropFirst(tabIndex).first { + // Insert after the target window + targetWindow.addTabbedWindow(newWindow, ordered: .above) + } else if let targetWindow = tabGroup.windows.last { + // Add at the end if the original position is beyond current tabs + targetWindow.addTabbedWindow(newWindow, ordered: .above) + } else if let firstWindow = tabGroup.windows.first { + // Fallback: add to the beginning if needed + firstWindow.addTabbedWindow(newWindow, ordered: .below) } + + // Make it the key window + newWindow.makeKeyAndOrderFront(nil) } + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration) { target in + // For redo, we close the tab again + target.closeTabImmediately() + } + } + } + + window.close() + } + + /// Closes the current window (including any other tabs) immediately and without + /// confirmation. This will setup proper undo state so the action can be undone. + private func closeWindowImmediately() { + guard let window = window else { return } + + // Regardless of tabs vs no tabs, what we want to do here is keep + // track of the window frame to restore, the surface tree, and the + // the focused surface. We want to restore that with undo even + // if we end up closing. + if let undoManager, let undoState { + // Register undo action to restore the window + undoManager.setActionName("Close Window") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration) { ghostty in + // Restore the undo state + let newController = TerminalController(ghostty, with: undoState) + // Register redo action undoManager.registerUndo( withTarget: newController, expiresAfter: newController.undoExpiration) { target in // For redo, we close the window again - target.closeWindowImmediately(sender) + target.closeWindowImmediately() } } } @@ -473,6 +503,44 @@ class TerminalController: BaseTerminalController { tabGroup.windows.forEach { $0.close() } } + // MARK: Undo/Redo + + /// The state that we require to recreate a TerminalController from an undo. + struct UndoState { + let frame: NSRect + let surfaceTree: SplitTree + let focusedSurface: UUID? + } + + convenience init(_ ghostty: Ghostty.App, + with undoState: UndoState + ) { + self.init(ghostty, withSurfaceTree: undoState.surfaceTree) + + // Show the window and restore its frame + showWindow(nil) + if let window { + window.setFrame(undoState.frame, display: true) + + // Restore focus to the previously focused surface + if let focusedUUID = undoState.focusedSurface, + let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) { + DispatchQueue.main.async { + Ghostty.moveFocus(to: focusTarget, from: nil) + } + } + } + } + + /// The current undo state for this controller + var undoState: UndoState? { + guard let window else { return nil } + return .init( + frame: window.frame, + surfaceTree: surfaceTree, + focusedSurface: focusedSurface?.uuid) + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -694,23 +762,22 @@ class TerminalController: BaseTerminalController { @IBAction func closeTab(_ sender: Any?) { guard let window = window else { return } - guard window.tabGroup != nil else { - // No tabs, no tab group, just perform a normal close. - window.performClose(sender) + guard window.tabGroup?.windows.count ?? 0 > 1 else { + closeWindow(sender) return } - if surfaceTree.contains(where: { $0.needsConfirmQuit }) { - confirmClose( - messageText: "Close Tab?", - informativeText: "The terminal still has a running process. If you close the tab the process will be killed." - ) { - window.close() - } + guard surfaceTree.contains(where: { $0.needsConfirmQuit }) else { + closeTabImmediately() return } - window.close() + confirmClose( + messageText: "Close Tab?", + informativeText: "The terminal still has a running process. If you close the tab the process will be killed." + ) { + self.closeTabImmediately() + } } @IBAction func returnToDefaultSize(_ sender: Any?) { @@ -722,13 +789,13 @@ class TerminalController: BaseTerminalController { guard let window = window else { return } guard let tabGroup = window.tabGroup else { // No tabs, no tab group, just perform a normal close. - closeWindowImmediately(sender) + closeWindowImmediately() return } // If have one window then we just do a normal close if tabGroup.windows.count == 1 { - closeWindowImmediately(sender) + closeWindowImmediately() return } @@ -742,7 +809,7 @@ class TerminalController: BaseTerminalController { // If none need confirmation then we can just close all the windows. if !needsConfirm { - closeWindowImmediately(sender) + closeWindowImmediately() return } @@ -750,7 +817,7 @@ class TerminalController: BaseTerminalController { messageText: "Close Window?", informativeText: "All terminal sessions in this window will be terminated." ) { - self.closeWindowImmediately(sender) + self.closeWindowImmediately() } } @@ -948,7 +1015,6 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } - struct DerivedConfig { let backgroundColor: Color let macosWindowButtons: Ghostty.MacOSWindowButtons @@ -971,6 +1037,7 @@ class TerminalController: BaseTerminalController { } } +// MARK: NSMenuItemValidation extension TerminalController: NSMenuItemValidation { func validateMenuItem(_ item: NSMenuItem) -> Bool { From e1847da1391b3bc49e3c79a0afcec338a7441c0a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 11:13:47 -0700 Subject: [PATCH 06/27] macos: more robust undo tab that goes back to the same position --- .../Terminal/TerminalController.swift | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index b7b2fcd89..162141d11 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -417,35 +417,13 @@ class TerminalController: BaseTerminalController { // Undo if let undoManager, let undoState { - // Get the current tab index before closing - let tabIndex = tabGroup.windows.firstIndex(of: window) ?? 0 - // Register undo action to restore the tab undoManager.setActionName("Close Tab") undoManager.registerUndo( withTarget: ghostty, expiresAfter: undoExpiration) { ghostty in - - // Create a new window controller with the saved state let newController = TerminalController(ghostty, with: undoState) - if let newWindow = newController.window { - // Add the window back to the tab group at the correct position - if let targetWindow = tabGroup.windows.dropFirst(tabIndex).first { - // Insert after the target window - targetWindow.addTabbedWindow(newWindow, ordered: .above) - } else if let targetWindow = tabGroup.windows.last { - // Add at the end if the original position is beyond current tabs - targetWindow.addTabbedWindow(newWindow, ordered: .above) - } else if let firstWindow = tabGroup.windows.first { - // Fallback: add to the beginning if needed - firstWindow.addTabbedWindow(newWindow, ordered: .below) - } - - // Make it the key window - newWindow.makeKeyAndOrderFront(nil) - } - // Register redo action undoManager.registerUndo( withTarget: newController, @@ -510,6 +488,8 @@ class TerminalController: BaseTerminalController { let frame: NSRect let surfaceTree: SplitTree let focusedSurface: UUID? + let tabIndex: Int? + private(set) weak var tabGroup: NSWindowTabGroup? } convenience init(_ ghostty: Ghostty.App, @@ -522,6 +502,21 @@ class TerminalController: BaseTerminalController { if let window { window.setFrame(undoState.frame, display: true) + // If we have a tab group and index, restore the tab to its original position + if let tabGroup = undoState.tabGroup, + let tabIndex = undoState.tabIndex { + if tabIndex < tabGroup.windows.count { + // Find the window that is currently at that index + let currentWindow = tabGroup.windows[tabIndex] + currentWindow.addTabbedWindow(window, ordered: .below) + } else { + tabGroup.windows.last?.addTabbedWindow(window, ordered: .above) + } + + // Make it the key window + window.makeKeyAndOrderFront(nil) + } + // Restore focus to the previously focused surface if let focusedUUID = undoState.focusedSurface, let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) { @@ -538,7 +533,9 @@ class TerminalController: BaseTerminalController { return .init( frame: window.frame, surfaceTree: surfaceTree, - focusedSurface: focusedSurface?.uuid) + focusedSurface: focusedSurface?.uuid, + tabIndex: window.tabGroup?.windows.firstIndex(of: window), + tabGroup: window.tabGroup) } //MARK: - NSWindowController From b044f4864ae4ad8ab06e3e631a23eb2e5748c0ba Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 11:34:33 -0700 Subject: [PATCH 07/27] add undo/redo keybindings, default them on macOS --- include/ghostty.h | 2 + macos/Sources/App/macOS/AppDelegate.swift | 7 +-- .../Terminal/TerminalController.swift | 6 +-- macos/Sources/Ghostty/Ghostty.App.swift | 48 +++++++++++++++++++ src/Surface.zig | 12 +++++ src/apprt/action.zig | 9 ++++ src/build/mdgen/mdgen.zig | 3 +- src/config/Config.zig | 12 +++++ src/input/Binding.zig | 31 ++++++++++++ src/input/command.zig | 12 +++++ 10 files changed, 132 insertions(+), 10 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6b1625a30..95bd58cd7 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -673,6 +673,8 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_UNDO, + GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES } ghostty_action_tag_e; diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1fce7d665..db332813f 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -398,11 +398,8 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) - // TODO: sync - menuUndo?.keyEquivalent = "z" - menuUndo?.keyEquivalentModifierMask = [.command] - menuRedo?.keyEquivalent = "z" - menuRedo?.keyEquivalentModifierMask = [.command, .shift] + syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo) + syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo) syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 162141d11..ddeb3dada 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -428,8 +428,7 @@ class TerminalController: BaseTerminalController { undoManager.registerUndo( withTarget: newController, expiresAfter: newController.undoExpiration) { target in - // For redo, we close the tab again - target.closeTabImmediately() + target.closeTab(nil) } } } @@ -459,8 +458,7 @@ class TerminalController: BaseTerminalController { undoManager.registerUndo( withTarget: newController, expiresAfter: newController.undoExpiration) { target in - // For redo, we close the window again - target.closeWindowImmediately() + target.closeWindow(nil) } } } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4a9dc0ea6..ba0b95212 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -553,6 +553,12 @@ extension Ghostty { case GHOSTTY_ACTION_CHECK_FOR_UPDATES: checkForUpdates(app) + case GHOSTTY_ACTION_UNDO: + return undo(app, target: target) + + case GHOSTTY_ACTION_REDO: + return redo(app, target: target) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -599,6 +605,48 @@ extension Ghostty { } } + private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { + let undoManager: UndoManager? + switch (target.tag) { + case GHOSTTY_TARGET_APP: + undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + undoManager = surfaceView.undoManager + + default: + assertionFailure() + return false + } + + guard let undoManager, undoManager.canUndo else { return false } + undoManager.undo() + return true + } + + private static func redo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool { + let undoManager: UndoManager? + switch (target.tag) { + case GHOSTTY_TARGET_APP: + undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + undoManager = surfaceView.undoManager + + default: + assertionFailure() + return false + } + + guard let undoManager, undoManager.canRedo else { return false } + undoManager.redo() + return true + } + private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) { switch (target.tag) { case GHOSTTY_TARGET_APP: diff --git a/src/Surface.zig b/src/Surface.zig index 62a0ce549..e53613ac0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4337,6 +4337,18 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .undo => return try self.rt_app.performAction( + .{ .surface = self }, + .undo, + {}, + ), + + .redo => return try self.rt_app.performAction( + .{ .surface = self }, + .redo, + {}, + ), + .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 7866db182..b4c5164c2 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -258,6 +258,13 @@ pub const Action = union(Key) { /// it needs to ring the bell. This is usually a sound or visual effect. ring_bell, + /// Undo the last action. See the "undo" keybinding for more + /// details on what can and cannot be undone. + undo, + + /// Redo the last undone action. + redo, + check_for_updates, /// Sync with: ghostty_action_tag_e @@ -307,6 +314,8 @@ pub const Action = union(Key) { config_change, close_window, ring_bell, + undo, + redo, check_for_updates, }; diff --git a/src/build/mdgen/mdgen.zig b/src/build/mdgen/mdgen.zig index aca230aa5..e7d966323 100644 --- a/src/build/mdgen/mdgen.zig +++ b/src/build/mdgen/mdgen.zig @@ -26,7 +26,7 @@ pub fn genConfig(writer: anytype, cli: bool) !void { \\ ); - @setEvalBranchQuota(3000); + @setEvalBranchQuota(5000); inline for (@typeInfo(Config).@"struct".fields) |field| { if (field.name[0] == '_') continue; @@ -94,6 +94,7 @@ pub fn genKeybindActions(writer: anytype) !void { const info = @typeInfo(KeybindAction); std.debug.assert(info == .@"union"); + @setEvalBranchQuota(5000); inline for (info.@"union".fields) |field| { if (field.name[0] == '_') continue; diff --git a/src/config/Config.zig b/src/config/Config.zig index 14f394559..fdbde692d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4898,6 +4898,18 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'q' }, .mods = .{ .super = true } }, .{ .quit = {} }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true } }, + .{ .undo = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true, .shift = true } }, + .{ .redo = {} }, + .{ .performable = true }, + ); try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } }, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 7818fac1e..52d36c004 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -655,6 +655,35 @@ pub const Action = union(enum) { /// Only implemented on macOS. check_for_updates, + /// Undo the last undoable action for the focused surface or terminal, + /// if possible. This can undo actions such as closing tabs or + /// windows. + /// + /// Not every action in Ghostty can be undone or redone. The list + /// of actions support undo/redo is currently limited to: + /// + /// - New window, close window + /// - New tab, close tab + /// - New split, close split + /// + /// All actions are only undoable/redoable for a limited time. + /// For example, restoring a closed split can only be done for + /// some number of seconds since the split was closed. The exact + /// amount is configured with `TODO`. + /// + /// The undo/redo actions being limited ensures that there is + /// bounded memory usage over time, closed surfaces don't continue running + /// in the background indefinitely, and the keybinds become available + /// for terminal applications to use. + /// + /// Only implemented on macOS. + undo, + + /// Redo the last undoable action for the focused surface or terminal, + /// if possible. See "undo" for more details on what can and cannot + /// be undone or redone. + redo, + /// Quit Ghostty. quit, @@ -991,6 +1020,8 @@ pub const Action = union(enum) { .toggle_secure_input, .toggle_command_palette, .reset_window_size, + .undo, + .redo, .crash, => .surface, diff --git a/src/input/command.zig b/src/input/command.zig index 4a918cff3..94fbf56a5 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -409,6 +409,18 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Check for updates to the application.", }}, + .undo => comptime &.{.{ + .action = .undo, + .title = "Undo", + .description = "Undo the last action.", + }}, + + .redo => comptime &.{.{ + .action = .redo, + .title = "Redo", + .description = "Redo the last undone action.", + }}, + .quit => comptime &.{.{ .action = .quit, .title = "Quit", From 3e02c0cbd5edc1fbbf842a5a603ecf907ad5a187 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 12:12:14 -0700 Subject: [PATCH 08/27] macos: fix an incorrect bindable write during view update --- macos/Sources/Ghostty/SurfaceView.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 46d379b9c..f830da4ef 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -301,8 +301,12 @@ extension Ghostty { if let instant = focusInstant { let d = instant.duration(to: ContinuousClock.now) if (d < .milliseconds(500)) { - // Avoid this size completely. - lastSize = geoSize + // Avoid this size completely. We can't set values during + // view updates so we have to defer this to another tick. + DispatchQueue.main.async { + lastSize = geoSize + } + return true; } } From 49cc88f0d335e4f52c69bbf38e9099c000d26c7d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 12:21:05 -0700 Subject: [PATCH 09/27] macos: configurable undo timeout --- .../Terminal/BaseTerminalController.swift | 2 +- macos/Sources/Ghostty/Ghostty.Config.swift | 8 +++ .../Sources/Helpers/ExpiringUndoManager.swift | 3 + src/config/Config.zig | 66 ++++++++++++++++++- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e34a44941..6ea56f693 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -77,7 +77,7 @@ class BaseTerminalController: NSWindowController, /// The time that undo/redo operations that contain running ptys are valid for. var undoExpiration: Duration { - .seconds(5) + ghostty.config.undoTimeout } /// The undo manager for this controller is the undo manager of the window, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 3acb93c25..fcbea2a12 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -506,6 +506,14 @@ extension Ghostty { return v; } + var undoTimeout: Duration { + guard let config = self.config else { return .seconds(5) } + var v: UInt = 0 + let key = "undo-timeout" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return .milliseconds(v) + } + var autoUpdate: AutoUpdate? { guard let config = self.config else { return nil } var v: UnsafePointer? = nil diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift index 3eda56182..9a9349cf3 100644 --- a/macos/Sources/Helpers/ExpiringUndoManager.swift +++ b/macos/Sources/Helpers/ExpiringUndoManager.swift @@ -29,6 +29,9 @@ class ExpiringUndoManager: UndoManager { expiresAfter duration: Duration, handler: @escaping (TargetType) -> Void ) { + // Ignore instantly expiring undos + guard duration.timeInterval > 0 else { return } + let expiringTarget = ExpiringTarget( target, expiresAfter: duration, diff --git a/src/config/Config.zig b/src/config/Config.zig index fdbde692d..e1d5b548e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1705,6 +1705,52 @@ keybind: Keybinds = .{}, /// window is ever created. Only implemented on Linux and macOS. @"initial-window": bool = true, +/// The duration that undo operations remain available. After this +/// time, the operation will be removed from the undo stack and +/// cannot be undone. +/// +/// The default value is 5 seconds. +/// +/// This timeout applies per operation, meaning that if you perform +/// multiple operations, each operation will have its own timeout. +/// New operations do not reset the timeout of previous operations. +/// +/// A timeout of zero will effectively disable undo operations. It is +/// not possible to set an infinite timeout, but you can set a very +/// large timeout to effectively disable the timeout (on the order of years). +/// This is highly discouraged, as it will cause the undo stack to grow +/// indefinitely, memory usage to grow unbounded, and terminal sessions +/// to never actually quit. +/// +/// The duration is specified as a series of numbers followed by time units. +/// Whitespace is allowed between numbers and units. Each number and unit will +/// be added together to form the total duration. +/// +/// The allowed time units are as follows: +/// +/// * `y` - 365 SI days, or 8760 hours, or 31536000 seconds. No adjustments +/// are made for leap years or leap seconds. +/// * `d` - one SI day, or 86400 seconds. +/// * `h` - one hour, or 3600 seconds. +/// * `m` - one minute, or 60 seconds. +/// * `s` - one second. +/// * `ms` - one millisecond, or 0.001 second. +/// * `us` or `µs` - one microsecond, or 0.000001 second. +/// * `ns` - one nanosecond, or 0.000000001 second. +/// +/// Examples: +/// * `1h30m` +/// * `45s` +/// +/// Units can be repeated and will be added together. This means that +/// `1h1h` is equivalent to `2h`. This is confusing and should be avoided. +/// A future update may disallow this. +/// +/// This configuration is only supported on macOS. Linux doesn't +/// support undo operations at all so this configuration has no +/// effect. +@"undo-timeout": Duration = .{ .duration = 5 * std.time.ns_per_s }, + /// The position of the "quick" terminal window. To learn more about the /// quick terminal, see the documentation for the `toggle_quick_terminal` /// binding action. @@ -6583,7 +6629,7 @@ pub const Duration = struct { if (remaining.len == 0) break; // Find the longest number - const number = number: { + const number: u64 = number: { var prev_number: ?u64 = null; var prev_remaining: ?[]const u8 = null; for (1..remaining.len + 1) |index| { @@ -6597,8 +6643,17 @@ pub const Duration = struct { break :number prev_number; } orelse return error.InvalidValue; - // A number without a unit is invalid - if (remaining.len == 0) return error.InvalidValue; + // A number without a unit is invalid unless the number is + // exactly zero. In that case, the unit is unambiguous since + // its all the same. + if (remaining.len == 0) { + if (number == 0) { + value = 0; + break; + } + + return error.InvalidValue; + } // Find the longest matching unit. Needs to be the longest matching // to distinguish 'm' from 'ms'. @@ -6808,6 +6863,11 @@ test "parse duration" { try std.testing.expectEqual(unit.factor, d.duration); } + { + const d = try Duration.parseCLI("0"); + try std.testing.expectEqual(@as(u64, 0), d.duration); + } + { const d = try Duration.parseCLI("100ns"); try std.testing.expectEqual(@as(u64, 100), d.duration); From d2d38520261c1ba9dc51ec7a787b3518077f4890 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 12:28:48 -0700 Subject: [PATCH 10/27] macos: remove debug log --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 6e35f40d1..682efa947 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -287,8 +287,6 @@ extension Ghostty { if let surface = self.surface { ghostty_surface_free(surface) } - - Ghostty.logger.warning("WOW close") } func focusDidChange(_ focused: Bool) { From 966c4f98c7da9bb286342c385d8e28796f376d4e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 12:39:02 -0700 Subject: [PATCH 11/27] apprt/glfw,gtk: noop undo/redo actions --- src/apprt/glfw.zig | 2 ++ src/apprt/gtk/App.zig | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index d67567aee..924737074 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -250,6 +250,8 @@ pub const App = struct { .reset_window_size, .ring_bell, .check_for_updates, + .undo, + .redo, .show_gtk_inspector, => { log.info("unimplemented action={}", .{action}); diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index d69102bda..099a051a4 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -515,6 +515,8 @@ pub fn performAction( .color_change, .reset_window_size, .check_for_updates, + .undo, + .redo, => { log.warn("unimplemented action={}", .{action}); return false; From 5507ec0fc0199d3442065a54e8c737759eb5d084 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 12:48:23 -0700 Subject: [PATCH 12/27] macos: compile errors in CI --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 5 ++--- .../Sources/Helpers/Extensions/NSApplication+Extension.swift | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 6ea56f693..129aeb1e2 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -404,8 +404,7 @@ class BaseTerminalController: NSWindowController, guard let node = surfaceTree.root?.node(view: target) else { return } closeSurfaceNode( node, - withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false, - ) + withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false) } /// Close a surface node (which may contain splits), requesting confirmation if necessary. @@ -413,7 +412,7 @@ class BaseTerminalController: NSWindowController, /// This will also insert the proper undo stack information in. func closeSurfaceNode( _ node: SplitTree.Node, - withConfirmation: Bool = true, + withConfirmation: Bool = true ) { // This node must be part of our tree guard surfaceTree.contains(node) else { return } diff --git a/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift index d8e41523a..0bc79fb6a 100644 --- a/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSApplication+Extension.swift @@ -1,3 +1,4 @@ +import AppKit import Cocoa // MARK: Presentation Options From 3b77a16b63448eb1a1764ba82c0e945969c2d819 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 13:35:31 -0700 Subject: [PATCH 13/27] Make undo/redo app-targeted so it works with no windows --- src/App.zig | 3 +++ src/Surface.zig | 27 +++++++++++++++------------ src/input/Binding.zig | 4 ++-- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/App.zig b/src/App.zig index 39db2e2f9..3bbeff2c8 100644 --- a/src/App.zig +++ b/src/App.zig @@ -446,6 +446,9 @@ pub fn performAction( .toggle_visibility => _ = try rt_app.performAction(.app, .toggle_visibility, {}), .check_for_updates => _ = try rt_app.performAction(.app, .check_for_updates, {}), .show_gtk_inspector => _ = try rt_app.performAction(.app, .show_gtk_inspector, {}), + .undo => _ = try rt_app.performAction(.app, .undo, {}), + + .redo => _ = try rt_app.performAction(.app, .redo, {}), } } diff --git a/src/Surface.zig b/src/Surface.zig index e53613ac0..9ab7234d6 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3923,6 +3923,21 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .parent = self }, ), + // Undo and redo both support both surface and app targeting. + // If we are triggering on a surface then we perform the + // action with the surface target. + .undo => return try self.rt_app.performAction( + .{ .surface = self }, + .undo, + {}, + ), + + .redo => return try self.rt_app.performAction( + .{ .surface = self }, + .redo, + {}, + ), + else => try self.app.performAction( self.rt_app, action.scoped(.app).?, @@ -4337,18 +4352,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), - .undo => return try self.rt_app.performAction( - .{ .surface = self }, - .undo, - {}, - ), - - .redo => return try self.rt_app.performAction( - .{ .surface = self }, - .redo, - {}, - ), - .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 52d36c004..ca3fd9790 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -980,6 +980,8 @@ pub const Action = union(enum) { // These are app but can be special-cased in a surface context. .new_window, + .undo, + .redo, => .app, // Obviously surface actions. @@ -1020,8 +1022,6 @@ pub const Action = union(enum) { .toggle_secure_input, .toggle_command_palette, .reset_window_size, - .undo, - .redo, .crash, => .surface, From 33d128bcff2ee529359a844bde50c3aa9dfed460 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 15:19:05 -0700 Subject: [PATCH 14/27] macos: remove TerminalManager All logic related to TerminalController is now in TerminalController. --- macos/Ghostty.xcodeproj/project.pbxproj | 4 - macos/Sources/App/macOS/AppDelegate.swift | 61 ++- .../Features/Services/ServiceProvider.swift | 5 +- .../Terminal/BaseTerminalController.swift | 2 +- .../Terminal/TerminalController.swift | 223 ++++++++++- .../Features/Terminal/TerminalManager.swift | 372 ------------------ .../Terminal/TerminalRestorable.swift | 6 +- 7 files changed, 268 insertions(+), 405 deletions(-) delete mode 100644 macos/Sources/Features/Terminal/TerminalManager.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 67f1784ac..7da727fbb 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -71,7 +71,6 @@ A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; }; A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; }; A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; }; - A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; }; A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; }; @@ -179,7 +178,6 @@ A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = ""; }; A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = ""; }; A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = ""; }; @@ -467,7 +465,6 @@ isa = PBXGroup; children = ( A59630992AEE1C6400D64628 /* Terminal.xib */, - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, @@ -710,7 +707,6 @@ C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, - A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index db332813f..aacf8f651 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -87,9 +87,6 @@ class AppDelegate: NSObject, /// The ghostty global state. Only one per process. let ghostty: Ghostty.App = Ghostty.App() - /// Manages our terminal windows. - let terminalManager: TerminalManager - /// The global undo manager for app-level state such as window restoration. lazy var undoManager = ExpiringUndoManager() @@ -119,7 +116,6 @@ class AppDelegate: NSObject, } override init() { - terminalManager = TerminalManager(ghostty) updaterController = SPUStandardUpdaterController( // Important: we must not start the updater here because we need to read our configuration // first to determine whether we're automatically checking, downloading, etc. The updater @@ -202,6 +198,16 @@ class AppDelegate: NSObject, name: .ghosttyBellDidRing, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyNewWindow(_:)), + name: Ghostty.Notification.ghosttyNewWindow, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyNewTab(_:)), + name: Ghostty.Notification.ghosttyNewTab, + object: nil) // Configure user notifications let actions = [ @@ -253,8 +259,8 @@ class AppDelegate: NSObject, // is possible to have other windows in a few scenarios: // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state - if terminalManager.windows.count == 0 && derivedConfig.initialWindow { - terminalManager.newWindow() + if TerminalController.all.isEmpty && derivedConfig.initialWindow { + _ = TerminalController.newWindow(ghostty) } } } @@ -339,10 +345,10 @@ class AppDelegate: NSObject, // This is possible with flag set to false if there a race where the // window is still initializing and is not visible but the user clicked // the dock icon. - guard terminalManager.windows.count == 0 else { return true } + guard TerminalController.all.isEmpty else { return true } // No visible windows, open a new one. - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) return false } @@ -358,16 +364,17 @@ class AppDelegate: NSObject, var config = Ghostty.SurfaceConfiguration() if (isDirectory.boolValue) { - // When opening a directory, create a new tab in the main window with that as the working directory. + // When opening a directory, create a new tab in the main + // window with that as the working directory. // If no windows exist, a new one will be created. config.workingDirectory = filename - terminalManager.newTab(withBaseConfig: config) + _ = TerminalController.newTab(ghostty, withBaseConfig: config) } else { // When opening a file, open a new window with that file as the command, // and its parent directory as the working directory. config.command = filename config.workingDirectory = (filename as NSString).deletingLastPathComponent - terminalManager.newWindow(withBaseConfig: config) + _ = TerminalController.newWindow(ghostty, withBaseConfig: config) } return true @@ -456,10 +463,6 @@ class AppDelegate: NSObject, menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers) } - private func focusedSurface() -> ghostty_surface_t? { - return terminalManager.focusedSurface?.surface - } - // MARK: Notifications and Events /// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get @@ -592,6 +595,22 @@ class AppDelegate: NSObject, } } + @objc private func ghosttyNewWindow(_ notification: Notification) { + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + _ = TerminalController.newWindow(ghostty, withBaseConfig: config) + } + + @objc private func ghosttyNewTab(_ notification: Notification) { + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard let window = surfaceView.window else { return } + + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + + _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config) + } + private func setDockBadge(_ label: String? = "•") { NSApp.dockTile.badgeLabel = label NSApp.dockTile.display() @@ -627,7 +646,7 @@ class AppDelegate: NSObject, // Config could change keybindings, so update everything that depends on that syncMenuShortcuts(config) - terminalManager.relabelAllTabs() + TerminalController.all.forEach { $0.relabelTabs() } // Config could change window appearance. We wrap this in an async queue because when // this is called as part of application launch it can deadlock with an internal @@ -756,8 +775,8 @@ class AppDelegate: NSObject, //MARK: - GhosttyAppDelegate func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { - for c in terminalManager.windows { - for view in c.controller.surfaceTree { + for c in TerminalController.all { + for view in c.surfaceTree { if view.uuid == uuid { return view } @@ -811,7 +830,7 @@ class AppDelegate: NSObject, } @IBAction func newWindow(_ sender: Any?) { - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -819,7 +838,7 @@ class AppDelegate: NSObject, } @IBAction func newTab(_ sender: Any?) { - terminalManager.newTab() + _ = TerminalController.newTab(ghostty) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -827,7 +846,7 @@ class AppDelegate: NSObject, } @IBAction func closeAllWindows(_ sender: Any?) { - terminalManager.closeAllWindows() + TerminalController.closeAllWindows() AboutController.shared.hide() } diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index 043f5d704..f60f94211 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -32,7 +32,6 @@ class ServiceProvider: NSObject { error: AutoreleasingUnsafeMutablePointer ) { guard let delegate = NSApp.delegate as? AppDelegate else { return } - let terminalManager = delegate.terminalManager guard let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else { error.pointee = Self.errorNoString @@ -53,10 +52,10 @@ class ServiceProvider: NSObject { switch (target) { case .window: - terminalManager.newWindow(withBaseConfig: config) + _ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config) case .tab: - terminalManager.newTab(withBaseConfig: config) + _ = TerminalController.newTab(delegate.ghostty, withBaseConfig: config) } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 129aeb1e2..e4b42c3a1 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -412,7 +412,7 @@ class BaseTerminalController: NSWindowController, /// This will also insert the proper undo stack information in. func closeSurfaceNode( _ node: SplitTree.Node, - withConfirmation: Bool = true + withConfirmation: Bool = true, ) { // This node must be part of our tree guard surfaceTree.contains(node) else { return } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index ddeb3dada..3210eda0c 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -32,7 +32,8 @@ class TerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: SplitTree? = nil + withSurfaceTree tree: SplitTree? = nil, + parent: NSWindow? = nil ) { // The window we manage is not restorable if we've specified a command // to execute. We do this because the restored window is meaningless at the @@ -137,6 +138,159 @@ class TerminalController: BaseTerminalController { syncAppearance(focusedSurface.derivedConfig) } + // MARK: Terminal Creation + + /// Returns all the available terminal controllers present in the app currently. + static var all: [TerminalController] { + return NSApplication.shared.windows.compactMap { + $0.windowController as? TerminalController + } + } + + // Keep track of the last point that our window was launched at so that new + // windows "cascade" over each other and don't just launch directly on top + // of each other. + private static var lastCascadePoint = NSPoint(x: 0, y: 0) + + // The preferred parent terminal controller. + private static var preferredParent: TerminalController? { + all.first { + $0.window?.isMainWindow ?? false + } ?? all.last + } + + /// The "new window" action. + static func newWindow( + _ ghostty: Ghostty.App, + withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil, + withParent explicitParent: NSWindow? = nil + ) -> TerminalController { + let c = TerminalController.init(ghostty, withBaseConfig: baseConfig) + + // Get our parent. Our parent is the one explicitly given to us, + // otherwise the focused terminal, otherwise an arbitrary one. + let parent: NSWindow? = explicitParent ?? preferredParent?.window + + if let parent { + if parent.styleMask.contains(.fullScreen) { + parent.toggleFullScreen(nil) + } else if ghostty.config.windowFullscreen { + switch (ghostty.config.windowFullscreenMode) { + case .native: + // Native has to be done immediately so that our stylemask contains + // fullscreen for the logic later in this method. + c.toggleFullscreen(mode: .native) + + case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: + // If we're non-native then we have to do it on a later loop + // so that the content view is setup. + DispatchQueue.main.async { + c.toggleFullscreen(mode: ghostty.config.windowFullscreenMode) + } + } + } + } + + // 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 { + // Only cascade if we aren't fullscreen. + if let window = c.window { + if (!window.styleMask.contains(.fullScreen)) { + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + } + } + + c.showWindow(self) + } + + return c + } + + static func newTab( + _ ghostty: Ghostty.App, + from parent: NSWindow? = nil, + withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil + ) -> TerminalController? { + // Making sure that we're dealing with a TerminalController. If not, + // then we just create a new window. + guard let parent, + let parentController = parent.windowController as? TerminalController else { + return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent) + } + + // If our parent is in non-native fullscreen, then new tabs do not work. + // See: https://github.com/mitchellh/ghostty/issues/392 + if let fullscreenStyle = parentController.fullscreenStyle, + fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { + let alert = NSAlert() + alert.messageText = "Cannot Create New Tab" + alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again." + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.beginSheetModal(for: parent) + return nil + } + + // Create a new window and add it to the parent + let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig) + guard let window = controller.window else { return controller } + + // If the parent is miniaturized, then macOS exhibits really strange behaviors + // so we have to bring it back out. + if (parent.isMiniaturized) { parent.deminiaturize(self) } + + // If our parent tab group already has this window, macOS added it and + // we need to remove it so we can set the correct order in the next line. + // If we don't do this, macOS gets really confused and the tabbedWindows + // state becomes incorrect. + // + // At the time of writing this code, the only known case this happens + // is when the "+" button is clicked in the tab bar. + if let tg = parent.tabGroup, + tg.windows.firstIndex(of: window) != nil { + tg.removeWindow(window) + } + + // Our windows start out invisible. We need to make it visible. If we + // don't do this then various features such as window blur won't work because + // the macOS APIs only work on a visible window. + controller.showWindow(self) + + // If we have the "hidden" titlebar style we want to create new + // tabs as windows instead, so just skip adding it to the parent. + if (ghostty.config.macosTitlebarStyle != "hidden") { + // Add the window to the tab group and show it. + switch ghostty.config.windowNewTabPosition { + case "end": + // If we already have a tab group and we want the new tab to open at the end, + // then we use the last window in the tab group as the parent. + if let last = parent.tabGroup?.windows.last { + last.addTabbedWindow(window, ordered: .above) + } else { + fallthrough + } + + case "current": fallthrough + default: + parent.addTabbedWindow(window, ordered: .above) + } + } + + window.makeKeyAndOrderFront(self) + + // It takes an event loop cycle until the macOS tabGroup state becomes + // consistent which causes our tab labeling to be off when the "+" button + // is used in the tab bar. This fixes that. If we can find a more robust + // solution we should do that. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + controller.relabelTabs() + } + + return controller + } + //MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { @@ -479,6 +633,44 @@ class TerminalController: BaseTerminalController { tabGroup.windows.forEach { $0.close() } } + /// Close all windows, asking for confirmation if necessary. + static func closeAllWindows() { + let needsConfirm: Bool = all.contains { + $0.surfaceTree.contains { $0.needsConfirmQuit } + } + + if (!needsConfirm) { + closeAllWindowsImmediately() + return + } + + // If we don't have a main window, we just close all windows because + // we have no window to show the modal on top of. I'm sure there's a way + // to do an app-level alert but I don't know how and this case should never + // really happen. + guard let alertWindow = preferredParent?.window else { + closeAllWindowsImmediately() + return + } + + // If we need confirmation by any, show one confirmation for all windows + let alert = NSAlert() + alert.messageText = "Close All Windows?" + alert.informativeText = "All terminal sessions will be terminated." + alert.addButton(withTitle: "Close All Windows") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: alertWindow, completionHandler: { response in + if (response == .alertFirstButtonReturn) { + closeAllWindowsImmediately() + } + }) + } + + static private func closeAllWindowsImmediately() { + all.forEach { $0.close() } + } + // MARK: Undo/Redo /// The state that we require to recreate a TerminalController from an undo. @@ -709,6 +901,35 @@ class TerminalController: BaseTerminalController { override func windowWillClose(_ notification: Notification) { super.windowWillClose(notification) self.relabelTabs() + + // If we remove a window, we reset the cascade point to the key window so that + // the next window cascade's from that one. + if let focusedWindow = NSApplication.shared.keyWindow { + // If we are NOT the focused window, then we are a tabbed window. If we + // are closing a tabbed window, we want to set the cascade point to be + // the next cascade point from this window. + if focusedWindow != window { + // The cascadeTopLeft call below should NOT move the window. Starting with + // macOS 15, we found that specifically when used with the new window snapping + // features of macOS 15, this WOULD move the frame. So we keep track of the + // old frame and restore it if necessary. Issue: + // https://github.com/ghostty-org/ghostty/issues/2565 + let oldFrame = focusedWindow.frame + + Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) + + if focusedWindow.frame != oldFrame { + focusedWindow.setFrame(oldFrame, display: true) + } + + return + } + + // If we are the focused window, then we set the last cascade point to + // our own frame so that it shows up in the same spot. + let frame = focusedWindow.frame + Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) + } } override func windowDidBecomeKey(_ notification: Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift deleted file mode 100644 index 050bc5563..000000000 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ /dev/null @@ -1,372 +0,0 @@ -import Cocoa -import SwiftUI -import GhosttyKit -import Combine - -/// Manages a set of terminal windows. This is effectively an array of TerminalControllers. -/// This abstraction helps manage tabs and multi-window scenarios. -class TerminalManager { - struct Window { - let controller: TerminalController - let closePublisher: AnyCancellable - } - - let ghostty: Ghostty.App - - /// The currently focused surface of the main window. - var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface } - - /// The set of windows we currently have. - var windows: [Window] = [] - - // Keep track of the last point that our window was launched at so that new - // windows "cascade" over each other and don't just launch directly on top - // of each other. - private static var lastCascadePoint = NSPoint(x: 0, y: 0) - - /// Returns the main window of the managed window stack. If there is no window - /// then an arbitrary window will be chosen. - private var mainWindow: Window? { - for window in windows { - if (window.controller.window?.isMainWindow ?? false) { - return window - } - } - - // If we have no main window, just use the last window. - return windows.last - } - - /// The configuration derived from the Ghostty config so we don't need to rely on references. - private var derivedConfig: DerivedConfig - - init(_ ghostty: Ghostty.App) { - self.ghostty = ghostty - self.derivedConfig = DerivedConfig(ghostty.config) - - let center = NotificationCenter.default - center.addObserver( - self, - selector: #selector(onNewTab), - name: Ghostty.Notification.ghosttyNewTab, - object: nil) - center.addObserver( - self, - selector: #selector(onNewWindow), - name: Ghostty.Notification.ghosttyNewWindow, - object: nil) - center.addObserver( - self, - selector: #selector(ghosttyConfigDidChange(_:)), - name: .ghosttyConfigDidChange, - object: nil) - } - - deinit { - let center = NotificationCenter.default - center.removeObserver(self) - } - - // MARK: - Window Management - - /// Create a new terminal window. - func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { - let c = createWindow(withBaseConfig: base) - let window = c.window! - - // If the previous focused window was native fullscreen, the new window also - // becomes native fullscreen. - if let parent = focusedSurface?.window, - parent.styleMask.contains(.fullScreen) { - window.toggleFullScreen(nil) - } else if derivedConfig.windowFullscreen { - switch (derivedConfig.windowFullscreenMode) { - case .native: - // Native has to be done immediately so that our stylemask contains - // fullscreen for the logic later in this method. - c.toggleFullscreen(mode: .native) - - case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: - // If we're non-native then we have to do it on a later loop - // so that the content view is setup. - DispatchQueue.main.async { - c.toggleFullscreen(mode: self.derivedConfig.windowFullscreenMode) - } - } - } - - // All new_window actions force our app to be active. - NSApp.activate(ignoringOtherApps: true) - - // 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 { - // Only cascade if we aren't fullscreen. - if (!window.styleMask.contains(.fullScreen)) { - Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) - } - - c.showWindow(self) - } - } - - /// Creates a new tab in the current main window. If there are no windows, a window - /// is created. - func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { - // If there is no main window, just create a new window - guard let parent = mainWindow?.controller.window else { - newWindow(withBaseConfig: base) - return - } - - // Create a new window and add it to the parent - newTab(to: parent, withBaseConfig: base) - } - - private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) { - // Making sure that we're dealing with a TerminalController - guard parent.windowController is TerminalController else { return } - - // If our parent is in non-native fullscreen, then new tabs do not work. - // See: https://github.com/mitchellh/ghostty/issues/392 - if let controller = parent.windowController as? TerminalController, - let fullscreenStyle = controller.fullscreenStyle, - fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { - let alert = NSAlert() - alert.messageText = "Cannot Create New Tab" - alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again." - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.beginSheetModal(for: parent) - return - } - - // Create a new window and add it to the parent - let controller = createWindow(withBaseConfig: base) - let window = controller.window! - - // If the parent is miniaturized, then macOS exhibits really strange behaviors - // so we have to bring it back out. - if (parent.isMiniaturized) { parent.deminiaturize(self) } - - // If our parent tab group already has this window, macOS added it and - // we need to remove it so we can set the correct order in the next line. - // If we don't do this, macOS gets really confused and the tabbedWindows - // state becomes incorrect. - // - // At the time of writing this code, the only known case this happens - // is when the "+" button is clicked in the tab bar. - if let tg = parent.tabGroup, tg.windows.firstIndex(of: window) != nil { - tg.removeWindow(window) - } - - // Our windows start out invisible. We need to make it visible. If we - // don't do this then various features such as window blur won't work because - // the macOS APIs only work on a visible window. - controller.showWindow(self) - - // If we have the "hidden" titlebar style we want to create new - // tabs as windows instead, so just skip adding it to the parent. - if (derivedConfig.macosTitlebarStyle != "hidden") { - // Add the window to the tab group and show it. - switch derivedConfig.windowNewTabPosition { - case "end": - // If we already have a tab group and we want the new tab to open at the end, - // then we use the last window in the tab group as the parent. - if let last = parent.tabGroup?.windows.last { - last.addTabbedWindow(window, ordered: .above) - } else { - fallthrough - } - case "current": fallthrough - default: - parent.addTabbedWindow(window, ordered: .above) - - } - } - - window.makeKeyAndOrderFront(self) - - // It takes an event loop cycle until the macOS tabGroup state becomes - // consistent which causes our tab labeling to be off when the "+" button - // is used in the tab bar. This fixes that. If we can find a more robust - // solution we should do that. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { controller.relabelTabs() } - } - - /// Creates a window controller, adds it to our managed list, and returns it. - func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: SplitTree? = nil) -> TerminalController { - // Initialize our controller to load the window - let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree) - - // Create a listener for when the window is closed so we can remove it. - let pubClose = NotificationCenter.default.publisher( - for: NSWindow.willCloseNotification, - object: c.window! - ).sink { notification in - guard let window = notification.object as? NSWindow else { return } - guard let c = window.windowController as? TerminalController else { return } - self.removeWindow(c) - } - - // Keep track of every window we manage - windows.append(Window( - controller: c, - closePublisher: pubClose - )) - - return c - } - - func removeWindow(_ controller: TerminalController) { - // Remove it from our managed set - guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return } - let w = self.windows[idx] - self.windows.remove(at: idx) - - // Ensure any publishers we have are cancelled - w.closePublisher.cancel() - - // If we remove a window, we reset the cascade point to the key window so that - // the next window cascade's from that one. - if let focusedWindow = NSApplication.shared.keyWindow { - // If we are NOT the focused window, then we are a tabbed window. If we - // are closing a tabbed window, we want to set the cascade point to be - // the next cascade point from this window. - if focusedWindow != controller.window { - // The cascadeTopLeft call below should NOT move the window. Starting with - // macOS 15, we found that specifically when used with the new window snapping - // features of macOS 15, this WOULD move the frame. So we keep track of the - // old frame and restore it if necessary. Issue: - // https://github.com/ghostty-org/ghostty/issues/2565 - let oldFrame = focusedWindow.frame - - Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) - - if focusedWindow.frame != oldFrame { - focusedWindow.setFrame(oldFrame, display: true) - } - - return - } - - // If we are the focused window, then we set the last cascade point to - // our own frame so that it shows up in the same spot. - let frame = focusedWindow.frame - Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) - } - - // I don't think we strictly have to do this but if a window is - // closed I want to make sure that the app state is invalided so - // we don't reopen closed windows. - NSApplication.shared.invalidateRestorableState() - } - - /// Close all windows, asking for confirmation if necessary. - func closeAllWindows() { - var needsConfirm: Bool = false - for w in self.windows { - if w.controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) { - needsConfirm = true - break - } - } - - if (!needsConfirm) { - for w in self.windows { - w.controller.close() - } - - return - } - - // If we don't have a main window, we just close all windows because - // we have no window to show the modal on top of. I'm sure there's a way - // to do an app-level alert but I don't know how and this case should never - // really happen. - guard let alertWindow = mainWindow?.controller.window else { - for w in self.windows { - w.controller.close() - } - - return - } - - // If we need confirmation by any, show one confirmation for all windows - let alert = NSAlert() - alert.messageText = "Close All Windows?" - alert.informativeText = "All terminal sessions will be terminated." - alert.addButton(withTitle: "Close All Windows") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: alertWindow, completionHandler: { response in - if (response == .alertFirstButtonReturn) { - for w in self.windows { - w.controller.close() - } - } - }) - } - - /// Relabels all the tabs with the proper keyboard shortcut. - func relabelAllTabs() { - for w in windows { - w.controller.relabelTabs() - } - } - - // MARK: - Notifications - - @objc private func onNewWindow(notification: SwiftUI.Notification) { - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? Ghostty.SurfaceConfiguration - self.newWindow(withBaseConfig: config) - } - - @objc private func onNewTab(notification: SwiftUI.Notification) { - guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard let window = surfaceView.window else { return } - - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? Ghostty.SurfaceConfiguration - - self.newTab(to: window, withBaseConfig: config) - } - - @objc private func ghosttyConfigDidChange(_ notification: Notification) { - // We only care if the configuration is a global configuration, not a - // surface-specific one. - guard notification.object == nil else { return } - - // Get our managed configuration object out - guard let config = notification.userInfo?[ - Notification.Name.GhosttyConfigChangeKey - ] as? Ghostty.Config else { return } - - // Update our derived config - self.derivedConfig = DerivedConfig(config) - } - - private struct DerivedConfig { - let windowFullscreen: Bool - let windowFullscreenMode: FullscreenMode - let macosTitlebarStyle: String - let windowNewTabPosition: String - - init() { - self.windowFullscreen = false - self.windowFullscreenMode = .native - self.macosTitlebarStyle = "transparent" - self.windowNewTabPosition = "" - } - - init(_ config: Ghostty.Config) { - self.windowFullscreen = config.windowFullscreen - self.windowFullscreenMode = config.windowFullscreenMode - self.macosTitlebarStyle = config.macosTitlebarStyle - self.windowNewTabPosition = config.windowNewTabPosition - } - } -} diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 5229dc46e..9d9b7ffb1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -83,9 +83,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // can be found for events from libghostty. This uses the low-level // createWindow so that AppKit can place the window wherever it should // be. - let c = appDelegate.terminalManager.createWindow( - withSurfaceTree: state.surfaceTree - ) + let c = TerminalController.init( + appDelegate.ghostty, + withSurfaceTree: state.surfaceTree) guard let window = c.window else { completionHandler(nil, TerminalRestoreError.windowDidNotLoad) return From 797c10af37aa71c0e36c569d6d47faa761f1614b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 15:50:30 -0700 Subject: [PATCH 15/27] macos: undo new window --- .../Terminal/TerminalController.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 3210eda0c..f90490c3f 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -205,6 +205,27 @@ class TerminalController: BaseTerminalController { c.showWindow(self) } + // Setup our undo + if let undoManager = c.undoManager { + undoManager.setActionName("New Window") + undoManager.registerUndo( + withTarget: c, + expiresAfter: c.undoExpiration) { target in + // Close the window when undoing + target.closeWindow(nil) + + // Register redo action + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration) { ghostty in + _ = TerminalController.newWindow( + ghostty, + withBaseConfig: baseConfig, + withParent: explicitParent) + } + } + } + return c } From 636b1fff8a4d0ca43ef1794791ff582dc6a6e111 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 15:56:17 -0700 Subject: [PATCH 16/27] macos: initial window shouldn't support undo --- macos/Sources/App/macOS/AppDelegate.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index aacf8f651..013e89f58 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -260,7 +260,9 @@ class AppDelegate: NSObject, // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state if TerminalController.all.isEmpty && derivedConfig.initialWindow { + undoManager.disableUndoRegistration() _ = TerminalController.newWindow(ghostty) + undoManager.enableUndoRegistration() } } } From d92db73f25110bd5de145ce50cb99a9925cbc8f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 15:59:22 -0700 Subject: [PATCH 17/27] macos: undo new tab --- .../Terminal/TerminalController.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f90490c3f..7aa8d5285 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -309,6 +309,27 @@ class TerminalController: BaseTerminalController { controller.relabelTabs() } + // Setup our undo + if let undoManager = parentController.undoManager { + undoManager.setActionName("New Tab") + undoManager.registerUndo( + withTarget: controller, + expiresAfter: controller.undoExpiration) { target in + // Close the tab when undoing + target.closeTab(nil) + + // Register redo action + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration) { ghostty in + _ = TerminalController.newTab( + ghostty, + from: parent, + withBaseConfig: baseConfig) + } + } + } + return controller } From aeede903f50ce6224c94efe1cdc338b6b8ac9f56 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Jun 2025 16:03:20 -0700 Subject: [PATCH 18/27] macos: undo close all windows --- macos/Sources/Features/Terminal/TerminalController.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7aa8d5285..907109e1c 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -710,7 +710,11 @@ class TerminalController: BaseTerminalController { } static private func closeAllWindowsImmediately() { - all.forEach { $0.close() } + let undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + undoManager?.beginUndoGrouping() + all.forEach { $0.closeWindowImmediately() } + undoManager?.setActionName("Close All Windows") + undoManager?.endUndoGrouping() } // MARK: Undo/Redo From 396e53244d998a7b6097f256a0af1daccf9e62d3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 06:57:11 -0700 Subject: [PATCH 19/27] config: add super+shift+t as a default undo too to mimic browsers --- src/config/Config.zig | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index e1d5b548e..2df66ba45 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -4944,6 +4944,25 @@ pub const Keybinds = struct { .{ .key = .{ .unicode = 'q' }, .mods = .{ .super = true } }, .{ .quit = {} }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } }, + .{ .clear_screen = {} }, + .{ .performable = true }, + ); + try self.set.put( + alloc, + .{ .key = .{ .unicode = 'a' }, .mods = .{ .super = true } }, + .{ .select_all = {} }, + ); + + // Undo/redo + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 't' }, .mods = .{ .super = true, .shift = true } }, + .{ .undo = {} }, + .{ .performable = true }, + ); try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true } }, @@ -4956,17 +4975,6 @@ pub const Keybinds = struct { .{ .redo = {} }, .{ .performable = true }, ); - try self.set.putFlags( - alloc, - .{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } }, - .{ .clear_screen = {} }, - .{ .performable = true }, - ); - try self.set.put( - alloc, - .{ .key = .{ .unicode = 'a' }, .mods = .{ .super = true } }, - .{ .select_all = {} }, - ); // Viewport scrolling try self.set.put( From b234cb20140fc2287799496fe7b4ad13d5deb94a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 07:01:08 -0700 Subject: [PATCH 20/27] macos: only process reopen if already activated --- macos/Sources/App/macOS/AppDelegate.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 013e89f58..e5b35037e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -349,6 +349,11 @@ class AppDelegate: NSObject, // the dock icon. guard TerminalController.all.isEmpty else { return true } + // If the application isn't active yet then we don't want to process + // this because we're not ready. This happens sometimes in Xcode runs + // but I haven't seen it happen in releases. I'm unsure why. + guard applicationHasBecomeActive else { return true } + // No visible windows, open a new one. _ = TerminalController.newWindow(ghostty) return false From 973a2afdde103c302c690f2e41e16ab53a4a86fa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 07:11:30 -0700 Subject: [PATCH 21/27] macos: make sure we're not registering unnecessary undos --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + .../Terminal/BaseTerminalController.swift | 103 ++++++++---------- .../Terminal/TerminalController.swift | 32 ++++-- .../Sources/Helpers/ExpiringUndoManager.swift | 10 +- .../Extensions/UndoManager+Extension.swift | 20 ++++ 5 files changed, 102 insertions(+), 67 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/UndoManager+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 7da727fbb..9686dcbd1 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; }; A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366E2DF25D8300E04A10 /* Duration+Extension.swift */; }; A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */; }; + A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A58636722DF4813000E04A10 /* UndoManager+Extension.swift */; }; A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; @@ -171,6 +172,7 @@ A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = ""; }; A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = ""; }; A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = ""; }; + A58636722DF4813000E04A10 /* UndoManager+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UndoManager+Extension.swift"; sourceTree = ""; }; A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -447,6 +449,7 @@ C1F26EA62B738B9900404083 /* NSView+Extension.swift */, A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, + A58636722DF4813000E04A10 /* UndoManager+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */, ); path = Extensions; @@ -683,6 +686,7 @@ A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, + A58636732DF4813400E04A10 /* UndoManager+Extension.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index e4b42c3a1..06cecf651 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -260,8 +260,8 @@ class BaseTerminalController: NSWindowController, self.alert = alert } - // MARK: Focus Management - + // MARK: Split Tree Management + /// Find the next surface to focus when a node is being closed. /// Goes to previous split unless we're the leftmost leaf, then goes to next. private func findNextFocusTargetAfterClosing(node: SplitTree.Node) -> Ghostty.SurfaceView? { @@ -282,45 +282,63 @@ class BaseTerminalController: NSWindowController, /// /// This does no confirmation and assumes confirmation is already done. private func removeSurfaceNode(_ node: SplitTree.Node) { - let nextTarget = findNextFocusTargetAfterClosing(node: node) - let oldFocused = focusedSurface - let focused = node.contains { $0 == focusedSurface } - - // Keep track of the old tree for undo management. - let oldTree = surfaceTree - - // Remove the node from the tree - surfaceTree = surfaceTree.remove(node) - // Move focus if the closed surface was focused and we have a next target - if let nextTarget, focused { + let nextFocus: Ghostty.SurfaceView? = if node.contains( + where: { $0 == focusedSurface } + ) { + findNextFocusTargetAfterClosing(node: node) + } else { + nil + } + + replaceSurfaceTree( + surfaceTree.remove(node), + moveFocusTo: nextFocus, + moveFocusFrom: focusedSurface, + undoAction: "Close Terminal" + ) + } + + private func replaceSurfaceTree( + _ newTree: SplitTree, + moveFocusTo newView: Ghostty.SurfaceView? = nil, + moveFocusFrom oldView: Ghostty.SurfaceView? = nil, + undoAction: String? = nil + ) { + // Setup our new split tree + let oldTree = surfaceTree + surfaceTree = newTree + if let newView { DispatchQueue.main.async { - Ghostty.moveFocus(to: nextTarget, from: oldFocused) + Ghostty.moveFocus(to: newView, from: oldView) } } // Setup our undo if let undoManager { - undoManager.setActionName("Close Terminal") + if let undoAction { + undoManager.setActionName(undoAction) + } undoManager.registerUndo( withTarget: self, - expiresAfter: undoExpiration) { target in + expiresAfter: undoExpiration + ) { target in target.surfaceTree = oldTree - if let oldFocused { + if let oldView { DispatchQueue.main.async { - Ghostty.moveFocus(to: oldFocused, from: target.focusedSurface) + Ghostty.moveFocus(to: oldView, from: target.focusedSurface) } } undoManager.registerUndo( withTarget: target, - expiresAfter: target.undoExpiration) { target in - target.closeSurfaceNode( - node, - withConfirmation: node.contains { - $0.needsConfirmQuit - } - ) + expiresAfter: target.undoExpiration + ) { target in + target.replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: target.focusedSurface, + undoAction: undoAction) } } } @@ -478,36 +496,11 @@ class BaseTerminalController: NSWindowController, return } - // Keep track of the old tree for undo - let oldTree = surfaceTree - - // Setup our new split tree - surfaceTree = newTree - DispatchQueue.main.async { - Ghostty.moveFocus(to: newView, from: oldView) - } - - // Setup our undo - if let undoManager { - undoManager.setActionName("New Split") - undoManager.registerUndo( - withTarget: self, - expiresAfter: undoExpiration) { target in - target.surfaceTree = oldTree - DispatchQueue.main.async { - Ghostty.moveFocus(to: oldView, from: target.focusedSurface) - } - - undoManager.registerUndo( - withTarget: target, - expiresAfter: target.undoExpiration) { target in - target.surfaceTree = newTree - DispatchQueue.main.async { - Ghostty.moveFocus(to: newView, from: target.focusedSurface) - } - } - } - } + replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: oldView, + undoAction: "New Split") } @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 907109e1c..244f8720d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -210,14 +210,18 @@ class TerminalController: BaseTerminalController { undoManager.setActionName("New Window") undoManager.registerUndo( withTarget: c, - expiresAfter: c.undoExpiration) { target in + expiresAfter: c.undoExpiration + ) { target in // Close the window when undoing - target.closeWindow(nil) + undoManager.disableUndoRegistration { + target.closeWindow(nil) + } // Register redo action undoManager.registerUndo( withTarget: ghostty, - expiresAfter: target.undoExpiration) { ghostty in + expiresAfter: target.undoExpiration + ) { ghostty in _ = TerminalController.newWindow( ghostty, withBaseConfig: baseConfig, @@ -314,14 +318,18 @@ class TerminalController: BaseTerminalController { undoManager.setActionName("New Tab") undoManager.registerUndo( withTarget: controller, - expiresAfter: controller.undoExpiration) { target in + expiresAfter: controller.undoExpiration + ) { target in // Close the tab when undoing - target.closeTab(nil) - + undoManager.disableUndoRegistration { + target.closeTab(nil) + } + // Register redo action undoManager.registerUndo( withTarget: ghostty, - expiresAfter: target.undoExpiration) { ghostty in + expiresAfter: target.undoExpiration + ) { ghostty in _ = TerminalController.newTab( ghostty, from: parent, @@ -617,14 +625,16 @@ class TerminalController: BaseTerminalController { undoManager.setActionName("Close Tab") undoManager.registerUndo( withTarget: ghostty, - expiresAfter: undoExpiration) { ghostty in + expiresAfter: undoExpiration + ) { ghostty in let newController = TerminalController(ghostty, with: undoState) // Register redo action undoManager.registerUndo( withTarget: newController, - expiresAfter: newController.undoExpiration) { target in - target.closeTab(nil) + expiresAfter: newController.undoExpiration + ) { target in + target.closeTabImmediately() } } } @@ -654,7 +664,7 @@ class TerminalController: BaseTerminalController { undoManager.registerUndo( withTarget: newController, expiresAfter: newController.undoExpiration) { target in - target.closeWindow(nil) + target.closeWindowImmediately() } } } diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift index 9a9349cf3..5fde0e870 100644 --- a/macos/Sources/Helpers/ExpiringUndoManager.swift +++ b/macos/Sources/Helpers/ExpiringUndoManager.swift @@ -32,6 +32,11 @@ class ExpiringUndoManager: UndoManager { // Ignore instantly expiring undos guard duration.timeInterval > 0 else { return } + // Ignore when undo registration is disabled. UndoManager still lets + // registration happen then cancels later but I was seeing some + // weird behavior with this so let's just guard on it. + guard self.isUndoRegistrationEnabled else { return } + let expiringTarget = ExpiringTarget( target, expiresAfter: duration, @@ -64,7 +69,10 @@ class ExpiringUndoManager: UndoManager { // Call super to handle standard removal super.removeAllActions(withTarget: target) - if !(target is ExpiringTarget) { + // If the target is an expiring target, remove it. + if let expiring = target as? ExpiringTarget { + expiringTargets.remove(expiring) + } else { // Find and remove any ExpiringTarget instances that wrap this target. expiringTargets .filter { $0.target == nil || $0.target === (target as AnyObject) } diff --git a/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift b/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift new file mode 100644 index 000000000..6c7c1e9f1 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/UndoManager+Extension.swift @@ -0,0 +1,20 @@ +import Foundation + +extension UndoManager { + /// A Boolean value that indicates whether the undo manager is currently performing + /// either an undo or redo operation. + var isUndoingOrRedoing: Bool { + isUndoing || isRedoing + } + + /// Temporarily disables undo registration while executing the provided handler. + /// + /// This method provides a convenient way to perform operations without recording them + /// in the undo stack. It ensures that undo registration is properly re-enabled even + /// if the handler throws an error. + func disableUndoRegistration(handler: () -> Void) { + disableUndoRegistration() + handler() + enableUndoRegistration() + } +} From 20744f0482e1369039b3b565bd782a4bad786e8d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 12:22:37 -0700 Subject: [PATCH 22/27] macos: fix some CI build issues --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 06cecf651..594a58056 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -430,7 +430,7 @@ class BaseTerminalController: NSWindowController, /// This will also insert the proper undo stack information in. func closeSurfaceNode( _ node: SplitTree.Node, - withConfirmation: Bool = true, + withConfirmation: Bool = true ) { // This node must be part of our tree guard surfaceTree.contains(node) else { return } From 6e77a5a6ca05c1416a1c19c5c61b76566574ea71 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 13:07:05 -0700 Subject: [PATCH 23/27] macos: address quick terminal basic functionality with new API --- include/ghostty.h | 1 + macos/Sources/App/macOS/AppDelegate.swift | 4 ++ .../QuickTerminalController.swift | 58 +++++++++++++++---- .../Sources/Ghostty/SurfaceView_AppKit.swift | 6 ++ src/apprt/embedded.zig | 5 ++ 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 95bd58cd7..9f17d0b97 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -786,6 +786,7 @@ ghostty_app_t ghostty_surface_app(ghostty_surface_t); ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t); void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t); bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); +bool ghostty_surface_process_exited(ghostty_surface_t); void ghostty_surface_refresh(ghostty_surface_t); void ghostty_surface_draw(ghostty_surface_t); void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index e5b35037e..7fb52a025 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -612,6 +612,10 @@ class AppDelegate: NSObject, guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } guard let window = surfaceView.window else { return } + // We only want to listen to new tabs if the focused parent is + // a regular terminal controller. + guard window.windowController is TerminalController else { return } + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] let config = configAny as? Ghostty.SurfaceConfiguration diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 8c86c2531..ce5f07616 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -61,6 +61,12 @@ class QuickTerminalController: BaseTerminalController { selector: #selector(ghosttyConfigDidChange(_:)), name: .ghosttyConfigDidChange, object: nil) + center.addObserver( + self, + selector: #selector(closeWindow(_:)), + name: .ghosttyCloseWindow, + object: nil + ) center.addObserver( self, selector: #selector(onNewTab), @@ -198,16 +204,38 @@ class QuickTerminalController: BaseTerminalController { // If our surface tree is nil then we animate the window out. if (to.isEmpty) { - // Save the current window frame before animating out. This preserves - // the user's preferred window size and position for when the quick - // terminal is reactivated with a new surface. Without this, SwiftUI - // would reset the window to its minimum content size. - lastClosedFrame = window?.frame - animateOut() } } + override func closeSurfaceNode( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // If this isn't the root then we're dealing with a split closure. + if surfaceTree.root != node { + super.closeSurfaceNode(node, withConfirmation: withConfirmation) + return + } + + // If this isn't a final leaf then we're dealing with a split closure + guard case .leaf(let surface) = node else { + super.closeSurfaceNode(node, withConfirmation: withConfirmation) + return + } + + // If its the root, we check if the process exited. If it did, + // then we do empty the tree. + if surface.processExited { + surfaceTree = .init() + return + } + + // If its the root then we just animate out. We never actually allow + // the surface to fully close. + animateOut() + } + // MARK: Methods func toggle() { @@ -252,12 +280,6 @@ class QuickTerminalController: BaseTerminalController { let view = Ghostty.SurfaceView(ghostty_app, baseConfig: nil) surfaceTree = SplitTree(view: view) focusedSurface = view - - // Restore our previous frame if we have one - if let lastClosedFrame { - window.setFrame(lastClosedFrame, display: false) - self.lastClosedFrame = nil - } } // Animate the window in @@ -283,6 +305,12 @@ class QuickTerminalController: BaseTerminalController { private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = derivedConfig.quickTerminalScreen.screen else { return } + // Restore our previous frame if we have one + if let lastClosedFrame { + window.setFrame(lastClosedFrame, display: false) + self.lastClosedFrame = nil + } + // Move our window off screen to the top position.setInitial(in: window, on: screen) @@ -393,6 +421,12 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { + // Save the current window frame before animating out. This preserves + // the user's preferred window size and position for when the quick + // terminal is reactivated with a new surface. Without this, SwiftUI + // would reset the window to its minimum content size. + lastClosedFrame = window.frame + // If we hid the dock then we unhide it. hiddenDock = nil diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 682efa947..ea9a8c61b 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -92,6 +92,12 @@ extension Ghostty { return ghostty_surface_needs_confirm_quit(surface) } + // Retruns true if the process in this surface has exited. + var processExited: Bool { + guard let surface = self.surface else { return true } + return ghostty_surface_process_exited(surface) + } + // Returns the inspector instance for this surface, or nil if the // surface has been closed. var inspector: ghostty_inspector_t? { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 67aeeaf7c..5334c8ecd 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1359,6 +1359,11 @@ pub const CAPI = struct { return surface.core_surface.needsConfirmQuit(); } + /// Returns true if the surface process has exited. + export fn ghostty_surface_process_exited(surface: *Surface) bool { + return surface.core_surface.child_exited; + } + /// Returns true if the surface has a selection. export fn ghostty_surface_has_selection(surface: *Surface) bool { return surface.core_surface.hasSelection(); From 6f6d493763f9fb77c9105578e6a748d014390b73 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Jun 2025 13:13:57 -0700 Subject: [PATCH 24/27] macos: show quick terminal on undo/redo --- .../QuickTerminal/QuickTerminalController.swift | 14 ++++++++++++-- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index ce5f07616..28dea9579 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -202,9 +202,19 @@ class QuickTerminalController: BaseTerminalController { override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) - // If our surface tree is nil then we animate the window out. - if (to.isEmpty) { + // If our surface tree is nil then we animate the window out. We + // defer reinitializing the tree to save some memory here. + if to.isEmpty { animateOut() + return + } + + // If we're not empty (e.g. this isn't the first set) and we're + // not visible, then we animate in. This allows us to show the quick + // terminal when things such as undo/redo are done. + if !from.isEmpty && !visible { + animateIn() + return } } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index ea9a8c61b..e4f6f507c 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -92,7 +92,7 @@ extension Ghostty { return ghostty_surface_needs_confirm_quit(surface) } - // Retruns true if the process in this surface has exited. + // Returns true if the process in this surface has exited. var processExited: Bool { guard let surface = self.surface else { return true } return ghostty_surface_process_exited(surface) From ec043e13866cef34d1835930a0512e1a42de5736 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Jun 2025 07:00:49 -0700 Subject: [PATCH 25/27] macos: red traffic light should be undoable --- macos/Sources/Features/Terminal/TerminalController.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 244f8720d..7a241d866 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -681,7 +681,6 @@ class TerminalController: BaseTerminalController { return } - tabGroup.windows.forEach { $0.close() } } @@ -954,6 +953,13 @@ class TerminalController: BaseTerminalController { //MARK: - NSWindowDelegate + override func windowShouldClose(_ sender: NSWindow) -> Bool { + closeWindow(sender) + + // We will always explicitly close the window using the above + return false + } + override func windowWillClose(_ notification: Notification) { super.windowWillClose(notification) self.relabelTabs() From 3de3f48faf830fe1326f44b08fb9f27fa65cefcd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Jun 2025 07:29:19 -0700 Subject: [PATCH 26/27] macos: fix undo/redo for closing windows with multiple tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When closing a window that contains multiple tabs, the undo operation now properly restores all tabs as a single tabbed window rather than just restoring the active tab. The implementation: - Collects undo states from all windows in the tab group before closing - Sorts them by their original tab index to preserve order - Clears tab group references to avoid referencing garbage collected objects - Restores all windows and re-adds them as tabs to the first window - Tracks and restores which tab was focused (or focuses the last tab if none were) AI prompts that generated this commit are below. Each separate prompt is separated by a blank line, so this session was made up with many prompts in a back-and-forth conversation. > We need to update the undo/redo implementation in > @macos/Sources/Features/Terminal/TerminalController.swift `closeWindowImmediately` > to handle the case that multiple windows in a tab group are closed all at once, > and to restore them as a tabbed window. To do this, I think we should collect > all the `undoStates`, sort them by `tabIndex` (null at the end), and then on j > restore, restore them one at a time but add them back to the same tabGroup. We > can't use the tab group in the `undoState` because it will be garbage collected > by then. To be sure, we should just set it to nil. I should note at this point that the feature already worked, but the code quality and organization wasn't up to my standards. If someone using AI were just trying to make something work, they might be done at this point. I do think this is the biggest gap I worry about with AI-assisted development: bridging between the "it works" stage at a junior quality and the "it works and is maintainable" stage at a senior quality. I suspect this will be a balance of LLMs getting better but also senior code reviewers remaining highly involved in the process. > Let's extract all the work you just did into a dedicated private method > called `registerUndoForCloseWindow` Manual: made some tweaks to comments, moved some lines around, didn’t change any logic. > I think we can pull the tabIndex directly from the undoState instead of > storing it in a tuple. > Instead of `var undoStates`, I think we can create a `let undoStates` and > build and filter and sort them all in a chain of functional mappings. > Okay, looking at your logic for restoration, the `var firstController` and > conditionals are littly messy. Can you make your own pass at cleaning those > up and I'll review and provide more specific guidance after. > Excellent. Perfect. The last thing we're missing is restoring the proper > focused window of the tab group. We should store that and make sure the > proper window is made key. If no windows were key, then we should make the > last one key. > Excellent. Any more cleanups or comments you'd recommend in the places you > changed? Notes on the last one: it gave me a bunch of suggestions, I rejected most but did accept some. > Can you write me a commit message summarizing the changes? It wrote me a part of the commit message you're reading now, but I always manually tweak the commit message and add my own flair. --- .../Terminal/TerminalController.swift | 140 ++++++++++++++---- 1 file changed, 112 insertions(+), 28 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7a241d866..fc262686b 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -647,41 +647,125 @@ class TerminalController: BaseTerminalController { private func closeWindowImmediately() { guard let window = window else { return } - // Regardless of tabs vs no tabs, what we want to do here is keep - // track of the window frame to restore, the surface tree, and the - // the focused surface. We want to restore that with undo even - // if we end up closing. - if let undoManager, let undoState { - // Register undo action to restore the window - undoManager.setActionName("Close Window") - undoManager.registerUndo( - withTarget: ghostty, - expiresAfter: undoExpiration) { ghostty in - // Restore the undo state - let newController = TerminalController(ghostty, with: undoState) + // Register undo for this close operation + registerUndoForCloseWindow() - // Register redo action + // Close the window(s) + if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { + tabGroup.windows.forEach { $0.close() } + } else { + window.close() + } + } + + /// Registers undo for closing window(s), handling both single windows and tab groups. + private func registerUndoForCloseWindow() { + guard let undoManager else { return } + guard let window else { return } + + // If we don't have a tab group or we don't have multiple tabs, then + // do a normal single window close. + guard let tabGroup = window.tabGroup, + tabGroup.windows.count > 1 else { + // No tabs, just save this window's state + if let undoState { + // Register undo action to restore the window + undoManager.setActionName("Close Window") undoManager.registerUndo( - withTarget: newController, - expiresAfter: newController.undoExpiration) { target in - target.closeWindowImmediately() + withTarget: ghostty, + expiresAfter: undoExpiration) { ghostty in + // Restore the undo state + let newController = TerminalController(ghostty, with: undoState) + + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration) { target in + target.closeWindowImmediately() + } + } + } + + return + } + + // Multiple windows in tab group - collect all undo states in sorted order + // by tab ordering. Also track which window was key. + let undoStates = tabGroup.windows + .compactMap { tabWindow -> UndoState? in + guard let controller = tabWindow.windowController as? TerminalController, + var undoState = controller.undoState else { return nil } + // Clear the tab group reference since it is unneeded. It should be + // garbage collected but we want to be extra sure we don't try to + // restore into it because we're going to recreate it. + undoState.tabGroup = nil + return undoState + } + .sorted { (lhs, rhs) in + switch (lhs.tabIndex, rhs.tabIndex) { + case let (l?, r?): return l < r + case (_?, nil): return true + case (nil, _?): return false + case (nil, nil): return true } } + + // Find the index of the key window in our sorted states. This is a bit verbose + // but we only need this for this style of undo so we don't want to add it to + // UndoState. + let keyWindowIndex: Int? + if let keyWindow = tabGroup.windows.first(where: { $0.isKeyWindow }), + let keyController = keyWindow.windowController as? TerminalController, + let keyUndoState = keyController.undoState { + keyWindowIndex = undoStates.firstIndex { + $0.tabIndex == keyUndoState.tabIndex } + } else { + keyWindowIndex = nil } - guard let tabGroup = window.tabGroup else { - // No tabs, no tab group, just perform a normal close. - window.close() - return - } + // Register undo action to restore all windows + guard !undoStates.isEmpty else { return } - // If have one window then we just do a normal close - if tabGroup.windows.count == 1 { - window.close() - return - } + undoManager.setActionName("Close Window") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration + ) { ghostty in + // Restore all windows in the tab group + let controllers = undoStates.map { undoState in + TerminalController(ghostty, with: undoState) + } + + // The first controller becomes the parent window for all tabs. + // If we don't have a first controller (shouldn't be possible?) + // then we can't restore tabs. + guard let firstController = controllers.first else { return } + + // Add all subsequent controllers as tabs to the first window + for controller in controllers.dropFirst() { + controller.showWindow(nil) + if let firstWindow = firstController.window, + let newWindow = controller.window { + firstWindow.addTabbedWindow(newWindow, ordered: .above) + } + } + + // Make the appropriate window key. If we had a key window, restore it. + // Otherwise, make the last window key. + if let keyWindowIndex, keyWindowIndex < controllers.count { + controllers[keyWindowIndex].window?.makeKeyAndOrderFront(nil) + } else { + controllers.last?.window?.makeKeyAndOrderFront(nil) + } - tabGroup.windows.forEach { $0.close() } + // Register redo action on the first controller + undoManager.registerUndo( + withTarget: firstController, + expiresAfter: firstController.undoExpiration + ) { target in + target.closeWindowImmediately() + } + } } /// Close all windows, asking for confirmation if necessary. @@ -734,7 +818,7 @@ class TerminalController: BaseTerminalController { let surfaceTree: SplitTree let focusedSurface: UUID? let tabIndex: Int? - private(set) weak var tabGroup: NSWindowTabGroup? + weak var tabGroup: NSWindowTabGroup? } convenience init(_ ghostty: Ghostty.App, From 26e1dd8f8e876bfc0b797c5968becf7fd565c319 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Jun 2025 12:23:08 -0700 Subject: [PATCH 27/27] macos: clear out the surface trees to prevent repeat undo see the comment --- .../Features/Terminal/TerminalController.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index fc262686b..c9f8ef216 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -647,12 +647,19 @@ class TerminalController: BaseTerminalController { private func closeWindowImmediately() { guard let window = window else { return } - // Register undo for this close operation registerUndoForCloseWindow() - // Close the window(s) if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { - tabGroup.windows.forEach { $0.close() } + tabGroup.windows.forEach { window in + // Clear out the surfacetree to ensure there is no undo state. + // 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.surfaceTree = .init() + } + + window.close() + } } else { window.close() } @@ -660,7 +667,7 @@ class TerminalController: BaseTerminalController { /// Registers undo for closing window(s), handling both single windows and tab groups. private func registerUndoForCloseWindow() { - guard let undoManager else { return } + guard let undoManager, undoManager.isUndoRegistrationEnabled else { return } guard let window else { return } // If we don't have a tab group or we don't have multiple tabs, then @@ -859,6 +866,7 @@ class TerminalController: BaseTerminalController { /// The current undo state for this controller var undoState: UndoState? { guard let window else { return nil } + guard !surfaceTree.isEmpty else { return nil } return .init( frame: window.frame, surfaceTree: surfaceTree,