From c26323d69708dc3d2dcad959b5a38b307ad26196 Mon Sep 17 00:00:00 2001 From: jamylak Date: Sat, 23 Aug 2025 14:41:01 +1000 Subject: [PATCH] Close other tabs feature on Mac. 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 make a default short cut for close other tabs --- include/ghostty.h | 1 + macos/Sources/App/macOS/MainMenu.xib | 4 +- .../Terminal/TerminalController.swift | 109 ++++++++++++++++-- macos/Sources/Ghostty/Ghostty.App.swift | 26 ++++- macos/Sources/Ghostty/Package.swift | 3 + src/Surface.zig | 6 + src/apprt/action.zig | 5 + src/apprt/gtk-ng/class/application.zig | 1 + src/apprt/gtk-ng/class/command_palette.zig | 1 + src/apprt/gtk/App.zig | 1 + src/apprt/gtk/CommandPalette.zig | 1 + src/input/Binding.zig | 7 ++ src/input/command.zig | 6 + 13 files changed, 160 insertions(+), 11 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 082711836..37c844472 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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, diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 5cd6d9bec..c97ed7c61 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 644a0c8ac..414f38d81 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -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 } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index c94f40291..e64ed30ee 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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: diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 73487f1bd..85040d390 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -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") diff --git a/src/Surface.zig b/src/Surface.zig index 770c2daef..686d214cd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index d2d444c3a..1a7a2a345 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -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, diff --git a/src/apprt/gtk-ng/class/application.zig b/src/apprt/gtk-ng/class/application.zig index 29a124798..8cf61c4ba 100644 --- a/src/apprt/gtk-ng/class/application.zig +++ b/src/apprt/gtk-ng/class/application.zig @@ -625,6 +625,7 @@ pub const Application = extern struct { // Unimplemented .secure_input, .close_all_windows, + .close_other_tabs, .float_window, .toggle_visibility, .cell_size, diff --git a/src/apprt/gtk-ng/class/command_palette.zig b/src/apprt/gtk-ng/class/command_palette.zig index 8b7bb328c..c2a4ec215 100644 --- a/src/apprt/gtk-ng/class/command_palette.zig +++ b/src/apprt/gtk-ng/class/command_palette.zig @@ -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, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 0f75a2d97..70c03e098 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -528,6 +528,7 @@ pub fn performAction( // Unimplemented .close_all_windows, + .close_other_tabs, .float_window, .toggle_visibility, .cell_size, diff --git a/src/apprt/gtk/CommandPalette.zig b/src/apprt/gtk/CommandPalette.zig index 076459dbd..e0ff8c177 100644 --- a/src/apprt/gtk/CommandPalette.zig +++ b/src/apprt/gtk/CommandPalette.zig @@ -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, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 2ed6c2636..6db0decc2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -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, diff --git a/src/input/command.zig b/src/input/command.zig index 615ffb713..68652cce3 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -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",