Files
Termind/TFM/Features/Terminal/TerminalPlaceholderView.swift
T

490 lines
20 KiB
Swift

import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
struct TerminalUploadConnection {
let channel: any SFTPChannelProtocol
let primaryRole: SFTPTransferPrimaryRole
}
enum TerminalUploadConnectionResolver {
@MainActor
static func resolve(
isCurrent: () -> Bool,
connectIndependent: () async throws -> any SFTPChannelProtocol,
openPTYSFTPChannel: () async throws -> any SFTPChannelProtocol
) async throws -> TerminalUploadConnection {
do {
return TerminalUploadConnection(
channel: try await connectIndependent(), primaryRole: .donatedConnection)
} catch is CancellationError {
throw CancellationError()
} catch let error as SFTPError {
if case .cancelled = error { throw error }
try Task.checkCancellation()
guard isCurrent() else { throw CancellationError() }
return TerminalUploadConnection(
channel: try await openPTYSFTPChannel(), primaryRole: .ptyConnectionFallback)
} catch {
try Task.checkCancellation()
guard isCurrent() else { throw CancellationError() }
return TerminalUploadConnection(
channel: try await openPTYSFTPChannel(), primaryRole: .ptyConnectionFallback)
}
}
}
enum TerminalTransferRequest {
case local([URL])
case remote(SFTPRemoteItemsPayload, itemCount: Int)
var itemCount: Int {
switch self {
case .local(let files): files.count
case .remote(_, let itemCount): itemCount
}
}
static func remote(
data: Data,
coordinator: SFTPTransferCoordinator
) -> TerminalTransferRequest? {
guard let payload = try? JSONDecoder().decode(SFTPRemoteItemsPayload.self, from: data),
let source = coordinator.remoteCopySource(for: payload),
!source.files.isEmpty
else { return nil }
return .remote(payload, itemCount: source.files.count)
}
func prepare(
on channel: SFTPChannelModel,
coordinator: SFTPTransferCoordinator
) async {
switch self {
case .local(let files):
await channel.prepareUpload(files)
case .remote(let payload, _):
await channel.prepareRemoteCopy(payload, coordinator: coordinator)
}
}
}
/// 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 pendingUploadChannel: SFTPChannelModel?
@State private var pendingUploadItemCount = 0
@State private var uploadConflictPresented = false
@State private var uploadNotice: WorkspaceUploadNotice?
@State private var uploadNoticeTask: Task<Void, Never>?
@State private var uploadPreparationTask: Task<Void, Never>?
@State private var uploadPreparationID: UUID?
@State private var uploadEnqueueTask: Task<Void, Never>?
/// 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, charsetError: charsetError)
.overlay(alignment: .bottom) { uploadDecoration }
.overlay { connectionJourney }
.animation(Motion.panel, value: uploadNotice)
.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()
sshSession.connectIfNeeded(using: app.hostLibrary)
if requestsFocus { sshSession.requestTerminalFocus() }
}
.onChange(of: requestsFocus) { _, focused in
terminalFocused = focused
if focused { sshSession.requestTerminalFocus() }
}
.onChange(of: externalFileDrop?.id) {
guard let externalFileDrop else { return }
_ = receiveDroppedURLs(externalFileDrop.urls)
}
.onChange(of: externalRemoteDrop?.id) {
guard let externalRemoteDrop else { return }
_ = receiveRemoteDrop(externalRemoteDrop.data)
}
.onChange(of: sshSession.connectionAttemptID) { cancelPendingUpload() }
.onDisappear {
terminalFocused = false
cancelPendingUpload()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { TerminalFontCatalog.shared.refresh() }
}
.confirmationDialog(
uploadConflictTitle,
isPresented: $uploadConflictPresented,
titleVisibility: .visible
) {
Button("Replace") { enqueuePendingUpload(.replace) }
Button("Skip Existing") { enqueuePendingUpload(.skip) }
Button("Keep Both") { enqueuePendingUpload(.keepBoth) }
Button("Cancel", role: .cancel) { cancelPendingUpload() }
} message: {
Text("Use this choice for all conflicts in this batch.")
}
}
private var charsetError: Binding<String?> {
Binding(
get: { sshSession.charsetError },
set: { sshSession.charsetError = $0 }
)
}
@ViewBuilder
private var uploadDecoration: some View {
if let uploadNotice {
TerminalUploadOverlay(notice: uploadNotice, 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,
keyboardAuthentication: sshSession.connection.keyboardAuthentication
)
}
}
private var uploadTargetProblem: String? {
TerminalUploadAvailability.problem(
isConnected: sshSession.status == .connected,
hasTransferConfiguration: sshSession.transferSnapshot != nil,
isWorkingDirectoryPending: sshSession.homeResolution.isPending,
workingDirectory: sshSession.workingDirectory
)
}
private var uploadConflictTitle: String {
let count = pendingUploadChannel?.conflictCount ?? 0
return count == 1
? String(localized: "1 item already exists")
: String(localized: "\(count) items already exist")
}
private func receiveDroppedURLs(_ urls: [URL]) -> Bool {
// Only this terminal convenience is Pro. SFTPBrowserView keeps its own
// upload flow and remains free.
guard app.proAccess.require(.terminalFileUpload) else { return false }
let files = urls.filter(\.isFileURL)
guard !files.isEmpty else { return false }
return prepareTerminalTransfer(.local(files))
}
private func receiveRemoteDrop(_ data: Data) -> Bool {
guard app.proAccess.require(.terminalFileUpload) else { return false }
guard let request = TerminalTransferRequest.remote(data: data, coordinator: app.sftpTransfers) else {
showUploadNotice(.rejected(String(localized: "The copied SFTP items are invalid.")))
return false
}
return prepareTerminalTransfer(request)
}
private func prepareTerminalTransfer(_ request: TerminalTransferRequest) -> Bool {
cancelPendingUpload()
guard uploadTargetProblem == nil,
let snapshot = sshSession.transferSnapshot,
let workingDirectory = sshSession.workingDirectory
else {
showUploadNotice(
.rejected(
uploadTargetProblem
?? String(localized: "The upload destination is unavailable.")
)
)
return false
}
let destination = RemotePath.normalized(workingDirectory)
let generation = sshSession.connectionAttemptID
let preparationID = UUID()
uploadPreparationID = preparationID
pendingUploadItemCount = request.itemCount
uploadNotice = .preparing(destination)
uploadPreparationTask = Task {
defer {
if uploadPreparationID == preparationID {
uploadPreparationTask = nil
uploadPreparationID = nil
}
}
var connectedChannel: (any SFTPChannelProtocol)?
do {
let resolved = try await TerminalUploadConnectionResolver.resolve(
isCurrent: { sshSession.connectionAttemptID == generation },
connectIndependent: {
let request = try sshSession.additionalConnectionRequest(from: snapshot)
return try await app.connections.connectSFTP(
using: request, progress: nil
)
},
openPTYSFTPChannel: {
try await sshSession.openPTYSFTPChannel(for: generation)
})
let client = resolved.channel
connectedChannel = client
try Task.checkCancellation()
guard sshSession.connectionAttemptID == generation else { throw CancellationError() }
let uploadChannel = SFTPChannelModel(
hostID: sshSession.hostID,
initialPath: destination,
client: client,
additionalSnapshot: snapshot,
hostStore: sshSession.transferHostStore,
primaryRole: resolved.primaryRole,
homeResolution: sshSession.homeResolution
)
await request.prepare(on: uploadChannel, coordinator: app.sftpTransfers)
try Task.checkCancellation()
guard sshSession.connectionAttemptID == generation else { throw CancellationError() }
if let error = uploadChannel.operationError {
uploadChannel.disconnect()
showUploadNotice(.rejected(error))
return
}
guard uploadChannel.hasPendingBatch else {
uploadChannel.disconnect()
showUploadNotice(.rejected(String(localized: "No uploadable files were found.")))
return
}
pendingUploadChannel = uploadChannel
connectedChannel = nil
if uploadChannel.conflictCount > 0 {
uploadNotice = nil
uploadConflictPresented = true
} else {
enqueuePendingUpload(.replace)
}
} catch is CancellationError {
await connectedChannel?.disconnect()
guard uploadPreparationID == preparationID else { return }
uploadNotice = nil
} catch {
await connectedChannel?.disconnect()
guard uploadPreparationID == preparationID,
sshSession.connectionAttemptID == generation
else { return }
showUploadNotice(.rejected(error.localizedDescription))
}
}
return true
}
private func enqueuePendingUpload(_ policy: TransferConflictPolicy) {
guard let uploadChannel = pendingUploadChannel else { return }
let itemCount = pendingUploadItemCount
let destination = uploadChannel.path
let generation = sshSession.connectionAttemptID
uploadConflictPresented = false
uploadEnqueueTask = Task {
if uploadChannel.pendingRemoteCopyBatch != nil {
await uploadChannel.enqueuePendingRemoteCopy(
policy: policy,
coordinator: app.sftpTransfers
)
} else {
await uploadChannel.enqueuePending(
policy: policy,
coordinator: app.sftpTransfers
)
}
let error = uploadChannel.operationError
uploadChannel.disconnect()
guard !Task.isCancelled, pendingUploadChannel === uploadChannel,
sshSession.connectionAttemptID == generation
else { return }
uploadEnqueueTask = nil
pendingUploadChannel = nil
pendingUploadItemCount = 0
if let error {
showUploadNotice(.rejected(error))
} else {
showUploadNotice(.queued(itemCount, destination))
}
}
}
private func cancelPendingUpload() {
uploadPreparationTask?.cancel()
uploadPreparationTask = nil
uploadPreparationID = nil
uploadEnqueueTask?.cancel()
uploadEnqueueTask = nil
uploadNoticeTask?.cancel()
uploadNoticeTask = nil
uploadNotice = nil
uploadConflictPresented = false
pendingUploadItemCount = 0
guard let uploadChannel = pendingUploadChannel else { return }
uploadChannel.cancelPendingBatch()
uploadChannel.disconnect()
pendingUploadChannel = nil
}
private func showUploadNotice(_ notice: WorkspaceUploadNotice) {
uploadNoticeTask?.cancel()
uploadNotice = notice
uploadNoticeTask = Task {
try? await Task.sleep(for: .seconds(notice.isRejected ? 3 : 2))
guard !Task.isCancelled, uploadNotice == notice else { return }
uploadNotice = nil
uploadNoticeTask = nil
}
}
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: "Connection Log"),
content: String(log.suffix(4_000))
))
conversation.composer.draft = String(
localized: "SSH connection failed: \(message). What should I check?"
)
}
}
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)
}
}
}