UI enhancements and exporting

This commit is contained in:
John Burwell 2026-05-25 15:02:51 -05:00
parent 1c06e4f67b
commit b0c2e4a44d
6 changed files with 640 additions and 189 deletions

View File

@ -397,7 +397,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -427,7 +427,7 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
ENABLE_APP_SANDBOX = YES; ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly; ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (

View File

@ -7,84 +7,76 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View { struct ContentView: View {
@StateObject private var scanner = WorldScanner() @StateObject private var library = SourceLibrary()
@State private var folderURL: URL?
@State private var selectedItem: MinecraftContentItem? @State private var selectedItem: MinecraftContentItem?
@State private var selectedSidebarSelection: SidebarSelection = .all @State private var selectedSidebarSelection: SidebarSelection?
@State private var searchText = ""
@State private var isDropTargeted = false
@State private var exportAlert: ExportAlert?
@State private var isExportingSelectedWorld = false
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
VStack(alignment: .leading, spacing: 12) { List(selection: $selectedSidebarSelection) {
Button("Choose Minecraft Folder...") { ForEach(library.sources) { source in
pickFolder() Section(source.displayName) {
} ForEach(sidebarFilters(for: source)) { filter in
SidebarFilterRow(filter: filter)
if let folderURL { .tag(filter.selection as SidebarSelection?)
Text(folderURL.path)
.font(.footnote)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} else {
Text("No folder selected")
.font(.footnote)
.foregroundStyle(.secondary)
}
if let scanError = scanner.scanError {
Text(scanError)
.font(.footnote)
.foregroundStyle(.red)
}
List(selection: $selectedSidebarSelection) {
if let folderURL {
Section(folderURL.lastPathComponent) {
ForEach(sidebarFilters) { filter in
SidebarFilterRow(filter: filter)
.tag(filter.selection)
}
} }
} }
} }
.listStyle(.sidebar)
} }
.padding() .listStyle(.sidebar)
.navigationTitle("Source") .navigationTitle("Sources")
} content: { } content: {
List(filteredItems, selection: $selectedItem) { item in if library.sources.isEmpty {
HStack(alignment: .top, spacing: 10) { EmptySourcesView(
ItemThumbnailView(iconURL: item.iconURL) isDropTargeted: isDropTargeted,
chooseFolder: pickFolder
)
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: handleDroppedProviders)
} else {
List(filteredItems, selection: $selectedItem) { item in
HStack(alignment: .top, spacing: 10) {
ItemThumbnailView(iconURL: item.iconURL)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(item.displayName) Text(item.displayName)
.lineLimit(1) .lineLimit(1)
Text(item.contentType.rawValue) Text(item.contentType.rawValue)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(item.folderName) Text(item.folderName)
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.lineLimit(1) .lineLimit(1)
} }
Spacer() Spacer()
if !item.metadataLoaded { if !item.metadataLoaded {
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
}
} }
.padding(.vertical, 2)
.contentShape(Rectangle())
.tag(item)
} }
.padding(.vertical, 2) .navigationTitle(contentListTitle)
.contentShape(Rectangle()) .searchable(text: $searchText, placement: .toolbar, prompt: "Search Content")
.tag(item)
} }
.navigationTitle(contentListTitle)
} detail: { } detail: {
if let selectedItem = currentSelectedItem { if library.sources.isEmpty {
Text("Add a source folder to start scanning Minecraft content")
.foregroundStyle(.secondary)
} else if let selectedItem = currentSelectedItem {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 16) { HStack(alignment: .top, spacing: 16) {
@ -102,6 +94,22 @@ struct ContentView: View {
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer(minLength: 0)
if selectedItem.contentType == .world {
Button {
exportSelectedWorld()
} label: {
if isExportingSelectedWorld {
ProgressView()
.controlSize(.small)
} else {
Label("Export .mcworld", systemImage: "square.and.arrow.up")
}
}
.disabled(isExportingSelectedWorld)
}
} }
detailRow(title: "Folder Path", value: selectedItem.folderURL.path) detailRow(title: "Folder Path", value: selectedItem.folderURL.path)
@ -135,18 +143,52 @@ struct ContentView: View {
} }
.navigationTitle("Minecraft World Manager") .navigationTitle("Minecraft World Manager")
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItemGroup(placement: .primaryAction) {
if scanner.isScanning { if selectedWorld != nil {
Button {
exportSelectedWorld()
} label: {
Label("Export .mcworld", systemImage: "square.and.arrow.up")
}
.disabled(isExportingSelectedWorld)
}
Button {
pickFolder()
} label: {
Label("Add Source", systemImage: "plus")
}
if let currentSource = currentSource {
Menu {
Button("Rescan \"\(currentSource.displayName)\"") {
library.rescanSource(withID: currentSource.id)
}
Divider()
Button("Remove \"\(currentSource.displayName)\"", role: .destructive) {
removeSource(currentSource.id)
}
} label: {
Image(systemName: "ellipsis.circle")
}
.help("Source actions")
}
}
ToolbarItem(placement: .secondaryAction) {
if let activeScanSummary = library.activeScanSummary {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
Text(scanner.scanStatus) Text(activeScanSummary)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
} }
.frame(maxWidth: 280, alignment: .trailing) .frame(maxWidth: 320, alignment: .trailing)
} }
} }
} }
@ -157,15 +199,50 @@ struct ContentView: View {
self.selectedItem = nil self.selectedItem = nil
} }
.onChange(of: library.sources.map(\.id)) { _, sourceIDs in
syncSelection(with: sourceIDs)
}
.alert(item: $exportAlert) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.message),
dismissButton: .default(Text("OK"))
)
}
} }
private var filteredItems: [MinecraftContentItem] { private var filteredItems: [MinecraftContentItem] {
switch selectedSidebarSelection { guard let selectedSidebarSelection else {
case .all: return []
return scanner.items
case .contentType(let contentType):
return scanner.items.filter { $0.contentType == contentType }
} }
let scopedItems: [MinecraftContentItem]
switch selectedSidebarSelection {
case .allContent(let sourceID):
scopedItems = library.source(withID: sourceID)?.items ?? []
case .contentType(let sourceID, let contentType):
scopedItems = library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? []
}
let trimmedSearchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedSearchText.isEmpty else {
return scopedItems
}
return scopedItems.filter { item in
item.displayName.localizedCaseInsensitiveContains(trimmedSearchText)
|| item.folderName.localizedCaseInsensitiveContains(trimmedSearchText)
|| item.contentType.rawValue.localizedCaseInsensitiveContains(trimmedSearchText)
}
}
private var currentSource: MinecraftSource? {
guard let sourceID = selectedSidebarSelection?.sourceID else {
return library.sources.first
}
return library.source(withID: sourceID)
} }
private var currentSelectedItem: MinecraftContentItem? { private var currentSelectedItem: MinecraftContentItem? {
@ -173,31 +250,45 @@ struct ContentView: View {
return nil return nil
} }
return scanner.items.first(where: { $0.id == selectedItem.id }) ?? selectedItem return library.sources
.flatMap(\.items)
.first(where: { $0.id == selectedItem.id }) ?? selectedItem
}
private var selectedWorld: MinecraftContentItem? {
guard let currentSelectedItem, currentSelectedItem.contentType == .world else {
return nil
}
return currentSelectedItem
} }
private var contentListTitle: String { private var contentListTitle: String {
switch selectedSidebarSelection { guard let selectedSidebarSelection else {
case .all:
return "Minecraft Content" return "Minecraft Content"
case .contentType(let contentType): }
return contentType.rawValue + "s"
switch selectedSidebarSelection {
case .allContent(let sourceID):
return library.source(withID: sourceID)?.displayName ?? "Minecraft Content"
case .contentType(_, let contentType):
return sidebarTitle(for: contentType)
} }
} }
private var sidebarFilters: [SidebarFilter] { private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
var filters = [ var filters = [
SidebarFilter( SidebarFilter(
title: "All Content", title: "All Content",
iconName: "square.grid.2x2", iconName: "square.grid.2x2",
count: scanner.items.count, count: source.items.count,
selection: .all selection: .allContent(sourceID: source.id)
) )
] ]
filters.append( filters.append(
contentsOf: MinecraftContentType.allCases.compactMap { contentType in contentsOf: MinecraftContentType.allCases.compactMap { contentType in
let count = scanner.items.filter { $0.contentType == contentType }.count let count = source.items.filter { $0.contentType == contentType }.count
guard count > 0 else { guard count > 0 else {
return nil return nil
} }
@ -206,7 +297,7 @@ struct ContentView: View {
title: sidebarTitle(for: contentType), title: sidebarTitle(for: contentType),
iconName: sidebarIcon(for: contentType), iconName: sidebarIcon(for: contentType),
count: count, count: count,
selection: .contentType(contentType) selection: .contentType(sourceID: source.id, contentType: contentType)
) )
} }
) )
@ -258,32 +349,145 @@ struct ContentView: View {
private func pickFolder() { private func pickFolder() {
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.allowsMultipleSelection = false panel.allowsMultipleSelection = true
panel.canChooseDirectories = true panel.canChooseDirectories = true
panel.canChooseFiles = false panel.canChooseFiles = false
panel.title = "Choose a Folder to Search" panel.title = "Add Minecraft Source Folders"
guard panel.runModal() == .OK, let pickedURL = panel.url else { guard panel.runModal() == .OK else {
return return
} }
folderURL = pickedURL for url in panel.urls {
selectedItem = nil let sourceID = library.addSource(at: url)
selectedSidebarSelection = .all selectSourceIfNeeded(sourceID)
}
}
private func handleDroppedProviders(_ providers: [NSItemProvider]) -> Bool {
let fileURLType = UTType.fileURL.identifier
let supportedProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(fileURLType) }
guard !supportedProviders.isEmpty else {
return false
}
for provider in supportedProviders {
provider.loadDataRepresentation(forTypeIdentifier: fileURLType) { data, _ in
guard
let data,
let url = NSURL(absoluteURLWithDataRepresentation: data, relativeTo: nil) as URL?
else {
return
}
Task { @MainActor in
let sourceID = library.addSource(at: url)
selectSourceIfNeeded(sourceID)
}
}
}
return true
}
private func selectSourceIfNeeded(_ sourceID: URL) {
guard selectedSidebarSelection == nil else {
return
}
selectedSidebarSelection = .allContent(sourceID: sourceID)
}
private func removeSource(_ sourceID: URL) {
let fallbackSourceID = library.sources.first(where: { $0.id != sourceID })?.id
library.removeSource(withID: sourceID)
if selectedSidebarSelection?.sourceID == sourceID {
selectedSidebarSelection = fallbackSourceID.map { .allContent(sourceID: $0) }
}
if let selectedItem, currentSelectedItem?.id != selectedItem.id {
self.selectedItem = nil
}
}
private func syncSelection(with sourceIDs: [URL]) {
if let selectedSidebarSelection, !sourceIDs.contains(selectedSidebarSelection.sourceID) {
self.selectedSidebarSelection = sourceIDs.first.map { .allContent(sourceID: $0) }
} else if self.selectedSidebarSelection == nil, let firstSourceID = sourceIDs.first {
self.selectedSidebarSelection = .allContent(sourceID: firstSourceID)
}
if let selectedItem {
let itemStillExists = library.sources
.flatMap(\.items)
.contains(where: { $0.id == selectedItem.id })
if !itemStillExists {
self.selectedItem = nil
}
}
}
private func exportSelectedWorld() {
guard let world = selectedWorld, !isExportingSelectedWorld else {
return
}
let panel = NSSavePanel()
panel.canCreateDirectories = true
panel.isExtensionHidden = false
panel.title = "Export Minecraft World"
panel.message = "Choose where to save the .mcworld file."
panel.nameFieldStringValue = WorldExporter.suggestedFilename(for: world)
panel.allowedContentTypes = [UTType(filenameExtension: "mcworld") ?? .data]
guard panel.runModal() == .OK, let destinationURL = panel.url else {
return
}
isExportingSelectedWorld = true
Task { Task {
await scanner.scan(at: pickedURL) do {
try await Task.detached(priority: .userInitiated) {
try WorldExporter.exportWorld(world, to: destinationURL)
}.value
await MainActor.run {
isExportingSelectedWorld = false
exportAlert = ExportAlert(
title: "Export Complete",
message: "\"\(world.displayName)\" was exported as a .mcworld file."
)
}
} catch {
await MainActor.run {
isExportingSelectedWorld = false
exportAlert = ExportAlert(
title: "Export Failed",
message: error.localizedDescription
)
}
}
} }
} }
} }
private enum SidebarSelection: Hashable { private enum SidebarSelection: Hashable {
case all case allContent(sourceID: URL)
case contentType(MinecraftContentType) case contentType(sourceID: URL, contentType: MinecraftContentType)
var sourceID: URL {
switch self {
case .allContent(let sourceID), .contentType(let sourceID, _):
return sourceID
}
}
} }
private struct SidebarFilter: Identifiable, Hashable { private struct SidebarFilter: Identifiable, Hashable {
let id = UUID() var id: SidebarSelection { selection }
let title: String let title: String
let iconName: String let iconName: String
let count: Int let count: Int
@ -309,6 +513,49 @@ private struct SidebarFilterRow: View {
} }
} }
private struct ExportAlert: Identifiable {
let id = UUID()
let title: String
let message: String
}
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.accentColor : Color.secondary.opacity(0.25))
.frame(width: 220, height: 160)
Image(systemName: "folder.badge.plus")
.font(.system(size: 56, weight: .regular))
.foregroundStyle(isDropTargeted ? Color.accentColor : 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 { private struct ItemThumbnailView: View {
let iconURL: URL? let iconURL: URL?

View File

@ -0,0 +1,33 @@
//
// MinecraftSource.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import Foundation
struct MinecraftSource: Identifiable, Hashable, Sendable {
let id: URL
let folderURL: URL
var displayName: String
var items: [MinecraftContentItem]
var isScanning: Bool
var scanStatus: String
var scanError: String?
init(folderURL: URL) {
let normalizedURL = folderURL.standardizedFileURL
self.id = normalizedURL
self.folderURL = normalizedURL
self.displayName = normalizedURL.lastPathComponent
self.items = []
self.isScanning = false
self.scanStatus = ""
self.scanError = nil
}
var itemCount: Int {
items.count
}
}

View File

@ -0,0 +1,156 @@
//
// SourceLibrary.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import Combine
import Foundation
@MainActor
final class SourceLibrary: ObservableObject {
@Published var sources: [MinecraftSource] = []
private var scanTasks: [URL: Task<Void, Never>] = [:]
func addSource(at url: URL) -> URL {
let normalizedURL = url.standardizedFileURL
if sources.contains(where: { $0.id == normalizedURL }) {
startScan(for: normalizedURL)
return normalizedURL
}
sources.append(MinecraftSource(folderURL: normalizedURL))
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
startScan(for: normalizedURL)
return normalizedURL
}
func source(withID sourceID: URL) -> MinecraftSource? {
sources.first(where: { $0.id == sourceID })
}
func rescanSource(withID sourceID: URL) {
startScan(for: sourceID)
}
func removeSource(withID sourceID: URL) {
scanTasks[sourceID]?.cancel()
scanTasks[sourceID] = nil
sources.removeAll { $0.id == sourceID }
}
var activeScanSummary: String? {
let scanningSources = sources.filter(\.isScanning)
guard !scanningSources.isEmpty else {
return nil
}
if scanningSources.count == 1, let source = scanningSources.first {
return "\(source.displayName): \(source.scanStatus)"
}
return "Scanning \(scanningSources.count) sources..."
}
private func startScan(for sourceID: URL) {
scanTasks[sourceID]?.cancel()
let task = Task { [weak self] in
guard let self else {
return
}
await self.scanSource(withID: sourceID)
}
scanTasks[sourceID] = task
}
private func scanSource(withID sourceID: URL) async {
updateSource(sourceID) { source in
source.isScanning = true
source.scanError = nil
source.scanStatus = "Searching for Minecraft content..."
source.items = []
}
do {
let discoveredItems = try await Task.detached(priority: .userInitiated) {
try WorldScanner.discoverItems(in: sourceID)
}.value
guard !Task.isCancelled else {
return
}
updateSource(sourceID) { source in
source.items = discoveredItems
source.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 {
WorldScanner.enrich(item: item)
}
}
for await enrichedItem in group {
guard !Task.isCancelled else {
return
}
loadedCount += 1
updateSource(sourceID) { source in
guard let index = source.items.firstIndex(where: { $0.id == enrichedItem.id }) else {
return
}
source.items[index] = enrichedItem
source.items.sort(by: WorldScanner.sortItems)
if loadedCount == discoveredItems.count {
source.scanStatus = "Loaded \(loadedCount) items."
source.isScanning = false
} else {
source.scanStatus = "Loaded details for \(loadedCount) of \(discoveredItems.count) items..."
}
}
}
}
if discoveredItems.isEmpty {
updateSource(sourceID) { source in
source.isScanning = false
}
}
} catch {
guard !Task.isCancelled else {
return
}
updateSource(sourceID) { source in
source.scanError = "Failed to scan folder: \(error.localizedDescription)"
source.scanStatus = ""
source.isScanning = false
}
}
scanTasks[sourceID] = nil
}
private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) {
guard let index = sources.firstIndex(where: { $0.id == sourceID }) else {
return
}
mutate(&sources[index])
}
}

View File

@ -0,0 +1,97 @@
//
// WorldExporter.swift
// World Manager for Minecraft
//
// Created by John Burwell on 2026-05-25.
//
import Foundation
enum WorldExporter {
enum ExportError: LocalizedError {
case unsupportedContentType
case failedToCreateArchive(String)
var errorDescription: String? {
switch self {
case .unsupportedContentType:
return "Only Minecraft worlds can be exported as .mcworld files."
case .failedToCreateArchive(let output):
return output.isEmpty ? "Failed to create the .mcworld archive." : output
}
}
}
nonisolated static func exportWorld(_ item: MinecraftContentItem, to destinationURL: URL) throws {
guard item.contentType == .world else {
throw ExportError.unsupportedContentType
}
let fileManager = FileManager.default
let normalizedDestinationURL = destinationURL.standardizedFileURL
let archiveURL = normalizedDestinationURL.pathExtension.lowercased() == "mcworld"
? normalizedDestinationURL
: normalizedDestinationURL.appendingPathExtension("mcworld")
let temporaryArchiveURL = fileManager.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mcworld")
defer {
try? fileManager.removeItem(at: temporaryArchiveURL)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
process.currentDirectoryURL = item.folderURL
process.arguments = [
"-c",
"-k",
"--norsrc",
".",
temporaryArchiveURL.path
]
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = outputPipe
try process.run()
process.waitUntilExit()
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard process.terminationStatus == 0 else {
throw ExportError.failedToCreateArchive(output)
}
if fileManager.fileExists(atPath: archiveURL.path) {
try fileManager.removeItem(at: archiveURL)
}
try fileManager.moveItem(at: temporaryArchiveURL, to: archiveURL)
}
nonisolated static func suggestedFilename(for item: MinecraftContentItem) -> String {
let baseName = sanitizedFilename(item.displayName.isEmpty ? item.folderName : item.displayName)
return "\(baseName).mcworld"
}
nonisolated private static func sanitizedFilename(_ value: String) -> String {
let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
let components = value.components(separatedBy: invalidCharacters)
let collapsed = components.joined(separator: " ")
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedWhitespace = collapsed.replacingOccurrences(
of: "\\s+",
with: " ",
options: .regularExpression
)
return normalizedWhitespace.isEmpty ? "Minecraft World" : normalizedWhitespace
}
}

View File

@ -5,92 +5,10 @@
// Created by John Burwell on 2026-05-25. // Created by John Burwell on 2026-05-25.
// //
import Combine
import Foundation import Foundation
@MainActor enum WorldScanner {
final class WorldScanner: ObservableObject { nonisolated static func discoverItems(in searchRootURL: URL) throws -> [MinecraftContentItem] {
@Published var items: [MinecraftContentItem] = []
@Published var isScanning = false
@Published var scanStatus = ""
@Published var scanError: String?
private var activeScanID = UUID()
func scan(at searchRootURL: URL) async {
let scanID = UUID()
activeScanID = scanID
isScanning = true
scanError = nil
scanStatus = "Searching for Minecraft content..."
items = []
do {
let discoveredItems = try await Task.detached(priority: .userInitiated) {
try Self.discoverItems(in: searchRootURL)
}.value
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
}
}
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: [URLResourceKey] = [.isDirectoryKey] let resourceKeys: [URLResourceKey] = [.isDirectoryKey]
@ -139,7 +57,7 @@ final class WorldScanner: ObservableObject {
return discoveredItems return discoveredItems
} }
nonisolated private static func enrich(item: MinecraftContentItem) -> MinecraftContentItem { nonisolated static func enrich(item: MinecraftContentItem) -> MinecraftContentItem {
let fileManager = FileManager.default let fileManager = FileManager.default
var enrichedItem = item var enrichedItem = item
@ -152,6 +70,19 @@ final class WorldScanner: ObservableObject {
return enrichedItem return enrichedItem
} }
nonisolated 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
}
nonisolated private static func contentType(forCollectionFolderName folderName: String) -> MinecraftContentType? { nonisolated private static func contentType(forCollectionFolderName folderName: String) -> MinecraftContentType? {
let normalizedFolderName = folderName.lowercased() let normalizedFolderName = folderName.lowercased()
@ -273,17 +204,4 @@ 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
}
} }