* Fix orphaned child processes when closing workspace tabs When closing a workspace tab via the sidebar X button, child processes (login → zsh → claude) survived as orphans because TabManager.closeWorkspace() only removed the workspace from the tabs array without explicitly freeing Ghostty surfaces. It relied on ARC to cascade deallocation, but SwiftUI views and Combine publishers held references, delaying or preventing ghostty_surface_free() (which sends SIGHUP) from ever running. This adds explicit teardown on the workspace close path: - TerminalSurface.teardownSurface(): idempotent method to free the Ghostty runtime surface eagerly, matching the existing deinit logic - TerminalPanel.close() now calls teardownSurface() to ensure SIGHUP is sent - Workspace.teardownAllPanels() iterates all panels and closes them - TabManager.closeWorkspace() calls teardownAllPanels() before removing the workspace from the tabs array * Harden workspace teardown and ownership checks * Address follow-up teardown review feedback --------- Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
196 lines
6.7 KiB
Swift
196 lines
6.7 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 triggerFlash() {
|
|
hostedView.triggerFlash()
|
|
}
|
|
|
|
func applyWindowBackgroundIfActive() {
|
|
surface.applyWindowBackgroundIfActive()
|
|
}
|
|
}
|