Refine sidebar and detail loading behavior

This commit is contained in:
John Burwell 2026-05-30 00:32:53 -05:00
parent 5de6567924
commit 34e10a2075
16 changed files with 695 additions and 379 deletions

View File

@ -17,6 +17,7 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
var bookmarkData: Data? var bookmarkData: Data?
var displayName: String var displayName: String
var displayItems: [MinecraftContentItem] var displayItems: [MinecraftContentItem]
var displayItemCountsByType: [MinecraftContentType: Int]
var rawItems: [MinecraftContentItem] var rawItems: [MinecraftContentItem]
var logicalPacks: [LogicalPack] var logicalPacks: [LogicalPack]
var logicalWorlds: [LogicalWorld] var logicalWorlds: [LogicalWorld]
@ -61,6 +62,7 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
self.bookmarkData = bookmarkData self.bookmarkData = bookmarkData
self.displayName = normalizedFolderURL.lastPathComponent self.displayName = normalizedFolderURL.lastPathComponent
self.displayItems = [] self.displayItems = []
self.displayItemCountsByType = [:]
self.rawItems = [] self.rawItems = []
self.logicalPacks = [] self.logicalPacks = []
self.logicalWorlds = [] self.logicalWorlds = []
@ -99,6 +101,29 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
displayItems displayItems
} }
func items(for contentType: MinecraftContentType) -> [MinecraftContentItem] {
displayItems.filter { $0.contentType == contentType }
}
func items(matching selection: SidebarSelection?) -> [MinecraftContentItem] {
guard let selection else {
return []
}
switch selection {
case .source(let sourceID), .allContent(let sourceID):
guard sourceID == id else {
return []
}
return displayItems
case .contentType(let sourceID, let contentType):
guard sourceID == id else {
return []
}
return items(for: contentType)
}
}
func rawItem(withID itemID: URL) -> MinecraftContentItem? { func rawItem(withID itemID: URL) -> MinecraftContentItem? {
rawItems.first(where: { $0.id == itemID }) rawItems.first(where: { $0.id == itemID })
} }

View File

