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 windows
This commit is contained in:
Mitchell Hashimoto
2025-06-08 12:54:55 -07:00
committed by GitHub
29 changed files with 1286 additions and 465 deletions

View File

@@ -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.

View File

@@ -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",