Split item detail view surfaces
This commit is contained in:
parent
64f75e73df
commit
c828c55ec7
File diff suppressed because it is too large
Load Diff
629
World Manager for Minecraft/ItemDetailView.swift
Normal file
629
World Manager for Minecraft/ItemDetailView.swift
Normal file
@ -0,0 +1,629 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct ItemDetailView: View {
|
||||
private let detailContentMaxWidth: CGFloat = 760
|
||||
private let heroContentMaxWidth: CGFloat = 1080
|
||||
|
||||
let item: MinecraftContentItem
|
||||
let source: MinecraftSource?
|
||||
let behaviorPacks: [ContentPackReference]
|
||||
let resourcePacks: [ContentPackReference]
|
||||
let worldsUsingPack: [MinecraftContentItem]
|
||||
let backingPackInstances: [MinecraftContentItem]
|
||||
let isSuspiciousPack: Bool
|
||||
let contents: [DirectoryPreviewEntry]
|
||||
let directoryPreviewLimit: Int
|
||||
let isPerformingItemAction: Bool
|
||||
let areFileActionsEnabled: Bool
|
||||
let exportTitle: String?
|
||||
let exportAction: () -> Void
|
||||
let revealAction: () -> Void
|
||||
let shareAction: (NSView?) -> Void
|
||||
@State private var storageBreakdown = StorageBreakdown.loading
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
heroSection
|
||||
|
||||
VStack(alignment: .leading, spacing: 28) {
|
||||
recordSection(title: "About") {
|
||||
summaryGrid
|
||||
}
|
||||
|
||||
if !worldSettingsRows.isEmpty {
|
||||
recordSection(title: "World Settings") {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(worldSettingsRows, id: \.title) { row in
|
||||
detailValueRow(title: row.title, value: row.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let healthMessages, !healthMessages.isEmpty {
|
||||
recordSection(title: "Compatibility") {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(healthMessages, id: \.self) { message in
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if item.contentType == .world, !behaviorPacks.isEmpty || !resourcePacks.isEmpty || !relationshipHighlights.isEmpty {
|
||||
recordSection(title: "Packs Used") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if !relationshipHighlights.isEmpty {
|
||||
summaryLines(relationshipHighlights)
|
||||
}
|
||||
|
||||
if !behaviorPacks.isEmpty {
|
||||
packSection(title: "Behavior Packs", packs: behaviorPacks)
|
||||
}
|
||||
|
||||
if !resourcePacks.isEmpty {
|
||||
packSection(title: "Resource Packs", packs: resourcePacks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !worldsUsingPack.isEmpty {
|
||||
recordSection(title: "Used By Worlds") {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
summaryLines(packUsageHighlights)
|
||||
|
||||
ForEach(worldsUsingPack) { world in
|
||||
recordListRow(
|
||||
title: world.displayName,
|
||||
subtitle: worldUsageSecondaryText(for: world),
|
||||
iconURL: world.iconURL,
|
||||
fallbackSystemImage: "globe.europe.africa"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !backingPackInstances.isEmpty {
|
||||
recordSection(title: "Pack Instances") {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
summaryLines(instanceHighlights)
|
||||
|
||||
ForEach(backingPackInstances) { instance in
|
||||
recordListRow(
|
||||
title: instance.folderName,
|
||||
subtitle: packInstanceSecondaryText(for: instance),
|
||||
iconURL: instance.iconURL,
|
||||
fallbackSystemImage: fallbackIconName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recordSection(title: "Storage") {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if !storageHighlights.isEmpty {
|
||||
summaryLines(storageHighlights)
|
||||
}
|
||||
|
||||
detailValueRow(title: "Primary Data", value: storageBreakdown.primaryDataText)
|
||||
detailValueRow(title: "Resources", value: storageBreakdown.resourcesText)
|
||||
detailValueRow(title: "Metadata", value: storageBreakdown.metadataText)
|
||||
detailValueRow(title: "Artwork", value: item.iconURL == nil ? "No icon found" : "Thumbnail found")
|
||||
}
|
||||
}
|
||||
|
||||
recordSection(title: "Locations") {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
detailRow(title: "Source Library", value: source?.displayName ?? item.collectionRootURL.deletingLastPathComponent().lastPathComponent)
|
||||
detailRow(title: "Record Path", value: item.folderURL.path)
|
||||
detailRow(title: "Collection Root", value: item.collectionRootURL.path)
|
||||
}
|
||||
}
|
||||
|
||||
recordSection(title: "Activity") {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
detailValueRow(title: "Indexed", value: source?.lastScanDate?.formatted(date: .abbreviated, time: .shortened) ?? "Unknown")
|
||||
detailValueRow(title: "Visible Items", value: visibleContentsCountText)
|
||||
|
||||
if !contents.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Recent Folder Contents")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
ForEach(contents) { entry in
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: entry.isDirectory ? "folder.fill" : "doc.text")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(entry.name)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
if contents.count == directoryPreviewLimit {
|
||||
Text("Showing the first \(directoryPreviewLimit) items")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recordSection(title: "Technical Details") {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
detailRow(title: "Folder ID", value: item.folderID)
|
||||
detailRow(title: "Type", value: item.contentType.rawValue)
|
||||
detailRow(title: "Collection Folder", value: item.collectionRootURL.lastPathComponent)
|
||||
if let spawn = item.worldMetadata?.spawn {
|
||||
detailValueRow(title: "Spawn", value: spawn)
|
||||
}
|
||||
if let storageVersion = item.worldMetadata?.storageVersion {
|
||||
detailValueRow(title: "Storage Version", value: storageVersion)
|
||||
}
|
||||
if let networkVersion = item.worldMetadata?.networkVersion {
|
||||
detailValueRow(title: "Network Version", value: networkVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: detailContentMaxWidth, alignment: .leading)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 28)
|
||||
.padding(.bottom, 24)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.task(id: item.id) {
|
||||
storageBreakdown = await loadStorageBreakdown(for: item)
|
||||
}
|
||||
}
|
||||
|
||||
private var heroSection: some View {
|
||||
RecordHeroView(
|
||||
item: item,
|
||||
metadataChips: heroMetadata,
|
||||
fallbackSystemImage: fallbackIconName,
|
||||
copyAction: {
|
||||
copyToPasteboard(item.displayName)
|
||||
},
|
||||
actionRow: AnyView(actionRow),
|
||||
contentMaxWidth: heroContentMaxWidth
|
||||
)
|
||||
}
|
||||
|
||||
private var actionRow: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 10) {
|
||||
actionButtons
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
actionButtons
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var summaryGrid: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
detailRow(title: "Name", value: item.displayName)
|
||||
detailValueRow(title: "Size", value: sizeText)
|
||||
detailValueRow(title: item.displayDateLabel, value: displayDateText)
|
||||
detailValueRow(title: "Created", value: createdDateText)
|
||||
|
||||
if let gameMode = item.worldMetadata?.gameMode {
|
||||
detailValueRow(title: "Game Mode", value: gameMode)
|
||||
}
|
||||
|
||||
if let difficulty = item.worldMetadata?.difficulty {
|
||||
detailValueRow(title: "Difficulty", value: difficulty)
|
||||
}
|
||||
|
||||
if let seed = item.worldMetadata?.seed {
|
||||
detailValueRow(title: "Seed", value: seed)
|
||||
}
|
||||
|
||||
if let lastOpenedWithVersion = item.worldMetadata?.lastOpenedWithVersion {
|
||||
detailValueRow(title: "Last Opened With", value: lastOpenedWithVersion)
|
||||
}
|
||||
|
||||
if let inventoryVersion = item.worldMetadata?.inventoryVersion {
|
||||
detailValueRow(title: "Inventory Version", value: inventoryVersion)
|
||||
}
|
||||
|
||||
if item.contentType == .world {
|
||||
detailValueRow(
|
||||
title: "Pack References",
|
||||
value: "\(behaviorPacks.count + resourcePacks.count)"
|
||||
)
|
||||
}
|
||||
|
||||
if item.contentType == .behaviorPack || item.contentType == .resourcePack {
|
||||
detailValueRow(title: "UUID", value: item.packUUID ?? "Unavailable")
|
||||
detailValueRow(title: "Version", value: item.packVersion ?? "Unavailable")
|
||||
if let minimumEngineVersion = item.packMetadataDetails?.minimumEngineVersion {
|
||||
detailValueRow(title: "Minimum Engine", value: minimumEngineVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var worldSettingsRows: [(title: String, value: String)] {
|
||||
guard let metadata = item.worldMetadata else {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
booleanRow("Cheats Enabled", metadata.cheatsEnabled),
|
||||
booleanRow("Commands Enabled", metadata.commandsEnabled),
|
||||
booleanRow("Education Features", metadata.educationFeaturesEnabled),
|
||||
booleanRow("Coordinates Shown", metadata.coordinatesShown),
|
||||
booleanRow("Keep Inventory", metadata.keepInventory),
|
||||
booleanRow("Mob Griefing", metadata.mobGriefingEnabled),
|
||||
booleanRow("Daylight Cycle", metadata.daylightCycleEnabled),
|
||||
booleanRow("Weather Cycle", metadata.weatherCycleEnabled)
|
||||
].compactMap { $0 }
|
||||
}
|
||||
|
||||
private var healthMessages: [String]? {
|
||||
var messages: [String] = []
|
||||
|
||||
if isSuspiciousPack {
|
||||
messages.append("Manifest UUID is missing or unreadable for this pack, so matching is using a weaker fallback identity.")
|
||||
}
|
||||
|
||||
if let unresolvedCount, unresolvedCount > 0 {
|
||||
messages.append("\(unresolvedCount) referenced pack\(unresolvedCount == 1 ? "" : "s") could not be matched in this library.")
|
||||
}
|
||||
|
||||
return messages.isEmpty ? nil : messages
|
||||
}
|
||||
|
||||
private var relationshipHighlights: [String] {
|
||||
guard item.contentType == .world else {
|
||||
return []
|
||||
}
|
||||
|
||||
var highlights: [String] = []
|
||||
let totalPackCount = behaviorPacks.count + resourcePacks.count
|
||||
if totalPackCount > 0 {
|
||||
highlights.append("Uses \(totalPackCount) pack\(totalPackCount == 1 ? "" : "s")")
|
||||
}
|
||||
|
||||
if let resolvedCount {
|
||||
highlights.append("\(resolvedCount) found in this library")
|
||||
}
|
||||
|
||||
if let unresolvedCount, unresolvedCount > 0 {
|
||||
highlights.append("\(unresolvedCount) unresolved")
|
||||
}
|
||||
|
||||
let relatedWorldCount = otherWorldsSharingReferencedPacks
|
||||
if relatedWorldCount > 0 {
|
||||
highlights.append("Shared by \(relatedWorldCount) other world\(relatedWorldCount == 1 ? "" : "s")")
|
||||
}
|
||||
|
||||
return highlights
|
||||
}
|
||||
|
||||
private var packUsageHighlights: [String] {
|
||||
[
|
||||
"\(worldsUsingPack.count) world\(worldsUsingPack.count == 1 ? "" : "s") use this",
|
||||
backingPackInstances.count > 1 ? "\(backingPackInstances.count) copies indexed" : "1 indexed copy"
|
||||
]
|
||||
}
|
||||
|
||||
private var instanceHighlights: [String] {
|
||||
let embeddedCount = backingPackInstances.filter {
|
||||
$0.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName)
|
||||
}.count
|
||||
let topLevelCount = backingPackInstances.count - embeddedCount
|
||||
|
||||
var highlights: [String] = []
|
||||
if topLevelCount > 0 {
|
||||
highlights.append("\(topLevelCount) library cop\(topLevelCount == 1 ? "y" : "ies")")
|
||||
}
|
||||
if embeddedCount > 0 {
|
||||
highlights.append("\(embeddedCount) embedded in worlds")
|
||||
}
|
||||
return highlights
|
||||
}
|
||||
|
||||
private var storageHighlights: [String] {
|
||||
var highlights = [storageFormatLabel]
|
||||
if let approximateAgeText {
|
||||
highlights.append(approximateAgeText)
|
||||
}
|
||||
return highlights
|
||||
}
|
||||
|
||||
private var heroMetadata: [String] {
|
||||
var chips = [item.contentType.rawValue, sizeText, "\(item.displayDateLabel) \(displayDateText)"]
|
||||
|
||||
if item.contentType == .world {
|
||||
let packCount = behaviorPacks.count + resourcePacks.count
|
||||
if packCount > 0 {
|
||||
chips.append("\(packCount) pack\(packCount == 1 ? "" : "s")")
|
||||
}
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
|
||||
private var unresolvedCount: Int? {
|
||||
source?.logicalWorld(forItemID: item.id)?.unresolvedReferences.count
|
||||
}
|
||||
|
||||
private var resolvedCount: Int? {
|
||||
guard let logicalWorld = source?.logicalWorld(forItemID: item.id) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return logicalWorld.usedPackIDs.count
|
||||
}
|
||||
|
||||
private var otherWorldsSharingReferencedPacks: Int {
|
||||
guard
|
||||
let source,
|
||||
let logicalWorld = source.logicalWorld(forItemID: item.id)
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let relatedWorldIDs = Set(logicalWorld.usedPackIDs.flatMap { packID in
|
||||
source.worldsUsingPack(packID).map(\.id)
|
||||
})
|
||||
|
||||
return max(0, relatedWorldIDs.subtracting([item.id]).count)
|
||||
}
|
||||
|
||||
private var actionRowExportTitle: String {
|
||||
if exportTitle != nil {
|
||||
switch item.contentType {
|
||||
case .world:
|
||||
return "Export World"
|
||||
case .behaviorPack, .resourcePack, .skinPack:
|
||||
return "Export Pack"
|
||||
case .worldTemplate:
|
||||
return "Export Template"
|
||||
}
|
||||
}
|
||||
|
||||
return "Export"
|
||||
}
|
||||
|
||||
private var fallbackIconName: String {
|
||||
switch item.contentType {
|
||||
case .world:
|
||||
return "globe.europe.africa"
|
||||
case .behaviorPack:
|
||||
return "shippingbox"
|
||||
case .resourcePack:
|
||||
return "paintpalette"
|
||||
case .skinPack:
|
||||
return "person.crop.square"
|
||||
case .worldTemplate:
|
||||
return "doc.on.doc"
|
||||
}
|
||||
}
|
||||
|
||||
private var storageFormatLabel: String {
|
||||
switch item.contentType {
|
||||
case .world:
|
||||
return FileManager.default.fileExists(atPath: item.folderURL.appendingPathComponent("db", isDirectory: true).path)
|
||||
? "LevelDB world storage"
|
||||
: "Flat-file world storage"
|
||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||
return "Manifest-based package"
|
||||
}
|
||||
}
|
||||
|
||||
private var createdDateText: String {
|
||||
(try? item.folderURL.resourceValues(forKeys: [.creationDateKey]).creationDate)?
|
||||
.formatted(date: .abbreviated, time: .omitted) ?? "Unknown"
|
||||
}
|
||||
|
||||
private var approximateAgeText: String? {
|
||||
guard let createdDate = try? item.folderURL.resourceValues(forKeys: [.creationDateKey]).creationDate else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let components = Calendar.current.dateComponents([.year, .month], from: createdDate, to: .now)
|
||||
if let year = components.year, year > 0 {
|
||||
return year == 1 ? "About 1 year old" : "About \(year) years old"
|
||||
}
|
||||
if let month = components.month, month > 0 {
|
||||
return month == 1 ? "About 1 month old" : "About \(month) months old"
|
||||
}
|
||||
return "Recently created"
|
||||
}
|
||||
|
||||
private var visibleContentsCountText: String {
|
||||
if contents.isEmpty {
|
||||
return "No visible files or folders"
|
||||
}
|
||||
|
||||
return "\(contents.count)\(contents.count == directoryPreviewLimit ? "+" : "") visible entries"
|
||||
}
|
||||
|
||||
private func recordSection<Content: View>(
|
||||
title: String,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title.uppercased())
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.tracking(0.5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
content()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.appCardSurface(.secondaryPanel)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func packSection(title: String, packs: [ContentPackReference]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
ForEach(packs) { pack in
|
||||
recordListRow(
|
||||
title: pack.name,
|
||||
subtitle: packSecondaryText(pack),
|
||||
iconURL: pack.iconURL,
|
||||
fallbackSystemImage: pack.type == .resourcePack ? "paintpalette" : "shippingbox"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailRow(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(value)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailValueRow(title: String, value: String) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||
Text(title)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(3)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private func booleanRow(_ title: String, _ value: Bool?) -> (title: String, value: String)? {
|
||||
guard let value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (title, value ? "Yes" : "No")
|
||||
}
|
||||
|
||||
private var sizeText: String {
|
||||
if let sizeBytes = item.sizeBytes {
|
||||
return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file)
|
||||
}
|
||||
|
||||
if item.sizeLoaded {
|
||||
return "Unavailable"
|
||||
}
|
||||
|
||||
return item.metadataLoaded ? "Calculating..." : "Loading..."
|
||||
}
|
||||
|
||||
private var displayDateText: String {
|
||||
item.displayDate.map { $0.formatted(date: .abbreviated, time: .omitted) } ?? "Unknown"
|
||||
}
|
||||
|
||||
private func packSecondaryText(_ pack: ContentPackReference) -> String? {
|
||||
let components = [pack.version.map { "v\($0)" }, pack.uuid]
|
||||
.compactMap { $0 }
|
||||
return components.isEmpty ? nil : components.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private func worldUsageSecondaryText(for world: MinecraftContentItem) -> String {
|
||||
let dateText = world.displayDate?.formatted(date: .abbreviated, time: .omitted) ?? "Date unavailable"
|
||||
return "\(world.displayDateLabel) \(dateText)"
|
||||
}
|
||||
|
||||
private func packInstanceSecondaryText(for instance: MinecraftContentItem) -> String {
|
||||
if instance.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName) {
|
||||
return "Embedded in world copy"
|
||||
}
|
||||
|
||||
return "Top-level pack folder"
|
||||
}
|
||||
|
||||
private func summaryLines(_ values: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(values, id: \.self) { value in
|
||||
Label(value, systemImage: "checkmark.circle")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButtons: some View {
|
||||
ActionPillButton(
|
||||
title: actionRowExportTitle,
|
||||
systemImage: "arrow.down.circle.fill",
|
||||
isDisabled: isPerformingItemAction || !areFileActionsEnabled,
|
||||
prominence: .primary,
|
||||
action: exportAction
|
||||
)
|
||||
|
||||
ActionPillButton(
|
||||
title: "Reveal",
|
||||
systemImage: "folder.fill",
|
||||
isDisabled: isPerformingItemAction || !areFileActionsEnabled,
|
||||
prominence: .secondary,
|
||||
action: revealAction
|
||||
)
|
||||
|
||||
SharingPillButton(
|
||||
title: "Share",
|
||||
systemImage: "square.and.arrow.up",
|
||||
isEnabled: !isPerformingItemAction && areFileActionsEnabled,
|
||||
action: shareAction
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func recordListRow(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
iconURL: URL?,
|
||||
fallbackSystemImage: String
|
||||
) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
PackReferenceIconView(iconURL: iconURL, fallbackSystemImage: fallbackSystemImage)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.lineLimit(2)
|
||||
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadStorageBreakdown(for item: MinecraftContentItem) async -> StorageBreakdown {
|
||||
await Task.detached(priority: .utility) {
|
||||
StorageBreakdown.build(for: item)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
524
World Manager for Minecraft/SourceDetailView.swift
Normal file
524
World Manager for Minecraft/SourceDetailView.swift
Normal file
@ -0,0 +1,524 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SourceDetailView: View {
|
||||
private enum StageStatus {
|
||||
case pending
|
||||
case inProgress
|
||||
case completed
|
||||
}
|
||||
|
||||
private struct StageRow: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let status: StageStatus
|
||||
let progress: Double?
|
||||
let showsIndeterminateProgress: Bool
|
||||
}
|
||||
|
||||
let source: MinecraftSource
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(source.displayName)
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
|
||||
Text(sourceSummary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if showsStatusSection {
|
||||
sourceStatusSection
|
||||
}
|
||||
|
||||
sourceSection(title: "Overview", rows: overviewRows)
|
||||
sourceSection(title: "Contents", rows: contentRows)
|
||||
sourceSection(title: "Location", rows: locationRows)
|
||||
|
||||
if !technicalRows.isEmpty {
|
||||
sourceSection(title: "Technical Details", rows: technicalRows)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 760, alignment: .leading)
|
||||
.padding(28)
|
||||
}
|
||||
}
|
||||
|
||||
private var sourceSummary: String {
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
return "Local filesystem source"
|
||||
case .connectedDevice(let device, let container):
|
||||
return "\(device.name) • \(container.appName)"
|
||||
}
|
||||
}
|
||||
|
||||
private var showsStatusSection: Bool {
|
||||
if source.isScanning || source.scanError != nil || source.availability != .available {
|
||||
return true
|
||||
}
|
||||
|
||||
guard !source.scanStatus.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
if source.scanStatus == "No Minecraft items found." {
|
||||
return false
|
||||
}
|
||||
|
||||
if source.scanStatus.hasPrefix("Loaded ") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
if !source.isScanning, source.availability != .available {
|
||||
return source.availabilityDisplayText
|
||||
}
|
||||
|
||||
if let scanError = source.scanError, !scanError.isEmpty {
|
||||
return "Scan Failed"
|
||||
}
|
||||
|
||||
if source.isScanning {
|
||||
return source.liveScanStatusTitle
|
||||
}
|
||||
|
||||
if !source.scanStatus.isEmpty {
|
||||
return source.scanStatus
|
||||
}
|
||||
|
||||
return "Scanning Minecraft library..."
|
||||
}
|
||||
|
||||
private var statusDetail: String? {
|
||||
if !source.isScanning, source.availability != .available {
|
||||
if let scanDiagnostic = source.scanDiagnostic, !scanDiagnostic.isEmpty {
|
||||
return scanDiagnostic
|
||||
}
|
||||
|
||||
if let cachedAvailabilityDetailText = source.cachedAvailabilityDetailText {
|
||||
return cachedAvailabilityDetailText
|
||||
}
|
||||
|
||||
if let lastScanDate = source.lastScanDate {
|
||||
return "Cached from \(lastScanDate.formatted(date: .abbreviated, time: .shortened))"
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if let scanError = source.scanError, !scanError.isEmpty {
|
||||
return scanError
|
||||
}
|
||||
|
||||
if let diagnostic = source.scanDiagnostic, !diagnostic.isEmpty {
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
if source.isScanning {
|
||||
return nil
|
||||
}
|
||||
|
||||
return "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sourceStatusSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Status")
|
||||
.font(.headline)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
if source.showsIndeterminateScanActivityIndicator {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else if !source.isScanning {
|
||||
sourceStatusIcon
|
||||
}
|
||||
|
||||
Text(statusTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
|
||||
if let statusDetail, !statusDetail.isEmpty {
|
||||
Text(statusDetail)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if source.isScanning {
|
||||
Divider()
|
||||
.padding(.vertical, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(stageRows) { stage in
|
||||
sourceStageRow(stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.appCardSurface(.primaryPanel)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sourceStatusIcon: some View {
|
||||
if source.availability == .limited {
|
||||
Image(systemName: "lock.circle.fill")
|
||||
.foregroundStyle(Color.appAccent)
|
||||
} else if source.availability != .available {
|
||||
Image(systemName: source.isOfflineCached ? "externaldrive.badge.exclamationmark" : "slash.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if source.scanError != nil {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var overviewRows: [(String, String)] {
|
||||
var rows: [(String, String)] = [
|
||||
("Type", sourceTypeLabel),
|
||||
("Availability", availabilityLabel)
|
||||
]
|
||||
|
||||
if let lastScanDate = source.lastScanDate {
|
||||
rows.append(("Last Successful Scan", lastScanDate.formatted(date: .abbreviated, time: .shortened)))
|
||||
}
|
||||
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
break
|
||||
case .connectedDevice(let device, let container):
|
||||
rows.append(("Connection", device.connection == .network ? "Network" : "USB"))
|
||||
rows.append(("App Container", container.appName))
|
||||
if let osVersion = device.osVersion, !osVersion.isEmpty {
|
||||
rows.append(("OS Version", osVersion))
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
private var contentRows: [(String, String)] {
|
||||
[
|
||||
("Total Items", source.items.count.formatted(.number)),
|
||||
("Worlds", itemCount(for: .world).formatted(.number)),
|
||||
("Behavior Packs", itemCount(for: .behaviorPack).formatted(.number)),
|
||||
("Resource Packs", itemCount(for: .resourcePack).formatted(.number)),
|
||||
("Skin Packs", itemCount(for: .skinPack).formatted(.number)),
|
||||
("World Templates", itemCount(for: .worldTemplate).formatted(.number))
|
||||
]
|
||||
}
|
||||
|
||||
private var locationRows: [(String, String)] {
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
return [("Filesystem Path", source.folderURL.path)]
|
||||
case .connectedDevice(_, let container):
|
||||
var rows: [(String, String)] = [
|
||||
("Source Identifier", source.folderURL.absoluteString)
|
||||
]
|
||||
if let relativePath = container.minecraftFolderRelativePath, !relativePath.isEmpty {
|
||||
rows.append(("Minecraft Path", relativePath))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
private var technicalRows: [(String, String)] {
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
return []
|
||||
case .connectedDevice(let device, let container):
|
||||
var rows: [(String, String)] = [
|
||||
("UDID", device.udid),
|
||||
("App ID", container.appID),
|
||||
("Access Mode", container.accessMode.rawValue)
|
||||
]
|
||||
if let productType = device.productType, !productType.isEmpty {
|
||||
rows.append(("Product Type", productType))
|
||||
}
|
||||
rows.append(("Trust State", device.trustState.rawValue.capitalized))
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
private var sourceTypeLabel: String {
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
return "Local Folder"
|
||||
case .connectedDevice:
|
||||
return "Connected Device"
|
||||
}
|
||||
}
|
||||
|
||||
private var availabilityLabel: String {
|
||||
switch source.availability {
|
||||
case .unknown:
|
||||
return "Unknown"
|
||||
case .available:
|
||||
return "Available"
|
||||
case .disconnected:
|
||||
return "Disconnected"
|
||||
case .limited:
|
||||
return "Limited"
|
||||
case .unavailable:
|
||||
return "Unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
private var stageRows: [StageRow] {
|
||||
[previewStageRow, sizeStageRow]
|
||||
}
|
||||
|
||||
private var previewStageRow: StageRow {
|
||||
let total = source.indexedItemCount
|
||||
let progress = total > 0 ? min(Double(source.previewLoadedCount) / Double(total), 1) : 0
|
||||
let hasPreviewWorkStarted = source.previewLoadedCount > 0 || source.scanStatus.contains("Loading previews")
|
||||
let previewsAreFullyLoaded = total > 0 && source.previewLoadedCount >= total
|
||||
let shouldShowIndeterminatePreviewProgress = hasPreviewWorkStarted
|
||||
&& source.isScanning
|
||||
&& (source.scanProgress ?? 0) < 0.65
|
||||
|
||||
let status: StageStatus
|
||||
if previewsAreFullyLoaded || source.scanPhase == .sizing || source.scanPhase == .completed {
|
||||
status = .completed
|
||||
} else if hasPreviewWorkStarted {
|
||||
status = .inProgress
|
||||
} else {
|
||||
status = .pending
|
||||
}
|
||||
|
||||
let detail: String
|
||||
switch status {
|
||||
case .completed:
|
||||
detail = finishedStageDetail(duration: source.previewStageDuration ?? source.previewStageElapsed)
|
||||
case .inProgress:
|
||||
detail = stageProgressDetail(
|
||||
completed: source.previewLoadedCount,
|
||||
total: total,
|
||||
unit: "previews loaded",
|
||||
elapsed: source.previewStageElapsed
|
||||
)
|
||||
case .pending:
|
||||
detail = "Waiting for discovery to finish"
|
||||
}
|
||||
|
||||
return StageRow(
|
||||
id: "previews",
|
||||
title: "Previews",
|
||||
detail: detail,
|
||||
status: status,
|
||||
progress: status == .completed ? 1 : progress,
|
||||
showsIndeterminateProgress: shouldShowIndeterminatePreviewProgress
|
||||
)
|
||||
}
|
||||
|
||||
private var sizeStageRow: StageRow {
|
||||
let total = source.indexedItemCount
|
||||
let progress = total > 0 ? min(Double(source.sizeLoadedCount) / Double(total), 1) : 0
|
||||
let previewsAreFullyLoaded = total > 0 && source.previewLoadedCount >= total
|
||||
|
||||
let status: StageStatus
|
||||
switch source.scanPhase {
|
||||
case .sizing:
|
||||
status = .inProgress
|
||||
case .completed:
|
||||
status = .completed
|
||||
case .discovering, .metadata, .previews, .idle:
|
||||
status = .pending
|
||||
}
|
||||
|
||||
let detail: String
|
||||
switch status {
|
||||
case .completed:
|
||||
detail = finishedStageDetail(duration: source.sizeStageDuration)
|
||||
case .inProgress:
|
||||
detail = stageProgressDetail(
|
||||
completed: source.sizeLoadedCount,
|
||||
total: total,
|
||||
unit: "sizes calculated",
|
||||
elapsed: source.sizeStageElapsed
|
||||
)
|
||||
case .pending:
|
||||
detail = previewsAreFullyLoaded
|
||||
? "Preparing size calculations..."
|
||||
: "Waiting for previews to finish loading"
|
||||
}
|
||||
|
||||
return StageRow(
|
||||
id: "sizes",
|
||||
title: "Sizes",
|
||||
detail: detail,
|
||||
status: status,
|
||||
progress: status == .completed ? 1 : progress,
|
||||
showsIndeterminateProgress: false
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sourceStageRow(_ stage: StageRow) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Image(systemName: stageIconName(for: stage.status))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(stageIconColor(for: stage.status))
|
||||
.frame(width: 14)
|
||||
|
||||
Text(stage.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(stage.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if stage.status == .completed {
|
||||
EmptyView()
|
||||
} else if stage.showsIndeterminateProgress {
|
||||
ProgressView()
|
||||
.progressViewStyle(.linear)
|
||||
.controlSize(.small)
|
||||
.tint(Color.appAccent)
|
||||
} else if let progress = stage.progress {
|
||||
ProgressView(value: progress, total: 1)
|
||||
.tint(stage.status == .pending ? .secondary.opacity(0.35) : Color.appAccent)
|
||||
.opacity(stage.status == .pending ? 0.55 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stageIconName(for status: StageStatus) -> String {
|
||||
switch status {
|
||||
case .pending:
|
||||
return "circle"
|
||||
case .inProgress:
|
||||
return "clock"
|
||||
case .completed:
|
||||
return "checkmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func stageIconColor(for status: StageStatus) -> Color {
|
||||
switch status {
|
||||
case .pending:
|
||||
return .secondary.opacity(0.7)
|
||||
case .inProgress:
|
||||
return Color.appAccent
|
||||
case .completed:
|
||||
return .green
|
||||
}
|
||||
}
|
||||
|
||||
private func stageProgressDetail(
|
||||
completed: Int,
|
||||
total: Int,
|
||||
unit: String,
|
||||
elapsed: TimeInterval?
|
||||
) -> String {
|
||||
guard total > 0 else {
|
||||
return "Starting..."
|
||||
}
|
||||
|
||||
var detail = "\(completed) of \(total) \(unit)"
|
||||
|
||||
if let remainingEstimate = estimateRemainingTime(
|
||||
completed: completed,
|
||||
total: total,
|
||||
elapsed: elapsed
|
||||
) {
|
||||
detail += " • about \(friendlyDuration(remainingEstimate)) left"
|
||||
}
|
||||
|
||||
return detail
|
||||
}
|
||||
|
||||
private func finishedStageDetail(duration: TimeInterval?) -> String {
|
||||
guard let duration else {
|
||||
return "Finished"
|
||||
}
|
||||
|
||||
return "Finished in \(friendlyDuration(duration))"
|
||||
}
|
||||
|
||||
private func estimateRemainingTime(
|
||||
completed: Int,
|
||||
total: Int,
|
||||
elapsed: TimeInterval?
|
||||
) -> TimeInterval? {
|
||||
guard
|
||||
let elapsed,
|
||||
elapsed >= 10,
|
||||
completed >= 10,
|
||||
total > 0,
|
||||
completed < total
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let completionFraction = Double(completed) / Double(total)
|
||||
guard completionFraction >= 0.05 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let secondsPerItem = elapsed / Double(completed)
|
||||
let remaining = max(Double(total - completed) * secondsPerItem, 1)
|
||||
guard remaining >= 60 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return remaining
|
||||
}
|
||||
|
||||
private func friendlyDuration(_ duration: TimeInterval) -> String {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
formatter.maximumUnitCount = duration >= 3600 ? 2 : 1
|
||||
formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : duration >= 60 ? [.minute] : [.second]
|
||||
formatter.includesApproximationPhrase = false
|
||||
formatter.includesTimeRemainingPhrase = false
|
||||
return formatter.string(from: duration) ?? "a moment"
|
||||
}
|
||||
|
||||
private func itemCount(for type: MinecraftContentType) -> Int {
|
||||
source.items.filter { $0.contentType == type }.count
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sourceSection(title: String, rows: [(String, String)]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
Text(row.0)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 170, alignment: .leading)
|
||||
|
||||
Text(row.1)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.appCardSurface(.primaryPanel)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user