Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions Sources/Vista/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,18 @@ final class AppState {
/// the Permissions tab.
var indexedCount: Int {
if case .watching(let n) = indexingProgress { return n }
if case .indexing(let done, _) = indexingProgress { return done }
if case .indexing(_, _, let indexed) = indexingProgress { return indexed }
return 0
}
Comment thread
GordonBeeming marked this conversation as resolved.

/// Folders we hold indexed rows for but couldn't read on the last scan.
/// Sticky state, NOT derived from `indexingProgress`: each scan emits
/// `.indexing` / `.watching` after the access check, which would clobber
/// a progress-derived value before the UI rendered it. Fed instead by the
/// indexer's dedicated `accessUpdates` stream, so the "grant access" CTA
/// stays up until a later scan clears it.
var accessBlockedFolders: [URL] = []

let preferences = Preferences()

// MARK: - Private state
Expand Down Expand Up @@ -68,7 +76,8 @@ final class AppState {
store: store,
thumbnails: thumbnails,
actions: actions,
preferences: prefs
preferences: prefs,
appState: self
)

// Register the current hotkey chord.
Expand All @@ -85,6 +94,15 @@ final class AppState {
}
}

// Access-blocked stream → sticky observable property. Kept off the
// progress stream so later `.indexing`/`.watching` events can't
// clear the "grant access" CTA before it's seen.
Task { [weak self] in
for await folders in indexer.accessUpdates {
await MainActor.run { self?.accessBlockedFolders = folders }
}
}

