diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 8e03b5c..e308f78 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -15,23 +15,33 @@ struct ContentView: View { @State private var selectedSidebarSelection: SidebarSelection? @State private var searchText = "" @State private var isDropTargeted = false - @State private var itemActionAlert: ItemActionAlert? @State private var isPerformingItemAction = false + private let directoryPreviewLimit = 12 + var body: some View { NavigationSplitView { - List(selection: $selectedSidebarSelection) { - ForEach(library.sources) { source in - Section(source.displayName) { - ForEach(sidebarFilters(for: source)) { filter in - SidebarFilterRow(filter: filter) - .tag(filter.selection as SidebarSelection?) + VStack(spacing: 0) { + List(selection: $selectedSidebarSelection) { + ForEach(library.sources) { source in + Section(source.displayName) { + ForEach(sidebarFilters(for: source)) { filter in + SidebarFilterRow(filter: filter) + .tag(filter.selection as SidebarSelection?) + } } } } + .listStyle(.sidebar) + .navigationTitle("Sources") + + Divider() + + SidebarFooterView( + state: library.sidebarFooterState, + revealAction: revealURLInFinder(_:) + ) } - .listStyle(.sidebar) - .navigationTitle("Sources") } content: { if library.sources.isEmpty { EmptySourcesView( @@ -40,150 +50,51 @@ struct ContentView: View { ) .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(spacing: 0) { + ContentCollectionHeaderView( + title: collectionHeaderTitle, + subtitle: collectionHeaderSubtitle, + prompt: searchPrompt, + searchText: $searchText + ) - VStack(alignment: .leading, spacing: 4) { - Text(item.displayName) - .lineLimit(1) + Divider() - Text(item.contentType.rawValue) - .font(.caption) - .foregroundStyle(.secondary) - - Text(item.folderName) - .font(.caption2) - .foregroundStyle(.tertiary) - .lineLimit(1) - } - - Spacer() - - if !item.metadataLoaded { - ProgressView() - .controlSize(.small) - } - } - .padding(.vertical, 2) - .contentShape(Rectangle()) - .tag(item) - .contextMenu { - itemContextMenu(for: item) + List(filteredItems, selection: $selectedItem) { item in + ContentRowView(item: item) + .tag(item) + .contextMenu { + itemContextMenu(for: item) + } } + .listStyle(.inset) } - .navigationTitle(contentListTitle) - .searchable(text: $searchText, placement: .toolbar, prompt: "Search Content") } } detail: { if library.sources.isEmpty { Text("Add a source folder to start scanning Minecraft content") .foregroundStyle(.secondary) } else if let selectedItem = currentSelectedItem { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - 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) - } - - Spacer(minLength: 0) - - VStack(alignment: .trailing, spacing: 8) { - SharingPickerButton( - title: "Share", - systemImage: "square.and.arrow.up", - isEnabled: !isPerformingItemAction - ) { anchorView in - shareItem(selectedItem, from: anchorView) - } - - Button { - saveItem(selectedItem) - } label: { - Label("Export .\(selectedItem.contentType.archiveExtension)", systemImage: "square.and.arrow.down") - } - .disabled(isPerformingItemAction) - - Button { - revealInFinder(selectedItem) - } label: { - Label("Reveal in Finder", systemImage: "folder") - } - .disabled(isPerformingItemAction) - } - } - - detailSection("Location") { - detailRow(title: "Folder Path", value: selectedItem.folderURL.path) - detailRow(title: "Collection Root", value: selectedItem.collectionRootURL.path) - } - - detailSection("Details") { - 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..." - ) - } - - detailSection("Contents") { - let previewEntries = directoryPreviewEntries(for: selectedItem) - - if previewEntries.isEmpty { - Text("No visible files or folders") - .foregroundStyle(.secondary) - } else { - ForEach(previewEntries) { entry in - HStack(spacing: 10) { - Image(systemName: entry.isDirectory ? "folder" : "doc") - .foregroundStyle(.secondary) - Text(entry.name) - .lineLimit(1) - Spacer() - } - } - - if previewEntries.count == directoryPreviewLimit { - Text("Showing the first \(directoryPreviewLimit) items") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .padding() - } + ItemDetailView( + item: selectedItem, + behaviorPacks: packReferences(for: selectedItem, type: .behaviorPack), + resourcePacks: packReferences(for: selectedItem, type: .resourcePack), + contents: directoryPreviewEntries(for: selectedItem), + directoryPreviewLimit: directoryPreviewLimit, + isPerformingItemAction: isPerformingItemAction, + primaryActionTitle: primaryActionTitle(for: selectedItem), + primaryActionSubtitle: primaryActionSubtitle(for: selectedItem), + primaryAction: { saveItem(selectedItem) }, + shareAction: { anchorView in shareItem(selectedItem, from: anchorView) }, + revealAction: { revealInFinder(selectedItem) } + ) } else { Text("Select a world or pack to see details") .foregroundStyle(.secondary) } } .navigationTitle("Minecraft World Manager") + .tint(.minecraftAccent) .toolbar { ToolbarItemGroup(placement: .primaryAction) { if let selectedExportableItem = selectedExportableItem { @@ -194,11 +105,12 @@ struct ContentView: View { ) { anchorView in shareItem(selectedExportableItem, from: anchorView) } + .help("Share") Button { saveItem(selectedExportableItem) } label: { - Label("Export", systemImage: "square.and.arrow.down") + Label("Export", systemImage: "arrow.down.circle") } .disabled(isPerformingItemAction) @@ -233,21 +145,6 @@ struct ContentView: View { .help("Source actions") } } - - ToolbarItem(placement: .secondaryAction) { - if let activeScanSummary = library.activeScanSummary { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - - Text(activeScanSummary) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .frame(maxWidth: 320, alignment: .trailing) - } - } } .onChange(of: filteredItems.map(\.id)) { _, filteredIDs in guard let selectedItem, !filteredIDs.contains(selectedItem.id) else { @@ -259,40 +156,29 @@ struct ContentView: View { .onChange(of: library.sources.map(\.id)) { _, sourceIDs in syncSelection(with: sourceIDs) } - .alert(item: $itemActionAlert) { alert in - Alert( - title: Text(alert.title), - message: Text(alert.message), - dismissButton: .default(Text("OK")) - ) - } } - private let directoryPreviewLimit = 12 - - private var filteredItems: [MinecraftContentItem] { + private var scopedItems: [MinecraftContentItem] { guard let selectedSidebarSelection else { return [] } - let scopedItems: [MinecraftContentItem] - switch selectedSidebarSelection { case .allContent(let sourceID): - scopedItems = library.source(withID: sourceID)?.items ?? [] + return library.source(withID: sourceID)?.items ?? [] case .contentType(let sourceID, let contentType): - scopedItems = library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? [] + return library.source(withID: sourceID)?.items.filter { $0.contentType == contentType } ?? [] } + } + private var filteredItems: [MinecraftContentItem] { 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) + item.searchText.localizedCaseInsensitiveContains(trimmedSearchText) } } @@ -318,19 +204,60 @@ struct ContentView: View { currentSelectedItem } - private var contentListTitle: String { + private var collectionHeaderTitle: String { guard let selectedSidebarSelection else { return "Minecraft Content" } switch selectedSidebarSelection { - case .allContent(let sourceID): - return library.source(withID: sourceID)?.displayName ?? "Minecraft Content" + case .allContent: + return "All Content" case .contentType(_, let contentType): return sidebarTitle(for: contentType) } } + private var collectionHeaderSubtitle: String { + let totalCount = scopedItems.count + let filteredCount = filteredItems.count + let noun = collectionCountNoun + + if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "\(totalCount.formatted(.number)) \(noun)" + } + + return "\(filteredCount.formatted(.number)) of \(totalCount.formatted(.number)) \(noun)" + } + + private var collectionCountNoun: String { + guard let selectedSidebarSelection else { + return "items" + } + + switch selectedSidebarSelection { + case .allContent: + return scopedItems.count == 1 ? "item" : "items" + case .contentType(_, let contentType): + switch contentType { + case .world: + return scopedItems.count == 1 ? "world" : "worlds" + case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: + return scopedItems.count == 1 ? "pack" : "packs" + } + } + } + + private var searchPrompt: String { + switch selectedSidebarSelection { + case .some(.allContent): + return "Search All Content" + case .some(.contentType(_, let contentType)): + return "Search \(sidebarTitle(for: contentType))" + case .none: + return "Search Content" + } + } + private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] { var filters = [ SidebarFilter( @@ -390,35 +317,13 @@ struct ContentView: View { } } - @ViewBuilder - private func detailSection(_ title: String, @ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.headline) - - content() - } - } - - @ViewBuilder - private func detailRow(title: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - - Text(value) - .textSelection(.enabled) - } - } - @ViewBuilder private func itemContextMenu(for item: MinecraftContentItem) -> some View { Button("Share...") { shareItem(item, from: nil) } - Button("Export .\(item.contentType.archiveExtension)") { + Button(exportMenuTitle(for: item)) { saveItem(item) } @@ -429,6 +334,43 @@ struct ContentView: View { } } + private func exportMenuTitle(for item: MinecraftContentItem) -> String { + switch item.contentType { + case .world: + return "Create Minecraft World File..." + case .behaviorPack, .resourcePack, .skinPack: + return "Create Minecraft Pack File..." + case .worldTemplate: + return "Create Minecraft Template File..." + } + } + + private func primaryActionTitle(for item: MinecraftContentItem) -> String { + switch item.contentType { + case .world: + return "Create Minecraft World File..." + case .behaviorPack, .resourcePack, .skinPack: + return "Create Minecraft Pack File..." + case .worldTemplate: + return "Create Minecraft Template File..." + } + } + + private func primaryActionSubtitle(for item: MinecraftContentItem) -> String { + switch item.contentType { + case .world: + return "Creates a .mcworld file that can be opened on another device to import this world into Minecraft." + case .behaviorPack, .resourcePack, .skinPack: + return "Creates a .mcpack file that can be shared or opened on another device." + case .worldTemplate: + return "Creates a .mctemplate file that can be opened on another device." + } + } + + private func packReferences(for item: MinecraftContentItem, type: MinecraftContentType) -> [ContentPackReference] { + item.packReferences.filter { $0.type == type } + } + private func directoryPreviewEntries(for item: MinecraftContentItem) -> [DirectoryPreviewEntry] { let fileManager = FileManager.default @@ -546,8 +488,8 @@ struct ContentView: View { let panel = NSSavePanel() panel.canCreateDirectories = true panel.isExtensionHidden = false - panel.title = "Export \(item.contentType.exportTitle)" - panel.message = "Choose where to save the .\(item.contentType.archiveExtension) file." + panel.title = primaryActionTitle(for: item) + panel.message = primaryActionSubtitle(for: item) panel.nameFieldStringValue = ContentPackageExporter.suggestedBaseFilename(for: item) panel.allowedContentTypes = [archiveType(for: item)] @@ -556,6 +498,7 @@ struct ContentView: View { } isPerformingItemAction = true + library.setItemActionInProgress("Creating \(item.contentType.archiveExtension) file...") Task { do { @@ -563,20 +506,20 @@ struct ContentView: View { try ContentPackageExporter.exportItem(item, to: destinationURL) }.value + let finalURL = ContentPackageExporter.finalArchiveURL(for: item, destinationURL: destinationURL) + await MainActor.run { isPerformingItemAction = false - itemActionAlert = ItemActionAlert( - title: "Export Complete", - message: "\"\(item.displayName)\" was exported as \(ContentPackageExporter.suggestedFilename(for: item))." + library.setItemActionSuccess( + title: "Created \(finalURL.lastPathComponent)", + subtitle: "Ready to move to another device", + revealURL: finalURL ) } } catch { await MainActor.run { isPerformingItemAction = false - itemActionAlert = ItemActionAlert( - title: "Export Failed", - message: error.localizedDescription - ) + library.setItemActionFailure(error.localizedDescription) } } } @@ -588,6 +531,7 @@ struct ContentView: View { } isPerformingItemAction = true + library.setItemActionInProgress("Preparing \(item.contentType.archiveExtension) file...") Task { do { @@ -600,24 +544,27 @@ struct ContentView: View { let presentationView = anchorView ?? NSApp.keyWindow?.contentView guard let presentationView else { - itemActionAlert = ItemActionAlert( - title: "Share Failed", - message: "Could not find a view to present the sharing menu." - ) + library.setItemActionFailure("Could not present the share menu.") return } + library.setItemActionSuccess( + title: "Share ready", + subtitle: shareURL.lastPathComponent, + revealURL: shareURL + ) + let picker = NSSharingServicePicker(items: [shareURL]) - let targetRect = anchorView?.bounds ?? presentationView.bounds.insetBy(dx: presentationView.bounds.width / 2, dy: presentationView.bounds.height / 2) + let targetRect = anchorView?.bounds ?? presentationView.bounds.insetBy( + dx: presentationView.bounds.width / 2, + dy: presentationView.bounds.height / 2 + ) picker.show(relativeTo: targetRect, of: presentationView, preferredEdge: .minY) } } catch { await MainActor.run { isPerformingItemAction = false - itemActionAlert = ItemActionAlert( - title: "Share Failed", - message: error.localizedDescription - ) + library.setItemActionFailure(error.localizedDescription) } } } @@ -627,6 +574,10 @@ struct ContentView: View { NSWorkspace.shared.activateFileViewerSelecting([item.folderURL]) } + private func revealURLInFinder(_ url: URL) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + private func archiveType(for item: MinecraftContentItem) -> UTType { UTType(filenameExtension: item.contentType.archiveExtension) ?? .data } @@ -671,10 +622,302 @@ private struct SidebarFilterRow: View { } } -private struct ItemActionAlert: Identifiable { - let id = UUID() +private struct SidebarFooterView: View { + let state: SidebarFooterState + let revealAction: (URL) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if state.style == .inProgress { + ProgressView() + .controlSize(.small) + } + + Text(state.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(primaryColor) + .lineLimit(2) + } + + if let subtitle = state.subtitle { + Text(subtitle) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + if let revealURL = state.revealURL { + Button("Reveal in Finder") { + revealAction(revealURL) + } + .buttonStyle(.link) + .font(.footnote) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(.bar) + } + + private var primaryColor: Color { + switch state.style { + case .idle, .inProgress: + return .primary + case .failure: + return .red + case .success: + return .minecraftAccent + } + } +} + +private struct ContentCollectionHeaderView: View { let title: String - let message: String + let subtitle: String + let prompt: String + @Binding var searchText: String + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title) + .font(.title2.weight(.semibold)) + + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + + TextField(prompt, text: $searchText) + .textFieldStyle(.roundedBorder) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.background) + } +} + +private struct ContentRowView: View { + let item: MinecraftContentItem + + var body: some View { + HStack(alignment: .center, spacing: 10) { + ItemThumbnailView(iconURL: item.iconURL) + + VStack(alignment: .leading, spacing: 4) { + Text(item.displayName) + .lineLimit(1) + + Text(metadataLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if !item.metadataLoaded { + ProgressView() + .controlSize(.small) + } + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + + private var metadataLine: String { + let sizeText = item.sizeBytes.map { + ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) + } ?? "Size unavailable" + let dateText = item.displayDate.map { + $0.formatted(date: .abbreviated, time: .omitted) + } ?? "Date unavailable" + + return "\(item.contentType.rawValue) • \(sizeText) • \(item.displayDateLabel) \(dateText)" + } +} + +private struct ItemDetailView: View { + let item: MinecraftContentItem + let behaviorPacks: [ContentPackReference] + let resourcePacks: [ContentPackReference] + let contents: [DirectoryPreviewEntry] + let directoryPreviewLimit: Int + let isPerformingItemAction: Bool + let primaryActionTitle: String + let primaryActionSubtitle: String + let primaryAction: () -> Void + let shareAction: (NSView) -> Void + let revealAction: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 16) { + LargeItemThumbnailView(iconURL: item.iconURL) + + VStack(alignment: .leading, spacing: 6) { + Text(item.displayName) + .font(.largeTitle.weight(.semibold)) + + Text(item.contentType.rawValue) + .font(.title3) + .foregroundStyle(.secondary) + } + + HStack(spacing: 18) { + metadataChip(title: "Size", value: sizeText) + metadataChip(title: item.displayDateLabel, value: displayDateText) + + if item.lastPlayedDate == nil, item.contentType == .world { + metadataChip(title: "Last Played", value: "Not available") + } + } + } + + VStack(alignment: .leading, spacing: 12) { + Text("Actions") + .font(.headline) + + Button(primaryActionTitle) { + primaryAction() + } + .buttonStyle(.borderedProminent) + .disabled(isPerformingItemAction) + + Text(primaryActionSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + SharingPickerButton( + title: "Share...", + systemImage: "square.and.arrow.up", + isEnabled: !isPerformingItemAction + ) { anchorView in + shareAction(anchorView) + } + + Button("Reveal in Finder") { + revealAction() + } + .disabled(isPerformingItemAction) + } + } + + if item.contentType == .world { + if !behaviorPacks.isEmpty || !resourcePacks.isEmpty { + VStack(alignment: .leading, spacing: 14) { + Text("Packs Used") + .font(.headline) + + if !behaviorPacks.isEmpty { + packSection(title: "Behavior Packs", packs: behaviorPacks) + } + + if !resourcePacks.isEmpty { + packSection(title: "Resource Packs", packs: resourcePacks) + } + } + } + } + + DisclosureGroup("Technical Details") { + VStack(alignment: .leading, spacing: 18) { + detailRow(title: "Folder ID", value: item.folderID) + detailRow(title: "Folder Path", value: item.folderURL.path) + detailRow(title: "Collection Root", value: item.collectionRootURL.path) + + VStack(alignment: .leading, spacing: 8) { + Text("Contents") + .font(.caption) + .foregroundStyle(.secondary) + + if contents.isEmpty { + Text("No visible files or folders") + .foregroundStyle(.secondary) + } else { + ForEach(contents) { entry in + HStack(spacing: 10) { + Image(systemName: entry.isDirectory ? "folder" : "doc") + .foregroundStyle(.secondary) + Text(entry.name) + .lineLimit(1) + Spacer() + } + } + + if contents.count == directoryPreviewLimit { + Text("Showing the first \(directoryPreviewLimit) items") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .padding(.top, 8) + } + } + .padding(24) + } + } + + @ViewBuilder + private func metadataChip(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.body.weight(.medium)) + } + } + + @ViewBuilder + private func packSection(title: String, packs: [ContentPackReference]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline.weight(.semibold)) + + ForEach(packs) { pack in + VStack(alignment: .leading, spacing: 2) { + Text(pack.name) + + if let secondary = packSecondaryText(pack), !secondary.isEmpty { + Text(secondary) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + + @ViewBuilder + private func detailRow(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + + Text(value) + .textSelection(.enabled) + } + } + + private var sizeText: String { + item.sizeBytes.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "Unknown" + } + + private var displayDateText: String { + item.displayDate.map { $0.formatted(date: .abbreviated, time: .omitted) } ?? "Unknown" + } + + private func packSecondaryText(_ pack: ContentPackReference) -> String? { + let components = [pack.version.map { "v\($0)" }, pack.uuid] + .compactMap { $0 } + return components.isEmpty ? nil : components.joined(separator: " • ") + } } private struct DirectoryPreviewEntry: Identifiable { @@ -739,12 +982,12 @@ private struct EmptySourcesView: View { ZStack { RoundedRectangle(cornerRadius: 24) .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [10, 10])) - .foregroundStyle(isDropTargeted ? Color.accentColor : Color.secondary.opacity(0.25)) + .foregroundStyle(isDropTargeted ? Color.minecraftAccent : 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) + .foregroundStyle(isDropTargeted ? Color.minecraftAccent : Color.secondary) } VStack(spacing: 8) { @@ -775,12 +1018,12 @@ private struct ItemThumbnailView: View { Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 36, height: 36) - .clipShape(RoundedRectangle(cornerRadius: 6)) + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 7)) } else { - RoundedRectangle(cornerRadius: 6) + RoundedRectangle(cornerRadius: 7) .fill(.quaternary) - .frame(width: 36, height: 36) + .frame(width: 40, height: 40) .overlay( Image(systemName: "shippingbox") .foregroundStyle(.secondary) @@ -797,12 +1040,12 @@ private struct LargeItemThumbnailView: View { Image(nsImage: image) .resizable() .aspectRatio(contentMode: .fit) - .frame(width: 128, height: 128) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 180, height: 180) + .clipShape(RoundedRectangle(cornerRadius: 16)) } else { - RoundedRectangle(cornerRadius: 12) + RoundedRectangle(cornerRadius: 16) .fill(.quaternary) - .frame(width: 128, height: 128) + .frame(width: 180, height: 180) .overlay( Image(systemName: "shippingbox") .font(.largeTitle) @@ -820,6 +1063,10 @@ private func loadImage(from url: URL?) -> NSImage? { return NSImage(contentsOf: url) } +private extension Color { + static let minecraftAccent = Color(red: 0.36, green: 0.63, blue: 0.24) +} + 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 index 5765bbd..c84675b 100644 --- a/World Manager for Minecraft/Models/MinecraftContentItem.swift +++ b/World Manager for Minecraft/Models/MinecraftContentItem.swift @@ -56,6 +56,40 @@ enum MinecraftContentType: String, CaseIterable, Hashable, Sendable { } } +enum PackSource: String, Hashable, Sendable { + case referencedByWorld + case embeddedInWorld + case foundInCollection +} + +struct ContentPackReference: Identifiable, Hashable, Sendable { + let id: String + let name: String + let type: MinecraftContentType + let uuid: String? + let version: String? + let source: PackSource + + nonisolated init( + name: String, + type: MinecraftContentType, + uuid: String? = nil, + version: String? = nil, + source: PackSource + ) { + self.type = type + self.uuid = uuid?.lowercased() + self.version = version + self.source = source + self.name = name + self.id = [ + type.rawValue, + self.uuid ?? name, + version ?? source.rawValue + ].joined(separator: "::") + } +} + struct MinecraftContentItem: Identifiable, Hashable, Sendable { let id: URL let folderURL: URL @@ -64,8 +98,10 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable { let collectionRootURL: URL var displayName: String var iconURL: URL? + var lastPlayedDate: Date? var modifiedDate: Date? var sizeBytes: Int64? + var packReferences: [ContentPackReference] var metadataLoaded: Bool nonisolated init( @@ -75,8 +111,10 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable { collectionRootURL: URL, displayName: String? = nil, iconURL: URL? = nil, + lastPlayedDate: Date? = nil, modifiedDate: Date? = nil, sizeBytes: Int64? = nil, + packReferences: [ContentPackReference] = [], metadataLoaded: Bool = false ) { self.id = folderURL.standardizedFileURL @@ -86,11 +124,40 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable { self.collectionRootURL = collectionRootURL self.displayName = displayName ?? folderName self.iconURL = iconURL + self.lastPlayedDate = lastPlayedDate self.modifiedDate = modifiedDate self.sizeBytes = sizeBytes + self.packReferences = packReferences self.metadataLoaded = metadataLoaded } + nonisolated var folderID: String { + folderName + } + + nonisolated var displayDate: Date? { + lastPlayedDate ?? modifiedDate + } + + nonisolated var displayDateLabel: String { + lastPlayedDate == nil ? "Modified" : "Last Played" + } + + nonisolated var searchText: String { + let values = [ + displayName, + folderName, + folderURL.path, + contentType.rawValue, + packReferences.map(\.name).joined(separator: " "), + packReferences.compactMap(\.uuid).joined(separator: " ") + ] + + return values + .filter { !$0.isEmpty } + .joined(separator: "\n") + } + nonisolated static func == (lhs: MinecraftContentItem, rhs: MinecraftContentItem) -> Bool { lhs.id == rhs.id } diff --git a/World Manager for Minecraft/Models/MinecraftSource.swift b/World Manager for Minecraft/Models/MinecraftSource.swift index 4f23322..61bb268 100644 --- a/World Manager for Minecraft/Models/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/MinecraftSource.swift @@ -15,6 +15,9 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { var isScanning: Bool var scanStatus: String var scanError: String? + var indexedItemCount: Int + var indexedDetailCount: Int + var lastScanDate: Date? init(folderURL: URL) { let normalizedURL = folderURL.standardizedFileURL @@ -25,6 +28,9 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { self.isScanning = false self.scanStatus = "" self.scanError = nil + self.indexedItemCount = 0 + self.indexedDetailCount = 0 + self.lastScanDate = nil } var itemCount: Int { diff --git a/World Manager for Minecraft/Services/ContentPackageExporter.swift b/World Manager for Minecraft/Services/ContentPackageExporter.swift index 0fad00a..dd2472e 100644 --- a/World Manager for Minecraft/Services/ContentPackageExporter.swift +++ b/World Manager for Minecraft/Services/ContentPackageExporter.swift @@ -21,7 +21,7 @@ enum ContentPackageExporter { nonisolated static func exportItem(_ item: MinecraftContentItem, to destinationURL: URL) throws { let fileManager = FileManager.default - let archiveURL = normalizedArchiveURL(for: item, destinationURL: destinationURL) + let archiveURL = finalArchiveURL(for: item, destinationURL: destinationURL) let temporaryArchiveURL = temporaryArchiveURL(for: item, fileManager: fileManager) defer { @@ -63,6 +63,10 @@ enum ContentPackageExporter { "\(suggestedBaseFilename(for: item)).\(item.contentType.archiveExtension)" } + nonisolated static func finalArchiveURL(for item: MinecraftContentItem, destinationURL: URL) -> URL { + normalizedArchiveURL(for: item, destinationURL: destinationURL) + } + nonisolated private static func createArchive(for item: MinecraftContentItem, at archiveURL: URL) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 523bf38..b95dee4 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -8,9 +8,29 @@ import Combine import Foundation +struct SidebarFooterState { + enum Style { + case idle + case inProgress + case failure + case success + } + + let style: Style + let title: String + let subtitle: String? + let revealURL: URL? +} + @MainActor final class SourceLibrary: ObservableObject { @Published var sources: [MinecraftSource] = [] + @Published private(set) var sidebarFooterState = SidebarFooterState( + style: .idle, + title: "Ready", + subtitle: nil, + revealURL: nil + ) private var scanTasks: [URL: Task] = [:] @@ -40,6 +60,34 @@ final class SourceLibrary: ObservableObject { scanTasks[sourceID]?.cancel() scanTasks[sourceID] = nil sources.removeAll { $0.id == sourceID } + refreshSidebarFooterState() + } + + func setItemActionInProgress(_ description: String) { + sidebarFooterState = SidebarFooterState( + style: .inProgress, + title: description, + subtitle: nil, + revealURL: nil + ) + } + + func setItemActionFailure(_ message: String) { + sidebarFooterState = SidebarFooterState( + style: .failure, + title: "Action Failed", + subtitle: message, + revealURL: nil + ) + } + + func setItemActionSuccess(title: String, subtitle: String, revealURL: URL?) { + sidebarFooterState = SidebarFooterState( + style: .success, + title: title, + subtitle: subtitle, + revealURL: revealURL + ) } var activeScanSummary: String? { @@ -75,7 +123,10 @@ final class SourceLibrary: ObservableObject { source.scanError = nil source.scanStatus = "Searching for Minecraft content..." source.items = [] + source.indexedItemCount = 0 + source.indexedDetailCount = 0 } + refreshSidebarFooterState() do { let discoveredItems = try await Task.detached(priority: .userInitiated) { @@ -88,10 +139,12 @@ final class SourceLibrary: ObservableObject { updateSource(sourceID) { source in source.items = discoveredItems + source.indexedItemCount = discoveredItems.count source.scanStatus = discoveredItems.isEmpty ? "No Minecraft content found." : "Found \(discoveredItems.count) items. Loading details..." } + refreshSidebarFooterState() var loadedCount = 0 @@ -114,22 +167,27 @@ final class SourceLibrary: ObservableObject { } source.items[index] = enrichedItem + source.indexedDetailCount = loadedCount source.items.sort(by: WorldScanner.sortItems) if loadedCount == discoveredItems.count { source.scanStatus = "Loaded \(loadedCount) 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.lastScanDate = Date() } + refreshSidebarFooterState() } } catch { guard !Task.isCancelled else { @@ -141,6 +199,7 @@ final class SourceLibrary: ObservableObject { source.scanStatus = "" source.isScanning = false } + refreshSidebarFooterState() } scanTasks[sourceID] = nil @@ -153,4 +212,49 @@ final class SourceLibrary: ObservableObject { mutate(&sources[index]) } + + private func refreshSidebarFooterState() { + let scanningSources = sources.filter(\.isScanning) + if let source = scanningSources.first { + let title = source.itemCount == 0 ? "Scanning worlds..." : "Scanning worlds..." + let subtitle: String + if source.indexedItemCount > 0 { + subtitle = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed" + } else { + subtitle = "Searching \(source.displayName)" + } + + sidebarFooterState = SidebarFooterState( + style: .inProgress, + title: title, + subtitle: subtitle, + revealURL: nil + ) + return + } + + if let source = sources.first(where: { $0.scanError != nil }) { + sidebarFooterState = SidebarFooterState( + style: .failure, + title: "Scan failed", + subtitle: source.scanError, + revealURL: nil + ) + return + } + + let totalItems = sources.reduce(0) { $0 + $1.itemCount } + let subtitle = totalItems == 0 ? "No content indexed" : "\(totalItems.formatted(.number)) items indexed" + let lastUpdatedDate = sources.compactMap(\.lastScanDate).max() + let secondaryText = lastUpdatedDate.map { + "\(subtitle) \u{2022} Last updated \($0.formatted(.relative(presentation: .named)))" + } ?? subtitle + + sidebarFooterState = SidebarFooterState( + style: .idle, + title: "Ready", + subtitle: secondaryText, + revealURL: nil + ) + } } diff --git a/World Manager for Minecraft/Services/WorldScanner.swift b/World Manager for Minecraft/Services/WorldScanner.swift index afc317c..a37a36f 100644 --- a/World Manager for Minecraft/Services/WorldScanner.swift +++ b/World Manager for Minecraft/Services/WorldScanner.swift @@ -63,8 +63,10 @@ enum WorldScanner { enrichedItem.displayName = displayName(for: item, fileManager: fileManager) enrichedItem.iconURL = iconURL(for: item, fileManager: fileManager) + enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager) enrichedItem.modifiedDate = modifiedDate(for: item.folderURL) enrichedItem.sizeBytes = folderSize(at: item.folderURL, fileManager: fileManager) + enrichedItem.packReferences = packReferences(for: item, fileManager: fileManager) enrichedItem.metadataLoaded = true return enrichedItem @@ -175,6 +177,18 @@ enum WorldScanner { return nil } + nonisolated private static func lastPlayedDate(for item: MinecraftContentItem, fileManager: FileManager) -> Date? { + guard item.contentType == .world else { + return nil + } + + // Bedrock's level.dat requires format-specific parsing to distinguish a true + // last-played timestamp from general save metadata. Until that is implemented + // reliably, prefer surfacing the filesystem modified date only. + _ = fileManager + return nil + } + nonisolated private static func modifiedDate(for directoryURL: URL) -> Date? { try? directoryURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate } @@ -204,4 +218,224 @@ enum WorldScanner { return totalSize } + + nonisolated private static func packReferences(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] { + switch item.contentType { + case .world: + var references = referencedWorldPacks(for: item, fileManager: fileManager) + references.append(contentsOf: embeddedWorldPacks(for: item, fileManager: fileManager)) + return uniquePackReferences(references) + case .behaviorPack, .resourcePack, .skinPack, .worldTemplate: + return [] + } + } + + nonisolated private static func referencedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] { + let behaviorReferences = packReferences( + fromWorldReferenceFileNamed: "world_behavior_packs.json", + type: .behaviorPack, + worldFolderURL: item.folderURL, + fileManager: fileManager + ) + let resourceReferences = packReferences( + fromWorldReferenceFileNamed: "world_resource_packs.json", + type: .resourcePack, + worldFolderURL: item.folderURL, + fileManager: fileManager + ) + + return behaviorReferences + resourceReferences + } + + nonisolated private static func embeddedWorldPacks(for item: MinecraftContentItem, fileManager: FileManager) -> [ContentPackReference] { + var references: [ContentPackReference] = [] + + references.append( + contentsOf: embeddedPackReferences( + in: item.folderURL.appendingPathComponent("behavior_packs", isDirectory: true), + type: .behaviorPack, + fileManager: fileManager + ) + ) + references.append( + contentsOf: embeddedPackReferences( + in: item.folderURL.appendingPathComponent("resource_packs", isDirectory: true), + type: .resourcePack, + fileManager: fileManager + ) + ) + + return references + } + + nonisolated private static func packReferences( + fromWorldReferenceFileNamed filename: String, + type: MinecraftContentType, + worldFolderURL: URL, + fileManager: FileManager + ) -> [ContentPackReference] { + let fileURL = worldFolderURL.appendingPathComponent(filename) + guard + fileManager.fileExists(atPath: fileURL.path), + let data = try? Data(contentsOf: fileURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { + return [] + } + + return jsonObject.compactMap { entry in + let uuid = (entry["pack_id"] as? String)?.lowercased() + let version = versionString(from: entry["version"]) + let resolvedName = uuid.flatMap { + resolvedPackName( + uuid: $0, + type: type, + worldCollectionRootURL: worldFolderURL.deletingLastPathComponent(), + fileManager: fileManager + ) + } + let fallbackName = resolvedName ?? uuid ?? "Referenced Pack" + return ContentPackReference( + name: fallbackName, + type: type, + uuid: uuid, + version: version, + source: .referencedByWorld + ) + } + } + + nonisolated private static func embeddedPackReferences( + in directoryURL: URL, + type: MinecraftContentType, + fileManager: FileManager + ) -> [ContentPackReference] { + guard + fileManager.fileExists(atPath: directoryURL.path), + let childDirectories = try? immediateChildDirectories(of: directoryURL, fileManager: fileManager) + else { + return [] + } + + return childDirectories.compactMap { childDirectory in + packReference( + fromPackFolder: childDirectory, + type: type, + source: .embeddedInWorld, + fileManager: fileManager + ) + } + } + + nonisolated private static func packReference( + fromPackFolder directoryURL: URL, + type: MinecraftContentType, + source: PackSource, + fileManager: FileManager + ) -> ContentPackReference? { + let manifestURL = directoryURL.appendingPathComponent("manifest.json") + guard + fileManager.fileExists(atPath: manifestURL.path), + let data = try? Data(contentsOf: manifestURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + + let header = jsonObject["header"] as? [String: Any] + let name = ((header?["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { + $0.isEmpty ? nil : $0 + } ?? directoryURL.lastPathComponent + let uuid = (header?["uuid"] as? String)?.lowercased() + let version = versionString(from: header?["version"]) + + return ContentPackReference( + name: name, + type: type, + uuid: uuid, + version: version, + source: source + ) + } + + nonisolated private static func resolvedPackName( + uuid: String, + type: MinecraftContentType, + worldCollectionRootURL: URL, + fileManager: FileManager + ) -> String? { + let siblingCollectionURL = worldCollectionRootURL + .deletingLastPathComponent() + .appendingPathComponent(type.collectionFolderName, isDirectory: true) + + guard + fileManager.fileExists(atPath: siblingCollectionURL.path), + let childDirectories = try? immediateChildDirectories(of: siblingCollectionURL, fileManager: fileManager) + else { + return nil + } + + for childDirectory in childDirectories { + guard + let reference = packReference( + fromPackFolder: childDirectory, + type: type, + source: .foundInCollection, + fileManager: fileManager + ), + reference.uuid == uuid + else { + continue + } + + return reference.name + } + + return nil + } + + nonisolated private static func versionString(from value: Any?) -> String? { + if let versionString = value as? String, !versionString.isEmpty { + return versionString + } + + if let versionArray = value as? [Any] { + let components = versionArray.compactMap { component -> String? in + if let intComponent = component as? Int { + return String(intComponent) + } + if let stringComponent = component as? String { + return stringComponent + } + return nil + } + + return components.isEmpty ? nil : components.joined(separator: ".") + } + + return nil + } + + nonisolated private static func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] { + var seen = Set() + var uniqueReferences: [ContentPackReference] = [] + + for reference in references { + let dedupeKey = [reference.type.rawValue, reference.uuid ?? reference.name, reference.version ?? ""] + .joined(separator: "::") + guard seen.insert(dedupeKey).inserted else { + continue + } + + uniqueReferences.append(reference) + } + + return uniqueReferences.sorted { lhs, rhs in + if lhs.type != rhs.type { + return lhs.type.rawValue.localizedStandardCompare(rhs.type.rawValue) == .orderedAscending + } + + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } }