macOS: Undo/Redo for changes to windows, tabs, and splits (#7535)
This PR implements the ability to undo/redo new and closed windows, tabs, and splits. ## Demo https://github.com/user-attachments/assets/98601810-71b8-4adb-bfa4-bdfaa2526dc6 ## Details ### Undo Timeout Running terminal sessions _remain running_ for a configurable period of time after close, during which time they're undoable. This is similar to "email unsend" (since email in the traditional sense can't be unsent, clients simply hold onto it for a period of time before sending). This behavior is not unique to Ghostty. The first and only place I've seen it is in iTerm2. And iTerm2 behaves similarly, although details of our behavior and our implementation vary greatly. The configurable period of time is done via the `undo-timeout` configuration. The default value is 5 seconds. This feels reasonable to be and is grounded in being the default for iTerm2 as well, so it's probably a safe choice. Undo can be disabled by setting `undo-timeout = 0`. ### Future The actions that can be potentially undone/redone can be easily expanded in the future. Some thoughts on things that make sense to me: - Any sort of split resizing, including equalization - Moving tabs or splits #### What about Linux? I'd love to support this on Linux. I don't think any other terminal on Linux has this kind of feature (definitely might be wrong, but I've never seen it and I've looked at a lot of terminal emulators 😄 ). But there's some work to be done to get there. ## TODO for the Draft PR This is still a draft. There are some items remaining (list will update as I go): - [x] Undoing a closed window is sometimes buggy still and I'm not sure why, I have to dig into this. - [x] New window should be undoable - [x] New tab should be undoable - [x] Close All Windows should be undoable - [x] I think I have to get rid of TerminalManager. Undone windows won't be in TerminalManager's list of controllers and I think that's going to break a lot of things. - [x] I haven't tested this with QuickTerminal at all. I expect bugs there but I want undo to work with splits there. - [x] Close window with the red traffic light button doesn't trigger undo - [x] Closing window with multiple tabs undoes them as separate windowspull/7547/head
commit
804d270ba1
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = "<group>"; };
|
||||
A58636652DEF963F00E04A10 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSplitTreeView.swift; sourceTree = "<group>"; };
|
||||
A586366A2DF0A98900E04A10 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; };
|
||||
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duration+Extension.swift"; sourceTree = "<group>"; };
|
||||
A58636702DF298F700E04A10 /* ExpiringUndoManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpiringUndoManager.swift; sourceTree = "<group>"; };
|
||||
A58636722DF4813000E04A10 /* UndoManager+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UndoManager+Extension.swift"; sourceTree = "<group>"; };
|
||||
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = "<group>"; };
|
||||
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = "<group>"; };
|
||||
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -175,7 +180,6 @@
|
|||
A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = "<group>"; };
|
||||
A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
|
||||
A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
|
||||
A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
|
||||
A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = "<group>"; };
|
||||
A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = "<group>"; };
|
||||
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = "<group>"; };
|
||||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
<outlet property="menuPreviousSplit" destination="Lic-px-1wg" id="Rto-CG-yRe"/>
|
||||
<outlet property="menuQuickTerminal" destination="1pv-LF-NBJ" id="glN-5B-IGi"/>
|
||||
<outlet property="menuQuit" destination="4sb-4s-VLi" id="qYN-S1-6UW"/>
|
||||
<outlet property="menuRedo" destination="EX8-lB-4s7" id="wON-2J-yT1"/>
|
||||
<outlet property="menuReloadConfig" destination="KKH-XX-5py" id="Wvp-7J-wqX"/>
|
||||
<outlet property="menuResetFontSize" destination="Jah-MY-aLX" id="ger-qM-wrm"/>
|
||||
<outlet property="menuReturnToDefaultSize" destination="Gbx-Vi-OGC" id="po9-qC-Iz6"/>
|
||||
|
|
@ -57,6 +58,7 @@
|
|||
<outlet property="menuTerminalInspector" destination="QwP-M5-fvh" id="wJi-Dh-S9f"/>
|
||||
<outlet property="menuToggleFullScreen" destination="8kY-Pi-KaY" id="yQg-6V-OO6"/>
|
||||
<outlet property="menuToggleVisibility" destination="DOX-wA-ilh" id="iBj-Bc-2bq"/>
|
||||
<outlet property="menuUndo" destination="r83-CV-syt" id="bU9-0b-xgQ"/>
|
||||
<outlet property="menuUseAsDefault" destination="TrB-O8-g8H" id="af4-Jh-2HU"/>
|
||||
<outlet property="menuZoomSplit" destination="oPd-mn-IEH" id="wTu-jK-egI"/>
|
||||
</connections>
|
||||
|
|
@ -204,6 +206,19 @@
|
|||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<menu key="submenu" title="Edit" id="iU4-OB-ccf">
|
||||
<items>
|
||||
<menuItem title="Undo" id="r83-CV-syt">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="undo:" target="-1" id="jrW-j3-OZj"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem title="Redo" id="EX8-lB-4s7">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
<action selector="redo:" target="-1" id="7UK-Hj-s4O"/>
|
||||
</connections>
|
||||
</menuItem>
|
||||
<menuItem isSeparatorItem="YES" id="4O9-zO-zB9"/>
|
||||
<menuItem title="Copy" id="Jqf-pv-Zcu">
|
||||
<modifierMask key="keyEquivalentModifierMask"/>
|
||||
<connections>
|
||||
|
|
|
|||
|
|
@ -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<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
|
||||
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<Ghostty.SurfaceView>.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
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ class ServiceProvider: NSObject {
|
|||
error: AutoreleasingUnsafeMutablePointer<NSString>
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,27 @@ class BaseTerminalController: NSWindowController,
|
|||
/// The cancellables related to our focused surface.
|
||||
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
|
||||
|
||||
/// 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<Ghostty.SurfaceView>.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<Ghostty.SurfaceView>.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<Ghostty.SurfaceView>.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<Ghostty.SurfaceView>,
|
||||
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<Ghostty.SurfaceView>.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<Ghostty.SurfaceView>
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ class TerminalController: BaseTerminalController {
|
|||
|
||||
init(_ ghostty: Ghostty.App,
|
||||
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = nil
|
||||
withSurfaceTree tree: SplitTree<Ghostty.SurfaceView>? = 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<Ghostty.SurfaceView>.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<Ghostty.SurfaceView>
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<Ghostty.SurfaceView>? = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<Int8>? = nil
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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<ExpiringTarget> = []
|
||||
|
||||
/// 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<TargetType: AnyObject>(
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import AppKit
|
||||
import Cocoa
|
||||
|
||||
// MARK: Presentation Options
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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, {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).?,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -515,6 +515,8 @@ pub fn performAction(
|
|||
.color_change,
|
||||
.reset_window_size,
|
||||
.check_for_updates,
|
||||
.undo,
|
||||
.redo,
|
||||
=> {
|
||||
log.warn("unimplemented action={}", .{action});
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue