mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
Compare commits
9
Commits
1.0.3
...
147c4a9353
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
147c4a9353 | ||
|
|
4600e28b9c | ||
|
|
29aa518a9e | ||
|
|
4e050decff | ||
|
|
64437d70fb | ||
|
|
9f4d47a25a | ||
|
|
c68b05ab9a | ||
|
|
3e67da4634 | ||
|
|
bac2ca0287 |
@@ -25,3 +25,6 @@ jobs:
|
||||
|
||||
- name: Build release
|
||||
run: swift build -c release
|
||||
|
||||
- name: Test
|
||||
run: swift test
|
||||
|
||||
@@ -39,20 +39,22 @@ jobs:
|
||||
env:
|
||||
ALLOFIT_VERSION: ${{ steps.version.outputs.version }}
|
||||
ALLOFIT_BUILD: ${{ github.run_number }}
|
||||
run: ./build-app.sh
|
||||
run: ./scripts/build-app.sh
|
||||
|
||||
- name: Zip Allofit.app (ditto preserves bundle metadata + signatures)
|
||||
working-directory: outputs
|
||||
run: |
|
||||
ditto -c -k --keepParent --sequesterRsrc \
|
||||
Allofit.app \
|
||||
"Allofit-${{ steps.version.outputs.version }}.app" \
|
||||
"Allofit-${{ steps.version.outputs.version }}.zip"
|
||||
|
||||
- name: Build DMG (drag-to-install with /Applications shortcut)
|
||||
env:
|
||||
ALLOFIT_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: ./build-dmg.sh --skip-build
|
||||
run: ./scripts/build-dmg.sh --skip-build
|
||||
|
||||
- name: SHA-256 checksums
|
||||
working-directory: outputs
|
||||
run: |
|
||||
shasum -a 256 \
|
||||
"Allofit-${{ steps.version.outputs.version }}.zip" \
|
||||
@@ -67,6 +69,6 @@ jobs:
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
Allofit-${{ steps.version.outputs.version }}.zip
|
||||
Allofit-${{ steps.version.outputs.version }}.dmg
|
||||
Allofit-${{ steps.version.outputs.version }}.sha256
|
||||
outputs/Allofit-${{ steps.version.outputs.version }}.zip
|
||||
outputs/Allofit-${{ steps.version.outputs.version }}.dmg
|
||||
outputs/Allofit-${{ steps.version.outputs.version }}.sha256
|
||||
|
||||
+3
-4
@@ -10,11 +10,10 @@ xcuserdata/
|
||||
DerivedData/
|
||||
*.xcuserstate
|
||||
|
||||
# Build output produced by ./build-app.sh and ./build-dmg.sh
|
||||
Allofit.app/
|
||||
*.dmg
|
||||
# Build output produced by ./scripts/build-app.sh and ./scripts/build-dmg.sh
|
||||
outputs/
|
||||
|
||||
# Service log files (also live in /tmp but local copies happen during dev)
|
||||
# service log files (also live in /tmp but local copies happen during dev)
|
||||
allofit-service.log
|
||||
allofit-service.err
|
||||
|
||||
|
||||
+10
-1
@@ -23,7 +23,16 @@ let package = Package(
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Allofit",
|
||||
path: "Sources/Allofit",
|
||||
path: "sources",
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]
|
||||
),
|
||||
// unit tests for the search syntax, index rules and cache format
|
||||
.testTarget(
|
||||
name: "AllofitTests",
|
||||
dependencies: ["Allofit"],
|
||||
path: "tests",
|
||||
swiftSettings: [
|
||||
.swiftLanguageMode(.v5)
|
||||
]
|
||||
|
||||
@@ -18,12 +18,24 @@ Requires macOS 15 (Sequoia) or newer.
|
||||
|
||||
## Search
|
||||
|
||||
Same syntax as Everything:
|
||||
|
||||
| Type… | …to match |
|
||||
|---|---|
|
||||
| `report` | any name containing "report" |
|
||||
| `report` | any name containing "report" (case-insensitive) |
|
||||
| `annual report` | names containing "annual" **and** "report" |
|
||||
| `Start*.pdf` | starts with "Start", ends with ".pdf" |
|
||||
| `IMG_????.heic` | "IMG_" + exactly 4 chars + ".heic" |
|
||||
| `*.png \| *.jpg` | OR — png or jpg |
|
||||
| `*.png \| *.jpg` | OR - png or jpg (OR binds tighter than the space AND) |
|
||||
| `report !draft` | NOT - names with "report" but without "draft" |
|
||||
| `"my file"` | quotes keep spaces inside one term |
|
||||
| `ext:pdf;docx` | by extension |
|
||||
| `file:` / `folder:` | files only / folders only, alone or as a prefix (`folder:build`) |
|
||||
| `src/main` | a term with `/` matches against the full path |
|
||||
| `"some/folder/**/path" IMG_????.heic` | path wildcards: `*` stays inside one folder name, `**` spans any number of folders (zero too) |
|
||||
| `photos/**/` | a trailing `/` means anything inside that folder |
|
||||
|
||||
The list shows the first 2,000 results in the chosen order; the status bar shows the total count.
|
||||
|
||||
## Shortcuts
|
||||
|
||||
@@ -43,11 +55,13 @@ Root daemon mode needs **Full Disk Access** granted to its binary in **System Se
|
||||
## Build from source
|
||||
|
||||
```bash
|
||||
./build-app.sh # produces Allofit.app
|
||||
./build-dmg.sh # produces Allofit-0.0.0.dmg
|
||||
./scripts/build-app.sh # produces outputs/Allofit-0.0.0.app
|
||||
./scripts/build-dmg.sh # produces outputs/Allofit-0.0.0.dmg
|
||||
```
|
||||
|
||||
`./clean.sh` wipes services, caches, prefs, and build artifacts for a fresh start.
|
||||
(The version is stamped into both the filename and the bundle's Info.plist. Pass `--version 1.2.3` or set `ALLOFIT_VERSION=1.2.3` to override.)
|
||||
|
||||
`./scripts/clean.sh` wipes services, caches, prefs, and build artifacts (including `outputs/`) for a fresh start.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import Foundation
|
||||
import CoreServices
|
||||
import Darwin
|
||||
|
||||
// AllofitService is the headless runtime invoked by launchd when the binary
|
||||
// is launched with the --service argument. It builds an initial index, then
|
||||
// listens to FSEvents and writes the cache to disk every few seconds.
|
||||
//
|
||||
// Holds a POSIX advisory lock on indexer.lock so a stray second instance
|
||||
// (a leftover launchd job, or someone running --service manually) exits
|
||||
// quietly instead of fighting for the cache file.
|
||||
enum AllofitService {
|
||||
|
||||
// Shared mutable state across the FSEvents callback queue and the
|
||||
// autosave loop. Wrapped in a class so closures capture by reference
|
||||
// and Swift 6's @Sendable closures can hold it cleanly. @unchecked
|
||||
// Sendable because every access goes through the NSLock below.
|
||||
private final class State: @unchecked Sendable {
|
||||
var records: [FileRecord] = []
|
||||
var pathIndex: [String: Int] = [:]
|
||||
var dirty: Bool = false
|
||||
let lock = NSLock()
|
||||
}
|
||||
|
||||
// runs the indexer/watcher loop forever (never returns)
|
||||
static func run() -> Never {
|
||||
// ensure files we create are world-readable (root daemon writes the
|
||||
// cache; the GUI runs as the user and must be able to read it)
|
||||
umask(0o022)
|
||||
NSLog("[Allofit] service starting (uid=\(getuid()))")
|
||||
|
||||
// figure out whether we are the root daemon writing to /Library or
|
||||
// the user agent writing to ~/Library, then acquire the lock
|
||||
let vIsSystem = ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1"
|
||||
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: vIsSystem).path)
|
||||
if !vLock.tryLock() {
|
||||
let vHolder = IndexerLock.readHolderPid(path: vLock.path).map(String.init) ?? "unknown"
|
||||
NSLog("[Allofit] another indexer is running (pid \(vHolder)), exiting")
|
||||
exit(0)
|
||||
}
|
||||
|
||||
let vPrefs = Preferences.shared
|
||||
let vRoots = VolumeManager.effectiveRoots(inPreferences: vPrefs)
|
||||
let vMatcher = ExclusionMatcher(inExclusions: vPrefs.excludedPaths)
|
||||
// log the actual configuration so the user can verify owner-prefs sync
|
||||
// (root daemon reads from /Users/<owner>/Library/Preferences/...)
|
||||
NSLog("[Allofit] roots: %@", vRoots.map { $0.path }.joined(separator: ", "))
|
||||
NSLog("[Allofit] excluded paths (%d): %@",
|
||||
vPrefs.excludedPaths.count,
|
||||
vPrefs.excludedPaths.joined(separator: ", "))
|
||||
|
||||
let vState = State()
|
||||
|
||||
// initial scan: streaming the walker through the shared state lock so
|
||||
// the autosave thread (every 3s) can write partial progress while we
|
||||
// continue walking. Without this, large filesystems leave the cache
|
||||
// empty for many minutes and the GUI shows nothing.
|
||||
let vStartId = UInt64(FSEventsGetCurrentEventId())
|
||||
for vRoot in vRoots {
|
||||
NSLog("[Allofit] scanning %@", vRoot.path)
|
||||
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
|
||||
vState.lock.lock()
|
||||
for vRec in vBatch {
|
||||
if vMatcher.isExcluded(inPath: vRec.fullPath) { continue }
|
||||
if vState.pathIndex[vRec.fullPath] == nil {
|
||||
vState.pathIndex[vRec.fullPath] = vState.records.count
|
||||
vState.records.append(vRec)
|
||||
}
|
||||
}
|
||||
vState.dirty = true
|
||||
vState.lock.unlock()
|
||||
}
|
||||
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
|
||||
}
|
||||
// force one save right after the scan finishes, so the GUI sees a
|
||||
// stable count even if no FSEvents come in for a while afterwards
|
||||
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
|
||||
NSLog("[Allofit] initial scan complete (%d entries)", vState.records.count)
|
||||
|
||||
// FSEvents watcher
|
||||
let vWatcher = FileWatcher()
|
||||
NSLog("[Allofit] starting FSEvents watcher on %d root(s)", vRoots.count)
|
||||
vWatcher.start(
|
||||
inRoots: vRoots.map { $0.path },
|
||||
inSinceWhen: FSEventStreamEventId(vStartId)
|
||||
) { vChanges in
|
||||
NSLog("[Allofit] FSEvents batch: %d change(s) (sample: %@)",
|
||||
vChanges.count,
|
||||
vChanges.first?.path ?? "—")
|
||||
vState.lock.lock()
|
||||
defer { vState.lock.unlock() }
|
||||
var vRescanPrefixes: [String] = []
|
||||
var vAdded = 0
|
||||
var vUpdated = 0
|
||||
// batch removals into a set so we do one bulk allRecords pass
|
||||
// and rebuild pathIndex once per FSEvents batch, instead of
|
||||
// O(records) per individual removal - that nested rebuild was
|
||||
// dominating CPU and turning into the daemon's memory churn
|
||||
var vRemoved: Set<String> = []
|
||||
|
||||
for vChange in vChanges {
|
||||
if vMatcher.isExcluded(inPath: vChange.path) { continue }
|
||||
if vChange.mustScanSubDirs {
|
||||
vRescanPrefixes.append(vChange.path)
|
||||
continue
|
||||
}
|
||||
// skip paths we've already queued for removal in this batch
|
||||
if vRemoved.contains(vChange.path) { continue }
|
||||
let vUrl = URL(fileURLWithPath: vChange.path)
|
||||
let vExists = (try? vUrl.checkResourceIsReachable()) ?? false
|
||||
if vExists, let vRec = FileIndexer.makeRecord(inURL: vUrl) {
|
||||
if let vIdx = vState.pathIndex[vRec.fullPath] {
|
||||
vState.records[vIdx] = vRec
|
||||
vUpdated += 1
|
||||
} else {
|
||||
vState.pathIndex[vRec.fullPath] = vState.records.count
|
||||
vState.records.append(vRec)
|
||||
vAdded += 1
|
||||
}
|
||||
} else if vState.pathIndex[vChange.path] != nil {
|
||||
vRemoved.insert(vChange.path)
|
||||
}
|
||||
}
|
||||
|
||||
if !vRemoved.isEmpty {
|
||||
// single bulk removeAll + single pathIndex rebuild
|
||||
vState.records.removeAll { vRemoved.contains($0.fullPath) }
|
||||
rebuildPathIndex(vState)
|
||||
}
|
||||
|
||||
if vAdded + vUpdated + vRemoved.count > 0 {
|
||||
NSLog("[Allofit] applied: +%d / ~%d / -%d (total %d)",
|
||||
vAdded, vUpdated, vRemoved.count, vState.records.count)
|
||||
}
|
||||
|
||||
if !vRescanPrefixes.isEmpty {
|
||||
NSLog("[Allofit] rescanning \(vRescanPrefixes.count) subtree(s) (history lost)")
|
||||
let vNormalized = vRescanPrefixes.map { $0.hasSuffix("/") ? $0 : $0 + "/" }
|
||||
vState.records.removeAll { vRec in
|
||||
let vP = vRec.fullPath
|
||||
for vPre in vNormalized where vP == String(vPre.dropLast()) || vP.hasPrefix(vPre) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
for vPath in vRescanPrefixes {
|
||||
let vList = FileIndexer.indexRoot(
|
||||
inRoot: URL(fileURLWithPath: vPath),
|
||||
inExclusions: vMatcher
|
||||
)
|
||||
vState.records.append(contentsOf: vList)
|
||||
}
|
||||
rebuildPathIndex(vState)
|
||||
}
|
||||
|
||||
vState.dirty = true
|
||||
}
|
||||
|
||||
// periodic save loop (background thread). 3-second check interval so
|
||||
// new files appear in the GUI within a few seconds of being created.
|
||||
// Every N saves we also compact the in-memory containers so Swift's
|
||||
// Array/Dict capacity (which only grows on churn, never auto-shrinks)
|
||||
// doesn't drift into multi-GB territory after a day of heavy file
|
||||
// activity. RSS is logged each save so the trajectory is visible.
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let kCompactEvery = 20
|
||||
var vSavesSinceCompact = 0
|
||||
while true {
|
||||
sleep(3)
|
||||
vState.lock.lock()
|
||||
let vShouldSave = vState.dirty
|
||||
let vSnapshot = vState.records
|
||||
vState.dirty = false
|
||||
vState.lock.unlock()
|
||||
if !vShouldSave { continue }
|
||||
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
|
||||
vSnapshot.count, processFootprintMB())
|
||||
IndexStore.save(
|
||||
inRecords: vSnapshot,
|
||||
inLastEventId: vWatcher.latestEventId
|
||||
)
|
||||
vSavesSinceCompact += 1
|
||||
if vSavesSinceCompact >= kCompactEvery {
|
||||
vSavesSinceCompact = 0
|
||||
compactContainers(vState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// block forever on the runloop so launchd keeps us alive
|
||||
RunLoop.current.run()
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// rebuilds pathIndex from records. Used after a bulk allRecords mutation
|
||||
// (batched removal or subtree rescan) - much cheaper than incrementally
|
||||
// maintaining pathIndex during the mutation, and the reserveCapacity
|
||||
// lets the dict size to its target without re-bucketing on each insert.
|
||||
private static func rebuildPathIndex(_ inState: State) {
|
||||
var vIndex: [String: Int] = [:]
|
||||
vIndex.reserveCapacity(inState.records.count)
|
||||
for (vI, vR) in inState.records.enumerated() {
|
||||
vIndex[vR.fullPath] = vI
|
||||
}
|
||||
inState.pathIndex = vIndex
|
||||
}
|
||||
|
||||
// recreates records and pathIndex with capacities matched to their
|
||||
// actual element count. Swift Array/Dict only grow their backing
|
||||
// allocations under churn, never auto-shrink, so a daemon that's
|
||||
// been processing FSEvents for days can hold huge dead capacity
|
||||
// (records.capacity >> records.count) that shows up as multi-GB
|
||||
// RSS. Forcing fresh containers reclaims it.
|
||||
private static func compactContainers(_ inState: State) {
|
||||
inState.lock.lock()
|
||||
defer { inState.lock.unlock() }
|
||||
let vBefore = processFootprintMB()
|
||||
// Array(_:) creates a fresh array sized exactly to the source -
|
||||
// the old buffer's slack capacity is released
|
||||
let vCompactRecords = Array(inState.records)
|
||||
var vCompactIndex: [String: Int] = [:]
|
||||
vCompactIndex.reserveCapacity(vCompactRecords.count)
|
||||
for (vI, vR) in vCompactRecords.enumerated() {
|
||||
vCompactIndex[vR.fullPath] = vI
|
||||
}
|
||||
inState.records = vCompactRecords
|
||||
inState.pathIndex = vCompactIndex
|
||||
let vAfter = processFootprintMB()
|
||||
NSLog("[Allofit] compacted (%d records, RSS %.1f → %.1f MB)",
|
||||
vCompactRecords.count, vBefore, vAfter)
|
||||
}
|
||||
|
||||
// resident-memory size in MB matching Activity Monitor's "Memory" column
|
||||
// on modern macOS (Catalina+). phys_footprint is the kernel's accounting
|
||||
// of pages owned by the task minus shared/clean pages.
|
||||
private static func processFootprintMB() -> Double {
|
||||
var vInfo = task_vm_info_data_t()
|
||||
var vCount = mach_msg_type_number_t(
|
||||
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
|
||||
)
|
||||
let vResult = withUnsafeMutablePointer(to: &vInfo) { vPtr in
|
||||
vPtr.withMemoryRebound(to: integer_t.self, capacity: Int(vCount)) { vIntPtr in
|
||||
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), vIntPtr, &vCount)
|
||||
}
|
||||
}
|
||||
if vResult == KERN_SUCCESS {
|
||||
return Double(vInfo.phys_footprint) / (1024.0 * 1024.0)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// ExclusionMatcher tests filesystem paths against a configured exclusion list.
|
||||
// A path is excluded when it matches an exclusion exactly or sits beneath one
|
||||
// of the configured prefix directories.
|
||||
struct ExclusionMatcher {
|
||||
|
||||
// normalized exclusion prefixes (no trailing slash, tilde expanded)
|
||||
private let prefixes: [String]
|
||||
|
||||
// builds a matcher from raw user-entered exclusion strings
|
||||
init(inExclusions: [String]) {
|
||||
prefixes = inExclusions.map { vRaw in
|
||||
let vExpanded = (vRaw as NSString).expandingTildeInPath
|
||||
let vStandard = (vExpanded as NSString).standardizingPath
|
||||
if vStandard.hasSuffix("/") && vStandard.count > 1 {
|
||||
return String(vStandard.dropLast())
|
||||
}
|
||||
return vStandard
|
||||
}
|
||||
}
|
||||
|
||||
// returns true when inPath matches an exclusion exactly or is below one
|
||||
func isExcluded(inPath: String) -> Bool {
|
||||
if prefixes.isEmpty { return false }
|
||||
let vPath = (inPath as NSString).standardizingPath
|
||||
for vEx in prefixes {
|
||||
if vPath == vEx { return true }
|
||||
if vPath.hasPrefix(vEx + "/") { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// FileIndexer walks a filesystem root and emits lightweight FileRecord entries.
|
||||
// It is a stateless utility so it can be invoked safely from any thread.
|
||||
//
|
||||
// Pre-fetching a fixed set of URLResourceKey values lets FileManager batch the
|
||||
// attribute reads through getattrlistbulk under the hood, which is the macOS
|
||||
// equivalent of the bulk MFT scan that makes voidtools Everything so fast.
|
||||
enum FileIndexer {
|
||||
|
||||
// the set of resource keys we ask for up front so they are pre-fetched in bulk
|
||||
static let kPrefetchKeys: [URLResourceKey] = [
|
||||
.nameKey,
|
||||
.isDirectoryKey,
|
||||
.fileSizeKey,
|
||||
.creationDateKey,
|
||||
.contentModificationDateKey,
|
||||
.fileResourceIdentifierKey
|
||||
]
|
||||
|
||||
// recursively walks inRoot, yielding records in batches via inBatch.
|
||||
// The batched form lets long scans publish partial progress: the daemon
|
||||
// can have its autosave write what it has so far while the rest of the
|
||||
// filesystem is still being walked. Excluded paths and their descendants
|
||||
// are pruned from the output.
|
||||
static func walkRoot(inRoot: URL,
|
||||
inExclusions: ExclusionMatcher? = nil,
|
||||
inBatchSize: Int = 2000,
|
||||
inBatch: (_ inRecords: [FileRecord]) -> Void) {
|
||||
// exclude the root itself
|
||||
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: inRoot.path) {
|
||||
return
|
||||
}
|
||||
|
||||
guard let vEnumerator = FileManager.default.enumerator(
|
||||
at: inRoot,
|
||||
includingPropertiesForKeys: kPrefetchKeys,
|
||||
options: [.skipsHiddenFiles, .skipsPackageDescendants],
|
||||
errorHandler: { (_, _) in true }
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
var vBatch: [FileRecord] = []
|
||||
vBatch.reserveCapacity(inBatchSize)
|
||||
|
||||
// include the root directory itself in the first batch
|
||||
if let vRootRecord = makeRecord(inURL: inRoot) {
|
||||
vBatch.append(vRootRecord)
|
||||
}
|
||||
|
||||
for vCase in vEnumerator {
|
||||
guard let vURL = vCase as? URL else { continue }
|
||||
|
||||
// short-circuit excluded entries (and don't descend into them)
|
||||
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
|
||||
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
if vIsDir { vEnumerator.skipDescendants() }
|
||||
continue
|
||||
}
|
||||
|
||||
if let vRecord = makeRecord(inURL: vURL) {
|
||||
vBatch.append(vRecord)
|
||||
if vBatch.count >= inBatchSize {
|
||||
inBatch(vBatch)
|
||||
vBatch.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vBatch.isEmpty {
|
||||
inBatch(vBatch)
|
||||
}
|
||||
}
|
||||
|
||||
// convenience wrapper that materializes the full record list. Used by the
|
||||
// GUI's in-process reindex (which builds everything before swapping the
|
||||
// index in atomically). For the daemon, prefer walkRoot directly so the
|
||||
// in-memory index grows incrementally and the autosave can persist partial
|
||||
// progress while the scan continues.
|
||||
static func indexRoot(inRoot: URL,
|
||||
inExclusions: ExclusionMatcher? = nil,
|
||||
inProgress: ((Int) -> Void)? = nil) -> [FileRecord] {
|
||||
var vRecords: [FileRecord] = []
|
||||
vRecords.reserveCapacity(100_000)
|
||||
var vLastReport = Date.distantPast
|
||||
let kReportInterval: TimeInterval = 0.25
|
||||
walkRoot(inRoot: inRoot, inExclusions: inExclusions) { vBatch in
|
||||
vRecords.append(contentsOf: vBatch)
|
||||
let vNow = Date()
|
||||
if vNow.timeIntervalSince(vLastReport) >= kReportInterval {
|
||||
inProgress?(vRecords.count)
|
||||
vLastReport = vNow
|
||||
}
|
||||
}
|
||||
inProgress?(vRecords.count)
|
||||
return vRecords
|
||||
}
|
||||
|
||||
// builds a FileRecord from a URL's pre-fetched resource values.
|
||||
// Clears the URL's resource-value cache first so we always re-stat the
|
||||
// file - a file modified between two FSEvents batches would otherwise
|
||||
// silently return the cached pre-edit mtime.
|
||||
static func makeRecord(inURL: URL) -> FileRecord? {
|
||||
var vUrl = inURL
|
||||
vUrl.removeAllCachedResourceValues()
|
||||
guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) else {
|
||||
return nil
|
||||
}
|
||||
let vName = vValues.name ?? inURL.lastPathComponent
|
||||
let vParent = inURL.deletingLastPathComponent().path
|
||||
let vIsDir = vValues.isDirectory ?? false
|
||||
let vSize = Int64(vValues.fileSize ?? 0)
|
||||
let vCreated = vValues.creationDate ?? .distantPast
|
||||
let vModified = vValues.contentModificationDate ?? .distantPast
|
||||
// derive a stable identifier from the absolute path
|
||||
var vHasher = Hasher()
|
||||
vHasher.combine(inURL.path)
|
||||
let vId = UInt64(bitPattern: Int64(vHasher.finalize()))
|
||||
return FileRecord(
|
||||
id: vId,
|
||||
name: vName,
|
||||
parentPath: vParent,
|
||||
size: vSize,
|
||||
dateCreated: vCreated,
|
||||
dateModified: vModified,
|
||||
isDirectory: vIsDir
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// FileRecord stores the minimum metadata needed to display and search a single
|
||||
// filesystem entry. Keeping only names and small fixed-size fields lets the
|
||||
// in-memory index scale to hundreds of thousands of entries.
|
||||
struct FileRecord: Identifiable, Hashable {
|
||||
|
||||
// stable identifier used for SwiftUI list diffing
|
||||
let id: UInt64
|
||||
// last path component (file or directory name)
|
||||
let name: String
|
||||
// parent directory absolute path
|
||||
let parentPath: String
|
||||
// size in bytes (0 for directories)
|
||||
let size: Int64
|
||||
// creation timestamp
|
||||
let dateCreated: Date
|
||||
// last modification timestamp
|
||||
let dateModified: Date
|
||||
// true if this entry is a directory
|
||||
let isDirectory: Bool
|
||||
// lowercased name cached once for case-insensitive substring matching
|
||||
let nameLower: String
|
||||
|
||||
init(id: UInt64,
|
||||
name: String,
|
||||
parentPath: String,
|
||||
size: Int64,
|
||||
dateCreated: Date,
|
||||
dateModified: Date,
|
||||
isDirectory: Bool) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.parentPath = parentPath
|
||||
self.size = size
|
||||
self.dateCreated = dateCreated
|
||||
self.dateModified = dateModified
|
||||
self.isDirectory = isDirectory
|
||||
self.nameLower = name.lowercased()
|
||||
}
|
||||
|
||||
// returns the absolute full path computed from parent and name
|
||||
var fullPath: String {
|
||||
if parentPath.isEmpty { return name }
|
||||
if parentPath == "/" { return "/" + name }
|
||||
return parentPath + "/" + name
|
||||
}
|
||||
}
|
||||
|
||||
// Sort modes for FileRecord lists. Defined at file scope so both Preferences
|
||||
// (which persists the last choice) and AppModel can reference it without a
|
||||
// circular dependency.
|
||||
enum FileSortDescriptor: String, CaseIterable, Identifiable {
|
||||
case nameAscending, nameDescending
|
||||
case sizeAscending, sizeDescending
|
||||
case createdAscending, createdDescending
|
||||
case modifiedAscending, modifiedDescending
|
||||
case pathAscending, pathDescending
|
||||
var id: String { rawValue }
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// Main is the binary's entry point. It routes between two modes based on
|
||||
// command-line arguments: --service starts the headless indexer/watcher
|
||||
// daemon, anything else launches the SwiftUI GUI application.
|
||||
@main
|
||||
struct Main {
|
||||
static func main() {
|
||||
if CommandLine.arguments.contains("--service") {
|
||||
AllofitService.run()
|
||||
} else {
|
||||
AllofitApp.main()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// SearchEngine matches a user query against a FileRecord's name.
|
||||
//
|
||||
// Without wildcards a query is a fast case-insensitive substring match.
|
||||
// With * or ? wildcards it compiles to an anchored regex so "Start*.pdf"
|
||||
// behaves like Everything: anchored at both ends and matched against the
|
||||
// file name (not the full path).
|
||||
//
|
||||
// A query may contain "|" as a top-level OR separator, so:
|
||||
// *.png | *.jpg | *.gif
|
||||
// matches anything ending in .png, .jpg or .gif. Whitespace around the
|
||||
// pipes is ignored; empty alternatives are dropped.
|
||||
struct SearchEngine {
|
||||
|
||||
// one alternative of the OR-separated query
|
||||
private enum Submatcher {
|
||||
// pre-lowercased substring for case-insensitive contains()
|
||||
case substring(String)
|
||||
// compiled anchored regex translated from a *? wildcard pattern
|
||||
case regex(NSRegularExpression)
|
||||
}
|
||||
|
||||
// all OR-alternatives; the query matches if any submatcher matches
|
||||
private let matchers: [Submatcher]
|
||||
|
||||
// builds an engine for the provided query string
|
||||
init(inQuery: String) {
|
||||
let vTrimmed = inQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if vTrimmed.isEmpty {
|
||||
self.matchers = []
|
||||
return
|
||||
}
|
||||
// split on top-level pipes (no escape mechanism - pipe is reserved)
|
||||
let vParts = vTrimmed
|
||||
.split(separator: "|", omittingEmptySubsequences: true)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
var vResult: [Submatcher] = []
|
||||
for vPart in vParts {
|
||||
let vHasWildcards = vPart.contains("*") || vPart.contains("?")
|
||||
if !vHasWildcards {
|
||||
vResult.append(.substring(vPart.lowercased()))
|
||||
} else {
|
||||
let vPattern = SearchEngine.wildcardToRegex(inPattern: vPart)
|
||||
if let vRegex = try? NSRegularExpression(pattern: vPattern, options: [.caseInsensitive]) {
|
||||
vResult.append(.regex(vRegex))
|
||||
}
|
||||
}
|
||||
}
|
||||
self.matchers = vResult
|
||||
}
|
||||
|
||||
// true when this engine has any pattern that will filter results
|
||||
var isActive: Bool { !matchers.isEmpty }
|
||||
|
||||
// returns true if the record's name matches any of the OR-alternatives
|
||||
func match(inRecord: FileRecord) -> Bool {
|
||||
for vMatcher in matchers {
|
||||
switch vMatcher {
|
||||
case .substring(let vNeedle):
|
||||
if inRecord.nameLower.contains(vNeedle) { return true }
|
||||
case .regex(let vRegex):
|
||||
let vName = inRecord.name
|
||||
let vRange = NSRange(vName.startIndex..., in: vName)
|
||||
if vRegex.firstMatch(in: vName, options: [], range: vRange) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// converts an Everything-style wildcard pattern into an anchored regex
|
||||
private static func wildcardToRegex(inPattern: String) -> String {
|
||||
var vResult = "^"
|
||||
for vChar in inPattern {
|
||||
switch vChar {
|
||||
case "*":
|
||||
vResult.append(".*")
|
||||
case "?":
|
||||
vResult.append(".")
|
||||
case ".", "+", "(", ")", "[", "]", "{", "}", "^", "$", "|", "\\":
|
||||
vResult.append("\\")
|
||||
vResult.append(vChar)
|
||||
default:
|
||||
vResult.append(vChar)
|
||||
}
|
||||
}
|
||||
vResult.append("$")
|
||||
return vResult
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env swift
|
||||
//
|
||||
// Generates a 1024x1024 placeholder app icon at icons/icon.png so build-app.sh
|
||||
// has something to bake into Allofit.app. Designed to be "Icon Composer ready"
|
||||
// - the output is a single flat 1024x1024 PNG, which is exactly what Apple's
|
||||
// Icon Composer (macOS 26+) takes as a base layer when you want to refine
|
||||
// the icon with Liquid Glass effects.
|
||||
//
|
||||
// Run from the project root:
|
||||
// swift make-placeholder-icon.swift
|
||||
// ./build-app.sh # picks up icons/icon.png automatically
|
||||
//
|
||||
// Tweak the colors / shape constants below and re-run to iterate.
|
||||
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import ImageIO
|
||||
import Foundation
|
||||
|
||||
// ==================
|
||||
// MARK: Tunables
|
||||
// ==================
|
||||
|
||||
// Final image side length (px). 1024 is the largest slot the macOS iconset
|
||||
// asks for so it works for every output size after sips downscales.
|
||||
let kSizePixels: Int = 1024
|
||||
// Apple's rounded-square corner radius is ~22.37% of side (classic) or
|
||||
// ~25% (macOS 26 "Tahoe"). 22.37% is a safe middle ground.
|
||||
let kCornerFactor: CGFloat = 0.2237
|
||||
// Gradient stops (top-left to bottom-right): blue → teal
|
||||
let kGradientStart = CGColor(srgbRed: 0.00, green: 0.48, blue: 1.00, alpha: 1)
|
||||
let kGradientEnd = CGColor(srgbRed: 0.13, green: 0.71, blue: 0.92, alpha: 1)
|
||||
// Magnifying glass styling (in normalised 0..1 of the canvas)
|
||||
let kGlassCenterX: CGFloat = 0.42
|
||||
let kGlassCenterY: CGFloat = 0.58
|
||||
let kGlassRadius: CGFloat = 0.20
|
||||
let kHandleLength: CGFloat = 0.22
|
||||
let kStrokeWidth: CGFloat = 0.06
|
||||
// Output relative path
|
||||
let kOutputPath = "icons/icon.png"
|
||||
|
||||
// ==================
|
||||
// MARK: Draw
|
||||
// ==================
|
||||
|
||||
let vSize = CGFloat(kSizePixels)
|
||||
guard let vColorSpace = CGColorSpace(name: CGColorSpace.sRGB) else {
|
||||
fputs("could not create sRGB color space\n", stderr); exit(1)
|
||||
}
|
||||
guard let ctx = CGContext(
|
||||
data: nil,
|
||||
width: kSizePixels,
|
||||
height: kSizePixels,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: vColorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
fputs("could not create CG context\n", stderr); exit(2)
|
||||
}
|
||||
|
||||
// Rounded-square mask matching Apple's app icon silhouette
|
||||
let vRect = CGRect(x: 0, y: 0, width: vSize, height: vSize)
|
||||
let vCornerRadius = vSize * kCornerFactor
|
||||
let vMaskPath = CGPath(roundedRect: vRect,
|
||||
cornerWidth: vCornerRadius,
|
||||
cornerHeight: vCornerRadius,
|
||||
transform: nil)
|
||||
|
||||
// Gradient background, clipped to the rounded square
|
||||
ctx.saveGState()
|
||||
ctx.addPath(vMaskPath)
|
||||
ctx.clip()
|
||||
let vGradient = CGGradient(
|
||||
colorsSpace: vColorSpace,
|
||||
colors: [kGradientStart, kGradientEnd] as CFArray,
|
||||
locations: [0.0, 1.0]
|
||||
)!
|
||||
ctx.drawLinearGradient(vGradient,
|
||||
start: CGPoint(x: 0, y: vSize),
|
||||
end: CGPoint(x: vSize, y: 0),
|
||||
options: [])
|
||||
ctx.restoreGState()
|
||||
|
||||
// White magnifying glass on top
|
||||
ctx.setStrokeColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1))
|
||||
ctx.setLineWidth(vSize * kStrokeWidth)
|
||||
ctx.setLineCap(.round)
|
||||
|
||||
let vGlassCenter = CGPoint(x: vSize * kGlassCenterX, y: vSize * kGlassCenterY)
|
||||
let vGlassR = vSize * kGlassRadius
|
||||
ctx.strokeEllipse(in: CGRect(
|
||||
x: vGlassCenter.x - vGlassR,
|
||||
y: vGlassCenter.y - vGlassR,
|
||||
width: vGlassR * 2,
|
||||
height: vGlassR * 2
|
||||
))
|
||||
|
||||
// Handle: from the lower-right edge of the glass, going down-right at -45°
|
||||
let vAngle: CGFloat = -.pi / 4
|
||||
let vHandleStart = CGPoint(
|
||||
x: vGlassCenter.x + vGlassR * cos(vAngle),
|
||||
y: vGlassCenter.y + vGlassR * sin(vAngle)
|
||||
)
|
||||
let vHandleLen = vSize * kHandleLength
|
||||
let vHandleEnd = CGPoint(
|
||||
x: vHandleStart.x + vHandleLen * cos(vAngle),
|
||||
y: vHandleStart.y + vHandleLen * sin(vAngle)
|
||||
)
|
||||
ctx.move(to: vHandleStart)
|
||||
ctx.addLine(to: vHandleEnd)
|
||||
ctx.strokePath()
|
||||
|
||||
// ==================
|
||||
// MARK: Write PNG
|
||||
// ==================
|
||||
|
||||
guard let vCgImage = ctx.makeImage() else {
|
||||
fputs("could not create CGImage from context\n", stderr); exit(3)
|
||||
}
|
||||
|
||||
try? FileManager.default.createDirectory(
|
||||
atPath: (kOutputPath as NSString).deletingLastPathComponent,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let vOutputURL = URL(fileURLWithPath: kOutputPath)
|
||||
guard let vDest = CGImageDestinationCreateWithURL(
|
||||
vOutputURL as CFURL,
|
||||
"public.png" as CFString,
|
||||
1,
|
||||
nil
|
||||
) else {
|
||||
fputs("could not create image destination\n", stderr); exit(4)
|
||||
}
|
||||
CGImageDestinationAddImage(vDest, vCgImage, nil)
|
||||
guard CGImageDestinationFinalize(vDest) else {
|
||||
fputs("PNG finalize failed\n", stderr); exit(5)
|
||||
}
|
||||
|
||||
print("Wrote \(kOutputPath) (\(kSizePixels)x\(kSizePixels))")
|
||||
@@ -1,14 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds Allofit.app from the SwiftPM executable so the binary can be
|
||||
# launched like any other macOS app (double-click in Finder, dragged into
|
||||
# /Applications, etc.). Run from the project root:
|
||||
# ./build-app.sh
|
||||
# ./build-app.sh --version 1.0.0 # explicit version stamp
|
||||
# ./build-app.sh -v 1.0.0 -b 42 # version + build number
|
||||
# ALLOFIT_VERSION=1.0.0 ./build-app.sh # env var still works
|
||||
# /Applications, etc.). Can be called from anywhere - paths are resolved
|
||||
# relative to the script's own location:
|
||||
# ./scripts/build-app.sh # from project root
|
||||
# scripts/build-app.sh --version 1.0.0
|
||||
# ./build-app.sh -v 1.0.0 -b 42 # from scripts/
|
||||
# ALLOFIT_VERSION=1.0.0 ./scripts/build-app.sh # env var still works
|
||||
#
|
||||
# Version precedence: --version arg > ALLOFIT_VERSION env > default 0.0.0.
|
||||
# The produced bundle ends up at ./Allofit.app.
|
||||
# The produced bundle ends up at <project root>/outputs/Allofit-<version>.app
|
||||
# (the version is appended so multiple builds can coexist in outputs/).
|
||||
#
|
||||
# Icon support (optional, first match wins):
|
||||
# icons/Allofit.icns - pre-built .icns, copied straight in
|
||||
@@ -69,13 +71,18 @@ else
|
||||
kBuildNumber="${ALLOFIT_BUILD:-0}"
|
||||
fi
|
||||
|
||||
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
|
||||
vAppBundle="${vProjectRoot}/${kAppName}.app"
|
||||
# Resolve the project root from the script's own location (scripts/..),
|
||||
# so this script works regardless of the caller's CWD.
|
||||
vProjectRoot="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
vOutputsDir="${vProjectRoot}/outputs"
|
||||
vAppBundle="${vOutputsDir}/${kAppName}-${kVersion}.app"
|
||||
vMacOSDir="${vAppBundle}/Contents/MacOS"
|
||||
vResourcesDir="${vAppBundle}/Contents/Resources"
|
||||
vBinarySrc="${vProjectRoot}/.build/release/${kAppName}"
|
||||
vIconStagingDir="${vProjectRoot}/.build/icon-staging"
|
||||
|
||||
mkdir -p "${vOutputsDir}"
|
||||
|
||||
# ==================
|
||||
# MARK: Build binary
|
||||
# ==================
|
||||
@@ -190,7 +197,7 @@ cat > "${vAppBundle}/Contents/Info.plist" <<EOF
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${kBuildNumber}</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© 2026 bitsycore — MIT Licensed</string>
|
||||
<string>© 2026 bitsycore - MIT Licensed</string>
|
||||
${vIconPlistEntry}
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>15.0</string>
|
||||
@@ -3,22 +3,31 @@
|
||||
# distribution from the GitHub Releases page. The DMG opens to a window
|
||||
# with the .app and an /Applications symlink so the user can drag-install.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-dmg.sh # default version (0.0.0)
|
||||
# ./build-dmg.sh --version 1.0.0 # explicit version
|
||||
# ./build-dmg.sh -v 1.0.0 --skip-build # version + skip rebuild
|
||||
# ALLOFIT_VERSION=1.0.0 ./build-dmg.sh # env var still works
|
||||
# Can be called from anywhere - paths are resolved relative to the
|
||||
# script's own location:
|
||||
# ./scripts/build-dmg.sh # from project root
|
||||
# scripts/build-dmg.sh --version 1.0.0
|
||||
# ./build-dmg.sh -v 1.0.0 --skip-build # from scripts/
|
||||
# ALLOFIT_VERSION=1.0.0 ./scripts/build-dmg.sh # env var still works
|
||||
#
|
||||
# Version precedence: --version arg > ALLOFIT_VERSION env > default 0.0.0.
|
||||
# Output: Allofit-<version>.dmg in the project root.
|
||||
# Output: <project root>/outputs/Allofit-<version>.dmg.
|
||||
set -euo pipefail
|
||||
|
||||
kAppName="Allofit"
|
||||
|
||||
vProjectRoot="$(cd "$(dirname "$0")" && pwd)"
|
||||
vAppBundle="${vProjectRoot}/${kAppName}.app"
|
||||
# Resolve the project root from the script's own location (scripts/..),
|
||||
# so this script works regardless of the caller's CWD.
|
||||
vProjectRoot="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
vOutputsDir="${vProjectRoot}/outputs"
|
||||
vStagingDir="${vProjectRoot}/.build/dmg-staging"
|
||||
|
||||
mkdir -p "${vOutputsDir}"
|
||||
|
||||
# vAppBundle is set after kVersion is resolved below - the .app bundle's
|
||||
# filename includes the version, so we can't construct the path until we
|
||||
# know which version we're targeting.
|
||||
|
||||
# ==================
|
||||
# MARK: Args
|
||||
# ==================
|
||||
@@ -56,7 +65,8 @@ if [[ -n "$vVersionArg" ]]; then
|
||||
else
|
||||
kVersion="${ALLOFIT_VERSION:-0.0.0}"
|
||||
fi
|
||||
vDmgPath="${vProjectRoot}/${kAppName}-${kVersion}.dmg"
|
||||
vDmgPath="${vOutputsDir}/${kAppName}-${kVersion}.dmg"
|
||||
vAppBundle="${vOutputsDir}/${kAppName}-${kVersion}.app"
|
||||
|
||||
# ==================
|
||||
# MARK: Build .app
|
||||
@@ -66,11 +76,11 @@ if [[ "$vSkipBuild" -eq 0 ]]; then
|
||||
echo "==> Rebuilding ${kAppName}.app first"
|
||||
# Propagate the resolved version into build-app.sh so the bundled
|
||||
# Info.plist matches what we're naming the DMG.
|
||||
ALLOFIT_VERSION="${kVersion}" "${vProjectRoot}/build-app.sh"
|
||||
ALLOFIT_VERSION="${kVersion}" "${vProjectRoot}/scripts/build-app.sh"
|
||||
fi
|
||||
|
||||
if [[ ! -d "${vAppBundle}" ]]; then
|
||||
echo "Run ./build-app.sh first - ${kAppName}.app is missing" >&2
|
||||
echo "Run ./scripts/build-app.sh first - ${kAppName}.app is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+15
-12
@@ -1,13 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Removes every trace of Allofit from the system: launchd services, cache
|
||||
# files, user preferences, build artifacts and log files. Useful for a
|
||||
# clean reinstall test or to fully uninstall.
|
||||
# clean reinstall test or to fully uninstall. Can be called from anywhere -
|
||||
# paths are resolved relative to the script's own location.
|
||||
#
|
||||
# Usage:
|
||||
# ./clean.sh # clean everything
|
||||
# ./clean.sh --keep-build # leave .build/ and Allofit.app/ in place
|
||||
# ./clean.sh --keep-prefs # leave UserDefaults / preferences alone
|
||||
# ./clean.sh --dry-run # print what would happen, change nothing
|
||||
# ./scripts/clean.sh # from project root
|
||||
# scripts/clean.sh --keep-build # leave .build/ and outputs/ in place
|
||||
# ./clean.sh --keep-prefs # from scripts/, keep user prefs
|
||||
# ./clean.sh --dry-run # print what would happen, change nothing
|
||||
set -uo pipefail
|
||||
|
||||
# ==================
|
||||
@@ -29,7 +30,9 @@ kServiceLogStderr="/tmp/allofit-service.err"
|
||||
# MARK: Args
|
||||
# ==================
|
||||
|
||||
vScriptDir="$(cd "$(dirname "$0")" && pwd)"
|
||||
# Resolve the project root from the script's own location (scripts/..),
|
||||
# so this script works regardless of the caller's CWD.
|
||||
vProjectRoot="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
vKeepBuild=0
|
||||
vKeepPrefs=0
|
||||
vDryRun=0
|
||||
@@ -105,7 +108,7 @@ fi
|
||||
|
||||
echo "==> Killing any leftover daemon processes"
|
||||
# `pkill -f` matches against the full command line; the daemon binary is
|
||||
# now installed as ".../Allofit Service" (renamed copy), so the literal
|
||||
# now installed as ".../Allofit service" (renamed copy), so the literal
|
||||
# "Allofit --service" no longer appears - use a regex that handles both
|
||||
# the legacy and the renamed binary by anchoring on "Allofit...--service"
|
||||
vRun pkill -f "Allofit.*--service" >/dev/null 2>&1 || true
|
||||
@@ -172,13 +175,13 @@ fi
|
||||
# ==================
|
||||
|
||||
if [[ "$vKeepBuild" -eq 0 ]]; then
|
||||
if [[ -d "${vScriptDir}/.build" ]]; then
|
||||
if [[ -d "${vProjectRoot}/.build" ]]; then
|
||||
echo "==> Removing SwiftPM .build directory"
|
||||
vRun rm -rf "${vScriptDir}/.build"
|
||||
vRun rm -rf "${vProjectRoot}/.build"
|
||||
fi
|
||||
if [[ -d "${vScriptDir}/Allofit.app" ]]; then
|
||||
echo "==> Removing Allofit.app bundle"
|
||||
vRun rm -rf "${vScriptDir}/Allofit.app"
|
||||
if [[ -d "${vProjectRoot}/outputs" ]]; then
|
||||
echo "==> Removing outputs/ directory (built .app and .dmg)"
|
||||
vRun rm -rf "${vProjectRoot}/outputs"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
// Main is the binary's entry point. Routes by command-line arguments:
|
||||
// --service run the headless indexer daemon
|
||||
// install / uninstall / ... CLI service-control commands (see CLI.swift)
|
||||
// anything else / no args launch the SwiftUI GUI
|
||||
@main
|
||||
struct Main {
|
||||
static func main() {
|
||||
let vArgs = Array(CommandLine.arguments.dropFirst())
|
||||
// No args: GUI mode. The Dock and Finder launch us with no args.
|
||||
if vArgs.isEmpty {
|
||||
AllofitApp.main()
|
||||
return
|
||||
}
|
||||
// Headless indexer daemon, invoked by launchd's plist.
|
||||
if vArgs.contains("--service") {
|
||||
AllofitService.run()
|
||||
return
|
||||
}
|
||||
// Anything else is a CLI command. Unknown commands print a hint
|
||||
// to stderr and exit 1 rather than silently launching the GUI.
|
||||
if !CLI.handle(arguments: vArgs) {
|
||||
FileHandle.standardError.write(Data(
|
||||
"Unknown command: \(vArgs.joined(separator: " "))\nUse --help for usage.\n".utf8
|
||||
))
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
// ExclusionMatcher tests filesystem paths against a configured exclusion list.
|
||||
// A path is excluded when it matches an exclusion exactly or sits beneath one
|
||||
// of the configured prefix directories.
|
||||
//
|
||||
// Called for every entry during a walk and for every record during a prune,
|
||||
// so the check is a plain UTF-8 prefix compare: the paths it receives come
|
||||
// from URL.path / FSEvents and are already standardized.
|
||||
struct ExclusionMatcher: Sendable {
|
||||
|
||||
// normalized exclusions (no trailing slash, tilde expanded)
|
||||
private let paths: [String]
|
||||
// the same exclusions with a trailing slash, as UTF-8 bytes
|
||||
private let prefixes: [[UInt8]]
|
||||
|
||||
// builds a matcher from raw user-entered exclusion strings
|
||||
init(inExclusions: [String]) {
|
||||
paths = inExclusions.map { vRaw in
|
||||
let vExpanded = (vRaw as NSString).expandingTildeInPath
|
||||
let vStandard = (vExpanded as NSString).standardizingPath
|
||||
if vStandard.hasSuffix("/") && vStandard.count > 1 {
|
||||
return String(vStandard.dropLast())
|
||||
}
|
||||
return vStandard
|
||||
}
|
||||
prefixes = paths.map { Array(($0 == "/" ? "/" : $0 + "/").utf8) }
|
||||
}
|
||||
|
||||
// returns true when inPath matches an exclusion exactly or is below one
|
||||
func isExcluded(inPath: String) -> Bool {
|
||||
if paths.isEmpty { return false }
|
||||
for (vI, vEx) in paths.enumerated() {
|
||||
if inPath == vEx { return true }
|
||||
if inPath.utf8.starts(with: prefixes[vI]) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
|
||||
// FileIndexer walks a filesystem root and emits lightweight FileRecord entries.
|
||||
// It is a stateless utility so it can be invoked safely from any thread.
|
||||
//
|
||||
// Pre-fetching a fixed set of URLResourceKey values lets FileManager batch the
|
||||
// attribute reads through getattrlistbulk under the hood, which is the macOS
|
||||
// equivalent of the bulk MFT scan that makes voidtools Everything so fast.
|
||||
enum FileIndexer {
|
||||
|
||||
// the set of resource keys we ask for up front so they are pre-fetched in bulk
|
||||
static let kPrefetchKeys: [URLResourceKey] = [
|
||||
.isDirectoryKey,
|
||||
.fileSizeKey,
|
||||
.creationDateKey,
|
||||
.contentModificationDateKey,
|
||||
.isHiddenKey
|
||||
]
|
||||
// same keys as a Set, built once instead of per record
|
||||
private static let kPrefetchKeySet = Set(kPrefetchKeys)
|
||||
|
||||
// recursively walks inRoot, yielding records in batches via inBatch.
|
||||
// The batched form lets long scans publish partial progress: the daemon
|
||||
// can have its autosave write what it has so far while the rest of the
|
||||
// filesystem is still being walked. Excluded paths and their descendants
|
||||
// are pruned from the output, as are hidden entries and package contents.
|
||||
static func walkRoot(inRoot: URL,
|
||||
inExclusions: ExclusionMatcher? = nil,
|
||||
inBatchSize: Int = 2000,
|
||||
inBatch: (_ inRecords: [FileRecord]) -> Void) {
|
||||
// exclude the root itself
|
||||
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: inRoot.path) {
|
||||
return
|
||||
}
|
||||
|
||||
guard let vEnumerator = FileManager.default.enumerator(
|
||||
at: inRoot,
|
||||
includingPropertiesForKeys: kPrefetchKeys,
|
||||
options: [.skipsHiddenFiles, .skipsPackageDescendants],
|
||||
errorHandler: { (_, _) in true }
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
var vBatch: [FileRecord] = []
|
||||
vBatch.reserveCapacity(inBatchSize)
|
||||
// one String per directory instead of one per entry
|
||||
let vInterner = PathInterner()
|
||||
|
||||
// include the root directory itself in the first batch
|
||||
if let vRootRecord = makeRecord(inURL: inRoot, inInterner: vInterner) {
|
||||
vBatch.append(vRootRecord)
|
||||
}
|
||||
|
||||
// Per-iteration autoreleasepool: every URL pulled from the
|
||||
// enumerator + every resourceValues() read autoreleases an
|
||||
// NSURL / NSDate / NSNumber. Without this drain a million-file
|
||||
// walk would let those accumulate into hundreds of MB of dead
|
||||
// allocations until the enclosing async block exited - and the
|
||||
// daemon's enclosing block never exits.
|
||||
for vCase in vEnumerator {
|
||||
autoreleasepool {
|
||||
guard let vURL = vCase as? URL else { return }
|
||||
|
||||
// short-circuit excluded entries (and don't descend into them)
|
||||
if let vMatcher = inExclusions, vMatcher.isExcluded(inPath: vURL.path) {
|
||||
let vIsDir = (try? vURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
if vIsDir { vEnumerator.skipDescendants() }
|
||||
return
|
||||
}
|
||||
|
||||
if let vRecord = makeRecord(inURL: vURL, inInterner: vInterner, inFresh: false) {
|
||||
vBatch.append(vRecord)
|
||||
if vBatch.count >= inBatchSize {
|
||||
inBatch(vBatch)
|
||||
vBatch.removeAll(keepingCapacity: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vBatch.isEmpty {
|
||||
inBatch(vBatch)
|
||||
}
|
||||
}
|
||||
|
||||
// true when the item at inPath has the hidden flag (UF_HIDDEN or a
|
||||
// dot-name) - the same test the walker's .skipsHiddenFiles applies
|
||||
static func isHidden(inPath: String) -> Bool {
|
||||
return autoreleasepool {
|
||||
let vValues = try? URL(fileURLWithPath: inPath).resourceValues(forKeys: [.isHiddenKey])
|
||||
return vValues?.isHidden ?? false
|
||||
}
|
||||
}
|
||||
|
||||
// convenience wrapper that materializes the full record list. Used by the
|
||||
// GUI's in-process reindex and subtree rescans. For the daemon, prefer
|
||||
// walkRoot directly so the in-memory index grows incrementally and the
|
||||
// autosave can persist partial progress while the scan continues.
|
||||
static func indexRoot(inRoot: URL,
|
||||
inExclusions: ExclusionMatcher? = nil,
|
||||
inProgress: ((Int) -> Void)? = nil) -> [FileRecord] {
|
||||
var vRecords: [FileRecord] = []
|
||||
var vLastReport = Date.distantPast
|
||||
let kReportInterval: TimeInterval = 0.25
|
||||
walkRoot(inRoot: inRoot, inExclusions: inExclusions) { vBatch in
|
||||
vRecords.append(contentsOf: vBatch)
|
||||
let vNow = Date()
|
||||
if vNow.timeIntervalSince(vLastReport) >= kReportInterval {
|
||||
inProgress?(vRecords.count)
|
||||
vLastReport = vNow
|
||||
}
|
||||
}
|
||||
inProgress?(vRecords.count)
|
||||
return vRecords
|
||||
}
|
||||
|
||||
// builds a FileRecord from a URL's resource values.
|
||||
// inFresh clears the URL's resource-value cache first so we always
|
||||
// re-stat the file - a file modified between two FSEvents batches would
|
||||
// otherwise silently return the cached pre-edit mtime. The walker passes
|
||||
// false because the enumerator's values were just bulk-fetched.
|
||||
// inSkipHidden returns nil for hidden entries (dot-files, UF_HIDDEN),
|
||||
// matching what the walker's .skipsHiddenFiles does. Callers wrap each
|
||||
// invocation in an autoreleasepool.
|
||||
static func makeRecord(inURL: URL,
|
||||
inInterner: PathInterner? = nil,
|
||||
inFresh: Bool = true,
|
||||
inSkipHidden: Bool = false) -> FileRecord? {
|
||||
var vUrl = inURL
|
||||
if inFresh {
|
||||
vUrl.removeAllCachedResourceValues()
|
||||
}
|
||||
guard let vValues = try? vUrl.resourceValues(forKeys: kPrefetchKeySet) else {
|
||||
return nil
|
||||
}
|
||||
if inSkipHidden && (vValues.isHidden ?? false) {
|
||||
return nil
|
||||
}
|
||||
// name/parent come from the path (not .nameKey) so that
|
||||
// parentPath + "/" + name always round-trips to the watched path,
|
||||
// which is what the id and the parent-is-indexed rule rely on
|
||||
let vPath = inURL.path
|
||||
let vName: String
|
||||
let vParentRaw: String
|
||||
if vPath == "/" {
|
||||
vName = "/"
|
||||
vParentRaw = ""
|
||||
} else {
|
||||
vName = inURL.lastPathComponent
|
||||
vParentRaw = inURL.deletingLastPathComponent().path
|
||||
}
|
||||
let vParent = inInterner?.intern(vParentRaw) ?? vParentRaw
|
||||
return FileRecord(
|
||||
name: vName,
|
||||
parentPath: vParent,
|
||||
size: Int64(vValues.fileSize ?? 0),
|
||||
dateCreated: vValues.creationDate ?? .distantPast,
|
||||
dateModified: vValues.contentModificationDate ?? .distantPast,
|
||||
isDirectory: vValues.isDirectory ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// PathInterner hands back one shared String instance per distinct value,
|
||||
// so the thousands of records of a directory share a single parentPath
|
||||
// buffer instead of each holding its own copy. Not thread-safe: use one
|
||||
// per walk / load.
|
||||
final class PathInterner {
|
||||
|
||||
// distinct value -> shared instance
|
||||
private var table: [String: String] = [:]
|
||||
|
||||
// returns the shared instance equal to inString
|
||||
func intern(_ inString: String) -> String {
|
||||
if let vShared = table[inString] { return vShared }
|
||||
table[inString] = inString
|
||||
return inString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Foundation
|
||||
|
||||
// FileRecord stores the minimum metadata needed to display and search a single
|
||||
// filesystem entry. Keeping only names and small fixed-size fields lets the
|
||||
// in-memory index scale to hundreds of thousands of entries.
|
||||
struct FileRecord: Identifiable, Hashable, Sendable {
|
||||
|
||||
// stable identifier derived from the absolute path (see pathHash). Also
|
||||
// the key of IndexState's lookup table, and the SwiftUI row identity -
|
||||
// being deterministic, it survives updates and relaunches so the
|
||||
// selection doesn't jump when a file changes under it.
|
||||
let id: UInt64
|
||||
// last path component (file or directory name)
|
||||
let name: String
|
||||
// parent directory absolute path (interned: siblings share one buffer)
|
||||
let parentPath: String
|
||||
// size in bytes (0 for directories)
|
||||
let size: Int64
|
||||
// creation timestamp
|
||||
let dateCreated: Date
|
||||
// last modification timestamp
|
||||
let dateModified: Date
|
||||
// true if this entry is a directory
|
||||
let isDirectory: Bool
|
||||
// lowercased, NFC-normalized name used for case-insensitive matching.
|
||||
// Shares the name's storage when the name is already in that form.
|
||||
let nameLower: String
|
||||
|
||||
init(name: String,
|
||||
parentPath: String,
|
||||
size: Int64,
|
||||
dateCreated: Date,
|
||||
dateModified: Date,
|
||||
isDirectory: Bool) {
|
||||
self.id = FileRecord.pathHash(inParent: parentPath, inName: name)
|
||||
self.name = name
|
||||
self.parentPath = parentPath
|
||||
self.size = size
|
||||
self.dateCreated = dateCreated
|
||||
self.dateModified = dateModified
|
||||
self.isDirectory = isDirectory
|
||||
let vLower = FileRecord.searchForm(of: name)
|
||||
self.nameLower = (vLower == name) ? name : vLower
|
||||
}
|
||||
|
||||
// returns the absolute full path computed from parent and name
|
||||
var fullPath: String {
|
||||
if parentPath.isEmpty { return name }
|
||||
if parentPath == "/" { return "/" + name }
|
||||
return parentPath + "/" + name
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Normalization / hashing
|
||||
// ===========================
|
||||
|
||||
// lowercases and canonically composes a string so byte-level matching
|
||||
// behaves like a case-insensitive, normalization-insensitive compare.
|
||||
// Pure-ASCII strings (the vast majority) skip the NFC pass.
|
||||
static func searchForm(of inString: String) -> String {
|
||||
let vLower = inString.lowercased()
|
||||
if vLower.utf8.allSatisfy({ $0 < 0x80 }) { return vLower }
|
||||
return vLower.precomposedStringWithCanonicalMapping
|
||||
}
|
||||
|
||||
// 64-bit FNV-1a offset basis and prime
|
||||
private static let kFnvOffset: UInt64 = 0xcbf2_9ce4_8422_2325
|
||||
private static let kFnvPrime: UInt64 = 0x0000_0100_0000_01b3
|
||||
|
||||
// FNV-1a hash of an absolute path's UTF-8 bytes. Deterministic across
|
||||
// processes (unlike Hasher, which is randomly seeded per launch).
|
||||
// Non-ASCII paths are hashed in composed (NFC) form: APFS accepts both
|
||||
// "é" and "e + combining accent" for the same file, and FSEvents and the
|
||||
// directory enumerator don't always report the same form.
|
||||
static func pathHash(_ inPath: String) -> UInt64 {
|
||||
var vHash = kFnvOffset
|
||||
if !isASCII(inPath) {
|
||||
for vByte in inPath.precomposedStringWithCanonicalMapping.utf8 {
|
||||
vHash = (vHash ^ UInt64(vByte)) &* kFnvPrime
|
||||
}
|
||||
return vHash
|
||||
}
|
||||
for vByte in inPath.utf8 {
|
||||
vHash = (vHash ^ UInt64(vByte)) &* kFnvPrime
|
||||
}
|
||||
return vHash
|
||||
}
|
||||
|
||||
// true when the string is plain ASCII (no normalization ambiguity)
|
||||
private static func isASCII(_ inString: String) -> Bool {
|
||||
return inString.utf8.allSatisfy { $0 < 0x80 }
|
||||
}
|
||||
|
||||
// same value as pathHash(fullPath) but computed from the parent + name
|
||||
// without allocating the joined string (ASCII fast path)
|
||||
static func pathHash(inParent: String, inName: String) -> UInt64 {
|
||||
if inParent.isEmpty { return pathHash(inName) }
|
||||
if !isASCII(inParent) || !isASCII(inName) {
|
||||
return pathHash(inParent == "/" ? "/" + inName : inParent + "/" + inName)
|
||||
}
|
||||
var vHash = kFnvOffset
|
||||
for vByte in inParent.utf8 {
|
||||
vHash = (vHash ^ UInt64(vByte)) &* kFnvPrime
|
||||
}
|
||||
if inParent != "/" {
|
||||
vHash = (vHash ^ UInt64(UInt8(ascii: "/"))) &* kFnvPrime
|
||||
}
|
||||
for vByte in inName.utf8 {
|
||||
vHash = (vHash ^ UInt64(vByte)) &* kFnvPrime
|
||||
}
|
||||
return vHash
|
||||
}
|
||||
}
|
||||
|
||||
// Sort modes for FileRecord lists. Defined at file scope so both Preferences
|
||||
// (which persists the last choice) and AppModel can reference it without a
|
||||
// circular dependency.
|
||||
enum FileSortDescriptor: String, CaseIterable, Identifiable {
|
||||
case nameAscending, nameDescending
|
||||
case sizeAscending, sizeDescending
|
||||
case createdAscending, createdDescending
|
||||
case modifiedAscending, modifiedDescending
|
||||
case pathAscending, pathDescending
|
||||
var id: String { rawValue }
|
||||
}
|
||||
@@ -10,7 +10,14 @@ struct FSChange {
|
||||
let path: String
|
||||
// raw FSEvents flag bits for this change
|
||||
let flags: FSEventStreamEventFlags
|
||||
// FSEvents id of this change, persisted so the next launch resumes after it
|
||||
let eventId: FSEventStreamEventId
|
||||
|
||||
// true for the synthetic marker FSEvents sends once the replay of
|
||||
// historical events (sinceWhen) is finished - not a real file change
|
||||
var isHistoryDone: Bool {
|
||||
return flags & FSEventStreamEventFlags(kFSEventStreamEventFlagHistoryDone) != 0
|
||||
}
|
||||
// true when the kernel asks the client to rescan inside this path
|
||||
// because events were dropped (history too old or buffer overflow)
|
||||
var mustScanSubDirs: Bool {
|
||||
@@ -75,7 +82,7 @@ final class FileWatcher: @unchecked Sendable {
|
||||
)
|
||||
|
||||
// C callback: forwards paths + flags back to the Swift handler
|
||||
let vCallback: FSEventStreamCallback = { (_, info, numEvents, eventPaths, eventFlags, _) in
|
||||
let vCallback: FSEventStreamCallback = { (_, info, numEvents, eventPaths, eventFlags, eventIds) in
|
||||
guard let vInfo = info else { return }
|
||||
let vWatcher = Unmanaged<FileWatcher>.fromOpaque(vInfo).takeUnretainedValue()
|
||||
let vPathsArray = unsafeBitCast(eventPaths, to: NSArray.self)
|
||||
@@ -83,7 +90,7 @@ final class FileWatcher: @unchecked Sendable {
|
||||
vChanges.reserveCapacity(numEvents)
|
||||
for vI in 0..<numEvents {
|
||||
guard let vPath = vPathsArray[vI] as? String else { continue }
|
||||
vChanges.append(FSChange(path: vPath, flags: eventFlags[vI]))
|
||||
vChanges.append(FSChange(path: vPath, flags: eventFlags[vI], eventId: eventIds[vI]))
|
||||
}
|
||||
vWatcher.lock.lock()
|
||||
let vHandler = vWatcher.handler
|
||||
@@ -0,0 +1,337 @@
|
||||
import Foundation
|
||||
|
||||
// ResolvedChanges is a coalesced set of FSEvents changes whose filesystem
|
||||
// lookups (stat) are already done, so merging it into an IndexState is pure
|
||||
// in-memory bookkeeping. Built off the main thread by IndexState.resolve.
|
||||
struct ResolvedChanges: Sendable {
|
||||
// fresh records for reported paths that exist and are indexable
|
||||
var upserts: [FileRecord] = []
|
||||
// reported paths that are gone (or no longer indexable: hidden, excluded)
|
||||
var removals: [String] = []
|
||||
// subtrees the kernel asked us to rescan (history lost / coalesced)
|
||||
var rescans: [String] = []
|
||||
// highest FSEvents id covered by this set of changes
|
||||
var maxEventId: UInt64 = 0
|
||||
// true when the end-of-replay marker was part of this set
|
||||
var historyDone = false
|
||||
}
|
||||
|
||||
// IndexState is the in-memory index: the record array plus an id -> position
|
||||
// lookup. Records are kept in no particular order (search results are sorted
|
||||
// on demand), which lets removals swap the last element into the hole in O(1)
|
||||
// instead of shifting the array and rebuilding the whole lookup.
|
||||
//
|
||||
// Indexing rules shared by the initial walk and live updates:
|
||||
// - a record is only kept if its parent directory is itself indexed (or it
|
||||
// is a root). That mirrors the walker skipping hidden directories,
|
||||
// package contents and exclusions without re-checking every ancestor.
|
||||
// - removing a directory removes everything below it (a folder moved to the
|
||||
// Trash only produces one FSEvents entry for the folder itself).
|
||||
// - a directory that appears (created, moved or renamed in) is rescanned,
|
||||
// since FSEvents does not report the entries moved along with it.
|
||||
//
|
||||
// Value type: callers own it on one thread (AppModel on main, the service
|
||||
// under its lock).
|
||||
struct IndexState {
|
||||
|
||||
// all indexed entries, unordered
|
||||
private(set) var records: [FileRecord] = []
|
||||
// FileRecord.id -> position in records
|
||||
private(set) var positions: [UInt64: Int] = [:]
|
||||
|
||||
// builds the state from a list of records, dropping duplicate paths
|
||||
init(inRecords: [FileRecord] = []) {
|
||||
records.reserveCapacity(inRecords.count)
|
||||
positions.reserveCapacity(inRecords.count)
|
||||
for vRecord in inRecords where positions[vRecord.id] == nil {
|
||||
positions[vRecord.id] = records.count
|
||||
records.append(vRecord)
|
||||
}
|
||||
}
|
||||
|
||||
// number of indexed entries
|
||||
var count: Int { records.count }
|
||||
|
||||
// true when the given absolute path is indexed
|
||||
func contains(inPath: String) -> Bool {
|
||||
return positions[FileRecord.pathHash(inPath)] != nil
|
||||
}
|
||||
|
||||
// returns the record at an absolute path, if indexed
|
||||
func record(atPath inPath: String) -> FileRecord? {
|
||||
guard let vPos = positions[FileRecord.pathHash(inPath)] else { return nil }
|
||||
return records[vPos]
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Mutations
|
||||
// ===========================
|
||||
|
||||
// inserts or replaces a record; returns true if the index changed
|
||||
@discardableResult
|
||||
mutating func upsert(_ inRecord: FileRecord) -> Bool {
|
||||
if let vPos = positions[inRecord.id] {
|
||||
if records[vPos] == inRecord { return false }
|
||||
records[vPos] = inRecord
|
||||
return true
|
||||
}
|
||||
positions[inRecord.id] = records.count
|
||||
records.append(inRecord)
|
||||
return true
|
||||
}
|
||||
|
||||
// removes the records at the given positions (swap-with-last, O(k))
|
||||
private mutating func remove(inPositions: [Int]) {
|
||||
for vPos in inPositions.sorted(by: >) {
|
||||
let vRemovedId = records[vPos].id
|
||||
let vLast = records.count - 1
|
||||
if vPos != vLast {
|
||||
let vMoved = records[vLast]
|
||||
records[vPos] = vMoved
|
||||
positions[vMoved.id] = vPos
|
||||
}
|
||||
records.removeLast()
|
||||
positions.removeValue(forKey: vRemovedId)
|
||||
}
|
||||
}
|
||||
|
||||
// removes each path and, for directories, everything below it.
|
||||
// Returns the number of records removed.
|
||||
@discardableResult
|
||||
mutating func removeSubtrees(inPaths: [String]) -> Int {
|
||||
var vPositions = Set<Int>()
|
||||
var vDirPrefixes: [String] = []
|
||||
for vPath in inPaths {
|
||||
guard let vPos = positions[FileRecord.pathHash(vPath)] else { continue }
|
||||
vPositions.insert(vPos)
|
||||
if records[vPos].isDirectory {
|
||||
vDirPrefixes.append(vPath)
|
||||
}
|
||||
}
|
||||
if !vDirPrefixes.isEmpty {
|
||||
let vMatcher = SubtreeMatcher(inRoots: vDirPrefixes)
|
||||
for (vPos, vRecord) in records.enumerated() where vMatcher.containsChild(inParentPath: vRecord.parentPath) {
|
||||
vPositions.insert(vPos)
|
||||
}
|
||||
}
|
||||
remove(inPositions: Array(vPositions))
|
||||
return vPositions.count
|
||||
}
|
||||
|
||||
// replaces everything at and below each root with the given records
|
||||
// (the result of re-walking those subtrees)
|
||||
mutating func replaceSubtrees(inRoots: [String], inRecords: [FileRecord]) {
|
||||
let vMatcher = SubtreeMatcher(inRoots: inRoots)
|
||||
var vPositions: [Int] = []
|
||||
for (vPos, vRecord) in records.enumerated() {
|
||||
if vMatcher.containsChild(inParentPath: vRecord.parentPath) || vMatcher.isRoot(inRecord: vRecord) {
|
||||
vPositions.append(vPos)
|
||||
}
|
||||
}
|
||||
remove(inPositions: vPositions)
|
||||
for vRecord in inRecords {
|
||||
upsert(vRecord)
|
||||
}
|
||||
}
|
||||
|
||||
// merges resolved FSEvents changes. Returns whether anything changed and
|
||||
// the subtrees that must be (re)walked: kernel-requested rescans plus
|
||||
// directories that newly appeared. inRoots are the configured roots,
|
||||
// whose own records are allowed without an indexed parent.
|
||||
mutating func apply(inChanges: ResolvedChanges,
|
||||
inRoots: Set<String>) -> (changed: Bool, rescans: [String]) {
|
||||
var vChanged = false
|
||||
var vRescans: [String] = []
|
||||
|
||||
// removals first: a rename shows up as old path gone + new path present
|
||||
if !inChanges.removals.isEmpty {
|
||||
vChanged = removeSubtrees(inPaths: inChanges.removals) > 0 || vChanged
|
||||
}
|
||||
|
||||
// parents before children, so a new folder and its new files arriving
|
||||
// in the same batch are accepted in one pass
|
||||
let vUpserts = inChanges.upserts.sorted { $0.parentPath.utf8.count < $1.parentPath.utf8.count }
|
||||
for vRecord in vUpserts {
|
||||
let vIsRoot = inRoots.contains(vRecord.fullPath)
|
||||
guard vIsRoot || contains(inPath: vRecord.parentPath) else { continue }
|
||||
let vIsNew = positions[vRecord.id] == nil
|
||||
if upsert(vRecord) { vChanged = true }
|
||||
if vIsNew && vRecord.isDirectory {
|
||||
vRescans.append(vRecord.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
// kernel rescans only for subtrees that belong to the index
|
||||
for vPath in inChanges.rescans where inRoots.contains(vPath) || contains(inPath: vPath) {
|
||||
vRescans.append(vPath)
|
||||
}
|
||||
return (vChanged, SubtreeMatcher.minimalRoots(inPaths: vRescans))
|
||||
}
|
||||
|
||||
// drops records that fall outside the current configuration: excluded
|
||||
// paths, entries under removed roots, entries whose parent isn't indexed,
|
||||
// and hidden entries the walker would have skipped (content added by
|
||||
// older versions' live updates). Hidden means a dot-name anywhere, or a
|
||||
// hidden flag (inIsHidden, e.g. ~/Library) on a folder directly under a
|
||||
// root - deeper folders aren't stat'ed so a prune stays cheap even on
|
||||
// network roots. Run on load and after the roots/exclusions change.
|
||||
// Returns the number removed.
|
||||
@discardableResult
|
||||
mutating func prune(inRoots: Set<String>,
|
||||
inExclusions: ExclusionMatcher,
|
||||
inIsHidden: ((String) -> Bool)? = nil) -> Int {
|
||||
// visit shallow entries first so a dropped directory takes its
|
||||
// descendants with it in the same pass
|
||||
let vOrder = records.indices.sorted {
|
||||
records[$0].parentPath.utf8.count < records[$1].parentPath.utf8.count
|
||||
}
|
||||
var vKept = Set<UInt64>()
|
||||
vKept.reserveCapacity(records.count)
|
||||
var vDrop: [Int] = []
|
||||
for vPos in vOrder {
|
||||
let vRecord = records[vPos]
|
||||
let vFull = vRecord.fullPath
|
||||
let vIsRoot = inRoots.contains(vFull)
|
||||
var vKeep = vIsRoot || vKept.contains(FileRecord.pathHash(vRecord.parentPath))
|
||||
if vKeep && !vIsRoot {
|
||||
if vRecord.name.hasPrefix(".") {
|
||||
vKeep = false
|
||||
} else if vRecord.isDirectory, inRoots.contains(vRecord.parentPath), let vCheck = inIsHidden {
|
||||
vKeep = !vCheck(vFull)
|
||||
}
|
||||
}
|
||||
if vKeep && !inExclusions.isExcluded(inPath: vFull) {
|
||||
vKept.insert(vRecord.id)
|
||||
} else {
|
||||
vDrop.append(vPos)
|
||||
}
|
||||
}
|
||||
remove(inPositions: vDrop)
|
||||
return vDrop.count
|
||||
}
|
||||
|
||||
// returns a copy with array/dictionary capacity trimmed to the content.
|
||||
// Swift collections never shrink on their own, so after heavy churn
|
||||
// the slack can amount to hundreds of MB.
|
||||
func compacted() -> IndexState {
|
||||
var vResult = IndexState()
|
||||
vResult.records = Array(unsafeUninitializedCapacity: records.count) { vBuffer, vCount in
|
||||
_ = vBuffer.initialize(from: records)
|
||||
vCount = records.count
|
||||
}
|
||||
vResult.positions = Dictionary(minimumCapacity: positions.count)
|
||||
for (vKey, vValue) in positions {
|
||||
vResult.positions[vKey] = vValue
|
||||
}
|
||||
return vResult
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Change resolution
|
||||
// ===========================
|
||||
|
||||
// resolves raw FSEvents changes against the filesystem. Each unique path
|
||||
// is stat'ed once however many times it was reported (a multi-day replay
|
||||
// repeats hot files thousands of times). Safe to call on any thread.
|
||||
static func resolve(inChanges: [FSChange], inExclusions: ExclusionMatcher) -> ResolvedChanges {
|
||||
var vResult = ResolvedChanges()
|
||||
var vSeen = Set<String>()
|
||||
var vRescanSeen = Set<String>()
|
||||
var vPaths: [String] = []
|
||||
for vChange in inChanges {
|
||||
vResult.maxEventId = max(vResult.maxEventId, UInt64(vChange.eventId))
|
||||
if vChange.isHistoryDone {
|
||||
vResult.historyDone = true
|
||||
continue
|
||||
}
|
||||
if vChange.mustScanSubDirs {
|
||||
if !inExclusions.isExcluded(inPath: vChange.path), vRescanSeen.insert(vChange.path).inserted {
|
||||
vResult.rescans.append(vChange.path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if vSeen.insert(vChange.path).inserted {
|
||||
vPaths.append(vChange.path)
|
||||
}
|
||||
}
|
||||
|
||||
// package check cache: the walker doesn't descend into bundles, so
|
||||
// changes inside a .app / .photoslibrary / ... are dropped as well
|
||||
var vPackageCache: [String: Bool] = [:]
|
||||
for vPath in vPaths {
|
||||
// per-path pool: the URL + resourceValues objects autorelease
|
||||
autoreleasepool {
|
||||
if inExclusions.isExcluded(inPath: vPath) {
|
||||
vResult.removals.append(vPath)
|
||||
return
|
||||
}
|
||||
let vUrl = URL(fileURLWithPath: vPath)
|
||||
let vParent = vUrl.deletingLastPathComponent().path
|
||||
let vInPackage: Bool
|
||||
if let vCached = vPackageCache[vParent] {
|
||||
vInPackage = vCached
|
||||
} else {
|
||||
let vValues = try? URL(fileURLWithPath: vParent, isDirectory: true)
|
||||
.resourceValues(forKeys: [.isPackageKey])
|
||||
vInPackage = vValues?.isPackage ?? false
|
||||
vPackageCache[vParent] = vInPackage
|
||||
}
|
||||
if !vInPackage, let vRecord = FileIndexer.makeRecord(inURL: vUrl, inSkipHidden: true) {
|
||||
vResult.upserts.append(vRecord)
|
||||
} else {
|
||||
// normalized form (no trailing slash) so it hashes like the record
|
||||
vResult.removals.append(vUrl.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return vResult
|
||||
}
|
||||
}
|
||||
|
||||
// SubtreeMatcher answers "is this entry at or below one of these
|
||||
// directories?" using byte-level prefix checks on the parent path, so the
|
||||
// O(n) passes over the index don't allocate a full path per record.
|
||||
struct SubtreeMatcher {
|
||||
|
||||
// root directories without trailing slash ("/" kept as is)
|
||||
private let roots: [String]
|
||||
// roots with a trailing slash, for descendant checks
|
||||
private let prefixes: [[UInt8]]
|
||||
// record ids of the roots themselves
|
||||
private let rootIds: Set<UInt64>
|
||||
|
||||
// builds a matcher for the given directory paths
|
||||
init(inRoots: [String]) {
|
||||
roots = inRoots.map { ($0.count > 1 && $0.hasSuffix("/")) ? String($0.dropLast()) : $0 }
|
||||
prefixes = roots.map { Array(($0 == "/" ? "/" : $0 + "/").utf8) }
|
||||
rootIds = Set(roots.map { FileRecord.pathHash($0) })
|
||||
}
|
||||
|
||||
// true if a record whose parent is inParentPath lies below a root
|
||||
// (the root directory's own record is matched by isRoot instead)
|
||||
func containsChild(inParentPath: String) -> Bool {
|
||||
for (vI, vRoot) in roots.enumerated() {
|
||||
if inParentPath == vRoot { return true }
|
||||
if inParentPath.utf8.starts(with: prefixes[vI]) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// true if the record is one of the roots itself
|
||||
func isRoot(inRecord: FileRecord) -> Bool {
|
||||
return rootIds.contains(inRecord.id)
|
||||
}
|
||||
|
||||
// removes duplicates and paths nested under another path of the list,
|
||||
// so overlapping subtrees are only walked once
|
||||
static func minimalRoots(inPaths: [String]) -> [String] {
|
||||
var vResult: [String] = []
|
||||
for vPath in Set(inPaths).sorted(by: { $0.utf8.count < $1.utf8.count }) {
|
||||
let vCovered = vResult.contains { vRoot in
|
||||
vRoot == "/" || vPath == vRoot || vPath.hasPrefix(vRoot + "/")
|
||||
}
|
||||
if !vCovered { vResult.append(vPath) }
|
||||
}
|
||||
return vResult
|
||||
}
|
||||
}
|
||||
@@ -91,8 +91,18 @@ enum IndexStore {
|
||||
save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL)
|
||||
}
|
||||
|
||||
// writes the records and event id atomically to a specific URL
|
||||
// writes the records and event id atomically to a specific URL.
|
||||
// Wrapped in autoreleasepool because every call autoreleases a number
|
||||
// of Foundation objects (the compressed NSData, NSURLs created by
|
||||
// FileManager.replaceItem, etc.) - if the caller is a long-running
|
||||
// block whose own pool never drains, those would accumulate forever.
|
||||
static func save(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
|
||||
autoreleasepool {
|
||||
save_impl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private static func save_impl(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
|
||||
var vPayload = Data()
|
||||
vPayload.reserveCapacity(16 + inRecords.count * 80)
|
||||
writeU64(into: &vPayload, value: inLastEventId)
|
||||
@@ -151,8 +161,16 @@ enum IndexStore {
|
||||
return load(from: cacheURL)
|
||||
}
|
||||
|
||||
// reads the persisted index back from a specific URL
|
||||
// reads the persisted index back from a specific URL. Wrapped in
|
||||
// autoreleasepool so the decompressed NSData and the per-record
|
||||
// String allocations don't linger in the caller's pool.
|
||||
static func load(from inUrl: URL) -> LoadResult? {
|
||||
return autoreleasepool {
|
||||
load_impl(from: inUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private static func load_impl(from inUrl: URL) -> LoadResult? {
|
||||
guard let vData = try? Data(contentsOf: inUrl) else { return nil }
|
||||
var vOffset = 0
|
||||
guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil }
|
||||
@@ -171,10 +189,17 @@ enum IndexStore {
|
||||
var vP = 0
|
||||
guard let vLastEventId = readU64(from: vPayload, offset: &vP) else { return nil }
|
||||
guard let vCount = readU64(from: vPayload, offset: &vP) else { return nil }
|
||||
// each record takes at least 41 bytes - rejects a corrupt count
|
||||
// before it turns into a huge reserveCapacity
|
||||
guard vCount <= UInt64(vPayload.count / 41) else { return nil }
|
||||
var vRecords: [FileRecord] = []
|
||||
vRecords.reserveCapacity(Int(vCount))
|
||||
// siblings share one parentPath buffer instead of one copy each
|
||||
let vInterner = PathInterner()
|
||||
for _ in 0..<Int(vCount) {
|
||||
guard let vId = readU64(from: vPayload, offset: &vP),
|
||||
// the stored id is ignored: ids are recomputed from the path so
|
||||
// caches written with the old per-launch random hash still load
|
||||
guard let _ = readU64(from: vPayload, offset: &vP),
|
||||
let vSize = readI64(from: vPayload, offset: &vP),
|
||||
let vCreated = readF64(from: vPayload, offset: &vP),
|
||||
let vModified = readF64(from: vPayload, offset: &vP),
|
||||
@@ -185,9 +210,8 @@ enum IndexStore {
|
||||
return nil
|
||||
}
|
||||
vRecords.append(FileRecord(
|
||||
id: vId,
|
||||
name: vName,
|
||||
parentPath: vParent,
|
||||
parentPath: vInterner.intern(vParent),
|
||||
size: vSize,
|
||||
dateCreated: Date(timeIntervalSince1970: vCreated),
|
||||
dateModified: Date(timeIntervalSince1970: vModified),
|
||||
@@ -262,8 +286,10 @@ enum IndexStore {
|
||||
guard offset + vLength <= inData.count else { return nil }
|
||||
let vStart = inData.startIndex + offset
|
||||
let vEnd = vStart + vLength
|
||||
let vSlice = inData[vStart..<vEnd]
|
||||
offset += vLength
|
||||
return String(data: vSlice, encoding: .utf8)
|
||||
// native decode (no Foundation bridging); invalid bytes are repaired
|
||||
return inData.withUnsafeBytes { vPtr in
|
||||
String(decoding: vPtr[(vStart - inData.startIndex)..<(vEnd - inData.startIndex)], as: UTF8.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
// ResultSorter turns the matching positions of a search into the first N
|
||||
// records in the requested order. Only N rows are ever displayed, so instead
|
||||
// of sorting every match (seconds for an unfiltered million-entry index) it
|
||||
// keeps a bounded max-heap of the best N candidates: O(m log N), and in
|
||||
// practice about one comparison per match once the heap is warm.
|
||||
enum ResultSorter {
|
||||
|
||||
// returns the first inLimit records of inPositions, ordered by inDescriptor
|
||||
static func top(inRecords: [FileRecord],
|
||||
inPositions: [Int32],
|
||||
inLimit: Int,
|
||||
inDescriptor: FileSortDescriptor) -> [FileRecord] {
|
||||
let vLess = comparator(for: inDescriptor)
|
||||
// strict weak order on positions, ties broken by id for stability
|
||||
let vBefore: (Int32, Int32) -> Bool = { vA, vB in
|
||||
let vRa = inRecords[Int(vA)]
|
||||
let vRb = inRecords[Int(vB)]
|
||||
if vLess(vRa, vRb) { return true }
|
||||
if vLess(vRb, vRa) { return false }
|
||||
return vRa.id < vRb.id
|
||||
}
|
||||
|
||||
if inPositions.count <= inLimit {
|
||||
return inPositions.sorted(by: vBefore).map { inRecords[Int($0)] }
|
||||
}
|
||||
|
||||
// max-heap (worst candidate on top) of the best inLimit seen so far
|
||||
var vHeap = Array(inPositions.prefix(inLimit))
|
||||
// restores the heap property downward from inStart
|
||||
func siftDown(_ inStart: Int) {
|
||||
var vI = inStart
|
||||
while true {
|
||||
let vLeft = 2 * vI + 1
|
||||
if vLeft >= vHeap.count { return }
|
||||
var vWorst = vLeft
|
||||
let vRight = vLeft + 1
|
||||
if vRight < vHeap.count && vBefore(vHeap[vLeft], vHeap[vRight]) { vWorst = vRight }
|
||||
if !vBefore(vHeap[vI], vHeap[vWorst]) { return }
|
||||
vHeap.swapAt(vI, vWorst)
|
||||
vI = vWorst
|
||||
}
|
||||
}
|
||||
for vI in stride(from: vHeap.count / 2 - 1, through: 0, by: -1) {
|
||||
siftDown(vI)
|
||||
}
|
||||
for vPos in inPositions[inLimit...] where vBefore(vPos, vHeap[0]) {
|
||||
vHeap[0] = vPos
|
||||
siftDown(0)
|
||||
}
|
||||
return vHeap.sorted(by: vBefore).map { inRecords[Int($0)] }
|
||||
}
|
||||
|
||||
// primary ordering for each sort mode. Names compare on the folded form
|
||||
// (plain code-point order: fast, case-insensitive), path sorts by folder
|
||||
// then name.
|
||||
private static func comparator(for inDescriptor: FileSortDescriptor) -> (FileRecord, FileRecord) -> Bool {
|
||||
switch inDescriptor {
|
||||
case .nameAscending:
|
||||
return { $0.nameLower < $1.nameLower }
|
||||
case .nameDescending:
|
||||
return { $0.nameLower > $1.nameLower }
|
||||
case .sizeAscending:
|
||||
return { $0.size < $1.size }
|
||||
case .sizeDescending:
|
||||
return { $0.size > $1.size }
|
||||
case .createdAscending:
|
||||
return { $0.dateCreated < $1.dateCreated }
|
||||
case .createdDescending:
|
||||
return { $0.dateCreated > $1.dateCreated }
|
||||
case .modifiedAscending:
|
||||
return { $0.dateModified < $1.dateModified }
|
||||
case .modifiedDescending:
|
||||
return { $0.dateModified > $1.dateModified }
|
||||
case .pathAscending:
|
||||
return { $0.parentPath != $1.parentPath ? $0.parentPath < $1.parentPath : $0.nameLower < $1.nameLower }
|
||||
case .pathDescending:
|
||||
return { $0.parentPath != $1.parentPath ? $0.parentPath > $1.parentPath : $0.nameLower > $1.nameLower }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import Foundation
|
||||
|
||||
// SearchEngine matches a user query against FileRecords using voidtools
|
||||
// Everything's query syntax:
|
||||
//
|
||||
// foo bar AND: both "foo" and "bar" appear in the name
|
||||
// *.png | *.jpg OR: binds tighter than AND (Everything precedence)
|
||||
// !tmp NOT: names that don't contain "tmp"
|
||||
// "my file" quotes keep spaces inside a single term
|
||||
// Start*.pdf wildcards: * any run, ? one character; a wildcard
|
||||
// term must match the whole name (anchored)
|
||||
// src/main a term containing "/" is matched against the full path
|
||||
// some/**/path with wildcards, a path term matches whole folder
|
||||
// names anywhere in the path: * stays inside one
|
||||
// name, ** spans any number of folders (zero too).
|
||||
// "photos/**/IMG_????.heic" or "src/**/build/" (a
|
||||
// trailing / means "anything inside that folder")
|
||||
// ext:pdf;docx extension list
|
||||
// file: folder: restrict to files / folders (alone or as a prefix,
|
||||
// e.g. folder:build)
|
||||
//
|
||||
// Matching is case- and normalization-insensitive and runs on raw UTF-8
|
||||
// bytes (memmem / a byte-level glob) against the record's pre-lowercased
|
||||
// name, so a full pass over a million records takes milliseconds.
|
||||
struct SearchEngine: Sendable {
|
||||
|
||||
// how a single term's text is matched
|
||||
private enum Pattern: Sendable {
|
||||
// case-folded UTF-8 needle searched anywhere in the target
|
||||
case substring([UInt8])
|
||||
// case-folded UTF-8 glob that must match the whole target
|
||||
case glob([UInt8])
|
||||
// case-folded path glob, aligned on folder boundaries
|
||||
case pathGlob([UInt8])
|
||||
// allowed extensions (case-folded, without the dot)
|
||||
case extensions([[UInt8]])
|
||||
}
|
||||
|
||||
// one term of the query, e.g. `!folder:*.app`
|
||||
private struct Term: Sendable {
|
||||
// text pattern, nil for a bare file: / folder: modifier
|
||||
let pattern: Pattern?
|
||||
// true to match the full path instead of the name
|
||||
let matchesPath: Bool
|
||||
// required entry kind: true = folders only, false = files only
|
||||
let requiresDirectory: Bool?
|
||||
// true when the term was prefixed with "!"
|
||||
let negated: Bool
|
||||
}
|
||||
|
||||
// AND of OR-groups: every group must have at least one matching term
|
||||
private let groups: [[Term]]
|
||||
|
||||
// builds an engine for the provided query string
|
||||
init(inQuery: String) {
|
||||
var vGroups: [[Term]] = []
|
||||
var vJoinNext = false
|
||||
for vToken in SearchEngine.tokenize(inQuery: inQuery) {
|
||||
if vToken == "|" {
|
||||
vJoinNext = !vGroups.isEmpty
|
||||
continue
|
||||
}
|
||||
guard let vTerm = SearchEngine.parseTerm(inToken: vToken) else { continue }
|
||||
if vJoinNext {
|
||||
vGroups[vGroups.count - 1].append(vTerm)
|
||||
} else {
|
||||
vGroups.append([vTerm])
|
||||
}
|
||||
vJoinNext = false
|
||||
}
|
||||
// cheap name-only groups first: path terms build a lowercased
|
||||
// full path per record, so they only run on records that survived
|
||||
self.groups = vGroups.sorted { vA, vB in
|
||||
!vA.contains(where: { $0.matchesPath }) && vB.contains(where: { $0.matchesPath })
|
||||
}
|
||||
}
|
||||
|
||||
// true when this engine has any pattern that will filter results
|
||||
var isActive: Bool { !groups.isEmpty }
|
||||
|
||||
// returns true if the record satisfies every AND-group of the query
|
||||
func match(inRecord: FileRecord) -> Bool {
|
||||
for vGroup in groups {
|
||||
var vAny = false
|
||||
for vTerm in vGroup where SearchEngine.matches(inTerm: vTerm, inRecord: inRecord) {
|
||||
vAny = true
|
||||
break
|
||||
}
|
||||
if !vAny { return false }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Parsing
|
||||
// ===========================
|
||||
|
||||
// splits the query on whitespace (outside quotes) and isolates "|"
|
||||
// separators. Quotes are removed; their content stays one token.
|
||||
private static func tokenize(inQuery: String) -> [String] {
|
||||
var vTokens: [String] = []
|
||||
var vCurrent = ""
|
||||
var vInQuotes = false
|
||||
// flushes the token being built, if any
|
||||
func flush() {
|
||||
if !vCurrent.isEmpty { vTokens.append(vCurrent) }
|
||||
vCurrent = ""
|
||||
}
|
||||
for vChar in inQuery {
|
||||
if vChar == "\"" {
|
||||
vInQuotes.toggle()
|
||||
} else if vInQuotes {
|
||||
vCurrent.append(vChar)
|
||||
} else if vChar.isWhitespace {
|
||||
flush()
|
||||
} else if vChar == "|" {
|
||||
flush()
|
||||
vTokens.append("|")
|
||||
} else {
|
||||
vCurrent.append(vChar)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return vTokens
|
||||
}
|
||||
|
||||
// parses one token into a term; nil when the token carries nothing
|
||||
private static func parseTerm(inToken: String) -> Term? {
|
||||
var vText = Substring(inToken)
|
||||
var vNegated = false
|
||||
while vText.hasPrefix("!") {
|
||||
vNegated.toggle()
|
||||
vText = vText.dropFirst()
|
||||
}
|
||||
|
||||
var vRequiresDir: Bool?
|
||||
let vLower = vText.lowercased()
|
||||
if vLower.hasPrefix("folder:") {
|
||||
vRequiresDir = true
|
||||
vText = vText.dropFirst("folder:".count)
|
||||
} else if vLower.hasPrefix("file:") {
|
||||
vRequiresDir = false
|
||||
vText = vText.dropFirst("file:".count)
|
||||
}
|
||||
|
||||
if vText.lowercased().hasPrefix("ext:") {
|
||||
let vExts = vText.dropFirst("ext:".count)
|
||||
.split(whereSeparator: { $0 == ";" || $0 == "," })
|
||||
.map { vExt -> [UInt8] in
|
||||
let vClean = vExt.hasPrefix(".") ? vExt.dropFirst() : vExt
|
||||
return Array(FileRecord.searchForm(of: String(vClean)).utf8)
|
||||
}
|
||||
.filter { !$0.isEmpty }
|
||||
if vExts.isEmpty && vRequiresDir == nil { return nil }
|
||||
return Term(
|
||||
pattern: vExts.isEmpty ? nil : .extensions(vExts),
|
||||
matchesPath: false,
|
||||
requiresDirectory: vRequiresDir,
|
||||
negated: vNegated
|
||||
)
|
||||
}
|
||||
|
||||
if vText.isEmpty {
|
||||
// a bare modifier ("file:", "!folder:") still filters
|
||||
guard vRequiresDir != nil else { return nil }
|
||||
return Term(pattern: nil, matchesPath: false, requiresDirectory: vRequiresDir, negated: vNegated)
|
||||
}
|
||||
|
||||
let vNeedle = Array(FileRecord.searchForm(of: String(vText)).utf8)
|
||||
let vHasWildcards = vText.contains("*") || vText.contains("?")
|
||||
let vIsPath = vText.contains("/")
|
||||
let vPattern: Pattern
|
||||
if vHasWildcards {
|
||||
vPattern = vIsPath ? .pathGlob(vNeedle) : .glob(vNeedle)
|
||||
} else {
|
||||
vPattern = .substring(vNeedle)
|
||||
}
|
||||
return Term(
|
||||
pattern: vPattern,
|
||||
matchesPath: vIsPath,
|
||||
requiresDirectory: vRequiresDir,
|
||||
negated: vNegated
|
||||
)
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Matching
|
||||
// ===========================
|
||||
|
||||
// evaluates one term (including its negation) against a record
|
||||
private static func matches(inTerm: Term, inRecord: FileRecord) -> Bool {
|
||||
var vResult = true
|
||||
if let vDir = inTerm.requiresDirectory, vDir != inRecord.isDirectory {
|
||||
vResult = false
|
||||
} else if let vPattern = inTerm.pattern {
|
||||
if inTerm.matchesPath {
|
||||
let vPath = FileRecord.searchForm(of: inRecord.fullPath)
|
||||
vResult = matches(inPattern: vPattern, inTarget: vPath)
|
||||
} else {
|
||||
vResult = matches(inPattern: vPattern, inTarget: inRecord.nameLower)
|
||||
}
|
||||
}
|
||||
return inTerm.negated ? !vResult : vResult
|
||||
}
|
||||
|
||||
// runs a pattern against an already case-folded target string
|
||||
private static func matches(inPattern: Pattern, inTarget: String) -> Bool {
|
||||
var vTarget = inTarget
|
||||
return vTarget.withUTF8 { vBytes in
|
||||
switch inPattern {
|
||||
case .substring(let vNeedle):
|
||||
return containsBytes(inHaystack: vBytes, inNeedle: vNeedle)
|
||||
case .glob(let vGlob):
|
||||
return vGlob.withUnsafeBufferPointer { globMatch(inPattern: $0, inText: vBytes) }
|
||||
case .pathGlob(let vGlob):
|
||||
return vGlob.withUnsafeBufferPointer { pathGlobMatch(inPattern: $0, inText: vBytes) }
|
||||
case .extensions(let vExts):
|
||||
return hasExtension(inName: vBytes, inExtensions: vExts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// substring search over raw bytes
|
||||
private static func containsBytes(inHaystack: UnsafeBufferPointer<UInt8>, inNeedle: [UInt8]) -> Bool {
|
||||
if inNeedle.isEmpty { return true }
|
||||
if inNeedle.count > inHaystack.count { return false }
|
||||
return inNeedle.withUnsafeBufferPointer { vNeedle in
|
||||
memmem(inHaystack.baseAddress, inHaystack.count, vNeedle.baseAddress, vNeedle.count) != nil
|
||||
}
|
||||
}
|
||||
|
||||
// true when the name's extension (after the last dot) is in the list
|
||||
private static func hasExtension(inName: UnsafeBufferPointer<UInt8>, inExtensions: [[UInt8]]) -> Bool {
|
||||
guard let vDot = inName.lastIndex(of: UInt8(ascii: ".")), vDot > 0 else { return false }
|
||||
let vExt = UnsafeBufferPointer(rebasing: inName[(vDot + 1)...])
|
||||
for vCandidate in inExtensions where vCandidate.count == vExt.count {
|
||||
if vCandidate.elementsEqual(vExt) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// index of the next UTF-8 scalar start after inIndex
|
||||
private static func nextScalar(in inText: UnsafeBufferPointer<UInt8>, after inIndex: Int) -> Int {
|
||||
var vI = inIndex + 1
|
||||
while vI < inText.count && (inText[vI] & 0xC0) == 0x80 { vI += 1 }
|
||||
return vI
|
||||
}
|
||||
|
||||
// anchored wildcard match: * = any run, ? = exactly one character.
|
||||
// Iterative with single-star backtracking (linear in practice).
|
||||
private static func globMatch(inPattern: UnsafeBufferPointer<UInt8>, inText: UnsafeBufferPointer<UInt8>) -> Bool {
|
||||
let kStar = UInt8(ascii: "*")
|
||||
let kQuestion = UInt8(ascii: "?")
|
||||
var vP = 0
|
||||
var vT = 0
|
||||
var vStarP = -1
|
||||
var vStarT = 0
|
||||
while vT < inText.count {
|
||||
if vP < inPattern.count && inPattern[vP] == kQuestion {
|
||||
vP += 1
|
||||
vT = nextScalar(in: inText, after: vT)
|
||||
} else if vP < inPattern.count && inPattern[vP] == kStar {
|
||||
vStarP = vP
|
||||
vStarT = vT
|
||||
vP += 1
|
||||
} else if vP < inPattern.count && inPattern[vP] == inText[vT] {
|
||||
vP += 1
|
||||
vT += 1
|
||||
} else if vStarP >= 0 {
|
||||
vStarT = nextScalar(in: inText, after: vStarT)
|
||||
vT = vStarT
|
||||
vP = vStarP + 1
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
while vP < inPattern.count && inPattern[vP] == kStar { vP += 1 }
|
||||
return vP == inPattern.count
|
||||
}
|
||||
|
||||
// path glob: the pattern may start at any folder boundary of the path
|
||||
// (or only at the root when it starts with "/") and must end on one
|
||||
// (end of path, or a "/" in the path). A pattern ending with "/" matches
|
||||
// everything inside that folder.
|
||||
private static func pathGlobMatch(inPattern: UnsafeBufferPointer<UInt8>, inText: UnsafeBufferPointer<UInt8>) -> Bool {
|
||||
let kSlash = UInt8(ascii: "/")
|
||||
if inPattern.first == kSlash {
|
||||
return pathGlobFrom(inPattern: inPattern, inP: 0, inText: inText, inT: 0)
|
||||
}
|
||||
for vStart in 0..<inText.count where vStart == 0 || inText[vStart - 1] == kSlash {
|
||||
if pathGlobFrom(inPattern: inPattern, inP: 0, inText: inText, inT: vStart) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recursive matcher behind pathGlobMatch, starting at pattern index
|
||||
// inP and text index inT
|
||||
private static func pathGlobFrom(inPattern: UnsafeBufferPointer<UInt8>,
|
||||
inP: Int,
|
||||
inText: UnsafeBufferPointer<UInt8>,
|
||||
inT: Int) -> Bool {
|
||||
let kSlash = UInt8(ascii: "/")
|
||||
let kStar = UInt8(ascii: "*")
|
||||
let kQuestion = UInt8(ascii: "?")
|
||||
var vP = inP
|
||||
var vT = inT
|
||||
while vP < inPattern.count {
|
||||
let vChar = inPattern[vP]
|
||||
if vChar == kStar {
|
||||
if vP + 1 < inPattern.count && inPattern[vP + 1] == kStar {
|
||||
// "**": any run of characters, folder separators included
|
||||
let vNext = vP + 2
|
||||
// "**/" also matches zero folders ("a/**/b" matches "a/b")
|
||||
if vNext < inPattern.count && inPattern[vNext] == kSlash,
|
||||
pathGlobFrom(inPattern: inPattern, inP: vNext + 1, inText: inText, inT: vT) {
|
||||
return true
|
||||
}
|
||||
var vTry = vT
|
||||
while true {
|
||||
if pathGlobFrom(inPattern: inPattern, inP: vNext, inText: inText, inT: vTry) { return true }
|
||||
if vTry >= inText.count { return false }
|
||||
vTry += 1
|
||||
}
|
||||
}
|
||||
// "*": any run of characters inside one folder name
|
||||
var vTry = vT
|
||||
while true {
|
||||
if pathGlobFrom(inPattern: inPattern, inP: vP + 1, inText: inText, inT: vTry) { return true }
|
||||
if vTry >= inText.count || inText[vTry] == kSlash { return false }
|
||||
vTry = nextScalar(in: inText, after: vTry)
|
||||
}
|
||||
} else if vChar == kQuestion {
|
||||
if vT >= inText.count || inText[vT] == kSlash { return false }
|
||||
vT = nextScalar(in: inText, after: vT)
|
||||
vP += 1
|
||||
} else {
|
||||
if vT >= inText.count || inText[vT] != vChar { return false }
|
||||
vT += 1
|
||||
vP += 1
|
||||
}
|
||||
}
|
||||
// must stop on a folder boundary, unless the pattern ended with "/"
|
||||
return vT == inText.count || inText[vT] == kSlash || (vP > 0 && inPattern[vP - 1] == kSlash)
|
||||
}
|
||||
}
|
||||
@@ -48,16 +48,23 @@ enum VolumeManager {
|
||||
}
|
||||
|
||||
// returns the effective root URLs combining configured paths and mounted
|
||||
// volumes that match the include-mounted / include-network preferences
|
||||
// volumes that match the include-mounted / include-network preferences.
|
||||
// Paths are standardized and nested roots dropped (a root inside another
|
||||
// root would otherwise be walked, watched and indexed twice).
|
||||
static func effectiveRoots(inPreferences: Preferences) -> [URL] {
|
||||
var vRoots: [URL] = inPreferences.rootPaths.map { URL(fileURLWithPath: $0) }
|
||||
var vPaths: [String] = inPreferences.rootPaths.map { URL(fileURLWithPath: $0).standardizedFileURL.path }
|
||||
if inPreferences.includeMountedVolumes || inPreferences.includeNetworkVolumes {
|
||||
for vVol in mountedVolumes() {
|
||||
if vVol.isNetwork && !inPreferences.includeNetworkVolumes { continue }
|
||||
if !vVol.isNetwork && !inPreferences.includeMountedVolumes { continue }
|
||||
vRoots.append(vVol.url)
|
||||
vPaths.append(vVol.url.standardizedFileURL.path)
|
||||
}
|
||||
}
|
||||
return vRoots
|
||||
let vMinimal = Set(SubtreeMatcher.minimalRoots(inPaths: vPaths))
|
||||
// keep the user's order, minus duplicates and nested entries
|
||||
var vSeen = Set<String>()
|
||||
return vPaths
|
||||
.filter { vMinimal.contains($0) && vSeen.insert($0).inserted }
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import Foundation
|
||||
import CoreServices
|
||||
import Darwin
|
||||
|
||||
// AllofitService is the headless runtime invoked by launchd when the binary
|
||||
// is launched with the --service argument. It resumes from its cache (or
|
||||
// builds an initial index), then listens to FSEvents and writes the cache to
|
||||
// disk every few seconds.
|
||||
//
|
||||
// Holds a POSIX advisory lock on indexer.lock so a stray second instance
|
||||
// (a leftover launchd job, or someone running --service manually) exits
|
||||
// quietly instead of fighting for the cache file.
|
||||
enum AllofitService {
|
||||
|
||||
// Shared mutable state across the FSEvents callback queue and the
|
||||
// autosave loop. Wrapped in a class so closures capture by reference
|
||||
// and Swift 6's @Sendable closures can hold it cleanly. @unchecked
|
||||
// Sendable because every access goes through the NSLock below.
|
||||
private final class State: @unchecked Sendable {
|
||||
// the in-memory index (same rules as the GUI's built-in indexer)
|
||||
var index = IndexState()
|
||||
// true when the index changed since the last save
|
||||
var dirty: Bool = false
|
||||
// FSEvents id the index is complete up to. 0 while an initial walk
|
||||
// is in progress, so a partial cache saved mid-scan is never taken
|
||||
// for a complete one on the next start.
|
||||
var lastEventId: UInt64 = 0
|
||||
// guards every field above
|
||||
let lock = NSLock()
|
||||
}
|
||||
|
||||
// runs the indexer/watcher loop forever (never returns)
|
||||
static func run() -> Never {
|
||||
// ensure files we create are world-readable (root daemon writes the
|
||||
// cache; the GUI runs as the user and must be able to read it)
|
||||
umask(0o022)
|
||||
NSLog("[Allofit] service starting (uid=\(getuid()))")
|
||||
|
||||
// figure out whether we are the root daemon writing to /Library or
|
||||
// the user agent writing to ~/Library, then acquire the lock
|
||||
let vIsSystem = ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1"
|
||||
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: vIsSystem).path)
|
||||
if !vLock.tryLock() {
|
||||
let vHolder = IndexerLock.readHolderPid(path: vLock.path).map(String.init) ?? "unknown"
|
||||
NSLog("[Allofit] another indexer is running (pid \(vHolder)), exiting")
|
||||
exit(0)
|
||||
}
|
||||
|
||||
let vPrefs = Preferences.shared
|
||||
let vRoots = VolumeManager.effectiveRoots(inPreferences: vPrefs).map { $0.path }
|
||||
let vRootSet = Set(vRoots)
|
||||
let vMatcher = ExclusionMatcher(inExclusions: vPrefs.excludedPaths)
|
||||
// log the actual configuration so the user can verify owner-prefs sync
|
||||
// (root daemon reads from /Users/<owner>/Library/Preferences/...)
|
||||
NSLog("[Allofit] roots: %@", vRoots.joined(separator: ", "))
|
||||
NSLog("[Allofit] excluded paths (%d): %@",
|
||||
vPrefs.excludedPaths.count,
|
||||
vPrefs.excludedPaths.joined(separator: ", "))
|
||||
|
||||
let vState = State()
|
||||
|
||||
// fast resume: load the cache, drop what no longer matches the
|
||||
// configuration and replay FSEvents from the saved id. Only roots
|
||||
// missing from the cache are walked. Without a usable cache (none,
|
||||
// incomplete, or an id from another FSEvents database) every root
|
||||
// is walked from scratch.
|
||||
let vCurrentId = UInt64(FSEventsGetCurrentEventId())
|
||||
var vSinceId = vCurrentId
|
||||
var vToScan = vRoots
|
||||
if let vCache = IndexStore.load(), vCache.lastEventId > 0, vCache.lastEventId <= vCurrentId {
|
||||
var vIndex = IndexState(inRecords: vCache.records)
|
||||
let vPruned = vIndex.prune(inRoots: vRootSet, inExclusions: vMatcher, inIsHidden: FileIndexer.isHidden)
|
||||
vToScan = vRoots.filter { !vIndex.contains(inPath: $0) }
|
||||
vSinceId = vCache.lastEventId
|
||||
vState.index = vIndex
|
||||
vState.dirty = vPruned > 0
|
||||
NSLog("[Allofit] resumed %d records from cache (%d pruned), replaying from event %llu",
|
||||
vIndex.count, vPruned, vSinceId)
|
||||
}
|
||||
|
||||
// periodic save loop, started before any walk so that a long initial
|
||||
// scan publishes partial progress for the GUI
|
||||
startAutosaveLoop(inState: vState)
|
||||
|
||||
// walk whatever the cache doesn't cover. The batch callback runs in
|
||||
// its own autoreleasepool so the autoreleased NSURL / NSDate /
|
||||
// NSNumber from the enumeration don't pile up until the whole scan
|
||||
// finishes - on a million-file scan that peaked well over a gig.
|
||||
if !vToScan.isEmpty {
|
||||
for vRoot in vToScan {
|
||||
autoreleasepool {
|
||||
NSLog("[Allofit] scanning %@", vRoot)
|
||||
FileIndexer.walkRoot(inRoot: URL(fileURLWithPath: vRoot), inExclusions: vMatcher) { vBatch in
|
||||
autoreleasepool {
|
||||
vState.lock.lock()
|
||||
for vRecord in vBatch {
|
||||
vState.index.upsert(vRecord)
|
||||
}
|
||||
vState.dirty = true
|
||||
vState.lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
vState.lock.lock()
|
||||
let vCount = vState.index.count
|
||||
vState.lock.unlock()
|
||||
NSLog("[Allofit] scan complete (%d entries)", vCount)
|
||||
}
|
||||
// the index is now complete up to vSinceId: replaying from there
|
||||
// fills in whatever happened during the walk
|
||||
vState.lock.lock()
|
||||
vState.lastEventId = vSinceId
|
||||
vState.dirty = true
|
||||
vState.lock.unlock()
|
||||
|
||||
// FSEvents watcher. The callbacks of one stream run serially on
|
||||
// its dispatch queue, so resolve / apply / rescan never overlap.
|
||||
let vWatcher = FileWatcher()
|
||||
NSLog("[Allofit] starting FSEvents watcher on %d root(s)", vRoots.count)
|
||||
vWatcher.start(
|
||||
inRoots: vRoots,
|
||||
inSinceWhen: FSEventStreamEventId(vSinceId)
|
||||
) { vChanges in
|
||||
autoreleasepool {
|
||||
// stat() outside the lock so the autosave isn't blocked
|
||||
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: vMatcher)
|
||||
vState.lock.lock()
|
||||
let vResult = vState.index.apply(inChanges: vResolved, inRoots: vRootSet)
|
||||
vState.lastEventId = max(vState.lastEventId, vResolved.maxEventId)
|
||||
if vResult.changed { vState.dirty = true }
|
||||
vState.lock.unlock()
|
||||
if vResolved.historyDone {
|
||||
NSLog("[Allofit] FSEvents history replay done")
|
||||
vState.lock.lock()
|
||||
vState.dirty = true
|
||||
vState.lock.unlock()
|
||||
}
|
||||
|
||||
// folders moved in, or history lost: walk them again
|
||||
if !vResult.rescans.isEmpty {
|
||||
NSLog("[Allofit] rescanning %d subtree(s)", vResult.rescans.count)
|
||||
var vFresh: [FileRecord] = []
|
||||
for vPath in vResult.rescans {
|
||||
vFresh.append(contentsOf: FileIndexer.indexRoot(
|
||||
inRoot: URL(fileURLWithPath: vPath),
|
||||
inExclusions: vMatcher
|
||||
))
|
||||
}
|
||||
vState.lock.lock()
|
||||
vState.index.replaceSubtrees(inRoots: vResult.rescans, inRecords: vFresh)
|
||||
vState.dirty = true
|
||||
vState.lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// block forever on the runloop so launchd keeps us alive
|
||||
RunLoop.current.run()
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// periodic save loop (background thread). 3-second check interval so
|
||||
// new files appear in the GUI within a few seconds of being created.
|
||||
// Every N saves we also compact the in-memory containers so Swift's
|
||||
// Array/Dict capacity (which only grows on churn, never auto-shrinks)
|
||||
// doesn't drift into multi-GB territory after a day of heavy file
|
||||
// activity. RSS is logged each save so the trajectory is visible.
|
||||
//
|
||||
// CRITICAL: each iteration runs inside its own autoreleasepool. The
|
||||
// outer GCD block has an autorelease pool that drains when the block
|
||||
// returns - which for our `while true` never happens. Without an
|
||||
// inner pool, every save's autoreleased NSData (returned by
|
||||
// .compressed(using: .lz4) and friends) accumulates forever and the
|
||||
// daemon's RSS grows by tens of MB per save (500MB+/min in practice).
|
||||
private static func startAutosaveLoop(inState: State) {
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let kCompactEvery = 20
|
||||
var vSavesSinceCompact = 0
|
||||
while true {
|
||||
sleep(3)
|
||||
autoreleasepool {
|
||||
inState.lock.lock()
|
||||
let vShouldSave = inState.dirty
|
||||
let vSnapshot = inState.index.records
|
||||
let vEventId = inState.lastEventId
|
||||
inState.dirty = false
|
||||
inState.lock.unlock()
|
||||
if !vShouldSave { return }
|
||||
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
|
||||
vSnapshot.count, processFootprintMB())
|
||||
IndexStore.save(inRecords: vSnapshot, inLastEventId: vEventId)
|
||||
vSavesSinceCompact += 1
|
||||
if vSavesSinceCompact >= kCompactEvery {
|
||||
vSavesSinceCompact = 0
|
||||
compact(inState: inState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replaces the index with a copy whose capacity matches its content,
|
||||
// releasing the slack accumulated by days of churn
|
||||
private static func compact(inState: State) {
|
||||
let vBefore = processFootprintMB()
|
||||
inState.lock.lock()
|
||||
inState.index = inState.index.compacted()
|
||||
let vCount = inState.index.count
|
||||
inState.lock.unlock()
|
||||
NSLog("[Allofit] compacted (%d records, RSS %.1f → %.1f MB)",
|
||||
vCount, vBefore, processFootprintMB())
|
||||
}
|
||||
|
||||
// resident-memory size in MB matching Activity Monitor's "Memory" column
|
||||
// on modern macOS (Catalina+). phys_footprint is the kernel's accounting
|
||||
// of pages owned by the task minus shared/clean pages.
|
||||
private static func processFootprintMB() -> Double {
|
||||
var vInfo = task_vm_info_data_t()
|
||||
var vCount = mach_msg_type_number_t(
|
||||
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<integer_t>.size
|
||||
)
|
||||
let vResult = withUnsafeMutablePointer(to: &vInfo) { vPtr in
|
||||
vPtr.withMemoryRebound(to: integer_t.self, capacity: Int(vCount)) { vIntPtr in
|
||||
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), vIntPtr, &vCount)
|
||||
}
|
||||
}
|
||||
if vResult == KERN_SUCCESS {
|
||||
return Double(vInfo.phys_footprint) / (1024.0 * 1024.0)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import Foundation
|
||||
|
||||
// CLI dispatches sub-commands invoked from Main.swift before the GUI app
|
||||
// boots. Lets the user install / uninstall / start / stop the background
|
||||
// service and flip the GUI's service-mode preference without opening the
|
||||
// Settings window.
|
||||
//
|
||||
// Usage shape:
|
||||
// Allofit launches the GUI (default)
|
||||
// Allofit --service runs as the headless indexer (existing)
|
||||
// Allofit install <user|root> installs the LaunchAgent or LaunchDaemon
|
||||
// Allofit uninstall [user|root]
|
||||
// Allofit start [user|root]
|
||||
// Allofit stop [user|root]
|
||||
// Allofit mode <none|user|root> sets GUI Preferences.serviceMode
|
||||
// Allofit status prints install + run state
|
||||
// Allofit --help
|
||||
enum CLI {
|
||||
|
||||
// returns true when a CLI command was recognised and handled, in which
|
||||
// case Main.swift returns without booting the GUI. Returns false to
|
||||
// fall through to the SwiftUI app.
|
||||
static func handle(arguments inArgs: [String]) -> Bool {
|
||||
guard let vCmd = inArgs.first else { return false }
|
||||
let vRest = Array(inArgs.dropFirst())
|
||||
switch vCmd {
|
||||
case "-h", "--help":
|
||||
printHelp()
|
||||
return true
|
||||
case "install":
|
||||
runInstall(inArgs: vRest)
|
||||
return true
|
||||
case "uninstall":
|
||||
runUninstall(inArgs: vRest)
|
||||
return true
|
||||
case "start":
|
||||
runStart(inArgs: vRest)
|
||||
return true
|
||||
case "stop":
|
||||
runStop(inArgs: vRest)
|
||||
return true
|
||||
case "mode":
|
||||
runSetMode(inArgs: vRest)
|
||||
return true
|
||||
case "status":
|
||||
runStatus()
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Command implementations
|
||||
// ===========================
|
||||
|
||||
// install <user|root> - copy the running binary to the daemon path and
|
||||
// register the launchd plist. Root scope prompts once for an admin
|
||||
// password via NSAppleScript inside ServiceInstaller.
|
||||
private static func runInstall(inArgs: [String]) {
|
||||
guard let vScope = parseScope(inArgs.first) else {
|
||||
fail("Usage: allofit install <user|root>")
|
||||
}
|
||||
print("==> Installing \(label(for: vScope))…")
|
||||
do {
|
||||
try ServiceInstaller.install(inScope: vScope)
|
||||
print("Installed.")
|
||||
} catch {
|
||||
fail("Install failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// uninstall [user|root] - explicit scope, or every installed scope when
|
||||
// none is given (the typical "tear it all down" intent).
|
||||
private static func runUninstall(inArgs: [String]) {
|
||||
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .everyInstalled)
|
||||
guard !vScopes.isEmpty else {
|
||||
print("Nothing to uninstall (no service installed).")
|
||||
return
|
||||
}
|
||||
for vScope in vScopes {
|
||||
print("==> Uninstalling \(label(for: vScope))…")
|
||||
do {
|
||||
try ServiceInstaller.uninstall(inScope: vScope)
|
||||
print("Uninstalled \(label(for: vScope)).")
|
||||
} catch {
|
||||
print("Uninstall failed for \(label(for: vScope)): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func runStart(inArgs: [String]) {
|
||||
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled)
|
||||
guard !vScopes.isEmpty else {
|
||||
fail("No service installed. Use: allofit install <user|root>")
|
||||
}
|
||||
for vScope in vScopes {
|
||||
print("==> Starting \(label(for: vScope))…")
|
||||
do {
|
||||
try ServiceInstaller.start(inScope: vScope)
|
||||
print("Started.")
|
||||
} catch {
|
||||
fail("Start failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func runStop(inArgs: [String]) {
|
||||
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled)
|
||||
guard !vScopes.isEmpty else {
|
||||
fail("No service installed.")
|
||||
}
|
||||
for vScope in vScopes {
|
||||
print("==> Stopping \(label(for: vScope))…")
|
||||
do {
|
||||
try ServiceInstaller.stop(inScope: vScope)
|
||||
print("Stopped.")
|
||||
} catch {
|
||||
fail("Stop failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mode <none|user|root> - writes Preferences.serviceMode so the next GUI
|
||||
// launch boots in the chosen role. Doesn't install or start anything
|
||||
// itself; pair with `install` + `start` for a one-shot setup.
|
||||
private static func runSetMode(inArgs: [String]) {
|
||||
guard let vArg = inArgs.first else {
|
||||
fail("Usage: allofit mode <none|user|root>")
|
||||
}
|
||||
let vMode: Preferences.ServiceMode
|
||||
switch vArg {
|
||||
case "none", "off":
|
||||
vMode = .none
|
||||
case "user", "useragent":
|
||||
vMode = .userAgent
|
||||
case "root", "rootdaemon", "system", "admin":
|
||||
vMode = .rootDaemon
|
||||
default:
|
||||
fail("Unknown mode: \(vArg). Use one of: none, user, root")
|
||||
}
|
||||
// Write into both the bundle id and the SwiftPM/process domain so
|
||||
// the setting takes regardless of how the GUI is launched (drag
|
||||
// from /Applications uses the bundle id; `swift run Allofit` uses
|
||||
// the executable name).
|
||||
writeMode(vMode, toDomain: kBundleId)
|
||||
writeMode(vMode, toDomain: kProcessName)
|
||||
print("Service mode set to \(vMode.rawValue).")
|
||||
}
|
||||
|
||||
private static func writeMode(_ inMode: Preferences.ServiceMode, toDomain inDomain: String) {
|
||||
guard let vDefaults = UserDefaults(suiteName: inDomain) else { return }
|
||||
vDefaults.set(inMode.rawValue, forKey: kServiceModeKey)
|
||||
vDefaults.synchronize()
|
||||
}
|
||||
|
||||
private static func runStatus() {
|
||||
print("Bundle version: \(ServiceInstaller.bundleVersion())")
|
||||
for vScope in [ServiceInstaller.Scope.userAgent, .rootDaemon] {
|
||||
let vInstalled = ServiceInstaller.isInstalled(inScope: vScope)
|
||||
let vRunning = vInstalled && ServiceInstaller.isRunning(inScope: vScope)
|
||||
let vVersion = ServiceInstaller.installedVersion(inScope: vScope) ?? "-"
|
||||
let vState: String
|
||||
if vRunning {
|
||||
vState = "running"
|
||||
} else if vInstalled {
|
||||
vState = "installed (stopped)"
|
||||
} else {
|
||||
vState = "not installed"
|
||||
}
|
||||
print("\(label(for: vScope).padding(toLength: 14, withPad: " ", startingAt: 0)) \(vState) v\(vVersion)")
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Helpers
|
||||
// ===========================
|
||||
|
||||
private static let kBundleId = "com.bitsycore.allofit"
|
||||
private static let kProcessName = "Allofit"
|
||||
private static let kServiceModeKey = "Allofit.serviceMode"
|
||||
|
||||
private enum ScopeResolution {
|
||||
case firstInstalled
|
||||
case everyInstalled
|
||||
}
|
||||
|
||||
// Returns the scopes a command should operate on:
|
||||
// - explicit `user`/`root` arg: just that one
|
||||
// - no arg, firstInstalled mode: the single installed scope, if any
|
||||
// - no arg, everyInstalled mode: all installed scopes
|
||||
private static func scopesToActOn(inArgs: [String], defaultMode: ScopeResolution) -> [ServiceInstaller.Scope] {
|
||||
if let vScope = parseScope(inArgs.first) {
|
||||
return [vScope]
|
||||
}
|
||||
let vAll: [ServiceInstaller.Scope] = [.userAgent, .rootDaemon]
|
||||
let vInstalled = vAll.filter { ServiceInstaller.isInstalled(inScope: $0) }
|
||||
switch defaultMode {
|
||||
case .firstInstalled:
|
||||
return vInstalled.first.map { [$0] } ?? []
|
||||
case .everyInstalled:
|
||||
return vInstalled
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseScope(_ inArg: String?) -> ServiceInstaller.Scope? {
|
||||
guard let vArg = inArg else { return nil }
|
||||
switch vArg {
|
||||
case "user", "useragent":
|
||||
return .userAgent
|
||||
case "root", "rootdaemon", "daemon", "system", "admin":
|
||||
return .rootDaemon
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func label(for inScope: ServiceInstaller.Scope) -> String {
|
||||
switch inScope {
|
||||
case .userAgent: return "user agent"
|
||||
case .rootDaemon: return "root daemon"
|
||||
}
|
||||
}
|
||||
|
||||
// prints a message to stderr and exits with status 1
|
||||
private static func fail(_ inMessage: String) -> Never {
|
||||
FileHandle.standardError.write(Data((inMessage + "\n").utf8))
|
||||
exit(1)
|
||||
}
|
||||
|
||||
private static func printHelp() {
|
||||
print("""
|
||||
Usage: allofit [command [args]]
|
||||
|
||||
Service control:
|
||||
install <user|root> Copy binary, register launchd plist, start the service.
|
||||
'root' prompts once for an admin password.
|
||||
uninstall [user|root] Stop and remove. Without an arg, uninstalls every
|
||||
installed scope.
|
||||
start [user|root] Start an installed service. Picks the only installed
|
||||
one when no arg is given.
|
||||
stop [user|root] Stop a running service.
|
||||
mode <none|user|root> Set the GUI's service-mode preference. Does NOT
|
||||
install anything itself.
|
||||
status Show install + run state for both scopes.
|
||||
|
||||
Other:
|
||||
--service Run as the headless indexer (used by launchd).
|
||||
--help, -h This message.
|
||||
|
||||
With no arguments, launches the GUI.
|
||||
""")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ enum ServiceInstaller {
|
||||
case rootDaemon
|
||||
}
|
||||
|
||||
// errors surfaced to the UI
|
||||
// errors surfaced to the ui
|
||||
enum InstallError: Error, LocalizedError {
|
||||
case binaryNotFound
|
||||
case authorizationFailed(String)
|
||||
@@ -33,7 +33,7 @@ enum ServiceInstaller {
|
||||
}
|
||||
|
||||
// installs (or replaces) the launchd plist for the given scope. Also
|
||||
// copies the binary to a renamed location ("Allofit Service") so the
|
||||
// copies the binary to a renamed location ("Allofit service") so the
|
||||
// daemon process shows up distinctly from the GUI in Activity Monitor /
|
||||
// `ps` - both used to be called "Allofit" because they share a binary.
|
||||
static func install(inScope: Scope) throws {
|
||||
@@ -236,7 +236,7 @@ enum ServiceInstaller {
|
||||
}
|
||||
|
||||
// Filesystem path where the daemon's binary is installed. We use a
|
||||
// renamed copy ("Allofit Service") so the daemon process is named
|
||||
// renamed copy ("Allofit service") so the daemon process is named
|
||||
// differently from the GUI in Activity Monitor / ps - both used to
|
||||
// be just "Allofit" because they shared the same on-disk binary.
|
||||
// The filename embedded in argv[0] is what those tools display.
|
||||
@@ -315,7 +315,7 @@ enum ServiceInstaller {
|
||||
|
||||
// runs a shell script with administrator privileges. Bridges
|
||||
// AdminShell.Error into ServiceInstaller.InstallError so the calling
|
||||
// SettingsView UI gets a single error type to surface.
|
||||
// SettingsView ui gets a single error type to surface.
|
||||
private static func runWithAdminPrivileges(inScript: String) throws {
|
||||
do {
|
||||
_ = try AdminShell.run(inScript)
|
||||
@@ -8,8 +8,11 @@ struct AllofitApp: App {
|
||||
// AppKit delegate that wrestles focus from whichever app was frontmost,
|
||||
// needed because SwiftPM-launched binaries default to .accessory policy
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
// shared application state injected into the view tree
|
||||
@StateObject private var model = AppModel()
|
||||
// shared application state injected into the view tree. Held with
|
||||
// @State (not @StateObject) so the App does not subscribe to it: the
|
||||
// model's counters change on every index update, and observing them
|
||||
// here would re-evaluate every scene each time.
|
||||
@State private var model = AppModel()
|
||||
// session-scoped store of sudo-staged user-readable copies. Sits
|
||||
// alongside AppModel so both the Table (lock badge on rows) and
|
||||
// the PreviewPane observe the same authorization state.
|
||||
@@ -17,12 +20,7 @@ struct AllofitApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Allofit") {
|
||||
ContentView()
|
||||
.environmentObject(model)
|
||||
.environmentObject(Preferences.shared)
|
||||
.environmentObject(access)
|
||||
.frame(minWidth: 760, minHeight: 480)
|
||||
.background(MainWindowMarker())
|
||||
AllofitWindowContent(model: model, access: access)
|
||||
}
|
||||
.windowToolbarStyle(.unified)
|
||||
.defaultSize(width: 1100, height: 640)
|
||||
@@ -37,7 +35,11 @@ struct AllofitApp: App {
|
||||
}
|
||||
.keyboardShortcut("r", modifiers: [.command])
|
||||
}
|
||||
CommandGroup(replacing: .newItem) { }
|
||||
// SwiftUI provides File > New Window (⌘N) automatically for a
|
||||
// WindowGroup; nothing to add here. Additional windows share the
|
||||
// AppModel/AccessManager StateObjects defined above, so the index
|
||||
// (and current search/sort) stays in sync across them - only the
|
||||
// per-window selection / column-customization differ.
|
||||
// ⌘F focuses the search field. Standard Find-style shortcut.
|
||||
// SearchField's Coordinator observes the notification and calls
|
||||
// makeFirstResponder on its underlying NSSearchField.
|
||||
@@ -57,6 +59,45 @@ struct AllofitApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
// AllofitWindowContent is the per-window root: it creates a fresh
|
||||
// WindowSearchModel for each window so the query / visible slice are
|
||||
// independent, while the shared AppModel + AccessManager + Preferences
|
||||
// are injected from the App level.
|
||||
//
|
||||
// The model/access StateObjects must be passed in via init so the
|
||||
// @StateObject autoclosure for WindowSearchModel can capture the shared
|
||||
// AppModel instance - @EnvironmentObject isn't available at init time.
|
||||
struct AllofitWindowContent: View {
|
||||
|
||||
let model: AppModel
|
||||
let access: AccessManager
|
||||
@StateObject private var searchModel: WindowSearchModel
|
||||
|
||||
init(model inModel: AppModel, access inAccess: AccessManager) {
|
||||
self.model = inModel
|
||||
self.access = inAccess
|
||||
// @autoclosure: SwiftUI evaluates this exactly once when the view
|
||||
// first appears, so re-renders won't keep allocating new search models
|
||||
_searchModel = StateObject(wrappedValue: WindowSearchModel(model: inModel))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ContentView()
|
||||
.environmentObject(model)
|
||||
.environmentObject(Preferences.shared)
|
||||
.environmentObject(access)
|
||||
.environmentObject(searchModel)
|
||||
.frame(minWidth: 760, minHeight: 480)
|
||||
.background(MainWindowMarker())
|
||||
.onAppear {
|
||||
model.start()
|
||||
}
|
||||
.onDisappear {
|
||||
model.saveCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MainWindowMarker captures the main WindowGroup's NSWindow into
|
||||
// AppDelegate.mainWindow so applicationShouldHandleReopen can re-show that
|
||||
// specific window without triggering AppKit's default behavior of unhiding
|
||||
@@ -133,6 +174,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// bundle - covers the SwiftPM "swift run" case
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
// shrink the AppKit help-tag (.help() / NSView.toolTip) hover delay.
|
||||
// The system default is ~2 s; that makes truncated Name/Path cells
|
||||
// feel unreadable. Registered as a default so a user-set value in
|
||||
// the global domain still wins. Seconds.
|
||||
UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 0.3])
|
||||
// wipe any elevated-access staging files left over from a previous
|
||||
// run so a crash or hard-kill doesn't accumulate privileged copies
|
||||
// in ~/Library/Caches across sessions
|
||||
@@ -160,6 +206,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
// dock-icon right-click contextual menu: surface a "New Window" entry so
|
||||
// the user can spawn an additional window without bringing the app to the
|
||||
// front first. The action defers to whatever the File > New Window menu
|
||||
// item does (SwiftUI auto-generates that item for WindowGroup) so we stay
|
||||
// compatible with whichever underlying selector SwiftUI uses.
|
||||
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
|
||||
let vMenu = NSMenu()
|
||||
let vItem = NSMenuItem(title: "New Window",
|
||||
action: #selector(newWindowFromDock(_:)),
|
||||
keyEquivalent: "")
|
||||
vItem.target = self
|
||||
vMenu.addItem(vItem)
|
||||
return vMenu
|
||||
}
|
||||
|
||||
// finds the ⌘N main-menu item (File > New Window) and re-invokes its
|
||||
// action. We match on the keyboard shortcut rather than the title so the
|
||||
// lookup survives localized menus.
|
||||
@objc func newWindowFromDock(_ sender: Any?) {
|
||||
guard let vMain = NSApp.mainMenu else { return }
|
||||
for vTop in vMain.items {
|
||||
guard let vSub = vTop.submenu else { continue }
|
||||
for vItem in vSub.items {
|
||||
if vItem.keyEquivalent == "n",
|
||||
vItem.keyEquivalentModifierMask == [.command],
|
||||
let vAction = vItem.action {
|
||||
NSApp.sendAction(vAction, to: vItem.target, from: nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dock-icon click while no windows are visible: re-show only the main
|
||||
// window and return false so AppKit doesn't run its default "unhide every
|
||||
// hidden window" action (which would also resurrect the Settings window).
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import CoreServices
|
||||
|
||||
// Background-thread-safe last-seen-mtime cell for the cache-file poll.
|
||||
@@ -23,24 +24,71 @@ private final class BackgroundMtime: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// AppModel is the central observable state for the application.
|
||||
// Thread-safe accumulator for FSEvents batches. The FSEvents callback appends
|
||||
// here from its own queue and only schedules a flush when none is pending, so
|
||||
// a replay that delivers thousands of batches in a burst becomes a handful of
|
||||
// coalesced flushes instead of thousands of main-queue hops.
|
||||
private final class PendingChanges: @unchecked Sendable {
|
||||
// changes received since the last drain
|
||||
private var changes: [FSChange] = []
|
||||
// true while a flush is scheduled but has not drained yet
|
||||
private var flushScheduled = false
|
||||
// guards changes and flushScheduled
|
||||
private let lock = NSLock()
|
||||
|
||||
// appends a batch; returns true if the caller must schedule a flush
|
||||
func append(inChanges: [FSChange]) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
changes.append(contentsOf: inChanges)
|
||||
if flushScheduled { return false }
|
||||
flushScheduled = true
|
||||
return true
|
||||
}
|
||||
|
||||
// takes every pending change and re-arms flush scheduling
|
||||
func drain() -> [FSChange] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
let vResult = changes
|
||||
changes = []
|
||||
flushScheduled = false
|
||||
return vResult
|
||||
}
|
||||
|
||||
// discards pending changes (used when the watcher is torn down)
|
||||
func reset() {
|
||||
_ = drain()
|
||||
}
|
||||
}
|
||||
|
||||
// AppModel is the shared backing state for the application.
|
||||
// It owns the in-memory file index, drives the background indexer and the
|
||||
// FSEvents watcher, debounces the search query and exposes a filtered/sorted
|
||||
// slice of the index to SwiftUI.
|
||||
// FSEvents watcher, and exposes the active sort descriptor. The per-window
|
||||
// search query and filtered/visible slice live in WindowSearchModel so two
|
||||
// windows can run independent searches against this same shared index.
|
||||
//
|
||||
// Threading rule of thumb: this class is @MainActor so all Published properties
|
||||
// are written from main. Heavy work (LZ4 (de)compression, filtering, sorting,
|
||||
// path-lookup rebuilds, filesystem walks) happens on background queues; the
|
||||
// result is then assigned back on main with DispatchQueue.main.async or
|
||||
// MainActor.run. Snapshots of value types (Array, String) cross thread
|
||||
// boundaries via Swift COW semantics, so no defensive copies are needed.
|
||||
// Threading rule of thumb: this class is @MainActor so all state is written
|
||||
// from main. Heavy work (LZ4 (de)compression, filtering, sorting, pruning,
|
||||
// filesystem walks and stats) happens on background queues; the result is
|
||||
// then merged back on main. Snapshots of value types (Array, IndexState)
|
||||
// cross thread boundaries via Swift COW semantics.
|
||||
//
|
||||
// The record array is deliberately NOT @Published: every mutation of a
|
||||
// @Published array emits the whole array, and any subscriber that buffers
|
||||
// those values (receive(on:)) forces a full copy of the index per change -
|
||||
// that is what used to blow up to tens of GB during an FSEvents replay.
|
||||
// Views observe the small @Published counters; search models listen to the
|
||||
// payload-free recordsChanged signal and read allRecords when they refilter.
|
||||
@MainActor
|
||||
final class AppModel: ObservableObject {
|
||||
|
||||
// the canonical full index of every entry observed so far
|
||||
@Published private(set) var allRecords: [FileRecord] = []
|
||||
// the filtered, sorted and capped records currently shown in the table
|
||||
@Published private(set) var visibleRecords: [FileRecord] = []
|
||||
// the canonical index (records + id lookup), main-thread only
|
||||
private var index = IndexState()
|
||||
// every entry observed so far, in no particular order
|
||||
var allRecords: [FileRecord] { index.records }
|
||||
// fires on main after allRecords changed (no payload by design)
|
||||
let recordsChanged = PassthroughSubject<Void, Never>()
|
||||
// total number of entries indexed (used for the status bar)
|
||||
@Published private(set) var indexedCount: Int = 0
|
||||
// true while a full reindex is in progress
|
||||
@@ -49,53 +97,41 @@ final class AppModel: ObservableObject {
|
||||
@Published private(set) var isLoadingCache: Bool = false
|
||||
// true when this process owns the index (got the lock or built-in mode)
|
||||
@Published private(set) var isIndexer: Bool = false
|
||||
// the user-entered search query, debounced before filtering
|
||||
@Published var query: String = "" {
|
||||
didSet {
|
||||
scheduleSearch()
|
||||
prefs.lastQuery = query
|
||||
}
|
||||
}
|
||||
// the active sort descriptor chosen by the user via column headers
|
||||
@Published var sortDescriptor: FileSortDescriptor = .nameAscending {
|
||||
didSet {
|
||||
applyFilterAndSort()
|
||||
prefs.lastSort = sortDescriptor
|
||||
}
|
||||
}
|
||||
|
||||
// maximum number of rows handed to SwiftUI Table for snappy scrolling
|
||||
private let kMaxVisibleRows = 5000
|
||||
// debounce delay between keystroke and filter rebuild
|
||||
private let kSearchDebounceSeconds = 0.05
|
||||
// how often the index is written to disk while running (seconds)
|
||||
private let kAutosaveSeconds: TimeInterval = 30
|
||||
// delay used to coalesce FSEvents batches before resolving them
|
||||
private let kChangeCoalesceSeconds: TimeInterval = 0.5
|
||||
// delay before a roots / exclusions edit is applied to the live index
|
||||
private let kConfigDebounceSeconds: TimeInterval = 1.0
|
||||
|
||||
private let prefs = Preferences.shared
|
||||
// background queues isolated by concern - keeps the slow stuff off main
|
||||
private let indexQueue = DispatchQueue(label: "allofit.index", qos: .utility)
|
||||
private let searchQueue = DispatchQueue(label: "allofit.search", qos: .userInitiated)
|
||||
private let ioQueue = DispatchQueue(label: "allofit.io", qos: .utility)
|
||||
// pending debounced search work, cancelled on each keystroke
|
||||
private var searchWorkItem: DispatchWorkItem?
|
||||
// pending filter+sort task, cancelled if a newer one supersedes it
|
||||
private var filterTask: Task<Void, Never>?
|
||||
// watcher used in indexer mode for live updates
|
||||
private let watcher = FileWatcher()
|
||||
// watcher used in reader mode to detect cache file refreshes
|
||||
private let cacheWatcher = FileWatcher()
|
||||
// path -> array index, kept in sync with allRecords (main-thread only)
|
||||
private var pathIndex: [String: Int] = [:]
|
||||
// FSEvents batches waiting to be resolved and applied (indexer mode)
|
||||
private let pendingChanges = PendingChanges()
|
||||
// roots the watcher was started with; their own records need no parent
|
||||
private var watchedRoots: Set<String> = []
|
||||
// bumped whenever the whole index is replaced, so a subtree rescan that
|
||||
// started against the previous index doesn't merge into the new one
|
||||
private var indexGeneration = 0
|
||||
// autosave timer when running in indexer mode
|
||||
private var autosaveTimer: Timer?
|
||||
// true when allRecords has changed since the last save
|
||||
// true when the index has changed since the last save
|
||||
private var dirty = false
|
||||
// last FSEvents event id known to be reflected in allRecords
|
||||
// last FSEvents event id known to be reflected in the index
|
||||
private var lastEventId: UInt64 = 0
|
||||
// process-wide indexer mutex (nil until acquired)
|
||||
private var indexerLock: IndexerLock?
|
||||
// guards start() from running twice across window close/reopen
|
||||
private var hasStarted = false
|
||||
// subscriptions to Preferences / app lifecycle
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
// true while a privileged service operation is in flight (Reindex / Clear)
|
||||
@Published private(set) var isWorking: Bool = false
|
||||
// last user-facing status string for the Settings buttons
|
||||
@@ -106,8 +142,6 @@ final class AppModel: ObservableObject {
|
||||
// runloop) so the periodic stat() doesn't compete with NSTableView click
|
||||
// handling - main-runloop timers were the source of dropped clicks.
|
||||
private var cachePollSource: DispatchSourceTimer?
|
||||
// last mtime we observed on the cache file - skips needless reloads
|
||||
private var lastSeenCacheMtime: Date?
|
||||
// minimum gap between two reader-mode reloads. Prevents the Table from
|
||||
// being re-rendered while the user is mid-click. 1 second is well below
|
||||
// any human-perceptible "stale" threshold but caps the worst-case churn
|
||||
@@ -117,10 +151,30 @@ final class AppModel: ObservableObject {
|
||||
private var lastReloadAt: Date?
|
||||
|
||||
init() {
|
||||
// only the cheap UI state is restored synchronously - the cache file
|
||||
// is loaded off-main in start() so the window appears instantly
|
||||
query = prefs.lastQuery
|
||||
sortDescriptor = prefs.lastSort
|
||||
// the cache file is loaded off-main in start() so the window
|
||||
// appears instantly; here we only wire up observers.
|
||||
// Persist on Cmd+Q: the autosave only runs every 30 s and an async
|
||||
// save would be killed with the process.
|
||||
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
|
||||
.sink { [weak self] _ in
|
||||
MainActor.assumeIsolated {
|
||||
self?.saveCacheNow()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
// apply roots / exclusions / volume edits to the live index
|
||||
Publishers.Merge4(
|
||||
prefs.$rootPaths.map { _ in () },
|
||||
prefs.$excludedPaths.map { _ in () },
|
||||
prefs.$includeMountedVolumes.map { _ in () },
|
||||
prefs.$includeNetworkVolumes.map { _ in () }
|
||||
)
|
||||
.dropFirst(4)
|
||||
.debounce(for: .seconds(kConfigDebounceSeconds), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] in
|
||||
self?.applyConfigurationChange()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// kicks off background activity for the first time. Subsequent calls are
|
||||
@@ -146,14 +200,13 @@ final class AppModel: ObservableObject {
|
||||
private func tearDownActiveMode() {
|
||||
watcher.stop()
|
||||
cacheWatcher.stop()
|
||||
pendingChanges.reset()
|
||||
autosaveTimer?.invalidate()
|
||||
autosaveTimer = nil
|
||||
cachePollSource?.cancel()
|
||||
cachePollSource = nil
|
||||
indexerLock?.unlock()
|
||||
indexerLock = nil
|
||||
filterTask?.cancel()
|
||||
filterTask = nil
|
||||
}
|
||||
|
||||
// determines indexer vs reader role, loads the cache from the appropriate
|
||||
@@ -176,39 +229,33 @@ final class AppModel: ObservableObject {
|
||||
|
||||
isLoadingCache = true
|
||||
let vCacheURL = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
|
||||
let vIsIndexer = isIndexer
|
||||
let vRoots = Set(VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path })
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
NSLog("[Allofit GUI] bootstrap: serviceMode=%@ isIndexer=%@ cacheURL=%@",
|
||||
prefs.serviceMode.rawValue,
|
||||
isIndexer ? "true" : "false",
|
||||
vIsIndexer ? "true" : "false",
|
||||
vCacheURL.path)
|
||||
ioQueue.async { [weak self] in
|
||||
// decompress + parse off-main (this is the expensive part)
|
||||
// decompress + parse + reconcile off-main (the expensive part)
|
||||
let vCache = IndexStore.load(from: vCacheURL)
|
||||
// build the path lookup while we are already off-main
|
||||
let vLookup = AppModel.buildPathLookup(inRecords: vCache?.records ?? [])
|
||||
var vState = IndexState(inRecords: vCache?.records ?? [])
|
||||
// as the indexer, drop what no longer matches the configuration
|
||||
// (removed roots, new exclusions, orphans from older versions)
|
||||
let vPruned = vIsIndexer ? vState.prune(inRoots: vRoots, inExclusions: vMatcher, inIsHidden: FileIndexer.isHidden) : 0
|
||||
DispatchQueue.main.async {
|
||||
guard let vSelf = self else { return }
|
||||
if let vCache = vCache {
|
||||
NSLog("[Allofit GUI] loaded %d records from cache", vCache.records.count)
|
||||
vSelf.allRecords = vCache.records
|
||||
vSelf.pathIndex = vLookup
|
||||
vSelf.indexedCount = vCache.records.count
|
||||
NSLog("[Allofit GUI] loaded %d records from cache (%d pruned)",
|
||||
vState.count, vPruned)
|
||||
vSelf.lastEventId = vCache.lastEventId
|
||||
} else {
|
||||
NSLog("[Allofit GUI] cache load returned nil (file missing or invalid)")
|
||||
vSelf.allRecords = []
|
||||
vSelf.pathIndex = [:]
|
||||
vSelf.indexedCount = 0
|
||||
}
|
||||
// capture the initial mtime so the reader-mode poller doesn't
|
||||
// immediately re-trigger on its first tick
|
||||
if let vAttrs = try? FileManager.default.attributesOfItem(atPath: vCacheURL.path),
|
||||
let vMtime = vAttrs[.modificationDate] as? Date {
|
||||
vSelf.lastSeenCacheMtime = vMtime
|
||||
} else {
|
||||
vSelf.lastSeenCacheMtime = nil
|
||||
vSelf.lastEventId = 0
|
||||
}
|
||||
vSelf.replaceIndex(inState: vState)
|
||||
vSelf.dirty = vPruned > 0
|
||||
vSelf.isLoadingCache = false
|
||||
vSelf.applyFilterAndSort()
|
||||
if vSelf.isIndexer {
|
||||
vSelf.startIndexerMode()
|
||||
} else {
|
||||
@@ -239,23 +286,26 @@ final class AppModel: ObservableObject {
|
||||
let vStartId = UInt64(FSEventsGetCurrentEventId())
|
||||
indexQueue.async { [weak self] in
|
||||
var vAccumulated: [FileRecord] = []
|
||||
vAccumulated.reserveCapacity(200_000)
|
||||
for vRoot in vRoots {
|
||||
let vBaseline = vAccumulated.count
|
||||
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
|
||||
DispatchQueue.main.async {
|
||||
self?.indexedCount = vBaseline + vCount
|
||||
// per-root autoreleasepool so the file-enumeration's
|
||||
// autoreleased NSURL/NSDate/NSNumber objects don't pile
|
||||
// up across roots inside this long-running async block
|
||||
autoreleasepool {
|
||||
let vBaseline = vAccumulated.count
|
||||
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
|
||||
DispatchQueue.main.async {
|
||||
self?.indexedCount = vBaseline + vCount
|
||||
}
|
||||
}
|
||||
vAccumulated.append(contentsOf: vList)
|
||||
}
|
||||
vAccumulated.append(contentsOf: vList)
|
||||
}
|
||||
let vFinal = vAccumulated
|
||||
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
|
||||
let vState = IndexState(inRecords: vAccumulated)
|
||||
// persist off-main before bouncing back so main never sees the
|
||||
// LZ4 compression cost
|
||||
IndexStore.save(inRecords: vFinal, inLastEventId: vStartId)
|
||||
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
|
||||
DispatchQueue.main.async {
|
||||
self?.applyFreshIndex(inRecords: vFinal, inLookup: vLookup, inEventId: vStartId)
|
||||
self?.applyFreshIndex(inState: vState, inEventId: vStartId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,7 +315,7 @@ final class AppModel: ObservableObject {
|
||||
// the daemon, deletes its cache, and starts the daemon again so the
|
||||
// daemon's fresh process performs the scan from scratch. The privileged
|
||||
// portion runs on a detached task so the main thread (and the password
|
||||
// dialog from NSAppleScript) doesn't freeze the UI.
|
||||
// dialog from NSAppleScript) doesn't freeze the ui.
|
||||
func performReindex() async {
|
||||
if isIndexer {
|
||||
reindex()
|
||||
@@ -346,7 +396,7 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
// shared helper: runs a service-mode operation on a detached queue while
|
||||
// publishing isWorking/workMessage so the UI can show progress. Returns
|
||||
// publishing isWorking/workMessage so the ui can show progress. Returns
|
||||
// true if the action ran without throwing.
|
||||
@discardableResult
|
||||
private func runPrivilegedAction(inLabel: String,
|
||||
@@ -375,39 +425,93 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// writes the current index to disk (called on quit and by autosave)
|
||||
// writes the current index to disk (autosave, window close, replay end).
|
||||
// Snapshots on main (cheap COW), compresses off-main.
|
||||
func saveCache() {
|
||||
guard isIndexer else { return }
|
||||
// snapshot on main (cheap COW), then dispatch compression off-main
|
||||
let vRecords = allRecords
|
||||
let vEventId = max(lastEventId, watcher.latestEventId)
|
||||
let vRecords = index.records
|
||||
// lastEventId tracks what is actually merged into the index; the
|
||||
// stream's latest id can be ahead (changes still being resolved),
|
||||
// and saving that would make the next launch skip those changes
|
||||
let vEventId = lastEventId
|
||||
dirty = false
|
||||
ioQueue.async {
|
||||
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
|
||||
}
|
||||
}
|
||||
|
||||
// synchronous save used at quit; ioQueue.sync also waits for any
|
||||
// asynchronous save still in flight so the newest state wins
|
||||
private func saveCacheNow() {
|
||||
guard isIndexer, dirty else { return }
|
||||
let vRecords = index.records
|
||||
let vEventId = lastEventId
|
||||
dirty = false
|
||||
ioQueue.sync {
|
||||
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Indexer mode
|
||||
// ===========================
|
||||
|
||||
// starts live updates after the cache was loaded. Falls back to a full
|
||||
// reindex when there is no cache, and to background rescans when the
|
||||
// saved event id can't be replayed or a configured root is missing.
|
||||
private func startIndexerMode() {
|
||||
if allRecords.isEmpty {
|
||||
reindex()
|
||||
} else {
|
||||
startWatching(inSinceWhen: lastEventId)
|
||||
}
|
||||
startAutosaveTimer()
|
||||
if index.count == 0 {
|
||||
reindex()
|
||||
return
|
||||
}
|
||||
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }
|
||||
let vCurrentId = UInt64(FSEventsGetCurrentEventId())
|
||||
if lastEventId == 0 || lastEventId > vCurrentId {
|
||||
// the id is from another FSEvents database (volume erased,
|
||||
// cache copied from another Mac...): replay would silently
|
||||
// deliver nothing, so re-walk everything in the background
|
||||
// while the cached results stay searchable
|
||||
NSLog("[Allofit GUI] saved event id %llu not replayable (current %llu), rescanning roots",
|
||||
lastEventId, vCurrentId)
|
||||
lastEventId = vCurrentId
|
||||
startWatching(inSinceWhen: vCurrentId)
|
||||
kickRescanSubtrees(inPaths: vRoots)
|
||||
return
|
||||
}
|
||||
startWatching(inSinceWhen: lastEventId)
|
||||
// roots added since the cache was written have never been walked
|
||||
let vMissing = vRoots.filter { !index.contains(inPath: $0) }
|
||||
if !vMissing.isEmpty {
|
||||
kickRescanSubtrees(inPaths: vMissing)
|
||||
}
|
||||
}
|
||||
|
||||
// starts the FSEvents watcher from inSinceWhen. Batches are coalesced in
|
||||
// pendingChanges, resolved (stat'ed) on indexQueue, and only the final
|
||||
// in-memory merge runs on main - so replaying days of history after a
|
||||
// relaunch neither floods the main queue nor freezes the ui.
|
||||
private func startWatching(inSinceWhen: UInt64) {
|
||||
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs)
|
||||
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }
|
||||
watchedRoots = Set(vRoots)
|
||||
pendingChanges.reset()
|
||||
let vPending = pendingChanges
|
||||
let vQueue = indexQueue
|
||||
let vDelay = kChangeCoalesceSeconds
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
watcher.start(
|
||||
inRoots: vRoots.map { $0.path },
|
||||
inRoots: vRoots,
|
||||
inSinceWhen: FSEventStreamEventId(inSinceWhen)
|
||||
) { vChanges in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.applyFileSystemChanges(inChanges: vChanges)
|
||||
) { [weak self] vChanges in
|
||||
guard vPending.append(inChanges: vChanges) else { return }
|
||||
vQueue.asyncAfter(deadline: .now() + vDelay) { [weak self] in
|
||||
let vResolved = IndexState.resolve(
|
||||
inChanges: vPending.drain(),
|
||||
inExclusions: vMatcher
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self?.applyResolvedChanges(inResolved: vResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,98 +529,84 @@ final class AppModel: ObservableObject {
|
||||
autosaveTimer = vTimer
|
||||
}
|
||||
|
||||
// applies a batch of FSEvents-reported changes. Updates and removals are
|
||||
// batched so we rebuild the path lookup once per batch (the per-event
|
||||
// rebuild used previously was O(n × removals) on main and also left
|
||||
// pathIndex with stale indices between iterations). MustScanSubDirs is
|
||||
// handled off-main so a kernel history loss doesn't freeze the UI.
|
||||
private func applyFileSystemChanges(inChanges: [FSChange]) {
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
var vChanged = false
|
||||
var vRescan: [String] = []
|
||||
var vRemoved: Set<String> = []
|
||||
|
||||
for vChange in inChanges {
|
||||
if vMatcher.isExcluded(inPath: vChange.path) { continue }
|
||||
if vChange.mustScanSubDirs {
|
||||
vRescan.append(vChange.path)
|
||||
continue
|
||||
}
|
||||
// skip paths we've already queued for removal in this batch
|
||||
if vRemoved.contains(vChange.path) { continue }
|
||||
let vURL = URL(fileURLWithPath: vChange.path)
|
||||
let vExists = (try? vURL.checkResourceIsReachable()) ?? false
|
||||
if vExists, let vRecord = FileIndexer.makeRecord(inURL: vURL) {
|
||||
if let vIdx = pathIndex[vRecord.fullPath] {
|
||||
allRecords[vIdx] = vRecord
|
||||
} else {
|
||||
allRecords.append(vRecord)
|
||||
pathIndex[vRecord.fullPath] = allRecords.count - 1
|
||||
}
|
||||
vChanged = true
|
||||
} else if pathIndex[vChange.path] != nil {
|
||||
// defer the actual removal so we can bulk-remove all at once
|
||||
// at the end of the batch, then rebuild the lookup once
|
||||
vRemoved.insert(vChange.path)
|
||||
vChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
if !vRemoved.isEmpty {
|
||||
// one bulk pass over allRecords + one lookup rebuild per batch
|
||||
allRecords.removeAll { vRemoved.contains($0.fullPath) }
|
||||
rebuildPathLookup()
|
||||
}
|
||||
|
||||
if !vRescan.isEmpty {
|
||||
kickRescanSubtrees(inPaths: vRescan, inExclusions: vMatcher)
|
||||
}
|
||||
|
||||
if vChanged {
|
||||
indexedCount = allRecords.count
|
||||
lastEventId = watcher.latestEventId
|
||||
// merges a resolved set of FSEvents changes into the index (pure
|
||||
// in-memory work; the stats were done off-main by IndexState.resolve)
|
||||
private func applyResolvedChanges(inResolved: ResolvedChanges) {
|
||||
guard isIndexer else { return }
|
||||
let vResult = index.apply(inChanges: inResolved, inRoots: watchedRoots)
|
||||
lastEventId = max(lastEventId, inResolved.maxEventId)
|
||||
if vResult.changed {
|
||||
dirty = true
|
||||
scheduleSearch()
|
||||
publishRecords()
|
||||
}
|
||||
if !vResult.rescans.isEmpty {
|
||||
kickRescanSubtrees(inPaths: vResult.rescans)
|
||||
}
|
||||
// persist right after a relaunch replay finishes so the next launch
|
||||
// resumes from here instead of replaying the same history again
|
||||
if inResolved.historyDone {
|
||||
NSLog("[Allofit GUI] FSEvents history replay done, saving cache")
|
||||
saveCache()
|
||||
}
|
||||
}
|
||||
|
||||
// rebuilds the entries below the given subtrees from the filesystem in
|
||||
// the background, then hands the new arrays back to main in one shot
|
||||
private func kickRescanSubtrees(inPaths: [String], inExclusions: ExclusionMatcher) {
|
||||
let vCurrent = allRecords
|
||||
let vPrefixes = inPaths.map { $0.hasSuffix("/") ? $0 : $0 + "/" }
|
||||
// re-walks the given subtrees in the background, then swaps their
|
||||
// content in on main. Used for kernel-requested rescans, folders that
|
||||
// were moved/renamed into the tree, and newly added roots.
|
||||
private func kickRescanSubtrees(inPaths: [String]) {
|
||||
let vRoots = SubtreeMatcher.minimalRoots(inPaths: inPaths)
|
||||
guard !vRoots.isEmpty else { return }
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
let vGeneration = indexGeneration
|
||||
indexQueue.async { [weak self] in
|
||||
var vKept = vCurrent.filter { vRec in
|
||||
let vP = vRec.fullPath
|
||||
for vPre in vPrefixes where vP == String(vPre.dropLast()) || vP.hasPrefix(vPre) {
|
||||
return false
|
||||
var vRecords: [FileRecord] = []
|
||||
for vPath in vRoots {
|
||||
// per-subtree autoreleasepool keeps the walk's autoreleased
|
||||
// URL/stat objects from accumulating across subtrees
|
||||
autoreleasepool {
|
||||
vRecords.append(contentsOf: FileIndexer.indexRoot(
|
||||
inRoot: URL(fileURLWithPath: vPath),
|
||||
inExclusions: vMatcher
|
||||
))
|
||||
}
|
||||
return true
|
||||
}
|
||||
for vPath in inPaths {
|
||||
let vList = FileIndexer.indexRoot(
|
||||
inRoot: URL(fileURLWithPath: vPath),
|
||||
inExclusions: inExclusions
|
||||
)
|
||||
vKept.append(contentsOf: vList)
|
||||
}
|
||||
let vFinal = vKept
|
||||
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
|
||||
let vFinal = vRecords
|
||||
DispatchQueue.main.async {
|
||||
self?.allRecords = vFinal
|
||||
self?.pathIndex = vLookup
|
||||
self?.indexedCount = vFinal.count
|
||||
self?.dirty = true
|
||||
self?.scheduleSearch()
|
||||
guard let vSelf = self, vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
|
||||
vSelf.index.replaceSubtrees(inRoots: vRoots, inRecords: vFinal)
|
||||
vSelf.dirty = true
|
||||
vSelf.publishRecords()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rebuilds the path -> array-index dictionary from allRecords. Called
|
||||
// synchronously on main after batched removals so the subsequent code
|
||||
// sees a consistent pathIndex. One-shot O(n) per FSEvents batch.
|
||||
private func rebuildPathLookup() {
|
||||
pathIndex = AppModel.buildPathLookup(inRecords: allRecords)
|
||||
// applies edited roots / exclusions / volume options to the live index
|
||||
// without a full reindex: prunes what fell out of scope, walks roots
|
||||
// that are new, and restarts the watcher on the new root set
|
||||
private func applyConfigurationChange() {
|
||||
guard isIndexer, !isIndexing, !isLoadingCache else { return }
|
||||
NSLog("[Allofit GUI] configuration changed, reconciling index")
|
||||
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }
|
||||
let vRootSet = Set(vRoots)
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
let vState = index
|
||||
let vGeneration = indexGeneration
|
||||
watcher.stop()
|
||||
indexQueue.async { [weak self] in
|
||||
var vPruned = vState
|
||||
let vRemoved = vPruned.prune(inRoots: vRootSet, inExclusions: vMatcher, inIsHidden: FileIndexer.isHidden)
|
||||
DispatchQueue.main.async {
|
||||
guard let vSelf = self, vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
|
||||
NSLog("[Allofit GUI] reconcile: %d records pruned", vRemoved)
|
||||
vSelf.replaceIndex(inState: vPruned)
|
||||
vSelf.dirty = true
|
||||
vSelf.startWatching(inSinceWhen: vSelf.lastEventId)
|
||||
let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) }
|
||||
if !vMissing.isEmpty {
|
||||
vSelf.kickRescanSubtrees(inPaths: vMissing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -533,7 +623,7 @@ final class AppModel: ObservableObject {
|
||||
// when the cache file itself changed - other files in the dir
|
||||
// (indexer.lock, .tmp atomic-rename leftovers, etc.) used to also
|
||||
// trigger main.async hops, which contributed to dropped clicks.
|
||||
cacheWatcher.start(inRoots: [vDir]) { vChanges in
|
||||
cacheWatcher.start(inRoots: [vDir]) { [weak self] vChanges in
|
||||
let vCacheChanged = vChanges.contains(where: { $0.path == vTarget })
|
||||
guard vCacheChanged else { return }
|
||||
NSLog("[Allofit GUI] cache file changed (FSEvents), reloading")
|
||||
@@ -565,30 +655,13 @@ final class AppModel: ObservableObject {
|
||||
cachePollSource = vSource
|
||||
}
|
||||
|
||||
// stat()'s the cache file and triggers a reload when mtime changes
|
||||
private func pollCacheForChanges() {
|
||||
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
|
||||
guard let vAttrs = try? FileManager.default.attributesOfItem(atPath: vUrl.path),
|
||||
let vMtime = vAttrs[.modificationDate] as? Date
|
||||
else { return }
|
||||
if lastSeenCacheMtime != vMtime {
|
||||
NSLog("[Allofit GUI] cache mtime changed (poll), reloading")
|
||||
lastSeenCacheMtime = vMtime
|
||||
reloadFromCache()
|
||||
}
|
||||
}
|
||||
|
||||
// reloads the on-disk cache off-main and swaps it in atomically.
|
||||
//
|
||||
// Two guards keep this from disrupting Table interactions:
|
||||
// 1. lastEventId comparison - the daemon advances its event id every
|
||||
// time it actually persists new state, so a matching id means the
|
||||
// records on disk are identical to what we have and we can bail
|
||||
// before touching any @Published. This is what was eating clicks:
|
||||
// the daemon resaving an unchanged cache still bumped its mtime,
|
||||
// the poller triggered a reload, and the resulting @Published
|
||||
// cascade re-rendered the Table at the exact moment the user
|
||||
// was clicking a row.
|
||||
// before touching any published state.
|
||||
// 2. A 1-second floor between reloads. Even when changes do happen,
|
||||
// we don't need to re-render the whole list at FSEvents' rate -
|
||||
// the next tick will catch any newer state.
|
||||
@@ -600,22 +673,20 @@ final class AppModel: ObservableObject {
|
||||
lastReloadAt = vNow
|
||||
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
|
||||
let vCurrentEventId = lastEventId
|
||||
let vCurrentCount = index.count
|
||||
ioQueue.async { [weak self] in
|
||||
let vCache = IndexStore.load(from: vUrl)
|
||||
guard let vCache = IndexStore.load(from: vUrl) else { return }
|
||||
// short-circuit when the on-disk content matches what we already
|
||||
// have - common when the daemon resaves on a noise-only FSEvents
|
||||
// burst (e.g. spotlight reindexing, temp files in /var)
|
||||
if let vC = vCache, vC.lastEventId == vCurrentEventId {
|
||||
return
|
||||
}
|
||||
let vLookup = AppModel.buildPathLookup(inRecords: vCache?.records ?? [])
|
||||
// burst (e.g. spotlight reindexing, temp files in /var). The
|
||||
// count check lets partial saves of a first scan through (the
|
||||
// daemon keeps the event id at 0 until the scan completes).
|
||||
if vCache.lastEventId == vCurrentEventId && vCache.records.count == vCurrentCount { return }
|
||||
let vState = IndexState(inRecords: vCache.records)
|
||||
DispatchQueue.main.async {
|
||||
guard let vCache = vCache else { return }
|
||||
self?.allRecords = vCache.records
|
||||
self?.pathIndex = vLookup
|
||||
self?.indexedCount = vCache.records.count
|
||||
self?.lastEventId = vCache.lastEventId
|
||||
self?.applyFilterAndSort()
|
||||
guard let vSelf = self, !vSelf.isIndexer else { return }
|
||||
vSelf.lastEventId = vCache.lastEventId
|
||||
vSelf.replaceIndex(inState: vState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -624,114 +695,29 @@ final class AppModel: ObservableObject {
|
||||
// MARK: Internals
|
||||
// ===========================
|
||||
|
||||
// installs a freshly-built index and refreshes the visible slice
|
||||
private func applyFreshIndex(inRecords: [FileRecord],
|
||||
inLookup: [String: Int],
|
||||
inEventId: UInt64) {
|
||||
allRecords = inRecords
|
||||
pathIndex = inLookup
|
||||
indexedCount = inRecords.count
|
||||
// installs a whole new index (cache load, reindex, reload, reconcile)
|
||||
private func replaceIndex(inState: IndexState) {
|
||||
index = inState
|
||||
indexGeneration &+= 1
|
||||
publishRecords()
|
||||
}
|
||||
|
||||
// notifies observers that the records changed
|
||||
private func publishRecords() {
|
||||
if indexedCount != index.count {
|
||||
indexedCount = index.count
|
||||
}
|
||||
recordsChanged.send()
|
||||
}
|
||||
|
||||
// installs a freshly-built index and starts watching for changes
|
||||
private func applyFreshIndex(inState: IndexState, inEventId: UInt64) {
|
||||
replaceIndex(inState: inState)
|
||||
lastEventId = inEventId
|
||||
isIndexing = false
|
||||
dirty = false
|
||||
applyFilterAndSort()
|
||||
if isIndexer {
|
||||
startWatching(inSinceWhen: inEventId)
|
||||
}
|
||||
}
|
||||
|
||||
// debounces filter rebuilds so we don't refilter on every keystroke
|
||||
private func scheduleSearch() {
|
||||
searchWorkItem?.cancel()
|
||||
let vItem = DispatchWorkItem { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.applyFilterAndSort()
|
||||
}
|
||||
}
|
||||
searchWorkItem = vItem
|
||||
searchQueue.asyncAfter(deadline: .now() + kSearchDebounceSeconds, execute: vItem)
|
||||
}
|
||||
|
||||
// runs the actual filter and sort on a detached task so the UI never
|
||||
// stalls on keystrokes. The previous task is cancelled to avoid races.
|
||||
private func applyFilterAndSort() {
|
||||
filterTask?.cancel()
|
||||
// snapshot inputs on main; the detached task is self-contained
|
||||
let vQuery = query
|
||||
let vSort = sortDescriptor
|
||||
let vRecords = allRecords
|
||||
let vMax = kMaxVisibleRows
|
||||
filterTask = Task.detached(priority: .userInitiated) { [weak self] in
|
||||
let vEngine = SearchEngine(inQuery: vQuery)
|
||||
var vFiltered: [FileRecord]
|
||||
if vEngine.isActive {
|
||||
vFiltered = vRecords.filter { vEngine.match(inRecord: $0) }
|
||||
} else {
|
||||
vFiltered = vRecords
|
||||
}
|
||||
if Task.isCancelled { return }
|
||||
AppModel.sortInPlace(inRecords: &vFiltered, inDescriptor: vSort)
|
||||
if Task.isCancelled { return }
|
||||
let vCapped: [FileRecord]
|
||||
if vFiltered.count > vMax {
|
||||
vCapped = Array(vFiltered.prefix(vMax))
|
||||
} else {
|
||||
vCapped = vFiltered
|
||||
}
|
||||
if Task.isCancelled { return }
|
||||
// hop back to main with DispatchQueue.main.async (rather than
|
||||
// await MainActor.run) so the assignment is guaranteed to land
|
||||
// on the next runloop tick, avoiding NSTableView reentrance when
|
||||
// the search field is mid-edit
|
||||
DispatchQueue.main.async {
|
||||
guard let vSelf = self else { return }
|
||||
// Skip the @Published fire when the resulting list is byte-
|
||||
// for-byte identical to what the Table is already showing.
|
||||
// Full FileRecord equality catches mtime / size updates, so
|
||||
// we only skip true no-op reassignments. Clicks landing on
|
||||
// the Table during a no-op reload no longer get dropped.
|
||||
if vSelf.visibleRecords == vCapped { return }
|
||||
vSelf.visibleRecords = vCapped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// builds a fresh path -> array-index dictionary for the given records.
|
||||
// nonisolated so any background queue can call it without an actor hop.
|
||||
private nonisolated static func buildPathLookup(inRecords: [FileRecord]) -> [String: Int] {
|
||||
var vMap: [String: Int] = [:]
|
||||
vMap.reserveCapacity(inRecords.count)
|
||||
for (vI, vRecord) in inRecords.enumerated() {
|
||||
vMap[vRecord.fullPath] = vI
|
||||
}
|
||||
return vMap
|
||||
}
|
||||
|
||||
// sorts the provided slice in place. nonisolated so the detached filter
|
||||
// task can call it without an actor hop.
|
||||
private nonisolated static func sortInPlace(inRecords: inout [FileRecord],
|
||||
inDescriptor: FileSortDescriptor) {
|
||||
switch inDescriptor {
|
||||
case .nameAscending:
|
||||
inRecords.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
case .nameDescending:
|
||||
inRecords.sort { $0.name.localizedStandardCompare($1.name) == .orderedDescending }
|
||||
case .sizeAscending:
|
||||
inRecords.sort { $0.size < $1.size }
|
||||
case .sizeDescending:
|
||||
inRecords.sort { $0.size > $1.size }
|
||||
case .createdAscending:
|
||||
inRecords.sort { $0.dateCreated < $1.dateCreated }
|
||||
case .createdDescending:
|
||||
inRecords.sort { $0.dateCreated > $1.dateCreated }
|
||||
case .modifiedAscending:
|
||||
inRecords.sort { $0.dateModified < $1.dateModified }
|
||||
case .modifiedDescending:
|
||||
inRecords.sort { $0.dateModified > $1.dateModified }
|
||||
case .pathAscending:
|
||||
inRecords.sort { $0.parentPath < $1.parentPath }
|
||||
case .pathDescending:
|
||||
inRecords.sort { $0.parentPath > $1.parentPath }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,14 @@ import AppKit
|
||||
// the right (toggleable via the toolbar), and a status bar at the bottom.
|
||||
struct ContentView: View {
|
||||
|
||||
@EnvironmentObject var model: AppModel
|
||||
// AppModel is intentionally not observed here: its counters change on
|
||||
// every index update and would re-evaluate this body (and the Table)
|
||||
// each time. StatusBarView observes it instead.
|
||||
@EnvironmentObject var prefs: Preferences
|
||||
@EnvironmentObject var access: AccessManager
|
||||
// per-window search model: owns this window's query + filtered slice so
|
||||
// two windows can run independent searches against the shared AppModel
|
||||
@EnvironmentObject var searchModel: WindowSearchModel
|
||||
@State private var selection: Set<FileRecord.ID> = []
|
||||
// drives the Table's drag-to-reorder and column-visibility customization.
|
||||
// Initial value is hydrated from UserDefaults so the user's column order
|
||||
@@ -26,12 +31,13 @@ struct ContentView: View {
|
||||
private nonisolated static let kColumnCustomizationKey = "Allofit.columnCustomization"
|
||||
private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000
|
||||
|
||||
// Computed binding for the Table's sortOrder: reads/writes
|
||||
// model.sortDescriptor directly so the sort state survives any number
|
||||
// of window closes / reopens.
|
||||
// Computed binding for the Table's sortOrder: reads/writes the
|
||||
// per-window searchModel.sortDescriptor so clicking a column header
|
||||
// only re-sorts this window. The last-clicked sort is mirrored into
|
||||
// Preferences so a fresh window opens with the most recent choice.
|
||||
private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> {
|
||||
Binding(
|
||||
get: { [Self.comparatorFor(inDescriptor: model.sortDescriptor)] },
|
||||
get: { [Self.comparatorFor(inDescriptor: searchModel.sortDescriptor)] },
|
||||
set: { vNewOrder in
|
||||
guard let vFirst = vNewOrder.first else { return }
|
||||
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
|
||||
@@ -39,7 +45,7 @@ struct ContentView: View {
|
||||
// model while NSTableView is still in its sort delegate
|
||||
// callback (avoids the reentrant-operation AppKit warning)
|
||||
DispatchQueue.main.async {
|
||||
model.sortDescriptor = vDescriptor
|
||||
searchModel.sortDescriptor = vDescriptor
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -77,12 +83,6 @@ struct ContentView: View {
|
||||
.help("Preferences (⌘,)")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
model.start()
|
||||
}
|
||||
.onDisappear {
|
||||
model.saveCache()
|
||||
}
|
||||
.onChange(of: columnCustomization) { _, vNew in
|
||||
// Debounced + off-main save. SwiftUI fires onChange on every
|
||||
// micro-update during a column drag - encoding synchronously
|
||||
@@ -136,7 +136,7 @@ struct ContentView: View {
|
||||
|
||||
private var searchBar: some View {
|
||||
SearchField(
|
||||
text: $model.query,
|
||||
text: $searchModel.query,
|
||||
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
|
||||
initiallyFirstResponder: true
|
||||
)
|
||||
@@ -170,6 +170,7 @@ struct ContentView: View {
|
||||
.frame(width: 16, height: 16)
|
||||
Text(vRecord.name)
|
||||
.lineLimit(1)
|
||||
.help(vRecord.name)
|
||||
// When the preview pane is closed, surface the
|
||||
// elevate-permission affordance on the selected row
|
||||
// itself so the user has a way to authorize without
|
||||
@@ -181,7 +182,11 @@ struct ContentView: View {
|
||||
selection.contains(vRecord.id),
|
||||
access.needsAuthorization(for: vRecord) {
|
||||
Spacer(minLength: 4)
|
||||
AuthorizeBadge(record: vRecord)
|
||||
// pass access explicitly: Table cells live in
|
||||
// detached NSHostingViews that don't reliably
|
||||
// inherit @EnvironmentObject, which was the cause
|
||||
// of repeated EnvironmentObject.error() crashes
|
||||
AuthorizeBadge(access: access, record: vRecord)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,12 +198,13 @@ struct ContentView: View {
|
||||
.foregroundColor(.secondary)
|
||||
.truncationMode(.middle)
|
||||
.lineLimit(1)
|
||||
.help(vRecord.parentPath)
|
||||
}
|
||||
.width(min: 200, ideal: 380)
|
||||
.customizationID("path")
|
||||
|
||||
TableColumn("Size", value: \FileRecord.size) { vRecord in
|
||||
Text(vRecord.isDirectory ? "—" : Formatters.size(bytes: vRecord.size))
|
||||
Text(vRecord.isDirectory ? "-" : Formatters.size(bytes: vRecord.size))
|
||||
.foregroundColor(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
@@ -221,7 +227,7 @@ struct ContentView: View {
|
||||
.width(140)
|
||||
.customizationID("modified")
|
||||
} rows: {
|
||||
ForEach(model.visibleRecords) { vRecord in
|
||||
ForEach(searchModel.visibleRecords) { vRecord in
|
||||
TableRow(vRecord)
|
||||
.draggable(URL(fileURLWithPath: vRecord.fullPath))
|
||||
}
|
||||
@@ -282,7 +288,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private func recordsFor(inIds: Set<FileRecord.ID>) -> [FileRecord] {
|
||||
return model.visibleRecords.filter { inIds.contains($0.id) }
|
||||
return searchModel.visibleRecords.filter { inIds.contains($0.id) }
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -328,15 +334,20 @@ private struct StatusBarView: View {
|
||||
|
||||
@EnvironmentObject var model: AppModel
|
||||
@EnvironmentObject var prefs: Preferences
|
||||
@EnvironmentObject var searchModel: WindowSearchModel
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
if model.isIndexing {
|
||||
if model.isLoadingCache {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Indexing… \(model.indexedCount) entries")
|
||||
Text("Loading index…")
|
||||
} else if model.isIndexing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Indexing… \(model.indexedCount.formatted()) entries")
|
||||
} else {
|
||||
Text("\(model.visibleRecords.count) shown · \(model.indexedCount) indexed")
|
||||
Text(resultsText)
|
||||
}
|
||||
Spacer()
|
||||
Text(model.isIndexer ? "Indexer" : "Reader")
|
||||
@@ -346,7 +357,7 @@ private struct StatusBarView: View {
|
||||
case .userAgent: Text("· User service").foregroundColor(.secondary)
|
||||
case .rootDaemon: Text("· Root service").foregroundColor(.secondary)
|
||||
}
|
||||
if !model.query.isEmpty {
|
||||
if !searchModel.query.isEmpty {
|
||||
Text("· Filtered").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -355,4 +366,15 @@ private struct StatusBarView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// "N results" plus how many are listed when the list is capped
|
||||
private var resultsText: String {
|
||||
let vMatches = searchModel.matchCount
|
||||
let vShown = searchModel.visibleRecords.count
|
||||
let vIndexed = model.indexedCount.formatted()
|
||||
if vShown < vMatches {
|
||||
return "\(vMatches.formatted()) results (first \(vShown.formatted()) listed) · \(vIndexed) indexed"
|
||||
}
|
||||
return "\(vMatches.formatted()) results · \(vIndexed) indexed"
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ enum Formatters {
|
||||
|
||||
// short date+time, with em-dash for sentinel "no date" values
|
||||
static func date(_ inDate: Date) -> String {
|
||||
if inDate.timeIntervalSince1970 < 1 { return "—" }
|
||||
if inDate.timeIntervalSince1970 < 1 { return "-" }
|
||||
return kDateFormatter.string(from: inDate)
|
||||
}
|
||||
}
|
||||
@@ -29,14 +29,20 @@ struct QuickLookPreviewView: NSViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorizeBadge is the small lock icon that appears either inside the
|
||||
// preview pane (when the selected file isn't user-readable) or at the
|
||||
// right of the selected row when the preview pane is closed. Clicking
|
||||
// it kicks off the sudo cp + chown via AdminShell - the system prompts
|
||||
// for the password the first time inside the admin-auth-cache window.
|
||||
// AuthorizeBadge is the small lock icon that appears at the right of the
|
||||
// selected row when the preview pane is closed. Clicking it kicks off the
|
||||
// sudo cp + chown via AdminShell - the system prompts for the password
|
||||
// the first time inside the admin-auth-cache window.
|
||||
//
|
||||
// access is taken as an @ObservedObject property (not @EnvironmentObject)
|
||||
// because this view is hosted inside a SwiftUI Table cell, and Table cell
|
||||
// content is rendered in its own NSHostingView. That hosting view does
|
||||
// not reliably inherit the parent's environment objects; a missing
|
||||
// access lookup triggers EnvironmentObject.error() → SIGTRAP. Passing
|
||||
// the AccessManager explicitly sidesteps that entire failure mode.
|
||||
struct AuthorizeBadge: View {
|
||||
|
||||
@EnvironmentObject var access: AccessManager
|
||||
@ObservedObject var access: AccessManager
|
||||
let record: FileRecord
|
||||
|
||||
var body: some View {
|
||||
@@ -69,15 +75,15 @@ struct AuthorizeBadge: View {
|
||||
// closed) triggers the sudo-elevation flow.
|
||||
struct PreviewPane: View {
|
||||
|
||||
@EnvironmentObject var model: AppModel
|
||||
@EnvironmentObject var access: AccessManager
|
||||
@EnvironmentObject var searchModel: WindowSearchModel
|
||||
// passed in from ContentView (its @State) so this view re-renders
|
||||
// whenever the user's selection changes
|
||||
let selection: Set<FileRecord.ID>
|
||||
|
||||
private var selectedRecord: FileRecord? {
|
||||
guard selection.count == 1, let vId = selection.first else { return nil }
|
||||
return model.visibleRecords.first(where: { $0.id == vId })
|
||||
return searchModel.visibleRecords.first(where: { $0.id == vId })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -90,6 +90,12 @@ struct SearchField: NSViewRepresentable {
|
||||
private var navigationIndex: Int?
|
||||
// text the user typed before starting to navigate; restored on rollback
|
||||
private var savedQuery: String = ""
|
||||
// debounced auto-save to recents: if the user stops typing for this
|
||||
// many nanoseconds, the current query is added to recents as though
|
||||
// they had pressed Return
|
||||
private static let kAutoSaveDelayNanos: UInt64 = 4_000_000_000
|
||||
// pending auto-save task; cancelled+rescheduled on every keystroke
|
||||
private var autoSaveTask: Task<Void, Never>?
|
||||
|
||||
init(_ inParent: SearchField) {
|
||||
parent = inParent
|
||||
@@ -109,9 +115,12 @@ struct SearchField: NSViewRepresentable {
|
||||
}
|
||||
|
||||
// fired by the Find menu item; brings the search field to first
|
||||
// responder and selects existing text so typing replaces it
|
||||
// responder and selects existing text so typing replaces it.
|
||||
// With multi-window, every Coordinator gets the notification - so
|
||||
// we only act when our field belongs to the currently key window;
|
||||
// otherwise inactive windows would all race to steal focus.
|
||||
@objc private func handleFocusRequest() {
|
||||
guard let vField = field else { return }
|
||||
guard let vField = field, vField.window?.isKeyWindow == true else { return }
|
||||
vField.window?.makeFirstResponder(vField)
|
||||
vField.selectText(nil)
|
||||
}
|
||||
@@ -128,6 +137,28 @@ struct SearchField: NSViewRepresentable {
|
||||
self?.parent.text = vValue
|
||||
}
|
||||
}
|
||||
scheduleAutoSave(field: vField)
|
||||
}
|
||||
|
||||
// reschedules the deferred "treat the current query as submitted"
|
||||
// task. Called on every keystroke; if the user stops typing for the
|
||||
// configured dwell time, the query lands in recents without Enter.
|
||||
private func scheduleAutoSave(field inField: NSSearchField) {
|
||||
autoSaveTask?.cancel()
|
||||
let vQuery = inField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||
guard !vQuery.isEmpty else { return }
|
||||
autoSaveTask = Task { [weak self, weak inField] in
|
||||
try? await Task.sleep(nanoseconds: Coordinator.kAutoSaveDelayNanos)
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run {
|
||||
guard let vField = inField else { return }
|
||||
// the user may have edited or cleared the field while we
|
||||
// slept; only commit if the snapshot still matches
|
||||
let vNow = vField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||
guard vNow == vQuery else { return }
|
||||
self?.addCurrentToRecents(field: vField)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NSSearchField's action target: fires on Return and on recent-pick
|
||||
@@ -64,7 +64,7 @@ private struct RootsTab: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text("Changes take effect on the next reindex. In service mode this stops the daemon, deletes its cache, and restarts it so the fresh process re-scans from scratch.")
|
||||
Text("In built-in mode, changes apply automatically: new folders are scanned and removed ones dropped from the index. With a service, they apply when the service restarts; Reindex now stops the daemon, deletes its cache, and restarts it so the fresh process re-scans from scratch.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ private struct ExclusionsTab: View {
|
||||
.disabled(selection == nil)
|
||||
Spacer()
|
||||
}
|
||||
Text("Entries match exact paths and any descendants. Changes apply on next reindex.")
|
||||
Text("Entries match exact paths and any descendants. In built-in mode, matching entries are removed from the index right away.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ private struct VolumesTab: View {
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Service tab
|
||||
// MARK: service tab
|
||||
// ===========================
|
||||
|
||||
// ServiceTab manages the LaunchAgent / LaunchDaemon that keeps the index
|
||||
@@ -374,7 +374,7 @@ private struct ServiceTab: View {
|
||||
.font(.callout)
|
||||
}
|
||||
LabeledContent("Installed version") {
|
||||
Text(vInstalledVer ?? "—")
|
||||
Text(vInstalledVer ?? "-")
|
||||
.font(.callout)
|
||||
.monospacedDigit()
|
||||
}
|
||||
@@ -451,7 +451,7 @@ private struct CacheTab: View {
|
||||
.monospacedDigit()
|
||||
}
|
||||
LabeledContent("Last modified") {
|
||||
Text(cacheFileMtime() ?? "—")
|
||||
Text(cacheFileMtime() ?? "-")
|
||||
.font(.callout)
|
||||
.monospacedDigit()
|
||||
}
|
||||
@@ -528,7 +528,7 @@ private struct CacheTab: View {
|
||||
|
||||
// formats a byte count as a human-friendly short string
|
||||
private func formatSize(inBytes: Int64) -> String {
|
||||
if inBytes <= 0 { return "—" }
|
||||
if inBytes <= 0 { return "-" }
|
||||
let vF = ByteCountFormatter()
|
||||
vF.countStyle = .file
|
||||
return vF.string(fromByteCount: inBytes)
|
||||
@@ -548,8 +548,8 @@ private struct DiagnosticsTab: View {
|
||||
|
||||
@EnvironmentObject var prefs: Preferences
|
||||
@EnvironmentObject var model: AppModel
|
||||
@State private var daemonStatus: String = "—"
|
||||
@State private var serviceLogTail: String = "—"
|
||||
@State private var daemonStatus: String = "-"
|
||||
@State private var serviceLogTail: String = "-"
|
||||
@State private var lastRefresh: Date = Date()
|
||||
|
||||
var body: some View {
|
||||
@@ -577,7 +577,7 @@ private struct DiagnosticsTab: View {
|
||||
.monospacedDigit()
|
||||
}
|
||||
LabeledContent("Cache mtime") {
|
||||
Text(cacheMtimeString() ?? "—")
|
||||
Text(cacheMtimeString() ?? "-")
|
||||
.font(.callout)
|
||||
.monospacedDigit()
|
||||
}
|
||||
@@ -699,7 +699,7 @@ private struct DiagnosticsTab: View {
|
||||
|
||||
// formats a byte count as a human-friendly short string
|
||||
private func formatSize(_ inBytes: Int64) -> String {
|
||||
if inBytes <= 0 { return "—" }
|
||||
if inBytes <= 0 { return "-" }
|
||||
let vF = ByteCountFormatter()
|
||||
vF.countStyle = .file
|
||||
return vF.string(fromByteCount: inBytes)
|
||||
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
// WindowSearchModel owns the per-window slice of the search/index pipeline:
|
||||
// the query the user types, the sort descriptor chosen by clicking a column
|
||||
// header in THIS window, and the filtered+sorted+capped slice of
|
||||
// AppModel.allRecords currently shown here. Each new window gets its own
|
||||
// instance so two windows can run independent searches and independent
|
||||
// sorts against the same shared index.
|
||||
//
|
||||
// AppModel remains the single source of truth for allRecords. We subscribe
|
||||
// to its recordsChanged signal and rebuild the visible slice on a debounced
|
||||
// background task whenever any input changes.
|
||||
@MainActor
|
||||
final class WindowSearchModel: ObservableObject {
|
||||
|
||||
// the user-entered search query; refilter is debounced via scheduleFilter
|
||||
@Published var query: String = "" {
|
||||
didSet { scheduleFilter(inDelay: kTypingDebounceSeconds) }
|
||||
}
|
||||
// the sort descriptor chosen by clicking a column header. Per-window:
|
||||
// clicking the Size header in window A no longer re-sorts window B.
|
||||
// Persisted to prefs (last-write-wins) so a fresh window opens with
|
||||
// the most recently chosen sort.
|
||||
@Published var sortDescriptor: FileSortDescriptor {
|
||||
didSet {
|
||||
Preferences.shared.lastSort = sortDescriptor
|
||||
scheduleFilter(inDelay: kTypingDebounceSeconds)
|
||||
}
|
||||
}
|
||||
// the filtered, sorted and capped records currently shown in the table
|
||||
@Published private(set) var visibleRecords: [FileRecord] = []
|
||||
// total number of records matching the query (may exceed visibleRecords)
|
||||
@Published private(set) var matchCount: Int = 0
|
||||
|
||||
// strong ref to the shared index; the per-window WindowSearchModel does
|
||||
// not outlive its window, so the shared model has a longer lifetime
|
||||
private let model: AppModel
|
||||
// Combine subscription to AppModel's recordsChanged signal
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
// pending filter+sort task, cancelled if a newer one supersedes it
|
||||
private var filterTask: Task<Void, Never>?
|
||||
|
||||
// maximum rows handed to SwiftUI Table for snappy scrolling
|
||||
private let kMaxVisibleRows = 2000
|
||||
// delay after a keystroke / sort click: just enough to merge key repeats
|
||||
private let kTypingDebounceSeconds: Double = 0.04
|
||||
// delay after an index change: merges bursts of FSEvents updates
|
||||
private let kIndexDebounceSeconds: Double = 0.3
|
||||
// records per parallel filter chunk
|
||||
private nonisolated static let kChunkSize = 16_384
|
||||
|
||||
init(model inModel: AppModel) {
|
||||
self.model = inModel
|
||||
self.sortDescriptor = Preferences.shared.lastSort
|
||||
// Re-filter when the shared index changes. The signal carries no
|
||||
// payload (the array itself is read when the filter runs), so a
|
||||
// burst of updates never queues up copies of the index.
|
||||
inModel.recordsChanged
|
||||
.sink { [weak self] in
|
||||
self?.scheduleFilter(inDelay: self?.kIndexDebounceSeconds ?? 0.3)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
scheduleFilter(inDelay: 0)
|
||||
}
|
||||
|
||||
// debounces filter rebuilds so we don't refilter on every keystroke
|
||||
// or every FSEvents batch. The latest call wins: earlier pending
|
||||
// tasks are cancelled before they start (or between chunks).
|
||||
private func scheduleFilter(inDelay: Double) {
|
||||
filterTask?.cancel()
|
||||
// snapshot inputs on main; the detached task is self-contained
|
||||
let vQuery = query
|
||||
let vSort = sortDescriptor
|
||||
let vRecords = model.allRecords
|
||||
let vMax = kMaxVisibleRows
|
||||
let vDelayNanos = UInt64(inDelay * 1_000_000_000)
|
||||
filterTask = Task.detached(priority: .userInitiated) { [weak self] in
|
||||
if vDelayNanos > 0 {
|
||||
try? await Task.sleep(nanoseconds: vDelayNanos)
|
||||
}
|
||||
if Task.isCancelled { return }
|
||||
let vEngine = SearchEngine(inQuery: vQuery)
|
||||
// the chunks run on GCD worker threads, which can't see this
|
||||
// task's cancellation - relay it through a shared flag
|
||||
let vCancelled = ManagedAtomicFlag()
|
||||
let vResult = await withTaskCancellationHandler {
|
||||
WindowSearchModel.matchingPositions(
|
||||
inRecords: vRecords,
|
||||
inEngine: vEngine,
|
||||
inCancelled: vCancelled
|
||||
)
|
||||
} onCancel: {
|
||||
vCancelled.set()
|
||||
}
|
||||
guard let vPositions = vResult else { return }
|
||||
if Task.isCancelled { return }
|
||||
let vTop = ResultSorter.top(
|
||||
inRecords: vRecords,
|
||||
inPositions: vPositions,
|
||||
inLimit: vMax,
|
||||
inDescriptor: vSort
|
||||
)
|
||||
if Task.isCancelled { return }
|
||||
let vCount = vPositions.count
|
||||
// hop back to main with DispatchQueue.main.async (rather than
|
||||
// await MainActor.run) so the assignment is guaranteed to land
|
||||
// on the next runloop tick, avoiding NSTableView reentrance when
|
||||
// the search field is mid-edit
|
||||
DispatchQueue.main.async {
|
||||
guard let vSelf = self else { return }
|
||||
if vSelf.matchCount != vCount { vSelf.matchCount = vCount }
|
||||
// Skip the @Published fire when the resulting list is
|
||||
// identical to what the Table is already showing. Full
|
||||
// FileRecord equality catches mtime / size updates, so we
|
||||
// only skip true no-op reassignments.
|
||||
if vSelf.visibleRecords == vTop { return }
|
||||
vSelf.visibleRecords = vTop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// positions of the records matching the engine, computed in parallel
|
||||
// chunks. Returns nil if the task was cancelled midway.
|
||||
private nonisolated static func matchingPositions(inRecords: [FileRecord],
|
||||
inEngine: SearchEngine,
|
||||
inCancelled: ManagedAtomicFlag) -> [Int32]? {
|
||||
if !inEngine.isActive {
|
||||
return (0..<Int32(inRecords.count)).map { $0 }
|
||||
}
|
||||
let vChunkCount = (inRecords.count + kChunkSize - 1) / kChunkSize
|
||||
if vChunkCount == 0 { return [] }
|
||||
var vChunks = [[Int32]](repeating: [], count: vChunkCount)
|
||||
vChunks.withUnsafeMutableBufferPointer { vOut in
|
||||
// each chunk writes only its own slot, so sharing is safe
|
||||
nonisolated(unsafe) let vOutBase = vOut.baseAddress!
|
||||
DispatchQueue.concurrentPerform(iterations: vChunkCount) { vChunk in
|
||||
if inCancelled.isSet { return }
|
||||
let vStart = vChunk * kChunkSize
|
||||
let vEnd = min(vStart + kChunkSize, inRecords.count)
|
||||
var vLocal: [Int32] = []
|
||||
for vI in vStart..<vEnd where inEngine.match(inRecord: inRecords[vI]) {
|
||||
vLocal.append(Int32(vI))
|
||||
}
|
||||
(vOutBase + vChunk).pointee = vLocal
|
||||
}
|
||||
}
|
||||
if inCancelled.isSet { return nil }
|
||||
return Array(vChunks.joined())
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal thread-safe boolean used to stop parallel filter chunks early.
|
||||
private final class ManagedAtomicFlag: @unchecked Sendable {
|
||||
// current value
|
||||
private var value = false
|
||||
// guards value
|
||||
private let lock = NSLock()
|
||||
|
||||
// true once set() has been called
|
||||
var isSet: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return value
|
||||
}
|
||||
|
||||
// raises the flag
|
||||
func set() {
|
||||
lock.lock()
|
||||
value = true
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Allofit
|
||||
|
||||
// Tests for the index bookkeeping rules (IndexState), stable ids, the
|
||||
// top-N result selection and the cache file round trip.
|
||||
@Suite("IndexState")
|
||||
struct IndexStateTests {
|
||||
|
||||
// shorthand for a file record at an absolute path
|
||||
static func file(_ inPath: String, size inSize: Int64 = 0) -> FileRecord {
|
||||
let vUrl = URL(fileURLWithPath: inPath)
|
||||
return FileRecord(
|
||||
name: vUrl.lastPathComponent,
|
||||
parentPath: vUrl.deletingLastPathComponent().path,
|
||||
size: inSize,
|
||||
dateCreated: Date(timeIntervalSince1970: 1),
|
||||
dateModified: Date(timeIntervalSince1970: 1),
|
||||
isDirectory: false
|
||||
)
|
||||
}
|
||||
|
||||
// shorthand for a directory record at an absolute path
|
||||
static func dir(_ inPath: String) -> FileRecord {
|
||||
let vUrl = URL(fileURLWithPath: inPath)
|
||||
return FileRecord(
|
||||
name: vUrl.lastPathComponent,
|
||||
parentPath: vUrl.deletingLastPathComponent().path,
|
||||
size: 0,
|
||||
dateCreated: Date(timeIntervalSince1970: 1),
|
||||
dateModified: Date(timeIntervalSince1970: 1),
|
||||
isDirectory: true
|
||||
)
|
||||
}
|
||||
|
||||
// sorted full paths of the state, for readable comparisons
|
||||
static func paths(_ inState: IndexState) -> [String] {
|
||||
return inState.records.map { $0.fullPath }.sorted()
|
||||
}
|
||||
|
||||
// a small tree rooted at /r
|
||||
static func sampleState() -> IndexState {
|
||||
return IndexState(inRecords: [
|
||||
dir("/r"),
|
||||
dir("/r/a"),
|
||||
file("/r/a/1.txt"),
|
||||
dir("/r/a/b"),
|
||||
file("/r/a/b/2.txt"),
|
||||
file("/r/c.txt")
|
||||
])
|
||||
}
|
||||
|
||||
@Test func idIsStableAndMatchesPathHash() {
|
||||
let vRecord = Self.file("/Users/me/x.txt")
|
||||
#expect(vRecord.id == FileRecord.pathHash("/Users/me/x.txt"))
|
||||
#expect(FileRecord.pathHash(inParent: "/", inName: "etc") == FileRecord.pathHash("/etc"))
|
||||
}
|
||||
|
||||
@Test func duplicatesAreDropped() {
|
||||
let vState = IndexState(inRecords: [Self.file("/r/x"), Self.file("/r/x")])
|
||||
#expect(vState.count == 1)
|
||||
}
|
||||
|
||||
@Test func removingADirectoryRemovesItsSubtree() {
|
||||
var vState = Self.sampleState()
|
||||
let vRemoved = vState.removeSubtrees(inPaths: ["/r/a"])
|
||||
#expect(vRemoved == 4)
|
||||
#expect(Self.paths(vState) == ["/r", "/r/c.txt"])
|
||||
// lookup stays consistent after swap-removal
|
||||
for (vPos, vRecord) in vState.records.enumerated() {
|
||||
#expect(vState.positions[vRecord.id] == vPos)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func siblingWithSharedPrefixIsKept() {
|
||||
var vState = IndexState(inRecords: [Self.dir("/r"), Self.dir("/r/a"), Self.file("/r/a/x"), Self.file("/r/ab")])
|
||||
vState.removeSubtrees(inPaths: ["/r/a"])
|
||||
#expect(Self.paths(vState) == ["/r", "/r/ab"])
|
||||
}
|
||||
|
||||
@Test func applyRequiresAnIndexedParentAndRescansNewFolders() {
|
||||
var vState = Self.sampleState()
|
||||
var vChanges = ResolvedChanges()
|
||||
vChanges.upserts = [
|
||||
Self.file("/r/a/new.txt"), // parent indexed: accepted
|
||||
Self.file("/r/hidden/x.txt"), // parent not indexed: dropped
|
||||
Self.dir("/r/moved"), // new folder: accepted + rescanned
|
||||
Self.file("/r/moved/inside.txt") // its parent arrives in the same batch
|
||||
]
|
||||
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
|
||||
#expect(vResult.changed)
|
||||
#expect(vResult.rescans == ["/r/moved"])
|
||||
#expect(vState.contains(inPath: "/r/a/new.txt"))
|
||||
#expect(!vState.contains(inPath: "/r/hidden/x.txt"))
|
||||
#expect(vState.contains(inPath: "/r/moved/inside.txt"))
|
||||
}
|
||||
|
||||
@Test func renameRemovesOldSubtree() {
|
||||
var vState = Self.sampleState()
|
||||
var vChanges = ResolvedChanges()
|
||||
vChanges.removals = ["/r/a"]
|
||||
vChanges.upserts = [Self.dir("/r/renamed")]
|
||||
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
|
||||
#expect(!vState.contains(inPath: "/r/a/b/2.txt"))
|
||||
#expect(vResult.rescans == ["/r/renamed"])
|
||||
}
|
||||
|
||||
@Test func unchangedUpsertIsNotAChange() {
|
||||
var vState = Self.sampleState()
|
||||
var vChanges = ResolvedChanges()
|
||||
vChanges.upserts = [Self.file("/r/c.txt")]
|
||||
#expect(!vState.apply(inChanges: vChanges, inRoots: ["/r"]).changed)
|
||||
}
|
||||
|
||||
@Test func pruneDropsOrphansExclusionsAndOldRoots() {
|
||||
var vState = Self.sampleState()
|
||||
// orphan: parent /r/hidden is not indexed
|
||||
vState.upsert(Self.file("/r/hidden/x.txt"))
|
||||
// leftover of a root that is no longer configured
|
||||
vState.upsert(Self.dir("/old"))
|
||||
vState.upsert(Self.file("/old/y.txt"))
|
||||
let vRemoved = vState.prune(inRoots: ["/r"], inExclusions: ExclusionMatcher(inExclusions: ["/r/a/b"]))
|
||||
#expect(vRemoved == 5)
|
||||
#expect(Self.paths(vState) == ["/r", "/r/a", "/r/a/1.txt", "/r/c.txt"])
|
||||
}
|
||||
|
||||
@Test func pruneDropsHiddenEntries() {
|
||||
var vState = Self.sampleState()
|
||||
vState.upsert(Self.dir("/r/Library"))
|
||||
vState.upsert(Self.file("/r/Library/deep.txt"))
|
||||
vState.upsert(Self.dir("/r/a/.git"))
|
||||
vState.upsert(Self.file("/r/a/.git/HEAD"))
|
||||
let vRemoved = vState.prune(
|
||||
inRoots: ["/r"],
|
||||
inExclusions: ExclusionMatcher(inExclusions: []),
|
||||
inIsHidden: { $0 == "/r/Library" }
|
||||
)
|
||||
#expect(vRemoved == 4)
|
||||
#expect(Self.paths(vState) == Self.paths(Self.sampleState()))
|
||||
}
|
||||
|
||||
@Test func composedAndDecomposedNamesShareAnId() {
|
||||
let vComposed = Self.file("/r/Envoy\u{00E9}s.msf")
|
||||
let vDecomposed = Self.file("/r/Envoye\u{0301}s.msf")
|
||||
#expect(vComposed.id == vDecomposed.id)
|
||||
#expect(IndexState(inRecords: [vComposed, vDecomposed]).count == 1)
|
||||
#expect(FileRecord.pathHash("/r/Envoye\u{0301}s.msf") == vComposed.id)
|
||||
}
|
||||
|
||||
@Test func replaceSubtreesSwapsContent() {
|
||||
var vState = Self.sampleState()
|
||||
vState.replaceSubtrees(inRoots: ["/r/a"], inRecords: [Self.dir("/r/a"), Self.file("/r/a/fresh.txt")])
|
||||
#expect(Self.paths(vState) == ["/r", "/r/a", "/r/a/fresh.txt", "/r/c.txt"])
|
||||
}
|
||||
|
||||
@Test func minimalRootsDropsNestedPaths() {
|
||||
let vRoots = SubtreeMatcher.minimalRoots(inPaths: ["/a/b", "/a", "/a b/c", "/a", "/c"])
|
||||
#expect(vRoots.sorted() == ["/a", "/a b/c", "/c"])
|
||||
}
|
||||
|
||||
@Test func topSelectionMatchesFullSort() {
|
||||
let vRecords = (0..<5000).map { Self.file("/r/f\($0 % 977)-\($0).txt", size: Int64(($0 * 7919) % 1013)) }
|
||||
let vPositions = (0..<Int32(vRecords.count)).map { $0 }
|
||||
for vSort in FileSortDescriptor.allCases {
|
||||
let vTop = ResultSorter.top(inRecords: vRecords, inPositions: vPositions, inLimit: 100, inDescriptor: vSort)
|
||||
let vFull = ResultSorter.top(inRecords: vRecords, inPositions: vPositions, inLimit: vRecords.count, inDescriptor: vSort)
|
||||
#expect(vTop.map(\.id) == Array(vFull.prefix(100)).map(\.id), "sort \(vSort.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func cacheRoundTrip() throws {
|
||||
let vUrl = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("allofit-test-\(UUID().uuidString).bin")
|
||||
defer { try? FileManager.default.removeItem(at: vUrl) }
|
||||
let vRecords = Self.sampleState().records
|
||||
IndexStore.save(inRecords: vRecords, inLastEventId: 42, to: vUrl)
|
||||
let vLoaded = try #require(IndexStore.load(from: vUrl))
|
||||
#expect(vLoaded.lastEventId == 42)
|
||||
#expect(vLoaded.records == vRecords)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Allofit
|
||||
|
||||
// Tests for the Everything-style query syntax implemented by SearchEngine.
|
||||
@Suite("SearchEngine")
|
||||
struct SearchEngineTests {
|
||||
|
||||
// builds a record from an absolute path
|
||||
static func record(_ inPath: String, isDirectory inIsDir: Bool = false) -> FileRecord {
|
||||
let vUrl = URL(fileURLWithPath: inPath)
|
||||
return FileRecord(
|
||||
name: vUrl.lastPathComponent,
|
||||
parentPath: vUrl.deletingLastPathComponent().path,
|
||||
size: 0,
|
||||
dateCreated: .distantPast,
|
||||
dateModified: .distantPast,
|
||||
isDirectory: inIsDir
|
||||
)
|
||||
}
|
||||
|
||||
// true when the query matches the record at inPath
|
||||
static func matches(_ inQuery: String, _ inPath: String, isDirectory inIsDir: Bool = false) -> Bool {
|
||||
return SearchEngine(inQuery: inQuery).match(inRecord: record(inPath, isDirectory: inIsDir))
|
||||
}
|
||||
|
||||
@Test func emptyQueryIsInactive() {
|
||||
#expect(!SearchEngine(inQuery: " ").isActive)
|
||||
#expect(SearchEngine(inQuery: "a").isActive)
|
||||
}
|
||||
|
||||
@Test func substringIsCaseInsensitive() {
|
||||
#expect(Self.matches("report", "/d/Annual REPORT 2024.pdf"))
|
||||
#expect(!Self.matches("report", "/d/summary.pdf"))
|
||||
}
|
||||
|
||||
@Test func readmeExamples() {
|
||||
#expect(Self.matches("Start*.pdf", "/d/StartHere.pdf"))
|
||||
#expect(!Self.matches("Start*.pdf", "/d/Restart.pdf"))
|
||||
#expect(Self.matches("IMG_????.heic", "/d/IMG_1234.heic"))
|
||||
#expect(!Self.matches("IMG_????.heic", "/d/IMG_12345.heic"))
|
||||
#expect(Self.matches("*.png | *.jpg", "/d/a.jpg"))
|
||||
#expect(Self.matches("*.png|*.jpg", "/d/a.png"))
|
||||
#expect(!Self.matches("*.png | *.jpg", "/d/a.gif"))
|
||||
}
|
||||
|
||||
@Test func spaceIsAnd() {
|
||||
#expect(Self.matches("app apk", "/d/myapp-release.apk"))
|
||||
#expect(!Self.matches("app apk", "/d/myapp-release.zip"))
|
||||
}
|
||||
|
||||
@Test func orBindsTighterThanAnd() {
|
||||
// a AND (b OR c)
|
||||
#expect(Self.matches("osm *.apk|*.aab", "/d/osm-1.aab"))
|
||||
#expect(!Self.matches("osm *.apk|*.aab", "/d/other.aab"))
|
||||
}
|
||||
|
||||
@Test func notAndQuotes() {
|
||||
#expect(Self.matches("report !draft", "/d/report-final.pdf"))
|
||||
#expect(!Self.matches("report !draft", "/d/report-draft.pdf"))
|
||||
#expect(Self.matches("\"my file\"", "/d/this is my file.txt"))
|
||||
#expect(!Self.matches("\"my file\"", "/d/my other file.txt"))
|
||||
// wildcards still apply inside quotes
|
||||
#expect(Self.matches("\"*app*\" *.apk", "/d/osmapp.apk"))
|
||||
}
|
||||
|
||||
@Test func extensionAndKindModifiers() {
|
||||
#expect(Self.matches("ext:pdf;docx", "/d/a.DOCX"))
|
||||
#expect(!Self.matches("ext:pdf;docx", "/d/a.doc"))
|
||||
#expect(Self.matches("folder:build", "/d/build", isDirectory: true))
|
||||
#expect(!Self.matches("folder:build", "/d/build.gradle"))
|
||||
#expect(Self.matches("file: build", "/d/build.gradle"))
|
||||
#expect(!Self.matches("file:", "/d/src", isDirectory: true))
|
||||
}
|
||||
|
||||
@Test func pathSubstring() {
|
||||
#expect(Self.matches("sources/main", "/u/sources/main/app.swift"))
|
||||
#expect(!Self.matches("sources/main", "/u/sources/test/app.swift"))
|
||||
}
|
||||
|
||||
@Test func pathGlobWithDoubleStar() {
|
||||
let vQuery = "\"some/folder/**/path\" IMG_????.heic"
|
||||
#expect(Self.matches(vQuery, "/Users/me/some/folder/a/b/path/IMG_0001.heic"))
|
||||
// ** also matches zero folders
|
||||
#expect(Self.matches(vQuery, "/Users/me/some/folder/path/IMG_0001.heic"))
|
||||
// the name term still applies
|
||||
#expect(!Self.matches(vQuery, "/Users/me/some/folder/a/path/IMG_01.heic"))
|
||||
// folder names must match whole: "paths" is not "path"
|
||||
#expect(!Self.matches(vQuery, "/Users/me/some/folder/a/paths/IMG_0001.heic"))
|
||||
// must start on a folder boundary: "awesome" is not "some"
|
||||
#expect(!Self.matches(vQuery, "/Users/me/awesome/folder/a/path/IMG_0001.heic"))
|
||||
}
|
||||
|
||||
@Test func pathGlobSingleStarStaysInOneFolder() {
|
||||
#expect(Self.matches("src/*/build/", "/p/src/app/build/out.o"))
|
||||
#expect(!Self.matches("src/*/build/", "/p/src/app/sub/build/out.o"))
|
||||
#expect(Self.matches("/p/src/**/*.o", "/p/src/a/b/out.o"))
|
||||
#expect(!Self.matches("/src/**/*.o", "/p/src/a/b/out.o"))
|
||||
}
|
||||
|
||||
@Test func unicodeNormalizationAndCase() {
|
||||
// name stored decomposed (NFD), query typed composed (NFC)
|
||||
let vDecomposed = "Re\u{0301}sume\u{0301}.pdf"
|
||||
#expect(Self.matches("RÉSUMÉ", "/d/" + vDecomposed))
|
||||
#expect(Self.matches("r?sum?.pdf", "/d/" + vDecomposed))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user