@@ -22,6 +22,13 @@ actor FindFilesEngine {
2222 /// Archives already scanned during the main find pass (avoid double-scanning)
2323 private var scannedArchivePaths = Set < String > ( )
2424
25+ private static let findDateFormatter : DateFormatter = {
26+ let formatter = DateFormatter ( )
27+ formatter. locale = Locale ( identifier: " en_US_POSIX " )
28+ formatter. dateFormat = " yyyy-MM-dd HH:mm:ss "
29+ return formatter
30+ } ( )
31+
2532 // MARK: - Start Search
2633
2734 /// Starts an async search returning results as an AsyncStream.
@@ -75,14 +82,57 @@ actor FindFilesEngine {
7582 // MARK: - Private
7683
7784 // MARK: - Shared find arguments
78- /// Directory names to prune from find traversal (cloud placeholders, caches, trash)
79- private static let pruneNames = [ " CloudStorage " , " Group Containers " , " .Trash " , " Caches " ]
80- /// Build prune arguments for /usr/bin/find: ( -name X -type d -o ... ) -prune -o
81- private static func buildPruneArgs( ) -> [ String ] {
85+ /// Directory names to prune from find traversal for every search.
86+ private static let baselinePruneNames = [ " .Trash " ]
87+
88+ /// System roots skipped by the "user-controlled ballast" preset.
89+ /// Do not prune /Library, ~/Library, /Applications, Caches, Group Containers or CloudStorage here:
90+ /// user-controlled leftovers often live there and are filtered later by deletability.
91+ private static let systemPrunePaths = [
92+ " /System " ,
93+ " /private " ,
94+ " /usr " ,
95+ " /bin " ,
96+ " /sbin " ,
97+ " /dev " ,
98+ " /cores " ,
99+ " /Network "
100+ ]
101+
102+ /// Installed runtime/package roots are not ballast while the package is present.
103+ /// Leftovers from removed packages usually remain outside these roots, for example
104+ /// in Application Support, Preferences, Logs, Caches, or Group Containers.
105+ private static let installedPackagePrunePaths = [
106+ " /Library/Frameworks " ,
107+ " /Library/Developer " ,
108+ " /Library/Apple " ,
109+ " /Library/Java/JavaVirtualMachines " ,
110+ " /Library/Python " ,
111+ " /Library/Perl " ,
112+ " /Library/Ruby " ,
113+ " /Library/TeX " ,
114+ " /opt/homebrew " ,
115+ " /usr/local "
116+ ]
117+
118+ /// Build prune arguments for /usr/bin/find: ( ... ) -prune -o
119+ private static func buildPruneArgs( criteria: FindFilesCriteria ) -> [ String ] {
120+ var expressions : [ [ String ] ] = baselinePruneNames. map { [ " -name " , $0, " -type " , " d " ] }
121+ if criteria. excludeSystemLocations {
122+ expressions += systemPrunePaths. map { [ " -path " , $0, " -type " , " d " ] }
123+ expressions += installedPackagePrunePaths. map { [ " -path " , $0, " -type " , " d " ] }
124+ expressions += [
125+ [ " -name " , " node_modules " , " -type " , " d " ] ,
126+ [ " -name " , " .git " , " -type " , " d " ] ,
127+ [ " -name " , " .svn " , " -type " , " d " ] ,
128+ [ " -name " , " .hg " , " -type " , " d " ]
129+ ]
130+ }
131+ guard !expressions. isEmpty else { return [ ] }
82132 var args : [ String ] = [ " ( " ]
83- for (i , name ) in pruneNames . enumerated ( ) {
84- if i > 0 { args. append ( " -o " ) }
85- args += [ " -name " , name , " -type " , " d " ]
133+ for (index , expression ) in expressions . enumerated ( ) {
134+ if index > 0 { args. append ( " -o " ) }
135+ args += expression
86136 }
87137 args += [ " ) " , " -prune " , " -o " ]
88138 return args
@@ -211,9 +261,37 @@ actor FindFilesEngine {
211261 }
212262
213263 // Prune directories that cause I/O errors on virtual/offline volumes
214- args += Self . buildPruneArgs ( )
264+ // and, for user-file presets, skip macOS/app-support locations.
265+ args += Self . buildPruneArgs ( criteria: criteria)
215266
216267 // Name matching (after -prune -o)
268+ if criteria. filesOnly {
269+ args += [ " -type " , " f " ]
270+ }
271+ if let minSize = criteria. fileSizeMin {
272+ args += [ " -size " , " + \( max ( minSize - 1 , 0 ) ) c " ]
273+ }
274+ if let maxSize = criteria. fileSizeMax {
275+ args += [ " -size " , " - \( max ( maxSize + 1 , 1 ) ) c " ]
276+ }
277+ if let dateFrom = criteria. dateFrom {
278+ args += [ " -newermt " , Self . findDateFormatter. string ( from: dateFrom) ]
279+ }
280+ if let dateTo = criteria. dateTo {
281+ args += [ " ! " , " -newermt " , Self . findDateFormatter. string ( from: dateTo) ]
282+ }
283+ if let date = criteria. modificationBeforeDate {
284+ args += [ " ! " , " -newermt " , Self . findDateFormatter. string ( from: date) ]
285+ }
286+ if let date = criteria. accessBeforeDate {
287+ args += [ " ! " , " -newerat " , Self . findDateFormatter. string ( from: date) ]
288+ }
289+ if let days = criteria. modificationOlderThanDays, days > 0 {
290+ args += [ " -mtime " , " + \( days - 1 ) " ]
291+ }
292+ if let days = criteria. accessOlderThanDays, days > 0 {
293+ args += [ " -atime " , " + \( days - 1 ) " ]
294+ }
217295 if criteria. useRegex {
218296 // Note: -E flag is inserted at args[0] before the path for BSD find
219297 args. insert ( " -E " , at: 0 )
@@ -288,6 +366,9 @@ actor FindFilesEngine {
288366 var isDir : ObjCBool = false
289367 let exists = FileManager . default. fileExists ( atPath: line, isDirectory: & isDir)
290368 guard exists else { return }
369+ if criteria. deletableOnly, !Self. isUserDeletable ( path: line) {
370+ return
371+ }
291372
292373 stats. filesScanned += 1
293374 // Show the actual file/directory being scanned, not just the parent
@@ -351,6 +432,13 @@ actor FindFilesEngine {
351432 }
352433 }
353434
435+ private static func isUserDeletable( path: String ) -> Bool {
436+ let fm = FileManager . default
437+ guard fm. isDeletableFile ( atPath: path) else { return false }
438+ let parent = URL ( fileURLWithPath: path) . deletingLastPathComponent ( ) . path
439+ return fm. isWritableFile ( atPath: parent)
440+ }
441+
354442 // MARK: - Scan archives in directory (second pass)
355443 /// Runs a separate `find` to locate all archive files, then searches inside each.
356444 /// This is needed because the main `find -iname *.java` won't match archive files
@@ -369,7 +457,7 @@ actor FindFilesEngine {
369457 args += [ " -maxdepth " , " 1 " ]
370458 }
371459 // Same prune rules as main search
372- args += Self . buildPruneArgs ( )
460+ args += Self . buildPruneArgs ( criteria : criteria )
373461 // Match archive extensions: ( -iname '*.zip' -o -iname '*.jar' -o ... ) -print
374462 let archiveExts = Array ( ArchiveExtensions . all)
375463 var extArgs : [ String ] = [ " ( " ]
0 commit comments