From e426df70e4c4bf524a6badddefbf24cb68397516 Mon Sep 17 00:00:00 2001 From: Daniel Rodrigues <6717153+danieladr@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:06:22 -0300 Subject: [PATCH] Show folder discovery progress before indexing begins --- Package.swift | 1 + Sources/DigUpApp/AppModel.swift | 36 +++++++- Sources/DigUpApp/Engine.swift | 37 +++++++- Sources/DigUpApp/SettingsWindow.swift | 27 +++--- Sources/DigUpKit/Crawler.swift | 29 +++++- Sources/DigUpKit/Indexer.swift | 4 +- Sources/digup/IndexService.swift | 10 ++- .../DigUpAppTests/DiscoveryStatusTests.swift | 61 +++++++++++++ Tests/DigUpKitTests/CrawlProgressTests.swift | 90 +++++++++++++++++++ 9 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 Tests/DigUpAppTests/DiscoveryStatusTests.swift create mode 100644 Tests/DigUpKitTests/CrawlProgressTests.swift diff --git a/Package.swift b/Package.swift index 82cb4214..fed46958 100644 --- a/Package.swift +++ b/Package.swift @@ -56,6 +56,7 @@ let appDependencies: [Package.Dependency] = [ .package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.10.0"), ] let appTargets: [Target] = [ + .testTarget(name: "DigUpAppTests", dependencies: ["DigUpApp"]), // The menubar + hotkey app. build.sh wraps it into build.noindex/DigUp.app. (The product can't be called // "DigUp": on a case-insensitive disk it would collide with the `digup` CLI binary.) .executableTarget( diff --git a/Sources/DigUpApp/AppModel.swift b/Sources/DigUpApp/AppModel.swift index 6f67d386..ad542d0f 100644 --- a/Sources/DigUpApp/AppModel.swift +++ b/Sources/DigUpApp/AppModel.swift @@ -1,4 +1,5 @@ import AppKit +import DigUpKit import Observation /// State the menubar, the panel and the windows share. Engine updates arrive on the main thread. @@ -23,11 +24,13 @@ final class AppModel { /// One line for the menu and the windows: what the index is doing. var statusLine: String { let status = self.status + if case .failed(let problem) = status.activity { return "⚠︎ \(problem)" } + if status.code.scan != nil { return codeStatusLine } guard hasFolders else { return "No folders chosen yet" } switch status.activity { case .starting: return "Starting…" case .failed(let problem): return "⚠︎ \(problem)" - case .syncing: return "Checking folders…" + case .syncing: return scanLine(status.scan, code: false) case .indexing(let done, let total): let percent = total > 0 ? Int((Double(done) / Double(total) * 100).rounded(.down)) : 0 return "Indexing \(percent)% · \(done.formatted()) of \(total.formatted())" @@ -61,6 +64,32 @@ final class AppModel { } } + var isCheckingFolders: Bool { + if case .syncing = status.activity { return true } + return false + } + + private func scanLine(_ scan: CrawlProgress?, code: Bool) -> String { + let title = code ? "Checking code folders…" : "Checking folders…" + guard let scan, scan.checked > 0 else { return title } + return "\(title) · \(scan.checked.formatted()) entries checked" + } + + /// Discovery counts include folders and skipped entries; they are never an indexing denominator. + var indexDetail: String { + if isCheckingFolders { + if status.files == 0 { return "Finding files to index. The total is available after this scan." } + return "\(status.searchable.formatted()) files searchable · checking for changes" + } + var text = "\(status.searchable.formatted()) of \(status.files.formatted()) files searchable" + if status.pending > 0 { text += " · \(status.pending.formatted()) waiting" } + if status.unfinished > 0 { + text += " · \(status.unfinished.formatted()) long \(status.unfinished == 1 ? "file" : "files") to finish" + } + if download.isActive { text += " · model \(download.progressText)" } + return text + } + /// While the model isn't there, how its download goes. private var downloadLine: String? { switch download.phase { @@ -75,6 +104,7 @@ final class AppModel { /// 0…1 for the menubar ring and progress bars: the model download, then an indexing pass or the rest of long files. var progress: Double? { + if isCheckingFolders || status.code.scan != nil { return nil } if download.isActive { return download.fraction } switch status.activity { case .indexing(let done, let total) where total > 0: return Double(done) / Double(total) @@ -90,6 +120,7 @@ final class AppModel { var codeStatusLine: String { let code = status.code guard hasCode else { return "Code search is off" } + if let scan = code.scan { return scanLine(scan, code: true) } if !download.isReady { return "Code is read once the model has downloaded" } if let indexing = code.indexing, indexing.total > 0 { let percent = Int((Double(indexing.done) / Double(indexing.total) * 100).rounded(.down)) @@ -112,6 +143,8 @@ final class AppModel { /// A few words for the search bar while results may be incomplete: "Indexing 42%", "Downloading model · 42%". var activityBadge: String? { + if isCheckingFolders { return "Checking folders" } + if status.code.scan != nil { return "Checking code folders" } if download.isActive { return "Downloading model · \(Int(download.fraction * 100))%" } if case .indexing(let done, let total) = status.activity, total > 0 { return "Indexing \(Int(Double(done) / Double(total) * 100))%" @@ -131,6 +164,7 @@ final class AppModel { /// Why results may be incomplete right now, or nil. var resultsCaveat: String? { + if isCheckingFolders { return "Still checking folders: some files haven't been found yet" } if let meaningUnavailable { return meaningUnavailable } if case .indexing(let done, let total) = status.activity, total > 0 { return "Still indexing (\(Int(Double(done) / Double(total) * 100))%): some files aren't in yet" diff --git a/Sources/DigUpApp/Engine.swift b/Sources/DigUpApp/Engine.swift index a91121f6..bafc1e87 100644 --- a/Sources/DigUpApp/Engine.swift +++ b/Sources/DigUpApp/Engine.swift @@ -23,6 +23,8 @@ nonisolated struct EngineStatus: Sendable, Equatable { } var activity = Activity.starting + /// Transient discovery counts, separate from committed index totals. + var scan: CrawlProgress? var encoder = Encoder.stopped var files = 0 // in the index, any state var searchable = 0 // done or partial @@ -43,6 +45,7 @@ nonisolated struct EngineStatus: Sendable, Equatable { var code = Code() struct Code: Sendable, Equatable { + var scan: CrawlProgress? var files = 0 var searchable = 0 var pending = 0 @@ -536,6 +539,10 @@ nonisolated final class Engine: @unchecked Sendable { helperOutput = Data() pruneWanted = false update { // the helper says first if any are still missing or unreadable + $0.waitingForModel = false + if !roots.isEmpty { $0.activity = .syncing } + $0.scan = nil + $0.code.scan = nil $0.missingRoots = [] $0.unreadableRoots = [] $0.code.missingRoots = [] @@ -580,13 +587,22 @@ nonisolated final class Engine: @unchecked Sendable { func int(_ key: String) -> Int { event[key] as? Int ?? 0 } if event["code"] as? Bool == true { return handleCode(event) } switch event["event"] as? String { + case "scanning": + update { + $0.activity = .syncing + $0.scan = CrawlProgress(checked: int("checked"), root: event["root"] as? String) + } case "sync": + update { + $0.scan = nil + if case .syncing = $0.activity { $0.activity = .idle } + } if let started = event["started"] as? Double, let wanted = syncWantedAt, started >= wanted.timeIntervalSince1970 { syncWantedAt = nil } applyCounts(event) - if int("new") > 0 { update { $0.activity = .indexing(done: 0, total: int("new")) } } + if int("pending") > 0 { update { $0.activity = .indexing(done: 0, total: int("pending")) } } log("sync: \(int("added")) new · \(int("changed")) changed · \(int("moved")) moved · " + "\(int("removed")) removed · \(int("unchanged")) unchanged in \(int("ms")) ms") case "file": @@ -608,12 +624,18 @@ nonisolated final class Engine: @unchecked Sendable { log("index: \(what): \(states) in \(int("ms")) ms") case "idle": applyCounts(event) - update { $0.activity = .idle } + update { + $0.scan = nil + $0.code.scan = nil + $0.activity = .idle + } refreshSearcher(force: true) case "waiting": applyCounts(event) update { $0.activity = .idle + $0.scan = nil + $0.code.scan = nil $0.waitingForModel = true } log("indexer: waiting for the model to download") @@ -635,6 +657,10 @@ nonisolated final class Engine: @unchecked Sendable { case "reindex": log("indexer: the model or settings changed, indexing everything again (was: \(event["from"] as? String ?? "?"))") case "error": + update { + $0.scan = nil + if case .syncing = $0.activity { $0.activity = .idle } + } log("indexer: \(event["message"] as? String ?? "error")") if event["fatal"] as? Bool == true { update { $0.activity = .failed("Indexing stopped") } } default: @@ -646,7 +672,10 @@ nonisolated final class Engine: @unchecked Sendable { private func handleCode(_ event: [String: Any]) { func int(_ key: String) -> Int { event[key] as? Int ?? 0 } switch event["event"] as? String { + case "scanning": + update { $0.code.scan = CrawlProgress(checked: int("checked"), root: event["root"] as? String) } case "sync": + update { $0.code.scan = nil } applyCounts(event) // New code is read right away only when the main index has nothing waiting; else it waits its turn. if int("new") > 0, int("pending") == 0 { update { $0.code.indexing = .init(done: 0, total: int("new")) } } @@ -677,6 +706,7 @@ nonisolated final class Engine: @unchecked Sendable { case "reindex": log("indexer: the model changed, the code index starts over") case "error": + update { $0.code.scan = nil } log("indexer: code: \(event["message"] as? String ?? "error")") if event["fatal"] as? Bool == true { update { $0.activity = .failed("Indexing stopped") } } default: @@ -712,6 +742,9 @@ nonisolated final class Engine: @unchecked Sendable { if case .indexing = $0.activity { $0.activity = .idle } if case .finishing = $0.activity { $0.activity = .idle } if case .starting = $0.activity { $0.activity = .idle } + if case .syncing = $0.activity { $0.activity = .idle } + $0.scan = nil + $0.code.scan = nil $0.code.indexing = nil } refreshSearcher(force: true) diff --git a/Sources/DigUpApp/SettingsWindow.swift b/Sources/DigUpApp/SettingsWindow.swift index f56d40fd..8ed6e95b 100644 --- a/Sources/DigUpApp/SettingsWindow.swift +++ b/Sources/DigUpApp/SettingsWindow.swift @@ -273,7 +273,7 @@ private struct FolderSettings: View { .foregroundStyle(.secondary) } ForEach(summaries) { summary in - FolderStatusRow(summary: summary) { removing = summary } + FolderStatusRow(summary: summary, checking: state.model.isCheckingFolders) { removing = summary } } } else { ProgressView().controlSize(.small).frame(maxWidth: .infinity) @@ -434,10 +434,12 @@ private struct IndexProgress: View { HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 5) { Text(model.statusLine).font(.system(size: 13, weight: .semibold)) - if let progress = model.progress { + if model.isCheckingFolders { + ProgressView().controlSize(.small).accessibilityLabel("Checking folders") + } else if let progress = model.progress { ProgressView(value: progress).controlSize(.small) } - Text(detail) + Text(model.indexDetail) .font(.system(size: 11)) .foregroundStyle(.secondary) } @@ -448,21 +450,11 @@ private struct IndexProgress: View { } } - /// "357 of 365 files searchable · 8 waiting · 2 long files to finish" - private var detail: String { - let status = model.status - var text = "\(status.searchable.formatted()) of \(status.files.formatted()) files searchable" - if status.pending > 0 { text += " · \(status.pending.formatted()) waiting" } - if status.unfinished > 0 { - text += " · \(status.unfinished.formatted()) long \(status.unfinished == 1 ? "file" : "files") to finish" - } - if model.download.isActive { text += " · model \(model.download.progressText)" } - return text - } } private struct FolderStatusRow: View { let summary: Engine.RootSummary + var checking = false let remove: () -> Void var body: some View { @@ -499,6 +491,7 @@ private struct FolderStatusRow: View { Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 2) { Text(summary.missing || summary.unreadable ? "Kept for when it's back" + : checking && summary.files == 0 ? "Checking…" : "\(summary.indexed.formatted()) of \(summary.files.formatted()) indexed") .font(.system(size: 12, weight: .medium).monospacedDigit()) if !states.isEmpty { @@ -620,7 +613,9 @@ private struct CodeSettings: View { HStack { VStack(alignment: .leading, spacing: 5) { Text(state.model.codeStatusLine).font(.system(size: 12, weight: .medium)) - if let indexing = state.model.status.code.indexing, indexing.total > 0 { + if state.model.status.code.scan != nil { + ProgressView().controlSize(.small).accessibilityLabel("Checking code folders") + } else if let indexing = state.model.status.code.indexing, indexing.total > 0 { ProgressView(value: Double(indexing.done) / Double(indexing.total)).controlSize(.small) } } @@ -637,7 +632,7 @@ private struct CodeSettings: View { .foregroundStyle(.secondary) } ForEach(state.codeSummaries) { summary in - FolderStatusRow(summary: summary) { removing = summary } + FolderStatusRow(summary: summary, checking: state.model.status.code.scan != nil) { removing = summary } } } header: { Text("Code folders") diff --git a/Sources/DigUpKit/Crawler.swift b/Sources/DigUpKit/Crawler.swift index ffe55824..9284ea36 100644 --- a/Sources/DigUpKit/Crawler.swift +++ b/Sources/DigUpKit/Crawler.swift @@ -17,13 +17,26 @@ public struct CrawlReport: Sendable { public var datasets: [String: Int] = [:] // folder → files skipped as a dataset } +/// Entries the folder walk has checked so far, including files DigUp may later skip. The count is cumulative across +/// roots and is not an estimate of how many files will enter the index. `root` is nil for the final update. +public struct CrawlProgress: Sendable, Equatable { + public let checked: Int + public let root: String? + + public init(checked: Int, root: String?) { + self.checked = checked + self.root = root + } +} + /// Walks the chosen folders and applies `Rules`. Reads metadata only, never file contents (except the 600-byte /// sniff that tells a real MPEG-TS video from a TypeScript file). /// /// For the code index (`IndexOptions.code`) it walks the code folders instead: code files only (`Rules.classifyCode`), /// in a git repo only those git keeps (`GitFiles`), and none in vendored or built folders. public enum Crawler { - public static func crawl(_ roots: [URL], options: IndexOptions) -> CrawlReport { + public static func crawl(_ roots: [URL], options: IndexOptions, + progress: ((CrawlProgress) -> Void)? = nil) -> CrawlReport { let fm = FileManager.default let home = fm.homeDirectoryForCurrentUser.path let keys = FileSystem.crawlKeys @@ -33,8 +46,13 @@ public enum Crawler { var found: [Candidate] = [] var foundPaths = Set() // a folder inside another root would be walked twice let rootPaths = Set(roots.map { $0.standardizedFileURL.path }) + var checked = 0 + let clock = ContinuousClock() + var lastUpdate = clock.now for root in roots.map({ $0.standardizedFileURL }) { + progress?(CrawlProgress(checked: checked, root: root.path)) + lastUpdate = clock.now if options.code { repos.enterRoot(root.path) } else if Rules.isProjectRoot(root.path) { @@ -112,6 +130,14 @@ public enum Crawler { // URL resource values are autoreleased; draining per file keeps a big walk from piling them up (the app // crawls on a background queue whose pool only drains when the whole sync is done). while let url = autoreleasepool(invoking: { walker.nextObject() as? URL }) { + if let progress { + checked += 1 + let now = clock.now + if now - lastUpdate >= .milliseconds(250) { + progress(CrawlProgress(checked: checked, root: root.path)) + lastUpdate = now + } + } autoreleasepool { visit(url) } @@ -152,6 +178,7 @@ public enum Crawler { } } report.candidates = kept + progress?(CrawlProgress(checked: checked, root: nil)) return report } } diff --git a/Sources/DigUpKit/Indexer.swift b/Sources/DigUpKit/Indexer.swift index 8c5bb405..3decac64 100644 --- a/Sources/DigUpKit/Indexer.swift +++ b/Sources/DigUpKit/Indexer.swift @@ -87,8 +87,8 @@ public final class Indexer { /// Crawls `roots` and reconciles the index: new files are queued, changed ones re-queued, moved ones re-pathed /// (no re-embedding), and files that are gone or newly excluded are removed. - public func sync(_ roots: [URL]) throws -> SyncSummary { - let crawl = Crawler.crawl(roots, options: options) + public func sync(_ roots: [URL], progress: ((CrawlProgress) -> Void)? = nil) throws -> SyncSummary { + let crawl = Crawler.crawl(roots, options: options, progress: progress) let existing = try store.files(under: roots.map { $0.standardizedFileURL.path }) var byPath: [String: FileRecord] = [:] var byInode: [String: FileRecord] = [:] diff --git a/Sources/digup/IndexService.swift b/Sources/digup/IndexService.swift index 5c5a7102..9b9a7249 100644 --- a/Sources/digup/IndexService.swift +++ b/Sources/digup/IndexService.swift @@ -12,7 +12,7 @@ import WinSDK /// /// Protocol: one command per line on stdin, one JSON event per line on stdout. /// stdin: sync | sync code | stop | backfill on | backfill off (EOF = stop) -/// stdout: {"event": "missing" | "pruned" | "sync" | "file" | "rest" | "summary" | "waiting" | "idle" | "error" | +/// stdout: {"event": "missing" | "pruned" | "scanning" | "sync" | "file" | "rest" | "summary" | "waiting" | "idle" | "error" | /// "exit", ...}; events about the code index say "code": true /// /// The loop: sync, index what the sync just queued (if it's a few files: new files go first, on any power source), @@ -215,7 +215,13 @@ final class IndexService: @unchecked Sendable { lock.withLock { yieldAt = Date() + max(took, 2) } } do { - let summary = try part.indexer.sync(availableRoots(part)) + let roots = availableRoots(part) + emit(["event": "scanning", "checked": 0, "code": part.code]) + let summary = try part.indexer.sync(roots) { [self] progress in + var event: [String: Any] = ["event": "scanning", "checked": progress.checked, "code": part.code] + if let root = progress.root { event["root"] = root } + emit(event) + } if summary.queued.count <= newFilesLimit { newFiles.formUnion(summary.queued) } emitCounts("sync", [ "code": part.code, diff --git a/Tests/DigUpAppTests/DiscoveryStatusTests.swift b/Tests/DigUpAppTests/DiscoveryStatusTests.swift new file mode 100644 index 00000000..f0802ba3 --- /dev/null +++ b/Tests/DigUpAppTests/DiscoveryStatusTests.swift @@ -0,0 +1,61 @@ +import Foundation +import DigUpKit +import Testing +@testable import DigUpApp + +@MainActor @Suite struct DiscoveryStatusTests { + private func model() -> AppModel { + var settings = AppSettings.load() + settings.roots = [URL(fileURLWithPath: "/example")] + settings.codeRoots = [URL(fileURLWithPath: "/code")] + return AppModel(settings: settings, download: ModelDownload( + folder: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString))) + } + + @Test func discoveryDoesNotPresentAnEmptyCompletedIndex() { + let model = model() + model.status.activity = .syncing + #expect(model.statusLine == "Checking folders…") + #expect(model.indexDetail == "Finding files to index. The total is available after this scan.") + #expect(model.progress == nil) + #expect(model.activityBadge == "Checking folders") + #expect(model.resultsCaveat == "Still checking folders: some files haven't been found yet") + } + + @Test func aRescanKeepsExistingSearchableCounts() { + let model = model() + model.status.activity = .syncing + model.status.files = 12 + model.status.searchable = 10 + model.status.pending = 2 + #expect(model.indexDetail == "10 files searchable · checking for changes") + } + + @Test func liveCountsNeverBecomeAnIndexingPercentage() { + let model = model() + model.status.activity = .syncing + model.status.scan = CrawlProgress(checked: 42, root: "/example") + #expect(model.statusLine == "Checking folders… · 42 entries checked") + #expect(model.progress == nil) + model.status.scan = nil + model.status.activity = .indexing(done: 1, total: 4) + model.status.files = 4 + model.status.searchable = 1 + model.status.pending = 3 + #expect(model.progress == 0.25) + #expect(model.indexDetail == "1 of 4 files searchable · 3 waiting") + #expect(model.activityBadge == "Indexing 25%") + } + + @Test func codeDiscoveryIsVisibleWithoutMainFolders() { + let model = model() + model.settings.roots = [] + model.status.activity = .idle + model.status.code.scan = CrawlProgress(checked: 7, root: "/code") + #expect(model.statusLine == "Checking code folders… · 7 entries checked") + #expect(model.codeStatusLine == model.statusLine) + #expect(model.progress == nil) + model.status.activity = .failed("Indexing stopped") + #expect(model.statusLine == "⚠︎ Indexing stopped") + } +} diff --git a/Tests/DigUpKitTests/CrawlProgressTests.swift b/Tests/DigUpKitTests/CrawlProgressTests.swift new file mode 100644 index 00000000..35654354 --- /dev/null +++ b/Tests/DigUpKitTests/CrawlProgressTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +@testable import DigUpKit + +@Suite struct CrawlProgressTests { + @Test func emptyFolderReportsStartAndFinal() throws { + let root = try makeFolder() + defer { try? FileManager.default.removeItem(at: root) } + var updates: [CrawlProgress] = [] + + let report = Crawler.crawl([root], options: IndexOptions()) { updates.append($0) } + + #expect(report.candidates.isEmpty) + #expect(updates == [CrawlProgress(checked: 0, root: root.standardizedFileURL.path), + CrawlProgress(checked: 0, root: nil)]) + } + + @Test func countsEnumeratedEntriesIncludingExcludedFoldersWithoutChangingCandidates() throws { + let root = try makeFolder() + defer { try? FileManager.default.removeItem(at: root) } + let excluded = root.appendingPathComponent("excluded") + try FileManager.default.createDirectory(at: excluded, withIntermediateDirectories: true) + try "keep".write(to: root.appendingPathComponent("keep.txt"), atomically: false, encoding: .utf8) + try "skip".write(to: excluded.appendingPathComponent("skip.txt"), atomically: false, encoding: .utf8) + try "skip".write(to: root.appendingPathComponent("other.xyz"), atomically: false, encoding: .utf8) + var options = IndexOptions() + options.excludedFolders = [excluded.path] + var updates: [CrawlProgress] = [] + + let baseline = Crawler.crawl([root], options: options) + let observed = Crawler.crawl([root], options: options) { updates.append($0) } + + #expect(observed.candidates.map(\.path) == baseline.candidates.map(\.path)) + #expect(observed.skipped == baseline.skipped) + #expect(observed.skippedFolders == baseline.skippedFolders) + #expect(observed.candidates.map(\.path) == [FolderSelection.realPath(root.appendingPathComponent("keep.txt").path)]) + #expect(updates.first == CrawlProgress(checked: 0, root: root.standardizedFileURL.path)) + #expect(updates.last == CrawlProgress(checked: 3, root: nil)) + #expect(updates.map(\.checked) == updates.map(\.checked).sorted()) + } + + @Test func startsEachRootAndKeepsCumulativeCount() throws { + let first = try makeFolder() + let second = try makeFolder() + defer { + try? FileManager.default.removeItem(at: first) + try? FileManager.default.removeItem(at: second) + } + try "one".write(to: first.appendingPathComponent("one.txt"), atomically: false, encoding: .utf8) + try "two".write(to: second.appendingPathComponent("two.txt"), atomically: false, encoding: .utf8) + var updates: [CrawlProgress] = [] + + _ = Crawler.crawl([first, second], options: IndexOptions()) { updates.append($0) } + + #expect(updates.first == CrawlProgress(checked: 0, root: first.standardizedFileURL.path)) + #expect(updates.contains(CrawlProgress(checked: 1, root: second.standardizedFileURL.path))) + #expect(updates.last == CrawlProgress(checked: 2, root: nil)) + } + + @Test func indexerForwardsProgressBeforeWritingNewRows() throws { + let root = try makeFolder() + let index = try makeFolder() + defer { try? FileManager.default.removeItem(at: root) } + defer { try? FileManager.default.removeItem(at: index) } + try "note".write(to: root.appendingPathComponent("note.txt"), atomically: false, encoding: .utf8) + let store = try IndexStore(directory: index) + let indexer = try Indexer(store: store, options: IndexOptions(), + makeEmbedder: { throw WorkerError.notFound("no model needed to sync") }) + var updates: [CrawlProgress] = [] + var rowsDuringProgress: [Int] = [] + + let summary = try indexer.sync([root]) { update in + updates.append(update) + rowsDuringProgress.append((try? store.files(under: [root.path]).count) ?? -1) + } + + #expect(summary.added == 1) + #expect(updates.first == CrawlProgress(checked: 0, root: root.standardizedFileURL.path)) + #expect(updates.last?.root == nil) + #expect(updates.last?.checked == 1) + #expect(rowsDuringProgress.allSatisfy { $0 == 0 }) + #expect(try store.files(under: [root.path]).count == 1) + } + + private func makeFolder() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("DigUpCrawlProgress-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } +}