From c828c55ec7cb5933dfdef70df02b6b698d15d3be Mon Sep 17 00:00:00 2001 From: John Burwell Date: Fri, 29 May 2026 07:37:47 -0500 Subject: [PATCH] Split item detail view surfaces --- .../ItemDetailColumnViews.swift | 1155 ----------------- .../ItemDetailView.swift | 629 +++++++++ .../SourceDetailView.swift | 524 ++++++++ 3 files changed, 1153 insertions(+), 1155 deletions(-) create mode 100644 World Manager for Minecraft/ItemDetailView.swift create mode 100644 World Manager for Minecraft/SourceDetailView.swift diff --git a/World Manager for Minecraft/ItemDetailColumnViews.swift b/World Manager for Minecraft/ItemDetailColumnViews.swift index 526f2c5..6a9f707 100644 --- a/World Manager for Minecraft/ItemDetailColumnViews.swift +++ b/World Manager for Minecraft/ItemDetailColumnViews.swift @@ -29,8 +29,6 @@ struct ItemDetailColumnView: View { var body: some View { Group { if isEmpty { -// Text("Add a source folder to start scanning your Minecraft library") -// .foregroundStyle(.secondary) } else if let item { ItemDetailView( item: item, @@ -88,1159 +86,6 @@ struct ItemDetailColumnView: View { } } -private 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) - } - } -} - -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( - 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 - } -} - #if DEBUG struct ItemDetailColumnViews_Previews: PreviewProvider { static var previews: some View { diff --git a/World Manager for Minecraft/ItemDetailView.swift b/World Manager for Minecraft/ItemDetailView.swift new file mode 100644 index 0000000..acb7486 --- /dev/null +++ b/World Manager for Minecraft/ItemDetailView.swift @@ -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( + 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 + } +} diff --git a/World Manager for Minecraft/SourceDetailView.swift b/World Manager for Minecraft/SourceDetailView.swift new file mode 100644 index 0000000..f00ad5c --- /dev/null +++ b/World Manager for Minecraft/SourceDetailView.swift @@ -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) + } + } +}