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

@@ -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, {}),
}
}

View File

@@ -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).?,

View File

@@ -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,
};

View File

@@ -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();

View File

@@ -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});

View File

@@ -515,6 +515,8 @@ pub fn performAction(
.color_change,
.reset_window_size,
.check_for_updates,
.undo,
.redo,
=> {
log.warn("unimplemented action={}", .{action});
return false;

View File

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

View File

@@ -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);

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