Skip to content

Commit 9e91167

Browse files
committed
Show thumbnails in Uploads rows
Expose the materialized temp file URL on PendingUpload and FailedUpload, carry it through UploadRowItem, and render it with QLThumbnailGenerator in place of the kind icon. The icon remains until materialization completes, for failed rows without a retryable payload, and for kinds QuickLook cannot preview.
1 parent f280ad8 commit 9e91167

7 files changed

Lines changed: 152 additions & 6 deletions

File tree

Modules/Sources/WordPressMediaLibrary/Models/FailedUpload.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,7 @@ struct FailedUpload: Identifiable, Sendable {
1313
/// `MediaCreateParams` / temp file were never produced — the
1414
/// Uploads-screen row should offer Dismiss only.
1515
let isRetryable: Bool
16+
/// Materialized temp file on disk; non-nil exactly when `isRetryable`
17+
/// (both derive from the materialized payload surviving the failure).
18+
let localFileURL: URL?
1619
}

Modules/Sources/WordPressMediaLibrary/Models/PendingUpload.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ struct PendingUpload: Identifiable, Sendable {
88
let displayName: String // basename of the temp file
99
let kind: MediaKind // for icon + Uploads-row rendering
1010
let progress: Progress // bound to ProgressView directly
11+
/// Materialized temp file on disk; nil until materialization completes.
12+
/// Drives the Uploads-row thumbnail.
13+
let localFileURL: URL?
1114
}

Modules/Sources/WordPressMediaLibrary/Upload/MediaUploader.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,8 @@ private struct InternalPending {
364364
id: id,
365365
displayName: materialized?.displayName ?? displayName,
366366
kind: materialized?.kind ?? kind,
367-
progress: overallProgress
367+
progress: overallProgress,
368+
localFileURL: materialized?.tempFileURL
368369
)
369370
}
370371
}
@@ -382,7 +383,8 @@ private struct InternalFailed {
382383
displayName: materialized?.displayName ?? displayName,
383384
kind: materialized?.kind ?? kind,
384385
errorMessage: errorMessage,
385-
isRetryable: materialized != nil
386+
isRetryable: materialized != nil,
387+
localFileURL: materialized?.tempFileURL
386388
)
387389
}
388390
}

Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ final class MediaLibraryViewModel: ObservableObject {
3636
let id: UUID
3737
let displayName: String
3838
let kind: MediaKind
39+
let localFileURL: URL?
3940
let mode: Mode
4041
}
4142

@@ -139,13 +140,15 @@ final class MediaLibraryViewModel: ObservableObject {
139140
id: p.id,
140141
displayName: p.displayName,
141142
kind: p.kind,
143+
localFileURL: p.localFileURL,
142144
mode: .uploading(p.progress)
143145
)
144146
case .failed(let f):
145147
return UploadRowItem(
146148
id: f.id,
147149
displayName: f.displayName,
148150
kind: f.kind,
151+
localFileURL: f.localFileURL,
149152
mode: .failed(message: f.errorMessage, isRetryable: f.isRetryable)
150153
)
151154
}

