From b25f2e01487289a65164671892e196f690389cc6 Mon Sep 17 00:00:00 2001 From: John Burwell Date: Tue, 26 May 2026 09:59:57 -0500 Subject: [PATCH] More layout finicking on the details pane. refactor layouts out of the main contentview file --- .../ContentUIShared.swift | 623 +++++++++++++ World Manager for Minecraft/ContentView.swift | 832 +----------------- .../ItemDetailColumnViews.swift | 641 ++++++++++++++ .../ItemListColumnViews.swift | 133 +++ .../PreviewFixtures.swift | 356 ++++++++ .../SidebarColumnViews.swift | 180 ++++ 6 files changed, 1934 insertions(+), 831 deletions(-) create mode 100644 World Manager for Minecraft/ContentUIShared.swift create mode 100644 World Manager for Minecraft/ItemDetailColumnViews.swift create mode 100644 World Manager for Minecraft/ItemListColumnViews.swift create mode 100644 World Manager for Minecraft/PreviewFixtures.swift create mode 100644 World Manager for Minecraft/SidebarColumnViews.swift diff --git a/World Manager for Minecraft/ContentUIShared.swift b/World Manager for Minecraft/ContentUIShared.swift new file mode 100644 index 0000000..23c69e0 --- /dev/null +++ b/World Manager for Minecraft/ContentUIShared.swift @@ -0,0 +1,623 @@ +import AppKit +import SwiftUI + +struct SharingPickerButton: NSViewRepresentable { + let title: String? + let systemImage: String + let isEnabled: Bool + let action: (NSView) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + func makeNSView(context: Context) -> NSButton { + let button = NSButton() + button.target = context.coordinator + button.action = #selector(Coordinator.didPressButton(_:)) + button.isBordered = false + button.bezelStyle = .regularSquare + button.contentTintColor = .white + button.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + update(button) + return button + } + + func updateNSView(_ nsView: NSButton, context: Context) { + context.coordinator.action = action + update(nsView) + } + + private func update(_ button: NSButton) { + button.image = NSImage( + systemSymbolName: systemImage, + accessibilityDescription: title ?? "Share" + ) + button.imagePosition = title == nil ? .imageOnly : .imageLeading + button.isEnabled = isEnabled + button.attributedTitle = NSAttributedString( + string: title ?? "", + attributes: [ + .foregroundColor: NSColor.white, + .font: NSFont.systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + ] + ) + } + + final class Coordinator: NSObject { + var action: (NSView) -> Void + + init(action: @escaping (NSView) -> Void) { + self.action = action + } + + @objc func didPressButton(_ sender: NSButton) { + action(sender) + } + } +} + +struct ActionPillButton: View { + enum Prominence { + case primary + case secondary + } + + let title: String + let systemImage: String + var isDisabled = false + var prominence: Prominence = .secondary + let action: () -> Void + + var body: some View { + Button(action: action) { + HeroActionLabel(title: title, systemImage: systemImage) + } + .buttonStyle(HeroActionButtonStyle(prominence: prominence)) + .disabled(isDisabled) + .opacity(isDisabled ? 0.55 : 1) + } +} + +struct SharingPillButton: View { + let title: String + let systemImage: String + let isEnabled: Bool + let action: (NSView?) -> Void + @State private var anchorView: NSView? + + var body: some View { + Button { + action(anchorView) + } label: { + HeroActionLabel(title: title, systemImage: systemImage) + } + .buttonStyle(HeroActionButtonStyle(prominence: .secondary)) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.55) + .background { + ShareAnchorView(anchorView: $anchorView) + } + } +} + +struct HeroActionLabel: View { + let title: String + let systemImage: String + + var body: some View { + Label(title, systemImage: systemImage) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.horizontal, 14) + .padding(.vertical, 12) + } +} + +struct HeroActionButtonStyle: ButtonStyle { + let prominence: ActionPillButton.Prominence + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .background(backgroundColor.opacity(configuration.isPressed ? pressedOpacity : 1), in: Capsule()) + .overlay { + if prominence == .secondary { + Capsule() + .strokeBorder(.white.opacity(0.14)) + } + } + .controlSize(.large) + } + + private var backgroundColor: Color { + switch prominence { + case .primary: + return .appAccent + case .secondary: + return Color.black.opacity(0.22) + } + } + + private var pressedOpacity: CGFloat { + switch prominence { + case .primary: + return 0.88 + case .secondary: + return 0.72 + } + } +} + +struct ShareAnchorView: NSViewRepresentable { + @Binding var anchorView: NSView? + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + DispatchQueue.main.async { + anchorView = view + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + if anchorView !== nsView { + DispatchQueue.main.async { + anchorView = nsView + } + } + } +} + +struct RecordHeroView: View { + private let heroHeight: CGFloat = 360 + private let heroTopPadding: CGFloat = 74 + private let heroBottomPadding: CGFloat = 20 + private let titleLineLimit = 3 + private let titleMinimumScale: CGFloat = 0.82 + private let detailsColumnSpacing: CGFloat = 18 + + let item: MinecraftContentItem + let metadataChips: [String] + let fallbackSystemImage: String + let copyAction: () -> Void + let actionRow: AnyView + let contentMaxWidth: CGFloat + + var body: some View { + GeometryReader { proxy in + let innerHeight = max(0, proxy.size.height - heroTopPadding - heroBottomPadding) + + ZStack(alignment: .topLeading) { + artworkBackground + + LinearGradient( + colors: [.black.opacity(0.12), .black.opacity(0.22), .black.opacity(0.5)], + startPoint: .top, + endPoint: .bottom + ) + + HStack(alignment: .top, spacing: 28) { + HeroThumbnailView( + iconURL: item.iconURL, + contentType: item.contentType, + availableHeight: innerHeight + ) + + VStack(alignment: .leading, spacing: detailsColumnSpacing) { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 10) { + Text(item.displayName) + .font(titleFont) + .foregroundStyle(.white) + .lineLimit(titleLineLimit) + .minimumScaleFactor(titleMinimumScale) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + + Button(action: copyAction) { + Image(systemName: "document.on.document") + .font(.title3.weight(.semibold)) + .foregroundStyle(.white.opacity(0.88)) + } + .buttonStyle(.plain) + } + + recordHeroChips + } + + Spacer(minLength: 16) + + actionRow + } + .frame(maxWidth: .infinity, maxHeight: innerHeight, alignment: .topLeading) + } + .frame(maxWidth: contentMaxWidth, maxHeight: innerHeight, alignment: .topLeading) + .padding(.horizontal, 28) + .padding(.top, heroTopPadding) + .padding(.bottom, heroBottomPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + } + .frame(maxWidth: .infinity) + .frame(height: heroHeight, alignment: .top) + .clipShape(Rectangle()) + .ignoresSafeArea(edges: .top) + } + + private var artworkBackground: some View { + Group { + if let image = loadImage(from: item.iconURL) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .blur(radius: 36) + .saturation(1.08) + } else { + LinearGradient( + colors: [Color.appAccent.opacity(0.9), Color.black.opacity(0.8)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .overlay { + Image(systemName: fallbackSystemImage) + .font(.system(size: 84)) + .foregroundStyle(.white.opacity(0.2)) + } + } + } + } + + private var recordHeroChips: some View { + FlexibleTagLayout(spacing: 8, rowSpacing: 8, items: metadataChips) { chip in + Text(chip) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.95)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(.white.opacity(0.14), in: Capsule()) + } + } + + private var titleFont: Font { + .system( + item.displayName.count > 70 ? .title2 : .largeTitle, + design: .rounded + ).weight(.semibold) + } +} + +private struct HeroThumbnailView: View { + private let thumbnailMaxWidth: CGFloat = 320 + private let thumbnailMaxHeight: CGFloat = 200 + private let thumbnailMinHeight: CGFloat = 120 + + let iconURL: URL? + let contentType: MinecraftContentType + let availableHeight: CGFloat + + var body: some View { + Group { + if let image = loadImage(from: iconURL) { + let frame = thumbnailFrame(for: image.size) + + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: frame.width, height: frame.height) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + } else { + RoundedRectangle(cornerRadius: 28) + .fill(.white.opacity(0.14)) + .frame(width: 220, height: 160) + .overlay( + Image(systemName: fallbackIconName) + .font(.system(size: 52)) + .foregroundStyle(.white.opacity(0.75)) + ) + } + } + .padding(10) + .background(.ultraThinMaterial.opacity(0.55), in: RoundedRectangle(cornerRadius: 30, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .strokeBorder(.white.opacity(0.14)) + } + .clipShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 22, y: 10) + } + + private func thumbnailFrame(for imageSize: CGSize) -> CGSize { + let aspectRatio = max(0.4, min(3.0, imageSize.width / max(1, imageSize.height))) + let maxHeight = min(thumbnailMaxHeight, max(thumbnailMinHeight, availableHeight)) + let widthFromHeight = maxHeight * aspectRatio + + if widthFromHeight <= thumbnailMaxWidth { + return CGSize(width: widthFromHeight, height: maxHeight) + } + + return CGSize(width: thumbnailMaxWidth, height: thumbnailMaxWidth / aspectRatio) + } + + private var fallbackIconName: String { + switch 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" + } + } +} + +struct LaunchRestoreOverlayView: View { + var body: some View { + ZStack { + Rectangle() + .fill(.regularMaterial) + .ignoresSafeArea() + + VStack(spacing: 14) { + ProgressView() + .controlSize(.large) + + Text("Opening Library…") + .font(.title3.weight(.semibold)) + } + .padding(.horizontal, 32) + .padding(.vertical, 28) + .background( + RoundedRectangle(cornerRadius: 18) + .fill(.background.opacity(0.92)) + ) + } + } +} + +struct PackReferenceIconView: View { + let iconURL: URL? + let fallbackSystemImage: String + + var body: some View { + if let image = loadImage(from: iconURL) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 34, height: 34) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(.quaternary) + .frame(width: 34, height: 34) + .overlay( + Image(systemName: fallbackSystemImage) + .font(.caption) + .foregroundStyle(.secondary) + ) + } + } +} + +struct EmptySourcesView: View { + let isDropTargeted: Bool + let chooseFolder: () -> Void + + var body: some View { + VStack(spacing: 24) { + ZStack { + RoundedRectangle(cornerRadius: 24) + .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10])) + .foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary.opacity(0.25)) + .frame(width: 220, height: 160) + + Image(systemName: "folder.badge.plus") + .font(.system(size: 56, weight: .regular)) + .foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary) + } + + VStack(spacing: 8) { + Text("Add a Minecraft Source") + .font(.title2) + + Text("Choose a copied Minecraft folder or drop one here to start scanning worlds, packs, and templates.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + } + + Button("Choose Minecraft Folder...") { + chooseFolder() + } + .controlSize(.large) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(40) + } +} + +struct ItemThumbnailView: View { + let iconURL: URL? + let fallbackSystemImage: String = "shippingbox" + + var body: some View { + if let image = loadImage(from: iconURL) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 7)) + } else { + RoundedRectangle(cornerRadius: 7) + .fill(.quaternary) + .frame(width: 40, height: 40) + .overlay( + Image(systemName: fallbackSystemImage) + .foregroundStyle(.secondary) + ) + } + } +} + +struct FlexibleTagLayout: View { + let spacing: CGFloat + let rowSpacing: CGFloat + let items: [Item] + @ViewBuilder let content: (Item) -> Content + + var body: some View { + HStack(spacing: 0) { + GeometryReader { geometry in + generateContent(in: geometry) + } + } + .frame(minHeight: 1) + } + + private func generateContent(in geometry: GeometryProxy) -> some View { + var width = CGFloat.zero + var height = CGFloat.zero + + return ZStack(alignment: .topLeading) { + ForEach(items, id: \.self) { item in + content(item) + .alignmentGuide(.leading) { dimensions in + if abs(width - dimensions.width) > geometry.size.width { + width = 0 + height -= dimensions.height + rowSpacing + } + + let result = width + if item == items.last { + width = 0 + } else { + width -= dimensions.width + spacing + } + return result + } + .alignmentGuide(.top) { _ in + let result = height + if item == items.last { + height = 0 + } + return result + } + } + } + } +} + +struct StorageBreakdown { + let primaryDataSize: Int64? + let resourceSize: Int64? + let metadataSize: Int64? + + static let loading = StorageBreakdown(primaryDataSize: nil, resourceSize: nil, metadataSize: nil) + + nonisolated static func build(for item: MinecraftContentItem) -> StorageBreakdown { + let fileManager = FileManager.default + + switch item.contentType { + case .world: + let dbURL = item.folderURL.appendingPathComponent("db", isDirectory: true) + let behaviorURL = item.folderURL.appendingPathComponent("behavior_packs", isDirectory: true) + let resourceURL = item.folderURL.appendingPathComponent("resource_packs", isDirectory: true) + + let primarySize = folderSize(at: dbURL, fileManager: fileManager) + let resourcesSize = (folderSize(at: behaviorURL, fileManager: fileManager) ?? 0) + + (folderSize(at: resourceURL, fileManager: fileManager) ?? 0) + let totalSize = folderSize(at: item.folderURL, fileManager: fileManager) + let metadata = totalSize.map { total in + max(0, total - (primarySize ?? 0) - resourcesSize) + } + + return StorageBreakdown( + primaryDataSize: primarySize, + resourceSize: resourcesSize == 0 ? nil : resourcesSize, + metadataSize: metadata + ) + case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: + let totalSize = folderSize(at: item.folderURL, fileManager: fileManager) + return StorageBreakdown( + primaryDataSize: totalSize, + resourceSize: nil, + metadataSize: nil + ) + } + } + + var primaryDataText: String { + sizeText(for: primaryDataSize) + } + + var resourcesText: String { + sizeText(for: resourceSize) + } + + var metadataText: String { + sizeText(for: metadataSize) + } + + private func sizeText(for size: Int64?) -> String { + guard let size else { + return "Unavailable" + } + + return ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } + + private nonisolated static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? { + guard fileManager.fileExists(atPath: folderURL.path) else { + return nil + } + + guard let enumerator = fileManager.enumerator( + at: folderURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + var totalSize: Int64 = 0 + + for case let fileURL as URL in enumerator { + guard + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, + let fileSize = values.fileSize + else { + continue + } + + totalSize += Int64(fileSize) + } + + return totalSize + } +} + +func loadImage(from url: URL?) -> NSImage? { + guard let url else { + return nil + } + + return NSImage(contentsOf: url) +} + +func copyToPasteboard(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) +} + +extension Color { + static let appAccent = Color("AccentColor") +} diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 12311e1..bf1bea8 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -57,6 +57,7 @@ struct ContentView: View { } detail: { ItemDetailColumnView( item: currentSelectedItem, + source: currentSource, behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [], resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [], worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [], @@ -650,837 +651,6 @@ struct ContentView: View { } } -private enum SidebarSelection: Hashable { - case allContent(sourceID: URL) - case contentType(sourceID: URL, contentType: MinecraftContentType) - - var sourceID: URL { - switch self { - case .allContent(let sourceID), .contentType(let sourceID, _): - return sourceID - } - } -} - -private enum ItemSortMode: String, CaseIterable, Identifiable { - case name - case modifiedDate - case size - - var id: String { rawValue } - - var title: String { - switch self { - case .name: - return "Name" - case .modifiedDate: - return "Modified Date" - case .size: - return "Size" - } - } -} - -private struct SidebarFilter: Identifiable, Hashable { - var id: SidebarSelection { selection } - let title: String - let iconName: String - let count: Int - let selection: SidebarSelection -} - -private struct SourcesSidebarView: View { - let sources: [MinecraftSource] - @Binding var selection: SidebarSelection? - let footerState: SidebarFooterState - let addSourceAction: () -> Void - let rescanSourceAction: (MinecraftSource) -> Void - let removeSourceAction: (MinecraftSource) -> Void - let revealFooterURLAction: (URL) -> Void - let filters: (MinecraftSource) -> [SidebarFilter] - - var body: some View { - List(selection: $selection) { - Section { - ForEach(sources) { source in - SourceHeaderRow(title: source.displayName) - .listRowSeparator(.hidden) - .padding(.top, 6) - .contextMenu { - Button("Rescan \"\(source.displayName)\"") { - rescanSourceAction(source) - } - - Divider() - - Button("Remove \"\(source.displayName)\"", role: .destructive) { - removeSourceAction(source) - } - } - - ForEach(filters(source)) { filter in - SidebarFilterRow(filter: filter, isIndented: true) - .tag(filter.selection as SidebarSelection?) - } - } - } header: { - SidebarSourcesSectionHeaderView() - } - } - .listStyle(.sidebar) - .overlay(alignment: .bottom) { - if footerState.style != .idle { - SidebarFooterView( - state: footerState, - revealAction: revealFooterURLAction - ) - .padding(.horizontal, 10) - .padding(.bottom, 10) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - .toolbar { - ToolbarItem { - Button(action: addSourceAction) { - Image(systemName: "folder.badge.plus") - } - .help("Add Source Folder") - } - } - .animation(.easeInOut(duration: 0.2), value: footerState.style) - } -} - -private struct SidebarFilterRow: View { - let filter: SidebarFilter - let isIndented: Bool - - var body: some View { - HStack(spacing: 10) { - Image(systemName: filter.iconName) - .frame(width: 16) - .foregroundStyle(.secondary) - - Text(filter.title) - - Spacer() - - Text(filter.count, format: .number) - .foregroundStyle(.secondary) - } - .padding(.leading, isIndented ? 16 : 0) - } -} - -private struct SidebarSourcesSectionHeaderView: View { - var body: some View { - Text("Libraries") - .font(.headline) - .foregroundStyle(.secondary) - .textCase(nil) - } -} - -private struct SourceHeaderRow: View { - let title: String - - var body: some View { - Text(title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - } -} - -private struct SidebarFooterView: View { - let state: SidebarFooterState - let revealAction: (URL) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - if state.style == .inProgress { - ProgressView() - .controlSize(.small) - } - - Text(state.title) - .font(.footnote.weight(.semibold)) - .foregroundStyle(primaryColor) - .lineLimit(2) - } - - if let subtitle = state.subtitle { - Text(subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(3) - } - - if let revealURL = state.revealURL { - Button("Reveal in Finder") { - revealAction(revealURL) - } - .buttonStyle(.link) - .font(.footnote) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - } - - private var primaryColor: Color { - switch state.style { - case .idle, .inProgress: - return .primary - case .failure: - return .red - case .success: - return .appAccent - } - } -} - -private struct ItemListColumnView: View { - let isEmpty: Bool - @Binding var isDropTargeted: Bool - @Binding var selectedItemID: MinecraftContentItem.ID? - @Binding var searchText: String - @Binding var sortMode: ItemSortMode - let title: String - let subtitle: String - let items: [MinecraftContentItem] - let searchPrompt: String - let chooseFolderAction: () -> Void - let dropAction: ([NSItemProvider]) -> Bool - let refreshAction: () -> Void - let itemContextMenu: (MinecraftContentItem) -> MenuContent - - var body: some View { - Group { - if isEmpty { - EmptySourcesView( - isDropTargeted: isDropTargeted, - chooseFolder: chooseFolderAction - ) - .onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: dropAction) - } else { - List(items, selection: $selectedItemID) { item in - ContentRowView(item: item) - .tag(item.id) - .contextMenu { - itemContextMenu(item) - } - } - .listStyle(.inset) - } - } - .searchable(text: $searchText, prompt: searchPrompt) - .navigationTitle(isEmpty ? "Library" : title) - .navigationSubtitle(isEmpty ? "" : subtitle) - .toolbar { - if !isEmpty { - ToolbarItemGroup { - Button(action: refreshAction) { - Image(systemName: "arrow.clockwise") - } - .help("Rescan Source") - - Menu { - Picker("Sort By", selection: $sortMode) { - ForEach(ItemSortMode.allCases) { mode in - Text(mode.title).tag(mode) - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - .help("List Options") - } - } - } - } -} - -private struct ItemDetailColumnView: View { - let item: MinecraftContentItem? - let behaviorPacks: [ContentPackReference] - let resourcePacks: [ContentPackReference] - let worldsUsingPack: [MinecraftContentItem] - let backingPackInstances: [MinecraftContentItem] - let isSuspiciousPack: Bool - let contents: [DirectoryPreviewEntry] - let directoryPreviewLimit: Int - let isEmpty: Bool - let isPerformingItemAction: Bool - let exportTitle: String? - let exportAction: () -> Void - let revealAction: () -> Void - let shareAction: (NSView?) -> Void - - 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, - behaviorPacks: behaviorPacks, - resourcePacks: resourcePacks, - worldsUsingPack: worldsUsingPack, - backingPackInstances: backingPackInstances, - isSuspiciousPack: isSuspiciousPack, - contents: contents, - directoryPreviewLimit: directoryPreviewLimit - ) - } else { - Text("Select a world or pack to see details") - .foregroundStyle(.secondary) - } - } - .toolbar { - if item != nil { - ToolbarItemGroup { - Button(action: exportAction) { - Image(systemName: "arrow.down.circle") - } - .disabled(isPerformingItemAction) - .help(exportTitle ?? "Export") - - Button(action: revealAction) { - Image(systemName: "folder") - } - .disabled(isPerformingItemAction) - .help("Reveal in Finder") - - SharingPickerButton( - title: nil, - systemImage: "square.and.arrow.up", - isEnabled: !isPerformingItemAction - ) { anchorView in - shareAction(anchorView) - } - .help("Share") - } - } - } - } -} - -private struct ContentRowView: View { - let item: MinecraftContentItem - - var body: some View { - HStack(alignment: .center, spacing: 10) { - ItemThumbnailView(iconURL: item.iconURL) - - VStack(alignment: .leading, spacing: 4) { - Text(item.displayName) - .lineLimit(1) - - Text(metadataLine) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - if !item.metadataLoaded || !item.sizeLoaded { - ProgressView() - .controlSize(.small) - } - } - .padding(.vertical, 2) - .contentShape(Rectangle()) - } - - private var metadataLine: String { - let sizeText: String - if let sizeBytes = item.sizeBytes { - sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) - } else if item.metadataLoaded { - sizeText = "Calculating size..." - } else { - sizeText = "Loading metadata..." - } - let dateText = item.displayDate.map { - $0.formatted(date: .abbreviated, time: .omitted) - } ?? "Date unavailable" - - return "\(item.contentType.rawValue) • \(sizeText) • \(item.displayDateLabel) \(dateText)" - } -} - -private struct ItemDetailView: View { - let item: MinecraftContentItem - let behaviorPacks: [ContentPackReference] - let resourcePacks: [ContentPackReference] - let worldsUsingPack: [MinecraftContentItem] - let backingPackInstances: [MinecraftContentItem] - let isSuspiciousPack: Bool - let contents: [DirectoryPreviewEntry] - let directoryPreviewLimit: Int - @State private var isTechnicalDetailsExpanded = false - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .leading, spacing: 18) { - LargeItemThumbnailView(iconURL: item.iconURL, contentType: item.contentType) - .frame(maxWidth: .infinity) - - VStack(alignment: .leading, spacing: 6) { - Text(item.displayName) - .font(.largeTitle.weight(.semibold)) - - Text(item.contentType.rawValue) - .font(.title3) - .foregroundStyle(.secondary) - } - } - - detailCard { - VStack(alignment: .leading, spacing: 14) { - Text("Details") - .font(.headline) - - if isSuspiciousPack { - Label("Manifest UUID is missing or unreadable for this pack.", systemImage: "exclamationmark.triangle") - .font(.subheadline) - .foregroundStyle(.orange) - } - - detailValueRow(title: "Size", value: sizeText) - detailValueRow(title: item.displayDateLabel, value: displayDateText) - - if item.contentType == .world { - detailValueRow( - title: "Last Played", - value: item.lastPlayedDate?.formatted(date: .abbreviated, time: .omitted) ?? "Not available" - ) - } - } - } - - if item.contentType == .world, !behaviorPacks.isEmpty || !resourcePacks.isEmpty { - detailCard { - VStack(alignment: .leading, spacing: 14) { - Text("Packs Used") - .font(.headline) - - 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 { - detailCard { - VStack(alignment: .leading, spacing: 14) { - Text("Used By Worlds") - .font(.headline) - - ForEach(worldsUsingPack) { world in - HStack(alignment: .top, spacing: 12) { - PackReferenceIconView(iconURL: world.iconURL) - - VStack(alignment: .leading, spacing: 2) { - Text(world.displayName) - - Text(worldUsageSecondaryText(for: world)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - } - - if (item.contentType == .behaviorPack || item.contentType == .resourcePack), !backingPackInstances.isEmpty { - detailCard { - VStack(alignment: .leading, spacing: 14) { - Text("Pack Instances") - .font(.headline) - - ForEach(backingPackInstances) { instance in - HStack(alignment: .top, spacing: 12) { - PackReferenceIconView(iconURL: instance.iconURL) - - VStack(alignment: .leading, spacing: 2) { - Text(instance.folderName) - - Text(packInstanceSecondaryText(for: instance)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - } - - detailCard { - DisclosureGroup(isExpanded: $isTechnicalDetailsExpanded) { - VStack(alignment: .leading, spacing: 18) { - detailRow(title: "Folder ID", value: item.folderID) - detailRow(title: "Folder Path", value: item.folderURL.path) - detailRow(title: "Collection Root", value: item.collectionRootURL.path) - - VStack(alignment: .leading, spacing: 8) { - Text("Contents") - .font(.caption) - .foregroundStyle(.secondary) - - if contents.isEmpty { - Text("No visible files or folders") - .foregroundStyle(.secondary) - } else { - ForEach(contents) { entry in - HStack(spacing: 10) { - Image(systemName: entry.isDirectory ? "folder" : "doc") - .foregroundStyle(.secondary) - Text(entry.name) - .lineLimit(1) - Spacer() - } - } - - if contents.count == directoryPreviewLimit { - Text("Showing the first \(directoryPreviewLimit) items") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .padding(.top, 8) - } label: { - HStack { - Text("Technical Details") - Spacer() - } - .contentShape(Rectangle()) - .onTapGesture { - isTechnicalDetailsExpanded.toggle() - } - } - } - } - .padding(28) - .frame(maxWidth: 450, alignment: .leading) - } - } - - @ViewBuilder - private func detailCard(@ViewBuilder content: () -> Content) -> some View { - content() - .frame(maxWidth: .infinity, alignment: .leading) - .padding(18) - .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 20, style: .continuous)) - } - - @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 - HStack(alignment: .top, spacing: 12) { - PackReferenceIconView(iconURL: pack.iconURL) - - VStack(alignment: .leading, spacing: 2) { - Text(pack.name) - - if let secondary = packSecondaryText(pack), !secondary.isEmpty { - Text(secondary) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - } - } - - @ViewBuilder - private func detailRow(title: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - - Text(value) - .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) - .multilineTextAlignment(.trailing) - } - } - - private var sizeText: String { - if let sizeBytes = item.sizeBytes { - return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) - } - - 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 struct DirectoryPreviewEntry: Identifiable { - let id = UUID() - let name: String - let isDirectory: Bool -} - -private struct SharingPickerButton: NSViewRepresentable { - let title: String? - let systemImage: String - let isEnabled: Bool - let action: (NSView) -> Void - - func makeCoordinator() -> Coordinator { - Coordinator(action: action) - } - - func makeNSView(context: Context) -> NSButton { - let button = NSButton() - button.target = context.coordinator - button.action = #selector(Coordinator.didPressButton(_:)) - button.bezelStyle = .texturedRounded - update(button) - return button - } - - func updateNSView(_ nsView: NSButton, context: Context) { - context.coordinator.action = action - update(nsView) - } - - private func update(_ button: NSButton) { - button.image = NSImage( - systemSymbolName: systemImage, - accessibilityDescription: title ?? "Share" - ) - button.imagePosition = title == nil ? .imageOnly : .imageLeading - button.title = title ?? "" - button.isEnabled = isEnabled - } - - final class Coordinator: NSObject { - var action: (NSView) -> Void - - init(action: @escaping (NSView) -> Void) { - self.action = action - } - - @objc func didPressButton(_ sender: NSButton) { - action(sender) - } - } -} - -private struct LaunchRestoreOverlayView: View { - var body: some View { - ZStack { - Rectangle() - .fill(.regularMaterial) - .ignoresSafeArea() - - VStack(spacing: 14) { - ProgressView() - .controlSize(.large) - - Text("Restoring Saved Library") - .font(.title3.weight(.semibold)) - - Text("Loading saved sources, metadata, and cached artwork.") - .foregroundStyle(.secondary) - } - .padding(.horizontal, 32) - .padding(.vertical, 28) - .background( - RoundedRectangle(cornerRadius: 18) - .fill(.background.opacity(0.92)) - ) - } - } -} - -private struct PackReferenceIconView: View { - let iconURL: URL? - - var body: some View { - if let image = loadImage(from: iconURL) { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 34, height: 34) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { - RoundedRectangle(cornerRadius: 8) - .fill(.quaternary) - .frame(width: 34, height: 34) - .overlay( - Image(systemName: "shippingbox") - .font(.caption) - .foregroundStyle(.secondary) - ) - } - } -} - -private struct EmptySourcesView: View { - let isDropTargeted: Bool - let chooseFolder: () -> Void - - var body: some View { - VStack(spacing: 24) { - ZStack { - RoundedRectangle(cornerRadius: 24) - .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10])) - .foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary.opacity(0.25)) - .frame(width: 220, height: 160) - - Image(systemName: "folder.badge.plus") - .font(.system(size: 56, weight: .regular)) - .foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary) - } - - VStack(spacing: 8) { - Text("Add a Minecraft Source") - .font(.title2) - - Text("Choose a copied Minecraft folder or drop one here to start scanning worlds, packs, and templates.") - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 420) - } - - Button("Choose Minecraft Folder...") { - chooseFolder() - } - .controlSize(.large) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(40) - } -} - -private struct ItemThumbnailView: View { - let iconURL: URL? - - var body: some View { - if let image = loadImage(from: iconURL) { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 40, height: 40) - .clipShape(RoundedRectangle(cornerRadius: 7)) - } else { - RoundedRectangle(cornerRadius: 7) - .fill(.quaternary) - .frame(width: 40, height: 40) - .overlay( - Image(systemName: "shippingbox") - .foregroundStyle(.secondary) - ) - } - } -} - -private struct LargeItemThumbnailView: View { - let iconURL: URL? - let contentType: MinecraftContentType - - var body: some View { - if let image = loadImage(from: iconURL) { - Image(nsImage: image) - .resizable() - .aspectRatio(image.size, contentMode: .fit) - .frame(maxWidth: 420, maxHeight: 340) - .clipShape(RoundedRectangle(cornerRadius: 28)) - } else { - RoundedRectangle(cornerRadius: 28) - .fill(.quaternary) - .frame(maxWidth: 420, minHeight: 260, maxHeight: 340) - .overlay( - Image(systemName: fallbackIconName) - .font(.system(size: 56)) - .foregroundStyle(.secondary) - ) - } - } - - private var fallbackIconName: String { - switch 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 func loadImage(from url: URL?) -> NSImage? { - guard let url else { - return nil - } - - return NSImage(contentsOf: url) -} - -private extension Color { - static let appAccent = Color("AccentColor") -} - struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() diff --git a/World Manager for Minecraft/ItemDetailColumnViews.swift b/World Manager for Minecraft/ItemDetailColumnViews.swift new file mode 100644 index 0000000..84a62a2 --- /dev/null +++ b/World Manager for Minecraft/ItemDetailColumnViews.swift @@ -0,0 +1,641 @@ +import AppKit +import SwiftUI + +struct DirectoryPreviewEntry: Identifiable { + let id = UUID() + let name: String + let isDirectory: Bool +} + +struct ItemDetailColumnView: View { + 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 isEmpty: Bool + let isPerformingItemAction: Bool + let exportTitle: String? + let exportAction: () -> Void + let revealAction: () -> Void + let shareAction: (NSView?) -> Void + + 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, + source: source, + behaviorPacks: behaviorPacks, + resourcePacks: resourcePacks, + worldsUsingPack: worldsUsingPack, + backingPackInstances: backingPackInstances, + isSuspiciousPack: isSuspiciousPack, + contents: contents, + directoryPreviewLimit: directoryPreviewLimit, + isPerformingItemAction: isPerformingItemAction, + exportTitle: exportTitle, + exportAction: exportAction, + revealAction: revealAction, + shareAction: shareAction + ) + } else { + Text("Select a world or pack to see details") + .foregroundStyle(.secondary) + } + } + .toolbar { + if item != nil { + ToolbarItemGroup { + Button(action: exportAction) { + Image(systemName: "arrow.down.circle") + } + .disabled(isPerformingItemAction) + .help(exportTitle ?? "Export") + + Button(action: revealAction) { + Image(systemName: "folder") + } + .disabled(isPerformingItemAction) + .help("Reveal in Finder") + + SharingPickerButton( + title: nil, + systemImage: "square.and.arrow.up", + isEnabled: !isPerformingItemAction + ) { anchorView in + shareAction(anchorView) + } + .help("Share") + } + } + } + } +} + +struct ItemDetailView: View { + private let contentMaxWidth: CGFloat = 760 + + 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 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 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) + } + } + } + .frame(maxWidth: contentMaxWidth, 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: contentMaxWidth + ) + } + + 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) { + detailValueRow(title: "Name", value: item.displayName) + detailValueRow(title: "Size", value: sizeText) + detailValueRow(title: item.displayDateLabel, value: displayDateText) + detailValueRow(title: "Created", value: createdDateText) + + 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") + } + } + } + + 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) + .background(.quaternary.opacity(0.32), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + } + + @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) + .lineLimit(3) + .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 var sizeText: String { + if let sizeBytes = item.sizeBytes { + return ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) + } + + 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, + prominence: .primary, + action: exportAction + ) + + ActionPillButton( + title: "Reveal", + systemImage: "folder.fill", + isDisabled: isPerformingItemAction, + prominence: .secondary, + action: revealAction + ) + + SharingPillButton( + title: "Share", + systemImage: "square.and.arrow.up", + isEnabled: !isPerformingItemAction, + 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 + } +} + +struct ItemDetailColumnViews_Previews: PreviewProvider { + static var previews: some View { + ItemDetailColumnPreviewContainer() + } +} diff --git a/World Manager for Minecraft/ItemListColumnViews.swift b/World Manager for Minecraft/ItemListColumnViews.swift new file mode 100644 index 0000000..aea289c --- /dev/null +++ b/World Manager for Minecraft/ItemListColumnViews.swift @@ -0,0 +1,133 @@ +import SwiftUI +import UniformTypeIdentifiers + +enum ItemSortMode: String, CaseIterable, Identifiable { + case name + case modifiedDate + case size + + var id: String { rawValue } + + var title: String { + switch self { + case .name: + return "Name" + case .modifiedDate: + return "Modified Date" + case .size: + return "Size" + } + } +} + +struct ItemListColumnView: View { + let isEmpty: Bool + @Binding var isDropTargeted: Bool + @Binding var selectedItemID: MinecraftContentItem.ID? + @Binding var searchText: String + @Binding var sortMode: ItemSortMode + let title: String + let subtitle: String + let items: [MinecraftContentItem] + let searchPrompt: String + let chooseFolderAction: () -> Void + let dropAction: ([NSItemProvider]) -> Bool + let refreshAction: () -> Void + let itemContextMenu: (MinecraftContentItem) -> MenuContent + + var body: some View { + Group { + if isEmpty { + EmptySourcesView( + isDropTargeted: isDropTargeted, + chooseFolder: chooseFolderAction + ) + .onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: dropAction) + } else { + List(items, selection: $selectedItemID) { item in + ContentRowView(item: item) + .tag(item.id) + .contextMenu { + itemContextMenu(item) + } + } + .listStyle(.inset) + } + } + .searchable(text: $searchText, prompt: searchPrompt) + .navigationTitle(isEmpty ? "Library" : title) + .navigationSubtitle(isEmpty ? "" : subtitle) + .toolbar { + if !isEmpty { + ToolbarItemGroup { + Button(action: refreshAction) { + Image(systemName: "arrow.clockwise") + } + .help("Rescan Source") + + Menu { + Picker("Sort By", selection: $sortMode) { + ForEach(ItemSortMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .help("List Options") + } + } + } + } +} + +private struct ContentRowView: View { + let item: MinecraftContentItem + + var body: some View { + HStack(alignment: .center, spacing: 10) { + ItemThumbnailView(iconURL: item.iconURL) + + VStack(alignment: .leading, spacing: 4) { + Text(item.displayName) + .lineLimit(1) + + Text(metadataLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if !item.metadataLoaded || !item.sizeLoaded { + ProgressView() + .controlSize(.small) + } + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + + private var metadataLine: String { + let sizeText: String + if let sizeBytes = item.sizeBytes { + sizeText = ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) + } else if item.metadataLoaded { + sizeText = "Calculating size..." + } else { + sizeText = "Loading metadata..." + } + let dateText = item.displayDate.map { + $0.formatted(date: .abbreviated, time: .omitted) + } ?? "Date unavailable" + + return "\(item.contentType.rawValue) • \(sizeText) • \(item.displayDateLabel) \(dateText)" + } +} + +struct ItemListColumnViews_Previews: PreviewProvider { + static var previews: some View { + ItemListColumnPreviewContainer() + } +} diff --git a/World Manager for Minecraft/PreviewFixtures.swift b/World Manager for Minecraft/PreviewFixtures.swift new file mode 100644 index 0000000..f593ef6 --- /dev/null +++ b/World Manager for Minecraft/PreviewFixtures.swift @@ -0,0 +1,356 @@ +import Foundation +import SwiftUI + +enum PreviewFixtures { + static let baseDate = Date(timeIntervalSinceReferenceDate: 770_000_000) + + static let sourceOneURL = URL(fileURLWithPath: "/tmp/preview-library-1") + static let sourceTwoURL = URL(fileURLWithPath: "/tmp/preview-library-2") + + static let worldCollectionURL = sourceOneURL.appendingPathComponent(MinecraftContentType.world.collectionFolderName) + static let behaviorCollectionURL = sourceOneURL.appendingPathComponent(MinecraftContentType.behaviorPack.collectionFolderName) + static let resourceCollectionURL = sourceOneURL.appendingPathComponent(MinecraftContentType.resourcePack.collectionFolderName) + + static let behaviorPackIdentity = PackIdentity( + type: .behaviorPack, + uuid: "874117c4-3f9c-432e-bd64-da7336313337", + version: "3.0.0", + fallbackName: "Wither Storm Behavior", + fallbackLocationHint: behaviorCollectionURL.lastPathComponent + ) + + static let resourcePackIdentity = PackIdentity( + type: .resourcePack, + uuid: "823cbe13-eac7-49a4-b9cd-1dd25ab1048a", + version: "3.0.0", + fallbackName: "Wither Storm Resources", + fallbackLocationHint: resourceCollectionURL.lastPathComponent + ) + + static let behaviorPackReference = ContentPackReference( + name: "§5Cracker's Wither Storm Mod", + type: .behaviorPack, + uuid: behaviorPackIdentity.uuid, + version: behaviorPackIdentity.version, + source: .referencedByWorld + ) + + static let resourcePackReference = ContentPackReference( + name: "§5Cracker's Wither Storm Mod", + type: .resourcePack, + uuid: resourcePackIdentity.uuid, + version: resourcePackIdentity.version, + source: .referencedByWorld + ) + + static let featuredWorld = MinecraftContentItem( + folderURL: worldCollectionURL.appendingPathComponent("world-alpha"), + folderName: "world-alpha", + contentType: .world, + collectionRootURL: worldCollectionURL, + displayName: "THE ABSOLUTELY ENORMOUS KID CHAOS LAB WITH 9999 TNT AND A SECRET LLAMA BUNKER v2.0", + lastPlayedDate: baseDate.addingTimeInterval(-86_400 * 3), + modifiedDate: baseDate.addingTimeInterval(-86_400 * 2), + sizeBytes: 35_500_000, + packReferences: [behaviorPackReference, resourcePackReference], + metadataLoaded: true, + sizeLoaded: true + ) + + static let siblingWorld = MinecraftContentItem( + folderURL: worldCollectionURL.appendingPathComponent("world-beta"), + folderName: "world-beta", + contentType: .world, + collectionRootURL: worldCollectionURL, + displayName: "Sky Battle Arena", + modifiedDate: baseDate.addingTimeInterval(-86_400 * 14), + sizeBytes: 14_200_000, + packReferences: [resourcePackReference], + metadataLoaded: true, + sizeLoaded: true + ) + + static let archiveWorld = MinecraftContentItem( + folderURL: worldCollectionURL.appendingPathComponent("world-gamma"), + folderName: "world-gamma", + contentType: .world, + collectionRootURL: worldCollectionURL, + displayName: "Grandma's Survival Backup But Everyone Has Netherite", + modifiedDate: baseDate.addingTimeInterval(-86_400 * 60), + sizeBytes: 227_500_000, + metadataLoaded: true, + sizeLoaded: true + ) + + static let behaviorPackItem = MinecraftContentItem( + folderURL: behaviorCollectionURL.appendingPathComponent("wither-storm-bp"), + folderName: "wither-storm-bp", + contentType: .behaviorPack, + collectionRootURL: behaviorCollectionURL, + displayName: "§5Cracker's Wither Storm Mod", + modifiedDate: baseDate.addingTimeInterval(-86_400 * 6), + sizeBytes: 5_300_000, + packUUID: behaviorPackIdentity.uuid, + packVersion: behaviorPackIdentity.version, + metadataLoaded: true, + sizeLoaded: true + ) + + static let resourcePackItem = MinecraftContentItem( + folderURL: resourceCollectionURL.appendingPathComponent("wither-storm-rp"), + folderName: "wither-storm-rp", + contentType: .resourcePack, + collectionRootURL: resourceCollectionURL, + displayName: "§5Cracker's Wither Storm Mod", + modifiedDate: baseDate.addingTimeInterval(-86_400 * 6), + sizeBytes: 8_900_000, + packUUID: resourcePackIdentity.uuid, + packVersion: resourcePackIdentity.version, + metadataLoaded: true, + sizeLoaded: true + ) + + static let secondLibraryPack = MinecraftContentItem( + folderURL: sourceTwoURL + .appendingPathComponent(MinecraftContentType.resourcePack.collectionFolderName) + .appendingPathComponent("cartoon-rp"), + folderName: "cartoon-rp", + contentType: .resourcePack, + collectionRootURL: sourceTwoURL.appendingPathComponent(MinecraftContentType.resourcePack.collectionFolderName), + displayName: "Cartoon Blocks Remix", + modifiedDate: baseDate.addingTimeInterval(-86_400 * 11), + sizeBytes: 12_700_000, + packUUID: "246df391-7dd1-4df6-b7aa-308a94a85d81", + packVersion: "1.4.2", + metadataLoaded: true, + sizeLoaded: true + ) + + static let primarySource: MinecraftSource = { + var source = MinecraftSource(folderURL: sourceOneURL) + source.displayName = "Kid iPad Imports" + source.displayItems = [ + featuredWorld, + siblingWorld, + archiveWorld, + behaviorPackItem, + resourcePackItem + ] + source.rawItems = source.displayItems + source.logicalPacks = [ + LogicalPack( + id: behaviorPackIdentity, + contentType: .behaviorPack, + displayName: behaviorPackItem.displayName, + uuid: behaviorPackItem.packUUID, + version: behaviorPackItem.packVersion, + representativeItemID: behaviorPackItem.id, + instanceItemIDs: [behaviorPackItem.id], + isSuspicious: false + ), + LogicalPack( + id: resourcePackIdentity, + contentType: .resourcePack, + displayName: resourcePackItem.displayName, + uuid: resourcePackItem.packUUID, + version: resourcePackItem.packVersion, + representativeItemID: resourcePackItem.id, + instanceItemIDs: [resourcePackItem.id], + isSuspicious: false + ) + ] + source.logicalWorlds = [ + LogicalWorld( + id: featuredWorld.id, + itemID: featuredWorld.id, + usedPackIDs: [behaviorPackIdentity, resourcePackIdentity], + unresolvedReferences: [] + ), + LogicalWorld( + id: siblingWorld.id, + itemID: siblingWorld.id, + usedPackIDs: [resourcePackIdentity], + unresolvedReferences: [] + ) + ] + source.packInstances = [ + PackInstance( + id: behaviorPackItem.id, + itemID: behaviorPackItem.id, + sourceID: source.id, + logicalPackID: behaviorPackIdentity, + origin: .foundInCollection, + hostWorldItemID: nil + ), + PackInstance( + id: resourcePackItem.id, + itemID: resourcePackItem.id, + sourceID: source.id, + logicalPackID: resourcePackIdentity, + origin: .foundInCollection, + hostWorldItemID: nil + ) + ] + source.worldPackRelationships = [ + WorldPackRelationship( + worldItemID: featuredWorld.id, + logicalPackID: behaviorPackIdentity, + reference: behaviorPackReference + ), + WorldPackRelationship( + worldItemID: featuredWorld.id, + logicalPackID: resourcePackIdentity, + reference: resourcePackReference + ), + WorldPackRelationship( + worldItemID: siblingWorld.id, + logicalPackID: resourcePackIdentity, + reference: resourcePackReference + ) + ] + source.indexedItemCount = source.displayItems.count + source.indexedDetailCount = source.displayItems.count + source.lastScanDate = baseDate + return source + }() + + static let secondarySource: MinecraftSource = { + var source = MinecraftSource(folderURL: sourceTwoURL) + source.displayName = "Downloads" + source.displayItems = [secondLibraryPack] + source.rawItems = source.displayItems + source.indexedItemCount = source.displayItems.count + source.indexedDetailCount = source.displayItems.count + source.lastScanDate = baseDate.addingTimeInterval(-3_600) + return source + }() + + static let allSources = [primarySource, secondarySource] + + static let sidebarFooter = SidebarFooterState( + style: .success, + title: "Export Complete", + subtitle: "Saved a preview copy of \(featuredWorld.displayName)", + revealURL: featuredWorld.folderURL + ) + + static let directoryEntries = [ + DirectoryPreviewEntry(name: "db", isDirectory: true), + DirectoryPreviewEntry(name: "level.dat", isDirectory: false), + DirectoryPreviewEntry(name: "levelname.txt", isDirectory: false), + DirectoryPreviewEntry(name: "world_icon.jpeg", isDirectory: false), + DirectoryPreviewEntry(name: "resource_packs", isDirectory: true) + ] + + nonisolated static func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] { + let allFilter = SidebarFilter( + title: "All Content", + iconName: "square.stack.3d.up", + count: source.items.count, + selection: .allContent(sourceID: source.id) + ) + + let groupedFilters = MinecraftContentType.allCases.compactMap { contentType -> SidebarFilter? in + let count = source.items.filter { $0.contentType == contentType }.count + guard count > 0 else { + return nil + } + + return SidebarFilter( + title: contentType.rawValue, + iconName: iconName(for: contentType), + count: count, + selection: .contentType(sourceID: source.id, contentType: contentType) + ) + } + + return [allFilter] + groupedFilters + } + + nonisolated static func iconName(for contentType: MinecraftContentType) -> String { + switch 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" + } + } +} + +struct SidebarColumnPreviewContainer: View { + @State private var selection: SidebarSelection? = .allContent(sourceID: PreviewFixtures.primarySource.id) + + var body: some View { + NavigationStack { + SourcesSidebarView( + sources: PreviewFixtures.allSources, + selection: $selection, + footerState: PreviewFixtures.sidebarFooter, + addSourceAction: {}, + rescanSourceAction: { _ in }, + removeSourceAction: { _ in }, + revealFooterURLAction: { _ in }, + filters: PreviewFixtures.sidebarFilters(for:) + ) + } + } +} + +struct ItemListColumnPreviewContainer: View { + @State private var isDropTargeted = false + @State private var selectedItemID: MinecraftContentItem.ID? = PreviewFixtures.featuredWorld.id + @State private var searchText = "" + @State private var sortMode: ItemSortMode = .modifiedDate + + var body: some View { + NavigationStack { + ItemListColumnView( + isEmpty: false, + isDropTargeted: $isDropTargeted, + selectedItemID: $selectedItemID, + searchText: $searchText, + sortMode: $sortMode, + title: "All Items", + subtitle: "5 items in Kid iPad Imports", + items: PreviewFixtures.primarySource.displayItems, + searchPrompt: "Search Worlds", + chooseFolderAction: {}, + dropAction: { _ in false }, + refreshAction: {}, + itemContextMenu: { item in + Button("Reveal \(item.displayName)") {} + } + ) + } + } +} + +struct ItemDetailColumnPreviewContainer: View { + var body: some View { + NavigationStack { + ItemDetailColumnView( + item: PreviewFixtures.featuredWorld, + source: PreviewFixtures.primarySource, + behaviorPacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .behaviorPack), + resourcePacks: PreviewFixtures.primarySource.resolvedPackReferences(for: PreviewFixtures.featuredWorld.id, type: .resourcePack), + worldsUsingPack: [], + backingPackInstances: [], + isSuspiciousPack: false, + contents: PreviewFixtures.directoryEntries, + directoryPreviewLimit: 12, + isEmpty: false, + isPerformingItemAction: false, + exportTitle: PreviewFixtures.featuredWorld.contentType.exportTitle, + exportAction: {}, + revealAction: {}, + shareAction: { _ in } + ) + } + } +} diff --git a/World Manager for Minecraft/SidebarColumnViews.swift b/World Manager for Minecraft/SidebarColumnViews.swift new file mode 100644 index 0000000..09fb1eb --- /dev/null +++ b/World Manager for Minecraft/SidebarColumnViews.swift @@ -0,0 +1,180 @@ +import SwiftUI + +enum SidebarSelection: Hashable { + case allContent(sourceID: URL) + case contentType(sourceID: URL, contentType: MinecraftContentType) + + var sourceID: URL { + switch self { + case .allContent(let sourceID), .contentType(let sourceID, _): + return sourceID + } + } +} + +struct SidebarFilter: Identifiable, Hashable { + var id: SidebarSelection { selection } + let title: String + let iconName: String + let count: Int + let selection: SidebarSelection +} + +struct SourcesSidebarView: View { + let sources: [MinecraftSource] + @Binding var selection: SidebarSelection? + let footerState: SidebarFooterState + let addSourceAction: () -> Void + let rescanSourceAction: (MinecraftSource) -> Void + let removeSourceAction: (MinecraftSource) -> Void + let revealFooterURLAction: (URL) -> Void + let filters: (MinecraftSource) -> [SidebarFilter] + + var body: some View { + List(selection: $selection) { + Section { + ForEach(sources) { source in + SourceHeaderRow(title: source.displayName) + .listRowSeparator(.hidden) + .padding(.top, 6) + .contextMenu { + Button("Rescan \"\(source.displayName)\"") { + rescanSourceAction(source) + } + + Divider() + + Button("Remove \"\(source.displayName)\"", role: .destructive) { + removeSourceAction(source) + } + } + + ForEach(filters(source)) { filter in + SidebarFilterRow(filter: filter, isIndented: true) + .tag(filter.selection as SidebarSelection?) + } + } + } header: { + SidebarSourcesSectionHeaderView() + } + } + .listStyle(.sidebar) + .overlay(alignment: .bottom) { + if footerState.style != .idle { + SidebarFooterView( + state: footerState, + revealAction: revealFooterURLAction + ) + .padding(.horizontal, 10) + .padding(.bottom, 10) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .toolbar { + ToolbarItem { + Button(action: addSourceAction) { + Image(systemName: "folder.badge.plus") + } + .help("Add Source Folder") + } + } + .animation(.easeInOut(duration: 0.2), value: footerState.style) + } +} + +private struct SidebarFilterRow: View { + let filter: SidebarFilter + let isIndented: Bool + + var body: some View { + HStack(spacing: 10) { + Image(systemName: filter.iconName) + .frame(width: 16) + .foregroundStyle(.secondary) + + Text(filter.title) + + Spacer() + + Text(filter.count, format: .number) + .foregroundStyle(.secondary) + } + .padding(.leading, isIndented ? 16 : 0) + } +} + +private struct SidebarSourcesSectionHeaderView: View { + var body: some View { + Text("Libraries") + .font(.headline) + .foregroundStyle(.secondary) + .textCase(nil) + } +} + +private struct SourceHeaderRow: View { + let title: String + + var body: some View { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } +} + +private struct SidebarFooterView: View { + let state: SidebarFooterState + let revealAction: (URL) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if state.style == .inProgress { + ProgressView() + .controlSize(.small) + } + + Text(state.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(primaryColor) + .lineLimit(2) + } + + if let subtitle = state.subtitle { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + if let revealURL = state.revealURL { + Button("Reveal in Finder") { + revealAction(revealURL) + } + .buttonStyle(.link) + .font(.footnote) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + private var primaryColor: Color { + switch state.style { + case .idle, .inProgress: + return .primary + case .failure: + return .red + case .success: + return .appAccent + } + } +} + +struct SidebarColumnViews_Previews: PreviewProvider { + static var previews: some View { + SidebarColumnPreviewContainer() + } +}