From 1c06e4f67be3f4e4f3d66f0a1f91e36d16c0ea0b Mon Sep 17 00:00:00 2001 From: John Burwell Date: Mon, 25 May 2026 14:31:24 -0500 Subject: [PATCH] Tidy up the sidebar and implement scanning --- World Manager for Minecraft/ContentView.swift | 330 ++++++++++++++++-- .../Models/MinecraftContentItem.swift | 75 ++++ .../Models/MinecraftWorld.swift | 19 - .../Services/WorldScanner.swift | 272 ++++++++++++--- 4 files changed, 581 insertions(+), 115 deletions(-) create mode 100644 World Manager for Minecraft/Models/MinecraftContentItem.swift delete mode 100644 World Manager for Minecraft/Models/MinecraftWorld.swift diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index e2c3f15..6285b52 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -11,12 +11,13 @@ import SwiftUI struct ContentView: View { @StateObject private var scanner = WorldScanner() @State private var folderURL: URL? - @State private var selectedWorld: MinecraftWorld? + @State private var selectedItem: MinecraftContentItem? + @State private var selectedSidebarSelection: SidebarSelection = .all var body: some View { NavigationSplitView { VStack(alignment: .leading, spacing: 12) { - Button("Choose Minecraft Worlds Folder...") { + Button("Choose Minecraft Folder...") { pickFolder() } @@ -31,61 +32,228 @@ struct ContentView: View { .foregroundStyle(.secondary) } - if scanner.isScanning { - ProgressView(scanner.scanStatus) - } else if !scanner.scanStatus.isEmpty { - Text(scanner.scanStatus) - .font(.footnote) - .foregroundStyle(.secondary) - } - if let scanError = scanner.scanError { Text(scanError) .font(.footnote) .foregroundStyle(.red) } - Spacer() + List(selection: $selectedSidebarSelection) { + if let folderURL { + Section(folderURL.lastPathComponent) { + ForEach(sidebarFilters) { filter in + SidebarFilterRow(filter: filter) + .tag(filter.selection) + } + } + } + } + .listStyle(.sidebar) } .padding() - .navigationTitle("Folder") + .navigationTitle("Source") } content: { - List(scanner.worlds, selection: $selectedWorld) { world in - VStack(alignment: .leading, spacing: 4) { - Text(world.displayName) - Text(world.folderName) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .navigationTitle("Worlds") - } detail: { - if let selectedWorld { - VStack(alignment: .leading, spacing: 12) { - Text(selectedWorld.displayName) - .font(.title2) + List(filteredItems, selection: $selectedItem) { item in + HStack(alignment: .top, spacing: 10) { + ItemThumbnailView(iconURL: item.iconURL) - Text(selectedWorld.folderName) - .font(.headline) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text(item.displayName) + .lineLimit(1) - if let modifiedDate = selectedWorld.modifiedDate { - Text("Modified: \(modifiedDate.formatted(date: .abbreviated, time: .shortened))") - } + Text(item.contentType.rawValue) + .font(.caption) + .foregroundStyle(.secondary) - if let sizeBytes = selectedWorld.sizeBytes { - Text("Size: \(ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file))") + Text(item.folderName) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) } Spacer() + + if !item.metadataLoaded { + ProgressView() + .controlSize(.small) + } + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + .tag(item) + } + .navigationTitle(contentListTitle) + } detail: { + if let selectedItem = currentSelectedItem { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 16) { + LargeItemThumbnailView(iconURL: selectedItem.iconURL) + + VStack(alignment: .leading, spacing: 8) { + Text(selectedItem.displayName) + .font(.title2) + + Text(selectedItem.contentType.rawValue) + .font(.headline) + .foregroundStyle(.secondary) + + Text(selectedItem.folderName) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + detailRow(title: "Folder Path", value: selectedItem.folderURL.path) + detailRow(title: "Collection Root", value: selectedItem.collectionRootURL.path) + + if let modifiedDate = selectedItem.modifiedDate { + detailRow( + title: "Modified", + value: modifiedDate.formatted(date: .abbreviated, time: .shortened) + ) + } + + if let sizeBytes = selectedItem.sizeBytes { + detailRow( + title: "Size", + value: ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file) + ) + } + + detailRow( + title: "Metadata", + value: selectedItem.metadataLoaded ? "Loaded" : "Loading..." + ) + } + .padding() } - .padding() } else { - Text("Select a world to see details") + Text("Select a world or pack to see details") .foregroundStyle(.secondary) } } .navigationTitle("Minecraft World Manager") + .toolbar { + ToolbarItem(placement: .primaryAction) { + if scanner.isScanning { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + + Text(scanner.scanStatus) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: 280, alignment: .trailing) + } + } + } + .onChange(of: filteredItems.map(\.id)) { _, filteredIDs in + guard let selectedItem, !filteredIDs.contains(selectedItem.id) else { + return + } + + self.selectedItem = nil + } + } + + private var filteredItems: [MinecraftContentItem] { + switch selectedSidebarSelection { + case .all: + return scanner.items + case .contentType(let contentType): + return scanner.items.filter { $0.contentType == contentType } + } + } + + private var currentSelectedItem: MinecraftContentItem? { + guard let selectedItem else { + return nil + } + + return scanner.items.first(where: { $0.id == selectedItem.id }) ?? selectedItem + } + + private var contentListTitle: String { + switch selectedSidebarSelection { + case .all: + return "Minecraft Content" + case .contentType(let contentType): + return contentType.rawValue + "s" + } + } + + private var sidebarFilters: [SidebarFilter] { + var filters = [ + SidebarFilter( + title: "All Content", + iconName: "square.grid.2x2", + count: scanner.items.count, + selection: .all + ) + ] + + filters.append( + contentsOf: MinecraftContentType.allCases.compactMap { contentType in + let count = scanner.items.filter { $0.contentType == contentType }.count + guard count > 0 else { + return nil + } + + return SidebarFilter( + title: sidebarTitle(for: contentType), + iconName: sidebarIcon(for: contentType), + count: count, + selection: .contentType(contentType) + ) + } + ) + + return filters + } + + private func sidebarTitle(for contentType: MinecraftContentType) -> String { + switch contentType { + case .world: + return "Worlds" + case .behaviorPack: + return "Behavior Packs" + case .resourcePack: + return "Resource Packs" + case .skinPack: + return "Skin Packs" + case .worldTemplate: + return "World Templates" + } + } + + private func sidebarIcon(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" + } + } + + @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) + } } private func pickFolder() { @@ -93,14 +261,15 @@ struct ContentView: View { panel.allowsMultipleSelection = false panel.canChooseDirectories = true panel.canChooseFiles = false - panel.title = "Choose Minecraft Worlds Folder" + panel.title = "Choose a Folder to Search" guard panel.runModal() == .OK, let pickedURL = panel.url else { return } folderURL = pickedURL - selectedWorld = nil + selectedItem = nil + selectedSidebarSelection = .all Task { await scanner.scan(at: pickedURL) @@ -108,6 +277,91 @@ struct ContentView: View { } } +private enum SidebarSelection: Hashable { + case all + case contentType(MinecraftContentType) +} + +private struct SidebarFilter: Identifiable, Hashable { + let id = UUID() + let title: String + let iconName: String + let count: Int + let selection: SidebarSelection +} + +private struct SidebarFilterRow: View { + let filter: SidebarFilter + + 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) + } + } +} + +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: 36, height: 36) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + RoundedRectangle(cornerRadius: 6) + .fill(.quaternary) + .frame(width: 36, height: 36) + .overlay( + Image(systemName: "shippingbox") + .foregroundStyle(.secondary) + ) + } + } +} + +private struct LargeItemThumbnailView: View { + let iconURL: URL? + + var body: some View { + if let image = loadImage(from: iconURL) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 128, height: 128) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + RoundedRectangle(cornerRadius: 12) + .fill(.quaternary) + .frame(width: 128, height: 128) + .overlay( + Image(systemName: "shippingbox") + .font(.largeTitle) + .foregroundStyle(.secondary) + ) + } + } +} + +private func loadImage(from url: URL?) -> NSImage? { + guard let url else { + return nil + } + + return NSImage(contentsOf: url) +} + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() diff --git a/World Manager for Minecraft/Models/MinecraftContentItem.swift b/World Manager for Minecraft/Models/MinecraftContentItem.swift new file mode 100644 index 0000000..03a67e7 --- /dev/null +++ b/World Manager for Minecraft/Models/MinecraftContentItem.swift @@ -0,0 +1,75 @@ +// +// MinecraftContentItem.swift +// World Manager for Minecraft +// +// Created by John Burwell on 2026-05-25. +// + +import Foundation + +enum MinecraftContentType: String, CaseIterable, Hashable, Sendable { + case world = "World" + case behaviorPack = "Behavior Pack" + case resourcePack = "Resource Pack" + case skinPack = "Skin Pack" + case worldTemplate = "World Template" + + nonisolated var collectionFolderName: String { + switch self { + case .world: + return "minecraftWorlds" + case .behaviorPack: + return "behavior_packs" + case .resourcePack: + return "resource_packs" + case .skinPack: + return "skin_packs" + case .worldTemplate: + return "world_templates" + } + } +} + +struct MinecraftContentItem: Identifiable, Hashable, Sendable { + let id: URL + let folderURL: URL + let folderName: String + let contentType: MinecraftContentType + let collectionRootURL: URL + var displayName: String + var iconURL: URL? + var modifiedDate: Date? + var sizeBytes: Int64? + var metadataLoaded: Bool + + nonisolated init( + folderURL: URL, + folderName: String, + contentType: MinecraftContentType, + collectionRootURL: URL, + displayName: String? = nil, + iconURL: URL? = nil, + modifiedDate: Date? = nil, + sizeBytes: Int64? = nil, + metadataLoaded: Bool = false + ) { + self.id = folderURL.standardizedFileURL + self.folderURL = folderURL + self.folderName = folderName + self.contentType = contentType + self.collectionRootURL = collectionRootURL + self.displayName = displayName ?? folderName + self.iconURL = iconURL + self.modifiedDate = modifiedDate + self.sizeBytes = sizeBytes + self.metadataLoaded = metadataLoaded + } + + nonisolated static func == (lhs: MinecraftContentItem, rhs: MinecraftContentItem) -> Bool { + lhs.id == rhs.id + } + + nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/World Manager for Minecraft/Models/MinecraftWorld.swift b/World Manager for Minecraft/Models/MinecraftWorld.swift deleted file mode 100644 index a40ec72..0000000 --- a/World Manager for Minecraft/Models/MinecraftWorld.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// MinecraftWorld.swift -// World Manager for Minecraft -// -// Created by John Burwell on 2026-05-25. -// - -import Foundation - -struct MinecraftWorld: Identifiable, Hashable { - let id = UUID() - let folderURL: URL - let folderName: String - let displayName: String - let iconURL: URL? - let modifiedDate: Date? - let sizeBytes: Int64? - let isValidWorld: Bool -} diff --git a/World Manager for Minecraft/Services/WorldScanner.swift b/World Manager for Minecraft/Services/WorldScanner.swift index c224d09..65708e4 100644 --- a/World Manager for Minecraft/Services/WorldScanner.swift +++ b/World Manager for Minecraft/Services/WorldScanner.swift @@ -10,99 +10,242 @@ import Foundation @MainActor final class WorldScanner: ObservableObject { - @Published var worlds: [MinecraftWorld] = [] + @Published var items: [MinecraftContentItem] = [] @Published var isScanning = false @Published var scanStatus = "" @Published var scanError: String? - func scan(at parentFolderURL: URL) async { + private var activeScanID = UUID() + + func scan(at searchRootURL: URL) async { + let scanID = UUID() + activeScanID = scanID isScanning = true scanError = nil - scanStatus = "Scanning worlds..." - worlds = [] + scanStatus = "Searching for Minecraft content..." + items = [] do { - let worlds = try await Task.detached(priority: .userInitiated) { - try Self.scanWorlds(at: parentFolderURL) + let discoveredItems = try await Task.detached(priority: .userInitiated) { + try Self.discoverItems(in: searchRootURL) }.value - self.worlds = worlds - self.scanStatus = worlds.isEmpty ? "No worlds found." : "Found \(worlds.count) worlds." + guard activeScanID == scanID else { + return + } + + items = discoveredItems + scanStatus = discoveredItems.isEmpty + ? "No Minecraft content found." + : "Found \(discoveredItems.count) items. Loading details..." + + var loadedCount = 0 + + await withTaskGroup(of: MinecraftContentItem.self) { group in + for item in discoveredItems { + group.addTask { + Self.enrich(item: item) + } + } + + for await enrichedItem in group { + await MainActor.run { + guard self.activeScanID == scanID else { + return + } + + self.replaceItem(with: enrichedItem) + loadedCount += 1 + + if loadedCount == discoveredItems.count { + self.scanStatus = "Loaded \(loadedCount) items." + self.isScanning = false + } else { + self.scanStatus = "Loaded details for \(loadedCount) of \(discoveredItems.count) items..." + } + } + } + } + + if discoveredItems.isEmpty { + isScanning = false + } } catch { + guard activeScanID == scanID else { + return + } + scanError = "Failed to scan folder: \(error.localizedDescription)" scanStatus = "" + isScanning = false } - - isScanning = false } - nonisolated private static func scanWorlds(at parentFolderURL: URL) throws -> [MinecraftWorld] { + private func replaceItem(with updatedItem: MinecraftContentItem) { + guard let index = items.firstIndex(where: { $0.id == updatedItem.id }) else { + return + } + + items[index] = updatedItem + items.sort(by: Self.sortItems) + } + + nonisolated private static func discoverItems(in searchRootURL: URL) throws -> [MinecraftContentItem] { let fileManager = FileManager.default - let resourceKeys: Set = [.isDirectoryKey, .contentModificationDateKey] + let resourceKeys: [URLResourceKey] = [.isDirectoryKey] + + guard let enumerator = fileManager.enumerator( + at: searchRootURL, + includingPropertiesForKeys: resourceKeys, + options: [.skipsHiddenFiles] + ) else { + return [] + } + + var discoveredItems: [MinecraftContentItem] = [] + var seenItemURLs = Set() + + for case let directoryURL as URL in enumerator { + guard (try? directoryURL.resourceValues(forKeys: Set(resourceKeys)).isDirectory) == true else { + continue + } + + guard let contentType = contentType(forCollectionFolderName: directoryURL.lastPathComponent) else { + continue + } + + let childDirectories = try immediateChildDirectories(of: directoryURL, fileManager: fileManager) + for childDirectory in childDirectories { + let itemURL = childDirectory.standardizedFileURL + guard !seenItemURLs.contains(itemURL) else { + continue + } + + if isCandidateItem(at: childDirectory, type: contentType, fileManager: fileManager) { + seenItemURLs.insert(itemURL) + discoveredItems.append( + MinecraftContentItem( + folderURL: childDirectory, + folderName: childDirectory.lastPathComponent, + contentType: contentType, + collectionRootURL: directoryURL + ) + ) + } + } + } + + discoveredItems.sort(by: sortItems) + return discoveredItems + } + + nonisolated private static func enrich(item: MinecraftContentItem) -> MinecraftContentItem { + let fileManager = FileManager.default + var enrichedItem = item + + enrichedItem.displayName = displayName(for: item, fileManager: fileManager) + enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager) + enrichedItem.modifiedDate = modifiedDate(for: item.folderURL) + enrichedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager) + enrichedItem.metadataLoaded = true + + return enrichedItem + } + + nonisolated private static func contentType(forCollectionFolderName folderName: String) -> MinecraftContentType? { + let normalizedFolderName = folderName.lowercased() + + return MinecraftContentType.allCases.first { type in + type.collectionFolderName.lowercased() == normalizedFolderName + } + } + + nonisolated private static func immediateChildDirectories(of directoryURL: URL, fileManager: FileManager) throws -> [URL] { let children = try fileManager.contentsOfDirectory( - at: parentFolderURL, - includingPropertiesForKeys: Array(resourceKeys), + at: directoryURL, + includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) - return try children.compactMap { childURL in - let values = try childURL.resourceValues(forKeys: resourceKeys) - guard values.isDirectory == true else { - return nil - } - - let isValidWorld = isLikelyWorldFolder(childURL, fileManager: fileManager) - guard isValidWorld else { - return nil - } - - let folderName = childURL.lastPathComponent - let displayName = readDisplayName(in: childURL, fallback: folderName) - let iconURL = iconURL(in: childURL, fileManager: fileManager) - let sizeBytes = folderSize(at: childURL, fileManager: fileManager) - - return MinecraftWorld( - folderURL: childURL, - folderName: folderName, - displayName: displayName, - iconURL: iconURL, - modifiedDate: values.contentModificationDate, - sizeBytes: sizeBytes, - isValidWorld: true - ) - } - .sorted { lhs, rhs in - lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + return children.filter { + (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } } - nonisolated private static func isLikelyWorldFolder(_ url: URL, fileManager: FileManager) -> Bool { - let levelDatURL = url.appendingPathComponent("level.dat") - let dbURL = url.appendingPathComponent("db", isDirectory: true) - let levelNameURL = url.appendingPathComponent("levelname.txt") - - return fileManager.fileExists(atPath: levelDatURL.path) - || fileManager.fileExists(atPath: dbURL.path) - || fileManager.fileExists(atPath: levelNameURL.path) + nonisolated private static func isCandidateItem(at directoryURL: URL, type: MinecraftContentType, fileManager: FileManager) -> Bool { + switch type { + case .world: + return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("level.dat").path) + || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("db", isDirectory: true).path) + || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("levelname.txt").path) + case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: + return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("manifest.json").path) + || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("pack_icon.png").path) + || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("pack_icon.jpeg").path) + || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("pack_icon.jpg").path) + } } - nonisolated private static func readDisplayName(in worldURL: URL, fallback: String) -> String { - let levelNameURL = worldURL.appendingPathComponent("levelname.txt") + nonisolated private static func displayName(for item: MinecraftContentItem, fileManager: FileManager) -> String { + switch item.contentType { + case .world: + let levelNameURL = item.folderURL.appendingPathComponent("levelname.txt") + guard + let name = try? String(contentsOf: levelNameURL, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + else { + return item.folderName + } + return name + case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: + if let manifestName = manifestName(in: item.folderURL, fileManager: fileManager) { + return manifestName + } + + return item.folderName + } + } + + nonisolated private static func manifestName(in directoryURL: URL, fileManager: FileManager) -> String? { + let manifestURL = directoryURL.appendingPathComponent("manifest.json") guard - let name = try? String(contentsOf: levelNameURL, encoding: .utf8) - .trimmingCharacters(in: .whitespacesAndNewlines), + fileManager.fileExists(atPath: manifestURL.path), + let data = try? Data(contentsOf: manifestURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let header = jsonObject["header"] as? [String: Any], + let name = (header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { - return fallback + return nil } return name } - nonisolated private static func iconURL(in worldURL: URL, fileManager: FileManager) -> URL? { - let iconURL = worldURL.appendingPathComponent("world_icon.jpeg") - return fileManager.fileExists(atPath: iconURL.path) ? iconURL : nil + nonisolated private static func iconURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL? { + let candidateNames: [String] + + switch item.contentType { + case .world: + candidateNames = ["world_icon.jpeg", "world_icon.jpg", "world_icon.png"] + case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: + candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"] + } + + for candidateName in candidateNames { + let candidateURL = item.folderURL.appendingPathComponent(candidateName) + if fileManager.fileExists(atPath: candidateURL.path) { + return candidateURL + } + } + + return nil + } + + nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? { + try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate } nonisolated private static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? { @@ -130,4 +273,17 @@ final class WorldScanner: ObservableObject { return totalSize } + + nonisolated private static func sortItems(_ lhs: MinecraftContentItem, _ rhs: MinecraftContentItem) -> Bool { + if lhs.contentType != rhs.contentType { + return lhs.contentType.rawValue.localizedStandardCompare(rhs.contentType.rawValue) == .orderedAscending + } + + let displayNameOrder = lhs.displayName.localizedStandardCompare(rhs.displayName) + if displayNameOrder != .orderedSame { + return displayNameOrder == .orderedAscending + } + + return lhs.folderName.localizedStandardCompare(rhs.folderName) == .orderedAscending + } }