Add close_tab keybinding action for macOS (#4395)

This PR adds support for a dedicated `close_tab` keybinding action,
allowing users to bind specific keys for closing tabs. The
implementation:

- Adds `close_tab` as a new keybinding action
- Preserves all existing confirmation dialogs for running processes
- Works seamlessly with macOS native tab system

### Testing
- [x] Tested with single tabs
- [x] Tested with multiple tabs
- [x] Tested with running processes (confirmation dialog)
- [x] Tested with splits within tabs

<img width="797" alt="image"
src="https://github.com/user-attachments/assets/8e09eea3-1f71-40a3-a835-76de14013a29"
/>


https://github.com/user-attachments/assets/155210f7-20fe-4a96-8800-6969df214871

Partially resolved #4331
This commit is contained in:
Mitchell Hashimoto
2025-01-08 12:17:27 -08:00
committed by GitHub
7 changed files with 112 additions and 33 deletions

View File

@ -30,6 +30,7 @@ class AppDelegate: NSObject,
@IBOutlet private var menuSplitRight: NSMenuItem?
@IBOutlet private var menuSplitDown: NSMenuItem?
@IBOutlet private var menuClose: NSMenuItem?
@IBOutlet private var menuCloseTab: NSMenuItem?
@IBOutlet private var menuCloseWindow: NSMenuItem?
@IBOutlet private var menuCloseAllWindows: NSMenuItem?
@ -347,6 +348,7 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow)
syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab)
syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose)
syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab)
syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow)
syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows)
syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight)

View File

@ -17,6 +17,7 @@
<outlet property="menuCheckForUpdates" destination="GEA-5y-yzH" id="0nV-Tf-nJQ"/>
<outlet property="menuClose" destination="DVo-aG-piG" id="R3t-0C-aSU"/>
<outlet property="menuCloseAllWindows" destination="yKr-Vi-Yqw" id="Zet-Ir-zbm"/>
<outlet property="menuCloseTab" destination="Obb-Mk-j8J" id="Gda-L0-gdz"/>
<outlet property="menuCloseWindow" destination="W5w-UZ-crk" id="6ff-BT-ENV"/>
<outlet property="menuCopy" destination="Jqf-pv-Zcu" id="bKd-1C-oy9"/>
<outlet property="menuDecreaseFontSize" destination="kzb-SZ-dOA" id="Y1B-Vh-6Z2"/>
@ -155,6 +156,12 @@
<action selector="close:" target="-1" id="tTZ-2b-Mbm"/>
</connections>
</menuItem>
<menuItem title="Close Tab" id="Obb-Mk-j8J">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="closeTab:" target="-1" id="UBb-Bd-nkj"/>
</connections>
</menuItem>
<menuItem title="Close Window" id="W5w-UZ-crk">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>

View File

