Tidy up the sidebar and implement scanning

This commit is contained in:
John Burwell 2026-05-25 14:31:24 -05:00
parent 7d51075631
commit 1c06e4f67b
4 changed files with 581 additions and 115 deletions

View File

@ -11,12 +11,13 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@StateObject private var scanner = WorldScanner() @StateObject private var scanner = WorldScanner()
@State private var folderURL: URL? @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 { var body: some View {
NavigationSplitView { NavigationSplitView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Button("Choose Minecraft Worlds Folder...") { Button("Choose Minecraft Folder...") {
pickFolder() pickFolder()
} }
@ -31,61 +32,228 @@ struct ContentView: View {
.foregroundStyle(.secondary) .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 { if let scanError = scanner.scanError {
Text(scanError) Text(scanError)
.font(.footnote) .font(.footnote)
.foregroundStyle(.red) .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() .padding()
.navigationTitle("Folder") .navigationTitle("Source")
} content: { } content: {
List(scanner.worlds, selection: $selectedWorld) { world in List(filteredItems, selection: $selectedItem) { item in
VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top, spacing: 10) {
Text(world.displayName) ItemThumbnailView(iconURL: item.iconURL)
Text(world.folderName)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Worlds")
} detail: {
if let selectedWorld {
VStack(alignment: .leading, spacing: 12) {
Text(selectedWorld.displayName)
.font(.title2)
Text(selectedWorld.folderName) VStack(alignment: .leading, spacing: 4) {
.font(.headline) Text(item.displayName)
.foregroundStyle(.secondary) .lineLimit(1)
if let modifiedDate = selectedWorld.modifiedDate { Text(item.contentType.rawValue)
Text("Modified: \(modifiedDate.formatted(date: .abbreviated, time: .shortened))") .font(.caption)
} .foregroundStyle(.secondary)
if let sizeBytes = selectedWorld.sizeBytes { Text(item.folderName)
Text("Size: \(ByteCountFormatter.string(fromByteCount: sizeBytes, countStyle: .file))") .font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
} }
Spacer() 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 { } else {
Text("Select a world to see details") Text("Select a world or pack to see details")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.navigationTitle("Minecraft World Manager") .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() { private func pickFolder() {
@ -93,14 +261,15 @@ struct ContentView: View {
panel.allowsMultipleSelection = false panel.allowsMultipleSelection = false
panel.canChooseDirectories = true panel.canChooseDirectories = true
panel.canChooseFiles = false 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 { guard panel.runModal() == .OK, let pickedURL = panel.url else {
return return
} }
folderURL = pickedURL folderURL = pickedURL
selectedWorld = nil selectedItem = nil
selectedSidebarSelection = .all
Task { Task {
await scanner.scan(at: pickedURL) 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 { struct ContentView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ContentView() ContentView()

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -10,99 +10,242 @@ import Foundation
@MainActor @MainActor
final class WorldScanner: ObservableObject { final class WorldScanner: ObservableObject {
@Published var worlds: [MinecraftWorld] = [] @Published var items: [MinecraftContentItem] = []
@Published var isScanning = false @Published var isScanning = false
@Published var scanStatus = "" @Published var scanStatus = ""
@Published var scanError: String? @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 isScanning = true
scanError = nil scanError = nil
scanStatus = "Scanning worlds..." scanStatus = "Searching for Minecraft content..."
worlds = [] items = []
do { do {
let worlds = try await Task.detached(priority: .userInitiated) { let discoveredItems = try await Task.detached(priority: .userInitiated) {
try Self.scanWorlds(at: parentFolderURL) try Self.discoverItems(in: searchRootURL)
}.value }.value
self.worlds = worlds guard activeScanID == scanID else {
self.scanStatus = worlds.isEmpty ? "No worlds found." : "Found \(worlds.count) worlds." 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 { } catch {
guard activeScanID == scanID else {
return
}
scanError = "Failed to scan folder: \(error.localizedDescription)" scanError = "Failed to scan folder: \(error.localizedDescription)"
scanStatus = "" 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 fileManager = FileManager.default
let resourceKeys: Set<URLResourceKey> = [.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<URL>()
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( let children = try fileManager.contentsOfDirectory(
at: parentFolderURL, at: directoryURL,
includingPropertiesForKeys: Array(resourceKeys), includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles] options: [.skipsHiddenFiles]
) )
return try children.compactMap { childURL in return children.filter {
let values = try childURL.resourceValues(forKeys: resourceKeys) (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
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
} }
} }
nonisolated private static func isLikelyWorldFolder(_ url: URL, fileManager: FileManager) -> Bool { nonisolated private static func isCandidateItem(at directoryURL: URL, type: MinecraftContentType, fileManager: FileManager) -> Bool {
let levelDatURL = url.appendingPathComponent("level.dat") switch type {
let dbURL = url.appendingPathComponent("db", isDirectory: true) case .world:
let levelNameURL = url.appendingPathComponent("levelname.txt") return fileManager.fileExists(atPath: directoryURL.appendingPathComponent("level.dat").path)
|| fileManager.fileExists(atPath: directoryURL.appendingPathComponent("db", isDirectory: true).path)
return fileManager.fileExists(atPath: levelDatURL.path) || fileManager.fileExists(atPath: directoryURL.appendingPathComponent("levelname.txt").path)
|| fileManager.fileExists(atPath: dbURL.path) case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|| fileManager.fileExists(atPath: levelNameURL.path) 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 { nonisolated private static func displayName(for item: MinecraftContentItem, fileManager: FileManager) -> String {
let levelNameURL = worldURL.appendingPathComponent("levelname.txt") 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 guard
let name = try? String(contentsOf: levelNameURL, encoding: .utf8) fileManager.fileExists(atPath: manifestURL.path),
.trimmingCharacters(in: .whitespacesAndNewlines), 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 !name.isEmpty
else { else {
return fallback return nil
} }
return name return name
} }
nonisolated private static func iconURL(in worldURL: URL, fileManager: FileManager) -> URL? { nonisolated private static func iconURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL? {
let iconURL = worldURL.appendingPathComponent("world_icon.jpeg") let candidateNames: [String]
return fileManager.fileExists(atPath: iconURL.path) ? iconURL : nil
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? { nonisolated private static func folderSize(at folderURL: URL, fileManager: FileManager) -> Int64? {
@ -130,4 +273,17 @@ final class WorldScanner: ObservableObject {
return totalSize 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
}
} }