@ -117,12 +117,14 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
return return
} }
await SourcePersistenceCoordinator.persistVisibleSourcesForShutdown( let sourcesToPersist = visibleSources
from: visibleSources,
using: persistenceStore
)
shutdown() shutdown()
try? await Task.sleep(for: .seconds(timeout))
await SourcePersistenceCoordinator.persistVisibleSourcesForShutdown(
from: sourcesToPersist,
using: persistenceStore,
timeout: timeout
)
} }
func addSource(at url: URL) -> URL { func addSource(at url: URL) -> URL {
@ -303,6 +305,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
source.packInstances = index.packInstances source.packInstances = index.packInstances
source.worldPackRelationships = index.worldPackRelationships source.worldPackRelationships = index.worldPackRelationships
source.displayItems = index.displayItems source.displayItems = index.displayItems
source.displayItemCountsByType = index.displayItemCountsByType
} }
} }
@ -344,6 +347,7 @@ final class SourceLibrary: ObservableObject, SourceScanSessionHosting, SourcePer
func applySnapshot(_ snapshot: SourceIndexSnapshot, to sourceID: URL) { func applySnapshot(_ snapshot: SourceIndexSnapshot, to sourceID: URL) {
updateSource(sourceID) { source in updateSource(sourceID) { source in
source.displayItems = snapshot.displayItems source.displayItems = snapshot.displayItems
source.displayItemCountsByType = snapshot.displayItemCountsByType
source.rawItems = snapshot.rawItems source.rawItems = snapshot.rawItems
source.logicalPacks = snapshot.logicalPacks source.logicalPacks = snapshot.logicalPacks
source.logicalWorlds = snapshot.logicalWorlds source.logicalWorlds = snapshot.logicalWorlds

View File

@ -83,10 +83,30 @@ enum SourcePersistenceCoordinator {
static func persistVisibleSourcesForShutdown( static func persistVisibleSourcesForShutdown(
from sources: [MinecraftSource], from sources: [MinecraftSource],
using persistenceStore: SourcePersistenceStore using persistenceStore: SourcePersistenceStore,
timeout: TimeInterval
) async { ) async {
for source in sources { guard !sources.isEmpty else {
try? await persistenceStore.save(source: source) return
}
await withTaskGroup(of: Void.self) { group in
group.addTask {
for source in sources {
guard !Task.isCancelled else {
return
}
try? await persistenceStore.save(source: source)
}
}
group.addTask {
try? await Task.sleep(for: .seconds(timeout))
}
await group.next()
group.cancelAll()
} }
} }

View File

@ -57,6 +57,9 @@ enum SourceRestoration {
to source: inout MinecraftSource to source: inout MinecraftSource
) { ) {
source.rawItems = items source.rawItems = items
source.displayItemCountsByType = items.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
source.indexedItemCount = items.count source.indexedItemCount = items.count
source.indexedDetailCount = items.filter(\.metadataLoaded).count source.indexedDetailCount = items.filter(\.metadataLoaded).count
source.previewLoadedCount = items.filter(\.previewLoaded).count source.previewLoadedCount = items.filter(\.previewLoaded).count

View File

@ -14,6 +14,7 @@ struct SourceContentIndex {
let packInstances: [PackInstance] let packInstances: [PackInstance]
let worldPackRelationships: [WorldPackRelationship] let worldPackRelationships: [WorldPackRelationship]
let displayItems: [MinecraftContentItem] let displayItems: [MinecraftContentItem]
let displayItemCountsByType: [MinecraftContentType: Int]
} }
enum SourceContentIndexer { enum SourceContentIndexer {
@ -161,17 +162,20 @@ enum SourceContentIndexer {
$0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending
} }
let displayItems = buildDisplayItems(
from: rawItems,
logicalPacks: logicalPacks,
rawItemsByID: rawItemsByID
)
return SourceContentIndex( return SourceContentIndex(
rawItems: rawItems, rawItems: rawItems,
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
logicalWorlds: sortedLogicalWorlds, logicalWorlds: sortedLogicalWorlds,
packInstances: sortedPackInstances, packInstances: sortedPackInstances,
worldPackRelationships: worldRelationships, worldPackRelationships: worldRelationships,
displayItems: buildDisplayItems( displayItems: displayItems,
from: rawItems, displayItemCountsByType: displayItemCounts(for: displayItems)
logicalPacks: logicalPacks,
rawItemsByID: rawItemsByID
)
) )
} }
@ -213,6 +217,12 @@ enum SourceContentIndexer {
return normalizedItems return normalizedItems
} }
private static func displayItemCounts(for items: [MinecraftContentItem]) -> [MinecraftContentType: Int] {
items.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
}
private static func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool { private static func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool {
let candidateEmbedded = isEmbeddedWorldPack(candidate) let candidateEmbedded = isEmbeddedWorldPack(candidate)
let existingEmbedded = isEmbeddedWorldPack(existing) let existingEmbedded = isEmbeddedWorldPack(existing)

View File

@ -467,6 +467,7 @@ private actor EnrichmentWorkQueue {
struct SourceIndexSnapshot { struct SourceIndexSnapshot {
let displayItems: [MinecraftContentItem] let displayItems: [MinecraftContentItem]
let displayItemCountsByType: [MinecraftContentType: Int]
let rawItems: [MinecraftContentItem] let rawItems: [MinecraftContentItem]
let logicalPacks: [LogicalPack] let logicalPacks: [LogicalPack]
let logicalWorlds: [LogicalWorld] let logicalWorlds: [LogicalWorld]
@ -631,6 +632,9 @@ private actor SourceIndexActor {
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
rawItemsByID: rawItemsByID rawItemsByID: rawItemsByID
) )
let displayItemCountsByType = dedupedDisplayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount) let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount)
let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount) let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount)
let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount) let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount)
@ -651,6 +655,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot( return SourceIndexSnapshot(
displayItems: dedupedDisplayItems, displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
rawItems: rawItems, rawItems: rawItems,
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
logicalWorlds: [], logicalWorlds: [],
@ -678,6 +683,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot( return SourceIndexSnapshot(
displayItems: dedupedDisplayItems, displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
rawItems: rawItems, rawItems: rawItems,
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
logicalWorlds: [], logicalWorlds: [],
@ -709,6 +715,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot( return SourceIndexSnapshot(
displayItems: dedupedDisplayItems, displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
rawItems: rawItems, rawItems: rawItems,
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
logicalWorlds: [], logicalWorlds: [],
@ -820,6 +827,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot( return SourceIndexSnapshot(
displayItems: dedupedDisplayItems, displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
rawItems: rawItems, rawItems: rawItems,
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
logicalWorlds: logicalWorlds, logicalWorlds: logicalWorlds,

View File

@ -156,6 +156,7 @@ enum SourceScanPolicy {
enum SourceScanRecovery { enum SourceScanRecovery {
static func restoreIndexedState(from previousSource: MinecraftSource, into source: inout MinecraftSource) { static func restoreIndexedState(from previousSource: MinecraftSource, into source: inout MinecraftSource) {
source.displayItems = previousSource.displayItems source.displayItems = previousSource.displayItems
source.displayItemCountsByType = previousSource.displayItemCountsByType
source.rawItems = previousSource.rawItems source.rawItems = previousSource.rawItems
source.logicalPacks = previousSource.logicalPacks source.logicalPacks = previousSource.logicalPacks
source.logicalWorlds = previousSource.logicalWorlds source.logicalWorlds = previousSource.logicalWorlds

View File

@ -31,8 +31,8 @@ struct ConnectedDeviceSourceFactory: Sendable {
return source return source
} }
nonisolated func displayName(for device: ConnectedDevice, container: DeviceAppContainer) -> String { nonisolated func displayName(for device: ConnectedDevice, container _: DeviceAppContainer) -> String {
"\(device.name)\(container.appName)" device.name
} }
nonisolated func makeSourceIdentifier(device: ConnectedDevice, container: DeviceAppContainer) -> URL { nonisolated func makeSourceIdentifier(device: ConnectedDevice, container: DeviceAppContainer) -> URL {

View File

@ -135,7 +135,7 @@ struct ItemDetailView: View {
if !contents.isEmpty { if !contents.isEmpty {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Recent Folder Contents") Text("Recent Folder Contents")
.font(.subheadline.weight(.semibold)) .appTextStyle(.rowTitle)
ForEach(contents) { entry in ForEach(contents) { entry in
HStack(spacing: 10) { HStack(spacing: 10) {
@ -149,8 +149,7 @@ struct ItemDetailView: View {
if contents.count == directoryPreviewLimit { if contents.count == directoryPreviewLimit {
Text("Showing the first \(directoryPreviewLimit) items") Text("Showing the first \(directoryPreviewLimit) items")
.font(.caption) .appTextStyle(.fieldLabel)
.foregroundStyle(.secondary)
} }
} }
} }
@ -433,17 +432,13 @@ struct ItemDetailView: View {
@ViewBuilder content: () -> Content @ViewBuilder content: () -> Content
) -> some View { ) -> some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text(title.uppercased()) Text(title)
.font(.caption.weight(.semibold)) .appSectionTitleStyle(.section)
.foregroundStyle(.secondary)
.tracking(0.5)
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
content() content()
} }
.frame(maxWidth: .infinity, alignment: .leading) .appDetailSectionCard()
.padding(18)
.appCardSurface(.secondaryPanel)
} }
} }
@ -451,7 +446,7 @@ struct ItemDetailView: View {
private func packSection(title: String, packs: [ContentPackReference]) -> some View { private func packSection(title: String, packs: [ContentPackReference]) -> some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(title) Text(title)
.font(.subheadline.weight(.semibold)) .appTextStyle(.rowTitle)
ForEach(packs) { pack in ForEach(packs) { pack in
recordListRow( recordListRow(
@ -466,14 +461,15 @@ struct ItemDetailView: View {
@ViewBuilder @ViewBuilder
private func detailRow(title: String, value: String) -> some View { private func detailRow(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) { HStack(alignment: .top, spacing: 16) {
Text(title) Text(title)
.font(.caption) .appTextStyle(.emphasisLabel)
.foregroundStyle(.secondary) .frame(width: 170, alignment: .leading)
Text(value) Text(value)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled) .textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
} }
} }
@ -481,7 +477,7 @@ struct ItemDetailView: View {
private func detailValueRow(title: String, value: String) -> some View { private func detailValueRow(title: String, value: String) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 16) { HStack(alignment: .firstTextBaseline, spacing: 16) {
Text(title) Text(title)
.foregroundStyle(.secondary) .appTextStyle(.emphasisLabel)
Spacer() Spacer()
Text(value) Text(value)
.fontWeight(.medium) .fontWeight(.medium)
@ -538,8 +534,7 @@ struct ItemDetailView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(values, id: \.self) { value in ForEach(values, id: \.self) { value in
Label(value, systemImage: "checkmark.circle") Label(value, systemImage: "checkmark.circle")
.font(.subheadline) .appTextStyle(.supporting)
.foregroundStyle(.secondary)
} }
} }
} }
@ -586,8 +581,7 @@ struct ItemDetailView: View {
if let subtitle, !subtitle.isEmpty { if let subtitle, !subtitle.isEmpty {
Text(subtitle) Text(subtitle)
.font(.caption) .appTextStyle(.fieldLabel)
.foregroundStyle(.secondary)
.lineLimit(3) .lineLimit(3)
} }
} }

View File

@ -21,14 +21,8 @@ struct SourceDetailView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 8) { Text(source.displayName)
Text(source.displayName) .font(.largeTitle.weight(.semibold))
.font(.largeTitle.weight(.semibold))
Text(sourceSummary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
if showsStatusSection { if showsStatusSection {
sourceStatusSection sourceStatusSection
@ -47,15 +41,6 @@ struct SourceDetailView: View {
} }
} }
private var sourceSummary: String {
switch source.origin {
case .localFolder:
return "Local filesystem source"
case .connectedDevice(let device, let container):
return "\(device.name)\(container.appName)"
}
}
private var showsStatusSection: Bool { private var showsStatusSection: Bool {
if source.isScanning || source.scanError != nil || source.availability != .available { if source.isScanning || source.scanError != nil || source.availability != .available {
return true return true
@ -132,25 +117,24 @@ struct SourceDetailView: View {
private var sourceStatusSection: some View { private var sourceStatusSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Status") Text("Status")
.font(.headline) .appSectionTitleStyle(.section)
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 10) { HStack(alignment: .center, spacing: 10) {
if SourcePresentation.showsIndeterminateScanActivityIndicator(for: source) { if SourcePresentation.showsIndeterminateScanActivityIndicator(for: source) {
ProgressView() ProgressView()
.controlSize(.small) .appActivityIndicatorStyle(.small)
} else if !source.isScanning { } else if !source.isScanning {
sourceStatusIcon sourceStatusIcon
} }
Text(statusTitle) Text(statusTitle)
.font(.subheadline.weight(.semibold)) .appTextStyle(.rowTitle)
} }
if let statusDetail, !statusDetail.isEmpty { if let statusDetail, !statusDetail.isEmpty {
Text(statusDetail) Text(statusDetail)
.font(.subheadline) .appTextStyle(.supporting)
.foregroundStyle(.secondary)
} }
if source.isScanning { if source.isScanning {
@ -164,8 +148,7 @@ struct SourceDetailView: View {
} }
} }
} }
.padding(18) .appDetailSectionCard()
.appCardSurface(.primaryPanel)
} }
} }
@ -378,13 +361,12 @@ struct SourceDetailView: View {
.frame(width: 14) .frame(width: 14)
Text(stage.title) Text(stage.title)
.font(.subheadline.weight(.semibold)) .appTextStyle(.rowTitle)
Spacer() Spacer()
Text(stage.detail) Text(stage.detail)
.font(.caption) .appTextStyle(.fieldLabel)
.foregroundStyle(.secondary)
} }
if stage.status == .completed { if stage.status == .completed {
@ -392,7 +374,7 @@ struct SourceDetailView: View {
} else if stage.showsIndeterminateProgress { } else if stage.showsIndeterminateProgress {
ProgressView() ProgressView()
.progressViewStyle(.linear) .progressViewStyle(.linear)
.controlSize(.small) .appActivityIndicatorStyle(.small)
.tint(Color.appAccent) .tint(Color.appAccent)
} else if let progress = stage.progress { } else if let progress = stage.progress {
ProgressView(value: progress, total: 1) ProgressView(value: progress, total: 1)
@ -502,14 +484,13 @@ struct SourceDetailView: View {
private func sourceSection(title: String, rows: [(String, String)]) -> some View { private func sourceSection(title: String, rows: [(String, String)]) -> some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text(title) Text(title)
.font(.headline) .appSectionTitleStyle(.section)
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
HStack(alignment: .top, spacing: 16) { HStack(alignment: .top, spacing: 16) {
Text(row.0) Text(row.0)
.font(.subheadline.weight(.semibold)) .appTextStyle(.emphasisLabel)
.foregroundStyle(.secondary)
.frame(width: 170, alignment: .leading) .frame(width: 170, alignment: .leading)
Text(row.1) Text(row.1)
@ -518,8 +499,7 @@ struct SourceDetailView: View {
} }
} }
} }
.padding(18) .appDetailSectionCard()
.appCardSurface(.primaryPanel)
} }
} }
} }

View File

@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
enum ItemSortMode: String, CaseIterable, Identifiable { enum ItemSortMode: String, CaseIterable, Identifiable, Hashable, Sendable {
case name case name
case modifiedDate case modifiedDate
case size case size
@ -33,11 +33,11 @@ struct ItemListColumnView<MenuContent: View>: View {
let subtitle: String let subtitle: String
let showsSubtitle: Bool let showsSubtitle: Bool
let isRefreshing: Bool let isRefreshing: Bool
let isUpdatingProjection: Bool
let items: [MinecraftContentItem] let items: [MinecraftContentItem]
let searchPrompt: String let searchPrompt: String
let chooseFolderAction: () -> Void let chooseFolderAction: () -> Void
let dropAction: ([NSItemProvider]) -> Bool let dropAction: ([NSItemProvider]) -> Bool
let dragProvider: (MinecraftContentItem) -> NSItemProvider
let itemContextMenu: (MinecraftContentItem) -> MenuContent let itemContextMenu: (MinecraftContentItem) -> MenuContent
var body: some View { var body: some View {
@ -50,13 +50,18 @@ struct ItemListColumnView<MenuContent: View>: View {
.onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: dropAction) .onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted, perform: dropAction)
} else { } else {
List(items, selection: $selectedItemID) { item in List(items, selection: $selectedItemID) { item in
ContentRowView(item: item, dragProvider: dragProvider) ContentRowView(item: item)
.tag(item.id) .tag(item.id)
.contextMenu { .contextMenu {
itemContextMenu(item) itemContextMenu(item)
} }
} }
.listStyle(.inset) .listStyle(.inset)
.overlay {
if isUpdatingProjection && items.isEmpty {
ItemListLoadingOverlay()
}
}
} }
} }
.safeAreaInset(edge: .top, spacing: 0) { .safeAreaInset(edge: .top, spacing: 0) {
@ -67,7 +72,8 @@ struct ItemListColumnView<MenuContent: View>: View {
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
showsSubtitle: showsSubtitle, showsSubtitle: showsSubtitle,
isRefreshing: isRefreshing isRefreshing: isRefreshing,
isUpdatingProjection: isUpdatingProjection
) )
} }
} }
@ -100,13 +106,13 @@ private struct ItemListHeaderView: View {
let subtitle: String let subtitle: String
let showsSubtitle: Bool let showsSubtitle: Bool
let isRefreshing: Bool let isRefreshing: Bool
let isUpdatingProjection: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if showsSourceName { if showsSourceName {
Text(sourceName) Text(sourceName)
.font(.caption.weight(.semibold)) .appSectionTitleStyle(.overline)
.foregroundStyle(.secondary)
.textCase(.uppercase) .textCase(.uppercase)
} }
@ -115,47 +121,61 @@ private struct ItemListHeaderView: View {
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
.lineLimit(2) .lineLimit(2)
if isRefreshing { if isRefreshing || isUpdatingProjection {
ProgressView() ProgressView()
.controlSize(.small) .appActivityIndicatorStyle(.small)
} }
} }
if showsSubtitle { if showsSubtitle || isUpdatingProjection {
Text(subtitle) Text(displaySubtitle)
.font(.subheadline) .appTextStyle(.supporting)
.foregroundStyle(.secondary)
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 10) .padding(.top, 10)
.padding(.bottom, 12) .padding(.bottom, 12)
.background(.regularMaterial) .appListHeaderSurface()
.overlay(alignment: .bottom) { }
Divider()
private var displaySubtitle: String {
if isUpdatingProjection {
return "Loading items..."
} }
return subtitle
}
}
private struct ItemListLoadingOverlay: View {
var body: some View {
VStack(spacing: 10) {
ProgressView()
.appActivityIndicatorStyle(.small)
Text("Loading items...")
.appTextStyle(.supporting)
}
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
} }
} }
private struct ContentRowView: View { private struct ContentRowView: View {
let item: MinecraftContentItem let item: MinecraftContentItem
let dragProvider: (MinecraftContentItem) -> NSItemProvider
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 10) { HStack(alignment: .center, spacing: 10) {
ItemThumbnailView(iconURL: item.iconURL) ItemThumbnailView(iconURL: item.iconURL)
.onDrag {
dragProvider(item)
}
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(item.displayName) Text(item.displayName)
.lineLimit(1) .lineLimit(1)
Text(metadataLine) Text(metadataLine)
.font(.caption) .appTextStyle(.fieldLabel)
.foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
} }
@ -163,16 +183,8 @@ private struct ContentRowView: View {
if !item.metadataLoaded || !item.sizeLoaded { if !item.metadataLoaded || !item.sizeLoaded {
ProgressView() ProgressView()
.controlSize(.small) .appActivityIndicatorStyle(.small)
} }
Image(systemName: "square.and.arrow.up")
.font(.caption)
.foregroundStyle(.tertiary)
.help("Drag Out as Minecraft Package")
.onDrag {
dragProvider(item)
}
} }
.padding(.vertical, 2) .padding(.vertical, 2)
.contentShape(Rectangle()) .contentShape(Rectangle())

View File

@ -138,6 +138,9 @@ enum PreviewFixtures {
behaviorPackItem, behaviorPackItem,
resourcePackItem resourcePackItem
] ]
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
source.rawItems = source.displayItems source.rawItems = source.displayItems
source.logicalPacks = [ source.logicalPacks = [
LogicalPack( LogicalPack(
@ -220,6 +223,9 @@ enum PreviewFixtures {
var source = MinecraftSource(folderURL: sourceTwoURL) var source = MinecraftSource(folderURL: sourceTwoURL)
source.displayName = "Downloads" source.displayName = "Downloads"
source.displayItems = [secondLibraryPack] source.displayItems = [secondLibraryPack]
source.displayItemCountsByType = source.displayItems.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
source.rawItems = source.displayItems source.rawItems = source.displayItems
source.indexedItemCount = source.displayItems.count source.indexedItemCount = source.displayItems.count
source.indexedDetailCount = source.displayItems.count source.indexedDetailCount = source.displayItems.count
@ -317,11 +323,11 @@ struct ItemListColumnPreviewContainer: View {
subtitle: "5 items in Kid iPad Imports", subtitle: "5 items in Kid iPad Imports",
showsSubtitle: false, showsSubtitle: false,
isRefreshing: false, isRefreshing: false,
isUpdatingProjection: false,
items: PreviewFixtures.primarySource.displayItems, items: PreviewFixtures.primarySource.displayItems,
searchPrompt: "Search Worlds", searchPrompt: "Search Worlds",
chooseFolderAction: {}, chooseFolderAction: {},
dropAction: { _ in false }, dropAction: { _ in false },
dragProvider: { _ in NSItemProvider() },
itemContextMenu: { item in itemContextMenu: { item in
Button("Reveal \(item.displayName)") {} Button("Reveal \(item.displayName)") {}
} }

View File

@ -20,6 +20,14 @@ struct ContentView: View {
@State private var isShowingDeviceSourceSheet = false @State private var isShowingDeviceSourceSheet = false
@State private var sortMode: ItemSortMode = .name @State private var sortMode: ItemSortMode = .name
@State private var directoryPreviewContents: [DirectoryEntry] = [] @State private var directoryPreviewContents: [DirectoryEntry] = []
@State private var itemListProjection = ItemCollectionProjection.placeholder(
for: ItemCollectionProjectionRequest(
selection: nil,
searchText: "",
sortMode: .name,
source: nil
)
)
private let connectedDeviceAccess: AppleMobileDeviceSourceAccess private let connectedDeviceAccess: AppleMobileDeviceSourceAccess
private let deviceSourceFactory: ConnectedDeviceSourceFactory private let deviceSourceFactory: ConnectedDeviceSourceFactory
@ -37,11 +45,29 @@ struct ContentView: View {
} }
var body: some View { var body: some View {
let isEmptyLibrary = library.visibleSources.isEmpty && library.connectedDevices.isEmpty
let resolvedCurrentSource = currentSource
let currentProjectionRequest = ItemCollectionProjectionRequest(
selection: selectedSidebarSelection,
searchText: searchText,
sortMode: sortMode,
source: resolvedCurrentSource
)
let reusesCurrentProjectedItems =
itemListProjection.request.selection == currentProjectionRequest.selection &&
itemListProjection.request.source?.id == currentProjectionRequest.source?.id
let isUpdatingItemListProjection = itemListProjection.request != currentProjectionRequest
let resolvedItemListProjection = itemListProjection.request == currentProjectionRequest
? itemListProjection
: ItemCollectionProjection.placeholder(for: currentProjectionRequest)
let resolvedCurrentSelectedItem = currentSelectedItem(in: resolvedCurrentSource)
let resolvedDisplayedItems = reusesCurrentProjectedItems ? itemListProjection.items : []
NavigationSplitView(columnVisibility: $columnVisibility) { NavigationSplitView(columnVisibility: $columnVisibility) {
SourcesSidebarView( SourcesSidebarView(
sources: library.sidebarSources, sources: library.sidebarSources,
connectedDevices: library.connectedDevices, connectedDevices: library.connectedDevices,
selection: $selectedSidebarSelection, selection: sidebarSelectionBinding,
addSourceAction: pickFolder, addSourceAction: pickFolder,
addDeviceSourceAction: { isShowingDeviceSourceSheet = true }, addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
addConnectedDeviceAction: addConnectedDeviceSource(from:), addConnectedDeviceAction: addConnectedDeviceSource(from:),
@ -58,58 +84,58 @@ struct ContentView: View {
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380) .navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
} content: { } content: {
ItemListColumnView( ItemListColumnView(
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty, isEmpty: isEmptyLibrary,
isDropTargeted: $isDropTargeted, isDropTargeted: $isDropTargeted,
selectedItemID: $selectedItemID, selectedItemID: $selectedItemID,
searchText: $searchText, searchText: $searchText,
sortMode: $sortMode, sortMode: $sortMode,
showsHeader: shouldShowItemListHeader, showsHeader: shouldShowItemListHeader,
sourceName: currentSourceDisplayName, sourceName: resolvedItemListProjection.sourceName,
showsSourceName: !isSidebarVisible, showsSourceName: !isSidebarVisible,
title: collectionHeaderTitle, title: resolvedItemListProjection.title,
subtitle: collectionHeaderSubtitle, subtitle: resolvedItemListProjection.subtitle,
showsSubtitle: isSearching, showsSubtitle: isSearching || isUpdatingItemListProjection,
isRefreshing: currentSource?.isScanning == true, isRefreshing: resolvedCurrentSource?.isScanning == true,
items: displayedItems, isUpdatingProjection: isUpdatingItemListProjection,
searchPrompt: searchPrompt, items: resolvedDisplayedItems,
searchPrompt: resolvedItemListProjection.searchPrompt,
chooseFolderAction: pickFolder, chooseFolderAction: pickFolder,
dropAction: handleDroppedProviders(_:), dropAction: handleDroppedProviders(_:),
dragProvider: dragProvider(for:),
itemContextMenu: itemContextMenu(for:) itemContextMenu: itemContextMenu(for:)
) )
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460) .navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
} detail: { } detail: {
ItemDetailColumnView( ItemDetailColumnView(
item: currentSelectedItem, item: resolvedCurrentSelectedItem,
source: currentSource, source: resolvedCurrentSource,
showsSourceDetails: currentSelectedItem == nil && isSourceOverviewSelection, showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [], behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [], resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [], worldsUsingPack: resolvedCurrentSelectedItem.map(worldsUsingPack(for:)) ?? [],
backingPackInstances: currentSelectedItem.map(backingPackInstances(for:)) ?? [], backingPackInstances: resolvedCurrentSelectedItem.map(backingPackInstances(for:)) ?? [],
isSuspiciousPack: currentSelectedItem.map(isSuspiciousPack(_:)) ?? false, isSuspiciousPack: resolvedCurrentSelectedItem.map(isSuspiciousPack(_:)) ?? false,
contents: directoryPreviewContents, contents: directoryPreviewContents,
directoryPreviewLimit: directoryPreviewLimit, directoryPreviewLimit: directoryPreviewLimit,
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty, isEmpty: isEmptyLibrary,
isPerformingItemAction: isPerformingItemAction, isPerformingItemAction: isPerformingItemAction,
areFileActionsEnabled: areCurrentItemFileActionsEnabled, areFileActionsEnabled: areCurrentItemFileActionsEnabled,
exportTitle: currentSelectedItem.map(primaryActionTitle(for:)), exportTitle: resolvedCurrentSelectedItem.map(primaryActionTitle(for:)),
exportAction: { exportAction: {
guard let item = currentSelectedItem else { guard let item = resolvedCurrentSelectedItem else {
return return
} }
saveItem(item) saveItem(item)
}, },
revealAction: { revealAction: {
guard let item = currentSelectedItem else { guard let item = resolvedCurrentSelectedItem else {
return return
} }
revealInFinder(item) revealInFinder(item)
}, },
shareAction: { anchorView in shareAction: { anchorView in
guard let item = currentSelectedItem else { guard let item = resolvedCurrentSelectedItem else {
return return
} }
@ -119,7 +145,7 @@ struct ContentView: View {
.frame(minWidth: 450) .frame(minWidth: 450)
} }
.overlay { .overlay {
if library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty { if library.isRestoringPersistedSources && isEmptyLibrary {
LaunchRestoreOverlayView() LaunchRestoreOverlayView()
} }
} }
@ -138,60 +164,46 @@ struct ContentView: View {
.task { .task {
AppTerminationCoordinator.shared.register(library: library) AppTerminationCoordinator.shared.register(library: library)
} }
.disabled(library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty) .disabled(library.isRestoringPersistedSources && isEmptyLibrary)
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in .onChange(of: resolvedDisplayedItems.map(\.id)) { _, filteredIDs in
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else { guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
return return
} }
self.selectedItemID = nil self.selectedItemID = nil
} }
.onChange(of: selectedSidebarSelection) { _, selection in
guard let selection else {
return
}
if case .source = selection {
selectedItemID = nil
}
}
.onChange(of: library.sources.map(\.id)) { _, _ in .onChange(of: library.sources.map(\.id)) { _, _ in
syncSelection(with: library.visibleSources.map(\.id)) syncSelection(with: library.visibleSources.map(\.id))
} }
.onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in .onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
syncSelection(with: library.visibleSources.map(\.id)) syncSelection(with: library.visibleSources.map(\.id))
} }
.task(id: currentSelectedItem?.id) { .task(id: currentProjectionRequest) {
let request = currentProjectionRequest
let projection = await Task.detached(priority: .userInitiated) {
ItemCollectionProjector.makeProjection(for: request)
}.value
guard !Task.isCancelled else {
return
}
itemListProjection = projection
}
.task(id: resolvedCurrentSelectedItem?.id) {
await refreshDirectoryPreviewContents() await refreshDirectoryPreviewContents()
} }
} }
private var scopedItems: [MinecraftContentItem] { private var sidebarSelectionBinding: Binding<SidebarSelection?> {
guard let selectedSidebarSelection else { Binding(
return [] get: { selectedSidebarSelection },
} set: { newSelection in
if newSelection != selectedSidebarSelection {
selectedItemID = nil
}
switch selectedSidebarSelection { selectedSidebarSelection = newSelection
case .source(let sourceID), .allContent(let sourceID): }
return library.source(withID: sourceID)?.items ?? [] )
case .contentType(let sourceID, let 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.searchText.localizedCaseInsensitiveContains(trimmedSearchText)
}
}
private var displayedItems: [MinecraftContentItem] {
filteredItems.sorted(by: sortComparator)
} }
private var currentSource: MinecraftSource? { private var currentSource: MinecraftSource? {
@ -202,14 +214,23 @@ struct ContentView: View {
return library.source(withID: sourceID) return library.source(withID: sourceID)
} }
private var currentSelectedItem: MinecraftContentItem? { private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
guard let selectedItemID else { guard let selectedItemID else {
return nil return nil
} }
return library.visibleSources if let source,
.flatMap(\.items) let item = source.items.first(where: { $0.id == selectedItemID }) {
.first(where: { $0.id == selectedItemID }) return item
}
for source in library.visibleSources {
if let item = source.items.first(where: { $0.id == selectedItemID }) {
return item
}
}
return nil
} }
private var sortComparator: (MinecraftContentItem, MinecraftContentItem) -> Bool { private var sortComparator: (MinecraftContentItem, MinecraftContentItem) -> Bool {
@ -255,84 +276,14 @@ struct ContentView: View {
} }
} }
private var collectionHeaderTitle: String {
if isSearching {
return "Searching “\(searchScopeTitle)"
}
guard let selectedSidebarSelection else {
return "Library"
}
switch selectedSidebarSelection {
case .source, .allContent:
return "All Items"
case .contentType(_, let contentType):
return sidebarTitle(for: contentType)
}
}
private var collectionHeaderSubtitle: String {
let totalCount = scopedItems.count
let filteredCount = filteredItems.count
let noun = collectionCountNoun
if !isSearching {
return "\(totalCount.formatted(.number)) \(noun)"
}
return "\(filteredCount.formatted(.number)) of \(totalCount.formatted(.number)) \(noun)"
}
private var currentSourceDisplayName: String {
currentSource?.displayName ?? "Library"
}
private var currentCollectionStatus: String? {
guard let currentSource else {
return nil
}
if currentSource.isScanning {
return currentSource.scanStatus
}
if let scanError = currentSource.scanError, !scanError.isEmpty {
return scanError
}
if !currentSource.scanStatus.isEmpty {
return currentSource.scanStatus
}
if let lastScanDate = currentSource.lastScanDate {
return "Last scanned \(lastScanDate.formatted(date: .abbreviated, time: .shortened))"
}
return nil
}
private var areCurrentItemFileActionsEnabled: Bool { private var areCurrentItemFileActionsEnabled: Bool {
guard currentSelectedItem != nil else { guard currentSelectedItem(in: currentSource) != nil else {
return false return false
} }
return currentSource?.availability == .available return currentSource?.availability == .available
} }
private var searchScopeTitle: String {
switch selectedSidebarSelection {
case .some(.source(let sourceID)):
return library.source(withID: sourceID)?.displayName ?? "Library"
case .some(.allContent):
return "All"
case .some(.contentType(_, let contentType)):
return sidebarTitle(for: contentType)
case .none:
return "Library"
}
}
private var isSearching: Bool { private var isSearching: Bool {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
@ -345,42 +296,9 @@ struct ContentView: View {
isSearching || !isSidebarVisible isSearching || !isSidebarVisible
} }
private var collectionCountNoun: String {
guard let selectedSidebarSelection else {
return "items"
}
switch selectedSidebarSelection {
case .source, .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(.source(let sourceID)):
let sourceName = library.source(withID: sourceID)?.displayName ?? "Library"
return "Search \(sourceName)"
case .some(.allContent):
return "Search All Items"
case .some(.contentType(_, let contentType)):
return "Search \(sidebarTitle(for: contentType))"
case .none:
return "Search Library"
}
}
private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] { private func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
MinecraftContentType.allCases.compactMap { contentType in return MinecraftContentType.allCases.compactMap { contentType in
let count = source.items.filter { $0.contentType == contentType }.count guard let count = source.displayItemCountsByType[contentType], count > 0 else {
guard count > 0 else {
return nil return nil
} }
@ -539,7 +457,8 @@ struct ContentView: View {
} }
private func refreshDirectoryPreviewContents() async { private func refreshDirectoryPreviewContents() async {
guard let item = currentSelectedItem, let source = currentSource else { guard let source = currentSource,
let item = currentSelectedItem(in: source) else {
await MainActor.run { await MainActor.run {
directoryPreviewContents = [] directoryPreviewContents = []
} }
@ -615,7 +534,7 @@ struct ContentView: View {
selectedSidebarSelection = fallbackSourceID.map { .source(sourceID: $0) } selectedSidebarSelection = fallbackSourceID.map { .source(sourceID: $0) }
} }
if let selectedItemID, currentSelectedItem?.id != selectedItemID { if let selectedItemID, currentSelectedItem(in: currentSource)?.id != selectedItemID {
self.selectedItemID = nil self.selectedItemID = nil
} }
} }

View File

@ -0,0 +1,189 @@
import Foundation
struct ItemCollectionProjectionRequest: Hashable, Sendable {
let selection: SidebarSelection?
let searchText: String
let sortMode: ItemSortMode
let source: MinecraftSource?
}
struct ItemCollectionProjection: Sendable {
let request: ItemCollectionProjectionRequest
let sourceName: String
let title: String
let subtitle: String
let searchPrompt: String
let items: [MinecraftContentItem]
nonisolated static func placeholder(for request: ItemCollectionProjectionRequest) -> ItemCollectionProjection {
ItemCollectionProjection(
request: request,
sourceName: request.source?.displayName ?? "Library",
title: ItemCollectionProjector.title(
for: request.selection,
isSearching: !ItemCollectionProjector.trimmedSearchText(for: request).isEmpty
),
subtitle: "",
searchPrompt: ItemCollectionProjector.searchPrompt(for: request.selection, source: request.source),
items: []
)
}
}
enum ItemCollectionProjector {
nonisolated static func makeProjection(for request: ItemCollectionProjectionRequest) -> ItemCollectionProjection {
let trimmedSearchText = trimmedSearchText(for: request)
let scopedItems = request.source?.items(matching: request.selection) ?? []
let filteredItems: [MinecraftContentItem]
if trimmedSearchText.isEmpty {
filteredItems = scopedItems
} else {
filteredItems = scopedItems.filter { item in
item.searchText.localizedCaseInsensitiveContains(trimmedSearchText)
}
}
let displayedItems = filteredItems.sorted(by: sortComparator(for: request.sortMode))
let countNoun = collectionCountNoun(for: request.selection, scopedItemCount: scopedItems.count)
let subtitle: String
if trimmedSearchText.isEmpty {
subtitle = "\(scopedItems.count.formatted(.number)) \(countNoun)"
} else {
subtitle = "\(displayedItems.count.formatted(.number)) of \(scopedItems.count.formatted(.number)) \(countNoun)"
}
return ItemCollectionProjection(
request: request,
sourceName: request.source?.displayName ?? "Library",
title: title(for: request.selection, isSearching: !trimmedSearchText.isEmpty),
subtitle: subtitle,
searchPrompt: searchPrompt(for: request.selection, source: request.source),
items: displayedItems
)
}
nonisolated static func title(for selection: SidebarSelection?, isSearching: Bool) -> String {
if isSearching {
return "Searching “\(searchScopeTitle(for: selection))"
}
guard let selection else {
return "Library"
}
switch selection {
case .source, .allContent:
return "All Items"
case .contentType(_, let contentType):
return sidebarTitle(for: contentType)
}
}
nonisolated static func searchPrompt(for selection: SidebarSelection?, source: MinecraftSource?) -> String {
switch selection {
case .some(.source):
return "Search \(source?.displayName ?? "Library")"
case .some(.allContent):
return "Search All Items"
case .some(.contentType(_, let contentType)):
return "Search \(sidebarTitle(for: contentType))"
case .none:
return "Search Library"
}
}
nonisolated private static func searchScopeTitle(for selection: SidebarSelection?) -> String {
switch selection {
case .some(.source):
return "Library"
case .some(.allContent):
return "All"
case .some(.contentType(_, let contentType)):
return sidebarTitle(for: contentType)
case .none:
return "Library"
}
}
nonisolated private static func collectionCountNoun(for selection: SidebarSelection?, scopedItemCount: Int) -> String {
guard let selection else {
return "items"
}
switch selection {
case .source, .allContent:
return scopedItemCount == 1 ? "item" : "items"
case .contentType(_, let contentType):
switch contentType {
case .world:
return scopedItemCount == 1 ? "world" : "worlds"
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
return scopedItemCount == 1 ? "pack" : "packs"
}
}
}
nonisolated private static func sidebarTitle(for contentType: MinecraftContentType) -> String {
switch contentType {
case .world:
return "Worlds"
case .behaviorPack:
return "Behavior Packs"
case .resourcePack:
return "Resource Packs"
case .skinPack:
return "Skin Packs"
case .worldTemplate:
return "World Templates"
}
}
nonisolated static func trimmedSearchText(for request: ItemCollectionProjectionRequest) -> String {
request.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
}
nonisolated private static func sortComparator(for mode: ItemSortMode) -> (MinecraftContentItem, MinecraftContentItem) -> Bool {
switch mode {
case .name:
return { lhs, rhs in
lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
}
case .modifiedDate:
return { lhs, rhs in
switch (lhs.displayDate, rhs.displayDate) {
case let (lhsDate?, rhsDate?):
if lhsDate != rhsDate {
return lhsDate > rhsDate
}
case (.some, nil):
return true
case (nil, .some):
return false
case (nil, nil):
break
}
return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
}
case .size:
return { lhs, rhs in
switch (lhs.sizeBytes, rhs.sizeBytes) {
case let (lhsSize?, rhsSize?):
if lhsSize != rhsSize {
return lhsSize > rhsSize
}
case (.some, nil):
return true
case (nil, .some):
return false
case (nil, nil):
break
}
return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending
}
}
}
}

View File

@ -2,19 +2,18 @@ import AppKit
import SwiftUI import SwiftUI
enum AppChrome { enum AppChrome {
static let sidebarRowCornerRadius: CGFloat = 10
static let placeholderCardCornerRadius: CGFloat = 16 static let placeholderCardCornerRadius: CGFloat = 16
static let panelCardCornerRadius: CGFloat = 18 static let panelCardCornerRadius: CGFloat = 18
static let detailSectionCardPadding: CGFloat = 18
} }
enum AppCardStyle { enum AppCardStyle {
case primaryPanel case detailPanel
case secondaryPanel
case placeholder case placeholder
fileprivate var cornerRadius: CGFloat { fileprivate var cornerRadius: CGFloat {
switch self { switch self {
case .primaryPanel, .secondaryPanel: case .detailPanel:
return AppChrome.panelCardCornerRadius return AppChrome.panelCardCornerRadius
case .placeholder: case .placeholder:
return AppChrome.placeholderCardCornerRadius return AppChrome.placeholderCardCornerRadius
@ -23,9 +22,7 @@ enum AppCardStyle {
fileprivate var fillStyle: AnyShapeStyle { fileprivate var fillStyle: AnyShapeStyle {
switch self { switch self {
case .primaryPanel: case .detailPanel:
return AnyShapeStyle(.regularMaterial)
case .secondaryPanel:
return AnyShapeStyle(.quaternary.opacity(0.32)) return AnyShapeStyle(.quaternary.opacity(0.32))
case .placeholder: case .placeholder:
return AnyShapeStyle(.thinMaterial) return AnyShapeStyle(.thinMaterial)
@ -44,19 +41,213 @@ private struct AppCardSurfaceModifier: ViewModifier {
} }
} }
enum AppSectionTitleStyle {
case section
case overline
}
enum AppTextStyle {
case rowTitle
case supporting
case supportingCompact
case fieldLabel
case emphasisLabel
}
enum AppActivityIndicatorStyle {
case small
case large
}
private struct AppSectionTitleModifier: ViewModifier {
let style: AppSectionTitleStyle
func body(content: Content) -> some View {
switch style {
case .section:
content
.font(.headline)
case .overline:
content
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.tracking(0.5)
}
}
}
private struct AppTextStyleModifier: ViewModifier {
let style: AppTextStyle
func body(content: Content) -> some View {
switch style {
case .rowTitle:
content
.font(.subheadline.weight(.semibold))
case .supporting:
content
.font(.subheadline)
.foregroundStyle(.secondary)
case .supportingCompact:
content
.font(.footnote)
.foregroundStyle(.secondary)
case .fieldLabel:
content
.font(.caption)
.foregroundStyle(.secondary)
case .emphasisLabel:
content
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
}
}
}
private struct AppDetailSectionCardModifier: ViewModifier {
func body(content: Content) -> some View {
content
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppChrome.detailSectionCardPadding)
.appCardSurface(.detailPanel)
}
}
private struct AppActivityIndicatorModifier: ViewModifier {
let style: AppActivityIndicatorStyle
func body(content: Content) -> some View {
switch style {
case .small:
content.controlSize(.small)
case .large:
content.controlSize(.large)
}
}
}
private struct AppListHeaderSurfaceModifier: ViewModifier {
func body(content: Content) -> some View {
content
.background(.regularMaterial)
.overlay(alignment: .bottom) {
Divider()
}
}
}
private struct AppMiniProminentButtonModifier: ViewModifier {
func body(content: Content) -> some View {
content
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
private struct AppTransportBadgeBubbleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.primary)
.padding(4)
.background(.thinMaterial, in: Circle())
}
}
enum AppCapsuleLabelStyle {
case sidebarSubtle
case sidebarAccent
case heroMetadata
}
private struct AppCapsuleLabelModifier: ViewModifier {
let style: AppCapsuleLabelStyle
func body(content: Content) -> some View {
content
.font(.caption.weight(.semibold))
.foregroundStyle(foregroundStyle)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.background(backgroundStyle, in: Capsule())
}
private var foregroundStyle: AnyShapeStyle {
switch style {
case .sidebarSubtle:
return AnyShapeStyle(.secondary)
case .sidebarAccent:
return AnyShapeStyle(Color.appAccent)
case .heroMetadata:
return AnyShapeStyle(.white.opacity(0.95))
}
}
private var backgroundStyle: AnyShapeStyle {
switch style {
case .sidebarSubtle:
return AnyShapeStyle(.secondary.opacity(0.12))
case .sidebarAccent:
return AnyShapeStyle(Color.appAccent.opacity(0.14))
case .heroMetadata:
return AnyShapeStyle(.white.opacity(0.14))
}
}
private var horizontalPadding: CGFloat {
switch style {
case .heroMetadata:
return 10
case .sidebarSubtle, .sidebarAccent:
return 7
}
}
private var verticalPadding: CGFloat {
switch style {
case .heroMetadata:
return 7
case .sidebarSubtle, .sidebarAccent:
return 4
}
}
}
extension View { extension View {
func appCardSurface(_ style: AppCardStyle) -> some View { func appCardSurface(_ style: AppCardStyle) -> some View {
modifier(AppCardSurfaceModifier(style: style)) modifier(AppCardSurfaceModifier(style: style))
} }
func appSidebarRowSurface(isHighlighted: Bool) -> some View { func appSectionTitleStyle(_ style: AppSectionTitleStyle) -> some View {
let fillStyle = isHighlighted modifier(AppSectionTitleModifier(style: style))
? AnyShapeStyle(.secondary.opacity(0.08)) }
: AnyShapeStyle(Color.clear)
return background( func appTextStyle(_ style: AppTextStyle) -> some View {
fillStyle, modifier(AppTextStyleModifier(style: style))
in: RoundedRectangle(cornerRadius: AppChrome.sidebarRowCornerRadius, style: .continuous) }
)
func appActivityIndicatorStyle(_ style: AppActivityIndicatorStyle) -> some View {
modifier(AppActivityIndicatorModifier(style: style))
}
func appDetailSectionCard() -> some View {
modifier(AppDetailSectionCardModifier())
}
func appCapsuleLabelStyle(_ style: AppCapsuleLabelStyle) -> some View {
modifier(AppCapsuleLabelModifier(style: style))
}
func appListHeaderSurface() -> some View {
modifier(AppListHeaderSurfaceModifier())
}
func appMiniProminentButton() -> some View {
modifier(AppMiniProminentButtonModifier())
}
func appTransportBadgeBubble() -> some View {
modifier(AppTransportBadgeBubbleModifier())
} }
} }
@ -297,11 +488,7 @@ struct RecordHeroView: View {
private var recordHeroChips: some View { private var recordHeroChips: some View {
FlexibleTagLayout(spacing: 8, rowSpacing: 8, items: metadataChips) { chip in FlexibleTagLayout(spacing: 8, rowSpacing: 8, items: metadataChips) { chip in
Text(chip) Text(chip)
.font(.caption.weight(.semibold)) .appCapsuleLabelStyle(.heroMetadata)
.foregroundStyle(.white.opacity(0.95))
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(.white.opacity(0.14), in: Capsule())
} }
} }
@ -391,7 +578,7 @@ struct LaunchRestoreOverlayView: View {
VStack(spacing: 14) { VStack(spacing: 14) {
ProgressView() ProgressView()
.controlSize(.large) .appActivityIndicatorStyle(.large)
Text("Opening Library…") Text("Opening Library…")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))

View File

@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
enum SidebarSelection: Hashable { enum SidebarSelection: Hashable, Sendable {
case source(sourceID: URL) case source(sourceID: URL)
case allContent(sourceID: URL) case allContent(sourceID: URL)
case contentType(sourceID: URL, contentType: MinecraftContentType) case contentType(sourceID: URL, contentType: MinecraftContentType)
@ -78,14 +78,13 @@ struct SourcesSidebarView: View {
SourceHeaderRow( SourceHeaderRow(
source: source, source: source,
isSelected: selection == .source(sourceID: source.id),
onSelect: { onSelect: {
selection = .source(sourceID: source.id) selection = .source(sourceID: source.id)
} }
) )
.tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?) .tag(SidebarSelection.source(sourceID: source.id) as SidebarSelection?)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 8)) .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 0, trailing: 0))
.contextMenu { .contextMenu {
Button("Rescan \"\(source.displayName)\"") { Button("Rescan \"\(source.displayName)\"") {
rescanSourceAction(source) rescanSourceAction(source)
@ -143,54 +142,49 @@ private struct SidebarSourcesSectionHeaderView: View {
var body: some View { var body: some View {
Text(title) Text(title)
.font(.headline)
.foregroundStyle(.secondary)
.textCase(nil)
} }
} }
private struct SourceHeaderRow: View { private struct SourceHeaderRow: View {
let source: MinecraftSource let source: MinecraftSource
let isSelected: Bool
let onSelect: () -> Void let onSelect: () -> Void
@State private var isHovering = false
var body: some View { var body: some View {
Button(action: onSelect) { HStack(spacing: 8) {
Image(systemName: headerSymbolName)
.foregroundStyle(.secondary)
Text(source.displayName)
.lineLimit(1)
Spacer(minLength: 8)
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: headerSymbolName) if let availabilityBadgeText {
.font(.system(size: 14, weight: .semibold)) SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis)
.foregroundStyle(titleColor) }
Text(source.displayName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(titleColor)
Spacer(minLength: 8)
if let connection { if let connection {
SourceConnectionBadge(connection: connection) SourceConnectionBadge(connection: connection)
} }
if let availabilityBadgeText { if showsStatusAccessory {
SourceAvailabilityBadge(text: availabilityBadgeText, emphasis: availabilityBadgeEmphasis) statusAccessory
}
if showsStatusIndicator {
statusIndicator
.frame(width: 24, height: 24)
} }
} }
} }
.buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10) .padding(.leading, 5)
.padding(.vertical, 8) .padding(.vertical, 6)
.appSidebarRowSurface(isHighlighted: isHovering && !isSelected)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onHover { isHovering = $0 } .onTapGesture(perform: onSelect)
} }
private var connection: DeviceConnection? { private var connection: DeviceConnection? {
guard source.availability == .available else {
return nil
}
guard case .connectedDevice(let device, _) = source.origin else { guard case .connectedDevice(let device, _) = source.origin else {
return nil return nil
} }
@ -207,14 +201,6 @@ private struct SourceHeaderRow: View {
} }
} }
private var titleColor: Color {
if source.availability != .available && !isSelected {
return .secondary
}
return isSelected ? Color.primary : .secondary
}
private var availabilityBadgeText: String? { private var availabilityBadgeText: String? {
if source.isOfflineCached { if source.isOfflineCached {
return "Cached" return "Cached"
@ -233,33 +219,22 @@ private struct SourceHeaderRow: View {
private var availabilityBadgeEmphasis: Bool { private var availabilityBadgeEmphasis: Bool {
source.availability == .limited source.availability == .limited
} }
@ViewBuilder
private var statusIndicator: some View { private var showsStatusAccessory: Bool {
if source.isScanning { source.isScanning
if let scanProgress = source.scanProgress {
CircularScanProgressView(progress: scanProgress)
} else {
ProgressView()
.controlSize(.small)
}
} else if source.availability == .limited {
Image(systemName: "lock.circle")
.foregroundStyle(.secondary)
} else if source.availability != .available {
Image(systemName: source.isOfflineCached ? "externaldrive.badge.exclamationmark" : "slash.circle")
.foregroundStyle(.secondary)
} else if source.scanError != nil {
Image(systemName: "exclamationmark.circle")
.foregroundStyle(.secondary)
} else {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
}
} }
private var showsStatusIndicator: Bool { @ViewBuilder
source.isScanning || source.scanError != nil || source.availability != .available private var statusAccessory: some View {
} if source.isScanning {
if let scanProgress = source.scanProgress {
CircularScanProgressView(progress: scanProgress)
} else {
ProgressView()
.appActivityIndicatorStyle(.small)
}
}
}
} }
private struct SourceConnectionBadge: View { private struct SourceConnectionBadge: View {
@ -267,11 +242,7 @@ private struct SourceConnectionBadge: View {
var body: some View { var body: some View {
Image(systemName: symbolName) Image(systemName: symbolName)
.font(.caption.weight(.semibold)) .appCapsuleLabelStyle(.sidebarSubtle)
.foregroundStyle(.secondary)
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(.secondary.opacity(0.12), in: Capsule())
.help(helpText) .help(helpText)
.accessibilityLabel(helpText) .accessibilityLabel(helpText)
} }
@ -301,15 +272,7 @@ private struct SourceAvailabilityBadge: View {
var body: some View { var body: some View {
Text(text) Text(text)
.font(.caption.weight(.semibold)) .appCapsuleLabelStyle(emphasis ? .sidebarAccent : .sidebarSubtle)
.foregroundStyle(emphasis ? Color.appAccent : .secondary)
.padding(.horizontal, 7)
.padding(.vertical, 4)
.background(backgroundColor, in: Capsule())
}
private var backgroundColor: Color {
emphasis ? Color.appAccent.opacity(0.14) : .secondary.opacity(0.12)
} }
} }
@ -350,12 +313,11 @@ private struct ConnectedDeviceRow: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(entry.device.name) Text(entry.device.name)
.font(.subheadline.weight(.semibold)) .appTextStyle(.rowTitle)
.foregroundStyle(titleColor) .foregroundStyle(titleColor)
Text(statusText) Text(statusText)
.font(.footnote) .appTextStyle(.supportingCompact)
.foregroundStyle(.secondary)
} }
Spacer(minLength: 12) Spacer(minLength: 12)
@ -364,8 +326,7 @@ private struct ConnectedDeviceRow: View {
Button("Add") { Button("Add") {
addAction() addAction()
} }
.buttonStyle(.borderedProminent) .appMiniProminentButton()
.controlSize(.small)
} }
} }
.opacity(addAction == nil ? 0.68 : 1) .opacity(addAction == nil ? 0.68 : 1)
@ -429,10 +390,7 @@ private struct ConnectedDeviceTransportIcon: View {
.frame(width: 28, height: 28) .frame(width: 28, height: 28)
Image(systemName: badgeSymbolName) Image(systemName: badgeSymbolName)
.font(.system(size: 8, weight: .bold)) .appTransportBadgeBubble()
.foregroundStyle(.primary)
.padding(4)
.background(.thinMaterial, in: Circle())
.offset(x: 4, y: 4) .offset(x: 4, y: 4)
} }
.help(helpText) .help(helpText)