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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Sources/StorageScope/Stores/FilterStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions Sources/StorageScope/Stores/ScanSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,11 +25,23 @@ 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
}

var canCancelScan: Bool {
isScanning
}

var canPauseScan: Bool {
isScanning && !isScanPaused
}

var canResumeScan: Bool {
isScanning && isScanPaused
}
}
192 changes: 184 additions & 8 deletions Sources/StorageScope/Stores/ScanStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -165,6 +181,11 @@ func setSelectedView(_ view: SmartView) {
private var activeScanID: UUID?
private var cancellation: ScanCancellation?
private var scanTask: Task<Void, Never>?
/// 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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand All @@ -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<ScanProgress>.makeStream()
let (progressStream, progressContinuation) = AsyncStream<ScanTick>.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) {
Expand All @@ -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
Expand Down Expand Up @@ -815,17 +982,26 @@ func setSelectedView(_ view: SmartView) {
currentPath: "Scan complete"
)
isScanning = false
session.isScanPaused = false
refreshMountedVolumes()
clearActiveScan(scanID, cancellation: scanCancellation)
} catch FileSystemScannerError.cancelled {
guard isCurrentScan(scanID, cancellation: scanCancellation) else {
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
Expand Down
Loading
Loading