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