Modules/Sources/WordPressMediaLibrary/Views/UploadRow.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ struct UploadRow: View {
99

1010
var body: some View {
1111
HStack(spacing: 12) {
12-
Image(systemName: item.kind.systemImageName)
13-
.font(.title3)
14-
.foregroundStyle(.secondary)
15-
.frame(width: 32, height: 32)
12+
if let fileURL = item.localFileURL {
13+
UploadThumbnailView(
14+
fileURL: fileURL,
15+
fallbackSystemImage: item.kind.systemImageName
16+
)
17+
} else {
18+
Image(systemName: item.kind.systemImageName)
19+
.font(.title3)
20+
.foregroundStyle(.secondary)
21+
.frame(width: 44, height: 44)
22+
}
1623

1724
VStack(alignment: .leading, spacing: 4) {
1825
Text(item.displayName)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import QuickLookThumbnailing
2+
import SwiftUI
3+
4+
/// QuickLook thumbnail for a local upload file. Falls back to the kind's
5+
/// SF Symbol until generation finishes, or permanently when QuickLook
6+
/// cannot preview the type (e.g. audio). Requesting only `.thumbnail`
7+
/// (never `.icon`) keeps that failure clean instead of yielding a generic
8+
/// system file icon. No caching: scroll-back regenerates, which is cheap
9+
/// for local files.
10+
struct UploadThumbnailView: View {
11+
let fileURL: URL
12+
let fallbackSystemImage: String
13+
14+
@Environment(\.displayScale) private var displayScale
15+
@State private var thumbnail: UIImage?
16+
17+
var body: some View {
18+
ZStack {
19+
if let thumbnail {
20+
Image(uiImage: thumbnail)
21+
.resizable()
22+
.aspectRatio(contentMode: .fill)
23+
} else {
24+
Image(systemName: fallbackSystemImage)
25+
.font(.title3)
26+
.foregroundStyle(.secondary)
27+
}
28+
}
29+
.frame(width: 44, height: 44)
30+
.clipShape(RoundedRectangle(cornerRadius: 8))
31+
.task(id: fileURL) {
32+
let request = QLThumbnailGenerator.Request(
33+
fileAt: fileURL,
34+
size: CGSize(width: 44, height: 44),
35+
scale: displayScale,
36+
representationTypes: .thumbnail
37+
)
38+
// Extract UIImage inside the completion handler so that only the
39+
// Sendable UIImage crosses the concurrency boundary, not the
40+
// non-Sendable QLThumbnailRepresentation.
41+
let image: UIImage? = await withCheckedContinuation { continuation in
42+
QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { representation, _ in
43+
continuation.resume(returning: representation?.uiImage)
44+
}
45+
}
46+
thumbnail = image
47+
}
48+
}
49+
}

Modules/Tests/WordPressMediaLibraryTests/MediaUploaderTests.swift

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,4 +523,83 @@ struct MediaUploaderTests {
523523
#expect(state.pending.isEmpty)
524524
#expect(state.failed.isEmpty, "transport cancellation should be silent, not failed")
525525
}
526+
527+
@Test("localFileURL is nil before materialization and set after")
528+
func localFileURLAppearsAfterMaterialization() async throws {
529+
let transport = BlockingFakeUploadTransport()
530+
let mock = MockMaterializer()
531+
let uploader = MediaUploader(
532+
transport: transport,
533+
materializer: mock,
534+
filePickerContentTypes: [.content]
535+
)
536+
537+
let pdfURL = try writeTempPDF()
538+
defer { try? FileManager.default.removeItem(at: pdfURL.deletingLastPathComponent()) }
539+
540+
await uploader.enqueue(sources: [.file(pdfURL)])
541+
await mock.waitForStart()
542+
543+
let before = await uploader.snapshot()
544+
#expect(before.pending.first?.localFileURL == nil)
545+
546+
let realTemp = try writeTempFile(name: "materialized.bin", content: Data("payload".utf8))
547+
defer { try? FileManager.default.removeItem(at: realTemp.deletingLastPathComponent()) }
548+
let materialized = MaterializedUpload(
549+
tempFileURL: realTemp,
550+
params: MediaCreateParams(filePath: realTemp.path),
551+
kind: .document,
552+
displayName: realTemp.lastPathComponent
553+
)
554+
await mock.complete(with: .success(materialized))
555+
try await Task.sleep(for: .milliseconds(50))
556+
557+
// Transport is still blocked, so the entry is pending with a file on disk.
558+
let after = await uploader.snapshot()
559+
#expect(after.pending.first?.localFileURL == realTemp)
560+
561+
await transport.unblock()
562+
}
563+
564+
@Test("upload-stage failure keeps localFileURL on the retryable failed entry")
565+
func uploadFailureKeepsLocalFileURL() async throws {
566+
let transport = FakeUploadTransport()
567+
await transport.setResponses([.failure(URLError(.timedOut))])
568+
let uploader = MediaUploader(transport: transport, policy: makeAllowEverythingPolicy())
569+
570+
let sourceURL = try writeTempPDF(name: "fail.pdf")
571+
defer { try? FileManager.default.removeItem(at: sourceURL.deletingLastPathComponent()) }
572+
573+
await uploader.enqueue(sources: [.file(sourceURL)])
574+
try await Task.sleep(for: .milliseconds(200))
575+
576+
let state = await uploader.snapshot()
577+
#expect(state.failed.count == 1)
578+
#expect(state.failed[0].isRetryable)
579+
#expect(state.failed[0].localFileURL != nil)
580+
}
581+
582+
@Test("materialization failure yields no localFileURL")
583+
func materializationFailureHasNoLocalFileURL() async throws {
584+
let transport = FakeUploadTransport()
585+
let mock = MockMaterializer()
586+
let uploader = MediaUploader(
587+
transport: transport,
588+
materializer: mock,
589+
filePickerContentTypes: [.content]
590+
)
591+
592+
let pdfURL = try writeTempPDF()
593+
defer { try? FileManager.default.removeItem(at: pdfURL.deletingLastPathComponent()) }
594+
595+
await uploader.enqueue(sources: [.file(pdfURL)])
596+
await mock.waitForStart()
597+
await mock.complete(with: .failure(URLError(.cannotOpenFile)))
598+
try await Task.sleep(for: .milliseconds(50))
599+
600+
let state = await uploader.snapshot()
601+
#expect(state.failed.count == 1)
602+
#expect(!state.failed[0].isRetryable)
603+
#expect(state.failed[0].localFileURL == nil)
604+
}
526605
}

0 commit comments

Comments
 (0)