Add close tabs on the right action (#9783)

<img width="1694" height="1146" alt="image"
src="https://github.com/user-attachments/assets/f9e1e7e6-7cfe-4760-85fe-def7c10f4110"
/>
pull/9869/head
Mitchell Hashimoto 2025-12-10 21:09:13 -08:00 committed by GitHub
commit a531ea8b08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 208 additions and 7 deletions

View File

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

View File

@ -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,46 @@ 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 }
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 to 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 +1123,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 +1149,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 +1379,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 +1447,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 }

View File

@ -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,17 @@ class TerminalWindow: NSWindow {
override func awakeFromNib() {
// Notify that this terminal window has loaded
NotificationCenter.default.post(name: Self.terminalDidAwake, object: self)
// This is fragile, but there doesn't seem to be an official API for customizing
// native tab bar menus.
tabMenuObserver = NotificationCenter.default.addObserver(
forName: Notification.Name(rawValue: "NSMenuWillOpenNotification"),
object: nil,
queue: .main
) { [weak self] n in
guard let self, let menu = n.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 +215,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 +292,52 @@ class TerminalWindow: NSWindow {
}
}
private func configureTabContextMenuIfNeeded(_ menu: NSMenu) {
guard isTabContextMenu(menu) else { return }
// Get the target from an existing menu item. The native tab context menu items
// target the specific window/controller that was right-clicked, not the focused one.
// We need to use that same target so validation and action use the correct tab.
let targetController = menu.items
.first { $0.action == NSSelectorFromString("performClose:") }
.flatMap { $0.target as? NSWindow }
.flatMap { $0.windowController as? TerminalController }
// Close tabs to the right
let item = NSMenuItem(title: "Close Tabs to the Right", action: #selector(TerminalController.closeTabsOnTheRight(_:)), keyEquivalent: "")
item.identifier = Self.closeTabsOnRightMenuItemIdentifier
item.target = targetController
item.setImageIfDesired(systemSymbolName: "xmark")
if !menu.insertItem(item, after: NSSelectorFromString("performCloseOtherTabs:")) &&
!menu.insertItem(item, after: NSSelectorFromString("performClose:")) {
menu.addItem(item)
}
// Other close items should have the xmark to match Safari on macOS 26
for menuItem in menu.items {
if menuItem.action == NSSelectorFromString("performClose:") ||
menuItem.action == NSSelectorFromString("performCloseOtherTabs:") {
menuItem.setImageIfDesired(systemSymbolName: "xmark")
}
}
}
private func isTabContextMenu(_ menu: NSMenu) -> Bool {
guard NSApp.keyWindow === self else { return false }
// These are the target selectors, at least for macOS 26.
let tabContextSelectors: Set<String> = [
"performClose:",
"performCloseOtherTabs:",
"moveTabToNewWindow:",
"toggleTabOverview:"
]
let selectorNames = Set(menu.items.compactMap { $0.action }.map { NSStringFromSelector($0) })
return !selectorNames.isDisjoint(with: tabContextSelectors)
}
// MARK: Tab Key Equivalents
var keyEquivalent: String? = nil {
@ -517,6 +578,12 @@ class TerminalWindow: NSWindow {
standardWindowButton(.miniaturizeButton)?.isHidden = true
standardWindowButton(.zoomButton)?.isHidden = true
}
deinit {
if let observer = tabMenuObserver {
NotificationCenter.default.removeObserver(observer)
}
}
// MARK: Config

View File

@ -861,6 +861,13 @@ extension Ghostty {
)
return
case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT:
NotificationCenter.default.post(
name: .ghosttyCloseTabsOnTheRight,
object: surfaceView
)
return
default:
assertionFailure()
}

View File

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

View File

@ -0,0 +1,29 @@
import AppKit
extension NSMenu {
/// Inserts a menu item after an existing item with the specified action selector.
///
/// If an item with the same identifier already exists, it is removed first to avoid duplicates.
/// This is useful when menus are cached and reused across different targets.
///
/// - Parameters:
/// - item: The menu item to insert.
/// - action: The action selector to search for. The new item will be inserted after the first
/// item with this action.
/// - Returns: `true` if the item was inserted after the specified action, `false` if the action
/// was not found and the item was not inserted.
@discardableResult
func insertItem(_ item: NSMenuItem, after action: Selector) -> Bool {
if let identifier = item.identifier,
let existing = items.first(where: { $0.identifier == identifier }) {
removeItem(existing)
}
guard let idx = items.firstIndex(where: { $0.action == action }) else {
return false
}
insertItem(item, at: idx + 1)
return true
}
}

View File

@ -5299,6 +5299,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
switch (v) {
.this => .this,
.other => .other,
.right => .right,
},
),

View File

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

View File

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

View File

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

View File

@ -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 to the Right",
.description = "Close all tabs to the right of the current one.",
},
},
.close_window => comptime &.{.{