Separate source presentation from source models

This commit is contained in:
John Burwell 2026-05-29 13:46:30 -05:00
parent dce5af8a89
commit 2d84823826
6 changed files with 169 additions and 157 deletions

View File

@ -211,4 +211,16 @@ nonisolated struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codab
.joined(separator: "\n") .joined(separator: "\n")
} }
nonisolated static func displaySort(_ lhs: MinecraftContentItem, _ rhs: MinecraftContentItem) -> Bool {
if lhs.contentType != rhs.contentType {
return lhs.contentType.rawValue.localizedStandardCompare(rhs.contentType.rawValue) == .orderedAscending
}
let displayNameOrder = lhs.displayName.localizedStandardCompare(rhs.displayName)
if displayNameOrder != .orderedSame {
return displayNameOrder == .orderedAscending
}
return lhs.folderName.localizedStandardCompare(rhs.folderName) == .orderedAscending
}
} }

View File

@ -7,15 +7,6 @@
import Foundation import Foundation
enum SourceScanPhase {
case discovering
case metadata
case previews
case sizing
case completed
case idle
}
struct MinecraftSource: Identifiable, Hashable, Sendable { struct MinecraftSource: Identifiable, Hashable, Sendable {
let id: URL let id: URL
let folderURL: URL let folderURL: URL
@ -102,139 +93,10 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
availability != .available && hasCachedContent availability != .available && hasCachedContent
} }
var availabilityDisplayText: String {
switch availability {
case .available:
return "Available"
case .unknown:
return "Checking availability"
case .disconnected:
return origin.kind == .connectedDevice ? "Device offline" : "Folder offline"
case .limited:
return origin.kind == .connectedDevice ? "Device access limited" : "Limited access"
case .unavailable:
return origin.kind == .connectedDevice ? "Device unavailable" : "Folder unavailable"
}
}
var cachedAvailabilityDetailText: String? {
guard isOfflineCached else {
return nil
}
switch availability {
case .disconnected:
return origin.kind == .connectedDevice
? "Showing cached results until this device reconnects."
: "Showing cached results until this folder becomes reachable again."
case .limited:
return origin.kind == .connectedDevice
? "Showing cached results until the device is unlocked and trusted."
: "Showing cached results until full access is restored."
case .unavailable, .unknown:
return "Showing cached results while the source is unavailable."
case .available:
return nil
}
}
var items: [MinecraftContentItem] { var items: [MinecraftContentItem] {
displayItems displayItems
} }
var scanPhase: SourceScanPhase {
guard isScanning else {
if scanStatus.hasPrefix("Loaded ") || scanStatus == "No Minecraft items found." {
return .completed
}
return .idle
}
if sizeLoadedCount > 0 {
return .sizing
}
if previewLoadedCount > 0 {
return .previews
}
if let scanProgress {
if scanProgress >= 0.75 {
return .sizing
}
if scanProgress >= 0.65 {
return .previews
}
if scanProgress >= 0.1 {
return .metadata
}
}
if scanStatus.contains("Calculating sizes") {
return .sizing
}
if scanStatus.contains("Loading previews") {
return .previews
}
if scanStatus.contains("metadata") {
return .metadata
}
return .discovering
}
var liveScanStatusTitle: String {
guard isScanning else {
return scanStatus
}
if indexedItemCount == 0 {
return "Scanning Minecraft library..."
}
let discoveryIsComplete = (scanProgress ?? 0) >= 0.65
if !discoveryIsComplete {
return "Discovering items..."
}
if indexedItemCount > 0, previewLoadedCount >= indexedItemCount, sizeLoadedCount == 0 {
return "Preparing size calculations..."
}
if scanStatus == "Preparing previews..." || scanStatus == "Preparing size calculations..." {
return scanStatus
}
switch scanPhase {
case .discovering, .metadata, .previews:
return "Loading previews for \(previewLoadedCount) of \(indexedItemCount) items..."
case .sizing:
return "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..."
case .completed:
return indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(indexedDetailCount) items."
case .idle:
return scanStatus
}
}
var showsIndeterminateScanActivityIndicator: Bool {
guard isScanning else {
return false
}
let discoveryIsComplete = (scanProgress ?? 0) >= 0.65
if !discoveryIsComplete {
return true
}
if indexedItemCount > 0, previewLoadedCount >= indexedItemCount, sizeLoadedCount == 0 {
return true
}
return scanStatus == "Preparing previews..." || scanStatus == "Preparing size calculations..."
}
func rawItem(withID itemID: URL) -> MinecraftContentItem? { func rawItem(withID itemID: URL) -> MinecraftContentItem? {
rawItems.first(where: { $0.id == itemID }) rawItems.first(where: { $0.id == itemID })
} }
@ -256,7 +118,7 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
.filter { $0.logicalPackID == logicalPackID } .filter { $0.logicalPackID == logicalPackID }
.compactMap { rawItem(withID: $0.worldItemID) } .compactMap { rawItem(withID: $0.worldItemID) }
.uniqued(by: \.id) .uniqued(by: \.id)
.sorted(by: WorldScanner.sortItems) .sorted(by: MinecraftContentItem.displaySort)
} }
func resolvedPackReferences(for worldItemID: URL, type: MinecraftContentType) -> [ContentPackReference] { func resolvedPackReferences(for worldItemID: URL, type: MinecraftContentType) -> [ContentPackReference] {

View File

@ -186,16 +186,7 @@ enum WorldScanner {
} }
nonisolated static func sortItems(_ lhs: MinecraftContentItem, _ rhs: MinecraftContentItem) -> Bool { nonisolated static func sortItems(_ lhs: MinecraftContentItem, _ rhs: MinecraftContentItem) -> Bool {
if lhs.contentType != rhs.contentType { MinecraftContentItem.displaySort(lhs, rhs)
return lhs.contentType.rawValue.localizedStandardCompare(rhs.contentType.rawValue) == .orderedAscending
}
let displayNameOrder = lhs.displayName.localizedStandardCompare(rhs.displayName)
if displayNameOrder != .orderedSame {
return displayNameOrder == .orderedAscending
}
return lhs.folderName.localizedStandardCompare(rhs.folderName) == .orderedAscending
} }
nonisolated private static func contentType(forCollectionFolderName folderName: String) -> MinecraftContentType? { nonisolated private static func contentType(forCollectionFolderName folderName: String) -> MinecraftContentType? {

View File

@ -0,0 +1,146 @@
import Foundation
enum SourceScanPhase {
case discovering
case metadata
case previews
case sizing
case completed
case idle
}
enum SourcePresentation {
nonisolated static func availabilityDisplayText(for source: MinecraftSource) -> String {
switch source.availability {
case .available:
return "Available"
case .unknown:
return "Checking availability"
case .disconnected:
return source.origin.kind == .connectedDevice ? "Device offline" : "Folder offline"
case .limited:
return source.origin.kind == .connectedDevice ? "Device access limited" : "Limited access"
case .unavailable:
return source.origin.kind == .connectedDevice ? "Device unavailable" : "Folder unavailable"
}
}
nonisolated static func cachedAvailabilityDetailText(for source: MinecraftSource) -> String? {
let hasCachedContent = !source.displayItems.isEmpty || !source.rawItems.isEmpty || source.snapshot != nil
guard source.availability != .available && hasCachedContent else {
return nil
}
switch source.availability {
case .disconnected:
return source.origin.kind == .connectedDevice
? "Showing cached results until this device reconnects."
: "Showing cached results until this folder becomes reachable again."
case .limited:
return source.origin.kind == .connectedDevice
? "Showing cached results until the device is unlocked and trusted."
: "Showing cached results until full access is restored."
case .unavailable, .unknown:
return "Showing cached results while the source is unavailable."
case .available:
return nil
}
}
nonisolated static func scanPhase(for source: MinecraftSource) -> SourceScanPhase {
guard source.isScanning else {
if source.scanStatus.hasPrefix("Loaded ") || source.scanStatus == "No Minecraft items found." {
return .completed
}
return .idle
}
if source.sizeLoadedCount > 0 {
return .sizing
}
if source.previewLoadedCount > 0 {
return .previews
}
if let scanProgress = source.scanProgress {
if scanProgress >= 0.75 {
return .sizing
}
if scanProgress >= 0.65 {
return .previews
}
if scanProgress >= 0.1 {
return .metadata
}
}
if source.scanStatus.contains("Calculating sizes") {
return .sizing
}
if source.scanStatus.contains("Loading previews") {
return .previews
}
if source.scanStatus.contains("metadata") {
return .metadata
}
return .discovering
}
nonisolated static func liveScanStatusTitle(for source: MinecraftSource) -> String {
guard source.isScanning else {
return source.scanStatus
}
if source.indexedItemCount == 0 {
return "Scanning Minecraft library..."
}
let discoveryIsComplete = (source.scanProgress ?? 0) >= 0.65
if !discoveryIsComplete {
return "Discovering items..."
}
if source.indexedItemCount > 0,
source.previewLoadedCount >= source.indexedItemCount,
source.sizeLoadedCount == 0 {
return "Preparing size calculations..."
}
if source.scanStatus == "Preparing previews..." || source.scanStatus == "Preparing size calculations..." {
return source.scanStatus
}
switch scanPhase(for: source) {
case .discovering, .metadata, .previews:
return "Loading previews for \(source.previewLoadedCount) of \(source.indexedItemCount) items..."
case .sizing:
return "Calculating sizes for \(source.sizeLoadedCount) of \(source.indexedItemCount) items..."
case .completed:
return source.indexedItemCount == 0 ? "No Minecraft items found." : "Loaded \(source.indexedDetailCount) items."
case .idle:
return source.scanStatus
}
}
nonisolated static func showsIndeterminateScanActivityIndicator(for source: MinecraftSource) -> Bool {
guard source.isScanning else {
return false
}
let discoveryIsComplete = (source.scanProgress ?? 0) >= 0.65
if !discoveryIsComplete {
return true
}
if source.indexedItemCount > 0,
source.previewLoadedCount >= source.indexedItemCount,
source.sizeLoadedCount == 0 {
return true
}
return source.scanStatus == "Preparing previews..." || source.scanStatus == "Preparing size calculations..."
}
}

View File

@ -103,8 +103,8 @@ enum SourceSyncRuntime {
} else { } else {
source.scanError = nil source.scanError = nil
source.scanProgress = nil source.scanProgress = nil
source.scanStatus = source.availabilityDisplayText source.scanStatus = SourcePresentation.availabilityDisplayText(for: source)
source.scanDiagnostic = source.cachedAvailabilityDetailText source.scanDiagnostic = SourcePresentation.cachedAvailabilityDetailText(for: source)
} }
} }

