// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only #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 openSFTP: ((SessionOpenPlacement) -> Void)? var canOpenSFTP: (() -> 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) } if openSFTP != nil { menu.addItem(.separator()) let item = NSMenuItem( title: String(localized: AppStrings.SFTP.openWithSftp), action: nil, keyEquivalent: "") item.image = NSImage(systemSymbolName: MenuSymbol.sftp, accessibilityDescription: nil) item.isEnabled = canOpenSFTP?() == true let submenu = NSMenu() submenu.autoenablesItems = false for (title, symbol, action) in [ ( AppStrings.Workspace.inCurrentWorkspace, MenuSymbol.currentWorkspace, #selector(openSFTPInCurrentWorkspace(_:)) ), ( AppStrings.Workspace.inNewWorkspace, MenuSymbol.newWorkspace, #selector(openSFTPInNewWorkspace(_:)) ), ] { let child = NSMenuItem(title: String(localized: title), action: action, keyEquivalent: "") child.target = self child.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) child.isEnabled = item.isEnabled submenu.addItem(child) } item.submenu = submenu menu.addItem(item) } return menu } @objc private func openSFTPInCurrentWorkspace(_: Any?) { guard canOpenSFTP?() == true else { return } openSFTP?(.currentWorkspace) } @objc private func openSFTPInNewWorkspace(_: Any?) { guard canOpenSFTP?() == true else { return } openSFTP?(.newWorkspace) } @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( 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(set) var resizeThrottleMilliseconds: Double = 35 private var uploadBindingID: UUID? private var sftpBindingID: UUID? func bindSFTP(action: @escaping (SessionOpenPlacement) -> Void, isAvailable: @escaping () -> Bool) -> UUID { let id = UUID() sftpBindingID = id terminalView.openSFTP = action terminalView.canOpenSFTP = isAvailable return id } func updateSFTPAvailability(for id: UUID, isAvailable: @escaping () -> Bool) { guard sftpBindingID == id else { return } terminalView.canOpenSFTP = isAvailable } @discardableResult func unbindSFTP(_ id: UUID) -> Bool { guard sftpBindingID == id else { return false } sftpBindingID = nil terminalView.openSFTP = nil terminalView.canOpenSFTP = nil return true } /// 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 } /// Changes only the live coalescing policy; configuration and surface identity stay intact. @discardableResult func setResizeThrottle(milliseconds: Double) -> Bool { guard resizeThrottleMilliseconds != milliseconds else { return false } resizeThrottleMilliseconds = milliseconds terminalView.setResizeThrottle(milliseconds: milliseconds) return true } 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) } var hasActiveKeyboardFocus: Bool { terminalView.window?.isKeyWindow == true && terminalView.window?.firstResponder === terminalView } @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