Adjust UI behavior
This commit is contained in:
parent
b25f2e0148
commit
cf0471c7ad
@ -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])
|
||||
}
|
||||
|
||||
@ -26,13 +26,17 @@ struct ItemListColumnView<MenuContent: View>: 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<MenuContent: View>: 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<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 {
|
||||
let item: MinecraftContentItem
|
||||
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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)") {}
|
||||
}
|
||||
|
||||
@ -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<Void, Never>] = []
|
||||
var sizeWorkerTasks: [Task<Void, Never>] = []
|
||||
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..<Self.enrichmentWorkerCount).map { _ in
|
||||
@ -213,7 +248,7 @@ final class SourceLibrary: ObservableObject {
|
||||
let discoveryStream = AsyncThrowingStream<MinecraftContentItem, Error> { 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() {
|
||||
|
||||
@ -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<T: Encodable>(_ 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)))
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user