Add close tabs on the right action

pull/9783/head
George Papadakis 2025-12-01 20:15:53 +02:00 committed by Mitchell Hashimoto
parent 894e8d91ba
commit 625d7274bf
12 changed files with 231 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,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 }

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

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

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

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

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

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