Adjust UI behavior

This commit is contained in:
John Burwell 2026-05-26 11:09:30 -05:00
parent b25f2e0148
commit cf0471c7ad
7 changed files with 301 additions and 48 deletions

View File

@ -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])
} }

View File

@ -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

View File

@ -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 = []

View File

@ -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)") {}
} }

View File

@ -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() {

View File

@ -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)))
} }

View File

@ -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 {