UI and behavior changes
This commit is contained in:
parent
2932ac2f48
commit
6d2ee05786
@ -399,6 +399,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@ -429,6 +430,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"colors" : [
|
"colors" : [
|
||||||
{
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.240",
|
||||||
|
"green" : "0.630",
|
||||||
|
"red" : "0.360"
|
||||||
|
}
|
||||||
|
},
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@ -22,13 +22,31 @@ struct ContentView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
SidebarSourcesHeaderView(addSourceAction: pickFolder)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
List(selection: $selectedSidebarSelection) {
|
List(selection: $selectedSidebarSelection) {
|
||||||
ForEach(library.sources) { source in
|
ForEach(library.sources) { source in
|
||||||
Section(source.displayName) {
|
SourceHeaderRow(title: source.displayName)
|
||||||
ForEach(sidebarFilters(for: source)) { filter in
|
.listRowSeparator(.hidden)
|
||||||
SidebarFilterRow(filter: filter)
|
.padding(.top, 6)
|
||||||
.tag(filter.selection as SidebarSelection?)
|
.contextMenu {
|
||||||
|
Button("Rescan \"\(source.displayName)\"") {
|
||||||
|
library.rescanSource(withID: source.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Remove \"\(source.displayName)\"", role: .destructive) {
|
||||||
|
removeSource(source.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(sidebarFilters(for: source)) { filter in
|
||||||
|
SidebarFilterRow(filter: filter, isIndented: true)
|
||||||
|
.tag(filter.selection as SidebarSelection?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -94,7 +112,6 @@ struct ContentView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(.minecraftAccent)
|
|
||||||
.searchable(text: $searchText, placement: .toolbar, prompt: searchPrompt)
|
.searchable(text: $searchText, placement: .toolbar, prompt: searchPrompt)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
@ -122,29 +139,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.disabled(isPerformingItemAction)
|
.disabled(isPerformingItemAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: filteredItems.map(\.id)) { _, filteredIDs in
|
.onChange(of: filteredItems.map(\.id)) { _, filteredIDs in
|
||||||
@ -625,6 +619,7 @@ private struct SidebarFilter: Identifiable, Hashable {
|
|||||||
|
|
||||||
private struct SidebarFilterRow: View {
|
private struct SidebarFilterRow: View {
|
||||||
let filter: SidebarFilter
|
let filter: SidebarFilter
|
||||||
|
let isIndented: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
@ -639,6 +634,37 @@ private struct SidebarFilterRow: View {
|
|||||||
Text(filter.count, format: .number)
|
Text(filter.count, format: .number)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
.padding(.leading, isIndented ? 16 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SidebarSourcesHeaderView: View {
|
||||||
|
let addSourceAction: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text("Sources")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: addSourceAction) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Add Source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SourceHeaderRow: View {
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -688,7 +714,7 @@ private struct SidebarFooterView: View {
|
|||||||
case .failure:
|
case .failure:
|
||||||
return .red
|
return .red
|
||||||
case .success:
|
case .success:
|
||||||
return .minecraftAccent
|
return .appAccent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -765,6 +791,7 @@ private struct ItemDetailView: View {
|
|||||||
let primaryAction: () -> Void
|
let primaryAction: () -> Void
|
||||||
let shareAction: (NSView) -> Void
|
let shareAction: (NSView) -> Void
|
||||||
let revealAction: () -> Void
|
let revealAction: () -> Void
|
||||||
|
@State private var isTechnicalDetailsExpanded = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@ -851,7 +878,7 @@ private struct ItemDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
detailCard {
|
detailCard {
|
||||||
DisclosureGroup("Technical Details") {
|
DisclosureGroup(isExpanded: $isTechnicalDetailsExpanded) {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
detailRow(title: "Folder ID", value: item.folderID)
|
detailRow(title: "Folder ID", value: item.folderID)
|
||||||
detailRow(title: "Folder Path", value: item.folderURL.path)
|
detailRow(title: "Folder Path", value: item.folderURL.path)
|
||||||
@ -885,6 +912,15 @@ private struct ItemDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Technical Details")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
isTechnicalDetailsExpanded.toggle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -908,6 +944,9 @@ private struct ItemDetailView: View {
|
|||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
|
|
||||||
ForEach(packs) { pack in
|
ForEach(packs) { pack in
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
PackReferenceIconView(iconURL: pack.iconURL)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(pack.name)
|
Text(pack.name)
|
||||||
|
|
||||||
@ -920,6 +959,7 @@ private struct ItemDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func detailRow(title: String, value: String) -> some View {
|
private func detailRow(title: String, value: String) -> some View {
|
||||||
@ -1013,6 +1053,29 @@ private struct SharingPickerButton: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private struct EmptySourcesView: View {
|
||||||
let isDropTargeted: Bool
|
let isDropTargeted: Bool
|
||||||
let chooseFolder: () -> Void
|
let chooseFolder: () -> Void
|
||||||
@ -1022,12 +1085,12 @@ private struct EmptySourcesView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 24)
|
RoundedRectangle(cornerRadius: 24)
|
||||||
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10]))
|
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10]))
|
||||||
.foregroundStyle(isDropTargeted ? Color.minecraftAccent : Color.secondary.opacity(0.25))
|
.foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary.opacity(0.25))
|
||||||
.frame(width: 220, height: 160)
|
.frame(width: 220, height: 160)
|
||||||
|
|
||||||
Image(systemName: "folder.badge.plus")
|
Image(systemName: "folder.badge.plus")
|
||||||
.font(.system(size: 56, weight: .regular))
|
.font(.system(size: 56, weight: .regular))
|
||||||
.foregroundStyle(isDropTargeted ? Color.minecraftAccent : Color.secondary)
|
.foregroundStyle(isDropTargeted ? Color.appAccent : Color.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
@ -1080,8 +1143,8 @@ private struct LargeItemThumbnailView: View {
|
|||||||
if let image = loadImage(from: iconURL) {
|
if let image = loadImage(from: iconURL) {
|
||||||
Image(nsImage: image)
|
Image(nsImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(image.size, contentMode: .fit)
|
||||||
.frame(maxWidth: 420, minHeight: 260, maxHeight: 340)
|
.frame(maxWidth: 420, maxHeight: 340)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 28))
|
.clipShape(RoundedRectangle(cornerRadius: 28))
|
||||||
} else {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 28)
|
RoundedRectangle(cornerRadius: 28)
|
||||||
@ -1120,7 +1183,7 @@ private func loadImage(from url: URL?) -> NSImage? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension Color {
|
private extension Color {
|
||||||
static let minecraftAccent = Color(red: 0.36, green: 0.63, blue: 0.24)
|
static let appAccent = Color("AccentColor")
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
struct ContentView_Previews: PreviewProvider {
|
||||||
|
|||||||
@ -66,6 +66,7 @@ struct ContentPackReference: Identifiable, Hashable, Sendable {
|
|||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
let type: MinecraftContentType
|
let type: MinecraftContentType
|
||||||
|
let iconURL: URL?
|
||||||
let uuid: String?
|
let uuid: String?
|
||||||
let version: String?
|
let version: String?
|
||||||
let source: PackSource
|
let source: PackSource
|
||||||
@ -73,11 +74,13 @@ struct ContentPackReference: Identifiable, Hashable, Sendable {
|
|||||||
nonisolated init(
|
nonisolated init(
|
||||||
name: String,
|
name: String,
|
||||||
type: MinecraftContentType,
|
type: MinecraftContentType,
|
||||||
|
iconURL: URL? = nil,
|
||||||
uuid: String? = nil,
|
uuid: String? = nil,
|
||||||
version: String? = nil,
|
version: String? = nil,
|
||||||
source: PackSource
|
source: PackSource
|
||||||
) {
|
) {
|
||||||
self.type = type
|
self.type = type
|
||||||
|
self.iconURL = iconURL
|
||||||
self.uuid = uuid?.lowercased()
|
self.uuid = uuid?.lowercased()
|
||||||
self.version = version
|
self.version = version
|
||||||
self.source = source
|
self.source = source
|
||||||
|
|||||||
@ -134,18 +134,36 @@ enum ContentPackageExporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func sanitizedFilename(_ value: String) -> String {
|
nonisolated private static func sanitizedFilename(_ value: String) -> String {
|
||||||
|
let transliterated = portableASCIIString(from: value)
|
||||||
let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
|
let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
|
||||||
let components = value.components(separatedBy: invalidCharacters)
|
let components = transliterated.components(separatedBy: invalidCharacters)
|
||||||
let collapsed = components.joined(separator: " ")
|
let collapsed = components.joined(separator: " ")
|
||||||
.replacingOccurrences(of: "\n", with: " ")
|
.replacingOccurrences(of: "\n", with: " ")
|
||||||
|
.replacingOccurrences(of: "\r", with: " ")
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
let normalizedWhitespace = collapsed.replacingOccurrences(
|
let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: " -_().,'&+"))
|
||||||
|
let filteredScalars = collapsed.unicodeScalars.map { scalar in
|
||||||
|
allowedCharacters.contains(scalar) ? Character(scalar) : " "
|
||||||
|
}
|
||||||
|
let filtered = String(filteredScalars)
|
||||||
|
|
||||||
|
let normalizedWhitespace = filtered.replacingOccurrences(
|
||||||
of: "\\s+",
|
of: "\\s+",
|
||||||
with: " ",
|
with: " ",
|
||||||
options: .regularExpression
|
options: .regularExpression
|
||||||
)
|
)
|
||||||
|
let trimmedPunctuation = normalizedWhitespace.trimmingCharacters(in: CharacterSet(charactersIn: " .-_"))
|
||||||
|
|
||||||
return normalizedWhitespace.isEmpty ? "Minecraft Content" : normalizedWhitespace
|
return trimmedPunctuation.isEmpty ? "Minecraft Content" : trimmedPunctuation
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func portableASCIIString(from value: String) -> String {
|
||||||
|
let mutable = NSMutableString(string: value) as CFMutableString
|
||||||
|
|
||||||
|
CFStringTransform(mutable, nil, kCFStringTransformToLatin, false)
|
||||||
|
CFStringTransform(mutable, nil, kCFStringTransformStripCombiningMarks, false)
|
||||||
|
|
||||||
|
return mutable as String
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -133,66 +133,64 @@ final class SourceLibrary: ObservableObject {
|
|||||||
refreshSidebarFooterState()
|
refreshSidebarFooterState()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let discoveredItems = try await Task.detached(priority: .userInitiated) {
|
let enrichmentTracker = PendingEnrichmentTracker()
|
||||||
try WorldScanner.discoverItems(in: sourceID)
|
let applyEnrichedItem: @MainActor (MinecraftContentItem) -> Void = { [weak self] enrichedItem in
|
||||||
}.value
|
self?.handleEnrichedItem(enrichedItem, for: sourceID)
|
||||||
|
}
|
||||||
guard !Task.isCancelled else {
|
let discoveryStream = AsyncThrowingStream<MinecraftContentItem, Error> { continuation in
|
||||||
return
|
let discoveryTask = Task.detached(priority: .userInitiated) {
|
||||||
|
do {
|
||||||
|
_ = try WorldScanner.discoverItems(in: sourceID) { item in
|
||||||
|
continuation.yield(item)
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
discoveryTask.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var discoveredCount = 0
|
||||||
|
|
||||||
|
for try await item in discoveryStream {
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredCount += 1
|
||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
source.items = discoveredItems
|
source.items.append(item)
|
||||||
source.indexedItemCount = discoveredItems.count
|
source.indexedItemCount = discoveredCount
|
||||||
source.scanStatus = discoveredItems.isEmpty
|
source.scanStatus = "Found \(discoveredCount) items. Loading details..."
|
||||||
? "No Minecraft content found."
|
|
||||||
: "Found \(discoveredItems.count) items. Loading details..."
|
|
||||||
}
|
}
|
||||||
refreshSidebarFooterState()
|
refreshSidebarFooterState()
|
||||||
|
|
||||||
var loadedCount = 0
|
await enrichmentTracker.beginEnrichment()
|
||||||
|
let tracker = enrichmentTracker
|
||||||
|
|
||||||
await withTaskGroup(of: MinecraftContentItem.self) { group in
|
Task.detached(priority: .utility) {
|
||||||
for item in discoveredItems {
|
let enrichedItem = WorldScanner.enrich(item: item)
|
||||||
group.addTask {
|
await applyEnrichedItem(enrichedItem)
|
||||||
WorldScanner.enrich(item: item)
|
await tracker.finishEnrichment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for await enrichedItem in group {
|
await enrichmentTracker.markDiscoveryFinished()
|
||||||
guard !Task.isCancelled else {
|
await enrichmentTracker.waitForCompletion()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loadedCount += 1
|
|
||||||
updateSource(sourceID) { source in
|
updateSource(sourceID) { source in
|
||||||
guard let index = source.items.firstIndex(where: { $0.id == enrichedItem.id }) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
source.items[index] = enrichedItem
|
|
||||||
source.indexedDetailCount = loadedCount
|
|
||||||
source.items.sort(by: WorldScanner.sortItems)
|
source.items.sort(by: WorldScanner.sortItems)
|
||||||
|
source.scanStatus = source.indexedItemCount == 0
|
||||||
if loadedCount == discoveredItems.count {
|
? "No Minecraft content found."
|
||||||
source.scanStatus = "Loaded \(loadedCount) items."
|
: "Loaded \(source.indexedDetailCount) items."
|
||||||
source.isScanning = false
|
|
||||||
source.lastScanDate = Date()
|
|
||||||
} else {
|
|
||||||
source.scanStatus = "Loaded details for \(loadedCount) of \(discoveredItems.count) items..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refreshSidebarFooterState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if discoveredItems.isEmpty {
|
|
||||||
updateSource(sourceID) { source in
|
|
||||||
source.isScanning = false
|
source.isScanning = false
|
||||||
source.lastScanDate = Date()
|
source.lastScanDate = Date()
|
||||||
}
|
}
|
||||||
refreshSidebarFooterState()
|
refreshSidebarFooterState()
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
guard !Task.isCancelled else {
|
guard !Task.isCancelled else {
|
||||||
return
|
return
|
||||||
@ -209,6 +207,22 @@ final class SourceLibrary: ObservableObject {
|
|||||||
scanTasks[sourceID] = nil
|
scanTasks[sourceID] = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleEnrichedItem(_ enrichedItem: MinecraftContentItem, for sourceID: URL) {
|
||||||
|
updateSource(sourceID) { source in
|
||||||
|
guard let index = source.items.firstIndex(where: { $0.id == enrichedItem.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
source.items[index] = enrichedItem
|
||||||
|
source.indexedDetailCount += 1
|
||||||
|
|
||||||
|
if source.indexedDetailCount < source.indexedItemCount {
|
||||||
|
source.scanStatus = "Loaded details for \(source.indexedDetailCount) of \(source.indexedItemCount) items..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshSidebarFooterState()
|
||||||
|
}
|
||||||
|
|
||||||
private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) {
|
private func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) {
|
||||||
guard let index = sources.firstIndex(where: { $0.id == sourceID }) else {
|
guard let index = sources.firstIndex(where: { $0.id == sourceID }) else {
|
||||||
return
|
return
|
||||||
@ -268,3 +282,42 @@ final class SourceLibrary: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private actor PendingEnrichmentTracker {
|
||||||
|
private var pendingCount = 0
|
||||||
|
private var discoveryFinished = false
|
||||||
|
private var continuation: CheckedContinuation<Void, Never>?
|
||||||
|
|
||||||
|
func beginEnrichment() {
|
||||||
|
pendingCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishEnrichment() {
|
||||||
|
pendingCount -= 1
|
||||||
|
resumeIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markDiscoveryFinished() {
|
||||||
|
discoveryFinished = true
|
||||||
|
resumeIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForCompletion() async {
|
||||||
|
guard !(discoveryFinished && pendingCount == 0) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
self.continuation = continuation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resumeIfNeeded() {
|
||||||
|
guard discoveryFinished, pendingCount == 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation?.resume()
|
||||||
|
continuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -8,7 +8,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum WorldScanner {
|
enum WorldScanner {
|
||||||
nonisolated static func discoverItems(in searchRootURL: URL) throws -> [MinecraftContentItem] {
|
nonisolated static func discoverItems(
|
||||||
|
in searchRootURL: URL,
|
||||||
|
onDiscovered: @Sendable (MinecraftContentItem) -> Void = { _ in }
|
||||||
|
) throws -> [MinecraftContentItem] {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let resourceKeys: [URLResourceKey] = [.isDirectoryKey]
|
let resourceKeys: [URLResourceKey] = [.isDirectoryKey]
|
||||||
|
|
||||||
@ -40,15 +43,15 @@ enum WorldScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isCandidateItem(at: childDirectory, type: contentType, fileManager: fileManager) {
|
if isCandidateItem(at: childDirectory, type: contentType, fileManager: fileManager) {
|
||||||
seenItemURLs.insert(itemURL)
|
let item = MinecraftContentItem(
|
||||||
discoveredItems.append(
|
|
||||||
MinecraftContentItem(
|
|
||||||
folderURL: childDirectory,
|
folderURL: childDirectory,
|
||||||
folderName: childDirectory.lastPathComponent,
|
folderName: childDirectory.lastPathComponent,
|
||||||
contentType: contentType,
|
contentType: contentType,
|
||||||
collectionRootURL: directoryURL
|
collectionRootURL: directoryURL
|
||||||
)
|
)
|
||||||
)
|
seenItemURLs.insert(itemURL)
|
||||||
|
discoveredItems.append(item)
|
||||||
|
onDiscovered(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -157,6 +160,19 @@ enum WorldScanner {
|
|||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated private static func packIconURL(in directoryURL: URL, fileManager: FileManager) -> URL? {
|
||||||
|
let candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
|
||||||
|
|
||||||
|
for candidateName in candidateNames {
|
||||||
|
let candidateURL = directoryURL.appendingPathComponent(candidateName)
|
||||||
|
if fileManager.fileExists(atPath: candidateURL.path) {
|
||||||
|
return candidateURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated private static func iconURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL? {
|
nonisolated private static func iconURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL? {
|
||||||
let candidateNames: [String]
|
let candidateNames: [String]
|
||||||
|
|
||||||
@ -286,20 +302,21 @@ enum WorldScanner {
|
|||||||
return jsonObject.compactMap { entry in
|
return jsonObject.compactMap { entry in
|
||||||
let uuid = (entry["pack_id"] as? String)?.lowercased()
|
let uuid = (entry["pack_id"] as? String)?.lowercased()
|
||||||
let version = versionString(from: entry["version"])
|
let version = versionString(from: entry["version"])
|
||||||
let resolvedName = uuid.flatMap {
|
let resolvedPack = uuid.flatMap {
|
||||||
resolvedPackName(
|
resolvedPackReference(
|
||||||
uuid: $0,
|
uuid: $0,
|
||||||
type: type,
|
type: type,
|
||||||
worldCollectionRootURL: worldFolderURL.deletingLastPathComponent(),
|
worldCollectionRootURL: worldFolderURL.deletingLastPathComponent(),
|
||||||
fileManager: fileManager
|
fileManager: fileManager
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
let fallbackName = resolvedName ?? uuid ?? "Referenced Pack"
|
let fallbackName = resolvedPack?.name ?? uuid ?? "Referenced Pack"
|
||||||
return ContentPackReference(
|
return ContentPackReference(
|
||||||
name: fallbackName,
|
name: fallbackName,
|
||||||
type: type,
|
type: type,
|
||||||
|
iconURL: resolvedPack?.iconURL,
|
||||||
uuid: uuid,
|
uuid: uuid,
|
||||||
version: version,
|
version: resolvedPack?.version ?? version,
|
||||||
source: .referencedByWorld
|
source: .referencedByWorld
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -352,18 +369,19 @@ enum WorldScanner {
|
|||||||
return ContentPackReference(
|
return ContentPackReference(
|
||||||
name: name,
|
name: name,
|
||||||
type: type,
|
type: type,
|
||||||
|
iconURL: packIconURL(in: directoryURL, fileManager: fileManager),
|
||||||
uuid: uuid,
|
uuid: uuid,
|
||||||
version: version,
|
version: version,
|
||||||
source: source
|
source: source
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func resolvedPackName(
|
nonisolated private static func resolvedPackReference(
|
||||||
uuid: String,
|
uuid: String,
|
||||||
type: MinecraftContentType,
|
type: MinecraftContentType,
|
||||||
worldCollectionRootURL: URL,
|
worldCollectionRootURL: URL,
|
||||||
fileManager: FileManager
|
fileManager: FileManager
|
||||||
) -> String? {
|
) -> ContentPackReference? {
|
||||||
let siblingCollectionURL = worldCollectionRootURL
|
let siblingCollectionURL = worldCollectionRootURL
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
.appendingPathComponent(type.collectionFolderName, isDirectory: true)
|
||||||
@ -388,7 +406,7 @@ enum WorldScanner {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return reference.name
|
return reference
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -12,6 +12,8 @@ struct World_Manager_for_MinecraftApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
.tint(Color("AccentColor"))
|
||||||
}
|
}
|
||||||
|
.windowToolbarStyle(.unifiedCompact(showsTitle: false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user