// Preferences stream → targeted update per change.
Task { [weak self] in
let stream = prefs.changes
Expand Down
40 changes: 34 additions & 6 deletions Sources/Vista/MenuBarContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ struct MenuBarContentView: View {
var body: some View {
Text(statusLine)

// Surfaced only when a folder we already indexed can't be read.
// The index is preserved while this shows — granting access lets
// the scan resume without re-processing anything.
if !appState.accessBlockedFolders.isEmpty {
Button("Grant Folder Access…") {
openFullDiskAccessSettings()
}
}

Divider()

Button("Search Screenshots…") {
Expand Down Expand Up @@ -106,23 +115,42 @@ struct MenuBarContentView: View {
}

private var statusLine: String {
// Access-blocked is sticky state that outlives a single progress
// event, so it takes precedence over whatever the scan reports next.
if !appState.accessBlockedFolders.isEmpty {
return "Can't read your screenshots folder — grant access"
}
switch appState.indexingProgress {
case .idle:
return "Vista — idle"
return "Vista — up to date"
case .enumerating(let folders):
return folders == 1 ? "Scanning folder…" : "Scanning \(folders) folders…"
case .indexing(let done, let total):
return folders == 1
? "Scanning your screenshots folder…"
: "Scanning your screenshots folders…"
case .indexing(let done, let total, let indexed):
if total == 0 {
// Empty queue = fully resumed from the DB, everything
// was already indexed. Jump straight to the steady-state
// message so the user doesn't see a flash of "0 / 0".
return "Up to date"
return "\(indexed.formatted()) screenshots ready"
}
return "OCR'ing \(done) / \(total) new images"
// Show the work left to do plus how many are already searchable,
// so a large backlog never reads as "nothing indexed".
return "Reading text from screenshots · \(done.formatted()) of \(total.formatted()) · \(indexed.formatted()) ready"
case .watching(let indexed):
return "\(indexed) screenshots indexed"
return "\(indexed.formatted()) screenshots ready"
}
}

/// Opens System Settings → Privacy & Security → Full Disk Access, where
/// the user can re-enable Vista's access to the (TCC-protected) iCloud
/// screenshots folder.
private func openFullDiskAccessSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") {
NSWorkspace.shared.open(url)
}
}

}

extension HotKeyChord {
Expand Down
33 changes: 32 additions & 1 deletion Sources/Vista/PanelContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ struct PanelContentView: View {
let thumbnails: ThumbnailCache
let actions: ActionHandlers
let preferences: Preferences
let appState: AppState
let dismiss: () -> Void

// Drives keyboard focus so the search field is live the moment the
Expand Down Expand Up @@ -57,7 +58,9 @@ struct PanelContentView: View {
VStack(spacing: 0) {
searchBar
Divider()
if model.results.isEmpty {
if !appState.accessBlockedFolders.isEmpty, model.results.isEmpty {
accessBlockedState
} else if model.results.isEmpty {
emptyState
} else {
resultsGrid
Expand Down Expand Up @@ -136,6 +139,34 @@ struct PanelContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

/// Shown instead of the "nothing indexed" empty state when a folder we
/// already indexed can't be read. The distinction matters: the index is
/// intact, so the message reassures rather than implying data loss, and
/// points the user at the one action that fixes it.
private var accessBlockedState: some View {
VStack(spacing: 10) {
Image(systemName: "lock.shield")
.font(.system(size: 48, weight: .light))
.foregroundStyle(.tertiary)
Text("Vista can't read your screenshots folder")
.font(.headline)
.foregroundStyle(.secondary)
Text("Your index is safe — nothing was deleted. Grant access and Vista picks up where it left off.")
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button("Grant Folder Access…") {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
.padding(.top, 4)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

/// Hint text for the empty state. Picks the most relevant folder to
/// name — a user-added folder if one exists, otherwise the system
/// default if it's being watched, otherwise tells the user to add one.
Expand Down
23 changes: 19 additions & 4 deletions Sources/Vista/PanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ public final class PanelController {
private let thumbnails: ThumbnailCache
private let actions: ActionHandlers
private let preferences: Preferences
// Held so the panel's empty state can react to access-blocked status.
// `unowned` on purpose: AppState owns this PanelController, so a strong
// ref back would form a retain cycle. Both live for the whole app, so
// unowned is safe (it's never accessed after AppState is gone).
private unowned let appState: AppState

/// The app that was frontmost when the user invoked vista's hotkey —
/// captured before we steal focus so "Paste to Front App" can aim
Expand All @@ -28,16 +33,18 @@ public final class PanelController {
/// the view-model is already fresh and there's nothing to reset.
private var lastHiddenAt: Date?

public init(
init(
store: ScreenshotStore,
thumbnails: ThumbnailCache,
actions: ActionHandlers,
preferences: Preferences
preferences: Preferences,
appState: AppState
) {
self.store = store
self.thumbnails = thumbnails
self.actions = actions
self.preferences = preferences
self.appState = appState

// Wire the paste-to-front-app action. The closure runs on the
// main actor (ActionHandlers is @MainActor) so it's safe to touch
Expand Down Expand Up @@ -65,12 +72,19 @@ public final class PanelController {
// previous query is unlikely to still be relevant, wipe back to
// a clean state before the window is visible. Decided on each
// show so changing the timeout in Settings takes effect
// immediately. Skip on the very first show of the session
// (lastHiddenAt == nil) — the view-model is already fresh.
// immediately. Any shorter gap (and the very first show, when
// lastHiddenAt is nil) falls through to a plain reload so freshly
// indexed screenshots appear.
if let last = lastHiddenAt,
let timeout = preferences.panelResetTimeout.seconds,
Date().timeIntervalSince(last) >= timeout {
viewModel?.resetState()
} else {
// Otherwise keep the user's query but refresh the rows — the
// indexer may have added screenshots since the panel was last
// shown, and without this the grid would keep showing the stale
// (possibly empty) result set from a previous open.
viewModel?.reload()
}
Comment thread
GordonBeeming marked this conversation as resolved.
// Apply the latest panel-size preference every show so Appearance
// slider changes take effect without needing a relaunch.
Expand Down Expand Up @@ -149,6 +163,7 @@ public final class PanelController {
thumbnails: thumbnails,
actions: actions,
preferences: preferences,
appState: appState,
dismiss: { [weak self] in self?.hidePanel() }
)

Expand Down
46 changes: 32 additions & 14 deletions Sources/Vista/SearchViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ public final class SearchViewModel {
private let store: ScreenshotStore
private var debounceTask: Task<Void, Never>?

/// Bumped on every query. Each in-flight search captures the value at
/// launch and only applies its results if it's still current — so a
/// slower earlier search can't land on top of a newer one when several
/// reloads / keystrokes overlap.
private var queryGeneration = 0

public init(store: ScreenshotStore) {
self.store = store
reload()
Expand Down Expand Up @@ -91,20 +97,32 @@ public final class SearchViewModel {
}

private func runQuery(_ text: String) {
do {
let query = QueryParser.parse(text)
let page = try store.search(query, limit: pageSize)
self.results = page
self.selectedIndex = 0
// A full page means there may be more behind it; a short page
// (or empty) means we've hit the end of the index.
self.canLoadMore = page.count == pageSize
self.isLoadingMore = false
} catch {
NSLog("vista: search failed: \(error)")
self.results = []
self.canLoadMore = false
self.isLoadingMore = false
queryGeneration &+= 1
let generation = queryGeneration
let query = QueryParser.parse(text)
Task { [weak self, store, pageSize] in
do {
// Run the read off the main actor: `store`'s serial queue is
// shared with the indexer's writes, so a synchronous search on
// the main thread can stall the UI behind a write batch — and
// this now runs on every panel show, not just on keystrokes.
let page = try await Task.detached(priority: .userInitiated) {
try store.search(query, limit: pageSize)
}.value
guard let self, generation == self.queryGeneration else { return }
self.results = page
self.selectedIndex = 0
// A full page means there may be more behind it; a short page
// (or empty) means we've hit the end of the index.
self.canLoadMore = page.count == pageSize
self.isLoadingMore = false
} catch {
guard let self, generation == self.queryGeneration else { return }
NSLog("vista: search failed: \(error)")
self.results = []
self.canLoadMore = false
self.isLoadingMore = false
}
}
}

Expand Down
Loading
Loading