diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift index bf1bea8..7804eb2 100644 --- a/World Manager for Minecraft/ContentView.swift +++ b/World Manager for Minecraft/ContentView.swift @@ -13,6 +13,7 @@ struct ContentView: View { @StateObject private var library = SourceLibrary() @State private var selectedItemID: MinecraftContentItem.ID? @State private var selectedSidebarSelection: SidebarSelection? + @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var searchText = "" @State private var isDropTargeted = false @State private var isPerformingItemAction = false @@ -21,13 +22,15 @@ struct ContentView: View { private let directoryPreviewLimit = 12 var body: some View { - NavigationSplitView { + NavigationSplitView(columnVisibility: $columnVisibility) { SourcesSidebarView( sources: library.sources, selection: $selectedSidebarSelection, footerState: library.sidebarFooterState, addSourceAction: pickFolder, rescanSourceAction: { source in + selectedSidebarSelection = .allContent(sourceID: source.id) + selectedItemID = nil library.rescanSource(withID: source.id) }, removeSourceAction: { source in @@ -44,13 +47,17 @@ struct ContentView: View { selectedItemID: $selectedItemID, searchText: $searchText, sortMode: $sortMode, + showsHeader: shouldShowItemListHeader, + sourceName: currentSourceDisplayName, + showsSourceName: !isSidebarVisible, title: collectionHeaderTitle, subtitle: collectionHeaderSubtitle, + showsSubtitle: isSearching, + isRefreshing: currentSource?.isScanning == true, items: displayedItems, searchPrompt: searchPrompt, chooseFolderAction: pickFolder, dropAction: handleDroppedProviders(_:), - refreshAction: rescanCurrentSource, itemContextMenu: itemContextMenu(for:) ) .navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460) @@ -228,6 +235,34 @@ struct ContentView: View { 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 searchScopeTitle: String { switch selectedSidebarSelection { case .some(.allContent): @@ -243,6 +278,14 @@ struct ContentView: View { !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private var isSidebarVisible: Bool { + columnVisibility == .all + } + + private var shouldShowItemListHeader: Bool { + isSearching || !isSidebarVisible + } + private var collectionCountNoun: String { guard let selectedSidebarSelection else { return "items" @@ -634,14 +677,6 @@ struct ContentView: View { NSWorkspace.shared.activateFileViewerSelecting([item.folderURL]) } - private func rescanCurrentSource() { - guard let sourceID = selectedSidebarSelection?.sourceID else { - return - } - - library.rescanSource(withID: sourceID) - } - private func revealURLInFinder(_ url: URL) { NSWorkspace.shared.activateFileViewerSelecting([url]) } diff --git a/World Manager for Minecraft/ItemListColumnViews.swift b/World Manager for Minecraft/ItemListColumnViews.swift index aea289c..0efdb6c 100644 --- a/World Manager for Minecraft/ItemListColumnViews.swift +++ b/World Manager for Minecraft/ItemListColumnViews.swift @@ -26,13 +26,17 @@ struct ItemListColumnView: View { @Binding var selectedItemID: MinecraftContentItem.ID? @Binding var searchText: String @Binding var sortMode: ItemSortMode + let showsHeader: Bool + let sourceName: String + let showsSourceName: Bool let title: String let subtitle: String + let showsSubtitle: Bool + let isRefreshing: Bool let items: [MinecraftContentItem] let searchPrompt: String let chooseFolderAction: () -> Void let dropAction: ([NSItemProvider]) -> Bool - let refreshAction: () -> Void let itemContextMenu: (MinecraftContentItem) -> MenuContent var body: some View { @@ -54,17 +58,24 @@ struct ItemListColumnView: View { .listStyle(.inset) } } + .safeAreaInset(edge: .top, spacing: 0) { + if !isEmpty && showsHeader { + ItemListHeaderView( + sourceName: sourceName, + showsSourceName: showsSourceName, + title: title, + subtitle: subtitle, + showsSubtitle: showsSubtitle, + isRefreshing: isRefreshing + ) + } + } .searchable(text: $searchText, prompt: searchPrompt) .navigationTitle(isEmpty ? "Library" : title) .navigationSubtitle(isEmpty ? "" : subtitle) .toolbar { if !isEmpty { ToolbarItemGroup { - Button(action: refreshAction) { - Image(systemName: "arrow.clockwise") - } - .help("Rescan Source") - Menu { Picker("Sort By", selection: $sortMode) { ForEach(ItemSortMode.allCases) { mode in @@ -81,6 +92,51 @@ struct ItemListColumnView: View { } } +private struct ItemListHeaderView: View { + let sourceName: String + let showsSourceName: Bool + let title: String + let subtitle: String + let showsSubtitle: Bool + let isRefreshing: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if showsSourceName { + Text(sourceName) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + } + + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(title) + .font(.title2.weight(.semibold)) + .lineLimit(2) + + if isRefreshing { + ProgressView() + .controlSize(.small) + } + } + + if showsSubtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.top, 10) + .padding(.bottom, 12) + .background(.regularMaterial) + .overlay(alignment: .bottom) { + Divider() + } + } +} + private struct ContentRowView: View { let item: MinecraftContentItem diff --git a/World Manager for Minecraft/Models/MinecraftSource.swift b/World Manager for Minecraft/Models/MinecraftSource.swift index 12ddd29..1a3ffe4 100644 --- a/World Manager for Minecraft/Models/MinecraftSource.swift +++ b/World Manager for Minecraft/Models/MinecraftSource.swift @@ -10,6 +10,7 @@ import Foundation struct MinecraftSource: Identifiable, Hashable, Sendable { let id: URL let folderURL: URL + var bookmarkData: Data? var displayName: String var displayItems: [MinecraftContentItem] var rawItems: [MinecraftContentItem] @@ -25,10 +26,11 @@ struct MinecraftSource: Identifiable, Hashable, Sendable { var indexedDetailCount: Int var lastScanDate: Date? - init(folderURL: URL) { + init(folderURL: URL, bookmarkData: Data? = nil) { let normalizedURL = folderURL.standardizedFileURL self.id = normalizedURL self.folderURL = normalizedURL + self.bookmarkData = bookmarkData self.displayName = normalizedURL.lastPathComponent self.displayItems = [] self.rawItems = [] diff --git a/World Manager for Minecraft/PreviewFixtures.swift b/World Manager for Minecraft/PreviewFixtures.swift index f593ef6..2b7750c 100644 --- a/World Manager for Minecraft/PreviewFixtures.swift +++ b/World Manager for Minecraft/PreviewFixtures.swift @@ -231,6 +231,7 @@ enum PreviewFixtures { style: .success, title: "Export Complete", subtitle: "Saved a preview copy of \(featuredWorld.displayName)", + detail: nil, revealURL: featuredWorld.folderURL ) @@ -316,13 +317,17 @@ struct ItemListColumnPreviewContainer: View { selectedItemID: $selectedItemID, searchText: $searchText, sortMode: $sortMode, + showsHeader: true, + sourceName: PreviewFixtures.primarySource.displayName, + showsSourceName: false, title: "All Items", subtitle: "5 items in Kid iPad Imports", + showsSubtitle: false, + isRefreshing: false, items: PreviewFixtures.primarySource.displayItems, searchPrompt: "Search Worlds", chooseFolderAction: {}, dropAction: { _ in false }, - refreshAction: {}, itemContextMenu: { item in Button("Reveal \(item.displayName)") {} } diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 991e9e5..e59f853 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -19,6 +19,7 @@ struct SidebarFooterState { let style: Style let title: String let subtitle: String? + let detail: String? let revealURL: URL? } @@ -26,12 +27,14 @@ struct SidebarFooterState { final class SourceLibrary: ObservableObject { private static let enrichmentWorkerCount = 4 private static let sizeWorkerCount = 2 + private static let minimumVisibleScanDuration: TimeInterval = 0.8 @Published var sources: [MinecraftSource] = [] @Published private(set) var sidebarFooterState = SidebarFooterState( style: .idle, title: "", subtitle: nil, + detail: nil, revealURL: nil ) @Published private(set) var isRestoringPersistedSources = true @@ -50,13 +53,19 @@ final class SourceLibrary: ObservableObject { func addSource(at url: URL) -> URL { let normalizedURL = url.standardizedFileURL + let bookmarkData = securityScopedBookmarkData(for: normalizedURL) if sources.contains(where: { $0.id == normalizedURL }) { + updateSource(normalizedURL) { source in + if source.bookmarkData == nil { + source.bookmarkData = bookmarkData + } + } startScan(for: normalizedURL) return normalizedURL } - sources.append(MinecraftSource(folderURL: normalizedURL)) + sources.append(MinecraftSource(folderURL: normalizedURL, bookmarkData: bookmarkData)) sources.sort { $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending } persistSourceIfAvailable(withID: normalizedURL) startScan(for: normalizedURL) @@ -85,6 +94,7 @@ final class SourceLibrary: ObservableObject { style: .inProgress, title: description, subtitle: nil, + detail: nil, revealURL: nil ) } @@ -94,6 +104,7 @@ final class SourceLibrary: ObservableObject { style: .failure, title: "Action Failed", subtitle: message, + detail: nil, revealURL: nil ) scheduleFooterReset() @@ -104,6 +115,7 @@ final class SourceLibrary: ObservableObject { style: .success, title: title, subtitle: subtitle, + detail: nil, revealURL: revealURL ) scheduleFooterReset() @@ -139,12 +151,35 @@ final class SourceLibrary: ObservableObject { private func scanSource(withID sourceID: URL) async { var workerTasks: [Task] = [] var sizeWorkerTasks: [Task] = [] + let scanStartTime = Date() defer { workerTasks.forEach { $0.cancel() } sizeWorkerTasks.forEach { $0.cancel() } scanTasks[sourceID] = nil } + guard let source = source(withID: sourceID) else { + return + } + + let scanRootURL = resolvedSourceURL(for: source) ?? source.folderURL + let accessedSecurityScope = scanRootURL.startAccessingSecurityScopedResource() + defer { + if accessedSecurityScope { + scanRootURL.stopAccessingSecurityScopedResource() + } + } + + guard FileManager.default.fileExists(atPath: scanRootURL.path) else { + updateSource(sourceID) { source in + source.scanError = "Source folder is no longer available." + source.scanStatus = "" + source.isScanning = false + } + refreshSidebarFooterState() + return + } + await WorldScanner.beginScanSession(for: sourceID) updateSource(sourceID) { source in @@ -164,7 +199,7 @@ final class SourceLibrary: ObservableObject { refreshSidebarFooterState() do { - let index = SourceIndexActor(sourceID: sourceID, folderURL: sourceID) + let index = SourceIndexActor(sourceID: sourceID, folderURL: scanRootURL) let enrichmentQueue = EnrichmentWorkQueue() let sizeQueue = EnrichmentWorkQueue() workerTasks = (0.. { continuation in let discoveryTask = Task.detached(priority: .userInitiated) { do { - _ = try WorldScanner.discoverItems(in: sourceID) { item in + _ = try WorldScanner.discoverItems(in: scanRootURL) { item in continuation.yield(item) } continuation.finish() @@ -263,6 +298,13 @@ final class SourceLibrary: ObservableObject { await sizeWorkerTask.value } + let elapsedScanTime = Date().timeIntervalSince(scanStartTime) + if elapsedScanTime < Self.minimumVisibleScanDuration { + try? await Task.sleep( + for: .seconds(Self.minimumVisibleScanDuration - elapsedScanTime) + ) + } + if let snapshot = await index.finishScan() { applySnapshot(snapshot, to: sourceID) } @@ -515,7 +557,9 @@ final class SourceLibrary: ObservableObject { return } - mutate(&sources[index]) + var source = sources[index] + mutate(&source) + sources[index] = source } private func applySnapshot(_ snapshot: SourceIndexSnapshot, to sourceID: URL) { @@ -677,7 +721,7 @@ final class SourceLibrary: ObservableObject { } for record in records { - var source = MinecraftSource(folderURL: record.folderURL) + var source = MinecraftSource(folderURL: record.folderURL, bookmarkData: record.bookmarkData) source.displayName = record.displayName source.rawItems = await restoreCachedImages(in: record.rawItems) source.indexedItemCount = record.rawItems.count @@ -867,6 +911,32 @@ final class SourceLibrary: ObservableObject { } } + private func securityScopedBookmarkData(for url: URL) -> Data? { + try? url.bookmarkData( + options: [.withSecurityScope], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + } + + private func resolvedSourceURL(for source: MinecraftSource) -> URL? { + guard let bookmarkData = source.bookmarkData else { + return nil + } + + var isStale = false + guard let resolvedURL = try? URL( + resolvingBookmarkData: bookmarkData, + options: [.withSecurityScope], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) else { + return nil + } + + return resolvedURL.standardizedFileURL + } + private func isLogicalPackType(_ contentType: MinecraftContentType) -> Bool { contentType == .behaviorPack || contentType == .resourcePack } @@ -878,6 +948,7 @@ final class SourceLibrary: ObservableObject { style: .inProgress, title: "Restoring library...", subtitle: "Loading saved sources and cached metadata", + detail: nil, revealURL: nil ) return @@ -886,17 +957,22 @@ final class SourceLibrary: ObservableObject { let scanningSources = sources.filter(\.isScanning) if let source = scanningSources.first { cancelFooterReset() + let title = source.scanStatus.isEmpty ? "Scanning Minecraft library..." : source.scanStatus let subtitle: String + let detail: String? if source.indexedItemCount > 0 { - subtitle = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed" + subtitle = source.displayName + detail = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed" } else { subtitle = "Searching \(source.displayName)" + detail = nil } sidebarFooterState = SidebarFooterState( style: .inProgress, - title: "Scanning...", + title: title, subtitle: subtitle, + detail: detail, revealURL: nil ) return @@ -907,13 +983,14 @@ final class SourceLibrary: ObservableObject { style: .failure, title: "Scan failed", subtitle: source.scanError, + detail: nil, revealURL: nil ) scheduleFooterReset() return } cancelFooterReset() - sidebarFooterState = SidebarFooterState(style: .idle, title: "", subtitle: nil, revealURL: nil) + sidebarFooterState = SidebarFooterState(style: .idle, title: "", subtitle: nil, detail: nil, revealURL: nil) } private func cancelFooterReset() { diff --git a/World Manager for Minecraft/Services/SourcePersistenceStore.swift b/World Manager for Minecraft/Services/SourcePersistenceStore.swift index a56b0b8..9fba9b2 100644 --- a/World Manager for Minecraft/Services/SourcePersistenceStore.swift +++ b/World Manager for Minecraft/Services/SourcePersistenceStore.swift @@ -10,6 +10,7 @@ import SQLite3 struct PersistedSourceRecord: Sendable { let folderURL: URL + let bookmarkData: Data? let displayName: String let rawItems: [MinecraftContentItem] let snapshot: SourceSnapshot? @@ -188,7 +189,7 @@ actor SourcePersistenceStore { defer { sqlite3_close(database) } let sql = """ - SELECT folder_path, display_name, raw_items_json, snapshot_json, last_scan_date + SELECT folder_path, bookmark_data, display_name, raw_items_json, snapshot_json, last_scan_date FROM source_cache ORDER BY display_name COLLATE NOCASE ASC; """ @@ -207,17 +208,19 @@ actor SourcePersistenceStore { } let folderPath = String(cString: folderPathPointer) - let displayName = String(cString: sqlite3_column_text(statement, 1)) - let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 2) ?? [] - let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 3) + let bookmarkData = decodeDataColumn(statement: statement, columnIndex: 1) + let displayName = String(cString: sqlite3_column_text(statement, 2)) + let rawItems = try decodeColumn([MinecraftContentItem].self, statement: statement, columnIndex: 3) ?? [] + let snapshotPayload = try decodeColumn(PersistedSourceSnapshotPayload.self, statement: statement, columnIndex: 4) let snapshot = snapshotPayload?.sourceSnapshot - let lastScanDate = sqlite3_column_type(statement, 4) == SQLITE_NULL + let lastScanDate = sqlite3_column_type(statement, 5) == SQLITE_NULL ? nil - : Date(timeIntervalSince1970: sqlite3_column_double(statement, 4)) + : Date(timeIntervalSince1970: sqlite3_column_double(statement, 5)) records.append( PersistedSourceRecord( folderURL: URL(fileURLWithPath: folderPath, isDirectory: true).standardizedFileURL, + bookmarkData: bookmarkData, displayName: displayName, rawItems: rawItems, snapshot: snapshot, @@ -236,12 +239,14 @@ actor SourcePersistenceStore { let sql = """ INSERT INTO source_cache ( folder_path, + bookmark_data, display_name, raw_items_json, snapshot_json, last_scan_date - ) VALUES (?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(folder_path) DO UPDATE SET + bookmark_data = excluded.bookmark_data, display_name = excluded.display_name, raw_items_json = excluded.raw_items_json, snapshot_json = excluded.snapshot_json, @@ -255,14 +260,15 @@ actor SourcePersistenceStore { defer { sqlite3_finalize(statement) } try bindText(source.folderURL.path, to: statement, at: 1) - try bindText(source.displayName, to: statement, at: 2) - try bindJSON(source.rawItems, to: statement, at: 3) - try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 4) + try bindData(source.bookmarkData, to: statement, at: 2) + try bindText(source.displayName, to: statement, at: 3) + try bindJSON(source.rawItems, to: statement, at: 4) + try bindJSON(source.snapshot.map(PersistedSourceSnapshotPayload.init), to: statement, at: 5) if let lastScanDate = source.lastScanDate { - sqlite3_bind_double(statement, 5, lastScanDate.timeIntervalSince1970) + sqlite3_bind_double(statement, 6, lastScanDate.timeIntervalSince1970) } else { - sqlite3_bind_null(statement, 5) + sqlite3_bind_null(statement, 6) } guard sqlite3_step(statement) == SQLITE_DONE else { @@ -304,6 +310,7 @@ actor SourcePersistenceStore { """ CREATE TABLE IF NOT EXISTS source_cache ( folder_path TEXT PRIMARY KEY, + bookmark_data BLOB, display_name TEXT NOT NULL, raw_items_json BLOB NOT NULL, snapshot_json BLOB, @@ -312,12 +319,22 @@ actor SourcePersistenceStore { """, on: database ) + try execute( + "ALTER TABLE source_cache ADD COLUMN bookmark_data BLOB;", + on: database, + ignoringDuplicateColumn: true + ) return database } - private func execute(_ sql: String, on database: OpaquePointer?) throws { + private func execute(_ sql: String, on database: OpaquePointer?, ignoringDuplicateColumn: Bool = false) throws { guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + if ignoringDuplicateColumn, + let database, + String(cString: sqlite3_errmsg(database)).localizedCaseInsensitiveContains("duplicate column name") { + return + } throw databaseError(database) } } @@ -331,14 +348,23 @@ actor SourcePersistenceStore { private func bindJSON(_ value: T, to statement: OpaquePointer?, at index: Int32) throws { let data = try JSONEncoder().encode(value) + try bindData(data, to: statement, at: index) + } + + private func bindData(_ value: Data?, to statement: OpaquePointer?, at index: Int32) throws { + guard let value else { + sqlite3_bind_null(statement, index) + return + } + let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - let result = data.withUnsafeBytes { rawBuffer in - sqlite3_bind_blob(statement, index, rawBuffer.baseAddress, Int32(data.count), transientDestructor) + let result = value.withUnsafeBytes { rawBuffer in + sqlite3_bind_blob(statement, index, rawBuffer.baseAddress, Int32(value.count), transientDestructor) } guard result == SQLITE_OK else { - throw persistenceError("Failed to bind JSON parameter.") + throw persistenceError("Failed to bind data parameter.") } } @@ -359,6 +385,22 @@ actor SourcePersistenceStore { return try JSONDecoder().decode(type, from: data) } + private func decodeDataColumn(statement: OpaquePointer?, columnIndex: Int32) -> Data? { + guard sqlite3_column_type(statement, columnIndex) != SQLITE_NULL else { + return nil + } + + let byteCount = Int(sqlite3_column_bytes(statement, columnIndex)) + guard + byteCount > 0, + let bytes = sqlite3_column_blob(statement, columnIndex) + else { + return nil + } + + return Data(bytes: bytes, count: byteCount) + } + private func databaseError(_ database: OpaquePointer?) -> Error { persistenceError(String(cString: sqlite3_errmsg(database))) } diff --git a/World Manager for Minecraft/SidebarColumnViews.swift b/World Manager for Minecraft/SidebarColumnViews.swift index 09fb1eb..a2d4de3 100644 --- a/World Manager for Minecraft/SidebarColumnViews.swift +++ b/World Manager for Minecraft/SidebarColumnViews.swift @@ -127,24 +127,32 @@ private struct SidebarFooterView: View { let revealAction: (URL) -> Void var body: some View { - VStack(alignment: .leading, spacing: 6) { + 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(2) + .lineLimit(3) } if let subtitle = state.subtitle { Text(subtitle) .font(.footnote) .foregroundStyle(.secondary) - .lineLimit(3) + .lineLimit(2) + } + + if let detail = state.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) } if let revealURL = state.revealURL { @@ -157,20 +165,48 @@ private struct SidebarFooterView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .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, .inProgress: + 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 {