Skip to content

Commit ea028ed

Browse files
committed
Improve Find Files advanced search
1 parent 18630dd commit ea028ed

7 files changed

Lines changed: 571 additions & 112 deletions

GUI/Sources/FindFiles/Engine/FindFilesEngine.swift

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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] = ["("]

GUI/Sources/FindFiles/Engine/FindFilesModels.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,17 @@ struct FindFilesCriteria: Sendable {
120120
var searchInSubdirectories: Bool = true
121121
var searchInArchives: Bool = false
122122
var maxDepth: Int = 100
123+
var filesOnly: Bool = false
124+
var excludeSystemLocations: Bool = false
125+
var deletableOnly: Bool = false
123126
var fileSizeMin: Int64? = nil
124127
var fileSizeMax: Int64? = nil
125128
var dateFrom: Date? = nil
126129
var dateTo: Date? = nil
130+
var modificationBeforeDate: Date? = nil
131+
var accessBeforeDate: Date? = nil
132+
var modificationOlderThanDays: Int? = nil
133+
var accessOlderThanDays: Int? = nil
127134

128135
/// If true, searchDirectory is a single archive file (not a directory).
129136
/// Engine should search only inside that archive.

0 commit comments

Comments
 (0)