···1717 var importStatus: String = ""
18181919 var undoManager: UndoManager?
2020+ var workspace: WorkspaceDB?
2121+ private var saveTask: Task<Void, Never>?
20222123 // Remember cursor position per group
2224 private var groupCursorPositions: [UUID: Int] = [:]
23252426 // Filters: command-click to toggle hiding photos with these attributes
2527 var hiddenRatings: Set<Int> = [] // ratings to hide (1-5)
2828+ var hideUnrated: Bool = false
2629 var hidePicks: Bool = false
2730 var hideRejects: Bool = false
2831···3336 hiddenRatings.insert(rating)
3437 }
3538 ensureVisibleSelection()
3939+ scheduleSave()
3640 }
37413842 func togglePickFilter() {
3943 hidePicks.toggle()
4044 ensureVisibleSelection()
4545+ scheduleSave()
4146 }
42474348 func toggleRejectFilter() {
4449 hideRejects.toggle()
4550 ensureVisibleSelection()
5151+ scheduleSave()
5252+ }
5353+5454+ func toggleUnratedFilter() {
5555+ hideUnrated.toggle()
5656+ ensureVisibleSelection()
5757+ scheduleSave()
4658 }
47594860 func isPhotoFiltered(_ photo: Photo) -> Bool {
4961 if hidePicks && photo.flag == .pick { return true }
5062 if hideRejects && photo.flag == .reject { return true }
6363+ if hideUnrated && photo.rating == 0 && photo.flag == .none { return true }
5164 if photo.rating > 0 && hiddenRatings.contains(photo.rating) { return true }
5265 return false
5366 }
···258271 session.applyPhotoState(photo, rating: oldRating, flag: oldFlag, actionName: actionName)
259272 }
260273 undoManager?.setActionName(actionName)
274274+ scheduleSave()
261275 }
262276263277 func setRating(_ rating: Int) {
···314328315329 private func resetZoom() {
316330 zoomFaceIndex = nil
331331+ }
332332+333333+ // MARK: - Workspace persistence
334334+335335+ /// Debounced auto-save — coalesces rapid changes into a single write
336336+ func scheduleSave() {
337337+ saveTask?.cancel()
338338+ saveTask = Task { @MainActor [weak self] in
339339+ try? await Task.sleep(for: .milliseconds(500))
340340+ guard !Task.isCancelled else { return }
341341+ self?.saveWorkspace()
342342+ }
343343+ }
344344+345345+ func saveWorkspace() {
346346+ guard let workspace, let sourceFolder else { return }
347347+ let allPhotos = groups.flatMap(\.photos)
348348+ workspace.savePhotos(allPhotos, sourceFolder: sourceFolder)
349349+ workspace.saveGroups(groups, sourceFolder: sourceFolder)
350350+ workspace.saveSettings(session: self)
351351+ }
352352+353353+ func openWorkspace(folder: URL) -> Bool {
354354+ guard let db = WorkspaceDB(folder: folder) else { return false }
355355+ self.workspace = db
356356+357357+ guard db.hasCachedData else { return false }
358358+359359+ let savedPhotos = db.loadPhotos()
360360+ let groupOrder = db.loadGroupOrder()
361361+362362+ // Rebuild photos keyed by relative path
363363+ var photosByPath: [String: Photo] = [:]
364364+ for saved in savedPhotos {
365365+ let url = folder.appendingPathComponent(saved.path)
366366+ guard FileManager.default.fileExists(atPath: url.path) else { continue }
367367+ let photo = Photo(url: url)
368368+ if let pairedPath = saved.pairedPath {
369369+ let pairedURL = folder.appendingPathComponent(pairedPath)
370370+ if FileManager.default.fileExists(atPath: pairedURL.path) {
371371+ photo.pairedURL = pairedURL
372372+ }
373373+ }
374374+ photo.rating = saved.rating
375375+ photo.flag = saved.flag
376376+ photo.blurScore = saved.blurScore
377377+ photo.faceSharpness = saved.faceSharpness
378378+ photo.faceRegions = saved.faceRegions
379379+ photo.pixelWidth = saved.pixelWidth
380380+ photo.pixelHeight = saved.pixelHeight
381381+ photo.fileSize = saved.fileSize
382382+ photo.pairedPixelWidth = saved.pairedPixelWidth
383383+ photo.pairedPixelHeight = saved.pairedPixelHeight
384384+ photo.pairedFileSize = saved.pairedFileSize
385385+ photo.captureDate = saved.captureDate
386386+ photosByPath[saved.path] = photo
387387+ }
388388+389389+ // Rebuild groups in saved order
390390+ var photosByGroup: [String: [Photo]] = [:]
391391+ for saved in savedPhotos {
392392+ guard let groupID = saved.groupID, let photo = photosByPath[saved.path] else { continue }
393393+ photosByGroup[groupID, default: []].append(photo)
394394+ }
395395+396396+ var rebuiltGroups: [PhotoGroup] = []
397397+ for groupID in groupOrder {
398398+ guard let photos = photosByGroup[groupID], !photos.isEmpty else { continue }
399399+ rebuiltGroups.append(PhotoGroup(photos: photos))
400400+ }
401401+402402+ // Add any ungrouped photos (shouldn't happen but safety)
403403+ let groupedPaths = Set(savedPhotos.compactMap { $0.groupID != nil ? $0.path : nil })
404404+ let ungrouped = photosByPath.filter { !groupedPaths.contains($0.key) }.map(\.value)
405405+ if !ungrouped.isEmpty {
406406+ rebuiltGroups.append(PhotoGroup(photos: ungrouped))
407407+ }
408408+409409+ guard !rebuiltGroups.isEmpty else { return false }
410410+411411+ self.groups = rebuiltGroups
412412+ db.loadSettings(into: self)
413413+414414+ // Clamp navigation indices
415415+ selectedGroupIndex = min(selectedGroupIndex, groups.count - 1)
416416+ if let group = selectedGroup {
417417+ selectedPhotoIndex = min(selectedPhotoIndex, group.photos.count - 1)
418418+ }
419419+420420+ return true
317421 }
318422}
+2
cull/Services/PhotoImporter.swift
···123123 var pairedPixelWidth: Int = 0
124124 var pairedPixelHeight: Int = 0
125125 var pairedFileSize: Int64 = 0
126126+127127+ nonisolated init() {}
126128 }
127129128130 /// Read all metadata from a single CGImageSource open — date, dimensions, file size, paired metadata
+16-8
cull/Services/ThumbnailCache.swift
···4747 let diskPath = diskCacheURL.appendingPathComponent(stableDiskKey(for: photo.url))
4848 let pixelSize = maxPixelSize
49495050- let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in
5050+ let image: NSImage? = await Task.detached { () -> NSImage? in
5151 if let diskImage = NSImage(contentsOf: diskPath) {
5252 return diskImage
5353 }
···71717272 let loadURL = photo.imageURL
73737474- let image: NSImage? = await Task.detached(priority: .userInitiated) { () -> NSImage? in
7474+ let image: NSImage? = await Task.detached { () -> NSImage? in
7575 Self.loadFullPreviewSync(from: loadURL)
7676 }.value
7777···162162 }
163163 for await (key, image) in group {
164164 if let image {
165165- await MainActor.run { mc.setObject(image, forKey: key as NSString) }
165165+ // NSCache is thread-safe, no need for MainActor
166166+ mc.setObject(image, forKey: key as NSString)
166167 }
167168 }
168169 }
···224225 for batchStart in stride(from: 0, to: work.count, by: 4) {
225226 guard !Task.isCancelled else { return }
226227 let batch = Array(work[batchStart..<min(batchStart + 4, work.count)])
227227- await withTaskGroup(of: (String, NSImage?).self) { group in
228228+ let batchKeys = await withTaskGroup(of: (String, NSImage?).self, returning: [String].self) { group in
228229 for (key, loadURL) in batch {
229230 group.addTask {
230231 guard !Task.isCancelled else { return (key, nil) }
231232 return (key, Self.loadFullPreviewSync(from: loadURL))
232233 }
233234 }
235235+ var keys: [String] = []
234236 for await (key, image) in group {
235237 if let image {
236236- await MainActor.run {
237237- pc.setObject(image, forKey: key as NSString)
238238- self.previewKeys.insert(key)
239239- }
238238+ pc.setObject(image, forKey: key as NSString)
239239+ keys.append(key)
240240+ }
241241+ }
242242+ return keys
243243+ }
244244+ if !batchKeys.isEmpty {
245245+ await MainActor.run { [batchKeys] in
246246+ for key in batchKeys {
247247+ self.previewKeys.insert(key)
240248 }
241249 }
242250 }
+348
cull/Services/WorkspaceDB.swift
···11+import CoreGraphics
22+import Foundation
33+import SQLite3
44+55+/// Persists workspace state (ratings, flags, analysis, groups) in a SQLite database
66+/// stored as `.cull.db` in the source photo folder.
77+final class WorkspaceDB: @unchecked Sendable {
88+ private var db: OpaquePointer?
99+ private let dbURL: URL
1010+1111+ init?(folder: URL) {
1212+ self.dbURL = folder.appendingPathComponent(".cull.db")
1313+ guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { return nil }
1414+1515+ // WAL mode for better concurrent read/write
1616+ exec("PRAGMA journal_mode=WAL")
1717+ exec("PRAGMA synchronous=NORMAL")
1818+1919+ createTables()
2020+ }
2121+2222+ deinit {
2323+ sqlite3_close(db)
2424+ }
2525+2626+ // MARK: - Schema
2727+2828+ private func createTables() {
2929+ exec("""
3030+ CREATE TABLE IF NOT EXISTS photos (
3131+ path TEXT PRIMARY KEY,
3232+ paired_path TEXT,
3333+ rating INTEGER DEFAULT 0,
3434+ flag TEXT DEFAULT 'none',
3535+ blur_score REAL,
3636+ face_sharpness REAL,
3737+ face_regions TEXT,
3838+ pixel_width INTEGER DEFAULT 0,
3939+ pixel_height INTEGER DEFAULT 0,
4040+ file_size INTEGER DEFAULT 0,
4141+ paired_pixel_width INTEGER DEFAULT 0,
4242+ paired_pixel_height INTEGER DEFAULT 0,
4343+ paired_file_size INTEGER DEFAULT 0,
4444+ capture_date REAL,
4545+ group_id TEXT
4646+ )
4747+ """)
4848+4949+ exec("""
5050+ CREATE TABLE IF NOT EXISTS groups (
5151+ group_id TEXT PRIMARY KEY,
5252+ sort_order INTEGER
5353+ )
5454+ """)
5555+5656+ exec("""
5757+ CREATE TABLE IF NOT EXISTS settings (
5858+ key TEXT PRIMARY KEY,
5959+ value TEXT
6060+ )
6161+ """)
6262+ }
6363+6464+ // MARK: - Save
6565+6666+ func savePhotos(_ photos: [Photo], sourceFolder: URL) {
6767+ exec("BEGIN TRANSACTION")
6868+ let stmt = prepare("""
6969+ INSERT OR REPLACE INTO photos
7070+ (path, paired_path, rating, flag, blur_score, face_sharpness, face_regions,
7171+ pixel_width, pixel_height, file_size,
7272+ paired_pixel_width, paired_pixel_height, paired_file_size, capture_date, group_id)
7373+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
7474+ """)
7575+ defer { sqlite3_finalize(stmt) }
7676+7777+ for photo in photos {
7878+ let relativePath = photo.url.relativePath(from: sourceFolder)
7979+ let pairedPath = photo.pairedURL?.relativePath(from: sourceFolder)
8080+ let flagStr = flagToString(photo.flag)
8181+ let regionsJSON = encodeRegions(photo.faceRegions)
8282+8383+ sqlite3_reset(stmt)
8484+ bind(stmt, 1, relativePath)
8585+ bind(stmt, 2, pairedPath)
8686+ bind(stmt, 3, photo.rating)
8787+ bind(stmt, 4, flagStr)
8888+ bind(stmt, 5, photo.blurScore)
8989+ bind(stmt, 6, photo.faceSharpness)
9090+ bind(stmt, 7, regionsJSON)
9191+ bind(stmt, 8, photo.pixelWidth)
9292+ bind(stmt, 9, photo.pixelHeight)
9393+ bind(stmt, 10, photo.fileSize)
9494+ bind(stmt, 11, photo.pairedPixelWidth)
9595+ bind(stmt, 12, photo.pairedPixelHeight)
9696+ bind(stmt, 13, photo.pairedFileSize)
9797+ bind(stmt, 14, photo.captureDate?.timeIntervalSinceReferenceDate)
9898+ bind(stmt, 15, nil as String?) // group_id set separately
9999+ sqlite3_step(stmt)
100100+ }
101101+ exec("COMMIT")
102102+ }
103103+104104+ func saveGroups(_ groups: [PhotoGroup], sourceFolder: URL) {
105105+ exec("BEGIN TRANSACTION")
106106+ exec("DELETE FROM groups")
107107+108108+ let groupStmt = prepare("INSERT INTO groups (group_id, sort_order) VALUES (?,?)")
109109+ let photoStmt = prepare("UPDATE photos SET group_id = ? WHERE path = ?")
110110+ defer {
111111+ sqlite3_finalize(groupStmt)
112112+ sqlite3_finalize(photoStmt)
113113+ }
114114+115115+ for (i, group) in groups.enumerated() {
116116+ let groupID = group.id.uuidString
117117+ sqlite3_reset(groupStmt)
118118+ bind(groupStmt, 1, groupID)
119119+ bind(groupStmt, 2, i)
120120+ sqlite3_step(groupStmt)
121121+122122+ for photo in group.photos {
123123+ sqlite3_reset(photoStmt)
124124+ bind(photoStmt, 1, groupID)
125125+ bind(photoStmt, 2, photo.url.relativePath(from: sourceFolder))
126126+ sqlite3_step(photoStmt)
127127+ }
128128+ }
129129+ exec("COMMIT")
130130+ }
131131+132132+ func saveSetting(_ key: String, _ value: String?) {
133133+ if let value {
134134+ let stmt = prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?,?)")
135135+ defer { sqlite3_finalize(stmt) }
136136+ bind(stmt, 1, key)
137137+ bind(stmt, 2, value)
138138+ sqlite3_step(stmt)
139139+ } else {
140140+ let stmt = prepare("DELETE FROM settings WHERE key = ?")
141141+ defer { sqlite3_finalize(stmt) }
142142+ bind(stmt, 1, key)
143143+ sqlite3_step(stmt)
144144+ }
145145+ }
146146+147147+ func saveSettings(session: CullSession) {
148148+ saveSetting("selectedGroupIndex", "\(session.selectedGroupIndex)")
149149+ saveSetting("selectedPhotoIndex", "\(session.selectedPhotoIndex)")
150150+ saveSetting("hidePicks", session.hidePicks ? "1" : "0")
151151+ saveSetting("hideRejects", session.hideRejects ? "1" : "0")
152152+ saveSetting("hideUnrated", session.hideUnrated ? "1" : "0")
153153+ saveSetting("hiddenRatings", session.hiddenRatings.map(String.init).joined(separator: ","))
154154+ saveSetting("importRecursive", session.importRecursive ? "1" : "0")
155155+ }
156156+157157+ // MARK: - Load
158158+159159+ struct SavedPhoto {
160160+ let path: String
161161+ let pairedPath: String?
162162+ let rating: Int
163163+ let flag: PhotoFlag
164164+ let blurScore: Double?
165165+ let faceSharpness: Double?
166166+ let faceRegions: [CGRect]
167167+ let pixelWidth: Int
168168+ let pixelHeight: Int
169169+ let fileSize: Int64
170170+ let pairedPixelWidth: Int
171171+ let pairedPixelHeight: Int
172172+ let pairedFileSize: Int64
173173+ let captureDate: Date?
174174+ let groupID: String?
175175+ }
176176+177177+ func loadPhotos() -> [SavedPhoto] {
178178+ let stmt = prepare("SELECT * FROM photos")
179179+ defer { sqlite3_finalize(stmt) }
180180+181181+ var results: [SavedPhoto] = []
182182+ while sqlite3_step(stmt) == SQLITE_ROW {
183183+ let path = getString(stmt, 0) ?? ""
184184+ let pairedPath = getString(stmt, 1)
185185+ let rating = Int(sqlite3_column_int(stmt, 2))
186186+ let flag = stringToFlag(getString(stmt, 3) ?? "none")
187187+ let blurScore = getOptionalDouble(stmt, 4)
188188+ let faceSharpness = getOptionalDouble(stmt, 5)
189189+ let regionsJSON = getString(stmt, 6)
190190+ let pixelWidth = Int(sqlite3_column_int(stmt, 7))
191191+ let pixelHeight = Int(sqlite3_column_int(stmt, 8))
192192+ let fileSize = sqlite3_column_int64(stmt, 9)
193193+ let pairedPixelWidth = Int(sqlite3_column_int(stmt, 10))
194194+ let pairedPixelHeight = Int(sqlite3_column_int(stmt, 11))
195195+ let pairedFileSize = sqlite3_column_int64(stmt, 12)
196196+ let captureDateInterval = getOptionalDouble(stmt, 13)
197197+ let groupID = getString(stmt, 14)
198198+199199+ results.append(SavedPhoto(
200200+ path: path,
201201+ pairedPath: pairedPath,
202202+ rating: rating,
203203+ flag: flag,
204204+ blurScore: blurScore,
205205+ faceSharpness: faceSharpness,
206206+ faceRegions: decodeRegions(regionsJSON),
207207+ pixelWidth: pixelWidth,
208208+ pixelHeight: pixelHeight,
209209+ fileSize: fileSize,
210210+ pairedPixelWidth: pairedPixelWidth,
211211+ pairedPixelHeight: pairedPixelHeight,
212212+ pairedFileSize: pairedFileSize,
213213+ captureDate: captureDateInterval.map { Date(timeIntervalSinceReferenceDate: $0) },
214214+ groupID: groupID
215215+ ))
216216+ }
217217+ return results
218218+ }
219219+220220+ func loadGroupOrder() -> [String] {
221221+ let stmt = prepare("SELECT group_id FROM groups ORDER BY sort_order")
222222+ defer { sqlite3_finalize(stmt) }
223223+ var ids: [String] = []
224224+ while sqlite3_step(stmt) == SQLITE_ROW {
225225+ if let id = getString(stmt, 0) { ids.append(id) }
226226+ }
227227+ return ids
228228+ }
229229+230230+ func loadSetting(_ key: String) -> String? {
231231+ let stmt = prepare("SELECT value FROM settings WHERE key = ?")
232232+ defer { sqlite3_finalize(stmt) }
233233+ bind(stmt, 1, key)
234234+ guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
235235+ return getString(stmt, 0)
236236+ }
237237+238238+ func loadSettings(into session: CullSession) {
239239+ if let v = loadSetting("selectedGroupIndex"), let i = Int(v) { session.selectedGroupIndex = i }
240240+ if let v = loadSetting("selectedPhotoIndex"), let i = Int(v) { session.selectedPhotoIndex = i }
241241+ if let v = loadSetting("hidePicks") { session.hidePicks = v == "1" }
242242+ if let v = loadSetting("hideRejects") { session.hideRejects = v == "1" }
243243+ if let v = loadSetting("hideUnrated") { session.hideUnrated = v == "1" }
244244+ if let v = loadSetting("hiddenRatings"), !v.isEmpty {
245245+ session.hiddenRatings = Set(v.split(separator: ",").compactMap { Int($0) })
246246+ }
247247+ if let v = loadSetting("importRecursive") { session.importRecursive = v == "1" }
248248+ }
249249+250250+ /// Returns true if this database has cached photo data
251251+ var hasCachedData: Bool {
252252+ let stmt = prepare("SELECT COUNT(*) FROM photos")
253253+ defer { sqlite3_finalize(stmt) }
254254+ guard sqlite3_step(stmt) == SQLITE_ROW else { return false }
255255+ return sqlite3_column_int(stmt, 0) > 0
256256+ }
257257+258258+ // MARK: - Helpers
259259+260260+ @discardableResult
261261+ private func exec(_ sql: String) -> Bool {
262262+ sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK
263263+ }
264264+265265+ private func prepare(_ sql: String) -> OpaquePointer? {
266266+ var stmt: OpaquePointer?
267267+ sqlite3_prepare_v2(db, sql, -1, &stmt, nil)
268268+ return stmt
269269+ }
270270+271271+ private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: String?) {
272272+ if let value {
273273+ sqlite3_bind_text(stmt, index, (value as NSString).utf8String, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
274274+ } else {
275275+ sqlite3_bind_null(stmt, index)
276276+ }
277277+ }
278278+279279+ private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: Int) {
280280+ sqlite3_bind_int(stmt, index, Int32(value))
281281+ }
282282+283283+ private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: Int64) {
284284+ sqlite3_bind_int64(stmt, index, value)
285285+ }
286286+287287+ private func bind(_ stmt: OpaquePointer?, _ index: Int32, _ value: Double?) {
288288+ if let value {
289289+ sqlite3_bind_double(stmt, index, value)
290290+ } else {
291291+ sqlite3_bind_null(stmt, index)
292292+ }
293293+ }
294294+295295+ private func getString(_ stmt: OpaquePointer?, _ index: Int32) -> String? {
296296+ guard let cStr = sqlite3_column_text(stmt, index) else { return nil }
297297+ return String(cString: cStr)
298298+ }
299299+300300+ private func getOptionalDouble(_ stmt: OpaquePointer?, _ index: Int32) -> Double? {
301301+ if sqlite3_column_type(stmt, index) == SQLITE_NULL { return nil }
302302+ return sqlite3_column_double(stmt, index)
303303+ }
304304+305305+ private func flagToString(_ flag: PhotoFlag) -> String {
306306+ switch flag {
307307+ case .none: "none"
308308+ case .pick: "pick"
309309+ case .reject: "reject"
310310+ }
311311+ }
312312+313313+ private func stringToFlag(_ str: String) -> PhotoFlag {
314314+ switch str {
315315+ case "pick": .pick
316316+ case "reject": .reject
317317+ default: .none
318318+ }
319319+ }
320320+321321+ private func encodeRegions(_ regions: [CGRect]) -> String? {
322322+ guard !regions.isEmpty else { return nil }
323323+ let arrays = regions.map { [Double($0.origin.x), Double($0.origin.y), Double($0.width), Double($0.height)] }
324324+ guard let data = try? JSONSerialization.data(withJSONObject: arrays) else { return nil }
325325+ return String(data: data, encoding: .utf8)
326326+ }
327327+328328+ private func decodeRegions(_ json: String?) -> [CGRect] {
329329+ guard let json, let data = json.data(using: .utf8),
330330+ let arrays = try? JSONSerialization.jsonObject(with: data) as? [[Double]] else { return [] }
331331+ return arrays.compactMap { arr in
332332+ guard arr.count == 4 else { return nil }
333333+ return CGRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
334334+ }
335335+ }
336336+}
337337+338338+extension URL {
339339+ func relativePath(from base: URL) -> String {
340340+ let basePath = base.standardizedFileURL.path
341341+ let selfPath = self.standardizedFileURL.path
342342+ if selfPath.hasPrefix(basePath) {
343343+ let relative = String(selfPath.dropFirst(basePath.count))
344344+ return relative.hasPrefix("/") ? String(relative.dropFirst()) : relative
345345+ }
346346+ return selfPath
347347+ }
348348+}
+49
cull/Views/ContentView.swift
···54545555 Task {
5656 do {
5757+ // Try loading from workspace first
5858+ if s.openWorkspace(folder: url) {
5959+ await MainActor.run { s.importStatus = "Loading from workspace..." }
6060+ let allPhotos = s.allPhotos
6161+6262+ // Still need to load thumbnails and previews
6363+ await MainActor.run { s.importStatus = "Loading thumbnails..." }
6464+ await c.preloadAllThumbnails(photos: allPhotos) { p in
6565+ await MainActor.run {
6666+ withAnimation(.linear(duration: 0.2)) {
6767+ s.importProgress = p * 0.7
6868+ }
6969+ }
7070+ }
7171+7272+ await MainActor.run { s.importStatus = "Loading previews..." }
7373+ let ahead = Array(allPhotos.prefix(30))
7474+ let behind = Array(allPhotos.suffix(30))
7575+ let initialPreviews = ahead + behind.reversed()
7676+ await c.preloadAllPreviews(photos: initialPreviews) { p in
7777+ await MainActor.run {
7878+ withAnimation(.linear(duration: 0.2)) {
7979+ s.importProgress = 0.7 + p * 0.3
8080+ }
8181+ }
8282+ }
8383+8484+ await MainActor.run {
8585+ s.importProgress = 1.0
8686+ s.isImporting = false
8787+ }
8888+ return
8989+ }
9090+9191+ // No workspace — full import
9292+ _ = WorkspaceDB(folder: url).map { s.workspace = $0 }
9393+5794 await MainActor.run { s.importStatus = "Scanning photos..." }
5895 let result = try await PhotoImporter.importFolder(url, recursive: s.importRecursive)
5996···142179 s.selectedGroupIndex = 0
143180 s.selectedPhotoIndex = 0
144181 s.isImporting = false
182182+ s.saveWorkspace()
145183 }
146184 } catch {
147185 await MainActor.run {
···224262225263 ToolbarItem(placement: .automatic) {
226264 HStack(spacing: 2) {
265265+ ToolbarFilterButton(
266266+ activeIcon: "circle.slash",
267267+ inactiveIcon: "circle.slash",
268268+ isActive: session.selectedPhoto?.rating == 0,
269269+ isFiltered: session.hideUnrated,
270270+ activeColor: .secondary,
271271+ action: { session.clearRatingAndFlag() },
272272+ filterAction: { session.toggleUnratedFilter() },
273273+ help: "Unrated (0) · ⌘Click to filter"
274274+ )
275275+227276 ForEach(1...5, id: \.self) { star in
228277 let isActive = star <= (session.selectedPhoto?.rating ?? 0)
229278 let isFiltered = session.hiddenRatings.contains(star)