add undo/redo keybindings, default them on macOS

This commit is contained in:
Mitchell Hashimoto
2025-06-06 11:34:33 -07:00
parent e1847da139
commit b044f4864a
10 changed files with 132 additions and 10 deletions

View File

@@ -673,6 +673,8 @@ typedef enum {
GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CONFIG_CHANGE,
GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_CLOSE_WINDOW,
GHOSTTY_ACTION_RING_BELL, GHOSTTY_ACTION_RING_BELL,
GHOSTTY_ACTION_UNDO,
GHOSTTY_ACTION_REDO,
GHOSTTY_ACTION_CHECK_FOR_UPDATES GHOSTTY_ACTION_CHECK_FOR_UPDATES
} ghostty_action_tag_e; } ghostty_action_tag_e;

View File

@@ -398,11 +398,8 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown)
syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp)
// TODO: sync syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo)
menuUndo?.keyEquivalent = "z" syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo)
menuUndo?.keyEquivalentModifierMask = [.command]
menuRedo?.keyEquivalent = "z"
menuRedo?.keyEquivalentModifierMask = [.command, .shift]
syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)

View File

@@ -428,8 +428,7 @@ class TerminalController: BaseTerminalController {
undoManager.registerUndo( undoManager.registerUndo(
withTarget: newController, withTarget: newController,
expiresAfter: newController.undoExpiration) { target in expiresAfter: newController.undoExpiration) { target in
// For redo, we close the tab again target.closeTab(nil)
target.closeTabImmediately()
} }
} }
} }
@@ -459,8 +458,7 @@ class TerminalController: BaseTerminalController {
undoManager.registerUndo( undoManager.registerUndo(
withTarget: newController, withTarget: newController,
expiresAfter: newController.undoExpiration) { target in expiresAfter: newController.undoExpiration) { target in
// For redo, we close the window again target.closeWindow(nil)
target.closeWindowImmediately()
} }
} }
} }

View File

@@ -553,6 +553,12 @@ extension Ghostty {
case GHOSTTY_ACTION_CHECK_FOR_UPDATES: case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
checkForUpdates(app) 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: case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
fallthrough fallthrough
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: 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) { private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) { switch (target.tag) {
case GHOSTTY_TARGET_APP: case GHOSTTY_TARGET_APP:

View File

@@ -4337,6 +4337,18 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{}, {},
), ),
.undo => return try self.rt_app.performAction(
.{ .surface = self },
.undo,
{},
),
.redo => return try self.rt_app.performAction(
.{ .surface = self },
.redo,
{},
),
.select_all => { .select_all => {
const sel = self.io.terminal.screen.selectAll(); const sel = self.io.terminal.screen.selectAll();
if (sel) |s| { if (sel) |s| {

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. /// it needs to ring the bell. This is usually a sound or visual effect.
ring_bell, 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, check_for_updates,
/// Sync with: ghostty_action_tag_e /// Sync with: ghostty_action_tag_e
@@ -307,6 +314,8 @@ pub const Action = union(Key) {
config_change, config_change,
close_window, close_window,
ring_bell, ring_bell,
undo,
redo,
check_for_updates, check_for_updates,
}; };

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| { inline for (@typeInfo(Config).@"struct".fields) |field| {
if (field.name[0] == '_') continue; if (field.name[0] == '_') continue;
@@ -94,6 +94,7 @@ pub fn genKeybindActions(writer: anytype) !void {
const info = @typeInfo(KeybindAction); const info = @typeInfo(KeybindAction);
std.debug.assert(info == .@"union"); std.debug.assert(info == .@"union");
@setEvalBranchQuota(5000);
inline for (info.@"union".fields) |field| { inline for (info.@"union".fields) |field| {
if (field.name[0] == '_') continue; if (field.name[0] == '_') continue;

View File

@@ -4898,6 +4898,18 @@ pub const Keybinds = struct {
.{ .key = .{ .unicode = 'q' }, .mods = .{ .super = true } }, .{ .key = .{ .unicode = 'q' }, .mods = .{ .super = true } },
.{ .quit = {} }, .{ .quit = {} },
); );
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 },
);
try self.set.putFlags( try self.set.putFlags(
alloc, alloc,
.{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } }, .{ .key = .{ .unicode = 'k' }, .mods = .{ .super = true } },

View File

@@ -655,6 +655,35 @@ pub const Action = union(enum) {
/// Only implemented on macOS. /// Only implemented on macOS.
check_for_updates, 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 Ghostty.
quit, quit,
@@ -991,6 +1020,8 @@ pub const Action = union(enum) {
.toggle_secure_input, .toggle_secure_input,
.toggle_command_palette, .toggle_command_palette,
.reset_window_size, .reset_window_size,
.undo,
.redo,
.crash, .crash,
=> .surface, => .surface,

View File

@@ -409,6 +409,18 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Check for updates to the application.", .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 &.{.{ .quit => comptime &.{.{
.action = .quit, .action = .quit,
.title = "Quit", .title = "Quit",