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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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