From 625d7274bf0bcebf17b5cd4ffa853165269489a6 Mon Sep 17 00:00:00 2001 From: George Papadakis Date: Mon, 1 Dec 2025 20:15:53 +0200 Subject: [PATCH] Add close tabs on the right action --- include/ghostty.h | 1 + .../Terminal/TerminalController.swift | 95 ++++++++++++++++++- .../Window Styles/TerminalWindow.swift | 60 ++++++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 7 ++ macos/Sources/Ghostty/Package.swift | 3 + pkg/apple-sdk/build.zig | 27 ++++++ src/Surface.zig | 1 + src/apprt/action.zig | 2 + src/apprt/gtk/class/tab.zig | 1 + src/apprt/gtk/ui/1.5/window.blp | 30 ++++++ src/input/Binding.zig | 6 +- src/input/command.zig | 5 + 12 files changed, 231 insertions(+), 7 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 6cafe8773..cb8646560 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -714,6 +714,7 @@ typedef struct { typedef enum { GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, + GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, } ghostty_action_close_tab_mode_e; // apprt.surface.Message.ChildExited diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5cc2c67f1..1083fb405 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -104,6 +104,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr selector: #selector(onCloseOtherTabs), name: .ghosttyCloseOtherTabs, object: nil) + center.addObserver( + self, + selector: #selector(onCloseTabsOnTheRight), + name: .ghosttyCloseTabsOnTheRight, + object: nil) center.addObserver( self, selector: #selector(onResetWindowSize), @@ -627,6 +632,48 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + private func closeTabsOnTheRightImmediately() { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + if let undoManager { + undoManager.beginUndoGrouping() + } + defer { + undoManager?.endUndoGrouping() + } + + for (_, candidate) in tabsToClose { + if let controller = candidate.windowController as? TerminalController { + controller.closeTabImmediately(registerRedo: false) + } + } + + if let undoManager { + undoManager.setActionName("Close Tabs on the Right") + + undoManager.registerUndo( + withTarget: self, + expiresAfter: undoExpiration + ) { target in + DispatchQueue.main.async { + target.window?.makeKeyAndOrderFront(nil) + } + + undoManager.registerUndo( + withTarget: target, + expiresAfter: target.undoExpiration + ) { target in + target.closeTabsOnTheRightImmediately() + } + } + } + } + /// Closes the current window (including any other tabs) immediately and without /// confirmation. This will setup proper undo state so the action can be undone. private func closeWindowImmediately() { @@ -1078,24 +1125,24 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // If we only have one window then we have no other tabs to close guard tabGroup.windows.count > 1 else { return } - + // Check if we have to confirm close. guard tabGroup.windows.contains(where: { window in // Ignore ourself if window == self.window { return false } - + // Ignore non-terminals guard let controller = window.windowController as? TerminalController else { return false } - + // Check if any surfaces require confirmation return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) }) else { self.closeOtherTabsImmediately() return } - + confirmClose( messageText: "Close Other Tabs?", informativeText: "At least one other tab still has a running process. If you close the tab the process will be killed." @@ -1104,6 +1151,35 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } + @IBAction func closeTabsOnTheRight(_ sender: Any?) { + guard let window = window else { return } + guard let tabGroup = window.tabGroup else { return } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return } + + let tabsToClose = tabGroup.windows.enumerated().filter { $0.offset > currentIndex } + guard !tabsToClose.isEmpty else { return } + + let needsConfirm = tabsToClose.contains { (_, candidate) in + guard let controller = candidate.windowController as? TerminalController else { + return false + } + + return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) + } + + if !needsConfirm { + self.closeTabsOnTheRightImmediately() + return + } + + confirmClose( + messageText: "Close Tabs on the Right?", + informativeText: "At least one tab to the right still has a running process. If you close the tab the process will be killed." + ) { + self.closeTabsOnTheRightImmediately() + } + } + @IBAction func returnToDefaultSize(_ sender: Any?) { guard let window, let defaultSize else { return } defaultSize.apply(to: window) @@ -1305,6 +1381,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr closeOtherTabs(self) } + @objc private func onCloseTabsOnTheRight(notification: SwiftUI.Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.contains(target) else { return } + closeTabsOnTheRight(self) + } + @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } @@ -1367,6 +1449,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr extension TerminalController { override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { + case #selector(closeTabsOnTheRight): + guard let window, let tabGroup = window.tabGroup else { return false } + guard let currentIndex = tabGroup.windows.firstIndex(of: window) else { return false } + return tabGroup.windows.enumerated().contains { $0.offset > currentIndex } + case #selector(returnToDefaultSize): guard let window else { return false } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 2208d99cf..cbbbf99f7 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -26,6 +26,8 @@ class TerminalWindow: NSWindow { /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig = .init() + + private var tabMenuObserver: NSObjectProtocol? = nil /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. @@ -53,6 +55,15 @@ class TerminalWindow: NSWindow { override func awakeFromNib() { // Notify that this terminal window has loaded NotificationCenter.default.post(name: Self.terminalDidAwake, object: self) + + tabMenuObserver = NotificationCenter.default.addObserver( + forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"), + object: nil, + queue: .main + ) { [weak self] note in + guard let self, let menu = note.object as? NSMenu else { return } + self.configureTabContextMenuIfNeeded(menu) + } // This is required so that window restoration properly creates our tabs // again. I'm not sure why this is required. If you don't do this, then @@ -202,6 +213,8 @@ class TerminalWindow: NSWindow { /// added. static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar") + private static let closeTabsOnRightMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.mitchellh.ghostty.closeTabsOnTheRightMenuItem") + func findTitlebarView() -> NSView? { // Find our tab bar. If it doesn't exist we don't do anything. // @@ -277,6 +290,47 @@ class TerminalWindow: NSWindow { } } + private func configureTabContextMenuIfNeeded(_ menu: NSMenu) { + guard isTabContextMenu(menu) else { return } + if let existing = menu.items.first(where: { $0.identifier == Self.closeTabsOnRightMenuItemIdentifier }) { + menu.removeItem(existing) + } + guard let terminalController else { return } + + let title = NSLocalizedString("Close Tabs on the Right", comment: "Tab context menu option") + let item = NSMenuItem(title: title, action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "") + item.identifier = Self.closeTabsOnRightMenuItemIdentifier + item.target = terminalController + item.isEnabled = true + + let closeOtherIndex = menu.items.firstIndex(where: { menuItem in + guard let action = menuItem.action else { return false } + let name = NSStringFromSelector(action).lowercased() + return name.contains("close") && name.contains("other") && name.contains("tab") + }) + + let closeThisIndex = menu.items.firstIndex(where: { menuItem in + guard let action = menuItem.action else { return false } + let name = NSStringFromSelector(action).lowercased() + return name.contains("close") && name.contains("tab") + }) + + if let idx = closeOtherIndex { + menu.insertItem(item, at: idx + 1) + } else if let idx = closeThisIndex { + menu.insertItem(item, at: idx + 1) + } else { + menu.addItem(item) + } + } + + private func isTabContextMenu(_ menu: NSMenu) -> Bool { + guard NSApp.keyWindow === self else { return false } + let selectorNames = menu.items.compactMap { $0.action }.map { NSStringFromSelector($0).lowercased() } + return selectorNames.contains { $0.contains("close") && $0.contains("tab") } + } + + // MARK: Tab Key Equivalents var keyEquivalent: String? = nil { @@ -517,6 +571,12 @@ class TerminalWindow: NSWindow { standardWindowButton(.miniaturizeButton)?.isHidden = true standardWindowButton(.zoomButton)?.isHidden = true } + + deinit { + if let observer = tabMenuObserver { + NotificationCenter.default.removeObserver(observer) + } + } // MARK: Config diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 39ebbb51f..f6452e54e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -861,6 +861,13 @@ extension Ghostty { ) return + case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT: + NotificationCenter.default.post( + name: .ghosttyCloseTabsOnTheRight, + object: surfaceView + ) + return + default: assertionFailure() } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 7ee815caa..4b3eb60aa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -380,6 +380,9 @@ extension Notification.Name { /// Close other tabs static let ghosttyCloseOtherTabs = Notification.Name("com.mitchellh.ghostty.closeOtherTabs") + /// Close tabs to the right of the focused tab + static let ghosttyCloseTabsOnTheRight = Notification.Name("com.mitchellh.ghostty.closeTabsOnTheRight") + /// Close window static let ghosttyCloseWindow = Notification.Name("com.mitchellh.ghostty.closeWindow") diff --git a/pkg/apple-sdk/build.zig b/pkg/apple-sdk/build.zig index c573c3910..32cb726fd 100644 --- a/pkg/apple-sdk/build.zig +++ b/pkg/apple-sdk/build.zig @@ -30,6 +30,7 @@ pub fn addPaths( framework: []const u8, system_include: []const u8, library: []const u8, + cxx_include: []const u8, }) = .{}; }; @@ -82,11 +83,36 @@ pub fn addPaths( }); }; + const cxx_include_path = cxx: { + const preferred = try std.fs.path.join(b.allocator, &.{ + libc.sys_include_dir.?, + "c++", + "v1", + }); + if (std.fs.accessAbsolute(preferred, .{})) |_| { + break :cxx preferred; + } else |_| {} + + const sdk_root = std.fs.path.dirname(libc.sys_include_dir.?).?; + const fallback = try std.fs.path.join(b.allocator, &.{ + sdk_root, + "include", + "c++", + "v1", + }); + if (std.fs.accessAbsolute(fallback, .{})) |_| { + break :cxx fallback; + } else |_| {} + + break :cxx preferred; + }; + gop.value_ptr.* = .{ .libc = path, .framework = framework_path, .system_include = libc.sys_include_dir.?, .library = library_path, + .cxx_include = cxx_include_path, }; } @@ -107,5 +133,6 @@ pub fn addPaths( // https://github.com/ziglang/zig/issues/24024 step.root_module.addSystemFrameworkPath(.{ .cwd_relative = value.framework }); step.root_module.addSystemIncludePath(.{ .cwd_relative = value.system_include }); + step.root_module.addSystemIncludePath(.{ .cwd_relative = value.cxx_include }); step.root_module.addLibraryPath(.{ .cwd_relative = value.library }); } diff --git a/src/Surface.zig b/src/Surface.zig index 653178bdc..9e7ad0b97 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5299,6 +5299,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool switch (v) { .this => .this, .other => .other, + .right => .right, }, ), diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 00bf8685a..365f525f8 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -767,6 +767,8 @@ pub const CloseTabMode = enum(c_int) { this, /// Close all other tabs. other, + /// Close all tabs to the right of the current tab. + right, }; pub const CommandFinished = struct { diff --git a/src/apprt/gtk/class/tab.zig b/src/apprt/gtk/class/tab.zig index c8b5607a6..fb3b8b0ef 100644 --- a/src/apprt/gtk/class/tab.zig +++ b/src/apprt/gtk/class/tab.zig @@ -347,6 +347,7 @@ pub const Tab = extern struct { switch (mode) { .this => tab_view.closePage(page), .other => tab_view.closeOtherPages(page), + .right => tab_view.closePagesAfter(page), } } diff --git a/src/apprt/gtk/ui/1.5/window.blp b/src/apprt/gtk/ui/1.5/window.blp index 8c0a7bedb..de06b04da 100644 --- a/src/apprt/gtk/ui/1.5/window.blp +++ b/src/apprt/gtk/ui/1.5/window.blp @@ -162,6 +162,7 @@ template $GhosttyWindow: Adw.ApplicationWindow { page-attached => $page_attached(); page-detached => $page_detached(); create-window => $tab_create_window(); + menu-model: tab_context_menu; shortcuts: none; } } @@ -192,6 +193,35 @@ menu split_menu { } } +menu tab_context_menu { + section { + item { + label: _("New Tab"); + action: "win.new-tab"; + } + } + + section { + item { + label: _("Close Tab"); + action: "tab.close"; + target: "this"; + } + + item { + label: _("Close Other Tabs"); + action: "tab.close"; + target: "other"; + } + + item { + label: _("Close Tabs on the Right"); + action: "tab.close"; + target: "right"; + } + } +} + menu main_menu { section { item { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 1e7db3592..66fe03651 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -600,9 +600,8 @@ pub const Action = union(enum) { /// of the `confirm-close-surface` configuration setting. close_surface, - /// Close the current tab and all splits therein _or_ close all tabs and - /// splits thein of tabs _other_ than the current tab, depending on the - /// mode. + /// Close the current tab and all splits therein, close all other tabs, or + /// close every tab to the right of the current one depending on the mode. /// /// If the mode is not specified, defaults to closing the current tab. /// @@ -1005,6 +1004,7 @@ pub const Action = union(enum) { pub const CloseTabMode = enum { this, other, + right, pub const default: CloseTabMode = .this; }; diff --git a/src/input/command.zig b/src/input/command.zig index 72fb7f4ee..6baeca23b 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -538,6 +538,11 @@ fn actionCommands(action: Action.Key) []const Command { .title = "Close Other Tabs", .description = "Close all tabs in this window except the current one.", }, + .{ + .action = .{ .close_tab = .right }, + .title = "Close Tabs on the Right", + .description = "Close every tab to the right of the current one.", + }, }, .close_window => comptime &.{.{