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.
250 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|