// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import TermindApplication import TermindDomain import TermindPresentation 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) } } } /// Pending terminal transfers belong to the SSH session, so rebuilding or moving its pane /// does not replace preparation, conflict state, or the upload completion destination. @Observable @MainActor final class TerminalUploadModel { struct Target: Equatable { let generation: UUID? let destination: String var context: TerminalContextCoordinator.Ticket? = nil } private weak var session: SSHSessionModel? private var transfers: SFTPTransferCoordinator? private var pendingUploadChannel: SFTPChannelModel? private var pendingUploadItemCount = 0 private var insertUploadedPaths = false private(set) var hasConflict = false private(set) var notice: WorkspaceUploadNotice? private var noticeTask: Task? private var uploadPreparationTask: Task? private var uploadPreparationID: UUID? private var uploadEnqueueTask: Task? private var pendingContext: TerminalContextCoordinator.Ticket? init(session: SSHSessionModel) { self.session = session } var conflictCount: Int { pendingUploadChannel?.conflictCount ?? 0 } var uploadTargetProblem: String? { TerminalUploadAvailability.problem( isConnected: session?.status == .connected, hasTransferConfiguration: session?.transferSnapshot != nil, isWorkingDirectoryPending: session?.homeResolution.isPending ?? true, workingDirectory: session?.workingDirectory) } var target: Target? { guard uploadTargetProblem == nil, let session, let destination = session.workingDirectory else { return nil } return Target( generation: session.connectionAttemptID, destination: destination, context: session.directoryContextTicket) } @discardableResult func receiveFiles( _ urls: [URL], selectedTarget: Target? = nil, connections: RemoteConnectionCoordinator, transfers: SFTPTransferCoordinator ) -> Bool { if let selectedTarget, selectedTarget != target { return false } let files = urls.filter(\.isFileURL) guard !files.isEmpty else { return false } return prepare( .local(files), insertPaths: selectedTarget != nil, connections: connections, transfers: transfers) } @discardableResult func receiveRemoteDrop( _ data: Data, connections: RemoteConnectionCoordinator, transfers: SFTPTransferCoordinator ) -> Bool { guard let request = TerminalTransferRequest.remote(data: data, coordinator: transfers) else { showUploadNotice(.rejected(String(localized: AppStrings.SFTP.theCopiedSftpItemsAreInvalid))) return false } return prepare(request, insertPaths: false, connections: connections, transfers: transfers) } private func prepare( _ request: TerminalTransferRequest, insertPaths: Bool, connections: RemoteConnectionCoordinator, transfers: SFTPTransferCoordinator ) -> Bool { guard let sshSession = session else { return false } cancelPendingUpload() insertUploadedPaths = insertPaths guard uploadTargetProblem == nil, let snapshot = sshSession.transferSnapshot, let workingDirectory = sshSession.workingDirectory else { showUploadNotice( .rejected( uploadTargetProblem ?? String(localized: AppStrings.SFTP.theUploadDestinationIsUnavailable) ) ) return false } self.transfers = transfers let destination = RemotePath.normalized(workingDirectory) let generation = sshSession.connectionAttemptID let context = sshSession.directoryContextTicket pendingContext = context let preparationID = UUID() uploadPreparationID = preparationID pendingUploadItemCount = request.itemCount notice = .preparing(destination) uploadPreparationTask = Task { defer { if uploadPreparationID == preparationID { uploadPreparationTask = nil uploadPreparationID = nil } } var connectedChannel: (any SFTPChannelProtocol)? do { try await sshSession.confirmDirectoryContext(context) let resolved = try await TerminalUploadConnectionResolver.resolve( isCurrent: { sshSession.connectionAttemptID == generation }, connectIndependent: { let request = try sshSession.additionalConnectionRequest(from: snapshot) return try await 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: transfers) try await sshSession.confirmDirectoryContext(context) 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: AppStrings.SFTP.noUploadableFilesWereFound))) return } pendingUploadChannel = uploadChannel connectedChannel = nil if uploadChannel.conflictCount > 0 { notice = nil hasConflict = true } else { enqueue(.replace) } } catch is CancellationError { await connectedChannel?.disconnect() guard uploadPreparationID == preparationID else { return } notice = nil } catch { await connectedChannel?.disconnect() guard uploadPreparationID == preparationID, sshSession.connectionAttemptID == generation else { return } showUploadNotice(.rejected(error.localizedDescription)) } } return true } func enqueue(_ policy: TransferConflictPolicy) { guard let uploadChannel = pendingUploadChannel, let transfers, let sshSession = session else { return } let itemCount = pendingUploadItemCount let destination = uploadChannel.path let generation = sshSession.connectionAttemptID let insertPaths = insertUploadedPaths let context = pendingContext hasConflict = false uploadEnqueueTask = Task { do { try await sshSession.confirmDirectoryContext(context) try Task.checkCancellation() } catch { cancelPendingUpload() return } if uploadChannel.pendingRemoteCopyBatch != nil { await uploadChannel.enqueuePendingRemoteCopy( policy: policy, coordinator: transfers ) } else { await uploadChannel.enqueuePending( policy: policy, coordinator: transfers, uploadedPaths: insertPaths ? { [weak session = sshSession] paths in guard let session, session.status == .connected, session.connectionAttemptID == generation, session.isDirectoryContextCurrent(context) else { return } session.insertUploadedPaths(paths) } : nil ) } let error = uploadChannel.operationError uploadChannel.disconnect() guard !Task.isCancelled, pendingUploadChannel === uploadChannel, sshSession.connectionAttemptID == generation else { return } uploadEnqueueTask = nil pendingUploadChannel = nil pendingUploadItemCount = 0 self.transfers = nil if let error { showUploadNotice(.rejected(error)) } else { showUploadNotice(.queued(itemCount, destination)) } } } func cancelPendingUpload() { pendingContext = nil uploadPreparationTask?.cancel() uploadPreparationTask = nil uploadPreparationID = nil uploadEnqueueTask?.cancel() uploadEnqueueTask = nil noticeTask?.cancel() noticeTask = nil notice = nil hasConflict = false pendingUploadItemCount = 0 transfers = nil guard let uploadChannel = pendingUploadChannel else { return } uploadChannel.cancelPendingBatch() uploadChannel.disconnect() pendingUploadChannel = nil } private func showUploadNotice(_ value: WorkspaceUploadNotice) { noticeTask?.cancel() notice = value noticeTask = Task { try? await Task.sleep(for: .seconds(value.isRejected ? 3 : 2)) guard !Task.isCancelled, notice == value else { return } notice = nil noticeTask = nil } } }