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 displayName: String
var displayItems: [MinecraftContentItem]
var displayItemCountsByType: [MinecraftContentType: Int]
var rawItems: [MinecraftContentItem]
var logicalPacks: [LogicalPack]
var logicalWorlds: [LogicalWorld]
@ -61,6 +62,7 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
self.bookmarkData = bookmarkData
self.displayName = normalizedFolderURL.lastPathComponent
self.displayItems = []
self.displayItemCountsByType = [:]
self.rawItems = []
self.logicalPacks = []
self.logicalWorlds = []
@ -99,6 +101,29 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
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? {
rawItems.first(where: { $0.id == itemID })
}

View File

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

View File

@ -83,10 +83,30 @@ enum SourcePersistenceCoordinator {
static func persistVisibleSourcesForShutdown(
from sources: [MinecraftSource],
using persistenceStore: SourcePersistenceStore
using persistenceStore: SourcePersistenceStore,
timeout: TimeInterval
) async {
for source in sources {
try? await persistenceStore.save(source: source)
guard !sources.isEmpty else {
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
) {
source.rawItems = items
source.displayItemCountsByType = items.reduce(into: [MinecraftContentType: Int]()) { counts, item in
counts[item.contentType, default: 0] += 1
}
source.indexedItemCount = items.count
source.indexedDetailCount = items.filter(\.metadataLoaded).count
source.previewLoadedCount = items.filter(\.previewLoaded).count

View File

@ -14,6 +14,7 @@ struct SourceContentIndex {
let packInstances: [PackInstance]
let worldPackRelationships: [WorldPackRelationship]
let displayItems: [MinecraftContentItem]
let displayItemCountsByType: [MinecraftContentType: Int]
}
enum SourceContentIndexer {
@ -161,17 +162,20 @@ enum SourceContentIndexer {
$0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending
}
let displayItems = buildDisplayItems(
from: rawItems,
logicalPacks: logicalPacks,
rawItemsByID: rawItemsByID
)
return SourceContentIndex(
rawItems: rawItems,
logicalPacks: logicalPacks,
logicalWorlds: sortedLogicalWorlds,
packInstances: sortedPackInstances,
worldPackRelationships: worldRelationships,
displayItems: buildDisplayItems(
from: rawItems,
logicalPacks: logicalPacks,
rawItemsByID: rawItemsByID
)
displayItems: displayItems,
displayItemCountsByType: displayItemCounts(for: displayItems)
)
}
@ -213,6 +217,12 @@ enum SourceContentIndexer {
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 {
let candidateEmbedded = isEmbeddedWorldPack(candidate)
let existingEmbedded = isEmbeddedWorldPack(existing)

View File

@ -467,6 +467,7 @@ private actor EnrichmentWorkQueue {
struct SourceIndexSnapshot {
let displayItems: [MinecraftContentItem]
let displayItemCountsByType: [MinecraftContentType: Int]
let rawItems: [MinecraftContentItem]
let logicalPacks: [LogicalPack]
let logicalWorlds: [LogicalWorld]
@ -631,6 +632,9 @@ private actor SourceIndexActor {
logicalPacks: logicalPacks,
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 previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount)
let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount)
@ -651,6 +655,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot(
displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
rawItems: rawItems,
logicalPacks: logicalPacks,
logicalWorlds: [],
@ -678,6 +683,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot(
displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
rawItems: rawItems,
logicalPacks: logicalPacks,
logicalWorlds: [],
@ -709,6 +715,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot(
displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
rawItems: rawItems,
logicalPacks: logicalPacks,
logicalWorlds: [],
@ -820,6 +827,7 @@ private actor SourceIndexActor {
return SourceIndexSnapshot(
displayItems: dedupedDisplayItems,
displayItemCountsByType: displayItemCountsByType,
rawItems: rawItems,
logicalPacks: logicalPacks,
logicalWorlds: logicalWorlds,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,14 @@ struct ContentView: View {
@State private var isShowingDeviceSourceSheet = false
@State private var sortMode: ItemSortMode = .name
@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 deviceSourceFactory: ConnectedDeviceSourceFactory
@ -37,11 +45,29 @@ struct ContentView: 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) {
SourcesSidebarView(
sources: library.sidebarSources,
connectedDevices: library.connectedDevices,
selection: $selectedSidebarSelection,
selection: sidebarSelectionBinding,
addSourceAction: pickFolder,
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
addConnectedDeviceAction: addConnectedDeviceSource(from:),
@ -58,58 +84,58 @@ struct ContentView: View {
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
} content: {
ItemListColumnView(
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
isEmpty: isEmptyLibrary,
isDropTargeted: $isDropTargeted,
selectedItemID: $selectedItemID,
searchText: $searchText,
sortMode: $sortMode,
showsHeader: shouldShowItemListHeader,
sourceName: currentSourceDisplayName,
sourceName: resolvedItemListProjection.sourceName,
showsSourceName: !isSidebarVisible,
title: collectionHeaderTitle,
subtitle: collectionHeaderSubtitle,
showsSubtitle: isSearching,
isRefreshing: currentSource?.isScanning == true,
items: displayedItems,
searchPrompt: searchPrompt,
title: resolvedItemListProjection.title,
subtitle: resolvedItemListProjection.subtitle,
showsSubtitle: isSearching || isUpdatingItemListProjection,
isRefreshing: resolvedCurrentSource?.isScanning == true,
isUpdatingProjection: isUpdatingItemListProjection,
items: resolvedDisplayedItems,
searchPrompt: resolvedItemListProjection.searchPrompt,
chooseFolderAction: pickFolder,
dropAction: handleDroppedProviders(_:),
dragProvider: dragProvider(for:),
itemContextMenu: itemContextMenu(for:)
)
.navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460)
} detail: {
ItemDetailColumnView(
item: currentSelectedItem,
source: currentSource,
showsSourceDetails: currentSelectedItem == nil && isSourceOverviewSelection,
behaviorPacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
resourcePacks: currentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
worldsUsingPack: currentSelectedItem.map(worldsUsingPack(for:)) ?? [],
backingPackInstances: currentSelectedItem.map(backingPackInstances(for:)) ?? [],
isSuspiciousPack: currentSelectedItem.map(isSuspiciousPack(_:)) ?? false,
item: resolvedCurrentSelectedItem,
source: resolvedCurrentSource,
showsSourceDetails: resolvedCurrentSelectedItem == nil && isSourceOverviewSelection,
behaviorPacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .behaviorPack) } ?? [],
resourcePacks: resolvedCurrentSelectedItem.map { logicalPackReferences(for: $0, type: .resourcePack) } ?? [],
worldsUsingPack: resolvedCurrentSelectedItem.map(worldsUsingPack(for:)) ?? [],
backingPackInstances: resolvedCurrentSelectedItem.map(backingPackInstances(for:)) ?? [],
isSuspiciousPack: resolvedCurrentSelectedItem.map(isSuspiciousPack(_:)) ?? false,
contents: directoryPreviewContents,
directoryPreviewLimit: directoryPreviewLimit,
isEmpty: library.visibleSources.isEmpty && library.connectedDevices.isEmpty,
isEmpty: isEmptyLibrary,
isPerformingItemAction: isPerformingItemAction,
areFileActionsEnabled: areCurrentItemFileActionsEnabled,
exportTitle: currentSelectedItem.map(primaryActionTitle(for:)),
exportTitle: resolvedCurrentSelectedItem.map(primaryActionTitle(for:)),
exportAction: {
guard let item = currentSelectedItem else {
guard let item = resolvedCurrentSelectedItem else {
return
}
saveItem(item)
},
revealAction: {
guard let item = currentSelectedItem else {
guard let item = resolvedCurrentSelectedItem else {
return
}
revealInFinder(item)
},
shareAction: { anchorView in
guard let item = currentSelectedItem else {
guard let item = resolvedCurrentSelectedItem else {
return
}
@ -119,7 +145,7 @@ struct ContentView: View {
.frame(minWidth: 450)
}
.overlay {
if library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty {
if library.isRestoringPersistedSources && isEmptyLibrary {
LaunchRestoreOverlayView()
}
}
@ -138,60 +164,46 @@ struct ContentView: View {
.task {
AppTerminationCoordinator.shared.register(library: library)
}
.disabled(library.isRestoringPersistedSources && library.visibleSources.isEmpty && library.connectedDevices.isEmpty)
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
.disabled(library.isRestoringPersistedSources && isEmptyLibrary)
.onChange(of: resolvedDisplayedItems.map(\.id)) { _, filteredIDs in
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
return
}
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
syncSelection(with: library.visibleSources.map(\.id))
}
.onChange(of: library.connectedDevices.map { "\($0.id)::\($0.matchedSourceID?.absoluteString ?? "nil")" }) { _, _ in
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()
}
}
private var scopedItems: [MinecraftContentItem] {
guard let selectedSidebarSelection else {
return []
}
private var sidebarSelectionBinding: Binding<SidebarSelection?> {
Binding(
get: { selectedSidebarSelection },
set: { newSelection in
if newSelection != selectedSidebarSelection {
selectedItemID = nil
}
switch selectedSidebarSelection {
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)
selectedSidebarSelection = newSelection
}
)
}
private var currentSource: MinecraftSource? {
@ -202,14 +214,23 @@ struct ContentView: View {
return library.source(withID: sourceID)
}
private var currentSelectedItem: MinecraftContentItem? {
private func currentSelectedItem(in source: MinecraftSource?) -> MinecraftContentItem? {
guard let selectedItemID else {
return nil
}
return library.visibleSources
.flatMap(\.items)
.first(where: { $0.id == selectedItemID })
if let source,
let item = source.items.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 {
@ -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 {
guard currentSelectedItem != nil else {
guard currentSelectedItem(in: currentSource) != nil else {
return false
}
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 {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@ -345,42 +296,9 @@ struct ContentView: View {
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] {
MinecraftContentType.allCases.compactMap { contentType in
let count = source.items.filter { $0.contentType == contentType }.count
guard count > 0 else {
return MinecraftContentType.allCases.compactMap { contentType in
guard let count = source.displayItemCountsByType[contentType], count > 0 else {
return nil
}
@ -539,7 +457,8 @@ struct ContentView: View {
}
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 {
directoryPreviewContents = []
}
@ -615,7 +534,7 @@ struct ContentView: View {
selectedSidebarSelection = fallbackSourceID.map { .source(sourceID: $0) }
}
if let selectedItemID, currentSelectedItem?.id != selectedItemID {
if let selectedItemID, currentSelectedItem(in: currentSource)?.id != selectedItemID {
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
enum AppChrome {
static let sidebarRowCornerRadius: CGFloat = 10
static let placeholderCardCornerRadius: CGFloat = 16
static let panelCardCornerRadius: CGFloat = 18
static let detailSectionCardPadding: CGFloat = 18
}
enum AppCardStyle {
case primaryPanel
case secondaryPanel
case detailPanel
case placeholder
fileprivate var cornerRadius: CGFloat {
switch self {
case .primaryPanel, .secondaryPanel:
case .detailPanel:
return AppChrome.panelCardCornerRadius
case .placeholder:
return AppChrome.placeholderCardCornerRadius
@ -23,9 +22,7 @@ enum AppCardStyle {
fileprivate var fillStyle: AnyShapeStyle {
switch self {
case .primaryPanel:
return AnyShapeStyle(.regularMaterial)
case .secondaryPanel:
case .detailPanel:
return AnyShapeStyle(.quaternary.opacity(0.32))
case .placeholder:
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 {
func appCardSurface(_ style: AppCardStyle) -> some View {
modifier(AppCardSurfaceModifier(style: style))
}
func appSidebarRowSurface(isHighlighted: Bool) -> some View {
let fillStyle = isHighlighted
? AnyShapeStyle(.secondary.opacity(0.08))
: AnyShapeStyle(Color.clear)
return background(
fillStyle,
in: RoundedRectangle(cornerRadius: AppChrome.sidebarRowCornerRadius, style: .continuous)
)
func appSectionTitleStyle(_ style: AppSectionTitleStyle) -> some View {
modifier(AppSectionTitleModifier(style: style))
}
func appTextStyle(_ style: AppTextStyle) -> some View {
modifier(AppTextStyleModifier(style: style))
}
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 {
FlexibleTagLayout(spacing: 8, rowSpacing: 8, items: metadataChips) { chip in
Text(chip)
.font(.caption.weight(.semibold))
.foregroundStyle(.white.opacity(0.95))
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(.white.opacity(0.14), in: Capsule())
.appCapsuleLabelStyle(.heroMetadata)
}
}
@ -391,7 +578,7 @@ struct LaunchRestoreOverlayView: View {
VStack(spacing: 14) {
ProgressView()
.controlSize(.large)
.appActivityIndicatorStyle(.large)
Text("Opening Library…")
.font(.title3.weight(.semibold))

View File

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