mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-01 05:09:01 +00:00
197 lines
6.0 KiB
Swift
197 lines
6.0 KiB
Swift
import AppKit
|
|
import AppIntents
|
|
import Combine
|
|
import SwiftUI
|
|
import os
|
|
|
|
private let logger = Logger(
|
|
subsystem: Bundle.main.bundleIdentifier!,
|
|
category: "AppIntents.TerminalEntity"
|
|
)
|
|
|
|
struct TerminalEntity: AppEntity {
|
|
let id: UUID
|
|
|
|
@Property(title: "Title")
|
|
var title: String
|
|
|
|
@Property(title: "Working Directory")
|
|
var workingDirectory: String?
|
|
|
|
@Property(title: "PID")
|
|
var pid: Int?
|
|
|
|
@Property(title: "TTY")
|
|
var tty: String?
|
|
|
|
@Property(title: "Kind")
|
|
var kind: Kind
|
|
|
|
var screenshot: NSImage?
|
|
|
|
static var typeDisplayRepresentation: TypeDisplayRepresentation {
|
|
TypeDisplayRepresentation(name: "Terminal")
|
|
}
|
|
|
|
@MainActor
|
|
var displayRepresentation: DisplayRepresentation {
|
|
var rep = DisplayRepresentation(title: "\(title)")
|
|
if let screenshot,
|
|
let data = screenshot.tiffRepresentation {
|
|
rep.image = .init(data: data)
|
|
}
|
|
|
|
return rep
|
|
}
|
|
|
|
/// Returns the view associated with this entity. This may no longer exist.
|
|
@MainActor
|
|
var surfaceView: Ghostty.SurfaceView? {
|
|
Self.defaultQuery.all.first { $0.id == self.id }
|
|
}
|
|
|
|
@MainActor
|
|
var surfaceModel: Ghostty.Surface? {
|
|
surfaceView?.surfaceModel
|
|
}
|
|
|
|
static var defaultQuery = TerminalQuery()
|
|
|
|
@MainActor
|
|
init(_ view: Ghostty.SurfaceView) {
|
|
self.id = view.id
|
|
self.title = view.title
|
|
self.workingDirectory = view.pwd
|
|
self.pid = view.surfaceModel?.foregroundPID
|
|
self.tty = view.surfaceModel?.ttyName
|
|
if let nsImage = ImageRenderer(content: view.screenshot()).nsImage {
|
|
self.screenshot = nsImage
|
|
}
|
|
|
|
// Determine the kind based on the window controller type
|
|
if view.window?.windowController is QuickTerminalController {
|
|
self.kind = .quick
|
|
} else {
|
|
self.kind = .normal
|
|
}
|
|
}
|
|
|
|
/// Wait for the surface to be updated then create an entity
|
|
///
|
|
/// The PTY/Config sets the title and pwd asynchronously shortly after the
|
|
/// surface is created, so concurrently wait for the second published
|
|
/// value of each (the first is the current value) before returning.
|
|
///
|
|
/// If a value never arrives, the timeout completes the publisher and
|
|
/// we fall back to the current value.
|
|
///
|
|
/// Waiting for the title and pwd also gives the SurfaceView time to lay
|
|
/// out, so the screenshot we capture afterwards reflects the rendered view.
|
|
@MainActor
|
|
init(view: Ghostty.SurfaceView) async {
|
|
self.id = view.id
|
|
self.tty = view.surfaceModel?.ttyName
|
|
|
|
let waitTimeout = DispatchQueue.SchedulerTimeType.Stride.seconds(1)
|
|
let titleValues = view.$title.dropFirst()
|
|
.setFailureType(to: Error.self)
|
|
.timeout(waitTimeout, scheduler: DispatchQueue.main, customError: { EntityTimeoutError() })
|
|
.handleEvents(receiveCompletion: { completion in
|
|
if case .failure = completion {
|
|
logger.error("failed to get terminal's title: timeout")
|
|
}
|
|
})
|
|
.replaceError(with: view.title)
|
|
.values
|
|
let pwdValues = view.$pwd.dropFirst()
|
|
.setFailureType(to: Error.self)
|
|
.timeout(waitTimeout, scheduler: DispatchQueue.main, customError: { EntityTimeoutError() })
|
|
.handleEvents(receiveCompletion: { completion in
|
|
if case .failure = completion {
|
|
logger.error("failed to get terminal's pwd: timeout")
|
|
}
|
|
})
|
|
.replaceError(with: view.pwd)
|
|
.values
|
|
async let title = titleValues.first(where: { _ in true })
|
|
async let pwd = pwdValues.first(where: { _ in true })
|
|
|
|
self.title = await title ?? ""
|
|
self.workingDirectory = await pwd ?? ""
|
|
|
|
// Wait for the title and pwd then get latest pid and screenshots.
|
|
// This should gave SurfaceView enough time to layout in the window and we can get the most recent process's PID
|
|
// Determine the kind based on the window controller type
|
|
if view.window?.windowController is QuickTerminalController {
|
|
self.kind = .quick
|
|
} else {
|
|
self.kind = .normal
|
|
}
|
|
|
|
self.pid = view.surfaceModel?.foregroundPID
|
|
if let nsImage = ImageRenderer(content: view.screenshot()).nsImage {
|
|
self.screenshot = nsImage
|
|
}
|
|
}
|
|
}
|
|
|
|
extension TerminalEntity {
|
|
enum Kind: String, AppEnum {
|
|
case normal
|
|
case quick
|
|
|
|
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Kind")
|
|
|
|
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
|
|
.normal: .init(title: "Normal"),
|
|
.quick: .init(title: "Quick")
|
|
]
|
|
}
|
|
}
|
|
|
|
struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery {
|
|
@MainActor
|
|
func entities(for identifiers: [TerminalEntity.ID]) async throws -> [TerminalEntity] {
|
|
return all.filter {
|
|
identifiers.contains($0.id)
|
|
}.map {
|
|
TerminalEntity($0)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func entities(matching string: String) async throws -> [TerminalEntity] {
|
|
return all.filter {
|
|
$0.title.localizedCaseInsensitiveContains(string)
|
|
}.map {
|
|
TerminalEntity($0)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func allEntities() async throws -> [TerminalEntity] {
|
|
return all.map { TerminalEntity($0) }
|
|
}
|
|
|
|
@MainActor
|
|
func suggestedEntities() async throws -> [TerminalEntity] {
|
|
return try await allEntities()
|
|
}
|
|
|
|
@MainActor
|
|
var all: [Ghostty.SurfaceView] {
|
|
// Find all of our terminal windows. This will include the quick terminal
|
|
// but only if it was previously opened.
|
|
let controllers = NSApp.windows.compactMap {
|
|
$0.windowController as? BaseTerminalController
|
|
}
|
|
|
|
// Get all our surfaces
|
|
return controllers.flatMap {
|
|
$0.surfaceTree.root?.leaves() ?? []
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct EntityTimeoutError: Error {}
|