diff --git a/include/ghostty.h b/include/ghostty.h index 6b1625a30..9f17d0b97 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; @@ -784,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/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 62cb079bf..9686dcbd1 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -62,6 +62,9 @@ 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 */; }; + 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 */; }; @@ -69,7 +72,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 */; }; @@ -168,6 +170,9 @@ 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 = ""; }; + 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 = ""; }; @@ -175,7 +180,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 = ""; }; @@ -298,6 +302,7 @@ A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, + A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.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 */, @@ -443,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; @@ -461,7 +468,6 @@ isa = PBXGroup; children = ( A59630992AEE1C6400D64628 /* Terminal.xib */, - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, @@ -680,12 +686,14 @@ 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 */, 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 */, @@ -701,8 +709,8 @@ 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 */, 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 fd25ef358..7fb52a025 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? @@ -85,8 +87,8 @@ 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() /// Our quick terminal. This starts out uninitialized and only initializes if used. private var quickController: QuickTerminalController? = nil @@ -114,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 @@ -197,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 = [ @@ -248,8 +259,10 @@ 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 { + undoManager.disableUndoRegistration() + _ = TerminalController.newWindow(ghostty) + undoManager.enableUndoRegistration() } } } @@ -334,10 +347,15 @@ 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 } + + // 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. - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) return false } @@ -353,16 +371,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 @@ -393,6 +412,8 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) + 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) @@ -449,10 +470,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 @@ -585,6 +602,26 @@ 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 } + + // 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 + + _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config) + } + private func setDockBadge(_ label: String? = "•") { NSApp.dockTile.badgeLabel = label NSApp.dockTile.display() @@ -620,7 +657,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 @@ -749,8 +786,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 } @@ -804,7 +841,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. @@ -812,7 +849,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. @@ -820,7 +857,7 @@ class AppDelegate: NSObject, } @IBAction func closeAllWindows(_ sender: Any?) { - terminalManager.closeAllWindows() + TerminalController.closeAllWindows() AboutController.shared.hide() } @@ -882,6 +919,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 @@ -971,6 +1016,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/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/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 8c86c2531..28dea9579 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), @@ -196,16 +202,48 @@ 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) { - // 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 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 + } + } + + 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 @@ -252,12 +290,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 +315,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 +431,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/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/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 ea849bb4a..594a58056 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -75,6 +75,27 @@ 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. + var undoExpiration: Duration { + ghostty.config.undoTimeout + } + + /// The undo manager for this controller is the undo manager of the window, + /// which we set via the delegate method. + override var undoManager: ExpiringUndoManager? { + // This should be set via the delegate method windowWillReturnUndoManager + 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 { let window: NSRect let screen: NSRect @@ -166,7 +187,7 @@ class BaseTerminalController: NSWindowController, deinit { NotificationCenter.default.removeObserver(self) - + undoManager?.removeAllActions(withTarget: self) if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } @@ -239,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? { @@ -256,18 +277,69 @@ class BaseTerminalController: NSWindowController, } /// Remove a node from the surface tree and move focus appropriately. - private func removeSurfaceAndMoveFocus(_ node: SplitTree.Node) { - let nextTarget = findNextFocusTargetAfterClosing(node: node) - let oldFocused = focusedSurface - let focused = node.contains { $0 == focusedSurface } - - // Remove the node from the tree - surfaceTree = surfaceTree.remove(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) { // 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 { + if let undoAction { + undoManager.setActionName(undoAction) + } + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration + ) { target in + target.surfaceTree = oldTree + if let oldView { + DispatchQueue.main.async { + Ghostty.moveFocus(to: oldView, from: target.focusedSurface) + } + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration + ) { target in + target.replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: target.focusedSurface, + undoAction: undoAction) + } } } } @@ -346,20 +418,26 @@ 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 } + closeSurfaceNode( + node, + 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 node (which may contain splits), requesting confirmation if necessary. + /// + /// This will also insert the proper undo stack information in. + func closeSurfaceNode( + _ node: SplitTree.Node, + withConfirmation: Bool = true + ) { + // 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 processAlive else { - removeSurfaceAndMoveFocus(node) + guard withConfirmation else { + removeSurfaceNode(node) return } @@ -373,7 +451,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) } } } @@ -404,8 +482,12 @@ class BaseTerminalController: NSWindowController, let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config) // Do the split + let newTree: SplitTree do { - surfaceTree = try surfaceTree.insert(view: newView, at: oldView, direction: splitDirection) + 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 @@ -414,8 +496,11 @@ class BaseTerminalController: NSWindowController, return } - // Once we've split, we need to move focus to the new split - Ghostty.moveFocus(to: newView, from: oldView) + replaceSurfaceTree( + newTree, + moveFocusTo: newView, + moveFocusFrom: oldView, + undoAction: "New Split") } @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { @@ -732,6 +817,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 @@ -787,6 +877,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) { @@ -818,6 +911,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/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index baae90068..c9f8ef216 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,209 @@ 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) + } + + // 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 + undoManager.disableUndoRegistration { + target.closeWindow(nil) + } + + // Register redo action + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration + ) { ghostty in + _ = TerminalController.newWindow( + ghostty, + withBaseConfig: baseConfig, + withParent: explicitParent) + } + } + } + + 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() + } + + // 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 + undoManager.disableUndoRegistration { + target.closeTab(nil) + } + + // Register redo action + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: target.undoExpiration + ) { ghostty in + _ = TerminalController.newTab( + ghostty, + from: parent, + withBaseConfig: baseConfig) + } + } + } + + return controller + } + //MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { @@ -386,6 +590,291 @@ 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) + } + + private func closeTabImmediately() { + guard let window = window else { return } + guard let tabGroup = window.tabGroup, + tabGroup.windows.count > 1 else { + closeWindowImmediately() + return + } + + // Undo + if let undoManager, let undoState { + // Register undo action to restore the tab + undoManager.setActionName("Close Tab") + undoManager.registerUndo( + withTarget: ghostty, + expiresAfter: undoExpiration + ) { ghostty in + let newController = TerminalController(ghostty, with: undoState) + + // Register redo action + undoManager.registerUndo( + withTarget: newController, + expiresAfter: newController.undoExpiration + ) { target in + 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 } + + registerUndoForCloseWindow() + + if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { + 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() + } + } + + /// Registers undo for closing window(s), handling both single windows and tab groups. + private func registerUndoForCloseWindow() { + 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 + // 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: 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 + } + + // Register undo action to restore all windows + guard !undoStates.isEmpty else { 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) + } + + // 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. + 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() { + let undoManager = (NSApp.delegate as? AppDelegate)?.undoManager + undoManager?.beginUndoGrouping() + all.forEach { $0.closeWindowImmediately() } + undoManager?.setActionName("Close All Windows") + undoManager?.endUndoGrouping() + } + + // 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? + let tabIndex: Int? + weak var tabGroup: NSWindowTabGroup? + } + + 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) + + // 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 }) { + 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 } + guard !surfaceTree.isEmpty else { return nil } + return .init( + frame: window.frame, + surfaceTree: surfaceTree, + focusedSurface: focusedSurface?.uuid, + tabIndex: window.tabGroup?.windows.firstIndex(of: window), + tabGroup: window.tabGroup) + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -556,9 +1045,45 @@ 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() + + // 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) { @@ -607,23 +1132,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?) { @@ -635,13 +1159,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() return } // If have one window then we just do a normal close if tabGroup.windows.count == 1 { - window.performClose(sender) + closeWindowImmediately() return } @@ -655,7 +1179,7 @@ class TerminalController: BaseTerminalController { // If none need confirmation then we can just close all the windows. if !needsConfirm { - tabGroup.windows.forEach { $0.close() } + closeWindowImmediately() return } @@ -663,7 +1187,7 @@ class TerminalController: BaseTerminalController { messageText: "Close Window?", informativeText: "All terminal sessions in this window will be terminated." ) { - tabGroup.windows.forEach { $0.close() } + self.closeWindowImmediately() } } @@ -861,7 +1385,6 @@ class TerminalController: BaseTerminalController { toggleFullscreen(mode: fullscreenMode) } - struct DerivedConfig { let backgroundColor: Color let macosWindowButtons: Ghostty.MacOSWindowButtons @@ -884,6 +1407,7 @@ class TerminalController: BaseTerminalController { } } +// MARK: NSMenuItemValidation extension TerminalController: NSMenuItemValidation { func validateMenuItem(_ item: NSMenuItem) -> Bool { diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift deleted file mode 100644 index 805ae6e93..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 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/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/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; } } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 682efa947..e4f6f507c 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) } + // 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) + } + // Returns the inspector instance for this surface, or nil if the // surface has been closed. var inspector: ghostty_inspector_t? { diff --git a/macos/Sources/Helpers/ExpiringUndoManager.swift b/macos/Sources/Helpers/ExpiringUndoManager.swift new file mode 100644 index 000000000..5fde0e870 --- /dev/null +++ b/macos/Sources/Helpers/ExpiringUndoManager.swift @@ -0,0 +1,148 @@ +/// 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 + ) { + // 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, + 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 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) } + .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)) + } +} 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 + } +} 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 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() + } +} 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 62a0ce549..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).?, 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/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(); 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; 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..2df66ba45 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. @@ -4910,6 +4956,26 @@ pub const Keybinds = struct { .{ .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 } }, + .{ .undo = {} }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'z' }, .mods = .{ .super = true, .shift = true } }, + .{ .redo = {} }, + .{ .performable = true }, + ); + // Viewport scrolling try self.set.put( alloc, @@ -6571,7 +6637,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| { @@ -6585,8 +6651,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'. @@ -6796,6 +6871,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); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index c2c06948f..cccf12ac4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -657,6 +657,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, @@ -953,6 +982,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. 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",