diff --git a/Sources/StorageScope/Stores/FilterStore.swift b/Sources/StorageScope/Stores/FilterStore.swift index ae89360..7febc83 100644 --- a/Sources/StorageScope/Stores/FilterStore.swift +++ b/Sources/StorageScope/Stores/FilterStore.swift @@ -108,6 +108,22 @@ final class FilterStore: ObservableObject { didSet { coordinateInvalidate() } } + /// Master toggle for the folder-exclusion list below. Off by default so existing + /// scans are unaffected until the user opts in. + @Published var excludeFoldersEnabled: Bool = false { + didSet { coordinateInvalidate() } + } + + /// Name-based entries (matched against any path component, e.g. `node_modules`) and + /// absolute-prefix entries (starting with `~` or `/`, e.g. `~/Library/Caches`) live in + /// one flat list here; `ScanStore` splits them into `ScanOptions`'s two fields when + /// building scan options. + @Published var excludedPaths: [String] = [ + "node_modules", ".git", "~/Library/Caches", "~/Library/Application Support" + ] { + didSet { coordinateInvalidate() } + } + /// Set when the user clicks a row on `TypeBreakdownView` — narrows downstream views /// (currently `.largestFiles`) to files whose `fileExtension` matches. Cleared on its own /// when the user dismisses the corresponding chip, or anytime `query` changes so the user diff --git a/Sources/StorageScope/Stores/ScanSession.swift b/Sources/StorageScope/Stores/ScanSession.swift index a40a5d4..4ce66dd 100644 --- a/Sources/StorageScope/Stores/ScanSession.swift +++ b/Sources/StorageScope/Stores/ScanSession.swift @@ -8,6 +8,10 @@ struct ScanSession { var appliedOptions: ScanStore.ScanOptionsSnapshot? var resultsNeedRefresh = false var lastScannedURL: URL? + /// Wall-clock start of the current/most-recent scan, for the elapsed-time display. + /// Set when `ScanStore.scan(_:)` starts; not reset on cancel so the footer can still + /// show "ran for Ns" alongside the cancellation notice. + var scanStartedAt: Date? /// Set when a scan is canceled mid-flight so the UI can acknowledge the cancel. /// Cleared the next time `scan(_:)` starts. Previously `cancelScan` wrote a "Scan cancelled" /// string into `progress.currentPath` that the footer never displayed (it only shows @@ -21,6 +25,10 @@ struct ScanSession { /// Nil after a cancellation, since cancellation is intentionally silent. var lastErrorCategory: ScanStore.ScanStoreErrorCategory? + /// True while an in-flight scan is paused (in-memory only — not persisted). Reset to + /// false whenever a new scan starts. + var isScanPaused = false + var canRescan: Bool { lastScannedURL != nil && !isScanning } @@ -28,4 +36,12 @@ struct ScanSession { var canCancelScan: Bool { isScanning } + + var canPauseScan: Bool { + isScanning && !isScanPaused + } + + var canResumeScan: Bool { + isScanning && isScanPaused + } } diff --git a/Sources/StorageScope/Stores/ScanStore.swift b/Sources/StorageScope/Stores/ScanStore.swift index 9fd158b..f02771d 100644 --- a/Sources/StorageScope/Stores/ScanStore.swift +++ b/Sources/StorageScope/Stores/ScanStore.swift @@ -29,6 +29,22 @@ final class ScanStore: ObservableObject { return caches.appendingPathComponent("StorageScopeDuplicateHashCache.json") } + /// Splits `FilterStore.excludedPaths` into `ScanOptions`'s two exclusion fields: an + /// entry starting with `~` or `/` is an absolute-prefix match, everything else is a + /// bare folder-name match against any path component. + private static func splitExcludedPaths(_ paths: [String]) -> (components: [String], prefixes: [String]) { + var components: [String] = [] + var prefixes: [String] = [] + for path in paths { + if path.hasPrefix("~") || path.hasPrefix("/") { + prefixes.append(path) + } else { + components.append(path) + } + } + return (components, prefixes) + } + struct ScanOptionsSnapshot: Equatable { let includeHiddenFiles: Bool let oldFileAgeDays: Int @@ -165,6 +181,11 @@ func setSelectedView(_ view: SmartView) { private var activeScanID: UUID? private var cancellation: ScanCancellation? private var scanTask: Task? + /// Previous progress tick's (item count, timestamp) for the EMA rate calculation in + /// the progress-consumer closure. Reset to nil on scan start and scan resume so a + /// paused interval isn't counted as a near-zero-rate tick. + private var lastRateTick: (count: Int, date: Date)? + private var smoothedItemsPerSecond: Double = 0 private let bookmarkStore = SecurityScopedBookmarkStore() private let hashCache = DuplicateHashCache( cacheURL: ScanStore.defaultHashCacheURL(), @@ -344,6 +365,15 @@ func setSelectedView(_ view: SmartView) { case progressConsumerFinished } + /// One tick on the progress stream: the throttled `ScanProgress` update, paired with + /// the same-cadence in-progress `StorageScan` snapshot when the scanner produced one. + /// Folding both into one stream keeps a single consumer child task rather than two, + /// while still letting `session.scan` update live as list views read it. + private struct ScanTick: Sendable { + let progress: ScanProgress + let snapshot: StorageScan? + } + var scanStage: ScanStage { if !isScanning { return scan == nil ? .idle : .complete @@ -377,6 +407,22 @@ func setSelectedView(_ view: SmartView) { session.canCancelScan } + var canPauseScan: Bool { + session.canPauseScan + } + + var canResumeScan: Bool { + session.canResumeScan + } + + var isScanPaused: Bool { + session.isScanPaused + } + + var scanStartedAt: Date? { + session.scanStartedAt + } + var canUseSelectedItemActions: Bool { selectedItem != nil && !isScanning } @@ -638,15 +684,106 @@ func setSelectedView(_ view: SmartView) { if activeScanID == cancelledScanID { let pathLabel = session.lastScannedURL?.lastPathComponent ?? "Scan" + // `scan` may already hold the last streamed partial snapshot (or a fully + // completed scan) by the time cancellation lands — preserve it rather than + // resetting progress to a zeroed-out state, so partial results stay visible. session.lastCancellationMessage = scan != nil - ? "Scan of \(pathLabel) was canceled. Previous results are still shown; rescan when you are ready." + ? "Scan of \(pathLabel) was canceled. Partial results are shown; rescan when you are ready." : "Scan of \(pathLabel) was canceled. Rescan or pick another folder when ready." activeScanID = nil cancellation = nil scanTask = nil isScanning = false - progress = ScanProgress(scannedItemCount: 0, totalBytes: 0, currentPath: "Scan cancelled") + session.isScanPaused = false + progress = ScanProgress( + scannedItemCount: scan?.scannedItemCount ?? progress.scannedItemCount, + totalBytes: scan?.totalBytes ?? progress.totalBytes, + currentPath: "Scan cancelled" + ) + } + } + + func excludeFolder(_ item: StorageItem) { + // Standardized to match `isExcluded`'s comparison path in the scanner (which + // standardizes the child URL before comparing) — otherwise a folder reached via a + // symlinked ancestor or non-canonical path segment would never match on rescan. + excludeFolder(atPath: item.url.standardizedFileURL.path) + } + + /// Path-based entry point for contexts that don't have a folder `StorageItem` on hand — + /// Duplicate Review rows are always files, so "Exclude This Folder" there targets the + /// file's containing directory rather than the file itself. + func excludeFolder(atPath path: String) { + guard !filters.excludedPaths.contains(path) else { + return + } + filters.excludedPaths.append(path) + // Force the master toggle on: without it the exclusion list is never applied to + // ScanOptions and the folder the user just chose wouldn't actually be excluded. + // Side effect: this also activates the pre-seeded default entries (node_modules, + // .git, etc.) if the user hadn't enabled exclusion before — acceptable since those + // defaults exist for exactly this purpose and the alternative (a no-op click) is worse. + filters.excludeFoldersEnabled = true + rescan() + } + + private func pauseResumeGuardedScanID() -> UUID? { + guard isScanning, let activeScanID else { + return nil + } + return activeScanID + } + + func pauseScan() { + guard pauseResumeGuardedScanID() != nil, let cancellation else { + return + } + cancellation.pause() + session.isScanPaused = true + } + + func resumeScan() { + guard pauseResumeGuardedScanID() != nil, let cancellation else { + return } + cancellation.resume() + session.isScanPaused = false + lastRateTick = nil + } + + /// Computes items/sec from the delta against the previous tick and smooths with an + /// EMA (0.3 instant / 0.7 previous) so the displayed rate doesn't jitter tick to tick. + /// Returns `progress` unchanged but with `itemsPerSecond` filled in. Called only from + /// the progress-consumer closure (MainActor), never from the scanner itself, so the + /// rate reflects wall-clock delivery time rather than scan-thread timing. + private func rateAnnotatedProgress(_ progress: ScanProgress) -> ScanProgress { + defer { lastRateTick = (progress.scannedItemCount, Date()) } + + guard let lastRateTick else { + return progress + } + + let itemDelta = progress.scannedItemCount - lastRateTick.count + let timeDelta = Date().timeIntervalSince(lastRateTick.date) + guard itemDelta > 0, timeDelta > 0 else { + return ScanProgress( + scannedItemCount: progress.scannedItemCount, + totalBytes: progress.totalBytes, + currentPath: progress.currentPath, + phase: progress.phase, + itemsPerSecond: smoothedItemsPerSecond + ) + } + + let instantRate = Double(itemDelta) / timeDelta + smoothedItemsPerSecond = 0.3 * instantRate + 0.7 * smoothedItemsPerSecond + return ScanProgress( + scannedItemCount: progress.scannedItemCount, + totalBytes: progress.totalBytes, + currentPath: progress.currentPath, + phase: progress.phase, + itemsPerSecond: smoothedItemsPerSecond + ) } private func scanUserGrantedURL(_ url: URL, access: SecurityScopedResourceAccess? = nil) { @@ -689,16 +826,24 @@ func setSelectedView(_ view: SmartView) { session.lastErrorCategory = nil session.resultsNeedRefresh = false isScanning = true + session.isScanPaused = false + session.scanStartedAt = Date() + lastRateTick = nil + smoothedItemsPerSecond = 0 progress = ScanProgress(scannedItemCount: 0, totalBytes: 0, currentPath: url.path) let scanID = UUID() let thresholds = ScanOptionPolicy.interactiveScanThresholds() + let (excludedComponents, excludedPrefixes) = Self.splitExcludedPaths(filters.excludedPaths) let options = ScanOptions( includeHidden: filters.includeHiddenFiles, oldFileAgeDays: filters.oldFileAgeDays, largeFileThreshold: thresholds.largeFileThreshold, duplicateCandidateThreshold: Int64(filters.duplicateCandidateThresholdMB) * 1_000_000, - maxRankedResults: Self.rankedResultsCap + maxRankedResults: Self.rankedResultsCap, + excludeEnabled: filters.excludeFoldersEnabled, + excludedPathComponents: excludedComponents, + excludedAbsolutePrefixes: excludedPrefixes ) let optionsSnapshot = currentScanOptions let scanCancellation = ScanCancellation() @@ -721,7 +866,14 @@ func setSelectedView(_ view: SmartView) { // Both inherit scanTask's cancellation: withTaskCancellationHandler propagates // Task.cancel() into the scanner's ScanCancellation handle AND finishes the // stream so the consumer exits cleanly. - let (progressStream, progressContinuation) = AsyncStream.makeStream() + let (progressStream, progressContinuation) = AsyncStream.makeStream() + // The scanner's `progress` and `onSnapshot` callbacks fire at the same + // throttled cadence (`onSnapshot` is invoked from inside `emitProgressLocked` + // right after the progress callback) but as two separate calls. Pair the + // latest snapshot with the next progress tick on this side rather than adding + // a second stream, so the consumer only has one `for await` loop to drain. + let pendingSnapshotLock = NSLock() + var pendingSnapshot: StorageScan? let result = try await withTaskCancellationHandler { try await withThrowingTaskGroup(of: ScanOutcome.self) { group in group.addTask(priority: .userInitiated) { @@ -736,20 +888,35 @@ func setSelectedView(_ view: SmartView) { options: options, cancellation: scanCancellation, progress: { progress in - progressContinuation.yield(progress) + pendingSnapshotLock.lock() + let snapshot = pendingSnapshot + pendingSnapshot = nil + pendingSnapshotLock.unlock() + progressContinuation.yield(ScanTick(progress: progress, snapshot: snapshot)) + }, + onSnapshot: { snapshot in + pendingSnapshotLock.lock() + pendingSnapshot = snapshot + pendingSnapshotLock.unlock() } ) return .result(scan) } group.addTask { - for await progress in progressStream { + for await tick in progressStream { if Task.isCancelled { break } await MainActor.run { [weak self, scanID, scanCancellation] in guard let self, self.isCurrentScan(scanID, cancellation: scanCancellation) else { return } - self.progress = progress + self.progress = self.rateAnnotatedProgress(tick.progress) + if let snapshot = tick.snapshot { + // Non-final assignment: `scan` will be overwritten again + // when the scan completes (or left as-is if cancelled), + // same field either way. + self.scan = snapshot + } } } return .progressConsumerFinished @@ -815,6 +982,7 @@ func setSelectedView(_ view: SmartView) { currentPath: "Scan complete" ) isScanning = false + session.isScanPaused = false refreshMountedVolumes() clearActiveScan(scanID, cancellation: scanCancellation) } catch FileSystemScannerError.cancelled { @@ -822,10 +990,18 @@ func setSelectedView(_ view: SmartView) { return } isScanning = false + session.isScanPaused = false markResultsNeedRefreshWhenCurrentScanCompletes = false selectedViewWhenCurrentScanCompletes = nil selectVerifiedCleanupWhenCurrentScanCompletes = false - progress = ScanProgress(scannedItemCount: 0, totalBytes: 0, currentPath: "Scan cancelled") + // Preserve whatever the last streamed snapshot published to `scan` (or, if no + // snapshot ever arrived, leave it nil) rather than discarding it — cancel now + // keeps partial results instead of wiping the in-progress state. + progress = ScanProgress( + scannedItemCount: scan?.scannedItemCount ?? progress.scannedItemCount, + totalBytes: scan?.totalBytes ?? progress.totalBytes, + currentPath: "Scan cancelled" + ) // Intentionally quiet: cancellation is not an alert-worthy failure. The footer // already surfaces "Scan cancelled"; surfacing errorMessage here would pop a // redundant alert after every Cmd+. Clear lastErrorCategory so a stale category diff --git a/Sources/StorageScope/Views/CleanupReviewView.swift b/Sources/StorageScope/Views/CleanupReviewView.swift index 1785520..21cb17d 100644 --- a/Sources/StorageScope/Views/CleanupReviewView.swift +++ b/Sources/StorageScope/Views/CleanupReviewView.swift @@ -92,12 +92,16 @@ struct CleanupReviewView: View { isChecked: store.selectedCleanupCandidateIDs.contains(candidate.id), isSelected: store.selectedItemID == candidate.item.id, canTrash: store.canMoveItemToTrash(candidate.item), + // Excluding mid-scan would silently no-op: excludeFolder() + // routes through rescan(), which guards on !isScanning. + canExclude: candidate.item.isContainer && candidate.item.id != store.scan?.rootItem.id && !store.isScanning, onToggle: { store.toggleCleanupCandidate(candidate) }, onIgnore: { store.ignoreCleanupCandidate(candidate) }, onReveal: { store.selectedItemID = candidate.item.id; store.revealSelectedItem() }, onOpen: { store.selectedItemID = candidate.item.id; store.openSelectedItem() }, onCopyPath: { store.selectedItemID = candidate.item.id; store.copySelectedPath() }, - onTrash: { store.moveCleanupCandidateToTrash(candidate) } + onTrash: { store.moveCleanupCandidateToTrash(candidate) }, + onExclude: { store.excludeFolder(candidate.item) } ) .equatable() } @@ -295,12 +299,14 @@ private struct CleanupCandidateRow: View, Equatable { let isChecked: Bool let isSelected: Bool let canTrash: Bool + let canExclude: Bool let onToggle: () -> Void let onIgnore: () -> Void let onReveal: () -> Void let onOpen: () -> Void let onCopyPath: () -> Void let onTrash: () -> Void + let onExclude: () -> Void @State private var isHovered = false static func == (lhs: CleanupCandidateRow, rhs: CleanupCandidateRow) -> Bool { @@ -308,6 +314,7 @@ private struct CleanupCandidateRow: View, Equatable { && lhs.isChecked == rhs.isChecked && lhs.isSelected == rhs.isSelected && lhs.canTrash == rhs.canTrash + && lhs.canExclude == rhs.canExclude && lhs.displayName == rhs.displayName && lhs.displayPath == rhs.displayPath } @@ -378,6 +385,10 @@ private struct CleanupCandidateRow: View, Equatable { Button("Reveal in Finder") { onReveal() } Button("Open") { onOpen() } Button("Copy Path") { onCopyPath() } + if canExclude { + Divider() + Button("Exclude This Folder") { onExclude() } + } Divider() Button("Move to Trash", role: .destructive) { onTrash() } .disabled(!canTrash) diff --git a/Sources/StorageScope/Views/ContentView.swift b/Sources/StorageScope/Views/ContentView.swift index 47b0290..c75d55e 100644 --- a/Sources/StorageScope/Views/ContentView.swift +++ b/Sources/StorageScope/Views/ContentView.swift @@ -87,6 +87,15 @@ struct ContentView: View { .disabled(!store.canRescan) if store.isScanning { + Button { + store.canPauseScan ? store.pauseScan() : store.resumeScan() + } label: { + Label( + store.canResumeScan ? "Resume" : "Pause", + systemImage: store.canResumeScan ? "play.circle" : "pause.circle" + ) + } + Button { store.cancelScan() } label: { diff --git a/Sources/StorageScope/Views/DuplicateCandidatesView.swift b/Sources/StorageScope/Views/DuplicateCandidatesView.swift index 51e4749..1128194 100644 --- a/Sources/StorageScope/Views/DuplicateCandidatesView.swift +++ b/Sources/StorageScope/Views/DuplicateCandidatesView.swift @@ -156,7 +156,8 @@ private struct VerifiedDuplicateGroupCard: View { onSelect: { store.selectedItemID = $0.id }, onOpen: { store.selectedItemID = $0.id; store.openSelectedItem() }, onReveal: { store.selectedItemID = $0.id; store.revealSelectedItem() }, - onCopyPath: { store.selectedItemID = $0.id; store.copySelectedPath() } + onCopyPath: { store.selectedItemID = $0.id; store.copySelectedPath() }, + onExclude: store.isScanning ? nil : { store.excludeFolder(atPath: $0.url.deletingLastPathComponent().standardizedFileURL.path) } ) } .padding(14) @@ -246,7 +247,8 @@ private struct DuplicateGroupCard: View { onSelect: { store.selectedItemID = $0.id }, onOpen: { store.selectedItemID = $0.id; store.openSelectedItem() }, onReveal: { store.selectedItemID = $0.id; store.revealSelectedItem() }, - onCopyPath: { store.selectedItemID = $0.id; store.copySelectedPath() } + onCopyPath: { store.selectedItemID = $0.id; store.copySelectedPath() }, + onExclude: store.isScanning ? nil : { store.excludeFolder(atPath: $0.url.deletingLastPathComponent().standardizedFileURL.path) } ) } .padding(14) @@ -264,6 +266,7 @@ private struct DuplicateItemList: View { let onOpen: (StorageItem) -> Void let onReveal: (StorageItem) -> Void let onCopyPath: (StorageItem) -> Void + var onExclude: ((StorageItem) -> Void)? = nil var body: some View { VStack(spacing: 0) { @@ -284,6 +287,8 @@ private struct DuplicateItemList: View { onReveal(item) } onCopyPath: { onCopyPath(item) + } onExclude: { + onExclude?(item) } .equatable() @@ -306,6 +311,7 @@ private struct DuplicateFileRow: View, Equatable { let onOpen: () -> Void let onReveal: () -> Void let onCopyPath: () -> Void + var onExclude: (() -> Void)? = nil @State private var isHovered = false // Excludes the closures: not Equatable, and freshly allocated per render anyway. @@ -361,6 +367,10 @@ private struct DuplicateFileRow: View, Equatable { } Button("Reveal in Finder") { onReveal() } Button("Copy Path") { onCopyPath() } + if let onExclude { + Divider() + Button("Exclude This Folder") { onExclude() } + } } } } diff --git a/Sources/StorageScope/Views/SettingsView.swift b/Sources/StorageScope/Views/SettingsView.swift index 9dbc8b2..0871757 100644 --- a/Sources/StorageScope/Views/SettingsView.swift +++ b/Sources/StorageScope/Views/SettingsView.swift @@ -4,6 +4,7 @@ struct SettingsView: View { @ObservedObject var store: ScanStore @State private var showingClearCacheAlert = false @State private var cacheSnapshot: CacheSnapshot = .init(entryCount: 0, lastPersistedAt: nil) + @State private var newExcludedPath: String = "" var body: some View { ScrollView { @@ -32,6 +33,45 @@ struct SettingsView: View { Divider() + SettingsSection(title: "Excluded Folders") { + Toggle("Exclude folders", isOn: store.filterBinding(\.excludeFoldersEnabled)) + + VStack(alignment: .leading, spacing: 6) { + ForEach(store.filters.excludedPaths, id: \.self) { path in + HStack { + Text(path) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button { + store.filters.excludedPaths.removeAll { $0 == path } + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help("Remove this exclusion") + } + } + } + + HStack { + TextField("Folder name or path (e.g. node_modules, ~/Library/Caches)", text: $newExcludedPath) + .textFieldStyle(.roundedBorder) + Button("Add") { + let trimmed = newExcludedPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !store.filters.excludedPaths.contains(trimmed) else { return } + store.filters.excludedPaths.append(trimmed) + newExcludedPath = "" + } + .disabled(newExcludedPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + SettingsFootnote("Folder names (e.g. node_modules) match anywhere in the tree. Paths starting with ~ or / match only that exact folder and its contents. Excluded folders are skipped entirely during the next scan.") + } + + Divider() + SettingsSection(title: "Display Filters") { Picker("Visible size", selection: store.filterBinding(\.sizeFilter)) { ForEach(SizeFilter.allCases) { filter in diff --git a/Sources/StorageScope/Views/SidebarView.swift b/Sources/StorageScope/Views/SidebarView.swift index 7fdf232..d11b7c5 100644 --- a/Sources/StorageScope/Views/SidebarView.swift +++ b/Sources/StorageScope/Views/SidebarView.swift @@ -263,7 +263,7 @@ private struct ScanStatusFooter: View { HStack(spacing: 8) { ProgressView() .controlSize(.small) - Text(store.scanStage.title) + Text(store.isScanPaused ? "Paused" : store.scanStage.title) .font(.caption.weight(.semibold)) .foregroundStyle(.primary) Spacer(minLength: 4) @@ -271,6 +271,24 @@ private struct ScanStatusFooter: View { .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } + if store.isScanPaused { + Text("Scan paused — resume to continue") + .font(.caption2) + .foregroundStyle(.tertiary) + } else if let scanStartedAt = store.scanStartedAt { + TimelineView(.periodic(from: scanStartedAt, by: 1)) { context in + HStack(spacing: 6) { + if store.progress.itemsPerSecond > 0 { + Text("~\(Int(store.progress.itemsPerSecond.rounded())) items/sec") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } + Text(elapsedText(from: scanStartedAt, to: context.date)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } + } + } Text(store.progress.currentPath) .font(.caption2) .foregroundStyle(.tertiary) @@ -299,4 +317,11 @@ private struct ScanStatusFooter: View { .frame(maxWidth: .infinity, alignment: .leading) .background(.bar) } + + private func elapsedText(from start: Date, to now: Date) -> String { + let elapsed = max(0, Int(now.timeIntervalSince(start))) + let minutes = elapsed / 60 + let seconds = elapsed % 60 + return minutes > 0 ? "\(minutes)m \(seconds)s" : "\(seconds)s" + } } diff --git a/Sources/StorageScope/Views/StorageItemTable.swift b/Sources/StorageScope/Views/StorageItemTable.swift index d45372d..c5aae34 100644 --- a/Sources/StorageScope/Views/StorageItemTable.swift +++ b/Sources/StorageScope/Views/StorageItemTable.swift @@ -66,11 +66,15 @@ struct StorageItemTable: View { displayPath: store.filters.displayPath(for: item), redactionEnabled: store.filters.redactionEnabled, canTrash: store.canMoveItemToTrash(item), + // Excluding mid-scan would silently no-op: excludeFolder() + // routes through rescan(), which guards on !isScanning. + canExclude: item.isContainer && item.id != store.scan?.rootItem.id && !store.isScanning, onSelect: { store.selectedItemID = item.id }, onOpen: { store.selectedItemID = item.id; store.openSelectedItem() }, onReveal: { store.selectedItemID = item.id; store.revealSelectedItem() }, onCopyPath: { store.selectedItemID = item.id; store.copySelectedPath() }, - onTrash: { store.selectedItemID = item.id; store.moveSelectedItemToTrash() } + onTrash: { store.selectedItemID = item.id; store.moveSelectedItemToTrash() }, + onExclude: { store.excludeFolder(item) } ) .equatable() Divider() @@ -248,11 +252,13 @@ private struct StorageItemRow: View, Equatable { let displayPath: String let redactionEnabled: Bool let canTrash: Bool + let canExclude: Bool let onSelect: () -> Void let onOpen: () -> Void let onReveal: () -> Void let onCopyPath: () -> Void let onTrash: () -> Void + let onExclude: () -> Void @State private var isHovered = false // Closures are excluded: they're stable references back to the store and @@ -261,6 +267,7 @@ private struct StorageItemRow: View, Equatable { static func == (lhs: StorageItemRow, rhs: StorageItemRow) -> Bool { lhs.item == rhs.item && lhs.isSelected == rhs.isSelected && lhs.searchText == rhs.searchText && lhs.canTrash == rhs.canTrash + && lhs.canExclude == rhs.canExclude && lhs.displayName == rhs.displayName && lhs.displayPath == rhs.displayPath && lhs.redactionEnabled == rhs.redactionEnabled } @@ -321,6 +328,10 @@ private struct StorageItemRow: View, Equatable { Button("Reveal in Finder") { onReveal() } Button("Open") { onOpen() } Button("Copy Path") { onCopyPath() } + if canExclude { + Divider() + Button("Exclude This Folder") { onExclude() } + } Divider() Button("Move to Trash", role: .destructive) { onTrash() } .disabled(!canTrash) diff --git a/Sources/StorageScope/Views/TreeExplorerView.swift b/Sources/StorageScope/Views/TreeExplorerView.swift index d8b882a..a7138e2 100644 --- a/Sources/StorageScope/Views/TreeExplorerView.swift +++ b/Sources/StorageScope/Views/TreeExplorerView.swift @@ -65,7 +65,11 @@ struct TreeExplorerView: View { revealItem: { store.selectedItemID = $0.id; store.revealSelectedItem() }, copyItemPath: { store.selectedItemID = $0.id; store.copySelectedPath() }, trashItem: { store.selectedItemID = $0.id; store.moveSelectedItemToTrash() }, - canTrashItem: { store.canMoveItemToTrash($0) } + canTrashItem: { store.canMoveItemToTrash($0) }, + excludeItem: { store.excludeFolder($0) }, + // Excluding mid-scan would silently no-op: excludeFolder() routes + // through rescan(), which guards on !isScanning. + canExcludeItem: { $0.isContainer && $0.id != store.scan?.rootItem.id && !store.isScanning } ) } .cardBackground() @@ -115,6 +119,8 @@ private struct TreeNodeRow: View { let copyItemPath: (StorageItem) -> Void let trashItem: (StorageItem) -> Void let canTrashItem: (StorageItem) -> Bool + let excludeItem: (StorageItem) -> Void + let canExcludeItem: (StorageItem) -> Bool @State private var isHovered = false private var isExpanded: Bool { @@ -210,6 +216,10 @@ private struct TreeNodeRow: View { Button("Reveal in Finder") { revealItem(item) } Button("Open") { openItem(item) } Button("Copy Path") { copyItemPath(item) } + if canExcludeItem(item) { + Divider() + Button("Exclude This Folder") { excludeItem(item) } + } Divider() Button("Move to Trash", role: .destructive) { trashItem(item) } .disabled(!canTrashItem(item)) @@ -234,7 +244,9 @@ private struct TreeNodeRow: View { revealItem: revealItem, copyItemPath: copyItemPath, trashItem: trashItem, - canTrashItem: canTrashItem + canTrashItem: canTrashItem, + excludeItem: excludeItem, + canExcludeItem: canExcludeItem ) } } diff --git a/Sources/StorageScopeCore/Models/StorageScan.swift b/Sources/StorageScopeCore/Models/StorageScan.swift index e6fdc65..499927d 100644 --- a/Sources/StorageScopeCore/Models/StorageScan.swift +++ b/Sources/StorageScopeCore/Models/StorageScan.swift @@ -29,6 +29,7 @@ public struct StorageScan: Sendable { public let duplicateVerificationDuration: TimeInterval public let enumerateDuration: TimeInterval public let cleanupCandidates: [CleanupCandidate] + public let isPartial: Bool private let itemLookupByID: [String: StorageItem] public init( @@ -53,7 +54,8 @@ public struct StorageScan: Sendable { duplicateCandidateLimitReached: Bool = false, duplicateVerificationDuration: TimeInterval = 0, enumerateDuration: TimeInterval = 0, - cleanupCandidates: [CleanupCandidate] + cleanupCandidates: [CleanupCandidate], + isPartial: Bool = false ) { self.rootURL = rootURL self.startedAt = startedAt @@ -77,6 +79,7 @@ public struct StorageScan: Sendable { self.duplicateVerificationDuration = duplicateVerificationDuration self.enumerateDuration = enumerateDuration self.cleanupCandidates = cleanupCandidates + self.isPartial = isPartial self.itemLookupByID = Self.buildItemLookup( retainedItems: retainedItems, largestFiles: largestFiles, @@ -383,12 +386,20 @@ public struct ScanProgress: Sendable { public let totalBytes: Int64 public let currentPath: String public let phase: ScanPhase + public let itemsPerSecond: Double - public init(scannedItemCount: Int, totalBytes: Int64, currentPath: String, phase: ScanPhase = .enumerating) { + public init( + scannedItemCount: Int, + totalBytes: Int64, + currentPath: String, + phase: ScanPhase = .enumerating, + itemsPerSecond: Double = 0 + ) { self.scannedItemCount = scannedItemCount self.totalBytes = totalBytes self.currentPath = currentPath self.phase = phase + self.itemsPerSecond = itemsPerSecond } } @@ -403,6 +414,9 @@ public struct ScanOptions: Sendable { public var maxRankedResults: Int public var maxChildrenPerDirectory: Int public var maxRetainedItems: Int + public var excludeEnabled: Bool + public var excludedPathComponents: [String] + public var excludedAbsolutePrefixes: [String] public init( includeHidden: Bool = false, @@ -414,7 +428,10 @@ public struct ScanOptions: Sendable { maxDuplicateCandidateItems: Int = 5_000, maxRankedResults: Int = 500, maxChildrenPerDirectory: Int = 200, - maxRetainedItems: Int = 25_000 + maxRetainedItems: Int = 25_000, + excludeEnabled: Bool = false, + excludedPathComponents: [String] = [], + excludedAbsolutePrefixes: [String] = [] ) { self.includeHidden = includeHidden self.oldFileAgeDays = oldFileAgeDays @@ -426,5 +443,8 @@ public struct ScanOptions: Sendable { self.maxRankedResults = maxRankedResults self.maxChildrenPerDirectory = maxChildrenPerDirectory self.maxRetainedItems = maxRetainedItems + self.excludeEnabled = excludeEnabled + self.excludedPathComponents = excludedPathComponents + self.excludedAbsolutePrefixes = excludedAbsolutePrefixes } } diff --git a/Sources/StorageScopeCore/Services/FileSystemScanner.swift b/Sources/StorageScopeCore/Services/FileSystemScanner.swift index 3714c03..82351f9 100644 --- a/Sources/StorageScopeCore/Services/FileSystemScanner.swift +++ b/Sources/StorageScopeCore/Services/FileSystemScanner.swift @@ -17,21 +17,27 @@ public enum FileSystemScannerError: LocalizedError { } public final class ScanCancellation: @unchecked Sendable { - private let lock = NSLock() + // `NSCondition` replaces the plain `NSLock` used previously so `waitIfPaused()` can + // block cooperatively (via `wait()`) instead of spinning, while `cancel()`/`resume()` + // broadcast to wake any threads parked in `waitIfPaused()`. All prior lock/unlock call + // sites now lock/unlock through the same condition instance. + private let condition = NSCondition() private var cancelled = false + private var paused = false public init() {} public func cancel() { - lock.lock() - defer { lock.unlock() } + condition.lock() cancelled = true + condition.broadcast() + condition.unlock() } public func check() throws { - lock.lock() - defer { lock.unlock() } + condition.lock() let shouldCancel = cancelled + condition.unlock() if shouldCancel { throw FileSystemScannerError.cancelled @@ -40,10 +46,47 @@ public final class ScanCancellation: @unchecked Sendable { /// Non-throwing probe used inside `DispatchQueue.concurrentPerform`, which cannot propagate thrown errors. public var isCancelled: Bool { - lock.lock() - defer { lock.unlock() } + condition.lock() + defer { condition.unlock() } return cancelled } + + public func pause() { + condition.lock() + paused = true + condition.unlock() + } + + public func resume() { + condition.lock() + paused = false + condition.broadcast() + condition.unlock() + } + + public var isPaused: Bool { + condition.lock() + defer { condition.unlock() } + return paused + } + + /// Blocks the calling thread while paused. Re-checks `cancelled` on every wake so a + /// `cancel()` issued while paused unblocks immediately rather than waiting on a + /// `resume()` that may never come. + /// + /// Caution: called from inside `DispatchQueue.concurrentPerform` closures during wide + /// directory recursion. `concurrentPerform` dispatches onto the shared, bounded GCD + /// worker-thread pool — a pause held while many wide-directory iterations are parked + /// here simultaneously can approach that pool's thread ceiling and starve unrelated work + /// on the process. Not solved here; flagged as a known limitation for very wide + /// directories (thousands of siblings) combined with a long pause. + public func waitIfPaused() { + condition.lock() + while paused && !cancelled { + condition.wait() + } + condition.unlock() + } } public final class FileSystemScanner { @@ -87,7 +130,8 @@ public final class FileSystemScanner { root rootURL: URL, options: ScanOptions = ScanOptions(), cancellation: ScanCancellation? = nil, - progress: ProgressHandler? = nil + progress: ProgressHandler? = nil, + onSnapshot: ((StorageScan) -> Void)? = nil ) throws -> StorageScan { var isDirectory: ObjCBool = false guard fileManager.fileExists(atPath: rootURL.path, isDirectory: &isDirectory) else { @@ -98,7 +142,7 @@ public final class FileSystemScanner { "root=%{public}@", rootURL.path) let startedAt = Date() - let accumulator = ScanAccumulator(options: options, progress: progress) + let accumulator = ScanAccumulator(options: options, progress: progress, onSnapshot: onSnapshot, rootURL: rootURL, startedAt: startedAt) let rootItem = try scanItem( at: rootURL, options: options, @@ -148,7 +192,8 @@ public final class FileSystemScanner { rootID: rootItem.id, verifiedDuplicateGroups: verifiedDuplicateGroups, limit: options.maxRankedResults - ) + ), + isPartial: false ) } @@ -217,6 +262,7 @@ public final class FileSystemScanner { accumulator: ScanAccumulator, depth: Int ) throws -> StorageItem { + cancellation?.waitIfPaused() try cancellation?.check() // `url.resourceValues(forKeys:)` can fail (sandbox ACLs, unreachable APFS @@ -328,7 +374,17 @@ public final class FileSystemScanner { } do { + cancellation?.waitIfPaused() try cancellation?.check() + + if options.excludeEnabled, Self.isExcluded(childURLs[index], options: options) { + // Skip entirely: don't stat, count, or recurse. Leave the slot nil + // so the `for case let child?` filter below treats it as absent, + // matching the established "skip this child" pattern used for + // per-child errors elsewhere in this loop. + return + } + let child = try scanItem( at: childURLs[index], options: options, @@ -411,6 +467,28 @@ public final class FileSystemScanner { } } + /// True when `url` matches an exclusion rule: its last path component names an + /// excluded folder name (e.g. `node_modules`, `.git`), or its standardized path starts + /// with an excluded absolute prefix (tilde-expanded, e.g. `~/Library/Caches`). + private static func isExcluded(_ url: URL, options: ScanOptions) -> Bool { + if options.excludedPathComponents.contains(url.lastPathComponent) { + return true + } + + guard !options.excludedAbsolutePrefixes.isEmpty else { + return false + } + + let standardizedPath = url.standardizedFileURL.path + for prefix in options.excludedAbsolutePrefixes { + let expandedPrefix = (prefix as NSString).expandingTildeInPath + if standardizedPath == expandedPrefix || standardizedPath.hasPrefix(expandedPrefix + "/") { + return true + } + } + return false + } + private func verifiedDuplicateGroups( from sizeGroups: [DuplicateSizeGroup], options: ScanOptions, @@ -710,6 +788,9 @@ private final class ScanAccumulator { private let options: ScanOptions private let progress: FileSystemScanner.ProgressHandler? + private let onSnapshot: ((StorageScan) -> Void)? + private let rootURL: URL + private let startedAt: Date private var lastProgressDate = Date.distantPast private let oldFileCutoff: Date private var retainedItemCount: Int @@ -729,14 +810,27 @@ private final class ScanAccumulator { var totalBytes: Int64 = 0 /// Guards every mutable field above. Held briefly during directory enumeration's - /// record*() calls; the user `progress` closure is invoked under this lock, so it must - /// not re-enter the accumulator. Contention is bounded because the scan's bottleneck is - /// `contentsOfDirectory`+`resourceValues` I/O outside the lock. + /// record*() calls; the user `progress` and `onSnapshot` closures are both invoked + /// under this lock, so neither must re-enter the accumulator or block for long — every + /// concurrentPerform worker thread recording an item serializes on whichever thread is + /// currently inside one of these callbacks. Contention is bounded today because both + /// callbacks only do cheap NSLock bookkeeping on the ScanStore side; a callback that + /// becomes non-trivial (e.g. blocking on a busy MainActor) would serialize the whole + /// scan on this lock. private let lock = NSLock() - init(options: ScanOptions, progress: FileSystemScanner.ProgressHandler?) { + init( + options: ScanOptions, + progress: FileSystemScanner.ProgressHandler?, + onSnapshot: ((StorageScan) -> Void)? = nil, + rootURL: URL = URL(fileURLWithPath: "/"), + startedAt: Date = Date() + ) { self.options = options self.progress = progress + self.onSnapshot = onSnapshot + self.rootURL = rootURL + self.startedAt = startedAt self.retainedItemCount = 1 self.oldFileCutoff = Calendar.current.date( byAdding: .day, @@ -963,17 +1057,73 @@ private final class ScanAccumulator { } private func emitProgressLocked(path: String, force: Bool = false, phase: ScanPhase = .enumerating) { - guard let progress else { - return - } - let now = Date() guard force || scannedItemCount == 1 || scannedItemCount.isMultiple(of: 25) || now.timeIntervalSince(lastProgressDate) > 0.35 else { return } lastProgressDate = now - progress(ScanProgress(scannedItemCount: scannedItemCount, totalBytes: totalBytes, currentPath: path, phase: phase)) + // Snapshot fires before progress (not after): ScanStore's progress consumer reads + // whatever the most recent onSnapshot call staged before it yields the paired + // ScanTick. Firing progress first would pair each tick with the *previous* tick's + // snapshot — a one-tick lag that leaves cancel-preserved partial results stale by + // a full throttle interval. + if let onSnapshot { + onSnapshot(snapshotLocked()) + } + progress?(ScanProgress(scannedItemCount: scannedItemCount, totalBytes: totalBytes, currentPath: path, phase: phase)) + } + + /// Builds a `StorageScan` from current in-progress state under the caller's held lock. + /// Duplicate verification hasn't run yet mid-scan, so `verifiedDuplicateGroups` is + /// always empty here. `rootItem`/`retainedItems` use a minimal placeholder root — list + /// views driven by streaming snapshots read `largestFiles`/`largestFolders`/ + /// `oldLargeFiles`/counts, not the tree, so a full retained-tree rebuild mid-scan isn't + /// warranted. + private func snapshotLocked() -> StorageScan { + let placeholderRoot = StorageItem( + url: rootURL, + kind: .folder, + byteSize: totalBytes, + allocatedSize: totalBytes, + modifiedAt: nil, + immediateChildCount: 0, + descendantCount: scannedItemCount, + isReadable: true + ) + return StorageScan( + rootURL: rootURL, + startedAt: startedAt, + finishedAt: Date(), + rootItem: placeholderRoot, + retainedItems: [], + scannedItemCount: scannedItemCount, + inaccessibleItemCount: inaccessibleItemCount, + totalBytes: totalBytes, + largestFiles: sortedRankedItems(largestFileItems, limit: options.maxRankedResults) { $0.displaySize > $1.displaySize }, + largestFolders: sortedRankedItems(largestFolderItems, limit: options.maxRankedResults) { $0.displaySize > $1.displaySize }, + oldLargeFiles: sortedRankedItems(oldLargeFileItems, limit: options.maxRankedResults) { $0.displaySize > $1.displaySize }, + typeBreakdown: typeBreakdown, + categoryBreakdown: categoryBreakdown, + duplicateSizeGroups: [], + verifiedDuplicateGroups: [], + duplicateCandidateItemLimit: duplicateCandidateItemLimit, + duplicateCandidateItemsRetained: duplicateCandidateItemCount, + duplicateCandidateItemsConsidered: duplicateCandidateConsideredCount, + duplicateCandidateLimitReached: duplicateCandidateLimitReached, + duplicateVerificationDuration: 0, + enumerateDuration: Date().timeIntervalSince(startedAt), + cleanupCandidates: [], + isPartial: true + ) + } + + /// Thread-safe public entry point for `snapshot()` — acquires the lock itself so + /// callers outside the accumulator (e.g. tests) don't need to know about locking. + func snapshot() -> StorageScan { + lock.lock() + defer { lock.unlock() } + return snapshotLocked() } private func recordFileLocked(_ item: StorageItem) { diff --git a/Tests/StorageScopeCoreTests/FileSystemScannerTests.swift b/Tests/StorageScopeCoreTests/FileSystemScannerTests.swift index f5143b5..6f0b2b0 100644 --- a/Tests/StorageScopeCoreTests/FileSystemScannerTests.swift +++ b/Tests/StorageScopeCoreTests/FileSystemScannerTests.swift @@ -1431,6 +1431,228 @@ struct FileSystemScannerTests { #expect(!verifiedGroup.items.contains { $0.name == "c.bin" }) } + @Test("exclusion by folder name skips the folder and its children") + func exclusionByNameSkipsFolderAndChildren() throws { + let temporaryRoot = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let nodeModules = temporaryRoot.appendingPathComponent("node_modules", isDirectory: true) + try FileManager.default.createDirectory(at: nodeModules, withIntermediateDirectories: true) + try writeFile(named: "dep.js", bytes: 5_000, in: nodeModules) + let nested = nodeModules.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try writeFile(named: "inner.js", bytes: 3_000, in: nested) + try writeFile(named: "keep.txt", bytes: 1_000, in: temporaryRoot) + + let options = ScanOptions( + largeFileThreshold: 1, + duplicateCandidateThreshold: 1, + excludeEnabled: true, + excludedPathComponents: ["node_modules"] + ) + let scan = try FileSystemScanner().scan(root: temporaryRoot, options: options) + + #expect(!scan.retainedItems.contains { $0.name == "node_modules" }) + #expect(!scan.retainedItems.contains { $0.name == "dep.js" }) + #expect(!scan.retainedItems.contains { $0.name == "inner.js" }) + #expect(scan.retainedItems.contains { $0.name == "keep.txt" }) + #expect(!scan.largestFiles.contains { $0.name == "dep.js" }) + // Excluded items are skipped entirely — not counted as scanned or inaccessible. + // Only the root directory and keep.txt are visited; node_modules and everything + // beneath it never gets stat'd or recursed into. + #expect(scan.scannedItemCount == 2) + } + + @Test("exclusion by absolute prefix matches only the exact folder and its contents") + func exclusionByAbsolutePrefixMatchesExactFolder() throws { + let temporaryRoot = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let caches = temporaryRoot.appendingPathComponent("Caches", isDirectory: true) + try FileManager.default.createDirectory(at: caches, withIntermediateDirectories: true) + try writeFile(named: "blob.bin", bytes: 5_000, in: caches) + try writeFile(named: "keep.txt", bytes: 1_000, in: temporaryRoot) + + let options = ScanOptions( + largeFileThreshold: 1, + duplicateCandidateThreshold: 1, + excludeEnabled: true, + excludedAbsolutePrefixes: [caches.standardizedFileURL.path] + ) + let scan = try FileSystemScanner().scan(root: temporaryRoot, options: options) + + #expect(!scan.retainedItems.contains { $0.name == "Caches" }) + #expect(!scan.retainedItems.contains { $0.name == "blob.bin" }) + #expect(scan.retainedItems.contains { $0.name == "keep.txt" }) + } + + @Test("exclusion toggle off applies no filtering even with matching entries configured") + func exclusionDisabledAppliesNoFiltering() throws { + let temporaryRoot = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + let nodeModules = temporaryRoot.appendingPathComponent("node_modules", isDirectory: true) + try FileManager.default.createDirectory(at: nodeModules, withIntermediateDirectories: true) + try writeFile(named: "dep.js", bytes: 5_000, in: nodeModules) + + let options = ScanOptions( + largeFileThreshold: 1, + duplicateCandidateThreshold: 1, + excludeEnabled: false, + excludedPathComponents: ["node_modules"] + ) + let scan = try FileSystemScanner().scan(root: temporaryRoot, options: options) + + #expect(scan.retainedItems.contains { $0.name == "node_modules" }) + #expect(scan.retainedItems.contains { $0.name == "dep.js" }) + } + + @Test("snapshot mid-walk returns non-empty ranked lists marked isPartial") + func snapshotMidWalkReturnsPartialResults() throws { + let temporaryRoot = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + for index in 0..<40 { + try writeFile(named: "file-\(index).bin", bytes: 10_000 + index, in: temporaryRoot) + } + + var snapshots: [StorageScan] = [] + let scan = try FileSystemScanner().scan( + root: temporaryRoot, + options: ScanOptions(largeFileThreshold: 1, duplicateCandidateThreshold: 1), + onSnapshot: { snapshot in + snapshots.append(snapshot) + } + ) + + #expect(scan.isPartial == false) + // At least one mid-walk snapshot should have been captured (throttled cadence + // permitting — the "force" emit on the very first recorded item guarantees one). + let nonEmptySnapshot = snapshots.first { !$0.largestFiles.isEmpty } + #expect(nonEmptySnapshot != nil) + #expect(nonEmptySnapshot?.isPartial == true) + } + + @Test("pause blocks scan progress and resume continues to completion") + func pauseBlocksProgressAndResumeCompletes() throws { + let temporaryRoot = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + for index in 0..<5_000 { + try writeFile(named: "file-\(index).bin", bytes: 1_000, in: temporaryRoot) + } + + let cancellation = ScanCancellation() + let resultBox = LockedBox?>(nil) + let lastCountBox = LockedBox(0) + + let thread = Thread { + do { + let scan = try FileSystemScanner().scan( + root: temporaryRoot, + options: ScanOptions(largeFileThreshold: 1, duplicateCandidateThreshold: 1), + cancellation: cancellation, + progress: { progress in + lastCountBox.value = progress.scannedItemCount + } + ) + resultBox.value = .success(scan) + } catch { + resultBox.value = .failure(error) + } + } + thread.start() + + // Let the scan get moving, then pause it. + Thread.sleep(forTimeInterval: 0.02) + cancellation.pause() + // A handful of iterations already past their `waitIfPaused()` checkpoint (in-flight + // file I/O) can still land after `pause()` returns — this is expected concurrency + // slop, not a hang. Give those in-flight iterations a brief window to settle before + // sampling the "at pause" baseline. + Thread.sleep(forTimeInterval: 0.05) + let countAtPause = lastCountBox.value + + // Verify it makes no further progress (beyond that same small in-flight slop) for a + // longer window while paused — the bulk of the 5,000-item tree should stay blocked. + Thread.sleep(forTimeInterval: 0.3) + let countWhilePaused = lastCountBox.value + #expect(countWhilePaused - countAtPause < 50) + #expect(countWhilePaused < 5_000) + #expect(resultBox.value == nil) + + cancellation.resume() + + let deadline = Date().addingTimeInterval(5) + while resultBox.value == nil, Date() < deadline { + Thread.sleep(forTimeInterval: 0.02) + } + + switch resultBox.value { + case .success(let scan): + #expect(scan.scannedItemCount >= 5_000) + case .failure(let error): + Issue.record("Expected scan to complete after resume, got error: \(error)") + case nil: + Issue.record("Scan did not complete after resume within the deadline.") + } + } + + @Test("cancel while paused unblocks the scan and it terminates as cancelled") + func cancelWhilePausedUnblocksScan() throws { + let temporaryRoot = try makeTemporaryRoot() + defer { try? FileManager.default.removeItem(at: temporaryRoot) } + + for index in 0..<5_000 { + try writeFile(named: "file-\(index).bin", bytes: 1_000, in: temporaryRoot) + } + + let cancellation = ScanCancellation() + let resultBox = LockedBox?>(nil) + + let thread = Thread { + do { + let scan = try FileSystemScanner().scan( + root: temporaryRoot, + options: ScanOptions(largeFileThreshold: 1, duplicateCandidateThreshold: 1), + cancellation: cancellation + ) + resultBox.value = .success(scan) + } catch { + resultBox.value = .failure(error) + } + } + thread.start() + + Thread.sleep(forTimeInterval: 0.02) + cancellation.pause() + // Give the pause a real window to actually block the scan (a 5,000-item tree with + // parallel enumeration would otherwise finish before this test can prove the + // cancel-while-paused path, rather than the "already done" path). + Thread.sleep(forTimeInterval: 0.1) + #expect(resultBox.value == nil) + cancellation.cancel() + + let deadline = Date().addingTimeInterval(5) + while resultBox.value == nil, Date() < deadline { + Thread.sleep(forTimeInterval: 0.02) + } + + switch resultBox.value { + case .success: + Issue.record("Expected scan cancelled while paused to throw, but it completed.") + case .failure(let error): + #expect(error is FileSystemScannerError) + if case FileSystemScannerError.cancelled = error { + // Expected. + } else { + Issue.record("Expected FileSystemScannerError.cancelled, got \(error).") + } + case nil: + Issue.record("Scan did not terminate after cancel-while-paused within the deadline.") + } + } + private func makeTemporaryRoot() throws -> URL { let temporaryRoot = FileManager.default.temporaryDirectory .appendingPathComponent("StorageScopeTests-\(UUID().uuidString)", isDirectory: true) @@ -1541,3 +1763,29 @@ struct FileSystemScannerTests { case blocked } } + +/// Minimal thread-safe box for cross-thread test assertions against a `Thread`-run scan +/// (used by the pause/resume tests, which need a real OS thread rather than `Task` so +/// `Thread.sleep` timing windows are meaningful against `DispatchQueue.concurrentPerform` +/// worker threads). +private final class LockedBox: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ initial: Value) { + self.storage = initial + } + + var value: Value { + get { + lock.lock() + defer { lock.unlock() } + return storage + } + set { + lock.lock() + defer { lock.unlock() } + storage = newValue + } + } +} diff --git a/Tests/StorageScopeTests/ScanSessionPauseResumeTests.swift b/Tests/StorageScopeTests/ScanSessionPauseResumeTests.swift new file mode 100644 index 0000000..c9ad86f --- /dev/null +++ b/Tests/StorageScopeTests/ScanSessionPauseResumeTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import StorageScope + +@Suite("ScanSession pause/resume guards") +struct ScanSessionPauseResumeTests { + @Test("canPauseScan is false when not scanning") + func canPauseFalseWhenIdle() { + var session = ScanSession() + session.isScanning = false + session.isScanPaused = false + #expect(session.canPauseScan == false) + #expect(session.canResumeScan == false) + } + + @Test("canPauseScan is true while scanning and not paused") + func canPauseTrueWhileScanning() { + var session = ScanSession() + session.isScanning = true + session.isScanPaused = false + #expect(session.canPauseScan == true) + #expect(session.canResumeScan == false) + } + + @Test("canResumeScan is true while scanning and paused") + func canResumeTrueWhilePaused() { + var session = ScanSession() + session.isScanning = true + session.isScanPaused = true + #expect(session.canPauseScan == false) + #expect(session.canResumeScan == true) + } + + @Test("neither pause nor resume is available once scanning stops, even if isScanPaused lingers") + func neitherAvailableAfterScanStops() { + var session = ScanSession() + session.isScanning = false + session.isScanPaused = true + #expect(session.canPauseScan == false) + #expect(session.canResumeScan == false) + } +} diff --git a/Tests/StorageScopeTests/ScanStoreCancellationTests.swift b/Tests/StorageScopeTests/ScanStoreCancellationTests.swift index 8e88bb3..db1f1f4 100644 --- a/Tests/StorageScopeTests/ScanStoreCancellationTests.swift +++ b/Tests/StorageScopeTests/ScanStoreCancellationTests.swift @@ -31,7 +31,14 @@ struct ScanStoreCancellationTests { #expect(store.isScanning == false) #expect(store.errorMessage == nil) - #expect(store.scan == nil) + // Cancel now keeps partial results rather than discarding them. Whether a snapshot + // landed before this particular cancel is timing-dependent on this small fixture — + // `ScanStoreCancellationTests.cancelKeepsPartialSnapshot` below uses a wider fixture + // to make that landing deterministic and assert `isPartial == true` unconditionally. + // Here, only assert the invariant that must hold in either case: a scan present + // after cancel is never a *stale full scan pretending to be current* — if present, + // it must be marked partial. + #expect(store.scan == nil || store.scan?.isPartial == true) #expect(store.progress.currentPath == "Scan cancelled") } @@ -157,8 +164,14 @@ struct ScanStoreCancellationTests { #expect(store.isScanning == false) #expect(store.errorMessage == nil) - // Previous results are preserved after cancelling the rescan. - #expect(store.scan?.finishedAt == priorFinishedAt) + // Previous results are preserved after cancelling the rescan — either untouched + // (finishedAt unchanged) if no partial snapshot arrived before cancel landed, or + // replaced by a streamed partial snapshot (marked isPartial) if one did. Either + // way `store.scan` must not be nil. + #expect(store.scan != nil) + if store.scan?.finishedAt != priorFinishedAt { + #expect(store.scan?.isPartial == true) + } #expect(store.progress.currentPath == "Scan cancelled") // The dismissible cancellation notice replaces the prior scan-complete notice. #expect(store.scanNoticeIsDismissible == true) @@ -186,4 +199,52 @@ struct ScanStoreCancellationTests { // failure, not a cancel — cancel would have left errorMessage as nil). #expect(store.errorMessage?.isEmpty == false) } + + @Test("cancelling a large in-flight scan keeps the last streamed partial snapshot") + func cancelKeepsPartialSnapshot() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + // A wide, shallow tree gives the throttled progress/snapshot cadence (every 25 + // items or 0.35s) room to fire multiple times before the scan finishes, so the + // cancel below has a realistic chance of landing after at least one snapshot. + for index in 0..<2_000 { + let url = root.appendingPathComponent("file-\(index).bin", isDirectory: false) + try Data(repeating: UInt8(index % 255), count: 4_096).write(to: url) + } + + let store = ScanStore() + store.scanDeveloperFixturePath(root.path) + + // Poll until at least one streamed snapshot has landed (store.scan non-nil while + // still scanning) rather than a fixed sleep, so this test deterministically exercises + // the in-flight path instead of racing the throttle cadence. + let snapshotDeadline = Date().addingTimeInterval(5) + while store.isScanning, store.scan == nil, Date() < snapshotDeadline { + try await Task.sleep(for: .milliseconds(10)) + } + + guard store.isScanning else { + return // Completed too fast (small/fast disk) to exercise the in-flight path. + } + // A snapshot must have landed by now for this test to be meaningful; if the scan is + // still running but no snapshot arrived within the deadline, something upstream of + // this test (throttle cadence, streaming wiring) regressed independently of cancel. + try #require(store.scan != nil, "expected at least one streamed snapshot before cancelling") + + store.cancelScan() + + for _ in 0..<100 { + if !store.isScanning { break } + try await Task.sleep(for: .milliseconds(50)) + } + + #expect(store.isScanning == false) + #expect(store.errorMessage == nil) + // A snapshot was confirmed present before cancelScan() above, and cancel preserves + // rather than discards it — assert both unconditionally now. + let scan = try #require(store.scan) + #expect(scan.isPartial == true) + } } diff --git a/Tests/StorageScopeTests/ScanStoreFindNextNavigationTests.swift b/Tests/StorageScopeTests/ScanStoreFindNextNavigationTests.swift index 82d50f8..da27425 100644 --- a/Tests/StorageScopeTests/ScanStoreFindNextNavigationTests.swift +++ b/Tests/StorageScopeTests/ScanStoreFindNextNavigationTests.swift @@ -33,11 +33,10 @@ struct ScanStoreFindNextNavigationTests { } store.scanDeveloperFixturePath(root.path) - // Give the async scan + debounce a moment to populate searchResultIDs. - try await Task.sleep(for: .milliseconds(400)) + try await waitForScanToFinish(store) store.filters.searchText = "foo" - try await Task.sleep(for: .milliseconds(400)) + try await waitForSearchResultIDs(store, atLeast: 3) guard let ids = store.filters.searchResultIDs, ids.count >= 3 else { Issue.record("expected >=3 searchResultIDs, got \(store.filters.searchResultIDs?.count ?? 0)") @@ -72,9 +71,9 @@ struct ScanStoreFindNextNavigationTests { } store.scanDeveloperFixturePath(root.path) - try await Task.sleep(for: .milliseconds(400)) + try await waitForScanToFinish(store) store.filters.searchText = "foo" - try await Task.sleep(for: .milliseconds(400)) + try await waitForSearchResultIDs(store, atLeast: 3) guard let ids = store.filters.searchResultIDs, ids.count >= 3 else { Issue.record("expected >=3 searchResultIDs, got \(store.filters.searchResultIDs?.count ?? 0)") @@ -88,4 +87,21 @@ struct ScanStoreFindNextNavigationTests { store.reverseSearchResult() #expect(store.currentSearchResultIndex == ids.count - 2) } + + /// Polls instead of a fixed sleep so this test stays reliable when the suite runs + /// under heavier parallel load (many concurrent scan/pause-resume tests can squeeze a + /// fixed timing budget) rather than assuming a specific wall-clock duration. + private func waitForScanToFinish(_ store: ScanStore, timeout: Duration = .seconds(5)) async throws { + let deadline = ContinuousClock.now + timeout + while store.isScanning, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + } + + private func waitForSearchResultIDs(_ store: ScanStore, atLeast count: Int, timeout: Duration = .seconds(5)) async throws { + let deadline = ContinuousClock.now + timeout + while (store.filters.searchResultIDs?.count ?? 0) < count, ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + } } \ No newline at end of file