mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 07:46:12 +03:00
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:
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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 }
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -4266,6 +4266,7 @@ fn closingAction(action: input.Binding.Action) bool {
|
||||
return switch (action) {
|
||||
.close_surface,
|
||||
.close_window,
|
||||
.close_tab,
|
||||
=> true,
|
||||
|
||||
else => false,
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
Reference in New Issue
Block a user