second iteration: layout adjustments

This commit is contained in:
John Burwell 2026-05-25 16:00:47 -05:00
parent dcfc25091b
commit 711ac54f00
6 changed files with 890 additions and 228 deletions

View File

@ -15,11 +15,13 @@ 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 {
VStack(spacing: 0) {
List(selection: $selectedSidebarSelection) {
ForEach(library.sources) { source in
Section(source.displayName) {
@ -32,6 +34,14 @@ struct ContentView: View {
}
.listStyle(.sidebar)
.navigationTitle("Sources")
Divider()
SidebarFooterView(
state: library.sidebarFooterState,
revealAction: revealURLInFinder(_:)
)
}
} content: {
if library.sources.isEmpty {
EmptySourcesView(
@ -40,150 +50,51 @@ struct ContentView: View {
)
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: handleDroppedProviders)
} else {
VStack(spacing: 0) {
ContentCollectionHeaderView(
title: collectionHeaderTitle,
subtitle: collectionHeaderSubtitle,
prompt: searchPrompt,
searchText: $searchText
)
Divider()
List(filteredItems, selection: $selectedItem) { item in
HStack(alignment: .top, spacing: 10) {
ItemThumbnailView(iconURL: item.iconURL)
VStack(alignment: .leading, spacing: 4) {
Text(item.displayName)
.lineLimit(1)
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())
ContentRowView(item: item)
.tag(item)
.contextMenu {
itemContextMenu(for: item)
}
}
.navigationTitle(contentListTitle)
.searchable(text: $searchText, placement: .toolbar, prompt: "Search Content")
.listStyle(.inset)
}
}
} 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)
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) }
)
}
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()
}
} 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<Content: View>(_ 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()

View File

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

View File

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

View File

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

View File

@ -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<Void, Never>] = [:]
@ -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
)
}
}

View File

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