Remove abandoned sidebar footer

This commit is contained in:
John Burwell 2026-05-28 17:37:39 -05:00
parent b7ea9ce89d
commit 4a3b336643
4 changed files with 40 additions and 368 deletions

View File

@ -45,7 +45,6 @@ struct ContentView: View {
sources: library.sidebarSources,
connectedDevices: library.connectedDevices,
selection: $selectedSidebarSelection,
footerState: library.sidebarFooterState,
addSourceAction: pickFolder,
addDeviceSourceAction: { isShowingDeviceSourceSheet = true },
addConnectedDeviceAction: addConnectedDeviceSource(from:),
@ -57,7 +56,6 @@ struct ContentView: View {
removeSourceAction: { source in
removeSource(source.id)
},
revealFooterURLAction: revealURLInFinder(_:),
filters: sidebarFilters(for:)
)
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
@ -681,7 +679,6 @@ struct ContentView: View {
}
isPerformingItemAction = true
library.setItemActionInProgress("Creating \(item.contentType.archiveExtension) file...")
Task {
do {
@ -695,16 +692,11 @@ struct ContentView: View {
await MainActor.run {
isPerformingItemAction = false
library.setItemActionSuccess(
title: "Created \(finalURL.lastPathComponent)",
subtitle: "Ready to move to another device",
revealURL: finalURL
)
_ = finalURL
}
} catch {
await MainActor.run {
isPerformingItemAction = false
library.setItemActionFailure(error.localizedDescription)
}
}
}
@ -717,7 +709,6 @@ struct ContentView: View {
let source = currentSource
isPerformingItemAction = true
library.setItemActionInProgress("Preparing \(item.contentType.archiveExtension) file...")
Task {
do {
@ -733,16 +724,9 @@ struct ContentView: View {
let presentationView = anchorView ?? NSApp.keyWindow?.contentView
guard let presentationView else {
library.setItemActionFailure("Could not present the share menu.")
return
}
library.setItemActionSuccess(
title: "Share ready",
subtitle: shareURL.lastPathComponent,
revealURL: shareURL
)
let picker = NSSharingServicePicker(items: [shareURL])
let targetRect = anchorView?.bounds ?? presentationView.bounds.insetBy(
dx: presentationView.bounds.width / 2,
@ -753,7 +737,6 @@ struct ContentView: View {
} catch {
await MainActor.run {
isPerformingItemAction = false
library.setItemActionFailure(error.localizedDescription)
}
}
}
@ -774,7 +757,6 @@ struct ContentView: View {
}
isPerformingItemAction = true
library.setItemActionInProgress("Preparing item for Finder...")
Task {
do {
@ -783,25 +765,15 @@ struct ContentView: View {
await MainActor.run {
isPerformingItemAction = false
NSWorkspace.shared.activateFileViewerSelecting([revealURL])
library.setItemActionSuccess(
title: "Prepared for Finder",
subtitle: item.displayName,
revealURL: revealURL
)
}
} catch {
await MainActor.run {
isPerformingItemAction = false
library.setItemActionFailure(error.localizedDescription)
}
}
}
}
private func revealURLInFinder(_ url: URL) {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
private func archiveType(for item: MinecraftContentItem) -> UTType {
UTType(filenameExtension: item.contentType.archiveExtension) ?? .data
}

View File

@ -227,14 +227,6 @@ enum PreviewFixtures {
static let allSources = [primarySource, secondarySource]
static let sidebarFooter = SidebarFooterState(
style: .success,
title: "Export Complete",
subtitle: "Saved a preview copy of \(featuredWorld.displayName)",
detail: nil,
revealURL: featuredWorld.folderURL
)
static let directoryEntries = [
DirectoryPreviewEntry(name: "db", isDirectory: true),
DirectoryPreviewEntry(name: "level.dat", isDirectory: false),
@ -242,48 +234,9 @@ enum PreviewFixtures {
DirectoryPreviewEntry(name: "world_icon.jpeg", isDirectory: false),
DirectoryPreviewEntry(name: "resource_packs", isDirectory: true)
]
nonisolated static func sidebarFilters(for source: MinecraftSource) -> [SidebarFilter] {
let allFilter = SidebarFilter(
title: "All Content",
iconName: "square.stack.3d.up",
count: source.items.count,
selection: .allContent(sourceID: source.id)
)
let groupedFilters = MinecraftContentType.allCases.compactMap { contentType -> SidebarFilter? in
let count = source.items.filter { $0.contentType == contentType }.count
guard count > 0 else {
return nil
}
return SidebarFilter(
title: contentType.rawValue,
iconName: iconName(for: contentType),
count: count,
selection: .contentType(sourceID: source.id, contentType: contentType)
)
}
return [allFilter] + groupedFilters
}
nonisolated static func iconName(for contentType: MinecraftContentType) -> String {
switch contentType {
case .world:
return "globe.europe.africa"
case .behaviorPack:
return "shippingbox"
case .resourcePack:
return "paintpalette"
case .skinPack:
return "person.crop.square"
case .worldTemplate:
return "doc.on.doc"
}
}
}
@MainActor
struct SidebarColumnPreviewContainer: View {
@State private var selection: SidebarSelection? = .allContent(sourceID: PreviewFixtures.primarySource.id)
@ -293,14 +246,49 @@ struct SidebarColumnPreviewContainer: View {
sources: PreviewFixtures.allSources,
connectedDevices: [],
selection: $selection,
footerState: PreviewFixtures.sidebarFooter,
addSourceAction: {},
addDeviceSourceAction: {},
addConnectedDeviceAction: { _ in },
rescanSourceAction: { _ in },
removeSourceAction: { _ in },
revealFooterURLAction: { _ in },
filters: PreviewFixtures.sidebarFilters(for:)
filters: { source in
let allFilter = SidebarFilter(
title: "All Content",
iconName: "square.stack.3d.up",
count: source.displayItems.count,
selection: .allContent(sourceID: source.id)
)
let groupedFilters = MinecraftContentType.allCases.compactMap { contentType -> SidebarFilter? in
let count = source.displayItems.filter { $0.contentType == contentType }.count
guard count > 0 else {
return nil
}
let iconName: String
switch contentType {
case .world:
iconName = "globe.europe.africa"
case .behaviorPack:
iconName = "shippingbox"
case .resourcePack:
iconName = "paintpalette"
case .skinPack:
iconName = "person.crop.square"
case .worldTemplate:
iconName = "doc.on.doc"
}
return SidebarFilter(
title: contentType.rawValue,
iconName: iconName,
count: count,
selection: .contentType(sourceID: source.id, contentType: contentType)
)
}
return [allFilter] + groupedFilters
}
)
}
}

View File

@ -9,21 +9,6 @@ import Combine
import Foundation
import OSLog
struct SidebarFooterState {
enum Style {
case idle
case inProgress
case failure
case success
}
let style: Style
let title: String
let subtitle: String?
let detail: String?
let revealURL: URL?
}
struct ConnectedDeviceSidebarEntry: Identifiable, Hashable {
let device: ConnectedDevice
let containers: [DeviceAppContainer]
@ -58,7 +43,6 @@ final class SourceLibrary: ObservableObject {
private static let localSourceRefreshInterval: TimeInterval = 4.0
private static let connectedDeviceRefreshInterval: TimeInterval = 2.0
private static let connectedDeviceRefreshIntervalWhileScanning: TimeInterval = 5.0
private static let footerRefreshDebounce: TimeInterval = 0.15
private static let usbConnectedDeviceAutoRefreshInterval: TimeInterval = 45.0
private static let networkConnectedDeviceAutoRefreshInterval: TimeInterval = 120.0
private static let usbConnectedDeviceDiscoveryCacheTTL: TimeInterval = 60.0
@ -70,21 +54,12 @@ final class SourceLibrary: ObservableObject {
@Published var sources: [MinecraftSource] = []
@Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = []
@Published private(set) var sidebarFooterState = SidebarFooterState(
style: .idle,
title: "",
subtitle: nil,
detail: nil,
revealURL: nil
)
@Published private(set) var isRestoringPersistedSources = true
private var scanTasks: [URL: Task<Void, Never>] = [:]
private var automaticSyncTasks: [URL: Task<Void, Never>] = [:]
private var connectedDeviceRefreshTask: Task<Void, Never>?
private var localSourceRefreshTask: Task<Void, Never>?
private var footerResetTask: Task<Void, Never>?
private var footerRefreshTask: Task<Void, Never>?
private let persistenceStore: SourcePersistenceStore
private let sourceAccessMethod: SourceAccessMethod
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
@ -123,8 +98,6 @@ final class SourceLibrary: ObservableObject {
deinit {
connectedDeviceRefreshTask?.cancel()
localSourceRefreshTask?.cancel()
footerResetTask?.cancel()
footerRefreshTask?.cancel()
automaticSyncTasks.values.forEach { $0.cancel() }
scanTasks.values.forEach { $0.cancel() }
}
@ -147,10 +120,6 @@ final class SourceLibrary: ObservableObject {
connectedDeviceRefreshTask = nil
localSourceRefreshTask?.cancel()
localSourceRefreshTask = nil
footerResetTask?.cancel()
footerResetTask = nil
footerRefreshTask?.cancel()
footerRefreshTask = nil
for task in automaticSyncTasks.values {
task.cancel()
@ -257,53 +226,6 @@ final class SourceLibrary: ObservableObject {
if let removedSource {
purgeCachedArtifacts(for: removedSource)
}
refreshSidebarFooterState()
}
func setItemActionInProgress(_ description: String) {
cancelFooterReset()
sidebarFooterState = SidebarFooterState(
style: .inProgress,
title: description,
subtitle: nil,
detail: nil,
revealURL: nil
)
}
func setItemActionFailure(_ message: String) {
sidebarFooterState = SidebarFooterState(
style: .failure,
title: "Action Failed",
subtitle: message,
detail: nil,
revealURL: nil
)
scheduleFooterReset()
}
func setItemActionSuccess(title: String, subtitle: String, revealURL: URL?) {
sidebarFooterState = SidebarFooterState(
style: .success,
title: title,
subtitle: subtitle,
detail: nil,
revealURL: revealURL
)
scheduleFooterReset()
}
var activeScanSummary: String? {
let scanningSources = sources.filter(\.isScanning)
guard !scanningSources.isEmpty else {
return nil
}
if scanningSources.count == 1, let source = scanningSources.first {
return "\(source.displayName): \(source.scanStatus)"
}
return "Scanning \(scanningSources.count) sources..."
}
private func startScan(for sourceID: URL, mode: SourceDiscoveryMode) {
@ -361,7 +283,6 @@ final class SourceLibrary: ObservableObject {
source.previewLoadedCount = 0
source.sizeLoadedCount = 0
}
refreshSidebarFooterState()
updateSource(sourceID) { source in
source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source)
@ -383,7 +304,6 @@ final class SourceLibrary: ObservableObject {
source.availability = .available
source.scanStatus = scanningLibraryStatus(for: source, mode: mode)
}
refreshSidebarFooterState()
do {
let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL)
@ -405,7 +325,6 @@ final class SourceLibrary: ObservableObject {
if let snapshot = await index.applyEnrichedItem(enrichedItem) {
await MainActor.run {
library.applySnapshot(snapshot, to: sourceID)
library.scheduleSidebarFooterRefresh()
}
}
}
@ -466,8 +385,6 @@ final class SourceLibrary: ObservableObject {
) {
applySnapshot(snapshot, to: sourceID)
}
scheduleSidebarFooterRefresh()
if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false {
await enrichmentQueue.enqueue(item)
}
@ -509,8 +426,6 @@ final class SourceLibrary: ObservableObject {
if let snapshot = await index.markDiscoveryFinished() {
applySnapshot(snapshot, to: sourceID)
}
refreshSidebarFooterState()
await enrichmentQueue.finish()
let enrichmentStartTime = Date()
@ -529,7 +444,6 @@ final class SourceLibrary: ObservableObject {
applySnapshot(snapshot, to: sourceID)
}
persistSourceIfAvailable(withID: sourceID)
refreshSidebarFooterState()
let previewStageStartTime = Date()
let previewSeedItems = await index.currentItems()
@ -540,7 +454,6 @@ final class SourceLibrary: ObservableObject {
for previewItem in previewItems {
if let snapshot = await index.applyPreviewItem(previewItem) {
applySnapshot(snapshot, to: sourceID)
scheduleSidebarFooterRefresh()
}
}
@ -555,7 +468,6 @@ final class SourceLibrary: ObservableObject {
applySnapshot(snapshot, to: sourceID)
}
persistSourceIfAvailable(withID: sourceID)
refreshSidebarFooterState()
if source.origin.kind == .connectedDevice {
let sizeStageStartTime = Date()
@ -567,7 +479,6 @@ final class SourceLibrary: ObservableObject {
for sizedItem in sizedItems {
if let snapshot = await index.applySizedItem(sizedItem) {
applySnapshot(snapshot, to: sourceID)
scheduleSidebarFooterRefresh()
}
}
@ -596,7 +507,6 @@ final class SourceLibrary: ObservableObject {
}
}
persistSourceIfAvailable(withID: sourceID)
refreshSidebarFooterState()
logScanStage(
"Total",
elapsed: Date().timeIntervalSince(scanStartTime),
@ -629,7 +539,6 @@ final class SourceLibrary: ObservableObject {
if let snapshot = await index.applySizedItem(sizedItem) {
await MainActor.run {
library.applySnapshot(snapshot, to: sourceID)
library.scheduleSidebarFooterRefresh()
}
}
}
@ -670,7 +579,6 @@ final class SourceLibrary: ObservableObject {
}
}
persistSourceIfAvailable(withID: sourceID)
refreshSidebarFooterState()
logScanStage(
"Total",
elapsed: Date().timeIntervalSince(scanStartTime),
@ -719,7 +627,6 @@ final class SourceLibrary: ObservableObject {
source.isScanning = false
}
persistSourceIfAvailable(withID: sourceID)
refreshSidebarFooterState()
}
}
@ -746,7 +653,6 @@ final class SourceLibrary: ObservableObject {
sourceID: sourceID
)
}
refreshSidebarFooterState()
}
private func handleSizedItem(_ sizedItem: MinecraftContentItem, for sourceID: URL) {
@ -762,7 +668,6 @@ final class SourceLibrary: ObservableObject {
source.scanStatus = "Calculating sizes for \(source.rawItems.filter(\.sizeLoaded).count) of \(source.indexedItemCount) items..."
}
}
refreshSidebarFooterState()
}
private func rebuildNormalizedIndex(for sourceID: URL) {
@ -1647,7 +1552,6 @@ final class SourceLibrary: ObservableObject {
private func restorePersistedSources() async {
defer {
isRestoringPersistedSources = false
refreshSidebarFooterState()
}
let records: [PersistedSourceRecord]
@ -1708,7 +1612,6 @@ final class SourceLibrary: ObservableObject {
}
sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending }
refreshSidebarFooterState()
await Task.yield()
for record in records {
@ -1993,91 +1896,6 @@ final class SourceLibrary: ObservableObject {
contentType == .behaviorPack || contentType == .resourcePack
}
private func refreshSidebarFooterState() {
footerRefreshTask?.cancel()
footerRefreshTask = nil
if isRestoringPersistedSources {
cancelFooterReset()
sidebarFooterState = SidebarFooterState(
style: .inProgress,
title: "Restoring library...",
subtitle: "Loading saved sources and cached metadata",
detail: nil,
revealURL: nil
)
return
}
let scanningSources = sources.filter(\.isScanning)
if let source = scanningSources.first {
cancelFooterReset()
let title = source.liveScanStatusTitle.isEmpty
? "Scanning Minecraft library..."
: source.liveScanStatusTitle
let subtitle: String
let detail: String?
if source.indexedItemCount > 0 {
subtitle = source.displayName
switch source.scanPhase {
case .discovering:
detail = "\(source.indexedItemCount) items found"
case .metadata:
detail = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
case .previews:
detail = "\(source.previewLoadedCount) of \(source.indexedItemCount) previews loaded"
case .sizing:
detail = "\(source.sizeLoadedCount) of \(source.indexedItemCount) sizes calculated"
case .completed, .idle:
detail = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
}
} else {
subtitle = "Searching \(source.displayName)"
detail = nil
}
sidebarFooterState = SidebarFooterState(
style: .inProgress,
title: title,
subtitle: subtitle,
detail: detail,
revealURL: nil
)
return
}
if let source = sources.first(where: { $0.scanError != nil }) {
sidebarFooterState = SidebarFooterState(
style: .failure,
title: "Scan failed",
subtitle: source.scanError,
detail: nil,
revealURL: nil
)
scheduleFooterReset()
return
}
cancelFooterReset()
sidebarFooterState = SidebarFooterState(style: .idle, title: "", subtitle: nil, detail: nil, revealURL: nil)
}
private func scheduleSidebarFooterRefresh() {
guard !isRestoringPersistedSources else {
refreshSidebarFooterState()
return
}
footerRefreshTask?.cancel()
footerRefreshTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(Self.footerRefreshDebounce))
guard let self, !Task.isCancelled else {
return
}
self.refreshSidebarFooterState()
}
}
@discardableResult
private func updateAvailability(for sourceID: URL, to newAvailability: SourceAvailability) -> (previous: SourceAvailability, becameAvailable: Bool) {
let previousAvailability = source(withID: sourceID)?.availability ?? .unknown
@ -2165,11 +1983,6 @@ final class SourceLibrary: ObservableObject {
return source.previewLoadedCount < itemCount || source.sizeLoadedCount < itemCount
}
private func cancelFooterReset() {
footerResetTask?.cancel()
footerResetTask = nil
}
private func initialScanStatus(for source: MinecraftSource, mode: SourceDiscoveryMode) -> String {
switch (source.origin, mode) {
case (.localFolder, .fullScan):
@ -2231,18 +2044,6 @@ final class SourceLibrary: ObservableObject {
}
}
private func scheduleFooterReset(after seconds: Double = 5) {
cancelFooterReset()
footerResetTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(seconds))
guard let self, !Task.isCancelled else {
return
}
self.refreshSidebarFooterState()
}
}
private func buildSnapshot(
for source: MinecraftSource,
scanRootURL: URL,

View File

@ -25,13 +25,11 @@ struct SourcesSidebarView: View {
let sources: [MinecraftSource]
let connectedDevices: [ConnectedDeviceSidebarEntry]
@Binding var selection: SidebarSelection?
let footerState: SidebarFooterState
let addSourceAction: () -> Void
let addDeviceSourceAction: () -> Void
let addConnectedDeviceAction: (ConnectedDeviceSidebarEntry) -> Void
let rescanSourceAction: (MinecraftSource) -> Void
let removeSourceAction: (MinecraftSource) -> Void
let revealFooterURLAction: (URL) -> Void
let filters: (MinecraftSource) -> [SidebarFilter]
var body: some View {
@ -466,93 +464,6 @@ private struct ConnectedDeviceTransportIcon: View {
}
}
private struct SidebarFooterView: View {
let state: SidebarFooterState
let revealAction: (URL) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
if state.style == .inProgress {
ProgressView()
.controlSize(.small)
.tint(.appAccent)
}
Text(state.title)
.font(.footnote.weight(.semibold))
.foregroundStyle(primaryColor)
.lineLimit(3)
}
if let subtitle = state.subtitle {
Text(subtitle)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
}
if let detail = state.detail {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let revealURL = state.revealURL {
Button("Reveal in Finder") {
revealAction(revealURL)
}
.buttonStyle(.link)
.font(.footnote)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 12)
.background(cardBackground, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(cardStroke)
}
}
private var primaryColor: Color {
switch state.style {
case .idle:
return .primary
case .inProgress:
return .appAccent
case .failure:
return .red
case .success:
return .appAccent
}
}
private var cardBackground: AnyShapeStyle {
switch state.style {
case .inProgress:
return AnyShapeStyle(Color.appAccent.opacity(0.08))
default:
return AnyShapeStyle(.regularMaterial)
}
}
private var cardStroke: Color {
switch state.style {
case .inProgress:
return Color.appAccent.opacity(0.18)
case .failure:
return .red.opacity(0.18)
case .success:
return Color.appAccent.opacity(0.16)
case .idle:
return .white.opacity(0.08)
}
}
}
struct SidebarColumnViews_Previews: PreviewProvider {
static var previews: some View {
SidebarColumnPreviewContainer()