View File

@ -78,7 +78,7 @@ struct SourceDetailView: View {
private var statusTitle: String { private var statusTitle: String {
if !source.isScanning, source.availability != .available { if !source.isScanning, source.availability != .available {
return source.availabilityDisplayText return SourcePresentation.availabilityDisplayText(for: source)
} }
if let scanError = source.scanError, !scanError.isEmpty { if let scanError = source.scanError, !scanError.isEmpty {
@ -86,7 +86,7 @@ struct SourceDetailView: View {
} }
if source.isScanning { if source.isScanning {
return source.liveScanStatusTitle return SourcePresentation.liveScanStatusTitle(for: source)
} }
if !source.scanStatus.isEmpty { if !source.scanStatus.isEmpty {
@ -102,7 +102,7 @@ struct SourceDetailView: View {
return scanDiagnostic return scanDiagnostic
} }
if let cachedAvailabilityDetailText = source.cachedAvailabilityDetailText { if let cachedAvailabilityDetailText = SourcePresentation.cachedAvailabilityDetailText(for: source) {
return cachedAvailabilityDetailText return cachedAvailabilityDetailText
} }
@ -136,7 +136,7 @@ struct SourceDetailView: View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 10) { HStack(alignment: .center, spacing: 10) {
if source.showsIndeterminateScanActivityIndicator { if SourcePresentation.showsIndeterminateScanActivityIndicator(for: source) {
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
} else if !source.isScanning { } else if !source.isScanning {
@ -292,7 +292,8 @@ struct SourceDetailView: View {
&& (source.scanProgress ?? 0) < 0.65 && (source.scanProgress ?? 0) < 0.65
let status: StageStatus let status: StageStatus
if previewsAreFullyLoaded || source.scanPhase == .sizing || source.scanPhase == .completed { let scanPhase = SourcePresentation.scanPhase(for: source)
if previewsAreFullyLoaded || scanPhase == .sizing || scanPhase == .completed {
status = .completed status = .completed
} else if hasPreviewWorkStarted { } else if hasPreviewWorkStarted {
status = .inProgress status = .inProgress
@ -331,7 +332,7 @@ struct SourceDetailView: View {
let previewsAreFullyLoaded = total > 0 && source.previewLoadedCount >= total let previewsAreFullyLoaded = total > 0 && source.previewLoadedCount >= total
let status: StageStatus let status: StageStatus
switch source.scanPhase { switch SourcePresentation.scanPhase(for: source) {
case .sizing: case .sizing:
status = .inProgress status = .inProgress
case .completed: case .completed: