second iteration: layout adjustments
This commit is contained in:
parent
dcfc25091b
commit
711ac54f00
@ -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<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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user