Files
Termind/TFM/Features/Terminal/TerminalPlaceholderView.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

250 lines
11 KiB
Swift

import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
/// A libghostty terminal surface with Termind's connection and suggestion chrome.
struct TerminalPlaceholderView: View {
let sshSession: SSHSessionModel
/// The Workspace holding this pane owns broadcast input routing.
let workspace: Workspace
/// Hidden workspaces remain mounted to preserve Ghostty's surface, but stop
/// rendering frames until they come forward again.
let isVisible: Bool
/// Only the focused terminal pane in the visible workspace should own keyboard
/// focus. A split can keep several surfaces visible at once.
let requestsFocus: Bool
let externalFileDrop: LocalFileDropRequest?
let externalRemoteDrop: SFTPRemoteDropRequest?
/// Connection cancellation removes the pane instead of revealing the
/// retained pre-journey terminal surface underneath it.
let closePane: () -> Void
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@Environment(\.scenePhase) private var scenePhase
@FocusState private var terminalFocused: Bool
@State private var uploadConflictPresented = false
@State private var uploadBindingID: UUID?
private var uploads: TerminalUploadModel { sshSession.terminalUpload }
/// The surface decides the pane's size; everything else is an overlay.
///
/// These used to be siblings in a `ZStack`, which makes a stack as tall as
/// its tallest child — and the journey has a minimum height. In a short pane
/// it reported more than it was offered (measured: 512 for a 440 proposal),
/// `.frame(maxHeight: .infinity)` passed that up rather than clamping it,
/// and the oversize climbed the whole chain and came back down as a larger
/// proposal, so the journey was laid out taller than the pane and centred —
/// which is what threw it upward at the moment a connection settled. An
/// overlay is proposed the base's size and never reports one, so the
/// terminal alone answers for the pane.
var body: some View {
terminalSurface
.desktopTerminalChrome(state: sshSession.terminalState)
.overlay(alignment: .bottom) { uploadDecoration }
.overlay { connectionJourney }
.animation(Motion.panel, value: uploads.notice)
.onAppear {
// The mounted view is the final source of truth after any drag,
// merge, detach, or SwiftUI tree rebuild. Reassert the relationship
// here so a stale layout callback cannot leave live keystrokes on a
// sshSession whose router was cleared while it moved.
sshSession.inputRouter = workspace
terminalFocused = requestsFocus
TerminalFontCatalog.shared.refresh()
uploadBindingID = sshSession.terminalViewOwner.bindUpload(
action: chooseUploadFiles,
isAvailable: { isVisible && uploads.uploadTargetProblem == nil })
sshSession.connectIfNeeded(using: app.hostLibrary)
if requestsFocus { sshSession.requestTerminalFocus() }
}
.onChange(of: isVisible) {
guard let uploadBindingID else { return }
sshSession.terminalViewOwner.updateUploadAvailability(
for: uploadBindingID, isAvailable: { isVisible && uploads.uploadTargetProblem == nil })
}
.onChange(of: requestsFocus) { _, focused in
terminalFocused = focused
if focused { sshSession.requestTerminalFocus() }
}
.onChange(of: externalFileDrop?.id) {
guard let externalFileDrop else { return }
uploads.receiveFiles(externalFileDrop.urls, connections: app.connections, transfers: app.sftpTransfers)
}
.onChange(of: externalRemoteDrop?.id) {
guard let externalRemoteDrop else { return }
uploads.receiveRemoteDrop(
externalRemoteDrop.data, connections: app.connections, transfers: app.sftpTransfers)
}
.onChange(of: uploads.hasConflict, initial: true) { _, hasConflict in
uploadConflictPresented = hasConflict
}
.onDisappear {
terminalFocused = false
if let uploadBindingID { sshSession.terminalViewOwner.unbindUpload(uploadBindingID) }
uploadBindingID = nil
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { TerminalFontCatalog.shared.refresh() }
}
.confirmationDialog(
uploadConflictTitle,
isPresented: $uploadConflictPresented,
titleVisibility: .visible
) {
Button(AppStrings.SFTP.replace.swiftUIKey) { uploads.enqueue(.replace) }
Button(AppStrings.SFTP.skipExisting.swiftUIKey) { uploads.enqueue(.skip) }
Button(AppStrings.SFTP.keepBoth.swiftUIKey) { uploads.enqueue(.keepBoth) }
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { uploads.cancelPendingUpload() }
} message: {
Text(AppStrings.SFTP.applyToAllConflicts)
}
}
@ViewBuilder
private var uploadDecoration: some View {
if let notice = uploads.notice {
TerminalUploadOverlay(notice: notice, homeResolution: sshSession.homeResolution)
// Above the floating status capsule, not on top of it.
.padding(.bottom, Metrics.StatusBar.overlayClearance)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
}
}
@ViewBuilder
private var connectionJourney: some View {
if isVisible, sshSession.connectionJourney.isPresented {
ConnectionJourneyView(
journey: sshSession.connectionJourney,
authenticationPrompt: sshSession.pendingAuthentication,
hostKey: sshSession.pendingHostKey,
submitAuthentication: {
identityID, username, password, passphrase, keyID, save in
sshSession.continueAuthentication(
identityID: identityID, username: username, password: password,
passphrase: passphrase, keyID: keyID, save: save, using: app.hostLibrary
)
},
continueHostKey: {
sshSession.continueWithTemporaryHostKey($0)
},
trustHostKey: { sshSession.trustAndConnect($0, using: app.hostLibrary) },
denyHostKey: {
cancelConnection()
},
retry: { sshSession.connect(using: app.hostLibrary) },
cancel: cancelConnection,
askAssistant: {
if case .failed(let message) = sshSession.connectionJourney.phase {
askAssistant(
about: message,
log: sshSession.connectionJourney.plainTextLog
)
}
},
reconnectState: sshSession.reconnectState,
retryReconnectNow: sshSession.retryReconnectNow,
stopReconnecting: sshSession.stopReconnecting,
securityKeySigning: sshSession.connection.securityKeySigning,
keyboardAuthentication: sshSession.connection.keyboardAuthentication
)
}
}
private var uploadConflictTitle: String {
String(localized: AppStrings.SFTP.existingItemsMessage(count: uploads.conflictCount))
}
private func chooseUploadFiles() {
guard let target = uploads.target else { return }
sshSession.terminalViewOwner.chooseUploadFiles { urls in
uploads.receiveFiles(
urls, selectedTarget: target, connections: app.connections, transfers: app.sftpTransfers)
}
}
private func cancelConnection() {
sshSession.disconnect()
closePane()
}
@ViewBuilder
private var terminalSurface: some View {
#if canImport(AppKit)
PersistentTerminalSurfaceView(
state: sshSession.terminalState,
owner: sshSession.terminalViewOwner,
isVisible: isVisible,
requestsFocus: requestsFocus
)
#else
TerminalSurfaceView(context: sshSession.terminalState)
.terminalFocused($terminalFocused)
#endif
}
/// Hands the failure to the Assistant sidebar as a draft rather than sending it —
/// the user still decides what leaves the machine.
private func askAssistant(about message: String, log: String) {
let conversation = window.sidebar.assistantDraft
SSHFailureAssistantDraft.populate(conversation, message: message, log: log)
window.closeAssistantInSidebar()
window.showAssistant()
}
}
@MainActor
enum SSHFailureAssistantDraft {
static func populate(_ conversation: ConversationModel, message: String, log: String) {
// The log rides on the chip rather than in the draft. Pasting it into
// the editable text made the pill a label for something the user could
// silently delete half of, and left the draft too long to read.
conversation.attach(
ContextChip(
kind: .output,
label: String(localized: AppStrings.Terminal.connectionLog),
content: String(log.suffix(4_000))
))
conversation.composer.draft = String(
localized: AppStrings.Terminal.sshConnectionFailedWhatShouldICheckConfirmation(message)
)
}
}
private struct TerminalUploadOverlay: View {
let notice: WorkspaceUploadNotice
let homeResolution: RemoteHomeResolutionState
var body: some View {
VStack(spacing: Metrics.Space.s) {
symbol
Text(notice.title).font(.headline)
Text(notice.detail(home: homeResolution))
.font(notice.isRejected ? .caption : .caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(notice.isRejected ? .tail : .middle)
}
.multilineTextAlignment(.center)
.padding(.horizontal, Metrics.Space.xl)
.padding(.vertical, Metrics.Space.l)
.termindTransientOverlaySurface()
.accessibilityElement(children: .combine)
}
@ViewBuilder
private var symbol: some View {
switch notice {
case .preparing:
ProgressView().controlSize(.small)
case .queued, .rejected:
Image(systemName: notice.symbol ?? "")
.font(.title2)
.foregroundStyle(notice.isRejected ? .orange : .green)
}
}
}