diff --git a/include/ghostty.h b/include/ghostty.h index 61c3aad324..73d9561109 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -557,6 +557,10 @@ typedef struct { bool soft; } ghostty_action_reload_config_s; +// apprt.action.ReopenLastTab +typedef struct { +} ghostty_action_reopen_last_tab_s; + // apprt.Action.Key typedef enum { GHOSTTY_ACTION_NEW_WINDOW, @@ -594,6 +598,7 @@ typedef enum { GHOSTTY_ACTION_COLOR_CHANGE, GHOSTTY_ACTION_RELOAD_CONFIG, GHOSTTY_ACTION_CONFIG_CHANGE, + GHOSTTY_ACTION_REOPEN_LAST_TAB, } ghostty_action_tag_e; typedef union { @@ -620,6 +625,7 @@ typedef union { ghostty_action_color_change_s color_change; ghostty_action_reload_config_s reload_config; ghostty_action_config_change_s config_change; + ghostty_action_reopen_last_tab_s reopen_last_tab; } ghostty_action_u; typedef struct { diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 7b0ff6fc28..4e5c6c570a 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -345,6 +345,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) + syncMenuShortcut(config, action: "reopen_last_tab", menuItem: self.menuReopenLastTab) syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2d9822d6e5..95bd6ef236 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -107,7 +107,7 @@ extension Ghostty { deinit { // This will force the didSet callbacks to run which free. self.app = nil - + #if os(macOS) NotificationCenter.default.removeObserver(self) #endif @@ -197,6 +197,13 @@ extension Ghostty { } } + func reopenLastTab(surface: ghostty_surface_t) { + let action = "reopen_last_tab" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + logger.warning("action failed action=\(action)") + } + } + func newWindow(surface: ghostty_surface_t) { let action = "new_window" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { @@ -460,6 +467,9 @@ extension Ghostty { case GHOSTTY_ACTION_NEW_TAB: newTab(app, target: target) + case GHOSTTY_ACTION_REOPEN_LAST_TAB: + reopenLastTab(app, target: target) + case GHOSTTY_ACTION_NEW_SPLIT: newSplit(app, target: target, direction: action.action.new_split) diff --git a/src/App.zig b/src/App.zig index 279c4e497d..9dfda80e9e 100644 --- a/src/App.zig +++ b/src/App.zig @@ -24,6 +24,7 @@ const objc = @import("objc"); const log = std.log.scoped(.app); const SurfaceList = std.ArrayListUnmanaged(*apprt.Surface); +const LastClosedTabs = @import("terminal/closedtabs.zig").LastClosedTabs; /// General purpose allocator alloc: Allocator, @@ -31,6 +32,9 @@ alloc: Allocator, /// The list of surfaces that are currently active. surfaces: SurfaceList, +/// Storage for recently closed tabs +last_closed_tabs: LastClosedTabs = .{}, + /// This is true if the app that Ghostty is in is focused. This may /// mean that no surfaces (terminals) are focused but the app is still /// focused, i.e. may an about window. On macOS, this concept is known @@ -101,6 +105,7 @@ pub fn create( .quit = false, .font_grid_set = font_grid_set, .config_conditional_state = .{}, + .last_closed_tabs = .{}, }; errdefer app.surfaces.deinit(alloc); @@ -112,6 +117,9 @@ pub fn destroy(self: *App) void { for (self.surfaces.items) |surface| surface.deinit(); self.surfaces.deinit(self.alloc); + // Clean up our last closed tabs + self.last_closed_tabs.deinit(self.alloc); + // Clean up our font group cache // We should have zero items in the grid set at this point because // destroy only gets called when the app is shutting down and this diff --git a/src/Surface.zig b/src/Surface.zig index eedeb4fb5e..175a1f9cef 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -724,7 +724,28 @@ pub fn deinit(self: *Surface) void { /// Close this surface. This will trigger the runtime to start the /// close process, which should ultimately deinitialize this surface. pub fn close(self: *Surface) void { - self.rt_surface.close(self.needsConfirmQuit()); + // Save tab data before closing + const cwd = self.io.terminal.getPwd(); + const cwd_copy = if (cwd) |c| self.alloc.dupe(u8, c) catch null else null; + errdefer if (cwd_copy) |c| self.alloc.free(c); + + const title = self.rt_surface.getTitle(); + const title_copy = if (title) |t| self.alloc.dupe(u8, t) catch null else null; + errdefer if (title_copy) |t| self.alloc.free(t); + + // Save terminal contents including scrollback buffer + const contents = self.io.terminal.screen.dumpStringAlloc(self.alloc, .{ .screen = .{} }) catch null; + errdefer if (contents) |c| self.alloc.free(c); + + // Save to last closed tabs + self.app.last_closed_tabs.push(.{ + .title = title_copy, + .cwd = cwd_copy, + .contents = contents, + }); + + // Close the surface + self.rt_surface.close(true); } /// Forces the surface to render. This is useful for when the surface @@ -4007,6 +4028,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .reopen_last_tab => try self.rt_app.performAction( + .{ .surface = self }, + .reopen_last_tab, + {}, + ), + inline .previous_tab, .next_tab, .last_tab, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 527535ffaf..2e0fde28a5 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -217,6 +217,9 @@ pub const Action = union(Key) { /// for changes. config_change: ConfigChange, + /// Reopen the most recently closed tab. + reopen_last_tab, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { new_window, @@ -254,6 +257,7 @@ pub const Action = union(Key) { color_change, reload_config, config_change, + reopen_last_tab, }; /// Sync with: ghostty_action_u diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 64b0cbe811..a460c78f69 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -22,6 +22,8 @@ const CoreApp = @import("../App.zig"); const CoreSurface = @import("../Surface.zig"); const configpkg = @import("../config.zig"); const Config = @import("../config.zig").Config; +const LastClosedTab = @import("../terminal/closedtabs.zig").LastClosedTab; +const sgr = @import("../terminal/sgr.zig"); // Get native API access on certain platforms so we can do more customization. const glfwNative = glfw.Native(.{ @@ -159,6 +161,11 @@ pub const App = struct { .surface => |v| v, }), + .reopen_last_tab => try self.reopenLastTab(switch (target) { + .app => null, + .surface => |v| v, + }), + .size_limit => switch (target) { .app => {}, .surface => |surface| try surface.rt_surface.setSizeLimits(.{ @@ -317,20 +324,8 @@ pub const App = struct { win.setMonitor(monitor, 0, 0, video_mode.getWidth(), video_mode.getHeight(), 0); } - /// Create a new tab in the parent surface. - fn newTab(self: *App, parent_: ?*CoreSurface) !void { - if (!Darwin.enabled) { - log.warn("tabbing is not supported on this platform", .{}); - return; - } - - const parent = parent_ orelse { - _ = try self.newSurface(null); - return; - }; - - // Create the new window - const window = try self.newSurface(parent); + fn addTab(self: *App, parent: *CoreSurface, window: *Surface) !void { + _ = self; // Add the new window the parent window const parent_win = glfwNative.getCocoaWindow(parent.rt_surface.window).?; @@ -356,6 +351,71 @@ pub const App = struct { }; } + /// Log that a reopen last tab action was triggered + fn reopenLastTab(self: *App, parent_: ?*CoreSurface) !void { + if (!Darwin.enabled) { + log.warn("tabbing is not supported on this platform", .{}); + return; + } + + const parent: *CoreSurface = parent_ orelse { + log.warn("No parent surface found", .{}); + return; + }; + + const last_tab_opt = parent.app.last_closed_tabs.pop() orelse { + log.warn("No last closed tab found", .{}); + return; + }; + + // Make a mutable copy + var last_tab = last_tab_opt; + defer last_tab.deinit(parent.app.alloc); + + const window = try self.newSurface(parent); + + if (last_tab.cwd) |cwd| { + try window.core_surface.io.terminal.setPwd(cwd); + } + + if (last_tab.title) |title| { + const title_z = try window.core_surface.alloc.dupeZ(u8, title); + defer window.core_surface.alloc.free(title_z); + try window.core_surface.rt_surface.setTitle(title_z); + } + + if (last_tab.contents) |contents| { + try window.core_surface.io.terminal.printString(contents); + } + + log.debug("Reopening last tab - pwd: {s}, title: {s}", .{ + last_tab.cwd orelse "(null)", + last_tab.title orelse "(null)", + }); + + try self.addTab(parent, window); + } + + /// Create a new tab in the parent surface. + fn newTab(self: *App, parent_: ?*CoreSurface) !void { + if (!Darwin.enabled) { + log.warn("tabbing is not supported on this platform", .{}); + return; + } + + const parent = parent_ orelse { + _ = try self.newSurface(null); + return; + }; + + log.debug("New tab: {?}", .{parent}); + + // Create the new window + const window = try self.newSurface(parent); + + try self.addTab(parent, window); + } + fn newSurface(self: *App, parent_: ?*CoreSurface) !*Surface { // Grab a surface allocation because we're going to need it. var surface = try self.app.alloc.create(Surface); @@ -596,6 +656,7 @@ pub const Surface = struct { } pub fn deinit(self: *Surface) void { + // Free the title text if it exists if (self.title_text) |t| self.core_surface.alloc.free(t); // Remove ourselves from the list of known surfaces in the app. diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 9128c8b108..9d637cf455 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -459,6 +459,7 @@ pub fn performAction( .toggle_fullscreen => self.toggleFullscreen(target, value), .new_tab => try self.newTab(target), + .reopen_last_tab => try self.reopenLastTab(target), .goto_tab => self.gotoTab(target, value), .move_tab => self.moveTab(target, value), .new_split => try self.newSplit(target, value), @@ -514,6 +515,23 @@ fn newTab(_: *App, target: apprt.Target) !void { } } +fn reopenLastTab(_: *App, target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| { + const window = v.rt_surface.container.window() orelse { + log.info( + "reopen_last_tab invalid for container={s}", + .{@tagName(v.rt_surface.container)}, + ); + return; + }; + + window.reopenLastTab(); + }, + } +} + fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) void { switch (target) { .app => {}, @@ -927,6 +945,7 @@ fn syncActionAccelerators(self: *App) !void { try self.syncActionAccelerator("win.close", .{ .close_surface = {} }); try self.syncActionAccelerator("win.new_window", .{ .new_window = {} }); try self.syncActionAccelerator("win.new_tab", .{ .new_tab = {} }); + try self.syncActionAccelerator("win.reopen_last_tab", .{ .reopen_last_tab = {} }); try self.syncActionAccelerator("win.split_right", .{ .new_split = .right }); try self.syncActionAccelerator("win.split_down", .{ .new_split = .down }); try self.syncActionAccelerator("win.split_left", .{ .new_split = .left }); @@ -1591,6 +1610,7 @@ fn initMenu(self: *App) void { c.g_menu_append_section(menu, null, @ptrCast(@alignCast(section))); c.g_menu_append(section, "New Window", "win.new_window"); c.g_menu_append(section, "New Tab", "win.new_tab"); + c.g_menu_append(section, "Reopen Last Tab", "win.reopen_last_tab"); c.g_menu_append(section, "Split Right", "win.split_right"); c.g_menu_append(section, "Split Down", "win.split_down"); c.g_menu_append(section, "Close Window", "win.close"); diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 0ad09ab74f..cf4c8d11b3 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -129,6 +129,7 @@ pub fn init(self: *Window, app: *App) !void { const tab_overview = c.adw_tab_overview_new(); c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view); c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1); + c.adw_tab_overview_set_enable_reopen_last_tab(@ptrCast(tab_overview), 1); _ = c.g_signal_connect_data( tab_overview, "create-tab", @@ -382,6 +383,7 @@ fn initActions(self: *Window) void { .{ "close", >kActionClose }, .{ "new_window", >kActionNewWindow }, .{ "new_tab", >kActionNewTab }, + .{ "reopen_last_tab", >kActionReopenLastTab }, .{ "split_right", >kActionSplitRight }, .{ "split_down", >kActionSplitDown }, .{ "split_left", >kActionSplitLeft }, @@ -436,6 +438,12 @@ pub fn newTab(self: *Window, parent: ?*CoreSurface) !void { // does not (cursor doesn't blink) unless reactivated by refocusing. } +pub fn reopenLastTab(self: *Window) void { + _ = self; + + log.debug("reopen last tab", .{}); +} + /// Close the tab for the given notebook page. This will automatically /// handle closing the window if there are no more tabs. pub fn closeTab(self: *Window, tab: *Tab) void { @@ -810,6 +818,15 @@ fn gtkActionNewTab( gtkTabNewClick(undefined, ud); } +fn gtkActionReopenLastTab( + _: *c.GSimpleAction, + _: *c.GVariant, + ud: ?*anyopaque, +) callconv(.C) void { + const self: *Window = @ptrCast(@alignCast(ud orelse return)); + self.reopenLastTab(); +} + fn gtkActionSplitRight( _: *c.GSimpleAction, _: *c.GVariant, diff --git a/src/config/Config.zig b/src/config/Config.zig index 8726fd67ce..425738f69f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2046,6 +2046,13 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config { .{ .open_config = {} }, ); + // keybind for reopening last closed tab + try result.keybind.set.put( + alloc, + .{ .key = .{ .translated = .t }, .mods = inputpkg.ctrlOrSuper(.{ .shift = true }) }, + .{ .reopen_last_tab = {} }, + ); + { // On macOS we default to super but Linux ctrl+shift since // ctrl+c is to kill the process. diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 85721339da..c43828c894 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -430,6 +430,9 @@ pub const Action = union(enum) { /// crash: CrashThread, + /// Reopen the most recently closed tab + reopen_last_tab: void, + pub const Key = @typeInfo(Action).Union.tag_type.?; pub const CrashThread = enum { @@ -669,6 +672,7 @@ pub const Action = union(enum) { // come from. For example `new_window` needs to be sourced to // a surface so inheritance can be done correctly. .new_tab, + .reopen_last_tab, .previous_tab, .next_tab, .last_tab, @@ -892,6 +896,7 @@ pub const Key = enum(c_int) { paste_from_clipboard, new_tab, new_window, + reopen_last_tab, }; /// Trigger is the associated key state that can trigger an action. diff --git a/src/terminal/closedtabs.zig b/src/terminal/closedtabs.zig new file mode 100644 index 0000000000..705fe8364f --- /dev/null +++ b/src/terminal/closedtabs.zig @@ -0,0 +1,51 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const max_closed_tabs = 10; + +pub const LastClosedTab = struct { + title: ?[]const u8, + cwd: ?[]const u8, + contents: ?[]const u8, + + pub fn deinit(self: *LastClosedTab, alloc: Allocator) void { + if (self.title) |t| alloc.free(t); + if (self.cwd) |c| alloc.free(c); + if (self.contents) |c| alloc.free(c); + } +}; + +pub const LastClosedTabs = struct { + this: std.BoundedArray(LastClosedTab, max_closed_tabs) = std.BoundedArray(LastClosedTab, max_closed_tabs).init(0) catch unreachable, + + pub fn push(self: *LastClosedTabs, tab: LastClosedTab) void { + if (self.this.len == max_closed_tabs) { + // Remove oldest tab and free its memory + self.this.buffer[0] = tab; + // Shift all elements left + for (0..self.this.len - 1) |i| { + self.this.buffer[i] = self.this.buffer[i + 1]; + } + } else { + self.this.append(tab) catch unreachable; + } + } + + pub fn deinit(self: *LastClosedTabs, alloc: Allocator) void { + for (0..self.this.len) |i| { + self.this.buffer[i].deinit(alloc); + } + self.this.len = 0; + } + + pub fn get(self: *LastClosedTabs, index: usize) ?*LastClosedTab { + if (index >= self.this.len) return null; + return &self.this.buffer[index]; + } + + pub fn pop(self: *LastClosedTabs) ?LastClosedTab { + if (self.this.len == 0) return null; + self.this.len -= 1; + return self.this.buffer[self.this.len]; + } +};