Close other tabs feature on Mac. (#8363)

Supporting command line, file menu and keybindings. Default mac shortcut
of `super + alt + o` (other)

Not able to test on Linux so excluding `close_other_tabs` from `gtk` for
now
This commit is contained in:
Mitchell Hashimoto
2025-08-24 08:00:11 -07:00
committed by GitHub
13 changed files with 160 additions and 11 deletions

View File

@@ -709,6 +709,7 @@ typedef enum {
GHOSTTY_ACTION_NEW_WINDOW,
GHOSTTY_ACTION_NEW_TAB,
GHOSTTY_ACTION_CLOSE_TAB,
GHOSTTY_ACTION_CLOSE_OTHER_TABS,
GHOSTTY_ACTION_NEW_SPLIT,
GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
GHOSTTY_ACTION_TOGGLE_MAXIMIZE,

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24123.1" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24093.7"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24123.1"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">

View File

@@ -95,6 +95,11 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
selector: #selector(onCloseTab),
name: .ghosttyCloseTab,
object: nil)
center.addObserver(
self,
selector: #selector(onCloseOtherTabs),
name: .ghosttyCloseOtherTabs,
object: nil)
center.addObserver(
self,
selector: #selector(onResetWindowSize),
@@ -559,7 +564,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
closeWindow(nil)
}
private func closeTabImmediately() {
private func closeTabImmediately(registerRedo: Bool = true) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup,
tabGroup.windows.count > 1 else {
@@ -576,19 +581,69 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
expiresAfter: undoExpiration
) { ghostty in
let newController = TerminalController(ghostty, with: undoState)
// Register redo action
undoManager.registerUndo(
withTarget: newController,
expiresAfter: newController.undoExpiration
) { target in
target.closeTabImmediately()
if registerRedo {
undoManager.registerUndo(
withTarget: newController,
expiresAfter: newController.undoExpiration
) { target in
target.closeTabImmediately()
}
}
}
}
window.close()
}
private func closeOtherTabsImmediately() {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return }
guard tabGroup.windows.count > 1 else { return }
// Start an undo grouping
if let undoManager {
undoManager.beginUndoGrouping()
}
defer {
undoManager?.endUndoGrouping()
}
// Iterate through all tabs except the current one.
for window in tabGroup.windows where window != self.window {
// We ignore any non-terminal tabs. They don't currently exist and we can't
// properly undo them anyways so I'd rather ignore them and get a bug report
// later if and when we introduce non-terminal tabs.
if let controller = window.windowController as? TerminalController {
// We must not register a redo, because it messes with our own redo
// that we register later.
controller.closeTabImmediately(registerRedo: false)
}
}
if let undoManager {
undoManager.setActionName("Close Other Tabs")
// We need to register an undo that refocuses this window. Otherwise, the
// undo operation above for each tab will steal focus.
undoManager.registerUndo(
withTarget: self,
expiresAfter: undoExpiration
) { target in
DispatchQueue.main.async {
target.window?.makeKeyAndOrderFront(nil)
}
// Register redo action
undoManager.registerUndo(
withTarget: target,
expiresAfter: target.undoExpiration
) { target in
target.closeOtherTabsImmediately()
}
}
}
}
/// Closes the current window (including any other tabs) immediately and without
/// confirmation. This will setup proper undo state so the action can be undone.
@@ -1023,6 +1078,38 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
}
@IBAction func closeOtherTabs(_ sender: Any?) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else { return }
// 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."
) {
self.closeOtherTabsImmediately()
}
}
@IBAction func returnToDefaultSize(_ sender: Any?) {
guard let defaultSize else { return }
window?.setFrame(defaultSize, display: true)
@@ -1206,6 +1293,12 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
closeTab(self)
}
@objc private func onCloseOtherTabs(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(target) else { return }
closeOtherTabs(self)
}
@objc private func onCloseWindow(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree.contains(target) else { return }

View File

@@ -457,6 +457,9 @@ extension Ghostty {
case GHOSTTY_ACTION_CLOSE_TAB:
closeTab(app, target: target)
case GHOSTTY_ACTION_CLOSE_OTHER_TABS:
closeOtherTabs(app, target: target)
case GHOSTTY_ACTION_CLOSE_WINDOW:
closeWindow(app, target: target)
@@ -781,7 +784,7 @@ extension Ghostty {
private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("close tab does nothing with an app target")
Ghostty.logger.warning("close tabs does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
@@ -799,6 +802,27 @@ extension Ghostty {
}
}
private static func closeOtherTabs(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("close other tabs does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
NotificationCenter.default.post(
name: .ghosttyCloseOtherTabs,
object: surfaceView
)
default:
assertionFailure()
}
}
private static func closeWindow(_ app: ghostty_app_t, target: ghostty_target_s) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:

View File

@@ -329,6 +329,9 @@ extension Notification.Name {
/// Close tab
static let ghosttyCloseTab = Notification.Name("com.mitchellh.ghostty.closeTab")
/// Close other tabs
static let ghosttyCloseOtherTabs = Notification.Name("com.mitchellh.ghostty.closeOtherTabs")
/// Close window
static let ghosttyCloseWindow = Notification.Name("com.mitchellh.ghostty.closeWindow")

View File

@@ -4840,6 +4840,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{},
),
.close_other_tabs => return try self.rt_app.performAction(
.{ .surface = self },
.close_other_tabs,
{},
),
.select_all => {
const sel = self.io.terminal.screen.selectAll();
if (sel) |s| {

View File

@@ -86,6 +86,10 @@ pub const Action = union(Key) {
/// Closes the tab belonging to the currently focused split.
close_tab,
/// Closes all tabs in the current window other than the currently
/// focused tab.
close_other_tabs,
/// Create a new split. The value determines the location of the split
/// relative to the target.
new_split: SplitDirection,
@@ -300,6 +304,7 @@ pub const Action = union(Key) {
new_window,
new_tab,
close_tab,
close_other_tabs,
new_split,
close_all_windows,
toggle_maximize,

View File

@@ -625,6 +625,7 @@ pub const Application = extern struct {
// Unimplemented
.secure_input,
.close_all_windows,
.close_other_tabs,
.float_window,
.toggle_visibility,
.cell_size,

View File

@@ -156,6 +156,7 @@ pub const CommandPalette = extern struct {
// for GTK.
switch (command.action) {
.close_all_windows,
.close_other_tabs,
.toggle_secure_input,
.check_for_updates,
.redo,

View File

@@ -528,6 +528,7 @@ pub fn performAction(
// Unimplemented
.close_all_windows,
.close_other_tabs,
.float_window,
.toggle_visibility,
.cell_size,

View File

@@ -108,6 +108,7 @@ pub fn updateConfig(self: *CommandPalette, config: *const configpkg.Config) !voi
// or don't make sense for GTK
switch (command.action) {
.close_all_windows,
.close_other_tabs,
.toggle_secure_input,
.check_for_updates,
.redo,

View File

@@ -558,6 +558,12 @@ pub const Action = union(enum) {
/// of the `confirm-close-surface` configuration setting.
close_tab,
/// Close all tabs other than the currently focused one within the same
/// window.
///
/// Only available on macOS currently.
close_other_tabs,
/// Close the current window and all tabs and splits therein.
///
/// This might trigger a close confirmation popup, depending on the value
@@ -1052,6 +1058,7 @@ pub const Action = union(enum) {
.write_selection_file,
.close_surface,
.close_tab,
.close_other_tabs,
.close_window,
.toggle_maximize,
.toggle_fullscreen,

View File

@@ -375,6 +375,12 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Show the on-screen keyboard if present.",
}},
.close_other_tabs => comptime &.{.{
.action = .close_other_tabs,
.title = "Close Other Tabs",
.description = "Close all tabs in this window except the current one.",
}},
.open_config => comptime &.{.{
.action = .open_config,
.title = "Open Config",