@ -60,6 +60,11 @@ class TerminalController: BaseTerminalController {
selector: #selector(onGotoTab),
name: Ghostty.Notification.ghosttyGotoTab,
object: nil)
center.addObserver(
self,
selector: #selector(onCloseTab),
name: .ghosttyCloseTab,
object: nil)
center.addObserver(
self,
selector: #selector(ghosttyConfigDidChange(_:)),
@ -508,7 +513,50 @@ class TerminalController: BaseTerminalController {
ghostty.newTab(surface: surface)
}
@IBAction override func closeWindow(_ sender: Any) {
private func confirmClose(
window: NSWindow,
messageText: String,
informativeText: String,
completion: @escaping () -> Void
) {
// If we need confirmation by any, show one confirmation for all windows
// in the tab group.
let alert = NSAlert()
alert.messageText = messageText
alert.informativeText = informativeText
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window) { response in
if response == .alertFirstButtonReturn {
completion()
}
}
}
@IBAction func closeTab(_ sender: Any?) {
guard let window = window else { return }
guard window.tabGroup != nil else {
// No tabs, no tab group, just perform a normal close.
window.performClose(sender)
return
}
if surfaceTree?.needsConfirmQuit() ?? false {
confirmClose(
window: window,
messageText: "Close Tab?",
informativeText: "The terminal still has a running process. If you close the tab the process will be killed."
) {
window.close()
}
return
}
window.close()
}
@IBAction override func closeWindow(_ sender: Any?) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else {
// No tabs, no tab group, just perform a normal close.
@ -523,47 +571,34 @@ class TerminalController: BaseTerminalController {
}
// Check if any windows require close confirmation.
var needsConfirm: Bool = false
for tabWindow in tabGroup.windows {
guard let c = tabWindow.windowController as? TerminalController else { continue }
if (c.surfaceTree?.needsConfirmQuit() ?? false) {
needsConfirm = true
break
let needsConfirm = tabGroup.windows.contains { tabWindow in
guard let controller = tabWindow.windowController as? TerminalController else {
return false
}
return controller.surfaceTree?.needsConfirmQuit() ?? false
}
// If none need confirmation then we can just close all the windows.
if (!needsConfirm) {
for tabWindow in tabGroup.windows {
tabWindow.close()
}
if !needsConfirm {
tabGroup.windows.forEach { $0.close() }
return
}
// If we need confirmation by any, show one confirmation for all windows
// in the tab group.
let alert = NSAlert()
alert.messageText = "Close Window?"
alert.informativeText = "All terminal sessions in this window will be terminated."
alert.addButton(withTitle: "Close Window")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window, completionHandler: { response in
if (response == .alertFirstButtonReturn) {
for tabWindow in tabGroup.windows {
tabWindow.close()
confirmClose(
window: window,
messageText: "Close Window?",
informativeText: "All terminal sessions in this window will be terminated."
) {
tabGroup.windows.forEach { $0.close() }
}
}
})
}
@IBAction func toggleGhosttyFullScreen(_ sender: Any) {
@IBAction func toggleGhosttyFullScreen(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleFullscreen(surface: surface)
}
@IBAction func toggleTerminalInspector(_ sender: Any) {
@IBAction func toggleTerminalInspector(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleTerminalInspector(surface: surface)
}
@ -720,6 +755,12 @@ class TerminalController: BaseTerminalController {
targetWindow.makeKeyAndOrderFront(nil)
}
@objc private func onCloseTab(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard surfaceTree?.contains(view: target) ?? false else { return }
closeTab(self)
}
@objc private func onToggleFullscreen(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }

View File

@ -448,6 +448,9 @@ extension Ghostty {
case GHOSTTY_ACTION_NEW_SPLIT:
newSplit(app, target: target, direction: action.action.new_split)
case GHOSTTY_ACTION_CLOSE_TAB:
closeTab(app, target: target)
case GHOSTTY_ACTION_TOGGLE_FULLSCREEN:
toggleFullscreen(app, target: target, mode: action.action.toggle_fullscreen)
@ -651,6 +654,27 @@ 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")
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: .ghosttyCloseTab,
object: surfaceView
)
default:
assertionFailure()
}
}
private static func toggleFullscreen(
_ app: ghostty_app_t,
target: ghostty_target_s,

View File

@ -236,6 +236,9 @@ extension Notification.Name {
/// Goto tab. Has tab index in the userinfo.
static let ghosttyMoveTab = Notification.Name("com.mitchellh.ghostty.moveTab")
static let GhosttyMoveTabKey = ghosttyMoveTab.rawValue
/// Close tab
static let ghosttyCloseTab = Notification.Name("com.mitchellh.ghostty.closeTab")
}
// NOTE: I am moving all of these to Notification.Name extensions over time. This

View File

@ -4266,6 +4266,7 @@ fn closingAction(action: input.Binding.Action) bool {
return switch (action) {
.close_surface,
.close_window,
.close_tab,
=> true,
else => false,

View File

@ -375,6 +375,10 @@ pub const Action = union(enum) {
/// configured.
close_surface: void,
/// Close the current tab, regardless of how many splits there may be.
/// This will trigger close confirmation as configured.
close_tab: void,
/// Close the window, regardless of how many tabs or splits there may be.
/// This will trigger close confirmation as configured.
close_window: void,
@ -383,9 +387,6 @@ pub const Action = union(enum) {
/// This only works for macOS currently.
close_all_windows: void,
/// Closes the tab belonging to the currently focused split.
close_tab: void,
/// Toggle fullscreen mode of window.
toggle_fullscreen: void,
@ -729,6 +730,7 @@ pub const Action = union(enum) {
.write_screen_file,
.write_selection_file,
.close_surface,
.close_tab,
.close_window,
.toggle_fullscreen,
.toggle_window_decorations,
@ -753,7 +755,6 @@ pub const Action = union(enum) {
.resize_split,
.equalize_splits,
.inspector,
.close_tab,
=> .surface,
};
}