244 lines
8.4 KiB
Swift
244 lines
8.4 KiB
Swift
import Foundation
|
|
import Combine
|
|
import AppKit
|
|
import Bonsplit
|
|
|
|
/// TerminalPanel wraps an existing TerminalSurface and conforms to the Panel protocol.
|
|
/// This allows TerminalSurface to be used within the bonsplit-based layout system.
|
|
@MainActor
|
|
final class TerminalPanel: Panel, ObservableObject {
|
|
let id: UUID
|
|
let panelType: PanelType = .terminal
|
|
|
|
/// The underlying terminal surface
|
|
let surface: TerminalSurface
|
|
|
|
/// The workspace ID this panel belongs to
|
|
private(set) var workspaceId: UUID
|
|
|
|
/// Published title from the terminal process
|
|
@Published private(set) var title: String = "Terminal"
|
|
|
|
/// Published directory from the terminal
|
|
@Published private(set) var directory: String = ""
|
|
|
|
/// Search state for find functionality
|
|
@Published var searchState: TerminalSurface.SearchState? {
|
|
didSet {
|
|
surface.searchState = searchState
|
|
}
|
|
}
|
|
|
|
/// Bump this token to force SwiftUI to call `updateNSView` on `GhosttyTerminalView`,
|
|
/// which re-attaches the hosted view after bonsplit close/reparent operations.
|
|
///
|
|
/// Without this, certain pane-close sequences can leave terminal views detached
|
|
/// (hostedView.window == nil) until the user switches workspaces.
|
|
@Published var viewReattachToken: UInt64 = 0
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
var displayTitle: String {
|
|
title.isEmpty ? "Terminal" : title
|
|
}
|
|
|
|
var displayIcon: String? {
|
|
"terminal.fill"
|
|
}
|
|
|
|
var isDirty: Bool {
|
|
// Bonsplit's "dirty" indicator is a very small dot in the tab strip.
|
|
//
|
|
// For terminals, `ghostty_surface_needs_confirm_quit` is driven by shell integration
|
|
// heuristics and can be transiently (or permanently) wrong, which results in a dot
|
|
// showing on every new terminal. That reads as a notification/alert and is misleading.
|
|
//
|
|
// We still honor `needsConfirmClose()` when actually closing a panel; we just don't
|
|
// surface it as a tab-level dirty indicator.
|
|
false
|
|
}
|
|
|
|
/// The hosted NSView for embedding in SwiftUI
|
|
var hostedView: GhosttySurfaceScrollView {
|
|
surface.hostedView
|
|
}
|
|
|
|
init(workspaceId: UUID, surface: TerminalSurface) {
|
|
self.id = surface.id
|
|
self.workspaceId = workspaceId
|
|
self.surface = surface
|
|
|
|
// Subscribe to surface's search state changes
|
|
surface.$searchState
|
|
.sink { [weak self] state in
|
|
if self?.searchState !== state {
|
|
self?.searchState = state
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
/// Create a new terminal panel with a fresh surface
|
|
convenience init(
|
|
workspaceId: UUID,
|
|
context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: ghostty_surface_config_s? = nil,
|
|
workingDirectory: String? = nil,
|
|
additionalEnvironment: [String: String] = [:],
|
|
portOrdinal: Int = 0
|
|
) {
|
|
let surface = TerminalSurface(
|
|
tabId: workspaceId,
|
|
context: context,
|
|
configTemplate: configTemplate,
|
|
workingDirectory: workingDirectory,
|
|
additionalEnvironment: additionalEnvironment
|
|
)
|
|
surface.portOrdinal = portOrdinal
|
|
self.init(workspaceId: workspaceId, surface: surface)
|
|
}
|
|
|
|
func updateTitle(_ newTitle: String) {
|
|
let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty && title != trimmed {
|
|
title = trimmed
|
|
}
|
|
}
|
|
|
|
func updateDirectory(_ newDirectory: String) {
|
|
let trimmed = newDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty && directory != trimmed {
|
|
directory = trimmed
|
|
}
|
|
}
|
|
|
|
func updateWorkspaceId(_ newWorkspaceId: UUID) {
|
|
workspaceId = newWorkspaceId
|
|
surface.updateWorkspaceId(newWorkspaceId)
|
|
}
|
|
|
|
func focus() {
|
|
surface.setFocus(true)
|
|
// `unfocus()` force-disables active state to stop stale retries from stealing focus.
|
|
// Re-enable it immediately for explicit focus requests (socket/UI) so ensureFocus can run.
|
|
hostedView.setActive(true)
|
|
hostedView.ensureFocus(for: workspaceId, surfaceId: id)
|
|
}
|
|
|
|
func unfocus() {
|
|
surface.setFocus(false)
|
|
// Cancel any pending focus work items so an inactive terminal can't steal first responder
|
|
// back from another surface (notably WKWebView) during rapid focus changes in tests.
|
|
//
|
|
// Also flip the hosted view's active state immediately: SwiftUI focus propagation can lag
|
|
// by a runloop tick, and `requestFocus` retries that are already executing can otherwise
|
|
// schedule new work items that fire after we navigate away.
|
|
hostedView.setActive(false)
|
|
}
|
|
|
|
func close() {
|
|
// The surface will be cleaned up by its deinit
|
|
// Detach from the window portal on real close so stale hosted views
|
|
// cannot remain above browser panes after split close.
|
|
surface.beginPortalCloseLifecycle(reason: "panel.close")
|
|
#if DEBUG
|
|
let frame = String(format: "%.1fx%.1f", hostedView.frame.width, hostedView.frame.height)
|
|
let bounds = String(format: "%.1fx%.1f", hostedView.bounds.width, hostedView.bounds.height)
|
|
dlog(
|
|
"surface.panel.close.begin panel=\(id.uuidString.prefix(5)) " +
|
|
"workspace=\(workspaceId.uuidString.prefix(5)) runtimeSurface=\(surface.surface != nil ? 1 : 0) " +
|
|
"inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " +
|
|
"hidden=\(hostedView.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)"
|
|
)
|
|
#endif
|
|
unfocus()
|
|
hostedView.setVisibleInUI(false)
|
|
TerminalWindowPortalRegistry.detach(hostedView: hostedView)
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.panel.close.end panel=\(id.uuidString.prefix(5)) " +
|
|
"inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " +
|
|
"hidden=\(hostedView.isHidden ? 1 : 0)"
|
|
)
|
|
#endif
|
|
surface.teardownSurface()
|
|
}
|
|
|
|
func requestViewReattach() {
|
|
viewReattachToken &+= 1
|
|
}
|
|
|
|
// MARK: - Terminal-specific methods
|
|
|
|
func sendText(_ text: String) {
|
|
surface.sendText(text)
|
|
}
|
|
|
|
func performBindingAction(_ action: String) -> Bool {
|
|
surface.performBindingAction(action)
|
|
}
|
|
|
|
func hasSelection() -> Bool {
|
|
surface.hasSelection()
|
|
}
|
|
|
|
func needsConfirmClose() -> Bool {
|
|
surface.needsConfirmClose()
|
|
}
|
|
|
|
func shouldPersistScrollbackForSessionSnapshot() -> Bool {
|
|
// Session restore only replays terminal output into a fresh shell. If Ghostty
|
|
// says we are not safely at a prompt, replaying that state later is misleading.
|
|
!surface.needsConfirmClose()
|
|
}
|
|
|
|
func triggerFlash() {
|
|
hostedView.triggerFlash()
|
|
}
|
|
|
|
func triggerNotificationDismissFlash() {
|
|
hostedView.triggerFlash(style: .notificationDismiss)
|
|
}
|
|
|
|
func applyWindowBackgroundIfActive() {
|
|
surface.applyWindowBackgroundIfActive()
|
|
}
|
|
|
|
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
|
|
.terminal(hostedView.capturePanelFocusIntent(in: window))
|
|
}
|
|
|
|
func preferredFocusIntentForActivation() -> PanelFocusIntent {
|
|
.terminal(hostedView.preferredPanelFocusIntentForActivation())
|
|
}
|
|
|
|
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
|
|
guard case .terminal(let target) = intent else { return }
|
|
hostedView.preparePanelFocusIntentForActivation(target)
|
|
}
|
|
|
|
@discardableResult
|
|
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
|
|
switch intent {
|
|
case .panel:
|
|
focus()
|
|
return true
|
|
case .terminal(let target):
|
|
return hostedView.restorePanelFocusIntent(target)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
|
|
_ = window
|
|
guard let intent = hostedView.ownedPanelFocusIntent(for: responder) else { return nil }
|
|
return .terminal(intent)
|
|
}
|
|
|
|
@discardableResult
|
|
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
|
|
guard case .terminal(let target) = intent else { return false }
|
|
return hostedView.yieldPanelFocusIntent(target, in: window)
|
|
}
|
|
}
|