From 493b1f53506263c11d763ae19a0ca83f1b8bd0e2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 5 Jun 2025 15:04:11 -0700 Subject: [PATCH] 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 } - guard let node = surfaceTree.root?.node(view: target) else { return } + closeSurface( + target, + withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false, + ) + } - var processAlive = false - if let valueAny = notification.userInfo?["process_alive"] { - if let value = valueAny as? Bool { - processAlive = value - } - } + /// 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 } // 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 + } +} -- 2.51.2