7 Commits
Author SHA1 Message Date
Bitsy 876122ff98 Audit fixes: security hardening, index correctness, performance, UX
Security
- Elevated copies: root only reads the original; the copy is written by
  the user (sudo -u tee), so root never chowns / chmods a user-controlled
  path. Staging folder forced to 0700.
- Service logs moved from fixed /tmp names to /Library/Logs/Allofit
  (root-owned) and ~/Library/Logs/Allofit; Diagnostics reveals instead of
  opening the log.
- Index files are owner-only (0600; the root daemon's belongs to the
  installing user), set on the temp file before an atomic rename.
- Root install passes the plist inline (base64, plutil -lint) instead of a
  user-writable temp file; the binary comes from Bundle.main.
- Cache loader caps and checks the declared payload size; each save uses
  its own temp file.
- Release action pinned to a commit; non-system LC_RPATHs stripped.

Correctness
- Move to Trash removes the files from the index (the watcher ignores the
  app's own operations) and registers Undo (Put Back); trashed folders are
  matched with the original URLs; failures are shown.
- The saved event id stays below pending subtree walks (GUI and service).
- Roots / exclusions changes restart the watcher from the snapshot's id.
- Case-only renames no longer leave a ghost entry.
- Service mode is saved only after a successful install; a saved but
  missing service falls back to the in-process indexer.

Performance
- New folders are merged without the O(n) removal pass.
- Size / date sorts use a compact key array (539 -> 47 ms for 630k).
- Selection, preview and actions use the selected records directly.
- Service saves at most every 15 s; reader reloads pause while hidden and
  are deferred instead of dropped; window close saves only when dirty.

Usability
- Results appear during the first index; empty-list explanations.
- Down arrow moves to the results, Up on the first row back; history on
  Up / Option-Up / Option-Down.
- Search syntax popover and Help menu; shortcuts shown in the context menu;
  confirmations for Clear Cache and Uninstall; privacy usage strings;
  Group Containers excluded by default; wording, VoiceOver labels, plural.
2026-10-02 14:44:49 +02:00
Bitsy eb9ee9abd9 Hover overlay for cut-off cells, hold Option to freeze the list
- Resting the pointer 0.5 s on a cut-off name or path shows its full text
  (with highlights) in a click-through panel over the cell. It stays while
  hovered, updates in place when the list refreshes, and hides on move,
  exit, scroll, click or key press. Replaces AppKit's expansion tooltip,
  whose timing can't be set and which vanished when touched.
- Holding Option alone freezes the results: background refreshes are held
  back (the index keeps updating) and applied once on release, keeping the
  scroll position. Typing, sorting and filtering still apply. Released
  automatically when the app deactivates. "Frozen" shows in the status bar.
- README: current keyboard shortcuts, hover and Finder action.
  CHANGELOG: freeze key and hover overlay in 1.0.9.
2026-10-02 14:24:53 +02:00
Bitsy 17137a6f43 Instant full-text hover for cut-off names and paths, changelog for 1.0.9
- Result cells use expansion tooltips: hovering a truncated name or path
  shows the full text over the cell right away, like Finder, instead of a
  help tag waiting for the tooltip delay.
- Register NSInitialToolTipDelay in milliseconds (250); it was registered
  as 0.3, read as milliseconds.
- CHANGELOG.md with the 1.0.6 and 1.0.9 notes.
2026-10-02 11:11:53 +02:00
Bitsy cd1d8a756e Unlimited results: native results table, chunked index store
- Replace the SwiftUI Table (capped at 2000 rows) with an NSTableView that
  only builds the visible rows, so every match is listed and scrolls
  smoothly. Column order / width / visibility autosave, header menu to hide
  columns, Finder-accurate drag (file URLs), Edit > Copy copies files, keys
  and context menu as before, "Authorize Access..." in the menu for
  unreadable files, bold highlights readable on selected rows.
- Results arrive in two steps for broad queries: the first 2000 rows in
  milliseconds, then the complete sort (~0.2 s for 600k entries). Background
  refreshes publish once so the scroll position is kept.
- Store the index in 16k-record chunks (RecordStore): snapshots are cheap and
  a change copies only the touched chunk. Results are positions into a
  snapshot (ResultList), 4 bytes per row instead of a copy of each record.
  The parallel filter walks the store chunk by chunk.
2026-10-02 10:59:40 +02:00
Bitsy 132430e3cc Everything-style UI: filters, highlights, hotkey, menu bar, Finder service
- Filter menu next to the search field (Everything, Folders, Documents,
  Pictures, Audio, Video, Archives, Applications, Code), ANDed with the query.
- Bold highlighting of matched parts in names and, for path terms, folders.
- Global shortcut (default Option-Space) toggling the window, via Carbon
  RegisterEventHotKey; menu bar icon (NSStatusItem) with Show / Settings /
  Quit; start at login through SMAppService.
- Finder "Search in Allofit" service and folder document type: right-click a
  folder, drop it on the Dock icon or open -a Allofit <folder> to search
  inside it.
- Results: Finder-style keys (Return, Cmd-Return, Cmd-C copies files,
  Opt-Cmd-C copies paths, Cmd-Y, Cmd-Delete to Trash), richer context menu
  (Open With, Copy Name, Move to Trash), selection count and size in the
  status bar, configurable double-click action. Rebuild Index moves to
  Shift-Cmd-R.
- Settings regrouped into General / Indexes / Performance / Advanced, with
  aligned labels and top-aligned sub-pages.
2026-10-02 10:47:25 +02:00
Bitsy 547753cc4d Status bar stats, background throttling, Performance settings, drag fix
- Status bar: last search time, memory footprint, CPU use (highlighted when
  busy) and role, with a tooltip for peak memory, threads, index age, cache
  size and roots. Sampled every 2 s, redraws only the status bar; match count
  and search time live in a separate object so the table isn't re-rendered.
- Background throttling: file changes are collected longer and open results
  refresh less often when Allofit isn't focused or no window is visible;
  sampling pauses when hidden. Showing a window applies pending changes at
  once.
- Settings > Performance: the update delays (focused / another app focused /
  no window) and result refresh intervals, with an estimate of how soon a
  changed file appears and Restore Defaults. Defaults 2 s / 15 s / 1 min and
  1 s / 5 s.
- Drag rows as file URLs (like Finder) instead of a plain link, so browsers
  upload the file instead of navigating to it.
- Canonicalize roots with realpath: roots under /tmp, /var or /etc never
  matched their own entries because Foundation strips /private.
2026-10-02 10:20:45 +02:00
Bitsy 35ffc86e46 Fix idle CPU drain from open path queries, general code cleanup
Fix
- An open window with a path query (term containing "/") re-ran the search
  over the whole index on every FSEvents update, keeping several cores busy
  while files changed. Index-driven refilters are now throttled to one per
  second, skipped while the window is closed, minimized or covered, and
  replayed once when it becomes visible again. Typing stays instant.
- Path terms no longer build and lowercase the full path of every record:
  the folded folder path is precomputed once per folder (interned like
  parentPath) and matched together with the folded name without allocating
  the joined string. Path-only queries are about 3x faster.

Cleanup
- Remove dead code: FSChange.isDir, FileWatcher.latestEventId,
  IndexState.record(atPath:), the never-set lastQuery preference,
  AccessManager.reset(), SearchField.onSubmit, an unreachable return.
- Merge duplicates: the two Settings size formatters become
  Formatters.sizeOrDash, the shellQuote wrapper is replaced by
  AdminShell.quote.
- Naming conventions: "in"-prefixed parameter names everywhere (delegate
  methods, binary helpers, initializers), camelCase impl helpers.
- Comment every function and type; fix stale comments.
- Debug and release builds are warning-free.
2026-10-02 09:29:18 +02:00
41 changed files with 3844 additions and 730 deletions
+2 -1
View File
@@ -62,7 +62,8 @@ jobs:
| tee "Allofit-${{ steps.version.outputs.version }}.sha256"
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
# pinned to a commit: this step holds a write token for the repo
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Allofit ${{ steps.version.outputs.version }}
+116
View File
@@ -0,0 +1,116 @@
# Changelog
All notable changes to Allofit are documented here.
## [1.0.9] - 2026-10-02
### Added
- **Unlimited results.** Every match is listed, with no 2,000-row cap. The results list is a native macOS table that only builds the rows on screen, so scrolling through hundreds of thousands of entries stays smooth. For broad searches the first rows appear in milliseconds and the complete sorted list follows a fraction of a second later.
- **Filter menu** next to the search box, like Everything: Everything, Folders, Documents, Pictures, Audio, Video, Archives, Applications, Code.
- **Match highlighting:** the matched parts of names, and of folder paths for path terms, are shown in bold. It can be turned off in Settings.
- **Global shortcut** (default ⌥Space) to show or hide Allofit from any app, with a choice of shortcuts in Settings and a warning when one is already taken.
- **Menu bar icon** with Show Allofit, Settings… and Quit, which can be hidden.
- **Start at login** option.
- **"Search in Allofit" in Finder:** right-click a folder → Quick Actions (or Services). Dropping a folder on the Dock icon, or `open -a Allofit <folder>`, also searches inside it.
- **Keyboard shortcuts** in the results:
| Key | Action |
|---|---|
| Return | Open, or Reveal (choice in Settings) |
| ⌘Return | Reveal in Finder |
| ⌘C | Copy the files |
| ⌥⌘C | Copy the paths |
| ⌘Y or Space | Quick Look |
| ⌘⌫ | Move to Trash |
- **Context menu:** Open With (default app first), Copy, Copy Name, Copy Path, Move to Trash, and Authorize Access… for files you can't read.
- **Freeze the list:** hold ⌥ Option to stop background updates from moving rows while you read or select; releasing it catches up once. The status bar shows "Frozen" meanwhile.
- **Column options:** right-click the column header to show or hide columns. Column order, width and visibility are remembered.
- **Status bar:**
- The last search time, Allofit's memory and CPU use, and the selection count and size ("3 selected (12 MB)").
- Hovering it shows more detail: peak memory, threads, when the index last changed, cache size and roots.
- **Settings → Performance:** how quickly file changes reach the index and the open results, separately for when Allofit is focused (default about 3 s), when another app is focused (about 20 s) and when no window is visible (1 min). Showing a window always applies pending changes at once.
### Fixed
- **High CPU use with a path search open.** An open window searching with a `/` term re-ran the whole search on every file change and could keep several CPU cores busy. Path searches are also about three times faster.
- **Dragging files into a browser** opened the file in place of the web page instead of uploading it. Dragged rows now carry real files, like Finder.
- **Roots under `/tmp`, `/var` or `/etc`** never matched their own entries.
- **Files moved to the Trash from Allofit** came back in the results a moment later; they now leave the index, and ⌘Z puts them back.
- **Interrupted rescans** (quit or crash while a folder was being re-read) could lose that folder's contents until the next full rebuild.
- **Changing roots or exclusions** could drop file changes made at the same moment.
- **Renaming a file by case only** (`Foo` → `foo`) left a ghost entry.
- **Picking a service mode in Settings** took effect before Install, which could leave the app waiting for a service that didn't exist; the mode is now saved only when Install succeeds, and a missing service falls back to the built-in indexer.
- **Long names and paths:** hovering a cut-off name or path for half a second shows the full text over the cell, and it stays while you hover, even when the list refreshes.
### Security
- The "Authorize Access" copy no longer lets root change the owner or permissions of a path the user controls (it could be redirected to take over a system file).
- The background service no longer logs to fixed file names in `/tmp`; logs go to `/Library/Logs/Allofit/` (system service) or `~/Library/Logs/Allofit/` (user service).
- The system service's index, which lists every file name it can see, is readable only by root and the user who installed the service (it was readable by every account). Your own index is now private to you too.
- Installing the system service no longer copies a plist from a temporary file a user process could swap, and always uses the real app binary.
- Corrupt or oversized cache files are rejected instead of being expanded in memory, and two saves can no longer overwrite each other's temporary file.
- The release workflow pins its publishing action to an exact commit, and the app binary no longer searches the Xcode folder for libraries.
### Changed
- **Much less background work:** file changes are batched longer and open results refresh less often while Allofit isn't focused, and the status bar stops sampling when no window is visible.
- **Faster:** sorting a large list by date or size is about 10× faster, selecting rows no longer scans every result, new folders are merged without a pass over the whole index, and the background service saves at most every 15 s instead of every 3 s.
- **First launch:** results appear while the index is being built, an empty list explains why (loading, indexing, no match, filter on), and the macOS privacy prompts explain what Allofit reads.
- **Keyboard:** ↓ moves from the search field to the results and ↑ on the first row comes back; search history moved to ↑ / ⌥↑ / ⌥↓. The right-click menu shows the shortcuts.
- **Help:** a ? button and Help > Search Syntax show the query syntax. Clear Cache and Uninstall ask for confirmation, and Move to Trash reports failures.
- **Lower memory use:** the index is stored in chunks, so search results take 4 bytes per row and a file change copies only a small part of the index.
- **Settings** are regrouped into four tabs: General, Indexes (folders, exclusions, volumes), Performance and Advanced (service, cache, diagnostics).
- **Rebuild Index** moved from ⌘R to ⇧⌘R, so it's harder to trigger by accident.
## [1.0.6] - 2026-10-01
### Fixed
- **Memory blow-up on relaunch.** After the app had been closed for a while, reopening it (or opening its window while it ran in the background) could fill RAM and swap with many gigabytes within seconds. Catching up on file changes made while the app was closed no longer copies the whole index once per changed file. Memory now peaks under 1 GB during a multi-day catch-up and settles around 300 MB for 600k files.
- **Endless catch-up.** The index is now saved as soon as Allofit has caught up on file changes, and again on quit (⌘Q). Before, a launch that never got as far as saving had to replay an ever-growing backlog on every relaunch.
- **Ghost results.** Moving a folder to the Trash, or renaming it, left its whole contents searchable. Now the folder's contents are removed with it, and a folder moved or renamed into an indexed location is scanned.
- **Duplicate results** for files whose names contain accents (for example `Envoyés`).
- **Selection jumping** when a file changed or after a relaunch: each file's identity is now derived from its path, so it stays stable.
- **Flicker during file activity.** The results table and the Quick Look preview no longer redraw every time the index updates.
- **Hidden-folder content** (dot-folders such as `.git` or `.gradle`, and hidden folders at the top of a root) is no longer added by live updates when the initial scan skipped it. Existing entries are cleaned up on the next launch.
- **Excluded or removed paths** are dropped from the index on launch instead of lingering until a full reindex.
- Overlapping roots (one root inside another) are no longer indexed twice.
- A saved cache that can't be resumed (for example after the disk was erased or the cache was copied from another Mac) now triggers a background rescan instead of silently going stale.
- Minor memory leaks in the file-watching code.
### Added
- **Everything-style search syntax:**
| Query | Meaning |
|---|---|
| `annual report` | AND: both words in the name |
| `*.png \| *.jpg` | OR (binds tighter than AND) |
| `report !draft` | NOT |
| `"my file"` | quotes keep spaces inside one term |
| `ext:pdf;docx` | by extension |
| `file:` / `folder:` | files or folders only, alone or as a prefix (`folder:build`) |
| `src/main` | a term with `/` is matched against the full path |
| `"some/folder/**/path" IMG_????.heic` | folder-aware path wildcards: `*` within one folder name, `**` across any number of folders |
| `photos/**/` | a trailing `/` means anything inside that folder |
- Searches ignore case and accents.
- The status bar shows the total number of results, not only the rows listed.
- Changes to roots, exclusions and volume options take effect immediately in built-in mode: new roots are scanned and removed ones dropped, with no reindex needed.
- Command-line service control: `Allofit install|uninstall|start|stop [user|root]`, `Allofit mode <none|user|root>`, `Allofit status`.
- Unit tests for the search syntax and the index logic, run in CI.
### Changed
- **Much faster search.** Results appear almost as you type: the delay after a keystroke dropped from 0.5 s to 0.04 s. Matching is spread across all CPU cores, and only the 2,000 rows shown are sorted instead of every match. A 600k-file index answers typical queries in a few milliseconds.
- Sorting by name is now a fast, case-insensitive alphabetical order. Number-aware ordering (`file2` before `file10`) is no longer used.
- The background service resumes from its saved index on start instead of rescanning every root, and can no longer mistake an interrupted first scan for a complete one.
- The index uses less memory: files in the same folder share one copy of the folder path.
- Build scripts moved to `scripts/`, and build output goes to `outputs/`.
- Added the MIT license.
## [1.0.5] - 2026-06-18
Previous release.
+15 -6
View File
@@ -35,22 +35,31 @@ Same syntax as Everything:
| `"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.
Every match is listed, in the order of the column you click; the filter menu next to the search box narrows to one kind of item, and the ? button shows this syntax in the app. Hidden files and folders (dot-files, `~/Library` when hidden) are not indexed.
## Shortcuts
| Key | Action |
|---|---|
| ⌥Space | Show / hide Allofit from any app (changeable in Settings) |
| ⌘F | Focus search |
| ⌘R | Reindex |
| ⌘, | Preferences |
| ↑ ↓ | Cycle search history |
| ↑ ↓ (in the search field) | Cycle search history |
| Return | Open the selection (or reveal it, see Settings) |
| ⌘Return | Reveal in Finder |
| Space / ⌘Y | Quick Look |
| ⌘C / ⌥⌘C | Copy the files / their paths |
| ⌘⌫ | Move to Trash |
| Hold ⌥ | Freeze the list: no background updates until released |
| ⇧⌘R | Rebuild the index |
| ⌘, | Settings |
Hovering a cut-off name or path for half a second shows it in full. In Finder, right-click a folder → Quick Actions → **Search in Allofit** to search inside it.
## Background service (optional)
**Settings → Service** installs a LaunchAgent (user) or LaunchDaemon (root) that keeps the index updated even when the app is closed.
**Settings → Advanced → Service** installs a LaunchAgent (user) or LaunchDaemon (root) that keeps the index updated even when the app is closed. The chosen mode only takes effect once Install succeeds. Service logs go to `~/Library/Logs/Allofit/` (user) or `/Library/Logs/Allofit/` (root).
Root daemon mode needs **Full Disk Access** granted to its binary in **System Settings → Privacy & Security**, otherwise it won't see new files in `~/Documents`, `~/Desktop`, `~/Downloads`. The Diagnostics tab shows the exact path to add.
Root daemon mode needs **Full Disk Access** granted to its binary in **System Settings → Privacy & Security**, otherwise it won't see new files in `~/Documents`, `~/Desktop`, `~/Downloads`. **Settings → Advanced → Diagnostics** shows the exact path to add. The root daemon's index is readable only by root and the user who installed it.
## Build from source
+56
View File
@@ -103,6 +103,14 @@ echo "==> Assembling ${kAppName}.app"
rm -rf "${vAppBundle}"
mkdir -p "${vMacOSDir}" "${vResourcesDir}"
cp "${vBinarySrc}" "${vMacOSDir}/${kAppName}"
# drop library search paths that point outside the system (the toolchain
# embeds one into /Applications/Xcode.app): the same binary runs as the root
# daemon, which must never look for libraries in an admin-writable folder
while read -r vRpath; do
[[ -z "${vRpath}" ]] && continue
install_name_tool -delete_rpath "${vRpath}" "${vMacOSDir}/${kAppName}"
done < <(otool -l "${vMacOSDir}/${kAppName}" | awk '/cmd LC_RPATH/{getline; getline; print $2}' | grep -v '^/usr/lib' || true)
chmod +x "${vMacOSDir}/${kAppName}"
# ==================
@@ -211,6 +219,54 @@ ${vIconPlistEntry}
<false/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<!-- text shown in the macOS privacy prompts triggered by the first scan -->
<key>NSDesktopFolderUsageDescription</key>
<string>Allofit indexes file names on your Desktop so they show up in searches. File contents are never read.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string>Allofit indexes file names in Documents so they show up in searches. File contents are never read.</string>
<key>NSDownloadsFolderUsageDescription</key>
<string>Allofit indexes file names in Downloads so they show up in searches. File contents are never read.</string>
<key>NSRemovableVolumesUsageDescription</key>
<string>Allofit indexes file names on external drives you choose to include in searches.</string>
<key>NSNetworkVolumesUsageDescription</key>
<string>Allofit indexes file names on network volumes you choose to include in searches.</string>
<!-- Finder > right-click a folder > Services / Quick Actions > Search in Allofit -->
<key>NSServices</key>
<array>
<dict>
<key>NSMenuItem</key>
<dict>
<key>default</key>
<string>Search in Allofit</string>
</dict>
<key>NSMessage</key>
<string>searchInAllofit</string>
<key>NSPortName</key>
<string>${kAppName}</string>
<key>NSRequiredContext</key>
<dict/>
<key>NSSendFileTypes</key>
<array>
<string>public.folder</string>
</array>
</dict>
</array>
<!-- folders dropped on the Dock icon / open -a Allofit <folder> start a search inside them -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Folder</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>None</string>
<key>LSItemContentTypes</key>
<array>
<string>public.folder</string>
</array>
</dict>
</array>
</dict>
</plist>
EOF
+12
View File
@@ -23,8 +23,11 @@ kSystemDaemonPlist="/Library/LaunchDaemons/${kServicePlistName}"
kUserCacheDir="$HOME/Library/Application Support/Allofit"
kSystemCacheDir="/Library/Application Support/Allofit"
kUserPrefsPlist="$HOME/Library/Preferences/${kBundleId}.plist"
# old /tmp log names (versions up to 1.0.9) and the current log folders
kServiceLogStdout="/tmp/allofit-service.log"
kServiceLogStderr="/tmp/allofit-service.err"
kUserLogDir="$HOME/Library/Logs/Allofit"
kSystemLogDir="/Library/Logs/Allofit"
# ==================
# MARK: Args
@@ -169,6 +172,15 @@ if [[ -f "$kServiceLogStdout" || -f "$kServiceLogStderr" ]]; then
fi
done
fi
if [[ -d "$kUserLogDir" ]]; then
echo "==> Removing user service logs"
vRun rm -rf "$kUserLogDir"
fi
if [[ -d "$kSystemLogDir" ]]; then
echo "==> Removing system service logs (needs sudo)"
vNeedsSudo
vRun sudo rm -rf "$kSystemLogDir"
fi
# ==================
# MARK: Build artifacts
+1 -1
View File
@@ -6,6 +6,7 @@ import Foundation
// anything else / no args launch the SwiftUI GUI
@main
struct Main {
// entry point: routes to the GUI, the headless service or the CLI
static func main() {
let vArgs = Array(CommandLine.arguments.dropFirst())
// No args: GUI mode. The Dock and Finder launch us with no args.
@@ -16,7 +17,6 @@ struct Main {
// 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.
+26 -11
View File
@@ -136,6 +136,19 @@ enum FileIndexer {
if inSkipHidden && (vValues.isHidden ?? false) {
return nil
}
// live updates only: on a case-insensitive volume a case-only
// rename (Foo -> foo) still resolves the old spelling, which would
// leave a ghost "Foo" entry. If the name stored on disk differs from
// the reported path by case only, the reported path is gone.
if inSkipHidden, let vStored = try? vUrl.resourceValues(forKeys: [.nameKey]).name {
let vReported = inURL.lastPathComponent
// .nameKey shows a ":" in an HFS name as "/"; compare composed forms
let vOnDisk = vStored.replacingOccurrences(of: "/", with: ":").precomposedStringWithCanonicalMapping
let vAsReported = vReported.precomposedStringWithCanonicalMapping
if vOnDisk != vAsReported && vOnDisk.lowercased() == vAsReported.lowercased() {
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
@@ -149,10 +162,11 @@ enum FileIndexer {
vName = inURL.lastPathComponent
vParentRaw = inURL.deletingLastPathComponent().path
}
let vParent = inInterner?.intern(vParentRaw) ?? vParentRaw
let vParent = inInterner?.intern(vParentRaw)
return FileRecord(
name: vName,
parentPath: vParent,
parentPath: vParent?.path ?? vParentRaw,
parentLower: vParent?.folded,
size: Int64(vValues.fileSize ?? 0),
dateCreated: vValues.creationDate ?? .distantPast,
dateModified: vValues.contentModificationDate ?? .distantPast,
@@ -161,19 +175,20 @@ enum FileIndexer {
}
}
// 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
// PathInterner hands back one shared String instance per distinct value
// (plus its search form), so the thousands of records of a directory share
// a single parentPath / parentLower buffer instead of each holding a copy. Not thread-safe: use one
// per walk / load.
final class PathInterner {
// distinct value -> shared instance
private var table: [String: String] = [:]
// distinct value -> (shared instance, shared search form)
private var table: [String: (path: String, folded: String)] = [:]
// returns the shared instance equal to inString
func intern(_ inString: String) -> String {
// returns the shared instance equal to inString and its search form
func intern(_ inString: String) -> (path: String, folded: String) {
if let vShared = table[inString] { return vShared }
table[inString] = inString
return inString
let vEntry = (path: inString, folded: FileRecord.sharedSearchForm(of: inString))
table[inString] = vEntry
return vEntry
}
}
+28 -15
View File
@@ -25,22 +25,29 @@ struct FileRecord: Identifiable, Hashable, Sendable {
// 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
// lowercased, NFC-normalized parent path for path terms. Interned like
// parentPath when built through a PathInterner, so a folder's entries
// share one copy and path searches do no per-record string work.
let parentLower: 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
// builds a record; inParentLower is the precomputed search form of the
// parent path (computed here when nil)
init(name inName: String,
parentPath inParentPath: String,
parentLower inParentLower: String? = nil,
size inSize: Int64,
dateCreated inDateCreated: Date,
dateModified inDateModified: Date,
isDirectory inIsDirectory: Bool) {
self.id = FileRecord.pathHash(inParent: inParentPath, inName: inName)
self.name = inName
self.parentPath = inParentPath
self.parentLower = inParentLower ?? FileRecord.sharedSearchForm(of: inParentPath)
self.size = inSize
self.dateCreated = inDateCreated
self.dateModified = inDateModified
self.isDirectory = inIsDirectory
self.nameLower = FileRecord.sharedSearchForm(of: inName)
}
// returns the absolute full path computed from parent and name
@@ -63,6 +70,12 @@ struct FileRecord: Identifiable, Hashable, Sendable {
return vLower.precomposedStringWithCanonicalMapping
}
// search form that reuses the input's storage when nothing changes
static func sharedSearchForm(of inString: String) -> String {
let vForm = searchForm(of: inString)
return vForm == inString ? inString : vForm
}
// 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
+1 -12
View File
@@ -23,10 +23,6 @@ struct FSChange {
var mustScanSubDirs: Bool {
return flags & FSEventStreamEventFlags(kFSEventStreamEventFlagMustScanSubDirs) != 0
}
// true when this event references a directory
var isDir: Bool {
return flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemIsDir) != 0
}
}
// FileWatcher streams real-time filesystem change notifications using FSEvents.
@@ -51,18 +47,11 @@ final class FileWatcher: @unchecked Sendable {
// guards stream/handler against concurrent access
private let lock = NSLock()
// stops the stream so its callback never outlives the watcher
deinit {
stop()
}
// returns the latest event id observed by the running stream (or 0)
var latestEventId: UInt64 {
lock.lock()
defer { lock.unlock() }
guard let vStream = stream else { return 0 }
return UInt64(FSEventStreamGetLatestEventId(vStream))
}
// starts watching inRoots, delivering batches of changes to inHandler
// starting from inSinceWhen (defaults to "now")
func start(inRoots: [String],
+23 -15
View File
@@ -35,13 +35,13 @@ struct ResolvedChanges: Sendable {
struct IndexState {
// all indexed entries, unordered
private(set) var records: [FileRecord] = []
// (chunked: snapshots are cheap and a change copies only one chunk)
private(set) var records = RecordStore()
// 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
@@ -57,12 +57,6 @@ struct IndexState {
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
// ===========================
@@ -139,9 +133,10 @@ struct IndexState {
// 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]) {
inRoots: Set<String>) -> (changed: Bool, rescans: [String], newFolders: [String]) {
var vChanged = false
var vRescans: [String] = []
var vNewFolders: [String] = []
// removals first: a rename shows up as old path gone + new path present
if !inChanges.removals.isEmpty {
@@ -157,7 +152,7 @@ struct IndexState {
let vIsNew = positions[vRecord.id] == nil
if upsert(vRecord) { vChanged = true }
if vIsNew && vRecord.isDirectory {
vRescans.append(vRecord.fullPath)
vNewFolders.append(vRecord.fullPath)
}
}
@@ -165,7 +160,23 @@ struct IndexState {
for vPath in inChanges.rescans where inRoots.contains(vPath) || contains(inPath: vPath) {
vRescans.append(vPath)
}
return (vChanged, SubtreeMatcher.minimalRoots(inPaths: vRescans))
let vMinimalRescans = SubtreeMatcher.minimalRoots(inPaths: vRescans)
// a new folder inside a kernel rescan is walked by that rescan
let vCovered = SubtreeMatcher(inRoots: vMinimalRescans)
let vFolders = SubtreeMatcher.minimalRoots(inPaths: vNewFolders).filter { vFolder in
!vMinimalRescans.contains(vFolder) && !vCovered.containsChild(inParentPath: (vFolder as NSString).deletingLastPathComponent)
}
return (vChanged, vMinimalRescans, vFolders)
}
// adds the records found by walking folders that just appeared. Nothing
// below such a folder can be indexed yet (except entries upserted from
// the same FSEvents batch, which upsert replaces), so unlike
// replaceSubtrees no O(n) removal pass over the whole index is needed.
mutating func mergeNewFolders(inRecords: [FileRecord]) {
for vRecord in inRecords {
upsert(vRecord)
}
}
// drops records that fall outside the current configuration: excluded
@@ -215,10 +226,7 @@ struct IndexState {
// 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.records = RecordStore(records)
vResult.positions = Dictionary(minimumCapacity: positions.count)
for (vKey, vValue) in positions {
vResult.positions[vKey] = vValue
+115 -64
View File
@@ -49,14 +49,11 @@ enum IndexStore {
in: .userDomainMask
).first!
}
let vDir = vBase.appendingPathComponent("Allofit", isDirectory: true)
try? FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
// for the system-wide path the directory may have been created by the
// root daemon - make sure non-root users (the GUI) can traverse it
if inSystem {
chmod(vDir.path, 0o755)
}
return vDir.appendingPathComponent("index.bin")
// pure path computation (no disk access: it's called from views);
// saveImpl creates the folder when it writes
return vBase
.appendingPathComponent("Allofit", isDirectory: true)
.appendingPathComponent("index.bin")
}
// returns the URL appropriate for a given service mode
@@ -87,7 +84,7 @@ enum IndexStore {
// ===========================
// writes the records and event id atomically to the default cacheURL
static func save(inRecords: [FileRecord], inLastEventId: UInt64) {
static func save(inRecords: some Collection<FileRecord>, inLastEventId: UInt64) {
save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL)
}
@@ -96,13 +93,14 @@ enum IndexStore {
// 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) {
static func save(inRecords: some Collection<FileRecord>, inLastEventId: UInt64, to inUrl: URL) {
autoreleasepool {
save_impl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
saveImpl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
}
}
private static func save_impl(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
// encodes, LZ4-compresses and atomically writes the cache (body of save)
private static func saveImpl(inRecords: some Collection<FileRecord>, inLastEventId: UInt64, to inUrl: URL) {
var vPayload = Data()
vPayload.reserveCapacity(16 + inRecords.count * 80)
writeU64(into: &vPayload, value: inLastEventId)
@@ -136,26 +134,51 @@ enum IndexStore {
writeU64(into: &vEnvelope, value: vUncompressedSize)
vEnvelope.append(vCompressed)
let vTmp = inUrl.appendingPathExtension("tmp")
// the folder may not exist yet (first save). The system-wide one must
// stay traversable by the GUI user; the index inside is owner-only
// (see restrictAccess)
let vDir = inUrl.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
if getuid() == 0 {
chmod(vDir.path, 0o755)
}
// unique per save: two saves running at once (reindex and autosave
// on different queues) must not write the same temp file
let vTmp = inUrl.deletingLastPathComponent()
.appendingPathComponent(".\(inUrl.lastPathComponent).\(UUID().uuidString).tmp")
do {
try vEnvelope.write(to: vTmp, options: .atomic)
_ = try FileManager.default.replaceItem(
at: inUrl,
withItemAt: vTmp,
backupItemName: nil,
options: [],
resultingItemURL: nil
)
// the root daemon writes this file owned by root. The GUI runs
// as the user and must be able to read it, so force world-read
// permissions regardless of the process's umask.
chmod(inUrl.path, 0o644)
try vEnvelope.write(to: vTmp)
// owner-only access, set on the temp file *before* it becomes
// the cache, so the index (a listing of every file name) is
// never readable by other local users, even for a moment
restrictAccess(inPath: vTmp.path)
// rename(2) is atomic and keeps the temp file's owner and mode
// (FileManager.replaceItem may carry over the old file's)
guard rename(vTmp.path, inUrl.path) == 0 else {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
} catch {
NSLog("[Allofit] cache save failed at %@: %@", inUrl.path, "\(error)")
try? FileManager.default.removeItem(at: vTmp)
}
}
// makes a cache file readable by its owner only. The root daemon hands
// it to the user who installed the service (the GUI reads it as that
// user); everyone else gets nothing.
private static func restrictAccess(inPath: String) {
if getuid() == 0,
let vOwner = ProcessInfo.processInfo.environment["ALLOFIT_OWNER_USER"],
let vEntry = getpwnam(vOwner) {
chown(inPath, vEntry.pointee.pw_uid, vEntry.pointee.pw_gid)
}
chmod(inPath, 0o600)
}
// largest uncompressed payload accepted from a cache file (a corrupt or
// crafted header must not make the loader allocate without bound)
private static let kMaxPayloadBytes: UInt64 = 8 << 30
// reads the persisted index back from the default cacheURL
static func load() -> LoadResult? {
return load(from: cacheURL)
@@ -166,16 +189,18 @@ enum IndexStore {
// String allocations don't linger in the caller's pool.
static func load(from inUrl: URL) -> LoadResult? {
return autoreleasepool {
load_impl(from: inUrl)
loadImpl(from: inUrl)
}
}
private static func load_impl(from inUrl: URL) -> LoadResult? {
// reads, decompresses and decodes the cache (body of load)
private static func loadImpl(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 }
guard let vVersion = readU32(from: vData, offset: &vOffset), vVersion == kVersion else { return nil }
guard let _ = readU64(from: vData, offset: &vOffset) else { return nil } // uncompressed size, advisory only
guard let vDeclaredSize = readU64(from: vData, offset: &vOffset),
vDeclaredSize <= kMaxPayloadBytes else { return nil }
let vCompressed = vData.subdata(in: vOffset..<vData.count)
let vPayload: Data
@@ -185,6 +210,7 @@ enum IndexStore {
NSLog("[Allofit] LZ4 decompression failed: \(error)")
return nil
}
guard UInt64(vPayload.count) == vDeclaredSize else { return nil }
var vP = 0
guard let vLastEventId = readU64(from: vPayload, offset: &vP) else { return nil }
@@ -209,9 +235,11 @@ enum IndexStore {
else {
return nil
}
let vShared = vInterner.intern(vParent)
vRecords.append(FileRecord(
name: vName,
parentPath: vInterner.intern(vParent),
parentPath: vShared.path,
parentLower: vShared.folded,
size: vSize,
dateCreated: Date(timeIntervalSince1970: vCreated),
dateModified: Date(timeIntervalSince1970: vModified),
@@ -225,71 +253,94 @@ enum IndexStore {
// MARK: Binary helpers
// ===========================
private static func writeU32(into ioData: inout Data, value: UInt32) {
var vV = value.littleEndian
// appends a little-endian UInt32
private static func writeU32(into ioData: inout Data, value inValue: UInt32) {
var vV = inValue.littleEndian
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
}
private static func writeU64(into ioData: inout Data, value: UInt64) {
var vV = value.littleEndian
// appends a little-endian UInt64
private static func writeU64(into ioData: inout Data, value inValue: UInt64) {
var vV = inValue.littleEndian
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
}
private static func writeI64(into ioData: inout Data, value: Int64) {
var vV = value.littleEndian
// appends a little-endian Int64
private static func writeI64(into ioData: inout Data, value inValue: Int64) {
var vV = inValue.littleEndian
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
}
private static func writeF64(into ioData: inout Data, value: Double) {
var vV = value.bitPattern.littleEndian
// appends a Double as its little-endian bit pattern
private static func writeF64(into ioData: inout Data, value inValue: Double) {
var vV = inValue.bitPattern.littleEndian
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
}
private static func writeString(into ioData: inout Data, value: String) {
let vBytes = Array(value.utf8)
// appends a UInt32 byte length followed by the UTF-8 bytes
private static func writeString(into ioData: inout Data, value inValue: String) {
let vBytes = Array(inValue.utf8)
writeU32(into: &ioData, value: UInt32(vBytes.count))
ioData.append(contentsOf: vBytes)
}
private static func readU8(from inData: Data, offset: inout Int) -> UInt8? {
guard offset + 1 <= inData.count else { return nil }
let vV = inData[inData.startIndex + offset]
offset += 1
// reads one byte at ioOffset and advances it; nil past the end
private static func readU8(from inData: Data, offset ioOffset: inout Int) -> UInt8? {
guard ioOffset + 1 <= inData.count else { return nil }
let vV = inData[inData.startIndex + ioOffset]
ioOffset += 1
return vV
}
private static func readU32(from inData: Data, offset: inout Int) -> UInt32? {
guard offset + 4 <= inData.count else { return nil }
// reads a little-endian UInt32 at ioOffset and advances it
private static func readU32(from inData: Data, offset ioOffset: inout Int) -> UInt32? {
guard ioOffset + 4 <= inData.count else { return nil }
let vOffset = ioOffset
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> UInt32 in
vPtr.loadUnaligned(fromByteOffset: offset, as: UInt32.self).littleEndian
vPtr.loadUnaligned(fromByteOffset: vOffset, as: UInt32.self).littleEndian
}
offset += 4
ioOffset += 4
return vV
}
private static func readU64(from inData: Data, offset: inout Int) -> UInt64? {
guard offset + 8 <= inData.count else { return nil }
// reads a little-endian UInt64 at ioOffset and advances it
private static func readU64(from inData: Data, offset ioOffset: inout Int) -> UInt64? {
guard ioOffset + 8 <= inData.count else { return nil }
let vOffset = ioOffset
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> UInt64 in
vPtr.loadUnaligned(fromByteOffset: offset, as: UInt64.self).littleEndian
vPtr.loadUnaligned(fromByteOffset: vOffset, as: UInt64.self).littleEndian
}
offset += 8
ioOffset += 8
return vV
}
private static func readI64(from inData: Data, offset: inout Int) -> Int64? {
guard offset + 8 <= inData.count else { return nil }
// reads a little-endian Int64 at ioOffset and advances it
private static func readI64(from inData: Data, offset ioOffset: inout Int) -> Int64? {
guard ioOffset + 8 <= inData.count else { return nil }
let vOffset = ioOffset
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> Int64 in
vPtr.loadUnaligned(fromByteOffset: offset, as: Int64.self).littleEndian
vPtr.loadUnaligned(fromByteOffset: vOffset, as: Int64.self).littleEndian
}
offset += 8
ioOffset += 8
return vV
}
private static func readF64(from inData: Data, offset: inout Int) -> Double? {
guard let vBits = readU64(from: inData, offset: &offset) else { return nil }
// reads a Double stored as its little-endian bit pattern
private static func readF64(from inData: Data, offset ioOffset: inout Int) -> Double? {
guard let vBits = readU64(from: inData, offset: &ioOffset) else { return nil }
return Double(bitPattern: vBits)
}
private static func readString(from inData: Data, offset: inout Int) -> String? {
guard let vLen = readU32(from: inData, offset: &offset) else { return nil }
// reads a UInt32-length-prefixed UTF-8 string and advances ioOffset
private static func readString(from inData: Data, offset ioOffset: inout Int) -> String? {
guard let vLen = readU32(from: inData, offset: &ioOffset) else { return nil }
let vLength = Int(vLen)
guard offset + vLength <= inData.count else { return nil }
let vStart = inData.startIndex + offset
let vEnd = vStart + vLength
offset += vLength
guard ioOffset + vLength <= inData.count else { return nil }
let vStart = ioOffset
ioOffset += vLength
// 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)
String(decoding: vPtr[vStart..<(vStart + vLength)], as: UTF8.self)
}
}
}
+6 -4
View File
@@ -18,10 +18,12 @@ final class IndexerLock {
// open descriptor, -1 when not locked
private var fileDescriptor: Int32 = -1
init(path: String) {
self.path = path
// creates an unlocked handle for the sentinel file at inPath
init(path inPath: String) {
self.path = inPath
}
// releases the lock together with the handle
deinit {
unlock()
}
@@ -74,8 +76,8 @@ final class IndexerLock {
}
// reads the pid currently holding the lock, if any (best-effort)
static func readHolderPid(path: String) -> Int32? {
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: path)),
static func readHolderPid(path inPath: String) -> Int32? {
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: inPath)),
let vStr = String(data: vData, encoding: .utf8),
let vPid = Int32(vStr.trimmingCharacters(in: .whitespacesAndNewlines))
else { return nil }
+96 -6
View File
@@ -39,15 +39,72 @@ final class Preferences: ObservableObject, @unchecked Sendable {
@Published var serviceMode: ServiceMode {
didSet { UserDefaults.standard.set(serviceMode.rawValue, forKey: Self.kServiceModeKey) }
}
// last search query, restored on next launch
@Published var lastQuery: String {
didSet { UserDefaults.standard.set(lastQuery, forKey: Self.kLastQueryKey) }
}
// last sort descriptor used, restored on next launch
@Published var lastSort: FileSortDescriptor {
didSet { UserDefaults.standard.set(lastSort.rawValue, forKey: Self.kLastSortKey) }
}
// seconds file changes are collected before being applied to the index,
// while Allofit is focused / another app is focused / no window shows
@Published var updateDelayForeground: Double {
didSet { UserDefaults.standard.set(updateDelayForeground, forKey: Self.kUpdateDelayForegroundKey) }
}
@Published var updateDelayBackground: Double {
didSet { UserDefaults.standard.set(updateDelayBackground, forKey: Self.kUpdateDelayBackgroundKey) }
}
@Published var updateDelayHidden: Double {
didSet { UserDefaults.standard.set(updateDelayHidden, forKey: Self.kUpdateDelayHiddenKey) }
}
// minimum seconds between two result refreshes caused by index changes,
// while Allofit is focused / another app is focused
@Published var refreshIntervalForeground: Double {
didSet { UserDefaults.standard.set(refreshIntervalForeground, forKey: Self.kRefreshForegroundKey) }
}
@Published var refreshIntervalBackground: Double {
didSet { UserDefaults.standard.set(refreshIntervalBackground, forKey: Self.kRefreshBackgroundKey) }
}
// true to emphasize the matched parts of names and paths in the results
@Published var highlightMatches: Bool {
didSet { UserDefaults.standard.set(highlightMatches, forKey: Self.kHighlightMatchesKey) }
}
// what a double-click / Return on a result does
@Published var primaryAction: PrimaryAction {
didSet { UserDefaults.standard.set(primaryAction.rawValue, forKey: Self.kPrimaryActionKey) }
}
// true to show the Allofit icon in the menu bar
@Published var showMenuBarIcon: Bool {
didSet { UserDefaults.standard.set(showMenuBarIcon, forKey: Self.kShowMenuBarIconKey) }
}
// system-wide shortcut that shows / hides the main window
@Published var globalHotKey: HotKeyPreset {
didSet { UserDefaults.standard.set(globalHotKey.rawValue, forKey: Self.kGlobalHotKeyKey) }
}
// actions available for double-click / Return on a result
enum PrimaryAction: String, CaseIterable, Identifiable {
case open // open with the default app
case reveal // reveal in Finder
var id: String { rawValue }
}
// default values of the update / refresh timings, in seconds
static let kDefaultUpdateDelayForeground: Double = 2
static let kDefaultUpdateDelayBackground: Double = 15
static let kDefaultUpdateDelayHidden: Double = 60
static let kDefaultRefreshForeground: Double = 1
static let kDefaultRefreshBackground: Double = 5
// puts every update / refresh timing back to its default
func resetTimings() {
updateDelayForeground = Self.kDefaultUpdateDelayForeground
updateDelayBackground = Self.kDefaultUpdateDelayBackground
updateDelayHidden = Self.kDefaultUpdateDelayHidden
refreshIntervalForeground = Self.kDefaultRefreshForeground
refreshIntervalBackground = Self.kDefaultRefreshBackground
}
// service installation modes
enum ServiceMode: String, CaseIterable, Identifiable {
case none // no service: GUI indexes in its own process
@@ -61,8 +118,16 @@ final class Preferences: ObservableObject, @unchecked Sendable {
private static let kMountedKey = "Allofit.includeMountedVolumes"
private static let kNetworkKey = "Allofit.includeNetworkVolumes"
private static let kServiceModeKey = "Allofit.serviceMode"
private static let kLastQueryKey = "Allofit.lastQuery"
private static let kLastSortKey = "Allofit.lastSort"
private static let kHighlightMatchesKey = "Allofit.highlightMatches"
private static let kShowMenuBarIconKey = "Allofit.showMenuBarIcon"
private static let kGlobalHotKeyKey = "Allofit.globalHotKey"
private static let kPrimaryActionKey = "Allofit.primaryAction"
private static let kUpdateDelayForegroundKey = "Allofit.updateDelayForeground"
private static let kUpdateDelayBackgroundKey = "Allofit.updateDelayBackground"
private static let kUpdateDelayHiddenKey = "Allofit.updateDelayHidden"
private static let kRefreshForegroundKey = "Allofit.refreshIntervalForeground"
private static let kRefreshBackgroundKey = "Allofit.refreshIntervalBackground"
// flushes pending UserDefaults writes to disk so the daemon (which reads
// the plist file directly) picks up the latest settings on next start
@@ -70,6 +135,7 @@ final class Preferences: ObservableObject, @unchecked Sendable {
UserDefaults.standard.synchronize()
}
// loads every setting, from the owner's plist when running as the root daemon
private init() {
// When we are the root daemon, the standard UserDefaults points at
// /var/root/Library/Preferences/... which is *not* where the GUI user
@@ -88,13 +154,28 @@ final class Preferences: ObservableObject, @unchecked Sendable {
} else {
serviceMode = .none
}
lastQuery = Self.readString(forKey: Self.kLastQueryKey, from: vSourceDict) ?? ""
if let vRaw = Self.readString(forKey: Self.kLastSortKey, from: vSourceDict),
let vSort = FileSortDescriptor(rawValue: vRaw) {
lastSort = vSort
} else {
lastSort = .nameAscending
}
highlightMatches = Self.readBool(forKey: Self.kHighlightMatchesKey, from: vSourceDict) ?? true
showMenuBarIcon = Self.readBool(forKey: Self.kShowMenuBarIconKey, from: vSourceDict) ?? true
globalHotKey = Self.readString(forKey: Self.kGlobalHotKeyKey, from: vSourceDict)
.flatMap(HotKeyPreset.init(rawValue:)) ?? .optionSpace
primaryAction = Self.readString(forKey: Self.kPrimaryActionKey, from: vSourceDict)
.flatMap(PrimaryAction.init(rawValue:)) ?? .open
updateDelayForeground = Self.readDouble(forKey: Self.kUpdateDelayForegroundKey, from: vSourceDict)
?? Self.kDefaultUpdateDelayForeground
updateDelayBackground = Self.readDouble(forKey: Self.kUpdateDelayBackgroundKey, from: vSourceDict)
?? Self.kDefaultUpdateDelayBackground
updateDelayHidden = Self.readDouble(forKey: Self.kUpdateDelayHiddenKey, from: vSourceDict)
?? Self.kDefaultUpdateDelayHidden
refreshIntervalForeground = Self.readDouble(forKey: Self.kRefreshForegroundKey, from: vSourceDict)
?? Self.kDefaultRefreshForeground
refreshIntervalBackground = Self.readDouble(forKey: Self.kRefreshBackgroundKey, from: vSourceDict)
?? Self.kDefaultRefreshBackground
}
// ===========================
@@ -133,6 +214,11 @@ final class Preferences: ObservableObject, @unchecked Sendable {
if let vDict = inDict { return vDict[inKey] as? Bool }
return UserDefaults.standard.object(forKey: inKey) as? Bool
}
// reads a number from either the supplied plist dict or UserDefaults
private static func readDouble(forKey inKey: String, from inDict: [String: Any]?) -> Double? {
if let vDict = inDict { return (vDict[inKey] as? NSNumber)?.doubleValue }
return (UserDefaults.standard.object(forKey: inKey) as? NSNumber)?.doubleValue
}
// reads a string from either the supplied plist dict or UserDefaults
private static func readString(forKey inKey: String, from inDict: [String: Any]?) -> String? {
if let vDict = inDict { return vDict[inKey] as? String }
@@ -153,6 +239,7 @@ final class Preferences: ObservableObject, @unchecked Sendable {
return [FileManager.default.homeDirectoryForCurrentUser.path]
}
// default exclusions: system noise for the daemon, caches and trash for a user
private static func defaultExcludedPaths() -> [String] {
if ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1" {
return [
@@ -170,6 +257,9 @@ final class Preferences: ObservableObject, @unchecked Sendable {
return [
"~/Library/Caches",
"~/Library/Containers",
// other apps' shared data: reading it triggers macOS 15's
// "access data from other apps" prompt
"~/Library/Group Containers",
"~/.Trash",
"/private/var/folders"
].map { ($0 as NSString).expandingTildeInPath }
+109
View File
@@ -0,0 +1,109 @@
import Foundation
// RecordStore holds the index's records in fixed-size chunks instead of one
// flat array. Copies (snapshots for searches and result lists) only share
// chunk buffers, and a mutation copies just the chunk it touches: changing
// one record while a snapshot is alive costs ~1.6 MB instead of the whole
// 60+ MB index. That lets every window keep its results as plain positions
// into a snapshot, at 4 bytes per row.
struct RecordStore: RandomAccessCollection, MutableCollection, Sendable {
// records per chunk: 2^kShift
static let kShift = 14
// chunk size and index mask derived from kShift
static let kChunkSize = 1 << kShift
static let kMask = kChunkSize - 1
// the records, kChunkSize per chunk (the last one may be shorter)
private var chunks: [[FileRecord]] = []
// total number of records
private(set) var count: Int = 0
// empty store
init() {}
// store holding inRecords, in order
init<S: Sequence>(_ inRecords: S) where S.Element == FileRecord {
for vRecord in inRecords {
append(vRecord)
}
}
// first index (always 0)
var startIndex: Int { 0 }
// one past the last index
var endIndex: Int { count }
// record at inPosition
subscript(inPosition: Int) -> FileRecord {
get { chunks[inPosition >> Self.kShift][inPosition & Self.kMask] }
set { chunks[inPosition >> Self.kShift][inPosition & Self.kMask] = newValue }
}
// number of chunks
var chunkCount: Int { chunks.count }
// the records of chunk inIndex (store positions inIndex << kShift and
// up). Hot loops walk chunks to read records with plain array access.
func chunk(at inIndex: Int) -> [FileRecord] {
return chunks[inIndex]
}
// adds a record at the end
mutating func append(_ inRecord: FileRecord) {
if chunks.isEmpty || chunks[chunks.count - 1].count == Self.kChunkSize {
var vChunk: [FileRecord] = []
vChunk.reserveCapacity(Self.kChunkSize)
chunks.append(vChunk)
}
chunks[chunks.count - 1].append(inRecord)
count += 1
}
// removes the last record
mutating func removeLast() {
chunks[chunks.count - 1].removeLast()
count -= 1
if chunks[chunks.count - 1].isEmpty {
chunks.removeLast()
}
}
}
// ResultList is one window's search result: positions into a snapshot of
// the index, in display order. Cheap to keep around (4 bytes per row) and
// immune to later index changes, since the snapshot doesn't move.
struct ResultList: RandomAccessCollection, Sendable {
// the index as it was when the search ran
let store: RecordStore
// positions of the matching records in store, sorted for display
let positions: [Int32]
// empty result
init() {
store = RecordStore()
positions = []
}
// result for the given snapshot and positions
init(inStore: RecordStore, inPositions: [Int32]) {
store = inStore
positions = inPositions
}
// first index (always 0)
var startIndex: Int { 0 }
// number of results
var endIndex: Int { positions.count }
// record shown at inRow
subscript(inRow: Int) -> FileRecord {
return store[Int(positions[inRow])]
}
// the same list without the records whose ids are in inIds
func removing(inIds: Set<FileRecord.ID>) -> ResultList {
return ResultList(inStore: store, inPositions: positions.filter { !inIds.contains(store[Int($0)].id) })
}
}
+57 -9
View File
@@ -1,17 +1,28 @@
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.
// ResultSorter orders the matching positions of a search. The full order of
// a large result takes a while (~0.2 s for 600k entries), so the first rows
// can be computed on their own: a bounded max-heap of the best N candidates
// is O(m log N), about one comparison per match once the heap is warm, which
// lets the top of the list appear before the complete sort finishes.
enum ResultSorter {
// returns the first inLimit records of inPositions, ordered by inDescriptor
static func top(inRecords: [FileRecord],
static func top<C: RandomAccessCollection>(inRecords: C,
inPositions: [Int32],
inLimit: Int,
inDescriptor: FileSortDescriptor) -> [FileRecord] {
inDescriptor: FileSortDescriptor) -> [FileRecord]
where C.Index == Int, C.Element == FileRecord {
return topPositions(inRecords: inRecords, inPositions: inPositions, inLimit: inLimit, inDescriptor: inDescriptor)
.map { inRecords[Int($0)] }
}
// same as top, returning positions into inRecords instead of copies
static func topPositions<C: RandomAccessCollection>(inRecords: C,
inPositions: [Int32],
inLimit: Int,
inDescriptor: FileSortDescriptor) -> [Int32]
where C.Index == Int, C.Element == 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
@@ -23,7 +34,15 @@ enum ResultSorter {
}
if inPositions.count <= inLimit {
return inPositions.sorted(by: vBefore).map { inRecords[Int($0)] }
switch inDescriptor {
case .nameAscending, .nameDescending, .pathAscending, .pathDescending:
// string keys: comparing in place is as fast or faster
// (moving tuples of strings during the sort costs more)
return inPositions.sorted(by: vBefore)
default:
// numeric keys: ~10x faster sorted as a compact key array
return sortedByKey(inRecords: inRecords, inPositions: inPositions, inDescriptor: inDescriptor)
}
}
// max-heap (worst candidate on top) of the best inLimit seen so far
@@ -49,7 +68,36 @@ enum ResultSorter {
vHeap[0] = vPos
siftDown(0)
}
return vHeap.sorted(by: vBefore).map { inRecords[Int($0)] }
return vHeap.sorted(by: vBefore)
}
// complete sort by size or date. Each record's key is read once into a
// compact array, which is then sorted: comparing through the records
// copies two whole FileRecords (with their strings' retain / release)
// per comparison - 539 ms vs 47 ms for 630k entries by date. Ties are
// broken by id, matching the top-N path.
private static func sortedByKey<C: RandomAccessCollection>(inRecords: C,
inPositions: [Int32],
inDescriptor: FileSortDescriptor) -> [Int32]
where C.Index == Int, C.Element == FileRecord {
let vDescending = inDescriptor == .sizeDescending
|| inDescriptor == .createdDescending
|| inDescriptor == .modifiedDescending
var vKeys = inPositions.map { vPos -> (Double, UInt64, Int32) in
let vRecord = inRecords[Int(vPos)]
let vKey: Double
switch inDescriptor {
case .sizeAscending, .sizeDescending: vKey = Double(vRecord.size)
case .createdAscending, .createdDescending: vKey = vRecord.dateCreated.timeIntervalSinceReferenceDate
default: vKey = vRecord.dateModified.timeIntervalSinceReferenceDate
}
return (vKey, vRecord.id, vPos)
}
vKeys.sort { vA, vB in
if vA.0 != vB.0 { return vDescending ? vA.0 > vB.0 : vA.0 < vB.0 }
return vA.1 < vB.1
}
return vKeys.map(\.2)
}
// primary ordering for each sort mode. Names compare on the folded form
+119 -9
View File
@@ -46,6 +46,18 @@ struct SearchEngine: Sendable {
let requiresDirectory: Bool?
// true when the term was prefixed with "!"
let negated: Bool
// the term's text as typed (no "!" / modifier prefix), used to
// emphasize matches in the results; empty for ext: and bare modifiers
var text: String = ""
}
// literal pieces of the query to emphasize in the results, matched
// case- and accent-insensitively against the displayed name / folder
struct Highlights: Equatable, Sendable {
// pieces to emphasize in names
var name: [String] = []
// pieces to emphasize in folder paths
var path: [String] = []
}
// AND of OR-groups: every group must have at least one matching term
@@ -68,8 +80,8 @@ struct SearchEngine: Sendable {
}
vJoinNext = false
}
// cheap name-only groups first: path terms build a lowercased
// full path per record, so they only run on records that survived
// cheap name-only groups first: path terms scan the folder path
// too, so they only run on records that survived the name terms
self.groups = vGroups.sorted { vA, vB in
!vA.contains(where: { $0.matchesPath }) && vB.contains(where: { $0.matchesPath })
}
@@ -78,6 +90,25 @@ struct SearchEngine: Sendable {
// true when this engine has any pattern that will filter results
var isActive: Bool { !groups.isEmpty }
// what to emphasize in the results: the literal runs of every positive
// name / path term (wildcards and folder separators split the runs)
var highlights: Highlights {
var vResult = Highlights()
for vTerm in groups.joined() where !vTerm.negated && !vTerm.text.isEmpty {
let vSeparators: Set<Character> = vTerm.matchesPath ? ["*", "?", "/"] : ["*", "?"]
let vPieces = vTerm.text
.split(whereSeparator: { vSeparators.contains($0) })
.map(String.init)
.filter { !$0.isEmpty }
if vTerm.matchesPath {
vResult.path.append(contentsOf: vPieces)
} else {
vResult.name.append(contentsOf: vPieces)
}
}
return vResult
}
// returns true if the record satisfies every AND-group of the query
func match(inRecord: FileRecord) -> Bool {
for vGroup in groups {
@@ -179,7 +210,8 @@ struct SearchEngine: Sendable {
pattern: vPattern,
matchesPath: vIsPath,
requiresDirectory: vRequiresDir,
negated: vNegated
negated: vNegated,
text: String(vText)
)
}
@@ -194,8 +226,7 @@ struct SearchEngine: Sendable {
vResult = false
} else if let vPattern = inTerm.pattern {
if inTerm.matchesPath {
let vPath = FileRecord.searchForm(of: inRecord.fullPath)
vResult = matches(inPattern: vPattern, inTarget: vPath)
vResult = matchesPath(inPattern: vPattern, inFoldedParent: inRecord.parentLower, inFoldedName: inRecord.nameLower)
} else {
vResult = matches(inPattern: vPattern, inTarget: inRecord.nameLower)
}
@@ -203,7 +234,7 @@ struct SearchEngine: Sendable {
return inTerm.negated ? !vResult : vResult
}
// runs a pattern against an already case-folded target string
// runs a pattern against an already case-folded name
private static func matches(inPattern: Pattern, inTarget: String) -> Bool {
var vTarget = inTarget
return vTarget.withUTF8 { vBytes in
@@ -213,13 +244,35 @@ struct SearchEngine: Sendable {
case .glob(let vGlob):
return vGlob.withUnsafeBufferPointer { globMatch(inPattern: $0, inText: vBytes) }
case .pathGlob(let vGlob):
return vGlob.withUnsafeBufferPointer { pathGlobMatch(inPattern: $0, inText: vBytes) }
return vGlob.withUnsafeBufferPointer {
pathGlobMatch(inPattern: $0, inText: JoinedBytes(inHead: UnsafeBufferPointer(start: nil, count: 0), inTail: vBytes))
}
case .extensions(let vExts):
return hasExtension(inName: vBytes, inExtensions: vExts)
}
}
}
// runs a path pattern against folder + "/" + name without building the
// joined string (both parts already case-folded)
private static func matchesPath(inPattern: Pattern, inFoldedParent: String, inFoldedName: String) -> Bool {
var vName = inFoldedName
var vParent = inFoldedParent
return vName.withUTF8 { vNameBytes in
vParent.withUTF8 { vParentBytes in
let vText = JoinedBytes(inHead: vParentBytes, inTail: vNameBytes)
switch inPattern {
case .substring(let vNeedle):
return joinedContains(inText: vText, inNeedle: vNeedle)
case .pathGlob(let vGlob), .glob(let vGlob):
return vGlob.withUnsafeBufferPointer { pathGlobMatch(inPattern: $0, inText: vText) }
case .extensions(let vExts):
return hasExtension(inName: vNameBytes, inExtensions: vExts)
}
}
}
}
// substring search over raw bytes
private static func containsBytes(inHaystack: UnsafeBufferPointer<UInt8>, inNeedle: [UInt8]) -> Bool {
if inNeedle.isEmpty { return true }
@@ -229,6 +282,23 @@ struct SearchEngine: Sendable {
}
}
// substring search over folder + "/" + name: inside the folder part,
// inside the name, or straddling the "/" between them
private static func joinedContains(inText: JoinedBytes, inNeedle: [UInt8]) -> Bool {
if containsBytes(inHaystack: inText.head, inNeedle: inNeedle) { return true }
if containsBytes(inHaystack: inText.tail, inNeedle: inNeedle) { return true }
if inNeedle.count < 2 || inNeedle.count > inText.count { return inNeedle.count == 1 && inText.hasSeparator && inNeedle[0] == UInt8(ascii: "/") }
// window around the junction: up to needle-1 bytes on each side
let vStart = max(0, inText.head.count - (inNeedle.count - 1))
let vEnd = min(inText.count, inText.head.count + (inText.hasSeparator ? 1 : 0) + (inNeedle.count - 1))
return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: vEnd - vStart) { vWindow in
for vI in vStart..<vEnd {
vWindow[vI - vStart] = inText[vI]
}
return containsBytes(inHaystack: UnsafeBufferPointer(vWindow), inNeedle: inNeedle)
}
}
// 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 }
@@ -246,6 +316,13 @@ struct SearchEngine: Sendable {
return vI
}
// same as nextScalar, over a joined folder + name
private static func nextScalar(in inText: JoinedBytes, 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 {
@@ -282,7 +359,7 @@ struct SearchEngine: Sendable {
// (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 {
private static func pathGlobMatch(inPattern: UnsafeBufferPointer<UInt8>, inText: JoinedBytes) -> Bool {
let kSlash = UInt8(ascii: "/")
if inPattern.first == kSlash {
return pathGlobFrom(inPattern: inPattern, inP: 0, inText: inText, inT: 0)
@@ -297,7 +374,7 @@ struct SearchEngine: Sendable {
// inP and text index inT
private static func pathGlobFrom(inPattern: UnsafeBufferPointer<UInt8>,
inP: Int,
inText: UnsafeBufferPointer<UInt8>,
inText: JoinedBytes,
inT: Int) -> Bool {
let kSlash = UInt8(ascii: "/")
let kStar = UInt8(ascii: "*")
@@ -343,3 +420,36 @@ struct SearchEngine: Sendable {
return vT == inText.count || inText[vT] == kSlash || (vP > 0 && inPattern[vP - 1] == kSlash)
}
}
// JoinedBytes presents folder bytes + "/" + name bytes as one read-only
// byte sequence, so path matching never allocates the joined path.
private struct JoinedBytes {
// case-folded folder path ("" for none, "/" for the root)
let head: UnsafeBufferPointer<UInt8>
// case-folded name
let tail: UnsafeBufferPointer<UInt8>
// true when a "/" separates head and tail (not after "" or "/")
let hasSeparator: Bool
// total length including the separator
let count: Int
// joins a folder and a name
init(inHead: UnsafeBufferPointer<UInt8>, inTail: UnsafeBufferPointer<UInt8>) {
head = inHead
tail = inTail
hasSeparator = !inHead.isEmpty && !(inHead.count == 1 && inHead[0] == UInt8(ascii: "/"))
count = inHead.count + (hasSeparator ? 1 : 0) + inTail.count
}
// byte at inIndex of the joined sequence
subscript(inIndex: Int) -> UInt8 {
if inIndex < head.count { return head[inIndex] }
var vI = inIndex - head.count
if hasSeparator {
if vI == 0 { return UInt8(ascii: "/") }
vI -= 1
}
return tail[vI]
}
}
+81
View File
@@ -0,0 +1,81 @@
import Foundation
// SearchFilter is the category menu next to the search field (Everything's
// Filters dropdown). Each filter is an extra query clause ANDed with what the
// user typed, written in the regular search syntax.
enum SearchFilter: String, CaseIterable, Identifiable, Sendable {
case everything, folders, documents, pictures, audio, video, archives, applications, code
var id: String { rawValue }
// menu label
var title: String {
switch self {
case .everything: return "Everything"
case .folders: return "Folders"
case .documents: return "Documents"
case .pictures: return "Pictures"
case .audio: return "Audio"
case .video: return "Video"
case .archives: return "Archives"
case .applications: return "Applications"
case .code: return "Code"
}
}
// SF Symbol shown in the menu
var symbolName: String {
switch self {
case .everything: return "square.grid.2x2"
case .folders: return "folder"
case .documents: return "doc.text"
case .pictures: return "photo"
case .audio: return "music.note"
case .video: return "film"
case .archives: return "archivebox"
case .applications: return "app"
case .code: return "chevron.left.forwardslash.chevron.right"
}
}
// query clause added to the user's query; nil for no restriction
var clause: String? {
switch self {
case .everything:
return nil
case .folders:
return "folder:"
case .documents:
return Self.extClause(["pdf", "doc", "docx", "odt", "rtf", "txt", "md", "pages", "key", "numbers",
"xls", "xlsx", "ods", "ppt", "pptx", "odp", "csv", "epub"])
case .pictures:
return Self.extClause(["jpg", "jpeg", "png", "gif", "heic", "heif", "tif", "tiff", "bmp", "webp",
"svg", "raw", "cr2", "cr3", "nef", "arw", "dng", "psd", "ai", "ico", "icns"])
case .audio:
return Self.extClause(["mp3", "m4a", "aac", "wav", "aif", "aiff", "flac", "ogg", "opus", "wma",
"caf", "mid", "midi"])
case .video:
return Self.extClause(["mp4", "m4v", "mov", "avi", "mkv", "webm", "wmv", "flv", "mpg", "mpeg", "3gp"])
case .archives:
return Self.extClause(["zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz", "zst", "dmg", "iso",
"pkg", "jar", "apk", "aab", "xip"])
case .applications:
return "folder:ext:app"
case .code:
return Self.extClause(["swift", "c", "h", "m", "mm", "cpp", "hpp", "cc", "java", "kt", "kts", "js",
"mjs", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "php", "cs", "sh", "zsh",
"json", "yaml", "yml", "toml", "xml", "html", "css", "scss", "gradle", "sql"])
}
}
// the user's query with this filter's clause appended
func apply(toQuery inQuery: String) -> String {
guard let vClause = clause else { return inQuery }
return inQuery + " " + vClause
}
// "ext:a;b;c" for a list of extensions
private static func extClause(_ inExtensions: [String]) -> String {
return "ext:" + inExtensions.joined(separator: ";")
}
}
+17 -3
View File
@@ -49,15 +49,15 @@ enum VolumeManager {
// returns the effective root URLs combining configured paths and mounted
// volumes that match the include-mounted / include-network preferences.
// Paths are standardized and nested roots dropped (a root inside another
// Paths are canonicalized and nested roots dropped (a root inside another
// root would otherwise be walked, watched and indexed twice).
static func effectiveRoots(inPreferences: Preferences) -> [URL] {
var vPaths: [String] = inPreferences.rootPaths.map { URL(fileURLWithPath: $0).standardizedFileURL.path }
var vPaths: [String] = inPreferences.rootPaths.map { canonicalPath(inPath: $0) }
if inPreferences.includeMountedVolumes || inPreferences.includeNetworkVolumes {
for vVol in mountedVolumes() {
if vVol.isNetwork && !inPreferences.includeNetworkVolumes { continue }
if !vVol.isNetwork && !inPreferences.includeMountedVolumes { continue }
vPaths.append(vVol.url.standardizedFileURL.path)
vPaths.append(canonicalPath(inPath: vVol.url.path))
}
}
let vMinimal = Set(SubtreeMatcher.minimalRoots(inPaths: vPaths))
@@ -67,4 +67,18 @@ enum VolumeManager {
.filter { vMinimal.contains($0) && vSeen.insert($0).inserted }
.map { URL(fileURLWithPath: $0) }
}
// the path the kernel reports for inPath (symlinks resolved, e.g.
// /tmp -> /private/tmp), which is what FSEvents and the walker produce.
// Foundation's standardizedFileURL / resolvingSymlinksInPath strip the
// /private prefix instead, so a root under /tmp, /var or /etc would never
// match its own entries. Falls back to the given path (minus a trailing
// slash) when it doesn't exist, e.g. an unmounted volume.
static func canonicalPath(inPath: String) -> String {
if let vResolved = realpath(inPath, nil) {
defer { free(vResolved) }
return String(cString: vResolved)
}
return URL(fileURLWithPath: inPath).path
}
}
+45 -11
View File
@@ -127,8 +127,14 @@ enum AllofitService {
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 }
// with no walk pending the batch is fully merged: advance the
// saved event id now; otherwise only once the walks are in,
// so a restart mid-walk replays the changes that caused them
let vHasWalks = !vResult.rescans.isEmpty || !vResult.newFolders.isEmpty
if !vHasWalks {
vState.lastEventId = max(vState.lastEventId, vResolved.maxEventId)
}
vState.lock.unlock()
if vResolved.historyDone {
NSLog("[Allofit] FSEvents history replay done")
@@ -136,22 +142,28 @@ enum AllofitService {
vState.dirty = true
vState.lock.unlock()
}
guard vHasWalks else { return }
// folders moved in, or history lost: walk them again
// history lost (kernel rescans): replace those subtrees
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
))
}
let vFresh = walk(inPaths: vResult.rescans, inExclusions: vMatcher)
vState.lock.lock()
vState.index.replaceSubtrees(inRoots: vResult.rescans, inRecords: vFresh)
vState.dirty = true
vState.lock.unlock()
}
// folders that appeared (created, moved or renamed in)
if !vResult.newFolders.isEmpty {
let vFresh = walk(inPaths: vResult.newFolders, inExclusions: vMatcher)
vState.lock.lock()
vState.index.mergeNewFolders(inRecords: vFresh)
vState.dirty = true
vState.lock.unlock()
}
vState.lock.lock()
vState.lastEventId = max(vState.lastEventId, vResolved.maxEventId)
vState.lock.unlock()
}
}
@@ -160,8 +172,22 @@ enum AllofitService {
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.
// walks the given subtrees, returning every indexable entry below them
private static func walk(inPaths: [String], inExclusions: ExclusionMatcher) -> [FileRecord] {
var vResult: [FileRecord] = []
for vPath in inPaths {
autoreleasepool {
vResult.append(contentsOf: FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: inExclusions
))
}
}
return vResult
}
// periodic save loop (background thread): checks every 3 s and saves
// at most every 15 s, so new files reach the GUI within ~15 s.
// 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
@@ -176,9 +202,16 @@ enum AllofitService {
private static func startAutosaveLoop(inState: State) {
DispatchQueue.global(qos: .utility).async {
let kCompactEvery = 20
// minimum gap between two saves: a save writes the whole index
// (~25 MB for 600k entries) and makes the app reload all of it,
// so under constant file activity every 3 s was ~8 MB/s of disk
// writes plus over a second of CPU per cycle across both processes
let kMinSaveIntervalSeconds: TimeInterval = 15
var vSavesSinceCompact = 0
var vLastSaveAt = Date.distantPast
while true {
sleep(3)
if Date().timeIntervalSince(vLastSaveAt) < kMinSaveIntervalSeconds { continue }
autoreleasepool {
inState.lock.lock()
let vShouldSave = inState.dirty
@@ -190,6 +223,7 @@ enum AllofitService {
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
vSnapshot.count, processFootprintMB())
IndexStore.save(inRecords: vSnapshot, inLastEventId: vEventId)
vLastSaveAt = Date()
vSavesSinceCompact += 1
if vSavesSinceCompact >= kCompactEvery {
vSavesSinceCompact = 0
+10 -2
View File
@@ -89,6 +89,7 @@ enum CLI {
}
}
// starts the installed service(s) named by the arguments
private static func runStart(inArgs: [String]) {
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled)
guard !vScopes.isEmpty else {
@@ -105,6 +106,7 @@ enum CLI {
}
}
// stops the running service(s) named by the arguments
private static func runStop(inArgs: [String]) {
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled)
guard !vScopes.isEmpty else {
@@ -148,12 +150,14 @@ enum CLI {
print("Service mode set to \(vMode.rawValue).")
}
// stores the service mode in one preferences domain
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()
}
// prints the install / run state of both service scopes
private static func runStatus() {
print("Bundle version: \(ServiceInstaller.bundleVersion())")
for vScope in [ServiceInstaller.Scope.userAgent, .rootDaemon] {
@@ -180,6 +184,7 @@ enum CLI {
private static let kProcessName = "Allofit"
private static let kServiceModeKey = "Allofit.serviceMode"
// which scopes a command targets when none is given
private enum ScopeResolution {
case firstInstalled
case everyInstalled
@@ -189,13 +194,13 @@ enum CLI {
// - 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] {
private static func scopesToActOn(inArgs: [String], defaultMode inDefaultMode: 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 {
switch inDefaultMode {
case .firstInstalled:
return vInstalled.first.map { [$0] } ?? []
case .everyInstalled:
@@ -203,6 +208,7 @@ enum CLI {
}
}
// maps a user / root argument (and aliases) to a scope
private static func parseScope(_ inArg: String?) -> ServiceInstaller.Scope? {
guard let vArg = inArg else { return nil }
switch vArg {
@@ -215,6 +221,7 @@ enum CLI {
}
}
// human-readable name of a scope
private static func label(for inScope: ServiceInstaller.Scope) -> String {
switch inScope {
case .userAgent: return "user agent"
@@ -228,6 +235,7 @@ enum CLI {
exit(1)
}
// prints the command-line usage
private static func printHelp() {
print("""
Usage: allofit [command [args]]
+12 -5
View File
@@ -54,6 +54,9 @@ enum ElevatedAccess {
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
// creation attributes don't apply to an existing folder: enforce
// owner-only access every time (the copies may be other users' files)
chmod(vDir.path, 0o700)
// unique destination file, keeping the original extension so the
// QLPreviewView / Launch Services can pick the right renderer
@@ -63,12 +66,16 @@ enum ElevatedAccess {
vDst.appendPathExtension(vExt)
}
// cp + chown to the current user. The chmod restores plain user
// rw / group+other r so the file is treated normally by QL etc.
// Root only *reads* the original; the copy is written by the user
// (sudo -u ... tee), so root never creates, chowns or chmods a path
// inside a user-writable folder. A cp + chown + chmod as root could
// be redirected through a swapped-in symlink / hard link to take
// ownership of any system file. pipefail makes a failed read fail
// the script instead of leaving an empty copy behind.
let vScript = """
cp \(AdminShell.quote(inUrl.path)) \(AdminShell.quote(vDst.path)) && \
chown \(AdminShell.quote(NSUserName())) \(AdminShell.quote(vDst.path)) && \
chmod 0644 \(AdminShell.quote(vDst.path))
set -o pipefail; \
/bin/cat \(AdminShell.quote(inUrl.path)) \
| /usr/bin/sudo -u \(AdminShell.quote(NSUserName())) /usr/bin/tee \(AdminShell.quote(vDst.path)) > /dev/null
"""
_ = try AdminShell.run(vScript)
return vDst
+57 -32
View File
@@ -67,6 +67,10 @@ enum ServiceInstaller {
at: vTarget.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try? FileManager.default.createDirectory(
atPath: (logPath(inScope: .userAgent) as NSString).deletingLastPathComponent,
withIntermediateDirectories: true
)
try vPlistData.write(to: vTarget, options: .atomic)
_ = runLaunchctl(inArgs: ["unload", vTarget.path])
let vResult = runLaunchctl(inArgs: ["load", "-w", vTarget.path])
@@ -78,24 +82,30 @@ enum ServiceInstaller {
// root daemon install: binary copy + plist install + boot
// happen inside a single admin-priv script so the user
// gets ONE password prompt covering all of it
let vTmp = FileManager.default.temporaryDirectory
.appendingPathComponent("\(kLabel).plist")
try vPlistData.write(to: vTmp, options: .atomic)
// The plist travels inside the script as base64 (only
// [A-Za-z0-9+/=], safe in single quotes) instead of a temp
// file a user process could swap before root copies it in.
let vPlistBase64 = vPlistData.base64EncodedString()
let vTarget = rootDaemonPath()
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
let vLogDir = (logPath(inScope: .rootDaemon) as NSString).deletingLastPathComponent
let vScript = """
mkdir -p \(shellQuote(inString: vDaemonDir)) \
&& rm -f \(shellQuote(inString: vDaemonBinary)) \
&& cp \(shellQuote(inString: vBinary)) \(shellQuote(inString: vDaemonBinary)) \
&& chown root:wheel \(shellQuote(inString: vDaemonBinary)) \
&& chmod 755 \(shellQuote(inString: vDaemonBinary)) \
&& printf '%s' \(shellQuote(inString: vVersionString)) > \(shellQuote(inString: vVersionFile)) \
&& chmod 644 \(shellQuote(inString: vVersionFile)) \
&& cp \(shellQuote(inString: vTmp.path)) \(shellQuote(inString: vTarget.path)) \
&& chown root:wheel \(shellQuote(inString: vTarget.path)) \
&& chmod 644 \(shellQuote(inString: vTarget.path)) \
; /bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
; /bin/launchctl bootstrap system \(shellQuote(inString: vTarget.path))
mkdir -p \(AdminShell.quote(vDaemonDir)) \
&& rm -f \(AdminShell.quote(vDaemonBinary)) \
&& cp \(AdminShell.quote(vBinary)) \(AdminShell.quote(vDaemonBinary)) \
&& chown root:wheel \(AdminShell.quote(vDaemonBinary)) \
&& chmod 755 \(AdminShell.quote(vDaemonBinary)) \
&& printf '%s' \(AdminShell.quote(vVersionString)) > \(AdminShell.quote(vVersionFile)) \
&& chmod 644 \(AdminShell.quote(vVersionFile)) \
&& mkdir -p \(AdminShell.quote(vLogDir)) \
&& chown root:wheel \(AdminShell.quote(vLogDir)) \
&& chmod 755 \(AdminShell.quote(vLogDir)) \
&& printf '%s' \(AdminShell.quote(vPlistBase64)) | /usr/bin/base64 -D > \(AdminShell.quote(vTarget.path)) \
&& /usr/bin/plutil -lint -s \(AdminShell.quote(vTarget.path)) \
&& chown root:wheel \(AdminShell.quote(vTarget.path)) \
&& chmod 644 \(AdminShell.quote(vTarget.path)) \
; /bin/launchctl bootout system \(AdminShell.quote(vTarget.path)) 2>/dev/null \
; /bin/launchctl bootstrap system \(AdminShell.quote(vTarget.path))
"""
try runWithAdminPrivileges(inScript: vScript)
}
@@ -117,8 +127,8 @@ enum ServiceInstaller {
case .rootDaemon:
let vTarget = rootDaemonPath()
let vScript = """
/bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
; rm -f \(shellQuote(inString: vTarget.path)) \(shellQuote(inString: vDaemonBinary)) \(shellQuote(inString: vVersionFile))
/bin/launchctl bootout system \(AdminShell.quote(vTarget.path)) 2>/dev/null \
; rm -f \(AdminShell.quote(vTarget.path)) \(AdminShell.quote(vDaemonBinary)) \(AdminShell.quote(vVersionFile))
"""
try runWithAdminPrivileges(inScript: vScript)
}
@@ -146,7 +156,7 @@ enum ServiceInstaller {
}
case .rootDaemon:
let vPlist = rootDaemonPath().path
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vPlist))"
let vScript = "/bin/launchctl bootout system \(AdminShell.quote(vPlist))"
try runWithAdminPrivileges(inScript: vScript)
}
}
@@ -162,7 +172,7 @@ enum ServiceInstaller {
}
case .rootDaemon:
let vPlist = rootDaemonPath().path
let vScript = "/bin/launchctl bootstrap system \(shellQuote(inString: vPlist))"
let vScript = "/bin/launchctl bootstrap system \(AdminShell.quote(vPlist))"
try runWithAdminPrivileges(inScript: vScript)
}
}
@@ -217,7 +227,7 @@ enum ServiceInstaller {
case .rootDaemon:
let vPlist = rootDaemonPath().path
let vCache = inCacheURL.path
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vPlist)) 2>/dev/null ; rm -f \(shellQuote(inString: vCache)) ; /bin/launchctl bootstrap system \(shellQuote(inString: vPlist))"
let vScript = "/bin/launchctl bootout system \(AdminShell.quote(vPlist)) 2>/dev/null ; rm -f \(AdminShell.quote(vCache)) ; /bin/launchctl bootstrap system \(AdminShell.quote(vPlist))"
try runWithAdminPrivileges(inScript: vScript)
}
}
@@ -226,11 +236,13 @@ enum ServiceInstaller {
// MARK: Internals
// ===========================
// location of the per-user LaunchAgent plist
private static func userAgentPath() -> URL {
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(kLabel).plist")
}
// location of the system-wide LaunchDaemon plist
private static func rootDaemonPath() -> URL {
return URL(fileURLWithPath: "/Library/LaunchDaemons/\(kLabel).plist")
}
@@ -250,15 +262,32 @@ enum ServiceInstaller {
}
}
// where the service's stdout / stderr go: a root-owned folder for the
// daemon, the user's own Logs folder for the agent
static func logPath(inScope: Scope) -> String {
switch inScope {
case .userAgent:
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Logs/Allofit/service.log").path
case .rootDaemon:
return "/Library/Logs/Allofit/service.log"
}
}
// resolves an absolute path to the currently running binary
private static func resolveBinaryPath() throws -> String {
let vArg0 = CommandLine.arguments[0]
let vAbs = (vArg0 as NSString).standardizingPath
if FileManager.default.fileExists(atPath: vAbs) { return vAbs }
if let vUrl = Bundle.main.executableURL,
// the loaded executable, not argv[0] (which a caller can set to
// anything, including a relative path)
if let vUrl = Bundle.main.executableURL?.resolvingSymlinksInPath(),
vUrl.path.hasPrefix("/"),
FileManager.default.fileExists(atPath: vUrl.path) {
return vUrl.path
}
let vArg0 = CommandLine.arguments[0]
let vAbs = (vArg0 as NSString).standardizingPath
if vAbs.hasPrefix("/") && FileManager.default.fileExists(atPath: vAbs) {
return vAbs
}
throw InstallError.binaryNotFound
}
@@ -269,8 +298,10 @@ enum ServiceInstaller {
"ProgramArguments": [inBinary, "--service"],
"RunAtLoad": true,
"KeepAlive": true,
"StandardOutPath": "/tmp/allofit-service.log",
"StandardErrorPath": "/tmp/allofit-service.err",
// never /tmp: a fixed name there can be pre-created as a symlink
// by any local user, and launchd opens it as root for the daemon
"StandardOutPath": logPath(inScope: inScope),
"StandardErrorPath": logPath(inScope: inScope),
"ThrottleInterval": 10
]
if inScope == .rootDaemon {
@@ -323,10 +354,4 @@ enum ServiceInstaller {
throw InstallError.authorizationFailed(vErr.errorDescription ?? "\(vErr)")
}
}
// shell-quote helper, delegating to the shared AdminShell quoter so
// both call sites use the same escaping rules
private static func shellQuote(inString: String) -> String {
return AdminShell.quote(inString)
}
}
-8
View File
@@ -68,12 +68,4 @@ final class AccessManager: ObservableObject {
}
authorizingIds.remove(vId)
}
// wipes the in-memory mapping. Called after ElevatedAccess.cleanup()
// removes the on-disk files so the two stay consistent.
func reset() {
stagedURLs.removeAll()
authorizingIds.removeAll()
lastError = nil
}
}
+198 -25
View File
@@ -1,5 +1,6 @@
import SwiftUI
import AppKit
import Combine
// AllofitApp is the SwiftUI App for the GUI mode. The real entry point lives
// in Main.swift, which routes between this and the headless --service mode.
@@ -30,10 +31,12 @@ struct AllofitApp: App {
Button("About Allofit") { showAboutPanel() }
}
CommandGroup(after: .appInfo) {
Button("Reindex All") {
// ⇧⌘R rather than ⌘R: a full rebuild is too costly to trigger
// by accident
Button("Rebuild Index") {
Task { await model.performReindex() }
}
.keyboardShortcut("r", modifiers: [.command])
.keyboardShortcut("r", modifiers: [.command, .shift])
}
// SwiftUI provides File > New Window (⌘N) automatically for a
// WindowGroup; nothing to add here. Additional windows share the
@@ -43,6 +46,13 @@ struct AllofitApp: App {
// ⌘F focuses the search field. Standard Find-style shortcut.
// SearchField's Coordinator observes the notification and calls
// makeFirstResponder on its underlying NSSearchField.
// Help: the syntax cheat sheet and the project page
CommandGroup(replacing: .help) {
Button("Search Syntax") { showSyntaxWindow() }
Button("Allofit on GitHub") {
NSWorkspace.shared.open(URL(string: "https://github.com/bitsycore/Allofit#search")!)
}
}
CommandGroup(after: .pasteboard) {
Button("Find") {
NotificationCenter.default.post(name: .allofitFocusSearch, object: nil)
@@ -73,6 +83,7 @@ struct AllofitWindowContent: View {
let access: AccessManager
@StateObject private var searchModel: WindowSearchModel
// creates the per-window content around the shared models
init(model inModel: AppModel, access inAccess: AccessManager) {
self.model = inModel
self.access = inAccess
@@ -89,6 +100,9 @@ struct AllofitWindowContent: View {
.environmentObject(searchModel)
.frame(minWidth: 760, minHeight: 480)
.background(MainWindowMarker())
.background(WindowVisibilityReporter { vVisible in
searchModel.setWindowVisible(vVisible)
})
.onAppear {
model.start()
}
@@ -109,7 +123,9 @@ struct AllofitWindowContent: View {
// DispatchQueue.main.async sometimes ran before SwiftUI attached the view.
struct MainWindowMarker: NSViewRepresentable {
// NSView that records its window as the main window
final class MarkerView: NSView {
// captures the window once the view is attached to it
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard let vWindow = self.window else { return }
@@ -122,19 +138,114 @@ struct MainWindowMarker: NSViewRepresentable {
}
}
func makeNSView(context: Context) -> NSView {
// creates the marker view
func makeNSView(context inContext: Context) -> NSView {
return MarkerView(frame: .zero)
}
func updateNSView(_ nsView: NSView, context: Context) {
// rebind if SwiftUI ever swaps us into a different window
if let vWindow = nsView.window, AppDelegate.mainWindow !== vWindow {
// rebinds if SwiftUI ever swaps us into a different window
func updateNSView(_ inView: NSView, context inContext: Context) {
if let vWindow = inView.window, AppDelegate.mainWindow !== vWindow {
vWindow.isReleasedWhenClosed = false
AppDelegate.mainWindow = vWindow
}
}
}
// WindowVisibilityReporter tells its owner whether the hosting window is
// actually on screen (AppKit occlusion state), so a closed, minimized or
// fully covered window can skip work nobody would see.
struct WindowVisibilityReporter: NSViewRepresentable {
// called on main with true when the window becomes visible
let onChange: (Bool) -> Void
// NSView that follows its window's occlusion notifications
final class ReporterView: NSView {
// latest callback from the SwiftUI side
var onChange: ((Bool) -> Void)?
// occlusion observer for the current window
private var observer: NSObjectProtocol?
// re-targets the observer whenever the view changes window
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if let vObserver = observer {
NotificationCenter.default.removeObserver(vObserver)
observer = nil
}
guard let vWindow = window else {
report(false)
return
}
observer = NotificationCenter.default.addObserver(
forName: NSWindow.didChangeOcclusionStateNotification,
object: vWindow,
queue: .main
) { [weak self, weak vWindow] _ in
guard let vWin = vWindow else { return }
MainActor.assumeIsolated {
self?.report(vWin.occlusionState.contains(.visible))
}
}
report(vWindow.occlusionState.contains(.visible))
}
// forwards a visibility change to the owner and the app-wide tracker
private func report(_ inVisible: Bool) {
onChange?(inVisible)
AppActivity.shared.setWindow(ObjectIdentifier(self), visible: inVisible)
}
// removes the occlusion observer; a destroyed window no longer
// counts as visible
deinit {
if let vObserver = observer {
NotificationCenter.default.removeObserver(vObserver)
}
let vId = ObjectIdentifier(self)
DispatchQueue.main.async {
AppActivity.shared.setWindow(vId, visible: false)
}
}
}
// creates the reporter view
func makeNSView(context inContext: Context) -> NSView {
let vView = ReporterView(frame: .zero)
vView.onChange = onChange
return vView
}
// keeps the view's callback current
func updateNSView(_ inView: NSView, context inContext: Context) {
(inView as? ReporterView)?.onChange = onChange
}
}
// the Help > Search Syntax window (kept so a second click just brings it back)
@MainActor private var syntaxWindow: NSWindow?
// shows the search syntax cheat sheet in a small utility window
@MainActor private func showSyntaxWindow() {
if syntaxWindow == nil {
let vWindow = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 560, height: 420),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
vWindow.title = "Allofit Search Syntax"
vWindow.isReleasedWhenClosed = false
vWindow.contentView = NSHostingView(rootView: SyntaxHelpView())
vWindow.setContentSize(vWindow.contentView?.fittingSize ?? NSSize(width: 560, height: 420))
vWindow.center()
syntaxWindow = vWindow
}
syntaxWindow?.makeKeyAndOrderFront(nil)
}
// Opens the standard macOS About panel with a clickable GitHub link in the
// Credits section. App name + version come from Info.plist automatically.
private func showAboutPanel() {
@@ -167,22 +278,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// nonisolated(unsafe) because all reads/writes happen on the main thread
// (NSView callbacks + the AppKit delegate methods are all @MainActor).
nonisolated(unsafe) static var mainWindow: NSWindow?
// keeps the global-shortcut setting subscription alive
private var hotKeySubscription: AnyCancellable?
// keeps the menu-bar-icon setting subscription alive
private var menuBarSubscription: AnyCancellable?
// the menu bar icon, nil while hidden
private var statusItem: NSStatusItem?
// called once when the application has finished launching
func applicationDidFinishLaunching(_ notification: Notification) {
func applicationDidFinishLaunching(_ inNotification: Notification) {
// run as a regular foreground app even when launched outside a .app
// 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])
// shrink the AppKit help-tag (.help() / NSView.toolTip) hover delay
// from the ~1-2 s system default. Registered as a default so a
// user-set value in the global domain still wins. The value is in
// milliseconds (it used to be registered as 0.3, read as 0 ms).
UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 250])
// 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
ElevatedAccess.cleanup()
// Finder > Services > "Search in Allofit"
NSApp.servicesProvider = ServicesProvider()
NSUpdateDynamicServices()
// system-wide shortcut, re-registered whenever the setting changes
GlobalHotKey.shared.onPressed = { WindowPresenter.toggle() }
hotKeySubscription = Preferences.shared.$globalHotKey
.removeDuplicates()
.sink { vPreset in
MainActor.assumeIsolated {
_ = GlobalHotKey.shared.register(inPreset: vPreset)
}
}
// menu bar icon (Everything's tray icon), shown per the setting.
// Plain AppKit: a SwiftUI MenuBarExtra scene crashed the app at
// launch (runtime recursion resolving the App's scene type).
menuBarSubscription = Preferences.shared.$showMenuBarIcon
.removeDuplicates()
.sink { [weak self] vShow in
MainActor.assumeIsolated {
self?.setStatusItemVisible(vShow)
}
}
// bring the main window to the front so it accepts keystrokes
DispatchQueue.main.async {
for vWindow in NSApp.windows where vWindow.canBecomeKey {
@@ -195,14 +334,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// called on clean Cmd+Q quit; wipes the elevated-access staging dir
// so the user-readable copies of privileged files don't linger
func applicationWillTerminate(_ notification: Notification) {
func applicationWillTerminate(_ inNotification: Notification) {
ElevatedAccess.cleanup()
}
// keep the process alive when the user closes the last window: the index
// stays in RAM and clicking the dock icon snaps a new window up instantly.
// Cmd+Q still quits via the standard Quit menu item.
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
func applicationShouldTerminateAfterLastWindowClosed(_ inSender: NSApplication) -> Bool {
return false
}
@@ -211,7 +350,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// 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? {
func applicationDockMenu(_ inSender: NSApplication) -> NSMenu? {
let vMenu = NSMenu()
let vItem = NSMenuItem(title: "New Window",
action: #selector(newWindowFromDock(_:)),
@@ -224,25 +363,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// 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)
@objc func newWindowFromDock(_ inSender: Any?) {
MainActor.assumeIsolated {
WindowPresenter.openNewWindow()
}
}
// adds or removes the menu bar icon and its menu
private func setStatusItemVisible(_ inVisible: Bool) {
guard inVisible else {
if let vItem = statusItem {
NSStatusBar.system.removeStatusItem(vItem)
statusItem = nil
}
return
}
guard statusItem == nil else { return }
let vItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
vItem.button?.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Allofit")
let vMenu = NSMenu()
vMenu.addItem(withTitle: "Show Allofit", action: #selector(statusShow(_:)), keyEquivalent: "").target = self
vMenu.addItem(.separator())
vMenu.addItem(withTitle: "Settings…", action: #selector(statusSettings(_:)), keyEquivalent: "").target = self
vMenu.addItem(.separator())
vMenu.addItem(withTitle: "Quit Allofit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "")
vItem.menu = vMenu
statusItem = vItem
}
// menu bar > Show Allofit
@objc private func statusShow(_ inSender: Any?) {
MainActor.assumeIsolated {
WindowPresenter.show()
}
}
// menu bar > Settings…
@objc private func statusSettings(_ inSender: Any?) {
NSApp.activate(ignoringOtherApps: true)
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
// folders dropped on the Dock icon or opened with the app
// (open -a Allofit <folder>) start a search inside that folder
func application(_ inApplication: NSApplication, open inUrls: [URL]) {
guard let vFirst = inUrls.first else { return }
let vIsDir = (try? vFirst.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
WindowPresenter.search(inFolder: vIsDir ? vFirst.path : vFirst.deletingLastPathComponent().path)
}
// 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).
func applicationShouldHandleReopen(_ sender: NSApplication,
func applicationShouldHandleReopen(_ inSender: NSApplication,
hasVisibleWindows inHasVisible: Bool) -> Bool {
if inHasVisible { return true }
if let vMain = AppDelegate.mainWindow {
+83
View File
@@ -0,0 +1,83 @@
import AppKit
import Combine
// AppActivity tracks how visible Allofit currently is, so background work
// can slow down when nobody is looking:
// - foreground: the app is frontmost and one of its windows is on screen
// - background: a window is on screen but another app has focus
// - hidden: no window is on screen (closed, minimized, fully covered,
// or the app is hidden)
// Windows report their own visibility through WindowVisibilityReporter.
@MainActor
final class AppActivity: ObservableObject {
// shared tracker for the whole app
static let shared = AppActivity()
// how visible the app is, from least to most
enum Level: Int, Comparable {
case hidden = 0
case background = 1
case foreground = 2
// ordering by visibility
static func < (inLhs: Level, inRhs: Level) -> Bool {
return inLhs.rawValue < inRhs.rawValue
}
}
// current visibility level
@Published private(set) var level: Level = .foreground
// windows (by reporter identity) currently on screen
private var visibleWindows = Set<ObjectIdentifier>()
// true while Allofit is the frontmost app
private var isAppActive = true
// app activation observers
private var cancellables: Set<AnyCancellable> = []
// starts following app activation / hiding
private init() {
isAppActive = NSApplication.shared.isActive
let vCenter = NotificationCenter.default
for vName in [NSApplication.didBecomeActiveNotification,
NSApplication.didResignActiveNotification,
NSApplication.didHideNotification,
NSApplication.didUnhideNotification] {
vCenter.publisher(for: vName)
.sink { [weak self] _ in
MainActor.assumeIsolated {
guard let vSelf = self else { return }
vSelf.isAppActive = NSApplication.shared.isActive && !NSApplication.shared.isHidden
vSelf.recompute()
}
}
.store(in: &cancellables)
}
}
// records whether the window identified by inId is on screen
func setWindow(_ inId: ObjectIdentifier, visible inVisible: Bool) {
if inVisible {
visibleWindows.insert(inId)
} else {
visibleWindows.remove(inId)
}
recompute()
}
// derives the level from the window set and the app's active state
private func recompute() {
let vLevel: Level
if visibleWindows.isEmpty || NSApplication.shared.isHidden {
vLevel = .hidden
} else if isAppActive {
vLevel = .foreground
} else {
vLevel = .background
}
if level != vLevel {
level = vLevel
}
}
}
+259 -49
View File
@@ -33,9 +33,32 @@ private final class PendingChanges: @unchecked Sendable {
private var changes: [FSChange] = []
// true while a flush is scheduled but has not drained yet
private var flushScheduled = false
// guards changes and flushScheduled
// how long changes are collected before a flush (follows AppActivity)
private var delaySeconds: TimeInterval = 2
// guards every field above
private let lock = NSLock()
// current collection delay before a flush
var coalesceDelay: TimeInterval {
lock.lock()
defer { lock.unlock() }
return delaySeconds
}
// changes the collection delay used for the next flushes
func setCoalesceDelay(_ inSeconds: TimeInterval) {
lock.lock()
delaySeconds = inSeconds
lock.unlock()
}
// true when changes are waiting to be flushed
var hasPending: Bool {
lock.lock()
defer { lock.unlock() }
return !changes.isEmpty
}
// appends a batch; returns true if the caller must schedule a flush
func append(inChanges: [FSChange]) -> Bool {
lock.lock()
@@ -86,9 +109,12 @@ final class AppModel: ObservableObject {
// 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 }
var allRecords: RecordStore { index.records }
// fires on main after allRecords changed (no payload by design)
let recordsChanged = PassthroughSubject<Void, Never>()
// when the index last changed; read by the status bar on its own
// refresh tick, so deliberately not @Published
private(set) var lastIndexChangeAt: Date?
// 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
@@ -100,8 +126,6 @@ final class AppModel: ObservableObject {
// 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
@@ -117,6 +141,18 @@ final class AppModel: ObservableObject {
private let pendingChanges = PendingChanges()
// roots the watcher was started with; their own records need no parent
private var watchedRoots: Set<String> = []
// exclusions the running watcher resolves changes with
private var currentExclusions: ExclusionMatcher?
// subtree walks started but not merged yet
private var rescansInFlight = 0
// event id to save while rescans are in flight: the id from before the
// changes that triggered them, so a quit or crash mid-walk replays those
// changes instead of losing the folders' contents for good
private var rescanFloorEventId: UInt64?
// a reader-mode reload skipped (throttled or no window visible)
private var pendingReaderReload = false
// service mode the current role was started with (see activeServiceMode)
private(set) var bootMode: Preferences.ServiceMode = .none
// 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
@@ -150,6 +186,7 @@ final class AppModel: ObservableObject {
// timestamp of the last reloadFromCache() that actually swapped data in
private var lastReloadAt: Date?
// wires up the quit and settings observers (loading happens in start)
init() {
// the cache file is loaded off-main in start() so the window
// appears instantly; here we only wire up observers.
@@ -162,6 +199,26 @@ final class AppModel: ObservableObject {
}
}
.store(in: &cancellables)
// slow index updates down while the app isn't being looked at, and
// catch up at once when a window comes back
AppActivity.shared.$level
.removeDuplicates()
.sink { [weak self] vLevel in
self?.activityChanged(inLevel: vLevel)
}
.store(in: &cancellables)
// the delays are user settings: apply edits right away
Publishers.Merge3(
prefs.$updateDelayForeground,
prefs.$updateDelayBackground,
prefs.$updateDelayHidden
)
.dropFirst(3)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.activityChanged(inLevel: AppActivity.shared.level)
}
.store(in: &cancellables)
// apply roots / exclusions / volume edits to the live index
Publishers.Merge4(
prefs.$rootPaths.map { _ in () },
@@ -177,6 +234,18 @@ final class AppModel: ObservableObject {
.store(in: &cancellables)
}
// the service mode actually in effect: the saved mode, but only if that
// service is really installed. A mode saved without a working install
// (or a service removed behind the app's back) falls back to indexing
// in-process instead of waiting forever on a cache nobody writes.
var activeServiceMode: Preferences.ServiceMode {
switch prefs.serviceMode {
case .none: return .none
case .userAgent: return ServiceInstaller.isInstalled(inScope: .userAgent) ? .userAgent : .none
case .rootDaemon: return ServiceInstaller.isInstalled(inScope: .rootDaemon) ? .rootDaemon : .none
}
}
// kicks off background activity for the first time. Subsequent calls are
// no-ops so we don't double-bootstrap when the user reopens the window.
// To force a re-bootstrap (e.g. after Install/Uninstall swaps the role),
@@ -214,7 +283,12 @@ final class AppModel: ObservableObject {
private func bootstrap() {
tearDownActiveMode()
if prefs.serviceMode != .none {
let vMode = activeServiceMode
bootMode = vMode
if vMode != prefs.serviceMode {
NSLog("[Allofit GUI] service mode %@ saved but not installed, indexing in-process", prefs.serviceMode.rawValue)
}
if vMode != .none {
isIndexer = false
} else {
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: false).path)
@@ -228,12 +302,12 @@ final class AppModel: ObservableObject {
}
isLoadingCache = true
let vCacheURL = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
let vCacheURL = IndexStore.cacheURL(forServiceMode: vMode)
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,
vMode.rawValue,
vIsIndexer ? "true" : "false",
vCacheURL.path)
ioQueue.async { [weak self] in
@@ -284,26 +358,44 @@ final class AppModel: ObservableObject {
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs)
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vStartId = UInt64(FSEventsGetCurrentEventId())
let vIoQueue = ioQueue
// partial results only when there is nothing to show yet (first
// run): a manual rebuild keeps the old index visible until the end
let vPublishPartial = index.count == 0
indexQueue.async { [weak self] in
var vAccumulated: [FileRecord] = []
// the results fill in while the walk runs (instead of an empty
// window until the end of a scan that can take minutes)
let kPartialPublishSeconds: TimeInterval = 3
var vLastPublish = Date()
for vRoot in vRoots {
// 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
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
vAccumulated.append(contentsOf: vBatch)
let vCount = vAccumulated.count
let vNow = Date()
guard vPublishPartial, vNow.timeIntervalSince(vLastPublish) >= kPartialPublishSeconds else {
DispatchQueue.main.async { self?.indexedCount = vCount }
return
}
vLastPublish = vNow
let vPartial = IndexState(inRecords: vAccumulated)
DispatchQueue.main.async {
self?.indexedCount = vBaseline + vCount
guard let vSelf = self, vSelf.isIndexing else { return }
vSelf.replaceIndex(inState: vPartial)
}
}
vAccumulated.append(contentsOf: vList)
}
}
let vState = IndexState(inRecords: vAccumulated)
// persist off-main before bouncing back so main never sees the
// LZ4 compression cost
// persist off-main (on the save queue, so it never races another
// save) before bouncing back: main never sees the LZ4 cost
vIoQueue.sync {
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
}
DispatchQueue.main.async {
self?.applyFreshIndex(inState: vState, inEventId: vStartId)
}
@@ -332,7 +424,7 @@ final class AppModel: ObservableObject {
// the daemon would re-write it on its next autosave, so we tunnel the
// delete through the same stop/delete/start admin script.
func performClearCache() async {
switch prefs.serviceMode {
switch activeServiceMode {
case .none:
IndexStore.clearCache(at: IndexStore.cacheURL(forServiceMode: .none))
workMessage = "Cache cleared."
@@ -347,11 +439,14 @@ final class AppModel: ObservableObject {
// installs a launchd service for the current serviceMode preference,
// off-main so the password dialog doesn't freeze the GUI. On success
// the GUI hot-swaps into reader mode so the user doesn't need to relaunch.
func performInstallService() async {
let vOk = await runPrivilegedAction(inLabel: "Installing service") { vScope, _ in
// inMode is the mode chosen in Settings: it is only saved once the
// install succeeded, so a failed or cancelled install changes nothing
func performInstallService(inMode: Preferences.ServiceMode) async {
let vOk = await runPrivilegedAction(inLabel: "Installing service", inMode: inMode) { vScope, _ in
try ServiceInstaller.install(inScope: vScope)
}
if vOk {
prefs.serviceMode = inMode
// give launchd a moment to bring the daemon up before we switch
// the GUI into reader mode and start watching the cache file
try? await Task.sleep(nanoseconds: 1_500_000_000)
@@ -362,8 +457,8 @@ final class AppModel: ObservableObject {
// stops the running daemon for the current serviceMode preference
// without uninstalling. Plist stays on disk so the next launch (or
// performStartService) brings it back.
func performStopService() async {
await runPrivilegedAction(inLabel: "Stopping service") { vScope, _ in
func performStopService(inMode: Preferences.ServiceMode? = nil) async {
await runPrivilegedAction(inLabel: "Stopping service", inMode: inMode) { vScope, _ in
try ServiceInstaller.stop(inScope: vScope)
}
}
@@ -371,8 +466,8 @@ final class AppModel: ObservableObject {
// starts a stopped-but-installed daemon for the current serviceMode
// preference. Hot-swaps the GUI into reader mode on success so the
// reader watcher picks up the daemon's first cache write.
func performStartService() async {
let vOk = await runPrivilegedAction(inLabel: "Starting service") { vScope, _ in
func performStartService(inMode: Preferences.ServiceMode? = nil) async {
let vOk = await runPrivilegedAction(inLabel: "Starting service", inMode: inMode) { vScope, _ in
try ServiceInstaller.start(inScope: vScope)
}
if vOk {
@@ -383,11 +478,16 @@ final class AppModel: ObservableObject {
// uninstalls the launchd service for the current serviceMode preference.
// On success the GUI hot-swaps back into built-in indexer mode.
func performUninstallService() async {
let vOk = await runPrivilegedAction(inLabel: "Uninstalling service") { vScope, _ in
func performUninstallService(inMode: Preferences.ServiceMode? = nil) async {
let vMode = inMode ?? prefs.serviceMode
let vOk = await runPrivilegedAction(inLabel: "Uninstalling service", inMode: vMode) { vScope, _ in
try ServiceInstaller.uninstall(inScope: vScope)
}
if vOk {
// back to the built-in indexer when the active service went away
if vMode == prefs.serviceMode {
prefs.serviceMode = .none
}
// give launchd a moment to actually tear the daemon down so its
// indexer lock is released before the GUI tries to grab it
try? await Task.sleep(nanoseconds: 1_500_000_000)
@@ -399,9 +499,11 @@ final class AppModel: ObservableObject {
// publishing isWorking/workMessage so the ui can show progress. Returns
// true if the action ran without throwing.
@discardableResult
// inMode defaults to the saved mode (the installed service)
private func runPrivilegedAction(inLabel: String,
inMode: Preferences.ServiceMode? = nil,
inBody: @Sendable @escaping (ServiceInstaller.Scope, URL) throws -> Void) async -> Bool {
let vMode = prefs.serviceMode
let vMode = inMode ?? prefs.serviceMode
guard vMode != .none else { return false }
let vScope: ServiceInstaller.Scope = (vMode == .userAgent) ? .userAgent : .rootDaemon
let vUrl = IndexStore.cacheURL(forServiceMode: vMode)
@@ -427,13 +529,15 @@ final class AppModel: ObservableObject {
// 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 }
// inForce saves even when nothing changed (to persist replay progress)
func saveCache(inForce: Bool = false) {
guard isIndexer, dirty || inForce else { return }
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
// and saving that would make the next launch skip those changes.
// While subtree walks are pending, the id from before them is kept.
let vEventId = rescanFloorEventId ?? lastEventId
dirty = false
ioQueue.async {
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
@@ -445,7 +549,7 @@ final class AppModel: ObservableObject {
private func saveCacheNow() {
guard isIndexer, dirty else { return }
let vRecords = index.records
let vEventId = lastEventId
let vEventId = rescanFloorEventId ?? lastEventId
dirty = false
ioQueue.sync {
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
@@ -476,14 +580,15 @@ final class AppModel: ObservableObject {
lastEventId, vCurrentId)
lastEventId = vCurrentId
startWatching(inSinceWhen: vCurrentId)
kickRescanSubtrees(inPaths: vRoots)
// floor 0: an interrupted walk makes the next launch start over
kickRescanSubtrees(inPaths: vRoots, inFloorEventId: 0)
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)
kickRescanSubtrees(inPaths: vMissing, inFloorEventId: lastEventId)
}
}
@@ -497,25 +602,60 @@ final class AppModel: ObservableObject {
pendingChanges.reset()
let vPending = pendingChanges
let vQueue = indexQueue
let vDelay = kChangeCoalesceSeconds
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
currentExclusions = vMatcher
watcher.start(
inRoots: vRoots,
inSinceWhen: FSEventStreamEventId(inSinceWhen)
) { [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)
}
vQueue.asyncAfter(deadline: .now() + vPending.coalesceDelay) { [weak self] in
AppModel.flush(inPending: vPending, inExclusions: vMatcher, inModel: self)
}
}
}
// resolves everything pending (on the calling background queue) and
// merges the result on main
private nonisolated static func flush(inPending: PendingChanges,
inExclusions: ExclusionMatcher,
inModel: AppModel?) {
let vChanges = inPending.drain()
// an early catch-up flush may already have taken everything
guard !vChanges.isEmpty else { return }
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: inExclusions)
DispatchQueue.main.async { [weak inModel] in
inModel?.applyResolvedChanges(inResolved: vResolved)
}
}
// adapts the update cadence to the visibility level (delays from
// Settings > Performance). Longer collection windows when nobody looks
// mean fewer wake-ups and fewer stats, since repeated writes to one file
// collapse into one check. When the app becomes more visible (or a
// delay is shortened), pending changes are applied right away.
private func activityChanged(inLevel: AppActivity.Level) {
// reader mode: a reload skipped while hidden happens now
if inLevel != .hidden && pendingReaderReload && !isIndexer {
pendingReaderReload = false
reloadFromCache()
}
let vDelay: TimeInterval
switch inLevel {
case .foreground: vDelay = prefs.updateDelayForeground
case .background: vDelay = prefs.updateDelayBackground
case .hidden: vDelay = prefs.updateDelayHidden
}
let vPrevious = pendingChanges.coalesceDelay
pendingChanges.setCoalesceDelay(vDelay)
guard vDelay < vPrevious, isIndexer, pendingChanges.hasPending, let vMatcher = currentExclusions else { return }
let vPending = pendingChanges
indexQueue.async { [weak self] in
AppModel.flush(inPending: vPending, inExclusions: vMatcher, inModel: self)
}
}
// saves the index every kAutosaveSeconds while it has unsaved changes
private func startAutosaveTimer() {
autosaveTimer?.invalidate()
// .common mode so the timer fires even while SwiftUI is busy
@@ -533,6 +673,7 @@ final class AppModel: ObservableObject {
// in-memory work; the stats were done off-main by IndexState.resolve)
private func applyResolvedChanges(inResolved: ResolvedChanges) {
guard isIndexer else { return }
let vEventIdBefore = lastEventId
let vResult = index.apply(inChanges: inResolved, inRoots: watchedRoots)
lastEventId = max(lastEventId, inResolved.maxEventId)
if vResult.changed {
@@ -540,24 +681,32 @@ final class AppModel: ObservableObject {
publishRecords()
}
if !vResult.rescans.isEmpty {
kickRescanSubtrees(inPaths: vResult.rescans)
kickRescanSubtrees(inPaths: vResult.rescans, inFloorEventId: vEventIdBefore)
}
if !vResult.newFolders.isEmpty {
kickRescanSubtrees(inPaths: vResult.newFolders, inFloorEventId: vEventIdBefore, inNewFolders: true)
}
// 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()
saveCache(inForce: true)
}
}
// 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]) {
// inFloorEventId is saved instead of lastEventId until the walk is
// merged. inNewFolders: the folders just appeared, so their records are
// merged without the O(n) removal pass of a kernel-requested rescan.
private func kickRescanSubtrees(inPaths: [String], inFloorEventId: UInt64, inNewFolders: Bool = false) {
let vRoots = SubtreeMatcher.minimalRoots(inPaths: inPaths)
guard !vRoots.isEmpty else { return }
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vGeneration = indexGeneration
rescanFloorEventId = min(rescanFloorEventId ?? inFloorEventId, inFloorEventId)
rescansInFlight += 1
indexQueue.async { [weak self] in
var vRecords: [FileRecord] = []
for vPath in vRoots {
@@ -572,8 +721,18 @@ final class AppModel: ObservableObject {
}
let vFinal = vRecords
DispatchQueue.main.async {
guard let vSelf = self, vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
guard let vSelf = self else { return }
vSelf.rescansInFlight -= 1
if vSelf.rescansInFlight <= 0 {
vSelf.rescansInFlight = 0
vSelf.rescanFloorEventId = nil
}
guard vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
if inNewFolders {
vSelf.index.mergeNewFolders(inRecords: vFinal)
} else {
vSelf.index.replaceSubtrees(inRoots: vRoots, inRecords: vFinal)
}
vSelf.dirty = true
vSelf.publishRecords()
}
@@ -591,6 +750,10 @@ final class AppModel: ObservableObject {
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vState = index
let vGeneration = indexGeneration
// replay point: changes merged after this snapshot (and those whose
// subtree walks are still running) would be lost when the pruned
// copy replaces the live index, so the watcher restarts from here
let vSnapshotEventId = rescanFloorEventId ?? lastEventId
watcher.stop()
indexQueue.async { [weak self] in
var vPruned = vState
@@ -600,21 +763,49 @@ final class AppModel: ObservableObject {
NSLog("[Allofit GUI] reconcile: %d records pruned", vRemoved)
vSelf.replaceIndex(inState: vPruned)
vSelf.dirty = true
vSelf.startWatching(inSinceWhen: vSelf.lastEventId)
vSelf.lastEventId = vSnapshotEventId
vSelf.startWatching(inSinceWhen: vSnapshotEventId)
let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) }
if !vMissing.isEmpty {
vSelf.kickRescanSubtrees(inPaths: vMissing)
vSelf.kickRescanSubtrees(inPaths: vMissing, inFloorEventId: vSnapshotEventId)
}
}
}
}
// drops paths (and what's below them) from the index right away. Used
// after Move to Trash from Allofit: the watcher ignores this process's
// own file operations, so FSEvents would never report the removal
func forget(inPaths: [String]) {
guard isIndexer, !inPaths.isEmpty else { return }
if index.removeSubtrees(inPaths: inPaths) > 0 {
dirty = true
publishRecords()
}
}
// (re)adds paths this app created or moved back itself (undo of Move to
// Trash), which FSEvents doesn't report to it. Resolved like FSEvents
// changes, so restored folders get walked too.
func notice(inPaths: [String]) {
guard isIndexer, !inPaths.isEmpty else { return }
let vChanges = inPaths.map { FSChange(path: $0, flags: 0, eventId: 0) }
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
indexQueue.async { [weak self] in
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: vMatcher)
DispatchQueue.main.async {
self?.applyResolvedChanges(inResolved: vResolved)
}
}
}
// ===========================
// MARK: Reader mode
// ===========================
// follows the cache written by another indexer (service or second instance)
private func startReaderMode() {
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
let vUrl = IndexStore.cacheURL(forServiceMode: bootMode)
let vDir = vUrl.deletingLastPathComponent().path
let vTarget = vUrl.path
NSLog("[Allofit GUI] reader mode: watching cache at %@", vTarget)
@@ -666,12 +857,30 @@ final class AppModel: ObservableObject {
// we don't need to re-render the whole list at FSEvents' rate -
// the next tick will catch any newer state.
private func reloadFromCache() {
let vNow = Date()
if let vLast = lastReloadAt, vNow.timeIntervalSince(vLast) < kMinReloadInterval {
guard !isIndexer else { return }
// nobody looks: reload once a window shows up again
if AppActivity.shared.level == .hidden {
pendingReaderReload = true
return
}
let vNow = Date()
if let vLast = lastReloadAt, vNow.timeIntervalSince(vLast) < kMinReloadInterval {
// throttled: catch up when the interval ends instead of
// dropping the change (the mtime poll has already seen it)
if !pendingReaderReload {
pendingReaderReload = true
let vWait = kMinReloadInterval - vNow.timeIntervalSince(vLast)
DispatchQueue.main.asyncAfter(deadline: .now() + vWait) { [weak self] in
guard let vSelf = self, vSelf.pendingReaderReload else { return }
vSelf.pendingReaderReload = false
vSelf.reloadFromCache()
}
}
return
}
pendingReaderReload = false
lastReloadAt = vNow
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
let vUrl = IndexStore.cacheURL(forServiceMode: bootMode)
let vCurrentEventId = lastEventId
let vCurrentCount = index.count
ioQueue.async { [weak self] in
@@ -704,6 +913,7 @@ final class AppModel: ObservableObject {
// notifies observers that the records changed
private func publishRecords() {
lastIndexChangeAt = Date()
if indexedCount != index.count {
indexedCount = index.count
}
+280 -251
View File
@@ -3,53 +3,28 @@ import AppKit
// ContentView is the main window layout: a search bar bonded to the title
// bar via `.background(.bar)` (Liquid Glass on macOS 26, vibrant material
// on macOS 15), a results table on the left, a Quick Look preview pane on
// on macOS 15), the results list on the left, a Quick Look preview pane on
// the right (toggleable via the toolbar), and a status bar at the bottom.
struct ContentView: View {
// 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.
// every index update and would re-evaluate this body 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
// per-window search model: owns this window's query + results 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
// survives app launches; subsequent changes flow back via .onChange.
@State private var columnCustomization: TableColumnCustomization<FileRecord> = ContentView.loadColumnCustomization()
// debounced background save task for columnCustomization changes.
// Cancelled+rescheduled per change so a drag (which fires onChange on
// every micro-update) only runs JSONEncoder once, off-main.
@State private var columnSaveTask: Task<Void, Never>?
// selected records, in row order (shared with the table, preview and
// status bar)
@State private var selection: [FileRecord] = []
// window undo stack (Move to Trash can be undone)
@Environment(\.undoManager) private var undoManager
// whether the right-hand preview pane is currently visible. Persisted
// across launches so the user's pane-visibility preference sticks.
@AppStorage("Allofit.showPreviewPane") private var showPreviewPane: Bool = true
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 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: searchModel.sortDescriptor)] },
set: { vNewOrder in
guard let vFirst = vNewOrder.first else { return }
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
// defer one runloop tick so we don't write back into the
// model while NSTableView is still in its sort delegate
// callback (avoids the reentrant-operation AppKit warning)
DispatchQueue.main.async {
searchModel.sortDescriptor = vDescriptor
}
}
)
}
// search syntax popover
@State private var showSyntaxHelp = false
var body: some View {
Group {
@@ -70,256 +45,214 @@ struct ContentView: View {
Button {
showPreviewPane.toggle()
} label: {
Image(systemName: showPreviewPane
? "sidebar.right"
: "sidebar.squares.right")
// a real label so VoiceOver reads it, shown as an icon
Label(showPreviewPane ? "Hide Preview" : "Show Preview",
systemImage: showPreviewPane ? "sidebar.right" : "sidebar.squares.right")
.labelStyle(.iconOnly)
}
.help(showPreviewPane ? "Hide preview" : "Show preview")
}
ToolbarItem(placement: .primaryAction) {
SettingsLink {
Image(systemName: "gearshape")
Label("Settings", systemImage: "gearshape")
.labelStyle(.iconOnly)
}
.help("Preferences (⌘,)")
}
}
.onChange(of: columnCustomization) { _, vNew in
// Debounced + off-main save. SwiftUI fires onChange on every
// micro-update during a column drag - encoding synchronously
// on main here would freeze the drag delegate. Cancel any
// pending task and reschedule so we encode at most once per
// drag (300 ms after the user lets go).
columnSaveTask?.cancel()
columnSaveTask = Task.detached(priority: .utility) {
try? await Task.sleep(nanoseconds: Self.kColumnSaveDebounceNanos)
if Task.isCancelled { return }
ContentView.saveColumnCustomization(vNew)
.help("Settings (⌘,)")
}
}
}
// search bar + table + status bar - everything except the preview pane
// search bar + results + status bar - everything except the preview pane
private var mainColumn: some View {
VStack(spacing: 0) {
searchBar
resultsTable
Divider()
StatusBarView() // isolated so its @Published refresh
// doesn't re-evaluate the Table closure
// isolated so its refreshes don't re-evaluate this body
StatusBarView(stats: searchModel.stats, selectionText: selectionSummary)
}
}
// ===========================
// MARK: Column customization persistence
// ===========================
private static func loadColumnCustomization() -> TableColumnCustomization<FileRecord> {
guard let vData = UserDefaults.standard.data(forKey: kColumnCustomizationKey),
let vCustom = try? JSONDecoder().decode(
TableColumnCustomization<FileRecord>.self,
from: vData
)
else {
return TableColumnCustomization<FileRecord>()
}
return vCustom
}
private nonisolated static func saveColumnCustomization(_ inValue: TableColumnCustomization<FileRecord>) {
guard let vData = try? JSONEncoder().encode(inValue) else { return }
UserDefaults.standard.set(vData, forKey: kColumnCustomizationKey)
}
// ===========================
// MARK: Search bar
// ===========================
// search field plus the Everything-style category filter
private var searchBar: some View {
HStack(spacing: 8) {
SearchField(
text: $searchModel.query,
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
initiallyFirstResponder: true
)
.frame(minHeight: 24)
// category filter, ANDed with the query
Picker("Filter", selection: $searchModel.filter) {
ForEach(SearchFilter.allCases) { vFilter in
Label(vFilter.title, systemImage: vFilter.symbolName).tag(vFilter)
}
}
.labelsHidden()
.pickerStyle(.menu)
.fixedSize()
.help("Show only one kind of item")
// search syntax cheat sheet
Button {
showSyntaxHelp.toggle()
} label: {
Label("Search Syntax", systemImage: "questionmark.circle")
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.help("Search syntax")
.popover(isPresented: $showSyntaxHelp, arrowEdge: .bottom) {
SyntaxHelpView()
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.bar)
}
// ===========================
// MARK: Results table
// MARK: Results
// ===========================
// native table with every result (see ResultsTable)
private var resultsTable: some View {
// Uses the explicit `rows:` form of Table so `.draggable` lives on
// TableRow rather than embedded in cell content. Cell-content
// draggable installs a SwiftUI drag-gesture recognizer that races
// with NSTableView's mouseDown→selection event on macOS 26 and
// occasionally eats left-clicks; row-level draggable doesn't.
Table(of: FileRecord.self,
ResultsTable(
records: searchModel.results,
version: searchModel.resultsVersion,
highlights: searchModel.highlights,
highlightEnabled: prefs.highlightMatches,
selection: $selection,
sortOrder: sortOrderBinding,
columnCustomization: $columnCustomization) {
TableColumn("Name", value: \FileRecord.name) { vRecord in
HStack(spacing: 6) {
Image(nsImage: IconCache.icon(
forName: vRecord.name,
isDirectory: vRecord.isDirectory
))
.resizable()
.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
// having to open the pane first. needsAuthorization
// is a stat() call so we only invoke it for the row
// that's actually selected.
if !showPreviewPane,
selection.count == 1,
selection.contains(vRecord.id),
access.needsAuthorization(for: vRecord) {
Spacer(minLength: 4)
// 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)
sort: $searchModel.sortDescriptor,
needsAuthorization: { vRecord in access.needsAuthorization(for: vRecord) },
perform: { vAction, vRecords in perform(inAction: vAction, inRecords: vRecords) }
)
// explains an empty list (loading, indexing, no match, filter on)
.overlay {
EmptyResultsView(stats: searchModel.stats, filter: searchModel.filter, hasQuery: !searchModel.query.isEmpty)
.allowsHitTesting(false)
}
}
}
.width(min: 200, ideal: 320)
.customizationID("name")
TableColumn("Path", value: \FileRecord.parentPath) { vRecord in
Text(vRecord.parentPath)
.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))
.foregroundColor(.secondary)
.monospacedDigit()
}
.width(90)
.customizationID("size")
TableColumn("Created", value: \FileRecord.dateCreated) { vRecord in
Text(Formatters.date(vRecord.dateCreated))
.foregroundColor(.secondary)
.monospacedDigit()
}
.width(140)
.customizationID("created")
TableColumn("Modified", value: \FileRecord.dateModified) { vRecord in
Text(Formatters.date(vRecord.dateModified))
.foregroundColor(.secondary)
.monospacedDigit()
}
.width(140)
.customizationID("modified")
} rows: {
ForEach(searchModel.visibleRecords) { vRecord in
TableRow(vRecord)
.draggable(URL(fileURLWithPath: vRecord.fullPath))
}
}
.contextMenu(forSelectionType: FileRecord.ID.self) { vIds in
Button("Open") { openSelection(inIds: vIds) }
Button("Reveal in Finder") { revealSelection(inIds: vIds) }
Button("Quick Look") { quickLookSelection(inIds: vIds) }
Divider()
Button("Copy Path") { copyPaths(inIds: vIds) }
} primaryAction: { vIds in
openSelection(inIds: vIds)
}
// Finder-style spacebar Quick Look. .onKeyPress only fires when the
// view (Table) has keyboard focus, so spaces typed into the search
// field still produce literal spaces in the query.
.onKeyPress(.space) {
guard !selection.isEmpty else { return .ignored }
let vUrls = recordsFor(inIds: selection)
.map { URL(fileURLWithPath: $0.fullPath) }
QuickLookCoordinator.shared.show(inUrls: vUrls)
return .handled
}
// "3 selected (12 MB)" for the status bar, nil without a selection
private var selectionSummary: String? {
guard !selection.isEmpty else { return nil }
let vBytes = selection.filter { !$0.isDirectory }.reduce(Int64(0)) { $0 + $1.size }
let vCount = "\(selection.count.formatted()) selected"
return vBytes > 0 ? "\(vCount) (\(Formatters.size(bytes: vBytes)))" : vCount
}
// ===========================
// MARK: Selection actions
// MARK: Actions
// ===========================
private func revealSelection(inIds: Set<FileRecord.ID>) {
// reveal in Finder shows the *original* file (not the staged copy),
// since the user wants to navigate to the real location on disk
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) }
NSWorkspace.shared.activateFileViewerSelecting(vUrls)
// runs an action requested by the table (menu, keyboard, double-click)
private func perform(inAction: ResultAction, inRecords: [FileRecord]) {
switch inAction {
case .primary:
switch prefs.primaryAction {
case .open: openRecords(inRecords)
case .reveal: revealRecords(inRecords)
}
private func quickLookSelection(inIds: Set<FileRecord.ID>) {
// prefer the staged URL when one exists - QLPreviewPanel renders
// it without permission issues, whereas the original would fail
let vUrls = recordsFor(inIds: inIds).map { access.effectiveURL(for: $0) }
QuickLookCoordinator.shared.show(inUrls: vUrls)
}
private func copyPaths(inIds: Set<FileRecord.ID>) {
// always copy the original path - the staged tmp path is an
// implementation detail that has no meaning outside this session
let vPaths = recordsFor(inIds: inIds).map { $0.fullPath }
case .open: openRecords(inRecords)
case .openWith(let vApp): openRecords(inRecords, withApp: vApp)
case .reveal: revealRecords(inRecords)
case .quickLook:
// prefer the staged URL when one exists - QLPreviewPanel
// renders it without permission issues
QuickLookCoordinator.shared.show(inUrls: inRecords.map { access.effectiveURL(for: $0) })
case .copyFiles:
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(vPaths.joined(separator: "\n"), forType: .string)
NSPasteboard.general.writeObjects(inRecords.map { URL(fileURLWithPath: $0.fullPath) as NSURL })
case .copyNames:
copyLines(inRecords.map(\.name))
case .copyPaths:
// always the original path - a staged copy's tmp path means
// nothing outside this session
copyLines(inRecords.map(\.fullPath))
case .trash:
trashRecords(inRecords)
case .authorize:
if let vRecord = inRecords.first {
Task { await access.authorize(vRecord) }
}
}
}
private func openSelection(inIds: Set<FileRecord.ID>) {
// open the staged copy when available so the default app can read
// it; falls back to the original path for files we can read directly
for vRecord in recordsFor(inIds: inIds) {
// reveals the records in Finder (the original files, not staged copies)
private func revealRecords(_ inRecords: [FileRecord]) {
NSWorkspace.shared.activateFileViewerSelecting(inRecords.map { URL(fileURLWithPath: $0.fullPath) })
}
// puts one string per line on the pasteboard
private func copyLines(_ inLines: [String]) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(inLines.joined(separator: "\n"), forType: .string)
}
// moves the records to the Trash: their rows go away at once, ⌘Z puts
// them back, and a failure is reported instead of silently ignored
private func trashRecords(_ inRecords: [FileRecord]) {
let vUrls = inRecords.map { URL(fileURLWithPath: $0.fullPath) }
let vUndo = undoManager
NSWorkspace.shared.recycle(vUrls) { vTrashed, vError in
// look the results up with the very URL objects passed in (a
// folder's URL gains a trailing slash, rebuilt ones wouldn't match)
var vMoved: [(record: FileRecord, original: URL, trashed: URL)] = []
for (vRecord, vUrl) in zip(inRecords, vUrls) {
if let vInTrash = vTrashed[vUrl] {
vMoved.append((vRecord, vUrl, vInTrash))
}
}
DispatchQueue.main.async {
if !vMoved.isEmpty {
searchModel.removeTrashed(inRecords: vMoved.map(\.record))
let vGone = Set(vMoved.map(\.record.id))
selection.removeAll { vGone.contains($0.id) }
registerPutBack(inMoves: vMoved.map { ($0.original, $0.trashed) }, inUndo: vUndo)
}
if let vError = vError {
let vAlert = NSAlert(error: vError)
vAlert.messageText = vMoved.isEmpty
? "Couldn't move to the Trash"
: "Some items couldn't be moved to the Trash"
vAlert.runModal()
}
}
}
}
// registers "Undo Move to Trash": moves the items back and tells the
// index (the watcher ignores this app's own file operations)
private func registerPutBack(inMoves: [(original: URL, trashed: URL)], inUndo: UndoManager?) {
guard let vUndo = inUndo else { return }
let vModel = searchModel
vUndo.registerUndo(withTarget: vModel) { vTarget in
var vRestored: [String] = []
for vMove in inMoves where (try? FileManager.default.moveItem(at: vMove.trashed, to: vMove.original)) != nil {
vRestored.append(vMove.original.path)
}
vTarget.noticeRestored(inPaths: vRestored)
}
vUndo.setActionName(inMoves.count == 1 ? "Move to Trash" : "Move \(inMoves.count) Items to Trash")
}
// opens the records with a specific app
private func openRecords(_ inRecords: [FileRecord], withApp inApp: URL) {
let vUrls = inRecords.map { access.effectiveURL(for: $0) }
NSWorkspace.shared.open(vUrls, withApplicationAt: inApp, configuration: NSWorkspace.OpenConfiguration())
}
// opens the records with their default app (the staged copy when one
// exists, so the app can read it)
private func openRecords(_ inRecords: [FileRecord]) {
for vRecord in inRecords {
NSWorkspace.shared.open(access.effectiveURL(for: vRecord))
}
}
private func recordsFor(inIds: Set<FileRecord.ID>) -> [FileRecord] {
return searchModel.visibleRecords.filter { inIds.contains($0.id) }
}
// ===========================
// MARK: Sort mapping
// ===========================
private static func mapSortOrder(inComparator: KeyPathComparator<FileRecord>) -> FileSortDescriptor {
let vAsc = inComparator.order == .forward
let vKp = inComparator.keyPath
if vKp == \FileRecord.name { return vAsc ? .nameAscending : .nameDescending }
if vKp == \FileRecord.parentPath { return vAsc ? .pathAscending : .pathDescending }
if vKp == \FileRecord.size { return vAsc ? .sizeAscending : .sizeDescending }
if vKp == \FileRecord.dateCreated { return vAsc ? .createdAscending : .createdDescending }
if vKp == \FileRecord.dateModified { return vAsc ? .modifiedAscending : .modifiedDescending }
return .nameAscending
}
private static func comparatorFor(inDescriptor: FileSortDescriptor) -> KeyPathComparator<FileRecord> {
switch inDescriptor {
case .nameAscending: return KeyPathComparator(\FileRecord.name, order: .forward)
case .nameDescending: return KeyPathComparator(\FileRecord.name, order: .reverse)
case .pathAscending: return KeyPathComparator(\FileRecord.parentPath, order: .forward)
case .pathDescending: return KeyPathComparator(\FileRecord.parentPath, order: .reverse)
case .sizeAscending: return KeyPathComparator(\FileRecord.size, order: .forward)
case .sizeDescending: return KeyPathComparator(\FileRecord.size, order: .reverse)
case .createdAscending: return KeyPathComparator(\FileRecord.dateCreated, order: .forward)
case .createdDescending: return KeyPathComparator(\FileRecord.dateCreated, order: .reverse)
case .modifiedAscending: return KeyPathComparator(\FileRecord.dateModified, order: .forward)
case .modifiedDescending: return KeyPathComparator(\FileRecord.dateModified, order: .reverse)
}
}
}
// ===========================
@@ -327,14 +260,25 @@ struct ContentView: View {
// ===========================
// Extracted into its own View so its @Published-driven refreshes (cache
// load progress, indexed count changes during a scan, service-mode flip)
// only re-evaluate this small leaf view rather than the ContentView body
// that contains the Table.
// load progress, indexed count changes during a scan, service-mode flip,
// the resource sampler's 2-second tick) only re-evaluate this small leaf
// view rather than the ContentView body that contains the results.
private struct StatusBarView: View {
@EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var searchModel: WindowSearchModel
// per-window match count and search time
@ObservedObject var stats: SearchStats
// "3 selected (12 MB)", nil without a selection
let selectionText: String?
// process memory / CPU sampler shared by all windows
@ObservedObject private var process = ProcessStats.shared
// freeze key state, shown while held
@ObservedObject private var freeze = FreezeKey.shared
// CPU use above which the figure is highlighted (100 = one core)
private let kBusyCpuPercent: Double = 50
var body: some View {
HStack(spacing: 8) {
@@ -348,18 +292,29 @@ private struct StatusBarView: View {
Text("Indexing… \(model.indexedCount.formatted()) entries")
} else {
Text(resultsText)
if let vSelection = selectionText {
Text("·")
Text(vSelection)
}
}
Spacer()
Text(model.isIndexer ? "Indexer" : "Reader")
.foregroundColor(.secondary)
switch prefs.serviceMode {
case .none: EmptyView()
case .userAgent: Text("· User service").foregroundColor(.secondary)
case .rootDaemon: Text("· Root service").foregroundColor(.secondary)
if freeze.isHeld {
Label("Frozen", systemImage: "snowflake")
.foregroundColor(.accentColor)
.help("Holding ⌥ Option pauses list updates; release to catch up")
}
if !searchModel.query.isEmpty {
Text("· Filtered").foregroundColor(.secondary)
HStack(spacing: 6) {
Text(String(format: "%.0f ms", stats.lastSearchMilliseconds))
Text("·")
Text("RAM \(Formatters.memory(bytes: process.footprintBytes))")
Text("·")
Text(String(format: "CPU %.0f%%", process.cpuPercent))
.foregroundColor(process.cpuPercent >= kBusyCpuPercent ? .orange : .secondary)
Text("·")
Text(roleText)
}
.monospacedDigit()
.help(detailsText)
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
@@ -367,14 +322,88 @@ private struct StatusBarView: View {
.foregroundColor(.secondary)
}
// "N results" plus how many are listed when the list is capped
// "N results · M indexed"
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"
let vCount = stats.matchCount
return "\(vCount.formatted()) \(vCount == 1 ? "result" : "results") · \(model.indexedCount.formatted()) indexed"
}
// indexer / reader role, plus the service kind when one is used
private var roleText: String {
let vRole = model.isIndexer ? "Indexer" : "Reader"
switch prefs.serviceMode {
case .none: return vRole
case .userAgent: return vRole + " · User service"
case .rootDaemon: return vRole + " · Root service"
}
}
// multi-line tooltip with the full set of figures
private var detailsText: String {
let vCacheBytes = IndexStore.cacheFileSize(at: IndexStore.cacheURL(forServiceMode: prefs.serviceMode))
let vUpdated = model.lastIndexChangeAt.map { $0.formatted(.relative(presentation: .named)) } ?? "never"
let vLines = [
"Memory: \(Formatters.memory(bytes: process.footprintBytes)) (peak \(Formatters.memory(bytes: process.peakFootprintBytes)))",
String(format: "CPU: %.1f%% (100%% = one core)", process.cpuPercent) + " · \(process.threadCount) threads",
String(format: "Last search: %.1f ms over ", stats.lastSearchMilliseconds) + "\(model.indexedCount.formatted()) entries",
"Index updated: \(vUpdated)",
"Cache on disk: \(Formatters.sizeOrDash(bytes: vCacheBytes))",
"Roots: \(prefs.rootPaths.count) · Role: \(roleText)"
]
return vLines.joined(separator: "\n")
}
}
// ===========================
// MARK: Empty state
// ===========================
// EmptyResultsView is shown over an empty results list and says why it's
// empty: the index is loading or being built, nothing matches, or a filter
// hides everything. It observes AppModel itself so ContentView doesn't have to.
private struct EmptyResultsView: View {
@EnvironmentObject var model: AppModel
// match count of this window
@ObservedObject var stats: SearchStats
// active category filter
let filter: SearchFilter
// true when something is typed in the search field
let hasQuery: Bool
var body: some View {
if stats.matchCount == 0 {
VStack(spacing: 8) {
if model.isLoadingCache {
ProgressView()
Text("Loading the index…")
.font(.headline)
} else if model.isIndexing {
ProgressView()
Text("Building the index…")
.font(.headline)
Text("\(model.indexedCount.formatted()) files so far. Results appear as they are found.")
.font(.callout)
} else if model.indexedCount == 0 {
Text("Nothing indexed yet")
.font(.headline)
Text("Add folders in Settings > Indexes.")
.font(.callout)
} else {
Text("No results")
.font(.headline)
if filter != .everything {
Text("Only \(filter.title.lowercased()) are shown. Choose Everything in the filter menu to search all items.")
.font(.callout)
} else if hasQuery {
Text("Click ? next to the search field for the search syntax.")
.font(.callout)
}
}
}
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 360)
}
return "\(vMatches.formatted()) results · \(vIndexed) indexed"
}
}
+21 -2
View File
@@ -3,9 +3,10 @@ import Foundation
// Formatters bundles the byte-count and date formatting used by both
// the Table's columns and the right-hand preview pane footer. Keeping
// the formatter instances cached at file scope avoids reconstructing
// them per row render, which would be expensive at 5 000 rows.
// them per row render, which would be expensive at thousands of rows.
enum Formatters {
// file-size formatter (decimal units, like Finder)
private static let kSizeFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter()
vF.countStyle = .file
@@ -17,6 +18,24 @@ enum Formatters {
return kSizeFormatter.string(fromByteCount: inBytes)
}
// memory formatter (binary units, like Activity Monitor)
private static let kMemoryFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter()
vF.countStyle = .memory
return vF
}()
// memory amount, e.g. "312 MB" (binary units, like Activity Monitor)
static func memory(bytes inBytes: UInt64) -> String {
return kMemoryFormatter.string(fromByteCount: Int64(clamping: inBytes))
}
// same as size(bytes:), but "-" for zero / unknown (missing files)
static func sizeOrDash(bytes inBytes: Int64) -> String {
return inBytes <= 0 ? "-" : size(bytes: inBytes)
}
// short date + short time, in the user's locale
private static let kDateFormatter: DateFormatter = {
let vF = DateFormatter()
vF.dateStyle = .short
@@ -24,7 +43,7 @@ enum Formatters {
return vF
}()
// short date+time, with em-dash for sentinel "no date" values
// short date+time, with "-" for sentinel "no date" values
static func date(_ inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "-" }
return kDateFormatter.string(from: inDate)
+58
View File
@@ -0,0 +1,58 @@
import AppKit
// FreezeKey reports whether the "freeze" key (⌥ Option) is held down while
// Allofit is active. Holding it pauses the background refreshes of the
// results, so rows don't move while the user reads or selects them; typing,
// sorting and filtering still apply. Released (or the app deactivated), the
// lists catch up once.
@MainActor
final class FreezeKey: ObservableObject {
// shared state for every window
static let shared = FreezeKey()
// true while ⌥ Option is held in Allofit
@Published private(set) var isHeld = false
// modifier-change monitor, alive for the app's lifetime
private var monitor: Any?
// app activation observers
private var observers: [NSObjectProtocol] = []
// starts following the modifier keys
private init() {
monitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] vEvent in
MainActor.assumeIsolated {
self?.update(inFlags: vEvent.modifierFlags)
}
return vEvent
}
// a key released while another app was frontmost never reaches the
// local monitor: re-read the state on (de)activation
for vName in [NSApplication.didResignActiveNotification, NSApplication.didBecomeActiveNotification] {
observers.append(NotificationCenter.default.addObserver(forName: vName, object: nil, queue: .main) { [weak self] vNote in
MainActor.assumeIsolated {
if vNote.name == NSApplication.didResignActiveNotification {
self?.setHeld(false)
} else {
self?.update(inFlags: NSEvent.modifierFlags)
}
}
})
}
}
// held when Option is down on its own (shortcuts such as ⌥⌘C don't
// freeze the list)
private func update(inFlags: NSEvent.ModifierFlags) {
let vMods = inFlags.intersection([.command, .option, .control, .shift])
setHeld(vMods == [.option])
}
// publishes a change of state
private func setHeld(_ inHeld: Bool) {
if isHeld != inHeld {
isHeld = inHeld
}
}
}
+116
View File
@@ -0,0 +1,116 @@
import AppKit
import Carbon.HIToolbox
// HotKeyPreset lists the global shortcuts offered in Settings. A short list
// of presets avoids a recorder UI and only offers combinations that don't
// clash with standard macOS shortcuts (⌘Space is Spotlight).
enum HotKeyPreset: String, CaseIterable, Identifiable {
case none
case optionSpace
case controlSpace
case controlOptionSpace
case optionCommandSpace
case shiftCommandSpace
var id: String { rawValue }
// menu label, e.g. "⌥ Space"
var title: String {
switch self {
case .none: return "None"
case .optionSpace: return "⌥ Space"
case .controlSpace: return "⌃ Space"
case .controlOptionSpace: return "⌃⌥ Space"
case .optionCommandSpace: return "⌥⌘ Space"
case .shiftCommandSpace: return "⇧⌘ Space"
}
}
// Carbon modifier mask for RegisterEventHotKey (nil for none)
var carbonModifiers: UInt32? {
switch self {
case .none: return nil
case .optionSpace: return UInt32(optionKey)
case .controlSpace: return UInt32(controlKey)
case .controlOptionSpace: return UInt32(controlKey | optionKey)
case .optionCommandSpace: return UInt32(optionKey | cmdKey)
case .shiftCommandSpace: return UInt32(shiftKey | cmdKey)
}
}
}
// GlobalHotKey registers one system-wide shortcut through Carbon's
// RegisterEventHotKey, which needs no Accessibility permission (unlike an
// NSEvent global monitor) and swallows the key press for us.
@MainActor
final class GlobalHotKey: ObservableObject {
// shared registration for the app
static let shared = GlobalHotKey()
// false when the chosen shortcut is already taken by another app
@Published private(set) var isAvailable = true
// called on main when the shortcut is pressed
var onPressed: (() -> Void)?
// the active registration, nil when none
private var hotKeyRef: EventHotKeyRef?
// the Carbon event handler, installed once
private var handlerRef: EventHandlerRef?
// four-char signature identifying our hot key ("ALFT")
private let kSignature: OSType = 0x414C_4654
// registers inPreset (replacing any previous one). Returns false when
// the combination is already taken by another app.
@discardableResult
func register(inPreset: HotKeyPreset) -> Bool {
unregister()
guard let vModifiers = inPreset.carbonModifiers else {
isAvailable = true
return true
}
installHandlerIfNeeded()
var vRef: EventHotKeyRef?
let vId = EventHotKeyID(signature: kSignature, id: 1)
let vStatus = RegisterEventHotKey(
UInt32(kVK_Space),
vModifiers,
vId,
GetApplicationEventTarget(),
0,
&vRef
)
guard vStatus == noErr, let vRegistered = vRef else {
NSLog("[Allofit GUI] global shortcut %@ unavailable (status %d)", inPreset.title, vStatus)
isAvailable = false
return false
}
hotKeyRef = vRegistered
isAvailable = true
return true
}
// removes the current registration, if any
func unregister() {
if let vRef = hotKeyRef {
UnregisterEventHotKey(vRef)
hotKeyRef = nil
}
}
// installs the application-level handler that receives hot key events
private func installHandlerIfNeeded() {
guard handlerRef == nil else { return }
var vType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
let vHandler: EventHandlerUPP = { _, _, _ in
DispatchQueue.main.async {
MainActor.assumeIsolated {
GlobalHotKey.shared.onPressed?()
}
}
return noErr
}
InstallEventHandler(GetApplicationEventTarget(), vHandler, 1, &vType, nil, &handlerRef)
}
}
+8 -44
View File
@@ -12,7 +12,8 @@ struct QuickLookPreviewView: NSViewRepresentable {
// the file to preview; nil clears the view
let url: URL?
func makeNSView(context: Context) -> NSView {
// creates the embedded Quick Look view
func makeNSView(context inContext: Context) -> NSView {
guard let vView = QLPreviewView(frame: .zero, style: .normal) else {
return NSView()
}
@@ -23,50 +24,13 @@ struct QuickLookPreviewView: NSViewRepresentable {
return vView
}
func updateNSView(_ nsView: NSView, context: Context) {
guard let vQlView = nsView as? QLPreviewView else { return }
// shows the current file
func updateNSView(_ inView: NSView, context inContext: Context) {
guard let vQlView = inView as? QLPreviewView else { return }
vQlView.previewItem = (url as NSURL?)
}
}
// 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 {
@ObservedObject var access: AccessManager
let record: FileRecord
var body: some View {
Button {
Task { await access.authorize(record) }
} label: {
if access.isAuthorizing(record.id) {
ProgressView()
.controlSize(.small)
.frame(width: 16, height: 16)
} else {
Image(systemName: "lock.shield.fill")
.foregroundStyle(.orange)
.font(.system(size: 14, weight: .semibold))
}
}
.buttonStyle(.plain)
.disabled(access.isAuthorizing(record.id))
.help(access.isAuthorizing(record.id)
? "Authorizing…"
: "Authorize to read this file")
}
}
// PreviewPane is the right-hand side panel in the main window. When
// exactly one row is selected it renders a Quick Look preview plus a
// small metadata footer. If the file isn't user-readable the preview
@@ -79,11 +43,11 @@ struct PreviewPane: View {
@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>
let selection: [FileRecord]
// the record to preview: only with exactly one selected
private var selectedRecord: FileRecord? {
guard selection.count == 1, let vId = selection.first else { return nil }
return searchModel.visibleRecords.first(where: { $0.id == vId })
return selection.count == 1 ? selection.first : nil
}
var body: some View {
+148
View File
@@ -0,0 +1,148 @@
import Foundation
import Combine
import Darwin
// ProcessStats samples this process's resource usage for the status bar:
// memory footprint (the figure Activity Monitor shows), its peak, and CPU
// use. One shared instance ticks every kSampleSeconds; only the status bar
// observes it, so a tick never re-renders the results table.
@MainActor
final class ProcessStats: ObservableObject {
// shared sampler used by every window's status bar
static let shared = ProcessStats()
// current physical footprint in bytes
@Published private(set) var footprintBytes: UInt64 = 0
// highest footprint since launch, in bytes
@Published private(set) var peakFootprintBytes: UInt64 = 0
// CPU use over the last interval, 100 = one full core
@Published private(set) var cpuPercent: Double = 0
// number of threads in the process
@Published private(set) var threadCount: Int = 0
// refresh interval while focused / while another app is focused;
// sampling stops entirely when no window is on screen
private let kSampleForegroundSeconds: TimeInterval = 2
private let kSampleBackgroundSeconds: TimeInterval = 5
// repeating sampler
private var timer: Timer?
// user + system CPU seconds at the previous sample
private var lastCpuSeconds: Double = 0
// wall clock of the previous sample
private var lastSampleAt = Date()
// activity subscription
private var cancellable: AnyCancellable?
// takes a first sample and ticks at a pace that follows AppActivity
private init() {
lastCpuSeconds = ProcessStats.cpuSeconds()
sample()
cancellable = AppActivity.shared.$level
.removeDuplicates()
.sink { [weak self] vLevel in
self?.reschedule(inLevel: vLevel)
}
}
// restarts the timer for the given visibility level (none when hidden)
private func reschedule(inLevel: AppActivity.Level) {
timer?.invalidate()
timer = nil
let vInterval: TimeInterval
switch inLevel {
case .hidden: return
case .background: vInterval = kSampleBackgroundSeconds
case .foreground: vInterval = kSampleForegroundSeconds
}
sample()
let vTimer = Timer(timeInterval: vInterval, repeats: true) { [weak self] _ in
MainActor.assumeIsolated {
self?.sample()
}
}
// .common so it keeps ticking during scrolling / live resize
RunLoop.main.add(vTimer, forMode: .common)
timer = vTimer
}
// reads the current figures and publishes the ones that changed
private func sample() {
let vMemory = ProcessStats.memory()
let vNow = Date()
let vCpu = ProcessStats.cpuSeconds()
let vElapsed = vNow.timeIntervalSince(lastSampleAt)
let vPercent = vElapsed > 0 ? max(0, (vCpu - lastCpuSeconds) / vElapsed * 100) : 0
lastCpuSeconds = vCpu
lastSampleAt = vNow
let vThreads = ProcessStats.threadCount()
if footprintBytes != vMemory.footprint { footprintBytes = vMemory.footprint }
if peakFootprintBytes != vMemory.peak { peakFootprintBytes = vMemory.peak }
// round so tiny jitter doesn't republish every tick
let vRounded = (vPercent * 10).rounded() / 10
if cpuPercent != vRounded { cpuPercent = vRounded }
if threadCount != vThreads { threadCount = vThreads }
}
// ===========================
// MARK: System calls
// ===========================
// physical footprint and its lifetime peak (task_vm_info)
private nonisolated static func memory() -> (footprint: UInt64, peak: UInt64) {
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)
}
}
guard vResult == KERN_SUCCESS else { return (0, 0) }
let vFootprint = vInfo.phys_footprint
return (vFootprint, max(vFootprint, UInt64(vInfo.ledger_phys_footprint_peak)))
}
// total user + system CPU time consumed so far, in seconds
private nonisolated static func cpuSeconds() -> Double {
var vUsage = rusage()
guard getrusage(RUSAGE_SELF, &vUsage) == 0 else { return 0 }
let vUser = Double(vUsage.ru_utime.tv_sec) + Double(vUsage.ru_utime.tv_usec) / 1_000_000
let vSystem = Double(vUsage.ru_stime.tv_sec) + Double(vUsage.ru_stime.tv_usec) / 1_000_000
return vUser + vSystem
}
// number of threads of this task
private nonisolated static func threadCount() -> Int {
var vThreads: thread_act_array_t?
var vCount = mach_msg_type_number_t(0)
guard task_threads(mach_task_self_, &vThreads, &vCount) == KERN_SUCCESS, let vList = vThreads else {
return 0
}
// the port array and each thread port must be released
for vI in 0..<Int(vCount) {
mach_port_deallocate(mach_task_self_, vList[vI])
}
vm_deallocate(
mach_task_self_,
vm_address_t(UInt(bitPattern: vList)),
vm_size_t(Int(vCount) * MemoryLayout<thread_t>.stride)
)
return Int(vCount)
}
}
// SearchStats holds the per-window search figures shown by the status bar.
// Kept apart from WindowSearchModel so updating them (every refilter)
// doesn't invalidate ContentView, which observes the search model for the
// table rows.
@MainActor
final class SearchStats: ObservableObject {
// number of records matching the current query
@Published var matchCount: Int = 0
// wall time of the last completed filter + sort, in milliseconds
@Published var lastSearchMilliseconds: Double = 0
}
+5 -3
View File
@@ -28,11 +28,13 @@ final class QuickLookCoordinator: NSObject, @preconcurrency QLPreviewPanelDataSo
// MARK: QLPreviewPanelDataSource
// ===========================
func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
// number of files shown by the panel
func numberOfPreviewItems(in inPanel: QLPreviewPanel!) -> Int {
return urls.count
}
func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! {
return urls[index] as NSURL
// file shown at the given panel position
func previewPanel(_ inPanel: QLPreviewPanel!, previewItemAt inIndex: Int) -> QLPreviewItem! {
return urls[inIndex] as NSURL
}
}
+792
View File
@@ -0,0 +1,792 @@
import SwiftUI
import AppKit
// Actions the results table asks its owner to perform on a set of records.
enum ResultAction {
case primary // double-click / Return (open or reveal, per Settings)
case open // open with the default app
case openWith(URL) // open with a specific app
case reveal // reveal in Finder
case quickLook // Quick Look panel
case copyFiles // copy the files themselves
case copyNames // copy names, one per line
case copyPaths // copy full paths, one per line
case trash // move to Trash
case authorize // stage an unreadable file through an admin prompt
}
// ResultsTable shows every search result in a native NSTableView. Only the
// rows on screen get views, so the list isn't capped: a query matching the
// whole index scrolls as smoothly as one matching ten files. It also gives
// Finder-accurate drag and drop (file URLs on the pasteboard), column
// order / width / visibility autosave, and Finder-style keyboard shortcuts.
struct ResultsTable: NSViewRepresentable {
// all results, already filtered and sorted
let records: ResultList
// bumped by the search model whenever records change (cheap identity)
let version: Int
// what to emphasize in names / folders
let highlights: SearchEngine.Highlights
// false to show plain text (Settings > General)
let highlightEnabled: Bool
// selected records, in row order (handed out directly so the window
// never has to look them up among hundreds of thousands of results)
@Binding var selection: [FileRecord]
// active sort, driven by header clicks
@Binding var sort: FileSortDescriptor
// true when the record isn't readable without authorization
let needsAuthorization: (FileRecord) -> Bool
// runs an action on records
let perform: (ResultAction, [FileRecord]) -> Void
// column identifiers, also the autosave keys
static let kNameColumn = NSUserInterfaceItemIdentifier("name")
static let kPathColumn = NSUserInterfaceItemIdentifier("path")
static let kSizeColumn = NSUserInterfaceItemIdentifier("size")
static let kCreatedColumn = NSUserInterfaceItemIdentifier("created")
static let kModifiedColumn = NSUserInterfaceItemIdentifier("modified")
// creates the coordinator acting as data source and delegate
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
// builds the scroll view, table and columns
func makeNSView(context inContext: Context) -> NSScrollView {
let vTable = ResultsNSTableView()
vTable.coordinator = inContext.coordinator
vTable.allowsMultipleSelection = true
vTable.allowsColumnReordering = true
vTable.allowsColumnResizing = true
vTable.usesAlternatingRowBackgroundColors = true
vTable.style = .fullWidth
vTable.columnAutoresizingStyle = .noColumnAutoresizing
vTable.intercellSpacing = NSSize(width: 6, height: 2)
for (vId, vTitle, vWidth, vMin) in [
(Self.kNameColumn, "Name", 320.0, 160.0),
(Self.kPathColumn, "Path", 380.0, 120.0),
(Self.kSizeColumn, "Size", 90.0, 60.0),
(Self.kCreatedColumn, "Created", 140.0, 90.0),
(Self.kModifiedColumn, "Modified", 140.0, 90.0)
] {
let vColumn = NSTableColumn(identifier: vId)
vColumn.title = vTitle
vColumn.width = vWidth
vColumn.minWidth = vMin
vColumn.sortDescriptorPrototype = NSSortDescriptor(key: vId.rawValue, ascending: true)
vTable.addTableColumn(vColumn)
}
// remembers column order, widths and hidden columns across launches
vTable.autosaveName = "Allofit.ResultsTable"
vTable.autosaveTableColumns = true
vTable.dataSource = inContext.coordinator
vTable.delegate = inContext.coordinator
vTable.target = inContext.coordinator
vTable.doubleAction = #selector(Coordinator.doubleClicked(_:))
// dragging out of the app copies the files (like Finder)
vTable.setDraggingSourceOperationMask(.copy, forLocal: false)
let vMenu = NSMenu()
vMenu.delegate = inContext.coordinator
vTable.menu = vMenu
inContext.coordinator.installHeaderMenu(on: vTable)
let vScroll = NSScrollView()
vScroll.documentView = vTable
vScroll.hasVerticalScroller = true
vScroll.hasHorizontalScroller = true
vScroll.autohidesScrollers = true
vScroll.borderType = .noBorder
inContext.coordinator.table = vTable
inContext.coordinator.apply(inParent: self, inForce: true)
return vScroll
}
// pushes new results / sort / selection into the table
func updateNSView(_ inView: NSScrollView, context inContext: Context) {
inContext.coordinator.apply(inParent: self, inForce: false)
}
// ===========================
// MARK: Sort mapping
// ===========================
// sort mode for a column key + direction
static func descriptor(forKey inKey: String, ascending inAscending: Bool) -> FileSortDescriptor {
switch inKey {
case kPathColumn.rawValue: return inAscending ? .pathAscending : .pathDescending
case kSizeColumn.rawValue: return inAscending ? .sizeAscending : .sizeDescending
case kCreatedColumn.rawValue: return inAscending ? .createdAscending : .createdDescending
case kModifiedColumn.rawValue: return inAscending ? .modifiedAscending : .modifiedDescending
default: return inAscending ? .nameAscending : .nameDescending
}
}
// column key + direction for a sort mode
static func sortDescriptor(for inSort: FileSortDescriptor) -> NSSortDescriptor {
switch inSort {
case .nameAscending: return NSSortDescriptor(key: kNameColumn.rawValue, ascending: true)
case .nameDescending: return NSSortDescriptor(key: kNameColumn.rawValue, ascending: false)
case .pathAscending: return NSSortDescriptor(key: kPathColumn.rawValue, ascending: true)
case .pathDescending: return NSSortDescriptor(key: kPathColumn.rawValue, ascending: false)
case .sizeAscending: return NSSortDescriptor(key: kSizeColumn.rawValue, ascending: true)
case .sizeDescending: return NSSortDescriptor(key: kSizeColumn.rawValue, ascending: false)
case .createdAscending: return NSSortDescriptor(key: kCreatedColumn.rawValue, ascending: true)
case .createdDescending: return NSSortDescriptor(key: kCreatedColumn.rawValue, ascending: false)
case .modifiedAscending: return NSSortDescriptor(key: kModifiedColumn.rawValue, ascending: true)
case .modifiedDescending: return NSSortDescriptor(key: kModifiedColumn.rawValue, ascending: false)
}
}
// ===========================
// MARK: Coordinator
// ===========================
// Coordinator feeds the table from the current records and turns table
// events (selection, sort clicks, menus, drags) into SwiftUI updates.
@MainActor
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
// latest SwiftUI description of the table
var parent: ResultsTable
// the table, set once created
weak var table: NSTableView?
// rows currently shown
private var records = ResultList()
// version of `records`, to skip redundant reloads
private var shownVersion = -1
// highlight settings the visible rows were drawn with
private var shownHighlights = SearchEngine.Highlights()
private var shownHighlightEnabled = true
// sort the header currently shows
private var shownSort: FileSortDescriptor?
// true while the coordinator itself changes selection / sort, so the
// resulting delegate callbacks aren't echoed back to SwiftUI
private var isApplying = false
// binds to the initial SwiftUI description
init(_ inParent: ResultsTable) {
parent = inParent
}
// syncs the table with a (possibly) new SwiftUI description
func apply(inParent: ResultsTable, inForce: Bool) {
parent = inParent
guard let vTable = table else { return }
isApplying = true
defer { isApplying = false }
if shownSort != inParent.sort {
shownSort = inParent.sort
vTable.sortDescriptors = [ResultsTable.sortDescriptor(for: inParent.sort)]
}
let vHighlightsChanged = shownHighlights != inParent.highlights
|| shownHighlightEnabled != inParent.highlightEnabled
if inForce || shownVersion != inParent.version || vHighlightsChanged {
shownVersion = inParent.version
shownHighlights = inParent.highlights
shownHighlightEnabled = inParent.highlightEnabled
records = inParent.records
vTable.reloadData()
// background refreshes reload often: keep the overlay of
// the hovered cell instead of making it flicker away
(vTable as? ResultsNSTableView)?.refreshHover()
restoreSelection(inTable: vTable)
} else if selectedIds(inTable: vTable) != Set(inParent.selection.map(\.id)) {
restoreSelection(inTable: vTable)
}
}
// selects the rows of the SwiftUI selection in the (new) list. One
// pass over the rows, only with a selection; records that are gone
// (trashed, filtered out) are dropped from the selection so later
// updates don't keep searching for them
private func restoreSelection(inTable: NSTableView) {
let vWanted = Set(parent.selection.map(\.id))
var vRows = IndexSet()
if !vWanted.isEmpty {
for vRow in 0..<records.count where vWanted.contains(records[vRow].id) {
vRows.insert(vRow)
if vRows.count == vWanted.count { break }
}
}
inTable.selectRowIndexes(vRows, byExtendingSelection: false)
let vFound = selectedRecords(inTable: inTable)
if vFound.count != parent.selection.count || vFound != parent.selection {
DispatchQueue.main.async { [weak self] in
self?.parent.selection = vFound
}
}
}
// selected rows' records, in row order (cost follows the selection)
private func selectedRecords(inTable: NSTableView) -> [FileRecord] {
return inTable.selectedRowIndexes.compactMap { $0 < records.count ? records[$0] : nil }
}
// ids of the rows selected in the table
private func selectedIds(inTable: NSTableView) -> Set<FileRecord.ID> {
var vIds = Set<FileRecord.ID>()
for vRow in inTable.selectedRowIndexes where vRow < records.count {
vIds.insert(records[vRow].id)
}
return vIds
}
// the selected records, for the actions
var currentSelection: [FileRecord] {
guard let vTable = table else { return [] }
return selectedRecords(inTable: vTable)
}
// runs an action on the current selection
func perform(_ inAction: ResultAction) {
let vRecords = currentSelection
guard !vRecords.isEmpty else { return }
parent.perform(inAction, vRecords)
}
// ===========================
// MARK: Data source
// ===========================
// number of results
func numberOfRows(in inTableView: NSTableView) -> Int {
return records.count
}
// drag: each row writes its file URL, exactly like a Finder drag
func tableView(_ inTableView: NSTableView, pasteboardWriterForRow inRow: Int) -> NSPasteboardWriting? {
guard inRow < records.count else { return nil }
return URL(fileURLWithPath: records[inRow].fullPath) as NSURL
}
// header click: switch the sort (written back after the callback)
func tableView(_ inTableView: NSTableView, sortDescriptorsDidChange inOld: [NSSortDescriptor]) {
guard !isApplying, let vFirst = inTableView.sortDescriptors.first, let vKey = vFirst.key else { return }
let vSort = ResultsTable.descriptor(forKey: vKey, ascending: vFirst.ascending)
shownSort = vSort
// deferred so SwiftUI isn't updated from inside a table callback
DispatchQueue.main.async { [weak self] in
self?.parent.sort = vSort
}
}
// ===========================
// MARK: Delegate
// ===========================
// cell view for one row / column, reused from the table's pool
func tableView(_ inTableView: NSTableView, viewFor inColumn: NSTableColumn?, row inRow: Int) -> NSView? {
guard let vColumn = inColumn, inRow < records.count else { return nil }
let vRecord = records[inRow]
let vCell = (inTableView.makeView(withIdentifier: vColumn.identifier, owner: nil) as? ResultCellView)
?? ResultCellView(inIdentifier: vColumn.identifier, inWithIcon: vColumn.identifier == ResultsTable.kNameColumn)
let vTerms = parent.highlightEnabled ? parent.highlights : SearchEngine.Highlights()
switch vColumn.identifier {
case ResultsTable.kNameColumn:
vCell.imageView?.image = IconCache.icon(forName: vRecord.name, isDirectory: vRecord.isDirectory)
vCell.show(inText: vRecord.name, inTerms: vTerms.name, inSecondary: false, inMiddleTruncation: false)
case ResultsTable.kPathColumn:
vCell.show(inText: vRecord.parentPath, inTerms: vTerms.path, inSecondary: true, inMiddleTruncation: true)
case ResultsTable.kSizeColumn:
vCell.show(inText: vRecord.isDirectory ? "-" : Formatters.size(bytes: vRecord.size), inTerms: [], inSecondary: true, inMiddleTruncation: false)
case ResultsTable.kCreatedColumn:
vCell.show(inText: Formatters.date(vRecord.dateCreated), inTerms: [], inSecondary: true, inMiddleTruncation: false)
default:
vCell.show(inText: Formatters.date(vRecord.dateModified), inTerms: [], inSecondary: true, inMiddleTruncation: false)
}
return vCell
}
// selection changed in the table: publish the ids
func tableViewSelectionDidChange(_ inNotification: Notification) {
guard !isApplying, let vTable = table else { return }
let vRecords = selectedRecords(inTable: vTable)
DispatchQueue.main.async { [weak self] in
guard let vSelf = self, vSelf.parent.selection != vRecords else { return }
vSelf.parent.selection = vRecords
}
}
// double-click on a row
@objc func doubleClicked(_ inSender: Any?) {
guard let vTable = table, vTable.clickedRow >= 0 else { return }
perform(.primary)
}
// ===========================
// MARK: Menus
// ===========================
// right-click menu, rebuilt for the clicked row: like Finder, a click
// outside the selection selects that row first
func menuNeedsUpdate(_ inMenu: NSMenu) {
inMenu.removeAllItems()
guard let vTable = table else { return }
let vClicked = vTable.clickedRow
if vClicked >= 0, !vTable.selectedRowIndexes.contains(vClicked) {
vTable.selectRowIndexes(IndexSet(integer: vClicked), byExtendingSelection: false)
}
let vSelected = currentSelection
guard !vSelected.isEmpty else { return }
// key equivalents are shown as hints (the table handles the keys)
inMenu.addItem(ClosureMenuItem("Open") { [weak self] in self?.perform(.open) }.showingKey("\r", []))
if let vFirst = vSelected.first {
inMenu.addItem(openWithItem(for: vFirst))
}
inMenu.addItem(ClosureMenuItem("Reveal in Finder") { [weak self] in self?.perform(.reveal) }.showingKey("\r", [.command]))
inMenu.addItem(ClosureMenuItem("Quick Look") { [weak self] in self?.perform(.quickLook) }.showingKey("y", [.command]))
inMenu.addItem(.separator())
inMenu.addItem(ClosureMenuItem("Copy") { [weak self] in self?.perform(.copyFiles) }.showingKey("c", [.command]))
inMenu.addItem(ClosureMenuItem("Copy Name") { [weak self] in self?.perform(.copyNames) })
inMenu.addItem(ClosureMenuItem("Copy Path") { [weak self] in self?.perform(.copyPaths) }.showingKey("c", [.command, .option]))
if vSelected.count == 1, let vOnly = vSelected.first, parent.needsAuthorization(vOnly) {
inMenu.addItem(.separator())
inMenu.addItem(ClosureMenuItem("Authorize Access…") { [weak self] in self?.perform(.authorize) })
}
inMenu.addItem(.separator())
inMenu.addItem(ClosureMenuItem("Move to Trash") { [weak self] in self?.perform(.trash) }.showingKey("\u{8}", [.command]))
}
// "Open With" submenu: default app first, then the other candidates
private func openWithItem(for inRecord: FileRecord) -> NSMenuItem {
let vItem = NSMenuItem(title: "Open With", action: nil, keyEquivalent: "")
let vSub = NSMenu()
let vUrl = URL(fileURLWithPath: inRecord.fullPath)
let vDefault = NSWorkspace.shared.urlForApplication(toOpen: vUrl)
let vApps = [vDefault].compactMap { $0 }
+ NSWorkspace.shared.urlsForApplications(toOpen: vUrl).filter { $0 != vDefault }
for vApp in vApps {
let vTitle = FileManager.default.displayName(atPath: vApp.path) + (vApp == vDefault ? " (default)" : "")
let vAppItem = ClosureMenuItem(vTitle) { [weak self] in self?.perform(.openWith(vApp)) }
let vIcon = NSWorkspace.shared.icon(forFile: vApp.path)
vIcon.size = NSSize(width: 16, height: 16)
vAppItem.image = vIcon
vSub.addItem(vAppItem)
}
vItem.submenu = vSub
vItem.isEnabled = !vApps.isEmpty
return vItem
}
// header right-click menu to show / hide columns (Name stays)
func installHeaderMenu(on inTable: NSTableView) {
let vMenu = NSMenu()
for vColumn in inTable.tableColumns where vColumn.identifier != ResultsTable.kNameColumn {
let vItem = ClosureMenuItem(vColumn.title) { [weak vColumn] in
vColumn?.isHidden.toggle()
}
vItem.representedObject = vColumn
vMenu.addItem(vItem)
}
vMenu.delegate = HeaderMenuStates.shared
inTable.headerView?.menu = vMenu
}
}
}
// ResultsNSTableView adds Finder-style keyboard shortcuts and Edit > Copy to
// the results table.
final class ResultsNSTableView: NSTableView {
// owner that performs the actions
weak var coordinator: ResultsTable.Coordinator?
// how long the pointer rests on a cut-off cell before its full text shows
private let kHoverDelaySeconds: TimeInterval = 0.5
// cell under the pointer (row, column), nil when none
private var hoveredCell: (row: Int, column: Int)?
// pending overlay display for the hovered cell
private var hoverTimer: Timer?
// follows the pointer over the visible part of the table
override func updateTrackingAreas() {
super.updateTrackingAreas()
for vArea in trackingAreas where vArea.owner === self {
removeTrackingArea(vArea)
}
addTrackingArea(NSTrackingArea(
rect: .zero,
options: [.mouseMoved, .mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect],
owner: self,
userInfo: nil
))
}
// pointer moved: restart the hover delay when it enters another cell
override func mouseMoved(with inEvent: NSEvent) {
super.mouseMoved(with: inEvent)
let vPoint = convert(inEvent.locationInWindow, from: nil)
let vRow = row(at: vPoint)
let vColumn = column(at: vPoint)
if let vCurrent = hoveredCell, vCurrent.row == vRow, vCurrent.column == vColumn { return }
endHover()
guard vRow >= 0, vColumn >= 0 else { return }
hoveredCell = (vRow, vColumn)
hoverTimer = Timer.scheduledTimer(withTimeInterval: kHoverDelaySeconds, repeats: false) { [weak self] _ in
MainActor.assumeIsolated {
self?.showHoverOverlay(inRow: vRow, inColumn: vColumn)
}
}
}
// shows the full text if that cell is still hovered and cut off
private func showHoverOverlay(inRow: Int, inColumn: Int) {
guard let vCurrent = hoveredCell, vCurrent.row == inRow, vCurrent.column == inColumn,
let vWindow = window, vWindow.isKeyWindow || vWindow.isMainWindow,
let vCell = view(atColumn: inColumn, row: inRow, makeIfNecessary: false) as? ResultCellView,
vCell.isTruncated, let vFrame = vCell.labelScreenFrame
else {
HoverOverlay.shared.hide()
return
}
// updated in place when already shown (no flicker on reloads)
HoverOverlay.shared.show(inText: vCell.overlayText(), inFrame: vFrame, inParent: vWindow)
}
// after a reload: redraw the overlay for the still-hovered cell (its
// content may have changed), or hide it if that text now fits
func refreshHover() {
guard let vCurrent = hoveredCell, hoverTimer?.isValid != true else { return }
layoutSubtreeIfNeeded()
showHoverOverlay(inRow: vCurrent.row, inColumn: vCurrent.column)
}
// listens for ↓ in the search field of the same window
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
NotificationCenter.default.removeObserver(self, name: .allofitFocusResults, object: nil)
if let vWindow = window {
NotificationCenter.default.addObserver(
self,
selector: #selector(focusFromSearchField(_:)),
name: .allofitFocusResults,
object: vWindow
)
}
}
// takes the focus from the search field and selects the first row
// when nothing is selected yet
@objc private func focusFromSearchField(_ inNotification: Notification) {
guard numberOfRows > 0 else { return }
window?.makeFirstResponder(self)
if selectedRow < 0 {
selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
}
scrollRowToVisible(max(selectedRow, 0))
}
// cancels the pending overlay and hides the shown one
func endHover() {
hoverTimer?.invalidate()
hoverTimer = nil
hoveredCell = nil
HoverOverlay.shared.hide()
}
// pointer left the table
override func mouseExited(with inEvent: NSEvent) {
super.mouseExited(with: inEvent)
endHover()
}
// scrolling moves the rows under the overlay: hide it
override func scrollWheel(with inEvent: NSEvent) {
endHover()
super.scrollWheel(with: inEvent)
}
// a click dismisses the overlay
override func mouseDown(with inEvent: NSEvent) {
endHover()
super.mouseDown(with: inEvent)
}
// leaving the window (closed / moved) dismisses the overlay
override func viewWillMove(toWindow inWindow: NSWindow?) {
endHover()
super.viewWillMove(toWindow: inWindow)
}
// Space / Return / ⌘Return / ⌘Y / ⌥⌘C / ⌘⌫; everything else (arrows,
// type-select, ⌘A) goes to NSTableView
override func keyDown(with inEvent: NSEvent) {
endHover()
let vMods = inEvent.modifierFlags.intersection([.command, .option, .shift, .control])
let vChars = inEvent.charactersIgnoringModifiers?.lowercased() ?? ""
let vAction: ResultAction?
switch (Int(inEvent.keyCode), vMods) {
case (49, []): vAction = .quickLook // Space
case (36, []), (76, []): vAction = .primary // Return / Enter
case (36, [.command]), (76, [.command]): vAction = .reveal
case (51, [.command]): vAction = .trash // ⌘⌫
case (126, []) where selectedRow <= 0: // ↑ on the first row
NotificationCenter.default.post(name: .allofitFocusSearch, object: nil)
return
default:
if vChars == "y" && vMods == [.command] {
vAction = .quickLook
} else if vChars == "c" && vMods == [.command, .option] {
vAction = .copyPaths
} else {
vAction = nil
}
}
guard let vRun = vAction, selectedRow >= 0 else {
super.keyDown(with: inEvent)
return
}
coordinator?.perform(vRun)
}
// Edit > Copy (⌘C) copies the files themselves, like Finder
@objc func copy(_ inSender: Any?) {
coordinator?.perform(.copyFiles)
}
// enables Edit > Copy only with a selection
override func validateUserInterfaceItem(_ inItem: NSValidatedUserInterfaceItem) -> Bool {
if inItem.action == #selector(copy(_:)) {
return selectedRow >= 0
}
return super.validateUserInterfaceItem(inItem)
}
}
// ResultCellView is one cell: optional icon + a label. Highlights are drawn
// in bold; the colors follow the row's selection state so bold text stays
// readable on the blue selection.
final class ResultCellView: NSTableCellView {
// text, highlight terms and style of the current content
private var text = ""
private var terms: [String] = []
private var isSecondary = false
// builds the cell's subviews
init(inIdentifier: NSUserInterfaceItemIdentifier, inWithIcon: Bool) {
super.init(frame: .zero)
identifier = inIdentifier
let vLabel = NSTextField(labelWithString: "")
vLabel.translatesAutoresizingMaskIntoConstraints = false
vLabel.lineBreakMode = .byTruncatingTail
vLabel.cell?.usesSingleLineMode = true
// the full text of a cut-off cell comes from HoverOverlay (shown by
// the table after a short hover), not AppKit's expansion tooltip
vLabel.allowsExpansionToolTips = false
addSubview(vLabel)
textField = vLabel
if inWithIcon {
let vImage = NSImageView()
vImage.translatesAutoresizingMaskIntoConstraints = false
vImage.imageScaling = .scaleProportionallyDown
addSubview(vImage)
imageView = vImage
NSLayoutConstraint.activate([
vImage.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2),
vImage.centerYAnchor.constraint(equalTo: centerYAnchor),
vImage.widthAnchor.constraint(equalToConstant: 16),
vImage.heightAnchor.constraint(equalToConstant: 16),
vLabel.leadingAnchor.constraint(equalTo: vImage.trailingAnchor, constant: 6)
])
} else {
vLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2).isActive = true
}
NSLayoutConstraint.activate([
vLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -2),
vLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
])
}
// not used: cells are created in code
required init?(coder inCoder: NSCoder) {
fatalError("init(coder:) is not supported")
}
// sets the content and redraws it
func show(inText: String, inTerms: [String], inSecondary: Bool, inMiddleTruncation: Bool) {
text = inText
terms = inTerms
isSecondary = inSecondary
textField?.lineBreakMode = inMiddleTruncation ? .byTruncatingMiddle : .byTruncatingTail
render()
}
// selection highlight changed: recolor
override var backgroundStyle: NSView.BackgroundStyle {
didSet { render() }
}
// draws the label, bold where the terms match
private func render() {
guard let vLabel = textField else { return }
let vEmphasized = backgroundStyle == .emphasized
let vColor: NSColor = vEmphasized
? .alternateSelectedControlTextColor
: (isSecondary ? .secondaryLabelColor : .labelColor)
vLabel.attributedStringValue = styledText(inColor: vColor)
}
// the text in the cell's fonts, bold where the terms match
private func styledText(inColor: NSColor) -> NSAttributedString {
let vFont = isSecondary && terms.isEmpty
? NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
: NSFont.systemFont(ofSize: NSFont.systemFontSize)
let vString = NSMutableAttributedString(string: text, attributes: [.font: vFont, .foregroundColor: inColor])
let vBold = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
let vNs = text as NSString
for vTerm in terms {
var vSearch = NSRange(location: 0, length: vNs.length)
while vSearch.length > 0 {
let vFound = vNs.range(of: vTerm, options: [.caseInsensitive, .diacriticInsensitive], range: vSearch)
if vFound.location == NSNotFound || vFound.length == 0 { break }
vString.addAttribute(.font, value: vBold, range: vFound)
let vNext = vFound.location + vFound.length
vSearch = NSRange(location: vNext, length: vNs.length - vNext)
}
}
return vString
}
// true when the text doesn't fit and is shown cut off
var isTruncated: Bool {
guard let vLabel = textField else { return false }
let vAvailable = bounds.width - vLabel.frame.minX - 2
return styledText(inColor: .labelColor).size().width + 4 > vAvailable
}
// the full text for the hover overlay, in normal (unselected) colors
func overlayText() -> NSAttributedString {
return styledText(inColor: isSecondary ? .secondaryLabelColor : .labelColor)
}
// the label's frame in screen coordinates, where the overlay goes
var labelScreenFrame: NSRect? {
guard let vLabel = textField, let vWindow = window else { return nil }
return vWindow.convertToScreen(vLabel.convert(vLabel.bounds, to: nil))
}
}
// HoverOverlay is the panel showing the full text of a cut-off cell. It
// ignores the mouse, so it can't take the hover away from the table (the
// built-in expansion tooltip vanished when the pointer touched it), and it
// floats exactly over the cell's text.
@MainActor
final class HoverOverlay {
// shared overlay (only one cell is hovered at a time)
static let shared = HoverOverlay()
// the borderless panel, created on first use
private var panel: NSPanel?
// the label inside the panel
private let label = NSTextField(labelWithString: "")
// padding between the panel edge and the text
private let kPadding = NSSize(width: 4, height: 1)
// shows inText over inFrame (screen coordinates) of inParent's window
func show(inText: NSAttributedString, inFrame: NSRect, inParent: NSWindow) {
let vPanel = panel ?? makePanel()
label.attributedStringValue = inText
let vTextSize = inText.size()
var vFrame = NSRect(
x: inFrame.minX - kPadding.width,
y: inFrame.midY - (vTextSize.height / 2) - kPadding.height,
width: ceil(vTextSize.width) + kPadding.width * 2 + 4,
height: ceil(vTextSize.height) + kPadding.height * 2
)
// keep it on screen: shift left when it would run past the edge
if let vScreen = inParent.screen?.visibleFrame, vFrame.maxX > vScreen.maxX {
vFrame.origin.x = max(vScreen.minX, vScreen.maxX - vFrame.width)
}
vPanel.setFrame(vFrame, display: true)
label.frame = NSRect(x: kPadding.width, y: kPadding.height, width: vFrame.width - kPadding.width * 2, height: ceil(vTextSize.height))
if vPanel.parent !== inParent {
vPanel.parent?.removeChildWindow(vPanel)
inParent.addChildWindow(vPanel, ordered: .above)
}
vPanel.orderFront(nil)
}
// hides the overlay
func hide() {
guard let vPanel = panel, vPanel.isVisible else { return }
vPanel.parent?.removeChildWindow(vPanel)
vPanel.orderOut(nil)
}
// builds the panel: borderless, tooltip-like background, click-through
private func makePanel() -> NSPanel {
let vPanel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true)
vPanel.isOpaque = false
vPanel.backgroundColor = .clear
vPanel.hasShadow = true
vPanel.ignoresMouseEvents = true
vPanel.level = .floating
let vBackground = NSVisualEffectView()
vBackground.material = .toolTip
vBackground.state = .active
vBackground.wantsLayer = true
vBackground.layer?.cornerRadius = 4
vBackground.layer?.borderWidth = 0.5
vBackground.layer?.borderColor = NSColor.separatorColor.cgColor
vPanel.contentView = vBackground
label.lineBreakMode = .byClipping
vBackground.addSubview(label)
panel = vPanel
return vPanel
}
}
// ClosureMenuItem is an NSMenuItem that runs a closure.
final class ClosureMenuItem: NSMenuItem {
// what the item does
private let handler: () -> Void
// creates an item titled inTitle running inHandler
init(_ inTitle: String, _ inHandler: @escaping () -> Void) {
handler = inHandler
super.init(title: inTitle, action: #selector(run(_:)), keyEquivalent: "")
target = self
}
// not used: items are created in code
required init(coder inCoder: NSCoder) {
fatalError("init(coder:) is not supported")
}
// menu callback
@objc private func run(_ inSender: Any?) {
handler()
}
// shows a shortcut next to the title and returns the item
func showingKey(_ inKey: String, _ inModifiers: NSEvent.ModifierFlags) -> ClosureMenuItem {
keyEquivalent = inKey
keyEquivalentModifierMask = inModifiers
return self
}
}
// HeaderMenuStates ticks the visible columns each time the header menu opens.
final class HeaderMenuStates: NSObject, NSMenuDelegate {
// shared delegate for every results table's header menu
static let shared = HeaderMenuStates()
// marks shown columns with a checkmark
func menuNeedsUpdate(_ inMenu: NSMenu) {
for vItem in inMenu.items {
if let vColumn = vItem.representedObject as? NSTableColumn {
vItem.state = vColumn.isHidden ? .off : .on
}
}
}
}
+69 -31
View File
@@ -5,6 +5,9 @@ import AppKit
// listens and re-grabs first responder, mimicking the standard Find shortcut.
extension Notification.Name {
static let allofitFocusSearch = Notification.Name("AllofitFocusSearch")
// posted by the search field (object: its window) when ↓ is pressed:
// that window's results list takes the keyboard focus
static let allofitFocusResults = Notification.Name("AllofitFocusResults")
}
// SearchField wraps AppKit's NSSearchField so we get native macOS behaviors
@@ -17,17 +20,15 @@ struct SearchField: NSViewRepresentable {
@Binding var text: String
// placeholder shown when the field is empty
var placeholder: String = ""
// fired when the user presses Return inside the field
var onSubmit: (() -> Void)? = nil
// set true to grab keyboard focus when the field is first inserted
var initiallyFirstResponder: Bool = false
// builds and configures the underlying NSSearchField
func makeNSView(context: Context) -> NSSearchField {
func makeNSView(context inContext: Context) -> NSSearchField {
let vField = NSSearchField()
vField.placeholderString = placeholder
vField.delegate = context.coordinator
vField.target = context.coordinator
vField.delegate = inContext.coordinator
vField.target = inContext.coordinator
vField.action = #selector(Coordinator.actionTriggered(_:))
// keep filtering live as the user types
vField.sendsSearchStringImmediately = false
@@ -54,7 +55,15 @@ struct SearchField: NSViewRepresentable {
vMenu.addItem(vClear)
vField.searchMenuTemplate = vMenu
context.coordinator.field = vField
inContext.coordinator.field = vField
// a query sent from outside before this window existed
if let vPending = WindowPresenter.pendingQuery {
WindowPresenter.pendingQuery = nil
vField.stringValue = vPending
DispatchQueue.main.async {
inContext.coordinator.parent.text = vPending
}
}
if initiallyFirstResponder {
DispatchQueue.main.async {
@@ -65,12 +74,13 @@ struct SearchField: NSViewRepresentable {
}
// keeps the NSSearchField's stringValue in sync with our binding
func updateNSView(_ nsView: NSSearchField, context: Context) {
if nsView.stringValue != text {
nsView.stringValue = text
func updateNSView(_ inView: NSSearchField, context inContext: Context) {
if inView.stringValue != text {
inView.stringValue = text
}
}
// creates the delegate bridging AppKit events to SwiftUI
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
@@ -97,6 +107,7 @@ struct SearchField: NSViewRepresentable {
// pending auto-save task; cancelled+rescheduled on every keystroke
private var autoSaveTask: Task<Void, Never>?
// binds to the SearchField and listens for the Find shortcut
init(_ inParent: SearchField) {
parent = inParent
super.init()
@@ -108,8 +119,17 @@ struct SearchField: NSViewRepresentable {
name: .allofitFocusSearch,
object: nil
)
// queries sent from outside (Finder service, folder dropped on
// the Dock icon) replace the text of the key window's field
NotificationCenter.default.addObserver(
self,
selector: #selector(handleSetQuery(_:)),
name: .allofitSetQuery,
object: nil
)
}
// stops listening for the Find shortcut
deinit {
NotificationCenter.default.removeObserver(self)
}
@@ -125,9 +145,21 @@ struct SearchField: NSViewRepresentable {
vField.selectText(nil)
}
// puts an external query in the key window's field, caret at the end
// so the user can keep typing more terms
@objc private func handleSetQuery(_ inNotification: Notification) {
guard let vQuery = inNotification.object as? String,
let vField = field, vField.window?.isKeyWindow == true else { return }
WindowPresenter.pendingQuery = nil
vField.stringValue = vQuery
parent.text = vQuery
vField.window?.makeFirstResponder(vField)
vField.currentEditor()?.selectedRange = NSRange(location: (vQuery as NSString).length, length: 0)
}
// fired on every keystroke; reset navigation and push text to SwiftUI
func controlTextDidChange(_ notification: Notification) {
guard let vField = notification.object as? NSSearchField else { return }
func controlTextDidChange(_ inNotification: Notification) {
guard let vField = inNotification.object as? NSSearchField else { return }
navigationIndex = nil
let vValue = vField.stringValue
if parent.text != vValue {
@@ -162,27 +194,33 @@ struct SearchField: NSViewRepresentable {
}
// NSSearchField's action target: fires on Return and on recent-pick
@objc func actionTriggered(_ sender: NSSearchField) {
if parent.text != sender.stringValue {
parent.text = sender.stringValue
@objc func actionTriggered(_ inSender: NSSearchField) {
if parent.text != inSender.stringValue {
parent.text = inSender.stringValue
}
parent.onSubmit?()
}
// intercept arrow-keys and Return for history nav and recents-update
func control(_ control: NSControl,
textView: NSTextView,
doCommandBy commandSelector: Selector) -> Bool {
guard let vField = control as? NSSearchField else { return false }
if commandSelector == #selector(NSResponder.moveUp(_:)) {
func control(_ inControl: NSControl,
textView inTextView: NSTextView,
doCommandBy inSelector: Selector) -> Bool {
guard let vField = inControl as? NSSearchField else { return false }
// ↓ goes to the results (like Everything); history moves to
// ↑ / ⌥↑ (older) and ⌥↓ (newer)
if inSelector == #selector(NSResponder.moveDown(_:)) {
NotificationCenter.default.post(name: .allofitFocusResults, object: vField.window)
return true
}
if inSelector == #selector(NSResponder.moveUp(_:))
|| inSelector == #selector(NSResponder.moveToBeginningOfParagraph(_:)) {
navigateHistory(field: vField, inOlder: true)
return true
}
if commandSelector == #selector(NSResponder.moveDown(_:)) {
if inSelector == #selector(NSResponder.moveToEndOfParagraph(_:)) {
navigateHistory(field: vField, inOlder: false)
return true
}
if commandSelector == #selector(NSControl.insertNewline(_:)) {
if inSelector == #selector(NSControl.insertNewline(_:)) {
addCurrentToRecents(field: vField)
return false
}
@@ -190,25 +228,25 @@ struct SearchField: NSViewRepresentable {
}
// adds the current text to the recents list (deduped, newest-first)
private func addCurrentToRecents(field: NSSearchField) {
let vQuery = field.stringValue.trimmingCharacters(in: .whitespaces)
private func addCurrentToRecents(field inField: NSSearchField) {
let vQuery = inField.stringValue.trimmingCharacters(in: .whitespaces)
guard !vQuery.isEmpty else { return }
var vRecents = field.recentSearches
var vRecents = inField.recentSearches
vRecents.removeAll { $0 == vQuery }
vRecents.insert(vQuery, at: 0)
if vRecents.count > 30 { vRecents = Array(vRecents.prefix(30)) }
field.recentSearches = vRecents
inField.recentSearches = vRecents
navigationIndex = nil
}
// walks one step through the recent-searches list
private func navigateHistory(field: NSSearchField, inOlder: Bool) {
let vHistory = field.recentSearches
private func navigateHistory(field inField: NSSearchField, inOlder: Bool) {
let vHistory = inField.recentSearches
if vHistory.isEmpty { return }
if inOlder {
if navigationIndex == nil {
savedQuery = field.stringValue
savedQuery = inField.stringValue
navigationIndex = 0
} else if let vI = navigationIndex, vI + 1 < vHistory.count {
navigationIndex = vI + 1
@@ -219,7 +257,7 @@ struct SearchField: NSViewRepresentable {
navigationIndex = vI - 1
} else {
navigationIndex = nil
field.stringValue = savedQuery
inField.stringValue = savedQuery
parent.text = savedQuery
return
}
@@ -230,7 +268,7 @@ struct SearchField: NSViewRepresentable {
if let vIdx = navigationIndex, vIdx < vHistory.count {
let vValue = vHistory[vIdx]
field.stringValue = vValue
inField.stringValue = vValue
parent.text = vValue
}
}
+375 -56
View File
@@ -1,9 +1,10 @@
import SwiftUI
import AppKit
import ServiceManagement
// SettingsView is the multi-tab Preferences window. It exposes roots,
// exclusions, mounted volume inclusion, background service installation,
// and on-disk cache management.
// SettingsView is the Preferences window, grouped like Everything's options:
// General (app behavior), Indexes (what gets indexed), Performance (update
// timings) and Advanced (background service, cache, diagnostics).
struct SettingsView: View {
@EnvironmentObject var model: AppModel
@@ -11,24 +12,202 @@ struct SettingsView: View {
var body: some View {
TabView {
RootsTab()
.tabItem { Label("Roots", systemImage: "folder") }
ExclusionsTab()
.tabItem { Label("Exclusions", systemImage: "minus.circle") }
VolumesTab()
.tabItem { Label("Volumes", systemImage: "externaldrive") }
ServiceTab()
.tabItem { Label("Service", systemImage: "gearshape.2") }
CacheTab()
.tabItem { Label("Cache", systemImage: "internaldrive") }
DiagnosticsTab()
.tabItem { Label("Diagnostics", systemImage: "stethoscope") }
GeneralTab()
.tabItem { Label("General", systemImage: "gearshape") }
IndexesTab()
.tabItem { Label("Indexes", systemImage: "folder") }
PerformanceTab()
.tabItem { Label("Performance", systemImage: "speedometer") }
AdvancedTab()
.tabItem { Label("Advanced", systemImage: "wrench.and.screwdriver") }
}
.frame(width: 620, height: 480)
.frame(width: 620, height: 500)
.padding()
}
}
// ===========================
// MARK: General tab
// ===========================
// GeneralTab holds the app-level behavior: menu bar icon, global shortcut,
// start at login, and how results react to double-click / highlighting.
private struct GeneralTab: View {
@EnvironmentObject var prefs: Preferences
// availability of the chosen global shortcut
@ObservedObject private var hotKey = GlobalHotKey.shared
// current login item state, read from the system
@State private var loginStatus = SMAppService.mainApp.status
// last error from registering the login item
@State private var loginError: String?
var body: some View {
// plain sections like the other tabs: a .grouped Form drew hairline
// panel borders at fractional positions that flickered on refresh
VStack(alignment: .leading, spacing: 12) {
Text("Access")
.font(.headline)
Form {
// checkboxes get a left-column label like the pickers, so every
// control starts on the same vertical line
LabeledContent("Menu bar:") {
Toggle("Show Allofit in the menu bar", isOn: $prefs.showMenuBarIcon)
}
Picker("Global shortcut:", selection: $prefs.globalHotKey) {
ForEach(HotKeyPreset.allCases) { vPreset in
Text(vPreset.title).tag(vPreset)
}
}
.frame(maxWidth: 260)
if !hotKey.isAvailable {
Text("This shortcut is already used by another app. Pick another one.")
.font(.caption)
.foregroundColor(.orange)
}
LabeledContent("Login:") {
Toggle("Start Allofit at login", isOn: loginBinding)
}
if loginStatus == .requiresApproval {
HStack {
Text("Approve Allofit in System Settings > General > Login Items.")
.font(.caption)
.foregroundColor(.orange)
Button("Open Login Items") { SMAppService.openSystemSettingsLoginItems() }
.controlSize(.small)
}
}
if let vError = loginError {
Text(vError)
.font(.caption)
.foregroundColor(.red)
}
}
Divider()
Text("Results")
.font(.headline)
Form {
Picker("Double-click or Return:", selection: $prefs.primaryAction) {
Text("Opens the file").tag(Preferences.PrimaryAction.open)
Text("Reveals it in Finder").tag(Preferences.PrimaryAction.reveal)
}
.frame(maxWidth: 360)
LabeledContent("Highlighting:") {
Toggle("Highlight matches in names and paths", isOn: $prefs.highlightMatches)
}
}
Divider()
Text("Finder")
.font(.headline)
Text("Right-click a folder in Finder and choose Quick Actions (or Services) > Search in Allofit to search inside it. Dropping a folder on the Dock icon does the same.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.onAppear { loginStatus = SMAppService.mainApp.status }
}
// registers / unregisters the app as a login item
private var loginBinding: Binding<Bool> {
Binding(
get: { loginStatus == .enabled || loginStatus == .requiresApproval },
set: { vEnabled in
do {
if vEnabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
loginError = nil
} catch {
loginError = "Couldn't change the login item: \(error.localizedDescription)"
}
loginStatus = SMAppService.mainApp.status
}
)
}
}
// ===========================
// MARK: Grouping tabs
// ===========================
// IndexesTab groups what gets indexed: root folders, exclusions, volumes
private struct IndexesTab: View {
// sections of the tab
private enum Section: String, CaseIterable, Identifiable {
case folders = "Folders"
case exclusions = "Exclusions"
case volumes = "Volumes"
var id: String { rawValue }
}
// section currently shown
@State private var section: Section = .folders
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Picker("", selection: $section) {
ForEach(Section.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
// pinned to the top so every section starts right below the
// picker (a section without a trailing Spacer would otherwise
// be centered vertically)
Group {
switch section {
case .folders: RootsTab()
case .exclusions: ExclusionsTab()
case .volumes: VolumesTab()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
// AdvancedTab groups the power-user pages: background service, cache file
// and live diagnostics
private struct AdvancedTab: View {
// sections of the tab
private enum Section: String, CaseIterable, Identifiable {
case service = "Service"
case cache = "Cache"
case diagnostics = "Diagnostics"
var id: String { rawValue }
}
// section currently shown
@State private var section: Section = .service
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Picker("", selection: $section) {
ForEach(Section.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
// pinned to the top, see IndexesTab
Group {
switch section {
case .service: ServiceTab()
case .cache: CacheTab()
case .diagnostics: DiagnosticsTab()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}
// ===========================
// MARK: Roots tab
// ===========================
@@ -112,7 +291,7 @@ private struct ExclusionsTab: View {
.frame(minHeight: 180)
HStack {
TextField("Path or tilde-expanded path", text: $newExclusion)
TextField("Path, e.g. ~/Projects/build", text: $newExclusion)
.textFieldStyle(.roundedBorder)
Button("Add") {
let vTrim = newExclusion.trimmingCharacters(in: .whitespaces)
@@ -137,6 +316,7 @@ private struct ExclusionsTab: View {
}
}
// presents an Open panel and adds the picked folders as exclusions
private func chooseFolder() {
let vPanel = NSOpenPanel()
vPanel.canChooseDirectories = true
@@ -149,6 +329,7 @@ private struct ExclusionsTab: View {
}
}
// removes the currently-selected exclusion
private func removeSelected() {
guard let vSel = selection else { return }
prefs.excludedPaths.removeAll { $0 == vSel }
@@ -210,7 +391,100 @@ private struct VolumesTab: View {
}
// ===========================
// MARK: service tab
// MARK: Performance tab
// ===========================
// PerformanceTab sets how quickly file changes reach the index and the open
// search results, per app state. Longer delays when Allofit isn't being
// looked at keep it nearly idle in the background; focusing a window always
// catches up at once.
private struct PerformanceTab: View {
@EnvironmentObject var prefs: Preferences
// preset choices offered by each picker, in seconds
private let kForegroundDelays: [Double] = [0.5, 1, 2, 3, 5, 10]
private let kBackgroundDelays: [Double] = [3, 5, 10, 15, 30, 60, 120]
private let kHiddenDelays: [Double] = [15, 30, 60, 120, 300, 600]
private let kForegroundRefreshes: [Double] = [0.5, 1, 2, 3, 5]
private let kBackgroundRefreshes: [Double] = [1, 3, 5, 10, 20, 30]
// FSEvents' own delivery latency, added to the estimates
private let kEventLatency: Double = 0.2
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Index updates")
.font(.headline)
Text("How long file changes are collected before they are applied to the index. Longer delays mean less work: repeated writes to the same file are checked only once.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Form {
secondsPicker("While Allofit is focused", inChoices: kForegroundDelays, inValue: $prefs.updateDelayForeground)
secondsPicker("While another app is focused", inChoices: kBackgroundDelays, inValue: $prefs.updateDelayBackground)
secondsPicker("With no window visible", inChoices: kHiddenDelays, inValue: $prefs.updateDelayHidden)
}
Divider()
Text("Result refresh")
.font(.headline)
Text("Minimum time between two refreshes of the open search results when files change. Typing always searches immediately.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Form {
secondsPicker("While Allofit is focused", inChoices: kForegroundRefreshes, inValue: $prefs.refreshIntervalForeground)
secondsPicker("While another app is focused", inChoices: kBackgroundRefreshes, inValue: $prefs.refreshIntervalBackground)
}
Divider()
Text(summaryText)
.font(.callout)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("Restore Defaults") { prefs.resetTimings() }
}
Spacer()
}
}
// a picker over preset durations; a stored value outside the presets
// (set by hand) is kept selectable instead of showing a blank picker
private func secondsPicker(_ inLabel: String, inChoices: [Double], inValue: Binding<Double>) -> some View {
let vChoices = Array(Set(inChoices + [inValue.wrappedValue])).sorted()
return Picker(inLabel, selection: inValue) {
ForEach(vChoices, id: \.self) { vSeconds in
Text(Self.durationText(inSeconds: vSeconds)).tag(vSeconds)
}
}
.frame(maxWidth: 360)
}
// estimated delay before a changed file appears in the results
private var summaryText: String {
let vForeground = kEventLatency + prefs.updateDelayForeground + prefs.refreshIntervalForeground
let vBackground = kEventLatency + prefs.updateDelayBackground + prefs.refreshIntervalBackground
return "A changed file shows up in the results after about \(Self.durationText(inSeconds: vForeground)) while Allofit is focused, and \(Self.durationText(inSeconds: vBackground)) while another app is. With no window visible nothing is refreshed; showing a window applies every pending change at once."
}
// "0.5 s", "15 s", "1 min", "1 min 30 s"
private static func durationText(inSeconds: Double) -> String {
if inSeconds < 60 {
let vRounded = (inSeconds * 10).rounded() / 10
return vRounded == vRounded.rounded() ? "\(Int(vRounded)) s" : String(format: "%.1f s", vRounded)
}
let vMinutes = Int(inSeconds) / 60
let vRest = Int(inSeconds) % 60
return vRest == 0 ? "\(vMinutes) min" : "\(vMinutes) min \(vRest) s"
}
}
// ===========================
// MARK: Service tab
// ===========================
// ServiceTab manages the LaunchAgent / LaunchDaemon that keeps the index
@@ -225,29 +499,53 @@ private struct ServiceTab: View {
// status doesn't observe @Published changes since it reads file
// system state directly, so we need an explicit refresh signal
@State private var statusTick: Int = 0
// mode picked in the radio group; only saved by a successful Install
// (or Uninstall), so picking a mode alone never leaves the app waiting
// on a service that doesn't exist
@State private var draftMode: Preferences.ServiceMode?
// confirmation for Uninstall
@State private var confirmUninstall = false
// the mode shown and acted on: the draft, else the saved mode
private var chosenMode: Preferences.ServiceMode {
return draftMode ?? prefs.serviceMode
}
// launchd scope of the chosen mode, nil for Off
private var scope: ServiceInstaller.Scope? {
switch prefs.serviceMode {
switch chosenMode {
case .none: return nil
case .userAgent: return .userAgent
case .rootDaemon: return .rootDaemon
}
}
// the radio group writes the draft, not the preference
private var modeBinding: Binding<Preferences.ServiceMode> {
Binding(get: { chosenMode }, set: { draftMode = $0 })
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Background service")
.font(.headline)
Picker("Mode", selection: $prefs.serviceMode) {
Picker("Mode", selection: modeBinding) {
Text("Off (GUI maintains the index)").tag(Preferences.ServiceMode.none)
Text("User service (LaunchAgent, no admin needed)").tag(Preferences.ServiceMode.userAgent)
Text("System service (LaunchDaemon as root, scans everything)").tag(Preferences.ServiceMode.rootDaemon)
}
.pickerStyle(.radioGroup)
if prefs.serviceMode != .none && model.activeServiceMode == .none {
Text("The saved service isn't installed, so Allofit is indexing on its own. Install it again or choose Off.")
.font(.callout)
.foregroundColor(.orange)
.fixedSize(horizontal: false, vertical: true)
}
Group {
switch prefs.serviceMode {
switch chosenMode {
case .none:
Text("The GUI process indexes and saves the cache itself.")
.foregroundColor(.secondary)
@@ -310,7 +608,7 @@ private struct ServiceTab: View {
Text("• Prompt once for your administrator password (steps run as one privileged script).")
.font(.caption)
}
Text("Reinstall any time you rebuild Allofit.app - the on-disk copy doesn't auto-update.")
Text("Reinstall after updating Allofit: the service runs its own copy of the app, which doesn't update by itself.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
@@ -330,23 +628,38 @@ private struct ServiceTab: View {
_ = statusTick // re-read the file-system status on each tick
return HStack {
Button("Install") {
Task { await model.performInstallService() }
let vMode = chosenMode
Task {
await model.performInstallService(inMode: vMode)
draftMode = nil
}
.disabled(prefs.serviceMode == .none || model.isWorking)
Button("Uninstall") {
Task { await model.performUninstallService() }
}
.disabled(prefs.serviceMode == .none || !vInstalled || model.isWorking)
.disabled(chosenMode == .none || model.isWorking)
Button("Uninstall…") { confirmUninstall = true }
.disabled(chosenMode == .none || !vInstalled || model.isWorking)
Button("Stop") {
Task { await model.performStopService() }
let vMode = chosenMode
Task { await model.performStopService(inMode: vMode) }
}
.disabled(!vInstalled || !vRunning || model.isWorking)
Button("Start") {
Task { await model.performStartService() }
let vMode = chosenMode
Task { await model.performStartService(inMode: vMode) }
}
.disabled(!vInstalled || vRunning || model.isWorking)
Spacer()
}
.confirmationDialog("Uninstall the background service?", isPresented: $confirmUninstall) {
Button("Uninstall", role: .destructive) {
let vMode = chosenMode
Task {
await model.performUninstallService(inMode: vMode)
draftMode = nil
}
}
} message: {
Text("Allofit goes back to indexing on its own while it's open.")
}
}
// ===========================
@@ -428,6 +741,8 @@ private struct CacheTab: View {
@EnvironmentObject var prefs: Preferences
@EnvironmentObject var model: AppModel
// confirmation for Clear Cache
@State private var confirmClear = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -446,7 +761,7 @@ private struct CacheTab: View {
.foregroundColor(cacheFileExists() ? .primary : .red)
}
LabeledContent("Size on disk") {
Text(formatSize(inBytes: IndexStore.cacheFileSize(at: currentCacheURL())))
Text(Formatters.sizeOrDash(bytes: IndexStore.cacheFileSize(at: currentCacheURL())))
.font(.callout)
.monospacedDigit()
}
@@ -485,10 +800,17 @@ private struct CacheTab: View {
if model.isWorking {
ProgressView().controlSize(.small)
}
Button("Clear Cache", role: .destructive) {
Task { await model.performClearCache() }
Button("Clear Cache…", role: .destructive) {
confirmClear = true
}
.disabled(model.isWorking)
.confirmationDialog("Clear the index cache?", isPresented: $confirmClear) {
Button("Clear and Rebuild", role: .destructive) {
Task { await model.performClearCache() }
}
} message: {
Text("The whole index is rebuilt from scratch, which can take a few minutes.")
}
}
if !model.workMessage.isEmpty {
@@ -525,14 +847,6 @@ private struct CacheTab: View {
vFormatter.timeStyle = .medium
return vFormatter.string(from: vMtime)
}
// formats a byte count as a human-friendly short string
private func formatSize(inBytes: Int64) -> String {
if inBytes <= 0 { return "-" }
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF.string(fromByteCount: inBytes)
}
}
// ===========================
@@ -572,7 +886,7 @@ private struct DiagnosticsTab: View {
.textSelection(.enabled)
}
LabeledContent("Cache size") {
Text(formatSize(IndexStore.cacheFileSize(at: IndexStore.cacheURL(forServiceMode: prefs.serviceMode))))
Text(Formatters.sizeOrDash(bytes: IndexStore.cacheFileSize(at: IndexStore.cacheURL(forServiceMode: prefs.serviceMode))))
.font(.callout)
.monospacedDigit()
}
@@ -584,7 +898,7 @@ private struct DiagnosticsTab: View {
LabeledContent("Indexer lock holder") {
Text(daemonStatus)
.font(.callout)
.foregroundColor(daemonStatus.contains("running") ? .primary : .red)
.foregroundColor(daemonStatus.hasPrefix("running") ? .primary : .red)
}
LabeledContent("Service plist") {
Text(servicePlistStatus())
@@ -594,7 +908,7 @@ private struct DiagnosticsTab: View {
Divider()
Text("Service log tail (/tmp/allofit-service.err)")
Text("Service log tail (\(DiagnosticsTab.logPath(inMode: prefs.serviceMode) ?? "no service"))")
.font(.subheadline)
ScrollView {
Text(serviceLogTail)
@@ -609,8 +923,12 @@ private struct DiagnosticsTab: View {
HStack {
Button("Refresh now") { refresh() }
Button("Open log") {
NSWorkspace.shared.open(URL(fileURLWithPath: "/tmp/allofit-service.err"))
// revealed rather than opened: Finder never runs it
if let vPath = DiagnosticsTab.logPath(inMode: prefs.serviceMode) {
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: vPath)])
}
}
.disabled(DiagnosticsTab.logPath(inMode: prefs.serviceMode) == nil)
Spacer()
Text("auto-refresh every 2s")
.font(.caption)
@@ -627,7 +945,7 @@ private struct DiagnosticsTab: View {
private func refresh() {
Task.detached {
let vStatus = await DiagnosticsTab.computeDaemonStatus(inMode: Preferences.shared.serviceMode)
let vLog = DiagnosticsTab.readLogTail()
let vLog = DiagnosticsTab.readLogTail(inPath: DiagnosticsTab.logPath(inMode: Preferences.shared.serviceMode))
await MainActor.run {
self.daemonStatus = vStatus
self.serviceLogTail = vLog
@@ -665,9 +983,18 @@ private struct DiagnosticsTab: View {
return "stale lock (pid \(vPid) not running)"
}
// reads the last 30 lines of the service stderr log file
private nonisolated static func readLogTail() -> String {
let vPath = "/tmp/allofit-service.err"
// log file of the service for a mode, nil when no service is used
nonisolated static func logPath(inMode: Preferences.ServiceMode) -> String? {
switch inMode {
case .none: return nil
case .userAgent: return ServiceInstaller.logPath(inScope: .userAgent)
case .rootDaemon: return ServiceInstaller.logPath(inScope: .rootDaemon)
}
}
// reads the last 30 lines of the service log file
private nonisolated static func readLogTail(inPath: String?) -> String {
guard let vPath = inPath else { return "(no background service: Allofit indexes in its own process)" }
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: vPath)),
let vText = String(data: vData, encoding: .utf8)
else {
@@ -697,14 +1024,6 @@ private struct DiagnosticsTab: View {
return vF.string(from: vMtime)
}
// formats a byte count as a human-friendly short string
private func formatSize(_ inBytes: Int64) -> String {
if inBytes <= 0 { return "-" }
let vF = ByteCountFormatter()
vF.countStyle = .file
return vF.string(fromByteCount: inBytes)
}
// prominent reminder + shortcuts to enable Full Disk Access for the
// root daemon's binary. Without FDA, the initial scan still works
// (root has direct filesystem access) but FSEvents will not deliver
+53
View File
@@ -0,0 +1,53 @@
import SwiftUI
// SyntaxHelpView is the search syntax cheat sheet shown by the "?" button
// next to the search field and by Help > Search Syntax.
struct SyntaxHelpView: View {
// one example query and what it matches
private struct Example: Identifiable {
let query: String
let meaning: String
var id: String { query }
}
// the rows of the cheat sheet, same order as the README
private let kExamples: [Example] = [
Example(query: "report", meaning: "names containing \"report\" (case and accents ignored)"),
Example(query: "annual report", meaning: "names containing both words (AND)"),
Example(query: "*.png | *.jpg", meaning: "either one (OR, binds tighter than AND)"),
Example(query: "report !draft", meaning: "without \"draft\" (NOT)"),
Example(query: "\"my file\"", meaning: "quotes keep spaces in one term"),
Example(query: "Start*.pdf", meaning: "* any characters, the whole name must match"),
Example(query: "IMG_????.heic", meaning: "? exactly one character"),
Example(query: "ext:pdf;docx", meaning: "by extension"),
Example(query: "file: folder:", meaning: "files or folders only (also as a prefix: folder:build)"),
Example(query: "src/main", meaning: "a term with / matches the full path"),
Example(query: "\"photos/**/\" *.heic", meaning: "** spans folders, * stays in one; trailing / = inside"),
]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Search syntax")
.font(.headline)
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 6) {
ForEach(kExamples) { vExample in
GridRow {
Text(vExample.query)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Text(vExample.meaning)
.foregroundColor(.secondary)
}
}
}
Divider()
Text("↓ moves from the search field to the results, ↑ on the first row comes back. Hold ⌥ to freeze the list while files change.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(16)
.frame(width: 560)
}
}
+101
View File
@@ -0,0 +1,101 @@
import AppKit
// Posted with a query string as object: the key window's search field
// replaces its text with it (see SearchField.Coordinator).
extension Notification.Name {
static let allofitSetQuery = Notification.Name("AllofitSetQuery")
}
// WindowPresenter brings the main window up from anywhere: the global
// shortcut, the menu bar icon, the Dock, the Finder "Search in Allofit"
// service and folders opened with the app. It also carries a query to the
// window's search field.
@MainActor
enum WindowPresenter {
// query waiting for a search field that doesn't exist yet (the window
// is still being created); taken by the first field that appears
static var pendingQuery: String?
// shows and focuses the main window (creating one if needed) and puts
// the keyboard focus in the search field
static func show() {
NSApp.activate(ignoringOtherApps: true)
if let vMain = AppDelegate.mainWindow {
if vMain.isMiniaturized { vMain.deminiaturize(nil) }
vMain.makeKeyAndOrderFront(nil)
} else if !NSApp.windows.contains(where: { $0.isVisible && $0.canBecomeMain }) {
openNewWindow()
}
// one tick later the window is key, so the focus request reaches it
DispatchQueue.main.async {
NotificationCenter.default.post(name: .allofitFocusSearch, object: nil)
}
}
// global shortcut behavior (like Everything): hide Allofit when its
// window is already in front, show it otherwise
static func toggle() {
if NSApp.isActive, let vKey = NSApp.keyWindow, vKey === AppDelegate.mainWindow, vKey.isVisible {
NSApp.hide(nil)
} else {
show()
}
}
// shows the main window searching inside a folder: the query is the
// folder path with a trailing "/", which matches everything below it
static func search(inFolder inPath: String) {
let vFolder = VolumeManager.canonicalPath(inPath: inPath)
let vTerm = vFolder.hasSuffix("/") ? vFolder : vFolder + "/"
let vQuery = "\"\(vTerm)\" "
pendingQuery = vQuery
show()
// an existing window takes it through the notification; a window
// still being created takes pendingQuery when its field appears
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if pendingQuery != nil {
NotificationCenter.default.post(name: .allofitSetQuery, object: vQuery)
}
}
}
// triggers File > New Window, found by its ⌘N shortcut so the lookup
// survives localized menus
static func openNewWindow() {
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
}
}
}
}
}
// ServicesProvider implements the "Search in Allofit" entry of Finder's
// Services / Quick Actions menu (declared under NSServices in Info.plist).
final class ServicesProvider: NSObject {
// called by the system with the folders selected in Finder
@objc func searchInAllofit(_ inPasteboard: NSPasteboard,
userData inUserData: String?,
error inError: AutoreleasingUnsafeMutablePointer<NSString>?) {
let vUrls = inPasteboard.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL] ?? []
guard let vFirst = vUrls.first else {
inError?.pointee = "No folder selected" as NSString
return
}
// a file searches its folder
let vIsDir = (try? vFirst.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
let vFolder = vIsDir ? vFirst.path : vFirst.deletingLastPathComponent().path
DispatchQueue.main.async {
WindowPresenter.search(inFolder: vFolder)
}
}
}
+171 -33
View File
@@ -29,10 +29,21 @@ final class WindowSearchModel: ObservableObject {
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
// the category filter chosen in the menu next to the search field
@Published var filter: SearchFilter = .everything {
didSet { scheduleFilter(inDelay: kTypingDebounceSeconds) }
}
// every record matching the query, sorted (the table shows them all;
// only the rows on screen get views)
@Published private(set) var results = ResultList()
// bumped on every change of results, so the table can tell a new list
// from the same one without comparing hundreds of thousands of records
@Published private(set) var resultsVersion = 0
// what to emphasize in the shown results (always matches results)
@Published private(set) var highlights = SearchEngine.Highlights()
// match count and timing for the status bar (observed separately so
// their updates don't re-render the table)
let stats = SearchStats()
// strong ref to the shared index; the per-window WindowSearchModel does
// not outlive its window, so the shared model has a longer lifetime
@@ -42,15 +53,31 @@ final class WindowSearchModel: ObservableObject {
// 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
// rows published first when a query matches more than this: the top of
// the list appears in milliseconds, the complete sort follows (a full
// sort of 600k entries takes ~0.2 s)
private nonisolated static let kQuickRows = 2000
// above this many results, skip the "same list" comparison (it would
// cost more than just reloading the visible rows)
private nonisolated static let kCompareLimit = 5000
// 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
// true while this model's window is on screen (not closed, minimized
// or fully covered)
private var isWindowVisible = true
// true while the user holds the freeze key (⌥ Option)
private var isFrozen = false
// true when the index changed while the window was not visible or
// the list was frozen
private var isStale = false
// true while a throttled index refilter is waiting to run
private var isIndexRefreshScheduled = false
// when the last index-driven refilter started
private var lastIndexRefreshAt = Date.distantPast
// subscribes to index changes and runs the first filter
init(model inModel: AppModel) {
self.model = inModel
self.sortDescriptor = Preferences.shared.lastSort
@@ -59,28 +86,126 @@ final class WindowSearchModel: ObservableObject {
// burst of updates never queues up copies of the index.
inModel.recordsChanged
.sink { [weak self] in
self?.scheduleFilter(inDelay: self?.kIndexDebounceSeconds ?? 0.3)
self?.indexDidChange()
}
.store(in: &cancellables)
// holding ⌥ freezes the list; releasing it catches up once
FreezeKey.shared.$isHeld
.removeDuplicates()
.sink { [weak self] vHeld in
self?.setFrozen(vHeld)
}
.store(in: &cancellables)
scheduleFilter(inDelay: 0)
}
// reported by the window: refilters on reappearance if the index
// changed while the window was off screen
func setWindowVisible(_ inVisible: Bool) {
isWindowVisible = inVisible
if inVisible && isStale && !isFrozen {
isStale = false
scheduleFilter(inDelay: 0)
}
}
// freeze key pressed / released: while frozen, index changes don't
// touch the list; on release it is refreshed once, keeping the scroll
// position (no quick partial list)
private func setFrozen(_ inFrozen: Bool) {
isFrozen = inFrozen
if !inFrozen && isStale && isWindowVisible {
isStale = false
scheduleFilter(inDelay: 0, inProgressive: false)
}
}
// drops trashed records: their rows at once, and from the shared index
// (FSEvents doesn't report this process's own file operations)
func removeTrashed(inRecords: [FileRecord]) {
let vIds = Set(inRecords.map(\.id))
results = results.removing(inIds: vIds)
resultsVersion &+= 1
stats.matchCount = results.count
model.forget(inPaths: inRecords.map(\.fullPath))
}
// publishes a result list (and its count / highlights) on main
private func publish(inResults: ResultList,
inCount: Int,
inHighlights: SearchEngine.Highlights,
inMilliseconds: Double?) {
if stats.matchCount != inCount { stats.matchCount = inCount }
if let vMs = inMilliseconds { stats.lastSearchMilliseconds = vMs }
if highlights != inHighlights { highlights = inHighlights }
// skip a no-op reassignment of a small identical list (full record
// equality also catches size / date updates)
if inResults.count <= Self.kCompareLimit && results.count == inResults.count && results.elementsEqual(inResults) {
return
}
results = inResults
resultsVersion &+= 1
}
// files put back from the Trash (undo): adds them to the index again
func noticeRestored(inPaths: [String]) {
model.notice(inPaths: inPaths)
}
// throttled reaction to an index change: nothing while off screen,
// otherwise at most one refilter per refresh interval. The
// refilter reads the newest records when it runs, so skipped
// notifications lose nothing.
private func indexDidChange() {
guard isWindowVisible && !isFrozen else {
isStale = true
return
}
if isIndexRefreshScheduled { return }
isIndexRefreshScheduled = true
let vSinceLast = Date().timeIntervalSince(lastIndexRefreshAt)
// minimum gap between two index-driven refilters (Settings >
// Performance). Typing is unaffected; this caps the cost of an
// expensive query left open while files change constantly.
let vPrefs = Preferences.shared
let vInterval = AppActivity.shared.level == .foreground
? vPrefs.refreshIntervalForeground
: vPrefs.refreshIntervalBackground
let vWait = max(kIndexDebounceSeconds, vInterval - vSinceLast)
DispatchQueue.main.asyncAfter(deadline: .now() + vWait) { [weak self] in
guard let vSelf = self else { return }
vSelf.isIndexRefreshScheduled = false
guard vSelf.isWindowVisible && !vSelf.isFrozen else {
vSelf.isStale = true
return
}
vSelf.lastIndexRefreshAt = Date()
// no quick partial list here: it would briefly cut the list to
// its first rows and lose the scroll position of a user
// browsing further down
vSelf.scheduleFilter(inDelay: 0, inProgressive: false)
}
}
// 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) {
// inProgressive publishes the first rows before the complete sort
// when there are many results (typing, sort and filter changes).
private func scheduleFilter(inDelay: Double, inProgressive: Bool = true) {
filterTask?.cancel()
// snapshot inputs on main; the detached task is self-contained
let vQuery = query
let vQuery = filter.apply(toQuery: query)
let vHighlights = SearchEngine(inQuery: query).highlights
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 vStartedAt = DispatchTime.now()
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
@@ -96,40 +221,53 @@ final class WindowSearchModel: ObservableObject {
}
guard let vPositions = vResult else { return }
if Task.isCancelled { return }
let vTop = ResultSorter.top(
let vCount = vPositions.count
// timing covers the filter plus the first displayable sort
var vMilliseconds = Double(DispatchTime.now().uptimeNanoseconds - vStartedAt.uptimeNanoseconds) / 1_000_000
if inProgressive && vCount > WindowSearchModel.kQuickRows {
let vTop = ResultList(inStore: vRecords, inPositions: ResultSorter.topPositions(
inRecords: vRecords,
inPositions: vPositions,
inLimit: vMax,
inLimit: WindowSearchModel.kQuickRows,
inDescriptor: vSort
)
))
if Task.isCancelled { return }
let vCount = vPositions.count
vMilliseconds = Double(DispatchTime.now().uptimeNanoseconds - vStartedAt.uptimeNanoseconds) / 1_000_000
let vQuickMs = vMilliseconds
// 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
// await MainActor.run) so the assignment lands 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
self?.publish(inResults: vTop, inCount: vCount, inHighlights: vHighlights, inMilliseconds: vQuickMs)
}
}
let vAll = ResultList(inStore: vRecords, inPositions: ResultSorter.topPositions(
inRecords: vRecords,
inPositions: vPositions,
inLimit: vCount,
inDescriptor: vSort
))
if Task.isCancelled { return }
let vFinalMs: Double? = (inProgressive && vCount > WindowSearchModel.kQuickRows)
? nil
: Double(DispatchTime.now().uptimeNanoseconds - vStartedAt.uptimeNanoseconds) / 1_000_000
DispatchQueue.main.async {
self?.publish(inResults: vAll, inCount: vCount, inHighlights: vHighlights, inMilliseconds: vFinalMs)
}
}
}
// 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],
private nonisolated static func matchingPositions(inRecords: RecordStore,
inEngine: SearchEngine,
inCancelled: ManagedAtomicFlag) -> [Int32]? {
if !inEngine.isActive {
return (0..<Int32(inRecords.count)).map { $0 }
}
let vChunkCount = (inRecords.count + kChunkSize - 1) / kChunkSize
// one parallel job per store chunk, reading its records directly
let vChunkCount = inRecords.chunkCount
if vChunkCount == 0 { return [] }
var vChunks = [[Int32]](repeating: [], count: vChunkCount)
vChunks.withUnsafeMutableBufferPointer { vOut in
@@ -137,11 +275,11 @@ final class WindowSearchModel: ObservableObject {
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)
let vRecords = inRecords.chunk(at: vChunk)
let vBase = Int32(vChunk << RecordStore.kShift)
var vLocal: [Int32] = []
for vI in vStart..<vEnd where inEngine.match(inRecord: inRecords[vI]) {
vLocal.append(Int32(vI))
for (vI, vRecord) in vRecords.enumerated() where inEngine.match(inRecord: vRecord) {
vLocal.append(vBase + Int32(vI))
}
(vOutBase + vChunk).pointee = vLocal
}
+69 -3
View File
@@ -89,7 +89,8 @@ struct IndexStateTests {
]
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
#expect(vResult.changed)
#expect(vResult.rescans == ["/r/moved"])
#expect(vResult.newFolders == ["/r/moved"])
#expect(vResult.rescans.isEmpty)
#expect(vState.contains(inPath: "/r/a/new.txt"))
#expect(!vState.contains(inPath: "/r/hidden/x.txt"))
#expect(vState.contains(inPath: "/r/moved/inside.txt"))
@@ -102,7 +103,45 @@ struct IndexStateTests {
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"])
#expect(vResult.newFolders == ["/r/renamed"])
}
@Test func newFolderInsideKernelRescanIsWalkedOnce() {
var vState = Self.sampleState()
var vChanges = ResolvedChanges()
vChanges.upserts = [Self.dir("/r/a/fresh")]
vChanges.rescans = ["/r/a"]
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
#expect(vResult.rescans == ["/r/a"])
#expect(vResult.newFolders.isEmpty)
// merging a new folder's walk only upserts (no removal pass)
vState.mergeNewFolders(inRecords: [Self.dir("/r/a/fresh"), Self.file("/r/a/fresh/x.txt")])
#expect(vState.contains(inPath: "/r/a/fresh/x.txt"))
#expect(vState.contains(inPath: "/r/a/b/2.txt"))
}
@Test func fullSortMatchesComparatorForNumericKeys() {
let vRecords = (0..<3000).map { Self.file("/r/f\($0)", size: Int64(($0 * 7919) % 97)) }
let vPositions = (0..<Int32(vRecords.count)).map { $0 }
for vSort in [FileSortDescriptor.sizeAscending, .sizeDescending] {
let vFull = ResultSorter.topPositions(inRecords: vRecords, inPositions: vPositions, inLimit: vRecords.count, inDescriptor: vSort)
let vHeap = ResultSorter.topPositions(inRecords: vRecords, inPositions: vPositions, inLimit: 100, inDescriptor: vSort)
#expect(Array(vFull.prefix(100)) == vHeap, "sort \(vSort.rawValue)")
}
}
@Test func caseOnlyRenameDropsTheOldSpelling() throws {
let vDir = FileManager.default.temporaryDirectory.appendingPathComponent("allofit-case-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: vDir) }
let vOld = vDir.appendingPathComponent("Report.txt")
let vNew = vDir.appendingPathComponent("report.txt")
try Data("x".utf8).write(to: vOld)
try FileManager.default.moveItem(at: vOld, to: vNew)
// the old spelling still opens on a case-insensitive volume, but must
// not be re-added as a separate entry
#expect(FileIndexer.makeRecord(inURL: vOld, inSkipHidden: true) == nil)
#expect(FileIndexer.makeRecord(inURL: vNew, inSkipHidden: true)?.name == "report.txt")
}
@Test func unchangedUpsertIsNotAChange() {
@@ -153,6 +192,33 @@ struct IndexStateTests {
#expect(Self.paths(vState) == ["/r", "/r/a", "/r/a/fresh.txt", "/r/c.txt"])
}
@Test func rootsAreCanonicalized() {
// /tmp is a symlink: FSEvents and the walker report /private/tmp
#expect(VolumeManager.canonicalPath(inPath: "/tmp") == "/private/tmp")
#expect(VolumeManager.canonicalPath(inPath: "/Users/") == "/Users")
#expect(VolumeManager.canonicalPath(inPath: "/no/such/root/") == "/no/such/root")
}
@Test func recordStoreChunksAndCopiesOnWrite() {
var vStore = RecordStore()
let vTotal = RecordStore.kChunkSize * 2 + 10
for vI in 0..<vTotal { vStore.append(Self.file("/r/f\(vI)")) }
#expect(vStore.count == vTotal)
#expect(vStore[RecordStore.kChunkSize].name == "f\(RecordStore.kChunkSize)")
// a snapshot keeps its values when the original changes
let vSnapshot = vStore
vStore[5] = Self.file("/r/changed")
#expect(vSnapshot[5].name == "f5")
#expect(vStore[5].name == "changed")
for _ in 0..<11 { vStore.removeLast() }
#expect(vStore.count == RecordStore.kChunkSize * 2 - 1)
#expect(vStore.last?.name == "f\(RecordStore.kChunkSize * 2 - 2)")
// result lists read through the snapshot
let vList = ResultList(inStore: vSnapshot, inPositions: [3, 1])
#expect(vList.map(\.name) == ["f3", "f1"])
#expect(vList.removing(inIds: [vSnapshot[3].id]).map(\.name) == ["f1"])
}
@Test func minimalRootsDropsNestedPaths() {
let vRoots = SubtreeMatcher.minimalRoots(inPaths: ["/a/b", "/a", "/a b/c", "/a", "/c"])
#expect(vRoots.sorted() == ["/a", "/a b/c", "/c"])
@@ -172,7 +238,7 @@ struct IndexStateTests {
let vUrl = FileManager.default.temporaryDirectory
.appendingPathComponent("allofit-test-\(UUID().uuidString).bin")
defer { try? FileManager.default.removeItem(at: vUrl) }
let vRecords = Self.sampleState().records
let vRecords = Array(Self.sampleState().records)
IndexStore.save(inRecords: vRecords, inLastEventId: 42, to: vUrl)
let vLoaded = try #require(IndexStore.load(from: vUrl))
#expect(vLoaded.lastEventId == 42)
+15
View File
@@ -104,4 +104,19 @@ struct SearchEngineTests {
#expect(Self.matches("RÉSUMÉ", "/d/" + vDecomposed))
#expect(Self.matches("r?sum?.pdf", "/d/" + vDecomposed))
}
@Test func highlightsKeepLiteralRuns() {
let vHigh = SearchEngine(inQuery: "IMG_????.heic !draft \"some/folder/**/path\" ext:pdf").highlights
#expect(vHigh.name == ["IMG_", ".heic"])
#expect(vHigh.path == ["some", "folder", "path"])
}
@Test func filtersAreAndedWithTheQuery() {
#expect(Self.matches(SearchFilter.pictures.apply(toQuery: "img"), "/d/img_1.HEIC"))
#expect(!Self.matches(SearchFilter.pictures.apply(toQuery: "img"), "/d/img_1.txt"))
#expect(Self.matches(SearchFilter.folders.apply(toQuery: ""), "/d/src", isDirectory: true))
#expect(Self.matches(SearchFilter.applications.apply(toQuery: "xcode"), "/Applications/Xcode.app", isDirectory: true))
#expect(!Self.matches(SearchFilter.applications.apply(toQuery: "xcode"), "/d/xcode.app.zip"))
#expect(SearchFilter.everything.apply(toQuery: "a b") == "a b")
}
}