diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 4a1e9646..bac8b6c9 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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. diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 8a767a8f..60cf4555 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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 } diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 2a76358b..40627cc8 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -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) diff --git a/TFITests/MobileCommandGeneratorAndUploadTests.swift b/TFITests/MobileCommandGeneratorAndUploadTests.swift index 620fc76e..04964f2b 100644 --- a/TFITests/MobileCommandGeneratorAndUploadTests.swift +++ b/TFITests/MobileCommandGeneratorAndUploadTests.swift @@ -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? + 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) { + 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] = [] private let attributes: [String: RemoteFile] private let failedDownloadPaths: Set @@ -845,10 +1026,12 @@ private actor MobileUploadSFTPClient: SFTPClientProtocol { attributes: [String: RemoteFile] = [:], failedDownloadPaths: Set = [], 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) } diff --git a/TFITests/MobileDropTests.swift b/TFITests/MobileDropTests.swift index 549b63fd..d1822b2e 100644 --- a/TFITests/MobileDropTests.swift +++ b/TFITests/MobileDropTests.swift @@ -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) diff --git a/TFM/Features/Terminal/TerminalPlaceholderView.swift b/TFM/Features/Terminal/TerminalPlaceholderView.swift index 03764c59..91fe2a1e 100644 --- a/TFM/Features/Terminal/TerminalPlaceholderView.swift +++ b/TFM/Features/Terminal/TerminalPlaceholderView.swift @@ -33,7 +33,6 @@ struct TerminalPlaceholderView: View { @State private var uploadPreparationTask: Task? @State private var uploadPreparationID: UUID? @State private var uploadEnqueueTask: Task? - @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 diff --git a/TFMTests/TerminalUploadNoticeTests.swift b/TFMTests/TerminalUploadNoticeTests.swift new file mode 100644 index 00000000..24bdad06 --- /dev/null +++ b/TFMTests/TerminalUploadNoticeTests.swift @@ -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: {}) + } +}