Refine drag in terminal's error dialog

This commit is contained in:
Kaede Akino committed 2026-09-19 01:07:50 +08:00
1 parent c249d6796b
commit b0d925c55b
7 files changed
+423 -48

No files matched your search

+5
View File
@@ -347,6 +347,11 @@ whose endpoints share one connection alternate bounded reads and writes on that
checking source size/metadata and EOF before the existing atomic temporary-file commit.
Terminal preflight and batch preparation are cancellable and scoped to the terminal
generation; late results cannot enqueue files after invalidation.
Terminal file-drop failures, including interactive-authentication requirements and mobile
file-provider staging failures, use the existing nonmodal upload overlay on both platforms.
Rejections remain visible for three seconds; queued notices last two seconds and cannot
replace a visible failure from the same batch. New drops retire earlier preparation and
notice timers. SFTP browser operation alerts and transfer conflict choices remain app-owned.
Upload/download checkpoints, file references, acknowledged offsets and incremental digests live only
in memory. Pause and connection loss retain the destination partial file and release
the connection; explicit resume verifies the source and partial prefix before seeking.
+18 -9
View File
@@ -1003,6 +1003,7 @@ final class MobileWorkspace: Identifiable {
func prepareUpload(_ urls: [URL]) -> Bool {
let urls = urls.filter(\.isFileURL)
guard !urls.isEmpty else { return false }
cancelPendingUpload()
guard transfers != nil, let configuration = transferConfiguration,
let destination = uploadDestination
else {
@@ -1015,7 +1016,6 @@ final class MobileWorkspace: Identifiable {
return false
}
cancelPendingUpload()
let connectionGeneration = generation
let preparationID = UUID()
uploadPreparationID = preparationID
@@ -1078,7 +1078,7 @@ final class MobileWorkspace: Identifiable {
connectionGeneration: connectionGeneration
)
if batch.conflictCount > 0 {
uploadNotice = nil
if uploadNotice?.isRejected != true { uploadNotice = nil }
} else {
enqueuePendingUpload(.replace)
}
@@ -1087,9 +1087,8 @@ final class MobileWorkspace: Identifiable {
if uploadPreparationID == preparationID { uploadNotice = nil }
} catch {
if ownsClient { await client?.disconnect() }
guard uploadPreparationID == preparationID else { return }
uploadNotice = nil
operationError = error.localizedDescription
guard uploadPreparationID == preparationID, generation == connectionGeneration else { return }
reportUploadFailure(error.localizedDescription)
}
}
return true
@@ -1286,9 +1285,8 @@ final class MobileWorkspace: Identifiable {
if uploadEnqueueID == enqueueID { uploadNotice = nil }
} catch {
if pendingUpload.ownsClient { await pendingUpload.session.releaseOwner() }
guard uploadEnqueueID == enqueueID else { return }
uploadNotice = nil
operationError = error.localizedDescription
guard uploadEnqueueID == enqueueID, generation == pendingUpload.connectionGeneration else { return }
reportUploadFailure(error.localizedDescription)
}
}
}
@@ -1312,14 +1310,25 @@ final class MobileWorkspace: Identifiable {
}
}
func reportUploadFailure(_ message: String) {
if descriptor.kind == .ssh {
showUploadNotice(.rejected(message))
} else {
uploadNotice = nil
operationError = message
}
}
private func showUploadNotice(_ notice: WorkspaceUploadNotice) {
// A partially loaded drop can enqueue its readable files while its failure is still visible.
if case .queued = notice, uploadNotice?.isRejected == true { return }
uploadNoticeID = nil
uploadNoticeTask?.cancel()
let noticeID = UUID()
uploadNoticeID = noticeID
uploadNotice = notice
uploadNoticeTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
try? await Task.sleep(for: .seconds(notice.isRejected ? 3 : 2))
guard !Task.isCancelled, self?.uploadNoticeID == noticeID,
self?.uploadNotice == notice
else { return }
@@ -522,6 +522,7 @@ struct MobileWorkspaceView: View {
private func receiveUploadProviders(_ providers: [NSItemProvider]) -> Bool {
guard MobileDropPayloadLoader.acceptsUpload(providers) else { return false }
uploadDropTask?.cancel()
workspace.cancelPendingUpload()
let destination = workspace.uploadDestination
uploadDropTask = Task {
let payload = await MobileDropPayloadLoader.load(
@@ -540,7 +541,7 @@ struct MobileWorkspaceView: View {
}
}
if let error = payload.failures.first {
workspace.operationError = error
workspace.reportUploadFailure(error)
}
}
return true
@@ -755,10 +756,10 @@ private struct MobileUploadNoticeOverlay: View {
VStack(alignment: .leading, spacing: 2) {
Text(notice.title).font(.headline)
Text(notice.detail)
.font(.caption.monospaced())
.font(notice.isRejected ? .caption : .caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.middle)
.truncationMode(notice.isRejected ? .tail : .middle)
}
}
.accessibilityElement(children: .combine)
@@ -1,6 +1,7 @@
import Foundation
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import UniformTypeIdentifiers
@@ -452,11 +453,166 @@ struct MobileUploadBatchTests {
workspace.receiveWorkingDirectory("/srv/current")
#expect(workspace.uploadTargetProblem == nil)
#expect(workspace.prepareUpload([URL(fileURLWithPath: "/tmp/termind-test-upload")]))
#expect(await eventually { workspace.operationError != nil })
#expect(await eventually { workspace.uploadNotice?.isRejected == true })
#expect(workspace.operationError == nil)
#expect(workspace.keyboardAuthentication.current == nil)
#expect(transfers.sftpTransfers.isEmpty)
}
@Test(arguments: [UploadFailureStage.connection, .localFile, .preparation, .enqueue])
private func terminalUploadFailuresUseNoticesAndReleaseTheClient(_ stage: UploadFailureStage) async throws {
let client = MobileUploadSFTPClient(failureStage: stage)
let workspace = uploadWorkspace(client: client) {
if stage == .connection { throw URLError(.cannotConnectToHost) }
return client
}
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let source = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: source) }
if stage == .enqueue {
try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true)
} else if stage != .localFile {
try Data("upload".utf8).write(to: source)
}
#expect(workspace.prepareUpload([source]))
#expect(await eventually { workspace.uploadNotice?.isRejected == true })
#expect(workspace.operationError == nil)
#expect(workspace.transfers?.sftpTransfers.isEmpty == true)
#expect(!workspace.hasPendingTransferConflict)
if stage != .connection { #expect(await client.disconnectCount == 1) }
}
@Test func browserUploadFailuresKeepTheirOperationAlert() async throws {
let client = MobileUploadSFTPClient()
let workspace = uploadWorkspace(client: client, kind: .sftp)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
let missing = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
#expect(workspace.prepareUpload([missing]))
#expect(await eventually { workspace.operationError != nil })
#expect(workspace.uploadNotice == nil)
#expect(await client.disconnectCount == 0)
workspace.reportUploadFailure("Provider failed")
#expect(workspace.operationError == "Provider failed")
#expect(workspace.uploadNotice == nil)
}
@Test func partialFileProviderFailureStaysVisibleWhileReadableFilesAreQueued() async throws {
let client = MobileUploadSFTPClient()
let workspace = uploadWorkspace(client: client)
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let source = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try Data("readable".utf8).write(to: source)
defer { try? FileManager.default.removeItem(at: source) }
let good = NSItemProvider()
good.suggestedName = "good.txt"
good.registerFileRepresentation(
forTypeIdentifier: UTType.plainText.identifier, fileOptions: [], visibility: .all
) { completion in
completion(source, false, nil)
return nil
}
let bad = NSItemProvider()
bad.suggestedName = "bad.txt"
bad.registerFileRepresentation(
forTypeIdentifier: UTType.plainText.identifier, fileOptions: [], visibility: .all
) { completion in
completion(nil, false, CocoaError(.fileReadNoPermission))
return nil
}
let payload = await MobileDropPayloadLoader.load([good, bad], kinds: [.files], fileMode: .staged)
defer {
if let root = payload.stagedRoot { try? FileManager.default.removeItem(at: root) }
}
let error = try #require(payload.failures.first)
#expect(workspace.prepareUpload(payload.fileURLs))
workspace.reportUploadFailure(error)
#expect(await eventuallyAsync { await client.disconnectCount == 1 })
#expect(workspace.transfers?.sftpTransfers.count == 1)
#expect(workspace.uploadNotice == .rejected(error))
#expect(workspace.operationError == nil)
// A new batch replaces the partial-failure notice and restores the ordinary success timer.
#expect(workspace.prepareUpload([source]))
#expect(workspace.uploadNotice == .preparing("/srv/current"))
#expect(await eventually { workspace.uploadNotice == .queued(1, "/srv/current") })
try await Task.sleep(for: .milliseconds(2300))
#expect(workspace.uploadNotice == nil)
}
@Test func repeatedFailuresRestartTheThreeSecondDismissalTimer() async throws {
let workspace = uploadWorkspace(client: MobileUploadSFTPClient())
defer { workspace.disconnect() }
workspace.reportUploadFailure("Unreadable file")
try await Task.sleep(for: .milliseconds(1600))
workspace.reportUploadFailure("Unreadable file")
try await Task.sleep(for: .milliseconds(1600))
#expect(workspace.uploadNotice == .rejected("Unreadable file"))
try await Task.sleep(for: .milliseconds(1700))
#expect(workspace.uploadNotice == nil)
#expect(workspace.operationError == nil)
}
@Test(arguments: [false, true])
func cancelledOpeningCannotReplaceANewerDropFailure(_ lateFailure: Bool) async throws {
let client = MobileUploadSFTPClient()
let opening = MobileUploadOpeningGate()
let workspace = uploadWorkspace(client: client) {
if opening.hasStarted { throw URLError(.timedOut) }
return try await opening.open()
}
defer { workspace.disconnect() }
workspace.connect()
#expect(await eventually { workspace.connected })
workspace.receiveWorkingDirectory("/srv/current")
let source = URL(fileURLWithPath: "/tmp/unused-upload")
#expect(workspace.prepareUpload([source]))
#expect(await eventually { opening.hasStarted })
workspace.cancelPendingUpload()
#expect(workspace.uploadNotice == nil)
#expect(workspace.operationError == nil)
#expect(workspace.prepareUpload([source]))
#expect(await eventually { workspace.uploadNotice?.isRejected == true })
let notice = workspace.uploadNotice
opening.finish(lateFailure ? .failure(SSHInteractionRequired()) : .success(client))
#expect(await eventually { opening.hasFinished })
if !lateFailure { #expect(await eventuallyAsync { await client.disconnectCount == 1 }) }
#expect(workspace.uploadNotice == notice)
#expect(workspace.operationError == nil)
#expect(workspace.transfers?.sftpTransfers.isEmpty == true)
workspace.cancelPendingUpload()
#expect(workspace.uploadNotice == nil)
}
private func uploadWorkspace(
client: MobileUploadSFTPClient,
kind: ConnectionHistoryEntry.Kind = .ssh,
open: (() async throws -> any SFTPClientProtocol)? = nil
) -> MobileWorkspace {
let identity = Identity(name: "Upload", username: "user")
let host = Host(name: "Upload", host: "example.invalid", identityID: identity.id)
let library = HostLibraryModel(hosts: [host], identities: [identity])
let transfers = SFTPTransferCoordinator(store: library) { client }
let services = MobileWorkspaceConnectionServices(
makeSSHSession: { SSHSessionActor(output: $0, exit: $1, shellIntegration: $2) },
connectSSH: { _, _, _, _, _, _ in },
connectSFTP: { _, _, _ in
if let open { return try await open() }
return client
}, writeSSH: { _, _ in })
return MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: kind),
hosts: library, transfers: transfers, connectionServices: services)
}
@Test func serialUploadSurvivesClosingTheSFTPWorkspace() async throws {
let identity = Identity(name: "OTP", username: "user")
let host = Host(name: "OTP", host: "example.invalid", identityID: identity.id)
@@ -827,6 +983,30 @@ struct MobileUploadBatchTests {
}
}
private nonisolated enum UploadFailureStage: Sendable {
case connection, localFile, preparation, enqueue
}
@MainActor
private final class MobileUploadOpeningGate {
private var continuation: CheckedContinuation<any SFTPClientProtocol, any Error>?
private(set) var hasStarted = false
private(set) var hasFinished = false
func open() async throws -> any SFTPClientProtocol {
defer { hasFinished = true }
return try await withCheckedThrowingContinuation {
continuation = $0
hasStarted = true
}
}
func finish(_ result: Result<any SFTPClientProtocol, any Error>) {
continuation?.resume(with: result)
continuation = nil
}
}
private actor MobileUploadSFTPClient: SFTPClientProtocol {
private(set) var uploadedData: [String: Data] = [:]
private(set) var createdDirectories: [String] = []
@@ -836,6 +1016,7 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol {
private(set) var startedUploads = 0
private let blocksUpload: Bool
private let requiresInteraction: Bool
private let failureStage: UploadFailureStage?
private var uploadWaiters: [CheckedContinuation<Void, Never>] = []
private let attributes: [String: RemoteFile]
private let failedDownloadPaths: Set<String>
@@ -845,10 +1026,12 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol {
attributes: [String: RemoteFile] = [:],
failedDownloadPaths: Set<String> = [],
children: [String: [RemoteFile]] = [:],
blocksUpload: Bool = false, requiresInteraction: Bool = false
blocksUpload: Bool = false, requiresInteraction: Bool = false,
failureStage: UploadFailureStage? = nil
) {
self.blocksUpload = blocksUpload
self.requiresInteraction = requiresInteraction
self.failureStage = failureStage
self.attributes = attributes
self.failedDownloadPaths = failedDownloadPaths
self.children = children
@@ -868,9 +1051,15 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol {
listedDirectories.append(path)
return children[path, default: []]
}
func attributes(at path: String) async throws -> RemoteFile? { attributes[path] }
func attributes(at path: String) async throws -> RemoteFile? {
if failureStage == .preparation { throw SFTPError.operation("Cannot inspect upload destination") }
return attributes[path]
}
func setPermissions(at path: String, to permissions: UInt32) async throws {}
func createDirectory(at path: String) async throws { createdDirectories.append(path) }
func createDirectory(at path: String) async throws {
if failureStage == .enqueue { throw SFTPError.operation("Cannot create upload directory") }
createdDirectories.append(path)
}
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
func removeFile(at path: String) async throws { removedPaths.append(path) }
func removeDirectory(at path: String) async throws { removedPaths.append(path) }
+58
View File
@@ -3,6 +3,7 @@ import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
import UIKit
import UniformTypeIdentifiers
@@ -165,6 +166,63 @@ nonisolated private func failedFileProvider() -> NSItemProvider {
@Suite(.serialized) @MainActor
struct MobileWorkspaceDropTests {
@Test func failedProviderDropShowsANoticeWithoutPresentingAnAlertOrTakingFocus() async throws {
let app = try MobileAppModel(inMemory: true)
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(hostID: UUID(), title: "Upload Test", kind: .ssh), hosts: app.library)
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(
rootView: NavigationStack {
MobileWorkspaceView(
library: app.library, pro: app.pro, commandGenerator: app.commandGenerator,
workspace: workspace, workspaces: [workspace], selectWorkspace: { _ in },
renameWorkspace: { _ in }, openSameDirectory: { _ in }, closeWorkspace: { _ in })
}
.environment(\.scenePhase, .active))
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
workspace.disconnect()
}
controller.view.layoutIfNeeded()
workspace.terminal.requestFocus()
try await Task.sleep(for: .milliseconds(300))
let responder = try #require(firstResponder(in: window))
let region = try #require(dropRegion(in: controller.view))
let interaction = try #require(window.interactions.compactMap { $0 as? UIDropInteraction }.first)
let delegate = try #require(interaction.delegate)
let session = TestDropSession(providers: [failedFileProvider()], window: window)
session.point = region.convert(CGPoint(x: region.bounds.midX, y: region.bounds.midY), to: window)
delegate.dropInteraction?(interaction, sessionDidEnter: session)
delegate.dropInteraction?(interaction, performDrop: session)
delegate.dropInteraction?(interaction, sessionDidEnd: session)
for _ in 0..<100 where workspace.uploadNotice?.isRejected != true {
try await Task.sleep(for: .milliseconds(20))
}
#expect(workspace.uploadNotice?.isRejected == true)
#expect(workspace.operationError == nil)
#expect(controller.presentedViewController == nil)
#expect(firstResponder(in: window) === responder)
try await Task.sleep(for: .milliseconds(200))
let data = try #require(snapshot(window))
let name = "terminal-upload-provider-\(Locale.current.identifier)"
Attachment.record(data, named: "\(name).png")
try data.write(to: FileManager.default.temporaryDirectory.appending(path: "\(name).png"))
try await Task.sleep(for: .milliseconds(3000))
#expect(workspace.uploadNotice == nil)
#expect(firstResponder(in: window) === responder)
}
private func firstResponder(in view: UIView) -> UIView? {
if view.isFirstResponder { return view }
return view.subviews.lazy.compactMap { firstResponder(in: $0) }.first
}
@Test(arguments: ConnectionHistoryEntry.Kind.allCases)
func hostedWorkspacesRenderTheOverlayAcrossTheirContent(kind: ConnectionHistoryEntry.Kind) async throws {
let app = try MobileAppModel(inMemory: true)
@@ -33,7 +33,6 @@ struct TerminalPlaceholderView: View {
@State private var uploadPreparationTask: Task<Void, Never>?
@State private var uploadPreparationID: UUID?
@State private var uploadEnqueueTask: Task<Void, Never>?
@State private var uploadError: String?
/// The surface decides the pane's size; everything else is an overlay.
///
@@ -75,8 +74,6 @@ struct TerminalPlaceholderView: View {
.onChange(of: sshSession.connectionAttemptID) { cancelPendingUpload() }
.onDisappear {
terminalFocused = false
uploadNoticeTask?.cancel()
uploadPreparationTask?.cancel()
cancelPendingUpload()
}
.onChange(of: terminalAppearance) { _, appearance in
@@ -97,17 +94,6 @@ struct TerminalPlaceholderView: View {
} message: {
Text("Use this choice for all conflicts in this batch.")
}
.alert(
"Upload Failed",
isPresented: Binding(
get: { uploadError != nil },
set: { if !$0 { uploadError = nil } }
)
) {
Button("OK") { uploadError = nil }
} message: {
Text(uploadError ?? "")
}
}
@ViewBuilder
@@ -181,6 +167,7 @@ struct TerminalPlaceholderView: View {
guard app.proAccess.require(.terminalFileUpload) else { return false }
let files = urls.filter(\.isFileURL)
guard !files.isEmpty else { return false }
cancelPendingUpload()
guard uploadTargetProblem == nil,
let configuration = sshSession.transferConfiguration,
let workingDirectory = sshSession.workingDirectory
@@ -195,12 +182,10 @@ struct TerminalPlaceholderView: View {
}
let destination = RemotePath.normalized(workingDirectory)
cancelPendingUpload()
let generation = sshSession.connectionAttemptID
let preparationID = UUID()
uploadPreparationID = preparationID
pendingUploadItemCount = files.count
uploadNoticeTask?.cancel()
uploadNotice = .preparing(destination)
uploadPreparationTask = Task {
@@ -232,14 +217,12 @@ struct TerminalPlaceholderView: View {
guard sshSession.connectionAttemptID == generation else { throw CancellationError() }
if let error = uploadSession.operationError {
uploadSession.disconnect()
uploadNotice = nil
uploadError = error
showUploadNotice(.rejected(error))
return
}
guard uploadSession.hasPendingBatch else {
uploadSession.disconnect()
uploadNotice = nil
uploadError = String(localized: "No uploadable files were found.")
showUploadNotice(.rejected(String(localized: "No uploadable files were found.")))
return
}
pendingUploadSession = uploadSession
@@ -256,9 +239,10 @@ struct TerminalPlaceholderView: View {
uploadNotice = nil
} catch {
await connectedClient?.disconnect()
guard uploadPreparationID == preparationID else { return }
uploadNotice = nil
uploadError = error.localizedDescription
guard uploadPreparationID == preparationID,
sshSession.connectionAttemptID == generation
else { return }
showUploadNotice(.rejected(error.localizedDescription))
}
}
return true
@@ -268,6 +252,7 @@ struct TerminalPlaceholderView: View {
guard let uploadSession = pendingUploadSession else { return }
let itemCount = pendingUploadItemCount
let destination = uploadSession.path
let generation = sshSession.connectionAttemptID
uploadConflictPresented = false
uploadEnqueueTask = Task {
await uploadSession.enqueuePending(
@@ -276,13 +261,14 @@ struct TerminalPlaceholderView: View {
)
let error = uploadSession.operationError
uploadSession.disconnect()
guard pendingUploadSession === uploadSession else { return }
guard !Task.isCancelled, pendingUploadSession === uploadSession,
sshSession.connectionAttemptID == generation
else { return }
uploadEnqueueTask = nil
pendingUploadSession = nil
pendingUploadItemCount = 0
if let error {
uploadNotice = nil
uploadError = error
showUploadNotice(.rejected(error))
} else {
showUploadNotice(.queued(itemCount, destination))
}
@@ -295,23 +281,25 @@ struct TerminalPlaceholderView: View {
uploadPreparationID = nil
uploadEnqueueTask?.cancel()
uploadEnqueueTask = nil
uploadNoticeTask?.cancel()
uploadNoticeTask = nil
uploadNotice = nil
guard let uploadSession = pendingUploadSession else { return }
uploadConflictPresented = false
pendingUploadItemCount = 0
guard let uploadSession = pendingUploadSession else { return }
uploadSession.cancelPendingBatch()
uploadSession.disconnect()
pendingUploadSession = nil
pendingUploadItemCount = 0
uploadNotice = nil
}
private func showUploadNotice(_ notice: WorkspaceUploadNotice) {
uploadNoticeTask?.cancel()
uploadNotice = notice
uploadNoticeTask = Task {
try? await Task.sleep(for: .seconds(2))
try? await Task.sleep(for: .seconds(notice.isRejected ? 3 : 2))
guard !Task.isCancelled, uploadNotice == notice else { return }
uploadNotice = nil
uploadNoticeTask = nil
}
}
@@ -364,15 +352,16 @@ private struct TerminalUploadOverlay: View {
symbol
Text(notice.title).font(.headline)
Text(notice.detail)
.font(.caption.monospaced())
.font(notice.isRejected ? .caption : .caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
.truncationMode(.middle)
.truncationMode(notice.isRejected ? .tail : .middle)
}
.multilineTextAlignment(.center)
.padding(.horizontal, Metrics.Space.xl)
.padding(.vertical, Metrics.Space.l)
.termindTransientOverlaySurface()
.accessibilityElement(children: .combine)
}
@ViewBuilder
+124
View File
@@ -0,0 +1,124 @@
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
import Testing
@testable import Termind
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE"] != nil), .serialized)
@MainActor
struct TerminalUploadNoticeTests {
@Test(arguments: ["kbd", "none"])
func uploadFailureKeepsTerminalFocusAndDismissesWithoutAnAlert(_ mode: String) async throws {
let path = try #require(ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE"])
let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path)))
let identity = Identity(name: "Upload fixture", username: mode)
let host = TermindDomain.Host(
name: "Upload fixture", host: "127.0.0.1", port: fixture.port, identityID: identity.id)
let library = HostLibraryModel(
hosts: [host], identities: [identity],
knownHosts: [
KnownHostEntry(
hostname: host.host, port: host.port, algorithm: .rsa, fingerprint: fixture.hostFingerprint)
])
let app = AppModel(hosts: library)
let session = SSHSessionModel(host: host)
let workspace = Workspace(pane: Pane(.sshSession(session)))
let windowModel = WindowModel(workspaces: [workspace])
defer {
session.disconnect()
app.shutdownForTermination()
}
session.connect(using: library)
if mode == "kbd" {
try await waitUntil { session.connection.keyboardAuthentication.current != nil }
let challenge = try #require(session.connection.keyboardAuthentication.current)
session.connection.keyboardAuthentication.respond(challenge.id, answers: ["123456"])
}
try await waitUntil { session.status == .connected && !session.connectionJourney.isPresented }
session.receiveShellIntegrationEvent(.homeResolutionUnavailable)
session.workingDirectory = "/"
let drop = UploadDropState()
let controller = NSHostingController(
rootView: UploadHarness(session: session, workspace: workspace, drop: drop)
.frame(width: 720, height: 420)
.environment(app).environment(windowModel))
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 720, height: 420),
styleMask: [.titled], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentViewController = controller
window.makeKeyAndOrderFront(nil)
defer {
window.orderOut(nil)
window.contentViewController = nil
window.close()
}
try await Task.sleep(for: .milliseconds(300))
let responder = try #require(window.firstResponder)
let before = try noticeRegion(controller.view)
drop.request = LocalFileDropRequest(urls: [
FileManager.default.temporaryDirectory.appending(path: "missing-upload-\(UUID())")
])
try await Task.sleep(for: .milliseconds(700))
// The native terminal owns accessibility, so inspect the otherwise empty lower region.
#expect(try noticeRegion(controller.view) != before)
#expect(window.sheets.isEmpty)
#expect(window.firstResponder === responder)
#expect(session.connection.keyboardAuthentication.current == nil)
#expect(app.sftpTransfers.sftpTransfers.isEmpty)
controller.view.layoutSubtreeIfNeeded()
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
let image = try #require(bitmap.representation(using: .png, properties: [:]))
let name = "terminal-upload-\(mode)-\(Locale.current.identifier)"
Attachment.record(image, named: "\(name).png")
try image.write(to: FileManager.default.temporaryDirectory.appending(path: "\(name).png"))
try await Task.sleep(for: .milliseconds(1500))
#expect(try noticeRegion(controller.view) != before)
try await waitUntil { (try? noticeRegion(controller.view)) == before }
#expect(window.firstResponder === responder)
}
private func noticeRegion(_ view: NSView) throws -> Data {
view.layoutSubtreeIfNeeded()
let region = CGRect(x: 0, y: view.isFlipped ? view.bounds.height - 180 : 0, width: 720, height: 180)
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: region))
view.cacheDisplay(in: region, to: bitmap)
return try #require(bitmap.representation(using: .png, properties: [:]))
}
private func waitUntil(_ condition: () -> Bool) async throws {
for _ in 0..<500 {
if condition() { return }
try await Task.sleep(for: .milliseconds(20))
}
try #require(condition())
}
private struct Fixture: Decodable {
let port: Int
let hostFingerprint: String
}
}
@MainActor @Observable
private final class UploadDropState {
var request: LocalFileDropRequest?
}
private struct UploadHarness: View {
let session: SSHSessionModel
let workspace: Workspace
let drop: UploadDropState
var body: some View {
TerminalPlaceholderView(
sshSession: session, workspace: workspace, isVisible: true, requestsFocus: true,
externalFileDrop: drop.request, closePane: {})
}
}