mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68b05ab9a |
@@ -107,17 +107,12 @@ 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. The whole body is wrapped
|
||||
// in autoreleasepool so the autoreleased NSDate / NSNumber / NSURL
|
||||
// objects returned by resourceValues() are released at function exit
|
||||
// rather than at the caller's pool drain.
|
||||
// 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? {
|
||||
return autoreleasepool { () -> FileRecord? in
|
||||
makeRecord_impl(inURL: inURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeRecord_impl(inURL: URL) -> FileRecord? {
|
||||
var vUrl = inURL
|
||||
vUrl.removeAllCachedResourceValues()
|
||||
guard let vValues = try? vUrl.resourceValues(forKeys: Set(kPrefetchKeys)) else {
|
||||
@@ -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 {
|
||||
@@ -481,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,7 +481,6 @@ final class AppModel: ObservableObject {
|
||||
self?.pathIndex = vLookup
|
||||
self?.indexedCount = vFinal.count
|
||||
self?.dirty = true
|
||||
self?.scheduleSearch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,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:
|
||||
@@ -625,7 +575,6 @@ final class AppModel: ObservableObject {
|
||||
self?.pathIndex = vLookup
|
||||
self?.indexedCount = vCache.records.count
|
||||
self?.lastEventId = vCache.lastEventId
|
||||
self?.applyFilterAndSort()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -634,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) {
|
||||
@@ -644,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] {
|
||||
@@ -718,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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user