diff --git a/include/ghostty.h b/include/ghostty.h index 7888b380c1..4412b9df6e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -737,6 +737,7 @@ typedef enum { GHOSTTY_ACTION_QUIT, GHOSTTY_ACTION_NEW_WINDOW, GHOSTTY_ACTION_NEW_TAB, + GHOSTTY_ACTION_DUPLICATE_TAB, GHOSTTY_ACTION_CLOSE_TAB, GHOSTTY_ACTION_NEW_SPLIT, GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index df3a1f4f42..606e7daf02 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -28,6 +28,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuNewWindow: NSMenuItem? @IBOutlet private var menuNewTab: NSMenuItem? + @IBOutlet private var menuDuplicateTab: NSMenuItem? @IBOutlet private var menuSplitRight: NSMenuItem? @IBOutlet private var menuSplitLeft: NSMenuItem? @IBOutlet private var menuSplitDown: NSMenuItem? @@ -228,6 +229,11 @@ class AppDelegate: NSObject, selector: #selector(ghosttyNewTab(_:)), name: Ghostty.Notification.ghosttyNewTab, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyDuplicateTab(_:)), + name: Ghostty.Notification.ghosttyDuplicateTab, + object: nil) // Configure user notifications let actions = [ @@ -522,6 +528,7 @@ class AppDelegate: NSObject, self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display") self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus") self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow") + self.menuDuplicateTab?.setImageIfDesired(systemSymbolName: "plus.rectangle.on.rectangle") self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") @@ -563,6 +570,7 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) + syncMenuShortcut(config, action: "duplicate_tab", menuItem: self.menuDuplicateTab) syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab) syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) @@ -782,6 +790,20 @@ class AppDelegate: NSObject, _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config) } + @objc private func ghosttyDuplicateTab(_ 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 duplicate 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.duplicateTab(ghostty, from: window, withBaseConfig: config) + } + private func setDockBadge(_ label: String? = "•") { NSApp.dockTile.badgeLabel = label NSApp.dockTile.display() @@ -1015,6 +1037,10 @@ class AppDelegate: NSObject, _ = TerminalController.newTab(ghostty) } + @IBAction func duplicateTab(_ sender: Any?) { + _ = TerminalController.duplicateTab(ghostty) + } + @IBAction func closeAllWindows(_ sender: Any?) { TerminalController.closeAllWindows() AboutController.shared.hide() @@ -1167,6 +1193,14 @@ extension AppDelegate: NSMenuItemValidation { // terminal window (not quick terminal). return NSApp.keyWindow is TerminalWindow + case #selector(duplicateTab(_:)): + // Only enable duplicate tab if there's a focused surface in the key window + guard let keyWindow = NSApp.keyWindow, + let controller = keyWindow.windowController as? TerminalController else { + return false + } + return controller.focusedSurface != nil + case #selector(undo(_:)): if undoManager.canUndo { item.title = "Undo \(undoManager.undoActionName)" diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index c97ed7c61f..7cc373fba3 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -25,6 +25,7 @@ + @@ -150,6 +151,12 @@ + + + + + + diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 68b9ba3372..c1950a1a4a 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -79,6 +79,11 @@ class QuickTerminalController: BaseTerminalController { selector: #selector(onNewTab), name: Ghostty.Notification.ghosttyNewTab, object: nil) + center.addObserver( + self, + selector: #selector(onDuplicateTab), + name: Ghostty.Notification.ghosttyDuplicateTab, + object: nil) center.addObserver( self, selector: #selector(windowDidResize(_:)), @@ -588,6 +593,10 @@ class QuickTerminalController: BaseTerminalController { @IBAction func newTab(_ sender: Any?) { showNoNewTabAlert() } + + @IBAction func duplicateTab(_ sender: Any?) { + showNoNewTabAlert() + } @IBAction func toggleGhosttyFullScreen(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } @@ -659,6 +668,14 @@ class QuickTerminalController: BaseTerminalController { // Tabs aren't supported with Quick Terminals or derivatives showNoNewTabAlert() } + + @objc private func onDuplicateTab(notification: SwiftUI.Notification) { + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard let window = surfaceView.window else { return } + guard window.windowController is QuickTerminalController else { return } + // Tabs aren't supported with Quick Terminals or derivatives + showNoNewTabAlert() + } private struct DerivedConfig { let quickTerminalScreen: QuickTerminalScreen diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 4bb642ea61..2ea4955dd1 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -388,6 +388,28 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return controller } + static func duplicateTab( + _ ghostty: Ghostty.App, + from parent: NSWindow? = nil, + withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil + ) -> TerminalController? { + let parentWindow = parent ?? NSApp.keyWindow + + guard let parentWindow = parentWindow, + let parentController = parentWindow.windowController as? TerminalController else { + return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent) + } + + var config = baseConfig ?? Ghostty.SurfaceConfiguration() + + if let focusedSurface = parentController.focusedSurface, + let workingDirectory = focusedSurface.pwd { + config.workingDirectory = workingDirectory + } + + return newTab(ghostty, from: parentWindow, withBaseConfig: config) + } + //MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { @@ -1057,6 +1079,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr ghostty.newTab(surface: surface) } + @IBAction func duplicateTab(_ sender: Any?) { + _ = TerminalController.duplicateTab( + ghostty, + from: window, + withBaseConfig: Ghostty.SurfaceConfiguration() + ) + } + @IBAction func closeTab(_ sender: Any?) { guard let window = window else { return } guard window.tabGroup?.windows.count ?? 0 > 1 else { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 65979bd408..e99915536a 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -187,6 +187,13 @@ extension Ghostty { } } + func duplicateTab(surface: ghostty_surface_t) { + let action = "duplicate_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))) { @@ -451,6 +458,9 @@ extension Ghostty { case GHOSTTY_ACTION_NEW_TAB: newTab(app, target: target) + case GHOSTTY_ACTION_DUPLICATE_TAB: + duplicateTab(app, target: target) + case GHOSTTY_ACTION_NEW_SPLIT: newSplit(app, target: target, direction: action.action.new_split) @@ -752,6 +762,42 @@ extension Ghostty { } } + private static func duplicateTab(_ app: ghostty_app_t, target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + NotificationCenter.default.post( + name: Notification.ghosttyDuplicateTab, + object: nil, + userInfo: [:] + ) + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let appState = self.appState(fromView: surfaceView) else { return } + guard appState.config.windowDecorations else { + let alert = NSAlert() + alert.messageText = "Tabs are disabled" + alert.informativeText = "Enable window decorations to use tabs" + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + _ = alert.runModal() + return + } + + NotificationCenter.default.post( + name: Notification.ghosttyDuplicateTab, + object: surfaceView, + userInfo: [ + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)), + ] + ) + + default: + assertionFailure() + } + } + private static func newSplit( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 85040d390e..8661e51e20 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -370,6 +370,9 @@ extension Ghostty.Notification { /// New tab. Has base surface config requested in userinfo. static let ghosttyNewTab = Notification.Name("com.mitchellh.ghostty.newTab") + /// Duplicate tab. Has base surface config requested in userinfo. + static let ghosttyDuplicateTab = Notification.Name("com.mitchellh.ghostty.duplicateTab") + /// New window. Has base surface config requested in userinfo. static let ghosttyNewWindow = Notification.Name("com.mitchellh.ghostty.newWindow") diff --git a/src/Surface.zig b/src/Surface.zig index 8edeadf83f..6e473ae2e3 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4801,6 +4801,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .duplicate_tab => return try self.rt_app.performAction( + .{ .surface = self }, + .duplicate_tab, + {}, + ), + .close_tab => |v| return try self.rt_app.performAction( .{ .surface = self }, .close_tab, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index fbcc928059..411225601e 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -83,6 +83,11 @@ pub const Action = union(Key) { /// the tab should be opened in a new window. new_tab, + /// Duplicate the current tab. If the target is a surface it should be + /// duplicated in the same window as the surface. If the target is the app + /// then the tab should be duplicated in a new window. + duplicate_tab, + /// Closes the tab belonging to the currently focused split, or all other /// tabs, depending on the mode. close_tab: CloseTabMode, @@ -300,6 +305,7 @@ pub const Action = union(Key) { quit, new_window, new_tab, + duplicate_tab, close_tab, new_split, close_all_windows, diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 5f87613cde..d0d7bf4e34 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -591,6 +591,8 @@ pub const Application = extern struct { .new_tab => return Action.newTab(target), + .duplicate_tab => return Action.duplicateTab(target), + .new_window => try Action.newWindow( self, switch (target) { @@ -999,6 +1001,7 @@ pub const Application = extern struct { self.syncActionAccelerator("win.close", .{ .close_window = {} }); self.syncActionAccelerator("win.new-window", .{ .new_window = {} }); self.syncActionAccelerator("win.new-tab", .{ .new_tab = {} }); + self.syncActionAccelerator("win.duplicate-tab", .{ .duplicate_tab = {} }); self.syncActionAccelerator("win.close-tab::this", .{ .close_tab = .this }); self.syncActionAccelerator("tab.close::this", .{ .close_tab = .this }); self.syncActionAccelerator("win.split-right", .{ .new_split = .right }); @@ -1981,6 +1984,31 @@ const Action = struct { } } + pub fn duplicateTab(target: apprt.Target) bool { + switch (target) { + .app => { + log.warn("duplicate tab to app is unexpected", .{}); + return false; + }, + + .surface => |core| { + // Get the window ancestor of the surface. Surfaces shouldn't + // be aware they might be in windows but at the app level we + // can do this. + const surface = core.rt_surface.surface; + const window = ext.getAncestor( + Window, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a window, ignoring duplicate_tab", .{}); + return false; + }; + window.duplicateTab(core); + return true; + }, + } + } + pub fn newWindow( self: *Application, parent: ?*CoreSurface, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index c26d0c1ef8..a35c1a5a40 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -535,6 +535,7 @@ pub const Surface = extern struct { pub fn setParent( self: *Self, parent: *CoreSurface, + force_inherit_pwd: bool, ) void { const priv = self.private(); @@ -558,7 +559,7 @@ pub const Surface = extern struct { const config = config_obj.get(); // Setup our pwd if configured to inherit - if (config.@"window-inherit-working-directory") { + if (config.@"window-inherit-working-directory" or force_inherit_pwd) { if (parent.rt_surface.surface.getPwd()) |pwd| { priv.pwd = glib.ext.dupeZ(u8, pwd); self.as(gobject.Object).notifyByPspec(properties.pwd.impl.param_spec); diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index d8f9b97f8c..2771ebc6ba 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -166,9 +166,9 @@ pub const Tab = extern struct { /// Set the parent of this tab page. This only affects the first surface /// ever created for a tab. If a surface was already created this does /// nothing. - pub fn setParent(self: *Self, parent: *CoreSurface) void { + pub fn setParent(self: *Self, parent: *CoreSurface, force_inherit_pwd: bool) void { if (self.getActiveSurface()) |surface| { - surface.setParent(parent); + surface.setParent(parent, force_inherit_pwd); } } diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index df6ea647f2..afbea9d5b5 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -335,6 +335,7 @@ pub const Window = extern struct { .init("close", actionClose, null), .init("close-tab", actionCloseTab, s_variant_type), .init("new-tab", actionNewTab, null), + .init("duplicate-tab", actionDuplicateTab, null), .init("new-window", actionNewWindow, null), .init("ring-bell", actionRingBell, null), .init("split-right", actionSplitRight, null), @@ -362,10 +363,17 @@ pub const Window = extern struct { /// at the position dictated by the `window-new-tab-position` config. /// The new tab will be selected. pub fn newTab(self: *Self, parent_: ?*CoreSurface) void { - _ = self.newTabPage(parent_); + _ = self.newTabPage(parent_, false); } - fn newTabPage(self: *Self, parent_: ?*CoreSurface) *adw.TabPage { + /// Duplicate the current tab with the given parent. The tab will be inserted + /// at the position dictated by the `window-new-tab-position` config. + /// The new tab will be selected. + pub fn duplicateTab(self: *Self, parent_: ?*CoreSurface) void { + _ = self.newTabPage(parent_, true); + } + + fn newTabPage(self: *Self, parent_: ?*CoreSurface, force_inherit_pwd: bool) *adw.TabPage { const priv = self.private(); const tab_view = priv.tab_view; @@ -373,7 +381,7 @@ pub const Window = extern struct { const tab = gobject.ext.newInstance(Tab, .{ .config = priv.config, }); - if (parent_) |p| tab.setParent(p); + if (parent_) |p| tab.setParent(p, force_inherit_pwd); // Get the position that we should insert the new tab at. const config = if (priv.config) |v| v.get() else { @@ -1732,6 +1740,14 @@ pub const Window = extern struct { self.performBindingAction(.new_tab); } + fn actionDuplicateTab( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Window, + ) callconv(.c) void { + self.performBindingAction(.duplicate_tab); + } + fn actionSplitRight( _: *gio.SimpleAction, _: ?*glib.Variant, diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index f22f2c09a1..630650fdbd 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -246,6 +246,11 @@ menu context_menu_model { action: "win.new-tab"; } + item { + label: _("Duplicate Tab"); + action: "win.duplicate-tab"; + } + item { label: _("Close Tab"); action: "tab.close"; diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index 8c0a7bedb1..9bff0d65b5 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -223,6 +223,11 @@ menu main_menu { action: "win.new-tab"; } + item { + label: _("Duplicate Tab"); + action: "win.duplicate-tab"; + } + item { label: _("Close Tab"); action: "win.close-tab"; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 54e7754f28..5e2f1aedc0 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -454,6 +454,12 @@ pub const Action = union(enum) { /// Open a new tab. new_tab, + /// Duplicate the current tab. + /// + /// Creates a new tab with the same working directory as the current tab. + /// The new tab will be opened in the same window as the current tab. + duplicate_tab, + /// Go to the previous tab. previous_tab, @@ -1104,6 +1110,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, + .duplicate_tab, .previous_tab, .next_tab, .last_tab, @@ -2830,6 +2837,23 @@ test "set: parseAndPut sequence with two actions" { } } +test "set: parseAndPut duplicate_tab action" { + const testing = std.testing; + const alloc = testing.allocator; + + var s: Set = .{}; + defer s.deinit(alloc); + + try s.parseAndPut(alloc, "ctrl+shift+d=duplicate_tab"); + { + const t: Trigger = .{ .key = .{ .unicode = 'd' }, .mods = .{ .ctrl = true, .shift = true } }; + const e = s.get(t).?.value_ptr.*; + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .duplicate_tab); + try testing.expectEqual(Flags{}, e.leaf.flags); + } +} + test "set: parseAndPut overwrite sequence" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/input/command.zig b/src/input/command.zig index 63feb2edf4..83a1594365 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -258,6 +258,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Open a new tab.", }}, + .duplicate_tab => comptime &.{.{ + .action = .duplicate_tab, + .title = "Duplicate Tab", + .description = "Duplicate the current tab.", + }}, + .move_tab => comptime &.{ .{ .action = .{ .move_tab = -1 },