diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 2fe835303..a102beb91 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -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) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index 0a197fe65..4a01d5c62 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -17,6 +17,7 @@ + @@ -155,6 +156,12 @@ + + + + + + diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 2da498e3a..ef4054c2e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -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 } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 3a2510e3b..43c0f245a 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index deca8f89d..71fac4a99 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -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 diff --git a/src/Surface.zig b/src/Surface.zig index 50ef3c0cf..91f914f38 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4266,6 +4266,7 @@ fn closingAction(action: input.Binding.Action) bool { return switch (action) { .close_surface, .close_window, + .close_tab, => true, else => false, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index d0a34efe4..8cd3797ec 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -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, }; }