macos: Close Terminal Intent

pull/7634/head
Mitchell Hashimoto 2025-06-19 20:00:24 -07:00
parent 0a27aef508
commit f096675eaf
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
5 changed files with 88 additions and 37 deletions

View File

@ -13,6 +13,7 @@
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; };
A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; };
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
@ -149,6 +150,7 @@
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = "<group>"; };
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = "<group>"; };
A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = "<group>"; };
@ -625,6 +627,7 @@
isa = PBXGroup;
children = (
A5E408412E0453370035FEAC /* Entities */,
A511940E2E050590007258CC /* CloseTerminalIntent.swift */,
A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */,
A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */,
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */,
@ -793,6 +796,7 @@
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */,
A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */,
A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */,
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,

View File

@ -0,0 +1,38 @@
import AppKit
import AppIntents
import GhosttyKit
struct CloseTerminalIntent: AppIntent {
static var title: LocalizedStringResource = "Close Terminal"
static var description = IntentDescription("Close an existing terminal.")
@Parameter(
title: "Terminal",
description: "The terminal to close.",
)
var terminal: TerminalEntity
@Parameter(
title: "Command",
description: "Command to execute instead of the default shell.",
default: true
)
var confirm: Bool
@available(macOS 26.0, *)
static var supportedModes: IntentModes = .background
@MainActor
func perform() async throws -> some IntentResult {
guard let surfaceView = terminal.surfaceView else {
throw GhosttyIntentError.surfaceNotFound
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
return .result()
}
controller.closeSurface(surfaceView, withConfirmation: confirm)
return .result()
}
}

View File

@ -218,19 +218,19 @@ class QuickTerminalController: BaseTerminalController {
}
}
override func closeSurfaceNode(
override func closeSurface(
_ node: SplitTree<Ghostty.SurfaceView>.Node,
withConfirmation: Bool = true
) {
// If this isn't the root then we're dealing with a split closure.
if surfaceTree.root != node {
super.closeSurfaceNode(node, withConfirmation: withConfirmation)
super.closeSurface(node, withConfirmation: withConfirmation)
return
}
// If this isn't a final leaf then we're dealing with a split closure
guard case .leaf(let surface) = node else {
super.closeSurfaceNode(node, withConfirmation: withConfirmation)
super.closeSurface(node, withConfirmation: withConfirmation)
return
}

View File

@ -300,6 +300,46 @@ class BaseTerminalController: NSWindowController,
self.alert = alert
}
/// Close a surface from a view.
func closeSurface(
_ view: Ghostty.SurfaceView,
withConfirmation: Bool = true
) {
guard let node = surfaceTree.root?.node(view: view) else { return }
closeSurface(node, withConfirmation: withConfirmation)
}
/// Close a surface node (which may contain splits), requesting confirmation if necessary.
///
/// This will also insert the proper undo stack information in.
func closeSurface(
_ node: SplitTree<Ghostty.SurfaceView>.Node,
withConfirmation: Bool = true
) {
// This node must be part of our tree
guard surfaceTree.contains(node) else { return }
// If the child process is not alive, then we exit immediately
guard withConfirmation else {
removeSurfaceNode(node)
return
}
// Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
// due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
// confirmationDialog allows the user to Cmd-W close the alert, but when doing
// so SwiftUI does not update any of the bindings to note that window is no longer
// being shown, and provides no callback to detect this.
confirmClose(
messageText: "Close Terminal?",
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
) { [weak self] in
if let self {
self.removeSurfaceNode(node)
}
}
}
// MARK: Split Tree Management
/// Find the next surface to focus when a node is being closed.
@ -460,42 +500,11 @@ class BaseTerminalController: NSWindowController,
@objc private func ghosttyDidCloseSurface(_ notification: Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard let node = surfaceTree.root?.node(view: target) else { return }
closeSurfaceNode(
closeSurface(
node,
withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false)
}
/// Close a surface node (which may contain splits), requesting confirmation if necessary.
///
/// This will also insert the proper undo stack information in.
func closeSurfaceNode(
_ node: SplitTree<Ghostty.SurfaceView>.Node,
withConfirmation: Bool = true
) {
// This node must be part of our tree
guard surfaceTree.contains(node) else { return }
// If the child process is not alive, then we exit immediately
guard withConfirmation else {
removeSurfaceNode(node)
return
}
// Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
// due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
// confirmationDialog allows the user to Cmd-W close the alert, but when doing
// so SwiftUI does not update any of the bindings to note that window is no longer
// being shown, and provides no callback to detect this.
confirmClose(
messageText: "Close Terminal?",
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
) { [weak self] in
if let self {
self.removeSurfaceNode(node)
}
}
}
@objc private func ghosttyDidNewSplit(_ notification: Notification) {
// The target must be within our tree
guard let oldView = notification.object as? Ghostty.SurfaceView else { return }

View File

@ -519,13 +519,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
/// This is called anytime a node in the surface tree is being removed.
override func closeSurfaceNode(
override func closeSurface(
_ node: SplitTree<Ghostty.SurfaceView>.Node,
withConfirmation: Bool = true
) {
// If this isn't the root then we're dealing with a split closure.
if surfaceTree.root != node {
super.closeSurfaceNode(node, withConfirmation: withConfirmation)
super.closeSurface(node, withConfirmation: withConfirmation)
return
}