Files
Termind/TFM/Features/Terminal/PersistentTerminalSurfaceView.swift
T
Kaede Akino 82d871c4fe refactor: separate feature policies and UI workflows
Move connection policy out of ProAccess, keep terminal uploads with their
session, and share question interaction and highlight editing operations.
Consolidate CPU usage and settings snapshot handling, and remove obsolete
terminal selection, theme helpers, and hard-coded latest-schema assertions.

Fix draining connection-pool recovery and stale pane upload bindings. Keep
screenshot matrices opt-in and retain behavioral migration coverage.

Validation: 2468 macOS tests passed serially, 31 iOS tests passed, and both
SSH forwarding regressions passed against the local OpenSSH fixture.
Both app builds, Swift formatting, AppStrings, and Chinese localization
checks passed. Parallel test runs still exhibit intermittent timeouts.
2026-10-02 16:40:27 +08:00

651 lines
27 KiB
Swift

#if canImport(AppKit)
import AppKit
import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindPresentation
/// Mounts one long-lived Ghostty view into whichever pane currently owns it.
///
/// Moving a pane changes its position in `SplitNode`, which changes the
/// SwiftUI parent chain. SwiftUI is allowed to dismantle and recreate an
/// `NSViewRepresentable` across that move even when the pane keeps the same
/// `.id`. A newly created `TerminalView` would make Ghostty replace the
/// surface attached to the shared controller, losing the visible grid and
/// scrollback.
///
/// The owner below lives with its terminal session model, not with the
/// transient representable. Reparenting therefore moves the same native view
/// and surface into the new container instead of constructing either one.
struct PersistentTerminalSurfaceView: NSViewRepresentable {
let state: TerminalViewState
let owner: PersistentTerminalViewOwner
let isVisible: Bool
let requestsFocus: Bool
@AppStorage(SettingsKey.terminalCopyOnSelect) private var copyOnSelect = false
@AppStorage(SettingsKey.terminalPasteOnRightClick) private var pasteOnRightClick = false
final class Coordinator {
let owner: PersistentTerminalViewOwner
init(owner: PersistentTerminalViewOwner) {
self.owner = owner
}
}
func makeCoordinator() -> Coordinator {
Coordinator(owner: owner)
}
func makeNSView(context _: Context) -> TerminalMountView {
let container = TerminalMountView()
owner.mount(
in: container,
state: state,
isVisible: isVisible,
requestsFocus: requestsFocus,
copyOnSelect: copyOnSelect,
pasteOnRightClick: pasteOnRightClick
)
return container
}
func updateNSView(_ container: TerminalMountView, context _: Context) {
owner.mount(
in: container,
state: state,
isVisible: isVisible,
requestsFocus: requestsFocus,
copyOnSelect: copyOnSelect,
pasteOnRightClick: pasteOnRightClick
)
}
static func dismantleNSView(
_ container: TerminalMountView,
coordinator: Coordinator
) {
// The container may already have been reused for a different pane.
// Unmount only the owner represented by the view being dismantled;
// asking `container.owner` here could detach the newly mounted pane.
coordinator.owner.unmount(from: container)
}
}
/// A deliberately inert SwiftUI/AppKit boundary. Ghostty owns all drawing
/// and input; this view supplies only stable geometry and a mount point.
final class TerminalMountView: NSView {
weak var owner: PersistentTerminalViewOwner?
/// SwiftUI can reuse this NSView for another session while rebuilding a
/// split tree. Keep a generation for every owner that has visited it so
/// a late update from an older tree cannot steal it back.
fileprivate var mountOrdinals: [ObjectIdentifier: UInt64] = [:]
override var isOpaque: Bool { false }
}
/// Adds desktop mouse preferences and keeps application-owned shortcuts in
/// AppKit's menu command chain.
///
/// Ghostty recognizes several application commands while the terminal is
/// first responder. Returning `true` prevents SwiftUI's command owner from
/// seeing the event. Registered Termind shortcuts fall through; every other
/// key still uses Ghostty's input path.
final class TermindTerminalView: TerminalView {
var uploadFiles: (() -> Void)?
var canUploadFiles: (() -> Bool)?
var inputCapture: TerminalInteractiveInputCapture?
var copyOnSelect = false
var pasteOnRightClick = false
private var isSelectingWithMouse = false
private var rightClickPastes: Bool?
private var isPathDropVisible = false
override func setSurfaceVisible(_ visible: Bool) {
super.setSurfaceVisible(visible)
isPathDropVisible = visible
updatePathDropRegistration()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
updatePathDropRegistration()
}
private func updatePathDropRegistration() {
if isPathDropVisible, window != nil {
registerForDraggedTypes([TerminalPathDrag.pasteboardType])
} else {
unregisterDraggedTypes()
}
}
/// Registration alone cannot cancel a drag AppKit already dispatched
/// before this workspace became hidden. Recheck at every entry point.
func pathDropOperation(
from pasteboard: NSPasteboard, sourceOperations: NSDragOperation
) -> NSDragOperation {
guard isPathDropVisible, window != nil, superview != nil,
!isHiddenOrHasHiddenAncestor, sourceOperations.contains(.copy),
TerminalPathDrag.text(from: pasteboard) != nil
else { return [] }
// AppKit supplies the green circle with a white plus cursor badge.
return .copy
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
pathDropOperation(from: sender.draggingPasteboard, sourceOperations: sender.draggingSourceOperationMask)
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
pathDropOperation(from: sender.draggingPasteboard, sourceOperations: sender.draggingSourceOperationMask)
}
override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
draggingUpdated(sender) == .copy
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
receivePathDrop(from: sender.draggingPasteboard, sourceOperations: sender.draggingSourceOperationMask)
}
func receivePathDrop(from pasteboard: NSPasteboard, sourceOperations: NSDragOperation) -> Bool {
guard pathDropOperation(from: pasteboard, sourceOperations: sourceOperations) == .copy,
let text = TerminalPathDrag.text(from: pasteboard)
else { return false }
return pasteDroppedPath(text)
}
func pasteDroppedPath(_ text: String) -> Bool {
guard isPathDropVisible, window != nil, superview != nil,
!isHiddenOrHasHiddenAncestor, !text.isEmpty
else { return false }
acquireProgrammaticFocus()
return captureInteraction { paste(text: text) }
}
override func mouseDown(with event: NSEvent) {
// Remote mouse reports must never copy an unrelated retained selection.
isSelectingWithMouse = !isMouseCaptured
super.mouseDown(with: event)
}
override func mouseUp(with event: NSEvent) {
let shouldCopy = isSelectingWithMouse && copyOnSelect
isSelectingWithMouse = false
super.mouseUp(with: event)
if shouldCopy { _ = copySelectedTextToPasteboard() }
}
override func rightMouseDown(with event: NSEvent) {
let pastes = shouldPasteOnRightClick(event)
rightClickPastes = pastes
if pastes {
acquireProgrammaticFocus()
} else {
super.rightMouseDown(with: event)
}
}
override func rightMouseDragged(with event: NSEvent) {
if rightClickPastes == false { super.rightMouseDragged(with: event) }
}
override func rightMouseUp(with event: NSEvent) {
guard let pastes = rightClickPastes else { return }
rightClickPastes = nil
if pastes {
// An empty paste must not arm a deferred broadcast ticket that
// could consume the next unrelated mouse or protocol report.
guard canPasteFromClipboard else { return }
captureInteraction { _ = performBindingAction("paste_from_clipboard") }
} else {
super.rightMouseUp(with: event)
}
}
override func menu(for event: NSEvent) -> NSMenu? {
guard !isMouseCaptured else { return nil }
if event.type == .rightMouseDown || event.type == .rightMouseUp,
rightClickPastes ?? shouldPasteOnRightClick(event)
{
return nil
}
return super.menu(for: event)
}
override func selectionMenuPoint(at point: CGPoint) -> CGPoint? {
// The superclass uses this hook to decide whether a right-click
// presents a menu. Offer the app's default menu without a selection too.
isMouseCaptured ? nil : point
}
override func selectionContextMenu() -> NSMenu {
let menu: NSMenu
if super.selectionMenuPoint(at: .zero) != nil {
menu = super.selectionContextMenu()
menu.items.first?.title = String(localized: AppStrings.Common.copy)
} else {
menu = NSMenu()
}
menu.autoenablesItems = false
let pasteItem = NSMenuItem(
title: String(localized: AppStrings.Common.paste),
action: #selector(pasteFromContextMenu(_:)), keyEquivalent: "")
pasteItem.target = self
pasteItem.isEnabled = canPasteFromClipboard
menu.addItem(pasteItem)
let selectAllItem = NSMenuItem(
title: String(localized: AppStrings.Common.selectAll),
action: #selector(selectAll(_:)), keyEquivalent: "")
selectAllItem.target = self
menu.addItem(selectAllItem)
if uploadFiles != nil {
menu.addItem(.separator())
let upload = NSMenuItem(
title: String(localized: AppStrings.Terminal.uploadFile),
action: #selector(uploadFromContextMenu(_:)), keyEquivalent: "")
upload.target = self
upload.isEnabled = canUploadFiles?() == true
menu.addItem(upload)
}
return menu
}
@objc private func uploadFromContextMenu(_: Any?) {
guard canUploadFiles?() == true else { return }
uploadFiles?()
}
private var canPasteFromClipboard: Bool {
let pasteboard = NSPasteboard.general
return pasteboard.string(forType: .string)?.isEmpty == false
|| pasteboard.canReadObject(forClasses: [NSURL.self], options: nil)
}
@objc private func pasteFromContextMenu(_: Any?) {
guard canPasteFromClipboard else { return }
captureInteraction { _ = performBindingAction("paste_from_clipboard") }
}
private func shouldPasteOnRightClick(_ event: NSEvent) -> Bool {
pasteOnRightClick && !isMouseCaptured && !event.modifierFlags.contains(.option)
}
override func keyDown(with event: NSEvent) {
captureInteraction { super.keyDown(with: event) }
}
override func keyUp(with event: NSEvent) {
captureInteraction(allowsDeferredWrite: false) {
super.keyUp(with: event)
}
}
override func flagsChanged(with event: NSEvent) {
captureInteraction(allowsDeferredWrite: false) {
super.flagsChanged(with: event)
}
}
/// Menu-bar Paste reaches the first responder through `tryToPerform`
/// rather than `keyDown`. Keep the capture alive while AppKit invokes
/// Ghostty's paste action so bracketed-paste bytes are classified as
/// interactive input too.
override func tryToPerform(_ action: Selector, with object: Any?) -> Bool {
guard action == NSSelectorFromString("paste:") else {
return super.tryToPerform(action, with: object)
}
return captureInteraction {
super.tryToPerform(action, with: object)
}
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
if AppShortcutRegistry.shared.routesToApplication(event) { return false }
return captureInteraction {
super.performKeyEquivalent(with: event)
}
}
private func captureInteraction<Result>(
allowsDeferredWrite: Bool = true,
_ operation: () -> Result
) -> Result {
guard let inputCapture else { return operation() }
return inputCapture.withInteraction(
allowsDeferredWrite: allowsDeferredWrite,
operation
)
}
}
/// Strong owner for one terminal backend, state and native view, so SplitNode
/// mutations cannot change view identity or lose the screen buffer.
@MainActor
final class PersistentTerminalViewOwner {
private let terminalView: TermindTerminalView
let state: TerminalViewState
nonisolated private let backend: InMemoryTerminalSession
private weak var container: TerminalMountView?
private var mountConstraints: [NSLayoutConstraint] = []
private var appliedVisibility: Bool?
private var wantsFocus = false
private var nextMountOrdinal: UInt64 = 0
private var uploadBindingID: UUID?
/// Bindings follow their presenting pane. A late disappearance from the old pane
/// must not remove the replacement pane's menu or cancel its pending transfer.
func bindUpload(action: @escaping () -> Void, isAvailable: @escaping () -> Bool) -> UUID {
let id = UUID()
uploadBindingID = id
terminalView.uploadFiles = action
terminalView.canUploadFiles = isAvailable
return id
}
func updateUploadAvailability(for id: UUID, isAvailable: @escaping () -> Bool) {
guard uploadBindingID == id else { return }
terminalView.canUploadFiles = isAvailable
}
@discardableResult
func unbindUpload(_ id: UUID) -> Bool {
guard uploadBindingID == id else { return false }
uploadBindingID = nil
terminalView.uploadFiles = nil
terminalView.canUploadFiles = nil
return true
}
func chooseUploadFiles(completion: @escaping ([URL]) -> Void) {
guard isUploadSurfaceVisible, let window = terminalView.window else { return }
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = true
panel.allowsMultipleSelection = true
panel.beginSheetModal(for: window) { [weak self] response in
guard response == .OK, self?.isUploadSurfaceVisible == true else { return }
completion(panel.urls)
}
}
var isUploadSurfaceVisible: Bool {
appliedVisibility == true && terminalView.window != nil && !terminalView.isHiddenOrHasHiddenAncestor
}
func pasteUploadedText(_ text: String) {
terminalView.inputCapture?.withLocalPaste { _ = state.paste(text: text) }
}
private var ownerIdentifier: ObjectIdentifier { ObjectIdentifier(self) }
init(
write: @escaping @Sendable (Data, Bool) -> Void,
resize: @escaping @Sendable (InMemoryTerminalViewport) -> Void
) {
let inputCapture = TerminalInteractiveInputCapture()
let backend = InMemoryTerminalSession(
write: { data in write(data, inputCapture.classifyWrite(data)) },
resize: resize,
suppressesPixelOnlyResizes: true
)
let state = TerminalViewState(configSource: .none)
state.configuration = TerminalSurfaceOptions(
backend: .inMemory(backend), resizeThrottleMilliseconds: 35
)
self.backend = backend
self.state = state
let terminal = TermindTerminalView(frame: .zero)
terminal.inputCapture = inputCapture
terminal.translatesAutoresizingMaskIntoConstraints = false
terminal.delegate = state
terminal.configuration = state.configuration
terminal.controller = state.controller
terminalView = terminal
}
nonisolated func receive(_ data: Data) {
backend.receive(data)
}
nonisolated func readViewportText() -> String? {
backend.readViewportText()
}
/// Pane overlays own native drop routing above the embedded surface.
@discardableResult
func pasteDroppedPath(_ text: String) -> Bool {
terminalView.pasteDroppedPath(text)
}
func appendNotice(_ message: String) {
receive(Data("\r\n\u{001B}[2m[Termind] \(message)\u{001B}[0m\r\n".utf8))
}
/// Idempotent: ordinary SwiftUI updates stop after the reference checks.
/// Only a real pane move removes the terminal from one container and
/// constrains it to another.
func mount(
in newContainer: TerminalMountView,
state: TerminalViewState,
isVisible: Bool,
requestsFocus: Bool,
copyOnSelect: Bool = false,
pasteOnRightClick: Bool = false
) {
var didReparent = false
if container !== newContainer || terminalView.superview !== newContainer {
assignOrdinalIfNeeded(to: newContainer)
// A tree replacement can create the destination before
// dismantling the source. SwiftUI may still send an update to
// that older container afterwards; it must not steal the native
// terminal back from the newer layout.
if let currentOrdinal = container?.mountOrdinals[ownerIdentifier],
let proposedOrdinal = newContainer.mountOrdinals[ownerIdentifier],
proposedOrdinal < currentOrdinal
{
return
}
// A structural rewrite may hand this representable an AppKit
// container that still holds another session. Detach that
// session from this container before claiming it; its own
// update will mount it into the corresponding destination.
if let displacedOwner = newContainer.owner, displacedOwner !== self {
displacedOwner.unmount(from: newContainer)
}
move(to: newContainer)
didReparent = true
}
// The controller and options are established once by the session.
// Do not stamp options during ordinary updates: the dependency's
// equivalence helper is intentionally internal, and assigning a
// value it considers different tears down the live surface.
if terminalView.controller !== state.controller {
terminalView.controller = state.controller
}
terminalView.copyOnSelect = copyOnSelect
terminalView.pasteOnRightClick = pasteOnRightClick
if appliedVisibility != isVisible {
appliedVisibility = isVisible
state.isSurfaceVisible = isVisible
terminalView.setSurfaceVisible(isVisible)
}
synchronizeFocus(
requested: requestsFocus && isVisible,
force: false,
reparented: didReparent
)
}
func unmount(from oldContainer: TerminalMountView) {
// SwiftUI may build the destination before dismantling the source.
// A late dismantle callback for the old container must not detach
// the terminal that has already reached its new pane.
guard container === oldContainer,
terminalView.superview === oldContainer
else { return }
state.isSurfaceVisible = false
appliedVisibility = false
terminalView.setSurfaceVisible(false)
NSLayoutConstraint.deactivate(mountConstraints)
mountConstraints.removeAll(keepingCapacity: true)
terminalView.removeFromSuperview()
oldContainer.owner = nil
container = nil
}
/// An explicit hand-back — Escape in the Find field, a pane becoming
/// focused. This one is allowed to take the keyboard away from a text
/// field, because taking it away is the whole point.
func requestFocus() {
synchronizeFocus(requested: true, force: true)
}
@discardableResult
func performBindingAction(_ action: String) -> Bool {
// Ghostty may make its surface first responder while dispatching a
// search binding. Preserve an active AppKit field editor in the
// same call stack so terminal search never produces a visible
// search-field → terminal → search-field focus flash.
let window = terminalView.window
let editor = window?.firstResponder as? NSTextView
let textField =
editor?.isFieldEditor == true
? editor?.delegate as? NSTextField
: nil
let selection = editor?.selectedRange
let handled = terminalView.performBindingAction(action)
if let window, let textField,
window.firstResponder !== textField,
window.firstResponder !== textField.currentEditor()
{
window.makeFirstResponder(textField)
if let selection {
textField.currentEditor()?.selectedRange = selection
}
}
return handled
}
#if DEBUG
var hasKeyboardFocusForTesting: Bool {
terminalView.window?.firstResponder === terminalView
}
func performKeyEquivalentForTesting(_ event: NSEvent) -> Bool {
terminalView.performKeyEquivalent(with: event)
}
#endif
private func move(to newContainer: TerminalMountView) {
state.isSurfaceVisible = false
appliedVisibility = false
terminalView.setSurfaceVisible(false)
NSLayoutConstraint.deactivate(mountConstraints)
mountConstraints.removeAll(keepingCapacity: true)
terminalView.removeFromSuperview()
container?.owner = nil
container = newContainer
newContainer.owner = self
newContainer.addSubview(terminalView)
mountConstraints = [
terminalView.leadingAnchor.constraint(equalTo: newContainer.leadingAnchor),
terminalView.trailingAnchor.constraint(equalTo: newContainer.trailingAnchor),
terminalView.topAnchor.constraint(equalTo: newContainer.topAnchor),
terminalView.bottomAnchor.constraint(equalTo: newContainer.bottomAnchor),
]
NSLayoutConstraint.activate(mountConstraints)
}
private func assignOrdinalIfNeeded(to container: TerminalMountView) {
guard container.mountOrdinals[ownerIdentifier] == nil else { return }
nextMountOrdinal &+= 1
container.mountOrdinals[ownerIdentifier] = nextMountOrdinal
}
/// Moves the keyboard to or from the terminal — but only when something
/// actually asked for it to move.
///
/// `mount` re-runs for *any* observed change on the session, which
/// includes each letter typed into the window's Find field, and each of
/// those used to re-assert first responder here. Answering an unchanged
/// request with a first-responder call is not idempotent: it takes the
/// caret out of whatever holds it. So an ordinary update now stops at
/// the first guard and never touches the responder chain at all.
///
/// `force` is the explicit hand-back — Escape in the Find field, or a
/// pane being focused — and always acts. `reparented` covers the pane
/// move, where the request has not changed but the view has.
///
/// `isEditingText` stays as a second line of defence for the remaining
/// unforced case, where the request really did change while somebody was
/// typing somewhere else.
private func synchronizeFocus(
requested: Bool,
force: Bool,
reparented: Bool = false
) {
let changed = requested != wantsFocus
wantsFocus = requested
guard force || changed || reparented else { return }
guard let window = terminalView.window else {
if requested { scheduleFocusAfterMount(force: force) }
return
}
if requested {
guard window.firstResponder !== terminalView else { return }
guard force || !isEditingText(in: window) else { return }
window.makeFirstResponder(terminalView)
} else if window.firstResponder === terminalView {
window.makeFirstResponder(nil)
}
}
/// Whether the window's keyboard focus is in a text field. A focused
/// `NSTextField` or `NSSearchField` delegates to a shared field editor,
/// so the first responder is usually that `NSTextView` rather than the
/// control the user sees.
private func isEditingText(in window: NSWindow) -> Bool {
switch window.firstResponder {
case let textView as NSTextView: textView.isFieldEditor
case is NSTextField: true
default: false
}
}
private func scheduleFocusAfterMount(force: Bool) {
// Mounting commonly happens inside a SwiftUI update, before the new
// container has a window. One run-loop hop lets AppKit finish the
// reparent without retaining a task or polling layout state.
DispatchQueue.main.async { [weak self] in
guard let self,
wantsFocus,
appliedVisibility == true,
let window = terminalView.window,
container != nil
else { return }
// Same rule as the synchronous path, and it carries `force` for
// the same reason. A hop later is exactly when the Find field
// has just taken the caret, and this used to be the one route to
// first responder with no guard on it at all.
guard force || !isEditingText(in: window) else { return }
if window.firstResponder !== terminalView {
window.makeFirstResponder(terminalView)
}
}
}
}
#endif