diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index 517bef0..64dae2f 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -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 } diff --git a/World Manager for Minecraft/PreviewFixtures.swift b/World Manager for Minecraft/PreviewFixtures.swift index e35ebef..9fba194 100644 --- a/World Manager for Minecraft/PreviewFixtures.swift +++ b/World Manager for Minecraft/PreviewFixtures.swift @@ -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 + } ) } } diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 32454e1..6f189c7 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -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] = [:] private var automaticSyncTasks: [URL: Task] = [:] private var connectedDeviceRefreshTask: Task? private var localSourceRefreshTask: Task? - private var footerResetTask: Task? - private var footerRefreshTask: Task? 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, diff --git a/World Manager for Minecraft/SidebarColumnViews.swift b/World Manager for Minecraft/SidebarColumnViews.swift index f373d11..7d61b35 100644 --- a/World Manager for Minecraft/SidebarColumnViews.swift +++ b/World Manager for Minecraft/SidebarColumnViews.swift @@ -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()