Refine drag in terminal's error dialog
This commit is contained in:
1 parent
c249d6796b
commit
b0d925c55b
7 files changed
+423
-48
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user