3 Commits
26 changed files with 413 additions and 360 deletions
@@ -49,21 +49,29 @@ enum FileIndexer {
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 {
guard let vURL = vCase as? URL else { continue }
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() }
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() }
return
}
if let vRecord = makeRecord(inURL: vURL) {
vBatch.append(vRecord)
if vBatch.count >= inBatchSize {
inBatch(vBatch)
vBatch.removeAll(keepingCapacity: true)
if let vRecord = makeRecord(inURL: vURL) {
vBatch.append(vRecord)
if vBatch.count >= inBatchSize {
inBatch(vBatch)
vBatch.removeAll(keepingCapacity: true)
}
}
}
}
@@ -99,7 +107,11 @@ enum FileIndexer {
// 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.
// silently return the cached pre-edit mtime. Callers (walkRoot's
// enumerator loop, applyFileSystemChanges, AllofitService) already
// wrap each invocation in an autoreleasepool, so the autoreleased
// NSDate / NSNumber / NSURL objects from resourceValues() drain at
// the caller's pool boundary.
static func makeRecord(inURL: URL) -> FileRecord? {
var vUrl = inURL
vUrl.removeAllCachedResourceValues()
@@ -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 }
@@ -55,22 +55,33 @@ enum AllofitService {
// 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.
//
// Both the per-root and per-batch callback bodies run inside their
// own autoreleasepool so the autoreleased NSURL / NSDate / NSNumber
// from the file enumeration don't pile up until the entire scan
// finishes - on a million-file scan that "pile" was peaking at
// well over a gig of dead allocations before the main thread's
// runloop got a chance to drain.
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)
autoreleasepool {
NSLog("[Allofit] scanning %@", vRoot.path)
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
autoreleasepool {
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()
}
}
vState.dirty = true
vState.lock.unlock()
NSLog("[Allofit] scanned %@: %d total entries so far", vRoot.path, vState.records.count)
}
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
@@ -98,27 +109,35 @@ enum AllofitService {
// dominating CPU and turning into the daemon's memory churn
var vRemoved: Set<String> = []
// Per-change autoreleasepool: a large FSEvents batch (e.g.
// `rm -rf` of a deep tree) can deliver thousands of paths in
// one callback. Each path's NSURL + reachability check + the
// makeRecord internals autorelease - per-change drain keeps
// peak memory bounded by a single record's worth, not the
// whole batch.
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
autoreleasepool {
if vMatcher.isExcluded(inPath: vChange.path) { return }
if vChange.mustScanSubDirs {
vRescanPrefixes.append(vChange.path)
return
}
// skip paths we've already queued for removal in this batch
if vRemoved.contains(vChange.path) { return }
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)
}
} else if vState.pathIndex[vChange.path] != nil {
vRemoved.insert(vChange.path)
}
}
@@ -162,27 +181,36 @@ enum AllofitService {
// 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).
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)
autoreleasepool {
vState.lock.lock()
let vShouldSave = vState.dirty
let vSnapshot = vState.records
vState.dirty = false
vState.lock.unlock()
if !vShouldSave { return }
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)
}
}
}
}
@@ -17,12 +17,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 +32,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 +56,39 @@ 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())
}
}
// 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 +165,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 +197,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).
@@ -23,10 +23,11 @@ private final class BackgroundMtime: @unchecked Sendable {
}
}
// AppModel is the central observable state for the application.
// 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,
@@ -39,8 +40,6 @@ 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] = []
// 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,37 +48,14 @@ 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
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
@@ -106,8 +82,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 +91,8 @@ 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
// no synchronous UI-state restoration needed; the cache file is
// loaded off-main in start() so the window appears instantly
}
// kicks off background activity for the first time. Subsequent calls are
@@ -152,8 +124,6 @@ final class AppModel: ObservableObject {
cachePollSource = nil
indexerLock?.unlock()
indexerLock = nil
filterTask?.cancel()
filterTask = nil
}
// determines indexer vs reader role, loads the cache from the appropriate
@@ -199,16 +169,9 @@ final class AppModel: ObservableObject {
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.isLoadingCache = false
vSelf.applyFilterAndSort()
// WindowSearchModel(s) observe allRecords and will refilter;
// no need to kick a filter here ourselves
if vSelf.isIndexer {
vSelf.startIndexerMode()
} else {
@@ -241,13 +204,18 @@ final class AppModel: ObservableObject {
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)
@@ -476,7 +444,8 @@ final class AppModel: ObservableObject {
indexedCount = allRecords.count
lastEventId = watcher.latestEventId
dirty = true
scheduleSearch()
// WindowSearchModel(s) observe @Published allRecords and refilter
// on debounce; no need to fire a manual filter pass here
}
}
@@ -494,11 +463,16 @@ final class AppModel: ObservableObject {
return true
}
for vPath in inPaths {
let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
)
vKept.append(contentsOf: vList)
// per-subtree autoreleasepool keeps the rescan's
// autoreleased URL/stat objects from accumulating across
// subtrees inside this long-running async block
autoreleasepool {
let vList = FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
)
vKept.append(contentsOf: vList)
}
}
let vFinal = vKept
let vLookup = AppModel.buildPathLookup(inRecords: vFinal)
@@ -507,7 +481,6 @@ final class AppModel: ObservableObject {
self?.pathIndex = vLookup
self?.indexedCount = vFinal.count
self?.dirty = true
self?.scheduleSearch()
}
}
}
@@ -565,19 +538,6 @@ 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:
@@ -615,7 +575,6 @@ final class AppModel: ObservableObject {
self?.pathIndex = vLookup
self?.indexedCount = vCache.records.count
self?.lastEventId = vCache.lastEventId
self?.applyFilterAndSort()
}
}
}
@@ -624,7 +583,9 @@ final class AppModel: ObservableObject {
// MARK: Internals
// ===========================
// installs a freshly-built index and refreshes the visible slice
// installs a freshly-built index and starts watching for changes. The
// per-window WindowSearchModel(s) observe allRecords and will refilter
// themselves; no need to fire a filter pass from here.
private func applyFreshIndex(inRecords: [FileRecord],
inLookup: [String: Int],
inEventId: UInt64) {
@@ -634,68 +595,11 @@ final class AppModel: ObservableObject {
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] {
@@ -708,9 +612,9 @@ final class AppModel: ObservableObject {
}
// 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) {
// task can call it without an actor hop. Called by WindowSearchModel.
nonisolated static func sortInPlace(inRecords: inout [FileRecord],
inDescriptor: FileSortDescriptor) {
switch inDescriptor {
case .nameAscending:
inRecords.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
@@ -10,6 +10,9 @@ struct ContentView: View {
@EnvironmentObject var model: AppModel
@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 +29,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 +43,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
}
}
)
@@ -136,7 +140,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 +174,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 +186,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,6 +202,7 @@ struct ContentView: View {
.foregroundColor(.secondary)
.truncationMode(.middle)
.lineLimit(1)
.help(vRecord.parentPath)
}
.width(min: 200, ideal: 380)
.customizationID("path")
@@ -221,7 +231,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 +292,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,6 +338,7 @@ private struct StatusBarView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var searchModel: WindowSearchModel
var body: some View {
HStack(spacing: 8) {
@@ -336,7 +347,7 @@ private struct StatusBarView: View {
.controlSize(.small)
Text("Indexing… \(model.indexedCount) entries")
} else {
Text("\(model.visibleRecords.count) shown · \(model.indexedCount) indexed")
Text("\(searchModel.visibleRecords.count) shown · \(model.indexedCount) indexed")
}
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)
}
}
@@ -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 {
@@ -71,13 +77,14 @@ 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
+112
View File
@@ -0,0 +1,112 @@
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 it via Combine 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() }
}
// 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()
}
}
// the filtered, sorted and capped records currently shown in the table
@Published private(set) var visibleRecords: [FileRecord] = []
// 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 @Published allRecords
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
// debounce delay between keystroke / index update and filter rebuild
private let kSearchDebounceSeconds: Double = 0.5
init(model inModel: AppModel) {
self.model = inModel
self.sortDescriptor = Preferences.shared.lastSort
// Re-filter when the shared index changes. @Published fires the
// current value on subscribe; dropFirst skips that replay and we
// run one explicit scheduleFilter() below so the initial debounce
// delay applies consistently.
inModel.$allRecords
.dropFirst()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.scheduleFilter()
}
.store(in: &cancellables)
scheduleFilter()
}
// debounces filter rebuilds so we don't refilter on every keystroke
// or every FSEvents batch mutation of AppModel.allRecords. The same
// debounce window covers all input changes, which is what the old
// single-model code did before this split.
private func scheduleFilter() {
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(kSearchDebounceSeconds * 1_000_000_000)
filterTask = Task.detached(priority: .userInitiated) { [weak self] in
try? await Task.sleep(nanoseconds: vDelayNanos)
if Task.isCancelled { return }
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.
if vSelf.visibleRecords == vCapped { return }
vSelf.visibleRecords = vCapped
}
}
}
}
View File
-140
View File
@@ -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))")