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" | tee "Allofit-${{ steps.version.outputs.version }}.sha256"
- name: Publish GitHub release - 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: with:
tag_name: ${{ steps.version.outputs.tag }} tag_name: ${{ steps.version.outputs.tag }}
name: Allofit ${{ steps.version.outputs.version }} 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) | | `"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 | | `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 ## Shortcuts
| Key | Action | | Key | Action |
|---|---| |---|---|
| ⌥Space | Show / hide Allofit from any app (changeable in Settings) |
| ⌘F | Focus search | | ⌘F | Focus search |
| ⌘R | Reindex | | ↑ ↓ (in the search field) | Cycle search history |
| ⌘, | Preferences | | Return | Open the selection (or reveal it, see Settings) |
| ↑ ↓ | Cycle search history | | ⌘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) ## 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 ## Build from source
+56
View File
@@ -103,6 +103,14 @@ echo "==> Assembling ${kAppName}.app"
rm -rf "${vAppBundle}" rm -rf "${vAppBundle}"
mkdir -p "${vMacOSDir}" "${vResourcesDir}" mkdir -p "${vMacOSDir}" "${vResourcesDir}"
cp "${vBinarySrc}" "${vMacOSDir}/${kAppName}" 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}" chmod +x "${vMacOSDir}/${kAppName}"
# ================== # ==================
@@ -211,6 +219,54 @@ ${vIconPlistEntry}
<false/> <false/>
<key>LSApplicationCategoryType</key> <key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string> <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> </dict>
</plist> </plist>
EOF EOF
+12
View File
@@ -23,8 +23,11 @@ kSystemDaemonPlist="/Library/LaunchDaemons/${kServicePlistName}"
kUserCacheDir="$HOME/Library/Application Support/Allofit" kUserCacheDir="$HOME/Library/Application Support/Allofit"
kSystemCacheDir="/Library/Application Support/Allofit" kSystemCacheDir="/Library/Application Support/Allofit"
kUserPrefsPlist="$HOME/Library/Preferences/${kBundleId}.plist" 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" kServiceLogStdout="/tmp/allofit-service.log"
kServiceLogStderr="/tmp/allofit-service.err" kServiceLogStderr="/tmp/allofit-service.err"
kUserLogDir="$HOME/Library/Logs/Allofit"
kSystemLogDir="/Library/Logs/Allofit"
# ================== # ==================
# MARK: Args # MARK: Args
@@ -169,6 +172,15 @@ if [[ -f "$kServiceLogStdout" || -f "$kServiceLogStderr" ]]; then
fi fi
done done
fi 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 # MARK: Build artifacts
+1 -1
View File
@@ -6,6 +6,7 @@ import Foundation
// anything else / no args launch the SwiftUI GUI // anything else / no args launch the SwiftUI GUI
@main @main
struct Main { struct Main {
// entry point: routes to the GUI, the headless service or the CLI
static func main() { static func main() {
let vArgs = Array(CommandLine.arguments.dropFirst()) let vArgs = Array(CommandLine.arguments.dropFirst())
// No args: GUI mode. The Dock and Finder launch us with no args. // 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. // Headless indexer daemon, invoked by launchd's plist.
if vArgs.contains("--service") { if vArgs.contains("--service") {
AllofitService.run() AllofitService.run()
return
} }
// Anything else is a CLI command. Unknown commands print a hint // Anything else is a CLI command. Unknown commands print a hint
// to stderr and exit 1 rather than silently launching the GUI. // 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) { if inSkipHidden && (vValues.isHidden ?? false) {
return nil 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 // name/parent come from the path (not .nameKey) so that
// parentPath + "/" + name always round-trips to the watched path, // parentPath + "/" + name always round-trips to the watched path,
// which is what the id and the parent-is-indexed rule rely on // which is what the id and the parent-is-indexed rule rely on
@@ -149,10 +162,11 @@ enum FileIndexer {
vName = inURL.lastPathComponent vName = inURL.lastPathComponent
vParentRaw = inURL.deletingLastPathComponent().path vParentRaw = inURL.deletingLastPathComponent().path
} }
let vParent = inInterner?.intern(vParentRaw) ?? vParentRaw let vParent = inInterner?.intern(vParentRaw)
return FileRecord( return FileRecord(
name: vName, name: vName,
parentPath: vParent, parentPath: vParent?.path ?? vParentRaw,
parentLower: vParent?.folded,
size: Int64(vValues.fileSize ?? 0), size: Int64(vValues.fileSize ?? 0),
dateCreated: vValues.creationDate ?? .distantPast, dateCreated: vValues.creationDate ?? .distantPast,
dateModified: vValues.contentModificationDate ?? .distantPast, dateModified: vValues.contentModificationDate ?? .distantPast,
@@ -161,19 +175,20 @@ enum FileIndexer {
} }
} }
// PathInterner hands back one shared String instance per distinct value, // PathInterner hands back one shared String instance per distinct value
// so the thousands of records of a directory share a single parentPath // (plus its search form), so the thousands of records of a directory share
// buffer instead of each holding its own copy. Not thread-safe: use one // a single parentPath / parentLower buffer instead of each holding a copy. Not thread-safe: use one
// per walk / load. // per walk / load.
final class PathInterner { final class PathInterner {
// distinct value -> shared instance // distinct value -> (shared instance, shared search form)
private var table: [String: String] = [:] private var table: [String: (path: String, folded: String)] = [:]
// returns the shared instance equal to inString // returns the shared instance equal to inString and its search form
func intern(_ inString: String) -> String { func intern(_ inString: String) -> (path: String, folded: String) {
if let vShared = table[inString] { return vShared } if let vShared = table[inString] { return vShared }
table[inString] = inString let vEntry = (path: inString, folded: FileRecord.sharedSearchForm(of: inString))
return 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. // lowercased, NFC-normalized name used for case-insensitive matching.
// Shares the name's storage when the name is already in that form. // Shares the name's storage when the name is already in that form.
let nameLower: String 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, // builds a record; inParentLower is the precomputed search form of the
parentPath: String, // parent path (computed here when nil)
size: Int64, init(name inName: String,
dateCreated: Date, parentPath inParentPath: String,
dateModified: Date, parentLower inParentLower: String? = nil,
isDirectory: Bool) { size inSize: Int64,
self.id = FileRecord.pathHash(inParent: parentPath, inName: name) dateCreated inDateCreated: Date,
self.name = name dateModified inDateModified: Date,
self.parentPath = parentPath isDirectory inIsDirectory: Bool) {
self.size = size self.id = FileRecord.pathHash(inParent: inParentPath, inName: inName)
self.dateCreated = dateCreated self.name = inName
self.dateModified = dateModified self.parentPath = inParentPath
self.isDirectory = isDirectory self.parentLower = inParentLower ?? FileRecord.sharedSearchForm(of: inParentPath)
let vLower = FileRecord.searchForm(of: name) self.size = inSize
self.nameLower = (vLower == name) ? name : vLower 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 // returns the absolute full path computed from parent and name
@@ -63,6 +70,12 @@ struct FileRecord: Identifiable, Hashable, Sendable {
return vLower.precomposedStringWithCanonicalMapping 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 // 64-bit FNV-1a offset basis and prime
private static let kFnvOffset: UInt64 = 0xcbf2_9ce4_8422_2325 private static let kFnvOffset: UInt64 = 0xcbf2_9ce4_8422_2325
private static let kFnvPrime: UInt64 = 0x0000_0100_0000_01b3 private static let kFnvPrime: UInt64 = 0x0000_0100_0000_01b3
+1 -12
View File
@@ -23,10 +23,6 @@ struct FSChange {
var mustScanSubDirs: Bool { var mustScanSubDirs: Bool {
return flags & FSEventStreamEventFlags(kFSEventStreamEventFlagMustScanSubDirs) != 0 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. // FileWatcher streams real-time filesystem change notifications using FSEvents.
@@ -51,18 +47,11 @@ final class FileWatcher: @unchecked Sendable {
// guards stream/handler against concurrent access // guards stream/handler against concurrent access
private let lock = NSLock() private let lock = NSLock()
// stops the stream so its callback never outlives the watcher
deinit { deinit {
stop() 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 // starts watching inRoots, delivering batches of changes to inHandler
// starting from inSinceWhen (defaults to "now") // starting from inSinceWhen (defaults to "now")
func start(inRoots: [String], func start(inRoots: [String],
+23 -15
View File
@@ -35,13 +35,13 @@ struct ResolvedChanges: Sendable {
struct IndexState { struct IndexState {
// all indexed entries, unordered // 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 // FileRecord.id -> position in records
private(set) var positions: [UInt64: Int] = [:] private(set) var positions: [UInt64: Int] = [:]
// builds the state from a list of records, dropping duplicate paths // builds the state from a list of records, dropping duplicate paths
init(inRecords: [FileRecord] = []) { init(inRecords: [FileRecord] = []) {
records.reserveCapacity(inRecords.count)
positions.reserveCapacity(inRecords.count) positions.reserveCapacity(inRecords.count)
for vRecord in inRecords where positions[vRecord.id] == nil { for vRecord in inRecords where positions[vRecord.id] == nil {
positions[vRecord.id] = records.count positions[vRecord.id] = records.count
@@ -57,12 +57,6 @@ struct IndexState {
return positions[FileRecord.pathHash(inPath)] != nil 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 // MARK: Mutations
// =========================== // ===========================
@@ -139,9 +133,10 @@ struct IndexState {
// directories that newly appeared. inRoots are the configured roots, // directories that newly appeared. inRoots are the configured roots,
// whose own records are allowed without an indexed parent. // whose own records are allowed without an indexed parent.
mutating func apply(inChanges: ResolvedChanges, 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 vChanged = false
var vRescans: [String] = [] var vRescans: [String] = []
var vNewFolders: [String] = []
// removals first: a rename shows up as old path gone + new path present // removals first: a rename shows up as old path gone + new path present
if !inChanges.removals.isEmpty { if !inChanges.removals.isEmpty {
@@ -157,7 +152,7 @@ struct IndexState {
let vIsNew = positions[vRecord.id] == nil let vIsNew = positions[vRecord.id] == nil
if upsert(vRecord) { vChanged = true } if upsert(vRecord) { vChanged = true }
if vIsNew && vRecord.isDirectory { 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) { for vPath in inChanges.rescans where inRoots.contains(vPath) || contains(inPath: vPath) {
vRescans.append(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 // drops records that fall outside the current configuration: excluded
@@ -215,10 +226,7 @@ struct IndexState {
// the slack can amount to hundreds of MB. // the slack can amount to hundreds of MB.
func compacted() -> IndexState { func compacted() -> IndexState {
var vResult = IndexState() var vResult = IndexState()
vResult.records = Array(unsafeUninitializedCapacity: records.count) { vBuffer, vCount in vResult.records = RecordStore(records)
_ = vBuffer.initialize(from: records)
vCount = records.count
}
vResult.positions = Dictionary(minimumCapacity: positions.count) vResult.positions = Dictionary(minimumCapacity: positions.count)
for (vKey, vValue) in positions { for (vKey, vValue) in positions {
vResult.positions[vKey] = vValue vResult.positions[vKey] = vValue
+115 -64
View File
@@ -49,14 +49,11 @@ enum IndexStore {
in: .userDomainMask in: .userDomainMask
).first! ).first!
} }
let vDir = vBase.appendingPathComponent("Allofit", isDirectory: true) // pure path computation (no disk access: it's called from views);
try? FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true) // saveImpl creates the folder when it writes
// for the system-wide path the directory may have been created by the return vBase
// root daemon - make sure non-root users (the GUI) can traverse it .appendingPathComponent("Allofit", isDirectory: true)
if inSystem { .appendingPathComponent("index.bin")
chmod(vDir.path, 0o755)
}
return vDir.appendingPathComponent("index.bin")
} }
// returns the URL appropriate for a given service mode // 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 // 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) save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL)
} }
@@ -96,13 +93,14 @@ enum IndexStore {
// of Foundation objects (the compressed NSData, NSURLs created by // of Foundation objects (the compressed NSData, NSURLs created by
// FileManager.replaceItem, etc.) - if the caller is a long-running // FileManager.replaceItem, etc.) - if the caller is a long-running
// block whose own pool never drains, those would accumulate forever. // 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 { 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() var vPayload = Data()
vPayload.reserveCapacity(16 + inRecords.count * 80) vPayload.reserveCapacity(16 + inRecords.count * 80)
writeU64(into: &vPayload, value: inLastEventId) writeU64(into: &vPayload, value: inLastEventId)
@@ -136,26 +134,51 @@ enum IndexStore {
writeU64(into: &vEnvelope, value: vUncompressedSize) writeU64(into: &vEnvelope, value: vUncompressedSize)
vEnvelope.append(vCompressed) 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 { do {
try vEnvelope.write(to: vTmp, options: .atomic) try vEnvelope.write(to: vTmp)
_ = try FileManager.default.replaceItem( // owner-only access, set on the temp file *before* it becomes
at: inUrl, // the cache, so the index (a listing of every file name) is
withItemAt: vTmp, // never readable by other local users, even for a moment
backupItemName: nil, restrictAccess(inPath: vTmp.path)
options: [], // rename(2) is atomic and keeps the temp file's owner and mode
resultingItemURL: nil // (FileManager.replaceItem may carry over the old file's)
) guard rename(vTmp.path, inUrl.path) == 0 else {
// the root daemon writes this file owned by root. The GUI runs throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
// 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)
} catch { } catch {
NSLog("[Allofit] cache save failed at %@: %@", inUrl.path, "\(error)") NSLog("[Allofit] cache save failed at %@: %@", inUrl.path, "\(error)")
try? FileManager.default.removeItem(at: vTmp) 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 // reads the persisted index back from the default cacheURL
static func load() -> LoadResult? { static func load() -> LoadResult? {
return load(from: cacheURL) return load(from: cacheURL)
@@ -166,16 +189,18 @@ enum IndexStore {
// String allocations don't linger in the caller's pool. // String allocations don't linger in the caller's pool.
static func load(from inUrl: URL) -> LoadResult? { static func load(from inUrl: URL) -> LoadResult? {
return autoreleasepool { 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 } guard let vData = try? Data(contentsOf: inUrl) else { return nil }
var vOffset = 0 var vOffset = 0
guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil } 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 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 vCompressed = vData.subdata(in: vOffset..<vData.count)
let vPayload: Data let vPayload: Data
@@ -185,6 +210,7 @@ enum IndexStore {
NSLog("[Allofit] LZ4 decompression failed: \(error)") NSLog("[Allofit] LZ4 decompression failed: \(error)")
return nil return nil
} }
guard UInt64(vPayload.count) == vDeclaredSize else { return nil }
var vP = 0 var vP = 0
guard let vLastEventId = readU64(from: vPayload, offset: &vP) else { return nil } guard let vLastEventId = readU64(from: vPayload, offset: &vP) else { return nil }
@@ -209,9 +235,11 @@ enum IndexStore {
else { else {
return nil return nil
} }
let vShared = vInterner.intern(vParent)
vRecords.append(FileRecord( vRecords.append(FileRecord(
name: vName, name: vName,
parentPath: vInterner.intern(vParent), parentPath: vShared.path,
parentLower: vShared.folded,
size: vSize, size: vSize,
dateCreated: Date(timeIntervalSince1970: vCreated), dateCreated: Date(timeIntervalSince1970: vCreated),
dateModified: Date(timeIntervalSince1970: vModified), dateModified: Date(timeIntervalSince1970: vModified),
@@ -225,71 +253,94 @@ enum IndexStore {
// MARK: Binary helpers // MARK: Binary helpers
// =========================== // ===========================
private static func writeU32(into ioData: inout Data, value: UInt32) { // appends a little-endian UInt32
var vV = value.littleEndian private static func writeU32(into ioData: inout Data, value inValue: UInt32) {
var vV = inValue.littleEndian
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) } 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) } 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) } 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) } 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)) writeU32(into: &ioData, value: UInt32(vBytes.count))
ioData.append(contentsOf: vBytes) ioData.append(contentsOf: vBytes)
} }
private static func readU8(from inData: Data, offset: inout Int) -> UInt8? {
guard offset + 1 <= inData.count else { return nil } // reads one byte at ioOffset and advances it; nil past the end
let vV = inData[inData.startIndex + offset] private static func readU8(from inData: Data, offset ioOffset: inout Int) -> UInt8? {
offset += 1 guard ioOffset + 1 <= inData.count else { return nil }
let vV = inData[inData.startIndex + ioOffset]
ioOffset += 1
return vV 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 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 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 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 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 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 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) 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) let vLength = Int(vLen)
guard offset + vLength <= inData.count else { return nil } guard ioOffset + vLength <= inData.count else { return nil }
let vStart = inData.startIndex + offset let vStart = ioOffset
let vEnd = vStart + vLength ioOffset += vLength
offset += vLength
// native decode (no Foundation bridging); invalid bytes are repaired // native decode (no Foundation bridging); invalid bytes are repaired
return inData.withUnsafeBytes { vPtr in 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 // open descriptor, -1 when not locked
private var fileDescriptor: Int32 = -1 private var fileDescriptor: Int32 = -1
init(path: String) { // creates an unlocked handle for the sentinel file at inPath
self.path = path init(path inPath: String) {
self.path = inPath
} }
// releases the lock together with the handle
deinit { deinit {
unlock() unlock()
} }
@@ -74,8 +76,8 @@ final class IndexerLock {
} }
// reads the pid currently holding the lock, if any (best-effort) // reads the pid currently holding the lock, if any (best-effort)
static func readHolderPid(path: String) -> Int32? { static func readHolderPid(path inPath: String) -> Int32? {
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: path)), guard let vData = try? Data(contentsOf: URL(fileURLWithPath: inPath)),
let vStr = String(data: vData, encoding: .utf8), let vStr = String(data: vData, encoding: .utf8),
let vPid = Int32(vStr.trimmingCharacters(in: .whitespacesAndNewlines)) let vPid = Int32(vStr.trimmingCharacters(in: .whitespacesAndNewlines))
else { return nil } else { return nil }
+96 -6
View File
@@ -39,15 +39,72 @@ final class Preferences: ObservableObject, @unchecked Sendable {
@Published var serviceMode: ServiceMode { @Published var serviceMode: ServiceMode {
didSet { UserDefaults.standard.set(serviceMode.rawValue, forKey: Self.kServiceModeKey) } 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 // last sort descriptor used, restored on next launch
@Published var lastSort: FileSortDescriptor { @Published var lastSort: FileSortDescriptor {
didSet { UserDefaults.standard.set(lastSort.rawValue, forKey: Self.kLastSortKey) } 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 // service installation modes
enum ServiceMode: String, CaseIterable, Identifiable { enum ServiceMode: String, CaseIterable, Identifiable {
case none // no service: GUI indexes in its own process 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 kMountedKey = "Allofit.includeMountedVolumes"
private static let kNetworkKey = "Allofit.includeNetworkVolumes" private static let kNetworkKey = "Allofit.includeNetworkVolumes"
private static let kServiceModeKey = "Allofit.serviceMode" private static let kServiceModeKey = "Allofit.serviceMode"
private static let kLastQueryKey = "Allofit.lastQuery"
private static let kLastSortKey = "Allofit.lastSort" 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 // flushes pending UserDefaults writes to disk so the daemon (which reads
// the plist file directly) picks up the latest settings on next start // 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() UserDefaults.standard.synchronize()
} }
// loads every setting, from the owner's plist when running as the root daemon
private init() { private init() {
// When we are the root daemon, the standard UserDefaults points at // When we are the root daemon, the standard UserDefaults points at
// /var/root/Library/Preferences/... which is *not* where the GUI user // /var/root/Library/Preferences/... which is *not* where the GUI user
@@ -88,13 +154,28 @@ final class Preferences: ObservableObject, @unchecked Sendable {
} else { } else {
serviceMode = .none serviceMode = .none
} }
lastQuery = Self.readString(forKey: Self.kLastQueryKey, from: vSourceDict) ?? ""
if let vRaw = Self.readString(forKey: Self.kLastSortKey, from: vSourceDict), if let vRaw = Self.readString(forKey: Self.kLastSortKey, from: vSourceDict),
let vSort = FileSortDescriptor(rawValue: vRaw) { let vSort = FileSortDescriptor(rawValue: vRaw) {
lastSort = vSort lastSort = vSort
} else { } else {
lastSort = .nameAscending 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 } if let vDict = inDict { return vDict[inKey] as? Bool }
return UserDefaults.standard.object(forKey: 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 // reads a string from either the supplied plist dict or UserDefaults
private static func readString(forKey inKey: String, from inDict: [String: Any]?) -> String? { private static func readString(forKey inKey: String, from inDict: [String: Any]?) -> String? {
if let vDict = inDict { return vDict[inKey] as? 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] return [FileManager.default.homeDirectoryForCurrentUser.path]
} }
// default exclusions: system noise for the daemon, caches and trash for a user
private static func defaultExcludedPaths() -> [String] { private static func defaultExcludedPaths() -> [String] {
if ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1" { if ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1" {
return [ return [
@@ -170,6 +257,9 @@ final class Preferences: ObservableObject, @unchecked Sendable {
return [ return [
"~/Library/Caches", "~/Library/Caches",
"~/Library/Containers", "~/Library/Containers",
// other apps' shared data: reading it triggers macOS 15's
// "access data from other apps" prompt
"~/Library/Group Containers",
"~/.Trash", "~/.Trash",
"/private/var/folders" "/private/var/folders"
].map { ($0 as NSString).expandingTildeInPath } ].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 import Foundation
// ResultSorter turns the matching positions of a search into the first N // ResultSorter orders the matching positions of a search. The full order of
// records in the requested order. Only N rows are ever displayed, so instead // a large result takes a while (~0.2 s for 600k entries), so the first rows
// of sorting every match (seconds for an unfiltered million-entry index) it // can be computed on their own: a bounded max-heap of the best N candidates
// keeps a bounded max-heap of the best N candidates: O(m log N), and in // is O(m log N), about one comparison per match once the heap is warm, which
// practice about one comparison per match once the heap is warm. // lets the top of the list appear before the complete sort finishes.
enum ResultSorter { enum ResultSorter {
// returns the first inLimit records of inPositions, ordered by inDescriptor // returns the first inLimit records of inPositions, ordered by inDescriptor
static func top(inRecords: [FileRecord], static func top<C: RandomAccessCollection>(inRecords: C,
inPositions: [Int32], inPositions: [Int32],
inLimit: Int, 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) let vLess = comparator(for: inDescriptor)
// strict weak order on positions, ties broken by id for stability // strict weak order on positions, ties broken by id for stability
let vBefore: (Int32, Int32) -> Bool = { vA, vB in let vBefore: (Int32, Int32) -> Bool = { vA, vB in
@@ -23,7 +34,15 @@ enum ResultSorter {
} }
if inPositions.count <= inLimit { 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 // max-heap (worst candidate on top) of the best inLimit seen so far
@@ -49,7 +68,36 @@ enum ResultSorter {
vHeap[0] = vPos vHeap[0] = vPos
siftDown(0) 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 // 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? let requiresDirectory: Bool?
// true when the term was prefixed with "!" // true when the term was prefixed with "!"
let negated: Bool 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 // AND of OR-groups: every group must have at least one matching term
@@ -68,8 +80,8 @@ struct SearchEngine: Sendable {
} }
vJoinNext = false vJoinNext = false
} }
// cheap name-only groups first: path terms build a lowercased // cheap name-only groups first: path terms scan the folder path
// full path per record, so they only run on records that survived // too, so they only run on records that survived the name terms
self.groups = vGroups.sorted { vA, vB in self.groups = vGroups.sorted { vA, vB in
!vA.contains(where: { $0.matchesPath }) && vB.contains(where: { $0.matchesPath }) !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 // true when this engine has any pattern that will filter results
var isActive: Bool { !groups.isEmpty } 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 // returns true if the record satisfies every AND-group of the query
func match(inRecord: FileRecord) -> Bool { func match(inRecord: FileRecord) -> Bool {
for vGroup in groups { for vGroup in groups {
@@ -179,7 +210,8 @@ struct SearchEngine: Sendable {
pattern: vPattern, pattern: vPattern,
matchesPath: vIsPath, matchesPath: vIsPath,
requiresDirectory: vRequiresDir, requiresDirectory: vRequiresDir,
negated: vNegated negated: vNegated,
text: String(vText)
) )
} }
@@ -194,8 +226,7 @@ struct SearchEngine: Sendable {
vResult = false vResult = false
} else if let vPattern = inTerm.pattern { } else if let vPattern = inTerm.pattern {
if inTerm.matchesPath { if inTerm.matchesPath {
let vPath = FileRecord.searchForm(of: inRecord.fullPath) vResult = matchesPath(inPattern: vPattern, inFoldedParent: inRecord.parentLower, inFoldedName: inRecord.nameLower)
vResult = matches(inPattern: vPattern, inTarget: vPath)
} else { } else {
vResult = matches(inPattern: vPattern, inTarget: inRecord.nameLower) vResult = matches(inPattern: vPattern, inTarget: inRecord.nameLower)
} }
@@ -203,7 +234,7 @@ struct SearchEngine: Sendable {
return inTerm.negated ? !vResult : vResult 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 { private static func matches(inPattern: Pattern, inTarget: String) -> Bool {
var vTarget = inTarget var vTarget = inTarget
return vTarget.withUTF8 { vBytes in return vTarget.withUTF8 { vBytes in
@@ -213,13 +244,35 @@ struct SearchEngine: Sendable {
case .glob(let vGlob): case .glob(let vGlob):
return vGlob.withUnsafeBufferPointer { globMatch(inPattern: $0, inText: vBytes) } return vGlob.withUnsafeBufferPointer { globMatch(inPattern: $0, inText: vBytes) }
case .pathGlob(let vGlob): 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): case .extensions(let vExts):
return hasExtension(inName: vBytes, inExtensions: 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 // substring search over raw bytes
private static func containsBytes(inHaystack: UnsafeBufferPointer<UInt8>, inNeedle: [UInt8]) -> Bool { private static func containsBytes(inHaystack: UnsafeBufferPointer<UInt8>, inNeedle: [UInt8]) -> Bool {
if inNeedle.isEmpty { return true } 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 // true when the name's extension (after the last dot) is in the list
private static func hasExtension(inName: UnsafeBufferPointer<UInt8>, inExtensions: [[UInt8]]) -> Bool { private static func hasExtension(inName: UnsafeBufferPointer<UInt8>, inExtensions: [[UInt8]]) -> Bool {
guard let vDot = inName.lastIndex(of: UInt8(ascii: ".")), vDot > 0 else { return false } guard let vDot = inName.lastIndex(of: UInt8(ascii: ".")), vDot > 0 else { return false }
@@ -246,6 +316,13 @@ struct SearchEngine: Sendable {
return vI 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. // anchored wildcard match: * = any run, ? = exactly one character.
// Iterative with single-star backtracking (linear in practice). // Iterative with single-star backtracking (linear in practice).
private static func globMatch(inPattern: UnsafeBufferPointer<UInt8>, inText: UnsafeBufferPointer<UInt8>) -> Bool { 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 // (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 // (end of path, or a "/" in the path). A pattern ending with "/" matches
// everything inside that folder. // 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: "/") let kSlash = UInt8(ascii: "/")
if inPattern.first == kSlash { if inPattern.first == kSlash {
return pathGlobFrom(inPattern: inPattern, inP: 0, inText: inText, inT: 0) return pathGlobFrom(inPattern: inPattern, inP: 0, inText: inText, inT: 0)
@@ -297,7 +374,7 @@ struct SearchEngine: Sendable {
// inP and text index inT // inP and text index inT
private static func pathGlobFrom(inPattern: UnsafeBufferPointer<UInt8>, private static func pathGlobFrom(inPattern: UnsafeBufferPointer<UInt8>,
inP: Int, inP: Int,
inText: UnsafeBufferPointer<UInt8>, inText: JoinedBytes,
inT: Int) -> Bool { inT: Int) -> Bool {
let kSlash = UInt8(ascii: "/") let kSlash = UInt8(ascii: "/")
let kStar = 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) 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 // returns the effective root URLs combining configured paths and mounted
// volumes that match the include-mounted / include-network preferences. // volumes that match the include-mounted / include-network preferences.
// Paths are standardized and nested roots dropped (a root inside another // Paths are canonicalized and nested roots dropped (a root inside another
// root would otherwise be walked, watched and indexed twice). // root would otherwise be walked, watched and indexed twice).
static func effectiveRoots(inPreferences: Preferences) -> [URL] { 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 { if inPreferences.includeMountedVolumes || inPreferences.includeNetworkVolumes {
for vVol in mountedVolumes() { for vVol in mountedVolumes() {
if vVol.isNetwork && !inPreferences.includeNetworkVolumes { continue } if vVol.isNetwork && !inPreferences.includeNetworkVolumes { continue }
if !vVol.isNetwork && !inPreferences.includeMountedVolumes { 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)) let vMinimal = Set(SubtreeMatcher.minimalRoots(inPaths: vPaths))
@@ -67,4 +67,18 @@ enum VolumeManager {
.filter { vMinimal.contains($0) && vSeen.insert($0).inserted } .filter { vMinimal.contains($0) && vSeen.insert($0).inserted }
.map { URL(fileURLWithPath: $0) } .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) let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: vMatcher)
vState.lock.lock() vState.lock.lock()
let vResult = vState.index.apply(inChanges: vResolved, inRoots: vRootSet) let vResult = vState.index.apply(inChanges: vResolved, inRoots: vRootSet)
vState.lastEventId = max(vState.lastEventId, vResolved.maxEventId)
if vResult.changed { vState.dirty = true } 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() vState.lock.unlock()
if vResolved.historyDone { if vResolved.historyDone {
NSLog("[Allofit] FSEvents history replay done") NSLog("[Allofit] FSEvents history replay done")
@@ -136,22 +142,28 @@ enum AllofitService {
vState.dirty = true vState.dirty = true
vState.lock.unlock() 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 { if !vResult.rescans.isEmpty {
NSLog("[Allofit] rescanning %d subtree(s)", vResult.rescans.count) NSLog("[Allofit] rescanning %d subtree(s)", vResult.rescans.count)
var vFresh: [FileRecord] = [] let vFresh = walk(inPaths: vResult.rescans, inExclusions: vMatcher)
for vPath in vResult.rescans {
vFresh.append(contentsOf: FileIndexer.indexRoot(
inRoot: URL(fileURLWithPath: vPath),
inExclusions: vMatcher
))
}
vState.lock.lock() vState.lock.lock()
vState.index.replaceSubtrees(inRoots: vResult.rescans, inRecords: vFresh) vState.index.replaceSubtrees(inRoots: vResult.rescans, inRecords: vFresh)
vState.dirty = true vState.dirty = true
vState.lock.unlock() 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) exit(0)
} }
// periodic save loop (background thread). 3-second check interval so // walks the given subtrees, returning every indexable entry below them
// new files appear in the GUI within a few seconds of being created. 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 // Every N saves we also compact the in-memory containers so Swift's
// Array/Dict capacity (which only grows on churn, never auto-shrinks) // Array/Dict capacity (which only grows on churn, never auto-shrinks)
// doesn't drift into multi-GB territory after a day of heavy file // 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) { private static func startAutosaveLoop(inState: State) {
DispatchQueue.global(qos: .utility).async { DispatchQueue.global(qos: .utility).async {
let kCompactEvery = 20 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 vSavesSinceCompact = 0
var vLastSaveAt = Date.distantPast
while true { while true {
sleep(3) sleep(3)
if Date().timeIntervalSince(vLastSaveAt) < kMinSaveIntervalSeconds { continue }
autoreleasepool { autoreleasepool {
inState.lock.lock() inState.lock.lock()
let vShouldSave = inState.dirty let vShouldSave = inState.dirty
@@ -190,6 +223,7 @@ enum AllofitService {
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)", NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
vSnapshot.count, processFootprintMB()) vSnapshot.count, processFootprintMB())
IndexStore.save(inRecords: vSnapshot, inLastEventId: vEventId) IndexStore.save(inRecords: vSnapshot, inLastEventId: vEventId)
vLastSaveAt = Date()
vSavesSinceCompact += 1 vSavesSinceCompact += 1
if vSavesSinceCompact >= kCompactEvery { if vSavesSinceCompact >= kCompactEvery {
vSavesSinceCompact = 0 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]) { private static func runStart(inArgs: [String]) {
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled) let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled)
guard !vScopes.isEmpty else { 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]) { private static func runStop(inArgs: [String]) {
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled) let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled)
guard !vScopes.isEmpty else { guard !vScopes.isEmpty else {
@@ -148,12 +150,14 @@ enum CLI {
print("Service mode set to \(vMode.rawValue).") 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) { private static func writeMode(_ inMode: Preferences.ServiceMode, toDomain inDomain: String) {
guard let vDefaults = UserDefaults(suiteName: inDomain) else { return } guard let vDefaults = UserDefaults(suiteName: inDomain) else { return }
vDefaults.set(inMode.rawValue, forKey: kServiceModeKey) vDefaults.set(inMode.rawValue, forKey: kServiceModeKey)
vDefaults.synchronize() vDefaults.synchronize()
} }
// prints the install / run state of both service scopes
private static func runStatus() { private static func runStatus() {
print("Bundle version: \(ServiceInstaller.bundleVersion())") print("Bundle version: \(ServiceInstaller.bundleVersion())")
for vScope in [ServiceInstaller.Scope.userAgent, .rootDaemon] { for vScope in [ServiceInstaller.Scope.userAgent, .rootDaemon] {
@@ -180,6 +184,7 @@ enum CLI {
private static let kProcessName = "Allofit" private static let kProcessName = "Allofit"
private static let kServiceModeKey = "Allofit.serviceMode" private static let kServiceModeKey = "Allofit.serviceMode"
// which scopes a command targets when none is given
private enum ScopeResolution { private enum ScopeResolution {
case firstInstalled case firstInstalled
case everyInstalled case everyInstalled
@@ -189,13 +194,13 @@ enum CLI {
// - explicit `user`/`root` arg: just that one // - explicit `user`/`root` arg: just that one
// - no arg, firstInstalled mode: the single installed scope, if any // - no arg, firstInstalled mode: the single installed scope, if any
// - no arg, everyInstalled mode: all installed scopes // - 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) { if let vScope = parseScope(inArgs.first) {
return [vScope] return [vScope]
} }
let vAll: [ServiceInstaller.Scope] = [.userAgent, .rootDaemon] let vAll: [ServiceInstaller.Scope] = [.userAgent, .rootDaemon]
let vInstalled = vAll.filter { ServiceInstaller.isInstalled(inScope: $0) } let vInstalled = vAll.filter { ServiceInstaller.isInstalled(inScope: $0) }
switch defaultMode { switch inDefaultMode {
case .firstInstalled: case .firstInstalled:
return vInstalled.first.map { [$0] } ?? [] return vInstalled.first.map { [$0] } ?? []
case .everyInstalled: 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? { private static func parseScope(_ inArg: String?) -> ServiceInstaller.Scope? {
guard let vArg = inArg else { return nil } guard let vArg = inArg else { return nil }
switch vArg { switch vArg {
@@ -215,6 +221,7 @@ enum CLI {
} }
} }
// human-readable name of a scope
private static func label(for inScope: ServiceInstaller.Scope) -> String { private static func label(for inScope: ServiceInstaller.Scope) -> String {
switch inScope { switch inScope {
case .userAgent: return "user agent" case .userAgent: return "user agent"
@@ -228,6 +235,7 @@ enum CLI {
exit(1) exit(1)
} }
// prints the command-line usage
private static func printHelp() { private static func printHelp() {
print(""" print("""
Usage: allofit [command [args]] Usage: allofit [command [args]]
+12 -5
View File
@@ -54,6 +54,9 @@ enum ElevatedAccess {
withIntermediateDirectories: true, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700] 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 // unique destination file, keeping the original extension so the
// QLPreviewView / Launch Services can pick the right renderer // QLPreviewView / Launch Services can pick the right renderer
@@ -63,12 +66,16 @@ enum ElevatedAccess {
vDst.appendPathExtension(vExt) vDst.appendPathExtension(vExt)
} }
// cp + chown to the current user. The chmod restores plain user // Root only *reads* the original; the copy is written by the user
// rw / group+other r so the file is treated normally by QL etc. // (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 = """ let vScript = """
cp \(AdminShell.quote(inUrl.path)) \(AdminShell.quote(vDst.path)) && \ set -o pipefail; \
chown \(AdminShell.quote(NSUserName())) \(AdminShell.quote(vDst.path)) && \ /bin/cat \(AdminShell.quote(inUrl.path)) \
chmod 0644 \(AdminShell.quote(vDst.path)) | /usr/bin/sudo -u \(AdminShell.quote(NSUserName())) /usr/bin/tee \(AdminShell.quote(vDst.path)) > /dev/null
""" """
_ = try AdminShell.run(vScript) _ = try AdminShell.run(vScript)
return vDst return vDst
+57 -32
View File
@@ -67,6 +67,10 @@ enum ServiceInstaller {
at: vTarget.deletingLastPathComponent(), at: vTarget.deletingLastPathComponent(),
withIntermediateDirectories: true withIntermediateDirectories: true
) )
try? FileManager.default.createDirectory(
atPath: (logPath(inScope: .userAgent) as NSString).deletingLastPathComponent,
withIntermediateDirectories: true
)
try vPlistData.write(to: vTarget, options: .atomic) try vPlistData.write(to: vTarget, options: .atomic)
_ = runLaunchctl(inArgs: ["unload", vTarget.path]) _ = runLaunchctl(inArgs: ["unload", vTarget.path])
let vResult = runLaunchctl(inArgs: ["load", "-w", vTarget.path]) let vResult = runLaunchctl(inArgs: ["load", "-w", vTarget.path])
@@ -78,24 +82,30 @@ enum ServiceInstaller {
// root daemon install: binary copy + plist install + boot // root daemon install: binary copy + plist install + boot
// happen inside a single admin-priv script so the user // happen inside a single admin-priv script so the user
// gets ONE password prompt covering all of it // gets ONE password prompt covering all of it
let vTmp = FileManager.default.temporaryDirectory // The plist travels inside the script as base64 (only
.appendingPathComponent("\(kLabel).plist") // [A-Za-z0-9+/=], safe in single quotes) instead of a temp
try vPlistData.write(to: vTmp, options: .atomic) // file a user process could swap before root copies it in.
let vPlistBase64 = vPlistData.base64EncodedString()
let vTarget = rootDaemonPath() let vTarget = rootDaemonPath()
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
let vLogDir = (logPath(inScope: .rootDaemon) as NSString).deletingLastPathComponent
let vScript = """ let vScript = """
mkdir -p \(shellQuote(inString: vDaemonDir)) \ mkdir -p \(AdminShell.quote(vDaemonDir)) \
&& rm -f \(shellQuote(inString: vDaemonBinary)) \ && rm -f \(AdminShell.quote(vDaemonBinary)) \
&& cp \(shellQuote(inString: vBinary)) \(shellQuote(inString: vDaemonBinary)) \ && cp \(AdminShell.quote(vBinary)) \(AdminShell.quote(vDaemonBinary)) \
&& chown root:wheel \(shellQuote(inString: vDaemonBinary)) \ && chown root:wheel \(AdminShell.quote(vDaemonBinary)) \
&& chmod 755 \(shellQuote(inString: vDaemonBinary)) \ && chmod 755 \(AdminShell.quote(vDaemonBinary)) \
&& printf '%s' \(shellQuote(inString: vVersionString)) > \(shellQuote(inString: vVersionFile)) \ && printf '%s' \(AdminShell.quote(vVersionString)) > \(AdminShell.quote(vVersionFile)) \
&& chmod 644 \(shellQuote(inString: vVersionFile)) \ && chmod 644 \(AdminShell.quote(vVersionFile)) \
&& cp \(shellQuote(inString: vTmp.path)) \(shellQuote(inString: vTarget.path)) \ && mkdir -p \(AdminShell.quote(vLogDir)) \
&& chown root:wheel \(shellQuote(inString: vTarget.path)) \ && chown root:wheel \(AdminShell.quote(vLogDir)) \
&& chmod 644 \(shellQuote(inString: vTarget.path)) \ && chmod 755 \(AdminShell.quote(vLogDir)) \
; /bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \ && printf '%s' \(AdminShell.quote(vPlistBase64)) | /usr/bin/base64 -D > \(AdminShell.quote(vTarget.path)) \
; /bin/launchctl bootstrap system \(shellQuote(inString: 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) try runWithAdminPrivileges(inScript: vScript)
} }
@@ -117,8 +127,8 @@ enum ServiceInstaller {
case .rootDaemon: case .rootDaemon:
let vTarget = rootDaemonPath() let vTarget = rootDaemonPath()
let vScript = """ let vScript = """
/bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \ /bin/launchctl bootout system \(AdminShell.quote(vTarget.path)) 2>/dev/null \
; rm -f \(shellQuote(inString: vTarget.path)) \(shellQuote(inString: vDaemonBinary)) \(shellQuote(inString: vVersionFile)) ; rm -f \(AdminShell.quote(vTarget.path)) \(AdminShell.quote(vDaemonBinary)) \(AdminShell.quote(vVersionFile))
""" """
try runWithAdminPrivileges(inScript: vScript) try runWithAdminPrivileges(inScript: vScript)
} }
@@ -146,7 +156,7 @@ enum ServiceInstaller {
} }
case .rootDaemon: case .rootDaemon:
let vPlist = rootDaemonPath().path 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) try runWithAdminPrivileges(inScript: vScript)
} }
} }
@@ -162,7 +172,7 @@ enum ServiceInstaller {
} }
case .rootDaemon: case .rootDaemon:
let vPlist = rootDaemonPath().path 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) try runWithAdminPrivileges(inScript: vScript)
} }
} }
@@ -217,7 +227,7 @@ enum ServiceInstaller {
case .rootDaemon: case .rootDaemon:
let vPlist = rootDaemonPath().path let vPlist = rootDaemonPath().path
let vCache = inCacheURL.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) try runWithAdminPrivileges(inScript: vScript)
} }
} }
@@ -226,11 +236,13 @@ enum ServiceInstaller {
// MARK: Internals // MARK: Internals
// =========================== // ===========================
// location of the per-user LaunchAgent plist
private static func userAgentPath() -> URL { private static func userAgentPath() -> URL {
return FileManager.default.homeDirectoryForCurrentUser return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(kLabel).plist") .appendingPathComponent("Library/LaunchAgents/\(kLabel).plist")
} }
// location of the system-wide LaunchDaemon plist
private static func rootDaemonPath() -> URL { private static func rootDaemonPath() -> URL {
return URL(fileURLWithPath: "/Library/LaunchDaemons/\(kLabel).plist") 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 // resolves an absolute path to the currently running binary
private static func resolveBinaryPath() throws -> String { private static func resolveBinaryPath() throws -> String {
let vArg0 = CommandLine.arguments[0] // the loaded executable, not argv[0] (which a caller can set to
let vAbs = (vArg0 as NSString).standardizingPath // anything, including a relative path)
if FileManager.default.fileExists(atPath: vAbs) { return vAbs } if let vUrl = Bundle.main.executableURL?.resolvingSymlinksInPath(),
if let vUrl = Bundle.main.executableURL, vUrl.path.hasPrefix("/"),
FileManager.default.fileExists(atPath: vUrl.path) { FileManager.default.fileExists(atPath: vUrl.path) {
return 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 throw InstallError.binaryNotFound
} }
@@ -269,8 +298,10 @@ enum ServiceInstaller {
"ProgramArguments": [inBinary, "--service"], "ProgramArguments": [inBinary, "--service"],
"RunAtLoad": true, "RunAtLoad": true,
"KeepAlive": true, "KeepAlive": true,
"StandardOutPath": "/tmp/allofit-service.log", // never /tmp: a fixed name there can be pre-created as a symlink
"StandardErrorPath": "/tmp/allofit-service.err", // by any local user, and launchd opens it as root for the daemon
"StandardOutPath": logPath(inScope: inScope),
"StandardErrorPath": logPath(inScope: inScope),
"ThrottleInterval": 10 "ThrottleInterval": 10
] ]
if inScope == .rootDaemon { if inScope == .rootDaemon {
@@ -323,10 +354,4 @@ enum ServiceInstaller {
throw InstallError.authorizationFailed(vErr.errorDescription ?? "\(vErr)") 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) 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 SwiftUI
import AppKit import AppKit
import Combine
// AllofitApp is the SwiftUI App for the GUI mode. The real entry point lives // 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. // in Main.swift, which routes between this and the headless --service mode.
@@ -30,10 +31,12 @@ struct AllofitApp: App {
Button("About Allofit") { showAboutPanel() } Button("About Allofit") { showAboutPanel() }
} }
CommandGroup(after: .appInfo) { 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() } Task { await model.performReindex() }
} }
.keyboardShortcut("r", modifiers: [.command]) .keyboardShortcut("r", modifiers: [.command, .shift])
} }
// SwiftUI provides File > New Window (⌘N) automatically for a // SwiftUI provides File > New Window (⌘N) automatically for a
// WindowGroup; nothing to add here. Additional windows share the // 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. // ⌘F focuses the search field. Standard Find-style shortcut.
// SearchField's Coordinator observes the notification and calls // SearchField's Coordinator observes the notification and calls
// makeFirstResponder on its underlying NSSearchField. // 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) { CommandGroup(after: .pasteboard) {
Button("Find") { Button("Find") {
NotificationCenter.default.post(name: .allofitFocusSearch, object: nil) NotificationCenter.default.post(name: .allofitFocusSearch, object: nil)
@@ -73,6 +83,7 @@ struct AllofitWindowContent: View {
let access: AccessManager let access: AccessManager
@StateObject private var searchModel: WindowSearchModel @StateObject private var searchModel: WindowSearchModel
// creates the per-window content around the shared models
init(model inModel: AppModel, access inAccess: AccessManager) { init(model inModel: AppModel, access inAccess: AccessManager) {
self.model = inModel self.model = inModel
self.access = inAccess self.access = inAccess
@@ -89,6 +100,9 @@ struct AllofitWindowContent: View {
.environmentObject(searchModel) .environmentObject(searchModel)
.frame(minWidth: 760, minHeight: 480) .frame(minWidth: 760, minHeight: 480)
.background(MainWindowMarker()) .background(MainWindowMarker())
.background(WindowVisibilityReporter { vVisible in
searchModel.setWindowVisible(vVisible)
})
.onAppear { .onAppear {
model.start() model.start()
} }
@@ -109,7 +123,9 @@ struct AllofitWindowContent: View {
// DispatchQueue.main.async sometimes ran before SwiftUI attached the view. // DispatchQueue.main.async sometimes ran before SwiftUI attached the view.
struct MainWindowMarker: NSViewRepresentable { struct MainWindowMarker: NSViewRepresentable {
// NSView that records its window as the main window
final class MarkerView: NSView { final class MarkerView: NSView {
// captures the window once the view is attached to it
override func viewDidMoveToWindow() { override func viewDidMoveToWindow() {
super.viewDidMoveToWindow() super.viewDidMoveToWindow()
guard let vWindow = self.window else { return } 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) return MarkerView(frame: .zero)
} }
func updateNSView(_ nsView: NSView, context: Context) { // rebinds if SwiftUI ever swaps us into a different window
// rebind if SwiftUI ever swaps us into a different window func updateNSView(_ inView: NSView, context inContext: Context) {
if let vWindow = nsView.window, AppDelegate.mainWindow !== vWindow { if let vWindow = inView.window, AppDelegate.mainWindow !== vWindow {
vWindow.isReleasedWhenClosed = false vWindow.isReleasedWhenClosed = false
AppDelegate.mainWindow = vWindow 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 // Opens the standard macOS About panel with a clickable GitHub link in the
// Credits section. App name + version come from Info.plist automatically. // Credits section. App name + version come from Info.plist automatically.
private func showAboutPanel() { private func showAboutPanel() {
@@ -167,22 +278,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// nonisolated(unsafe) because all reads/writes happen on the main thread // nonisolated(unsafe) because all reads/writes happen on the main thread
// (NSView callbacks + the AppKit delegate methods are all @MainActor). // (NSView callbacks + the AppKit delegate methods are all @MainActor).
nonisolated(unsafe) static var mainWindow: NSWindow? 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 // 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 // run as a regular foreground app even when launched outside a .app
// bundle - covers the SwiftPM "swift run" case // bundle - covers the SwiftPM "swift run" case
NSApp.setActivationPolicy(.regular) NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
// shrink the AppKit help-tag (.help() / NSView.toolTip) hover delay. // shrink the AppKit help-tag (.help() / NSView.toolTip) hover delay
// The system default is ~2 s; that makes truncated Name/Path cells // from the ~1-2 s system default. Registered as a default so a
// feel unreadable. Registered as a default so a user-set value in // user-set value in the global domain still wins. The value is in
// the global domain still wins. Seconds. // milliseconds (it used to be registered as 0.3, read as 0 ms).
UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 0.3]) UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 250])
// wipe any elevated-access staging files left over from a previous // wipe any elevated-access staging files left over from a previous
// run so a crash or hard-kill doesn't accumulate privileged copies // run so a crash or hard-kill doesn't accumulate privileged copies
// in ~/Library/Caches across sessions // in ~/Library/Caches across sessions
ElevatedAccess.cleanup() 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 // bring the main window to the front so it accepts keystrokes
DispatchQueue.main.async { DispatchQueue.main.async {
for vWindow in NSApp.windows where vWindow.canBecomeKey { 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 // called on clean Cmd+Q quit; wipes the elevated-access staging dir
// so the user-readable copies of privileged files don't linger // so the user-readable copies of privileged files don't linger
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ inNotification: Notification) {
ElevatedAccess.cleanup() ElevatedAccess.cleanup()
} }
// keep the process alive when the user closes the last window: the index // 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. // stays in RAM and clicking the dock icon snaps a new window up instantly.
// Cmd+Q still quits via the standard Quit menu item. // Cmd+Q still quits via the standard Quit menu item.
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { func applicationShouldTerminateAfterLastWindowClosed(_ inSender: NSApplication) -> Bool {
return false return false
} }
@@ -211,7 +350,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// front first. The action defers to whatever the File > New Window menu // front first. The action defers to whatever the File > New Window menu
// item does (SwiftUI auto-generates that item for WindowGroup) so we stay // item does (SwiftUI auto-generates that item for WindowGroup) so we stay
// compatible with whichever underlying selector SwiftUI uses. // compatible with whichever underlying selector SwiftUI uses.
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { func applicationDockMenu(_ inSender: NSApplication) -> NSMenu? {
let vMenu = NSMenu() let vMenu = NSMenu()
let vItem = NSMenuItem(title: "New Window", let vItem = NSMenuItem(title: "New Window",
action: #selector(newWindowFromDock(_:)), 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 // 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 // action. We match on the keyboard shortcut rather than the title so the
// lookup survives localized menus. // lookup survives localized menus.
@objc func newWindowFromDock(_ sender: Any?) { @objc func newWindowFromDock(_ inSender: Any?) {
guard let vMain = NSApp.mainMenu else { return } MainActor.assumeIsolated {
for vTop in vMain.items { WindowPresenter.openNewWindow()
guard let vSub = vTop.submenu else { continue } }
for vItem in vSub.items { }
if vItem.keyEquivalent == "n",
vItem.keyEquivalentModifierMask == [.command], // adds or removes the menu bar icon and its menu
let vAction = vItem.action { private func setStatusItemVisible(_ inVisible: Bool) {
NSApp.sendAction(vAction, to: vItem.target, from: nil) guard inVisible else {
if let vItem = statusItem {
NSStatusBar.system.removeStatusItem(vItem)
statusItem = nil
}
return 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 // 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 // window and return false so AppKit doesn't run its default "unhide every
// hidden window" action (which would also resurrect the Settings window). // hidden window" action (which would also resurrect the Settings window).
func applicationShouldHandleReopen(_ sender: NSApplication, func applicationShouldHandleReopen(_ inSender: NSApplication,
hasVisibleWindows inHasVisible: Bool) -> Bool { hasVisibleWindows inHasVisible: Bool) -> Bool {
if inHasVisible { return true } if inHasVisible { return true }
if let vMain = AppDelegate.mainWindow { 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] = [] private var changes: [FSChange] = []
// true while a flush is scheduled but has not drained yet // true while a flush is scheduled but has not drained yet
private var flushScheduled = false 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() 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 // appends a batch; returns true if the caller must schedule a flush
func append(inChanges: [FSChange]) -> Bool { func append(inChanges: [FSChange]) -> Bool {
lock.lock() lock.lock()
@@ -86,9 +109,12 @@ final class AppModel: ObservableObject {
// the canonical index (records + id lookup), main-thread only // the canonical index (records + id lookup), main-thread only
private var index = IndexState() private var index = IndexState()
// every entry observed so far, in no particular order // 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) // fires on main after allRecords changed (no payload by design)
let recordsChanged = PassthroughSubject<Void, Never>() 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) // total number of entries indexed (used for the status bar)
@Published private(set) var indexedCount: Int = 0 @Published private(set) var indexedCount: Int = 0
// true while a full reindex is in progress // 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) // how often the index is written to disk while running (seconds)
private let kAutosaveSeconds: TimeInterval = 30 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 // delay before a roots / exclusions edit is applied to the live index
private let kConfigDebounceSeconds: TimeInterval = 1.0 private let kConfigDebounceSeconds: TimeInterval = 1.0
@@ -117,6 +141,18 @@ final class AppModel: ObservableObject {
private let pendingChanges = PendingChanges() private let pendingChanges = PendingChanges()
// roots the watcher was started with; their own records need no parent // roots the watcher was started with; their own records need no parent
private var watchedRoots: Set<String> = [] 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 // bumped whenever the whole index is replaced, so a subtree rescan that
// started against the previous index doesn't merge into the new one // started against the previous index doesn't merge into the new one
private var indexGeneration = 0 private var indexGeneration = 0
@@ -150,6 +186,7 @@ final class AppModel: ObservableObject {
// timestamp of the last reloadFromCache() that actually swapped data in // timestamp of the last reloadFromCache() that actually swapped data in
private var lastReloadAt: Date? private var lastReloadAt: Date?
// wires up the quit and settings observers (loading happens in start)
init() { init() {
// the cache file is loaded off-main in start() so the window // the cache file is loaded off-main in start() so the window
// appears instantly; here we only wire up observers. // appears instantly; here we only wire up observers.
@@ -162,6 +199,26 @@ final class AppModel: ObservableObject {
} }
} }
.store(in: &cancellables) .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 // apply roots / exclusions / volume edits to the live index
Publishers.Merge4( Publishers.Merge4(
prefs.$rootPaths.map { _ in () }, prefs.$rootPaths.map { _ in () },
@@ -177,6 +234,18 @@ final class AppModel: ObservableObject {
.store(in: &cancellables) .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 // 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. // 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), // To force a re-bootstrap (e.g. after Install/Uninstall swaps the role),
@@ -214,7 +283,12 @@ final class AppModel: ObservableObject {
private func bootstrap() { private func bootstrap() {
tearDownActiveMode() 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 isIndexer = false
} else { } else {
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: false).path) let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: false).path)
@@ -228,12 +302,12 @@ final class AppModel: ObservableObject {
} }
isLoadingCache = true isLoadingCache = true
let vCacheURL = IndexStore.cacheURL(forServiceMode: prefs.serviceMode) let vCacheURL = IndexStore.cacheURL(forServiceMode: vMode)
let vIsIndexer = isIndexer let vIsIndexer = isIndexer
let vRoots = Set(VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path }) let vRoots = Set(VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path })
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths) let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
NSLog("[Allofit GUI] bootstrap: serviceMode=%@ isIndexer=%@ cacheURL=%@", NSLog("[Allofit GUI] bootstrap: serviceMode=%@ isIndexer=%@ cacheURL=%@",
prefs.serviceMode.rawValue, vMode.rawValue,
vIsIndexer ? "true" : "false", vIsIndexer ? "true" : "false",
vCacheURL.path) vCacheURL.path)
ioQueue.async { [weak self] in ioQueue.async { [weak self] in
@@ -284,26 +358,44 @@ final class AppModel: ObservableObject {
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs) let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs)
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths) let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vStartId = UInt64(FSEventsGetCurrentEventId()) 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 indexQueue.async { [weak self] in
var vAccumulated: [FileRecord] = [] 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 { for vRoot in vRoots {
// per-root autoreleasepool so the file-enumeration's // per-root autoreleasepool so the file-enumeration's
// autoreleased NSURL/NSDate/NSNumber objects don't pile // autoreleased NSURL/NSDate/NSNumber objects don't pile
// up across roots inside this long-running async block // up across roots inside this long-running async block
autoreleasepool { autoreleasepool {
let vBaseline = vAccumulated.count FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount 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 { 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) let vState = IndexState(inRecords: vAccumulated)
// persist off-main before bouncing back so main never sees the // persist off-main (on the save queue, so it never races another
// LZ4 compression cost // save) before bouncing back: main never sees the LZ4 cost
vIoQueue.sync {
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId) IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
}
DispatchQueue.main.async { DispatchQueue.main.async {
self?.applyFreshIndex(inState: vState, inEventId: vStartId) 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 // the daemon would re-write it on its next autosave, so we tunnel the
// delete through the same stop/delete/start admin script. // delete through the same stop/delete/start admin script.
func performClearCache() async { func performClearCache() async {
switch prefs.serviceMode { switch activeServiceMode {
case .none: case .none:
IndexStore.clearCache(at: IndexStore.cacheURL(forServiceMode: .none)) IndexStore.clearCache(at: IndexStore.cacheURL(forServiceMode: .none))
workMessage = "Cache cleared." workMessage = "Cache cleared."
@@ -347,11 +439,14 @@ final class AppModel: ObservableObject {
// installs a launchd service for the current serviceMode preference, // installs a launchd service for the current serviceMode preference,
// off-main so the password dialog doesn't freeze the GUI. On success // 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. // the GUI hot-swaps into reader mode so the user doesn't need to relaunch.
func performInstallService() async { // inMode is the mode chosen in Settings: it is only saved once the
let vOk = await runPrivilegedAction(inLabel: "Installing service") { vScope, _ in // 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) try ServiceInstaller.install(inScope: vScope)
} }
if vOk { if vOk {
prefs.serviceMode = inMode
// give launchd a moment to bring the daemon up before we switch // give launchd a moment to bring the daemon up before we switch
// the GUI into reader mode and start watching the cache file // the GUI into reader mode and start watching the cache file
try? await Task.sleep(nanoseconds: 1_500_000_000) 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 // stops the running daemon for the current serviceMode preference
// without uninstalling. Plist stays on disk so the next launch (or // without uninstalling. Plist stays on disk so the next launch (or
// performStartService) brings it back. // performStartService) brings it back.
func performStopService() async { func performStopService(inMode: Preferences.ServiceMode? = nil) async {
await runPrivilegedAction(inLabel: "Stopping service") { vScope, _ in await runPrivilegedAction(inLabel: "Stopping service", inMode: inMode) { vScope, _ in
try ServiceInstaller.stop(inScope: vScope) try ServiceInstaller.stop(inScope: vScope)
} }
} }
@@ -371,8 +466,8 @@ final class AppModel: ObservableObject {
// starts a stopped-but-installed daemon for the current serviceMode // starts a stopped-but-installed daemon for the current serviceMode
// preference. Hot-swaps the GUI into reader mode on success so the // preference. Hot-swaps the GUI into reader mode on success so the
// reader watcher picks up the daemon's first cache write. // reader watcher picks up the daemon's first cache write.
func performStartService() async { func performStartService(inMode: Preferences.ServiceMode? = nil) async {
let vOk = await runPrivilegedAction(inLabel: "Starting service") { vScope, _ in let vOk = await runPrivilegedAction(inLabel: "Starting service", inMode: inMode) { vScope, _ in
try ServiceInstaller.start(inScope: vScope) try ServiceInstaller.start(inScope: vScope)
} }
if vOk { if vOk {
@@ -383,11 +478,16 @@ final class AppModel: ObservableObject {
// uninstalls the launchd service for the current serviceMode preference. // uninstalls the launchd service for the current serviceMode preference.
// On success the GUI hot-swaps back into built-in indexer mode. // On success the GUI hot-swaps back into built-in indexer mode.
func performUninstallService() async { func performUninstallService(inMode: Preferences.ServiceMode? = nil) async {
let vOk = await runPrivilegedAction(inLabel: "Uninstalling service") { vScope, _ in let vMode = inMode ?? prefs.serviceMode
let vOk = await runPrivilegedAction(inLabel: "Uninstalling service", inMode: vMode) { vScope, _ in
try ServiceInstaller.uninstall(inScope: vScope) try ServiceInstaller.uninstall(inScope: vScope)
} }
if vOk { 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 // give launchd a moment to actually tear the daemon down so its
// indexer lock is released before the GUI tries to grab it // indexer lock is released before the GUI tries to grab it
try? await Task.sleep(nanoseconds: 1_500_000_000) 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 // publishing isWorking/workMessage so the ui can show progress. Returns
// true if the action ran without throwing. // true if the action ran without throwing.
@discardableResult @discardableResult
// inMode defaults to the saved mode (the installed service)
private func runPrivilegedAction(inLabel: String, private func runPrivilegedAction(inLabel: String,
inMode: Preferences.ServiceMode? = nil,
inBody: @Sendable @escaping (ServiceInstaller.Scope, URL) throws -> Void) async -> Bool { 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 } guard vMode != .none else { return false }
let vScope: ServiceInstaller.Scope = (vMode == .userAgent) ? .userAgent : .rootDaemon let vScope: ServiceInstaller.Scope = (vMode == .userAgent) ? .userAgent : .rootDaemon
let vUrl = IndexStore.cacheURL(forServiceMode: vMode) 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). // writes the current index to disk (autosave, window close, replay end).
// Snapshots on main (cheap COW), compresses off-main. // Snapshots on main (cheap COW), compresses off-main.
func saveCache() { // inForce saves even when nothing changed (to persist replay progress)
guard isIndexer else { return } func saveCache(inForce: Bool = false) {
guard isIndexer, dirty || inForce else { return }
let vRecords = index.records let vRecords = index.records
// lastEventId tracks what is actually merged into the index; the // lastEventId tracks what is actually merged into the index; the
// stream's latest id can be ahead (changes still being resolved), // stream's latest id can be ahead (changes still being resolved),
// and saving that would make the next launch skip those changes // and saving that would make the next launch skip those changes.
let vEventId = lastEventId // While subtree walks are pending, the id from before them is kept.
let vEventId = rescanFloorEventId ?? lastEventId
dirty = false dirty = false
ioQueue.async { ioQueue.async {
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId) IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
@@ -445,7 +549,7 @@ final class AppModel: ObservableObject {
private func saveCacheNow() { private func saveCacheNow() {
guard isIndexer, dirty else { return } guard isIndexer, dirty else { return }
let vRecords = index.records let vRecords = index.records
let vEventId = lastEventId let vEventId = rescanFloorEventId ?? lastEventId
dirty = false dirty = false
ioQueue.sync { ioQueue.sync {
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId) IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
@@ -476,14 +580,15 @@ final class AppModel: ObservableObject {
lastEventId, vCurrentId) lastEventId, vCurrentId)
lastEventId = vCurrentId lastEventId = vCurrentId
startWatching(inSinceWhen: vCurrentId) startWatching(inSinceWhen: vCurrentId)
kickRescanSubtrees(inPaths: vRoots) // floor 0: an interrupted walk makes the next launch start over
kickRescanSubtrees(inPaths: vRoots, inFloorEventId: 0)
return return
} }
startWatching(inSinceWhen: lastEventId) startWatching(inSinceWhen: lastEventId)
// roots added since the cache was written have never been walked // roots added since the cache was written have never been walked
let vMissing = vRoots.filter { !index.contains(inPath: $0) } let vMissing = vRoots.filter { !index.contains(inPath: $0) }
if !vMissing.isEmpty { if !vMissing.isEmpty {
kickRescanSubtrees(inPaths: vMissing) kickRescanSubtrees(inPaths: vMissing, inFloorEventId: lastEventId)
} }
} }
@@ -497,25 +602,60 @@ final class AppModel: ObservableObject {
pendingChanges.reset() pendingChanges.reset()
let vPending = pendingChanges let vPending = pendingChanges
let vQueue = indexQueue let vQueue = indexQueue
let vDelay = kChangeCoalesceSeconds
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths) let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
currentExclusions = vMatcher
watcher.start( watcher.start(
inRoots: vRoots, inRoots: vRoots,
inSinceWhen: FSEventStreamEventId(inSinceWhen) inSinceWhen: FSEventStreamEventId(inSinceWhen)
) { [weak self] vChanges in ) { [weak self] vChanges in
guard vPending.append(inChanges: vChanges) else { return } guard vPending.append(inChanges: vChanges) else { return }
vQueue.asyncAfter(deadline: .now() + vDelay) { [weak self] in vQueue.asyncAfter(deadline: .now() + vPending.coalesceDelay) { [weak self] in
let vResolved = IndexState.resolve( AppModel.flush(inPending: vPending, inExclusions: vMatcher, inModel: self)
inChanges: vPending.drain(),
inExclusions: vMatcher
)
DispatchQueue.main.async {
self?.applyResolvedChanges(inResolved: vResolved)
}
} }
} }
} }
// 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() { private func startAutosaveTimer() {
autosaveTimer?.invalidate() autosaveTimer?.invalidate()
// .common mode so the timer fires even while SwiftUI is busy // .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) // in-memory work; the stats were done off-main by IndexState.resolve)
private func applyResolvedChanges(inResolved: ResolvedChanges) { private func applyResolvedChanges(inResolved: ResolvedChanges) {
guard isIndexer else { return } guard isIndexer else { return }
let vEventIdBefore = lastEventId
let vResult = index.apply(inChanges: inResolved, inRoots: watchedRoots) let vResult = index.apply(inChanges: inResolved, inRoots: watchedRoots)
lastEventId = max(lastEventId, inResolved.maxEventId) lastEventId = max(lastEventId, inResolved.maxEventId)
if vResult.changed { if vResult.changed {
@@ -540,24 +681,32 @@ final class AppModel: ObservableObject {
publishRecords() publishRecords()
} }
if !vResult.rescans.isEmpty { 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 // persist right after a relaunch replay finishes so the next launch
// resumes from here instead of replaying the same history again // resumes from here instead of replaying the same history again
if inResolved.historyDone { if inResolved.historyDone {
NSLog("[Allofit GUI] FSEvents history replay done, saving cache") NSLog("[Allofit GUI] FSEvents history replay done, saving cache")
saveCache() saveCache(inForce: true)
} }
} }
// re-walks the given subtrees in the background, then swaps their // re-walks the given subtrees in the background, then swaps their
// content in on main. Used for kernel-requested rescans, folders that // content in on main. Used for kernel-requested rescans, folders that
// were moved/renamed into the tree, and newly added roots. // 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) let vRoots = SubtreeMatcher.minimalRoots(inPaths: inPaths)
guard !vRoots.isEmpty else { return } guard !vRoots.isEmpty else { return }
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths) let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vGeneration = indexGeneration let vGeneration = indexGeneration
rescanFloorEventId = min(rescanFloorEventId ?? inFloorEventId, inFloorEventId)
rescansInFlight += 1
indexQueue.async { [weak self] in indexQueue.async { [weak self] in
var vRecords: [FileRecord] = [] var vRecords: [FileRecord] = []
for vPath in vRoots { for vPath in vRoots {
@@ -572,8 +721,18 @@ final class AppModel: ObservableObject {
} }
let vFinal = vRecords let vFinal = vRecords
DispatchQueue.main.async { 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.index.replaceSubtrees(inRoots: vRoots, inRecords: vFinal)
}
vSelf.dirty = true vSelf.dirty = true
vSelf.publishRecords() vSelf.publishRecords()
} }
@@ -591,6 +750,10 @@ final class AppModel: ObservableObject {
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths) let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vState = index let vState = index
let vGeneration = indexGeneration 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() watcher.stop()
indexQueue.async { [weak self] in indexQueue.async { [weak self] in
var vPruned = vState var vPruned = vState
@@ -600,21 +763,49 @@ final class AppModel: ObservableObject {
NSLog("[Allofit GUI] reconcile: %d records pruned", vRemoved) NSLog("[Allofit GUI] reconcile: %d records pruned", vRemoved)
vSelf.replaceIndex(inState: vPruned) vSelf.replaceIndex(inState: vPruned)
vSelf.dirty = true vSelf.dirty = true
vSelf.startWatching(inSinceWhen: vSelf.lastEventId) vSelf.lastEventId = vSnapshotEventId
vSelf.startWatching(inSinceWhen: vSnapshotEventId)
let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) } let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) }
if !vMissing.isEmpty { 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 // MARK: Reader mode
// =========================== // ===========================
// follows the cache written by another indexer (service or second instance)
private func startReaderMode() { private func startReaderMode() {
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode) let vUrl = IndexStore.cacheURL(forServiceMode: bootMode)
let vDir = vUrl.deletingLastPathComponent().path let vDir = vUrl.deletingLastPathComponent().path
let vTarget = vUrl.path let vTarget = vUrl.path
NSLog("[Allofit GUI] reader mode: watching cache at %@", vTarget) 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 - // we don't need to re-render the whole list at FSEvents' rate -
// the next tick will catch any newer state. // the next tick will catch any newer state.
private func reloadFromCache() { private func reloadFromCache() {
let vNow = Date() guard !isIndexer else { return }
if let vLast = lastReloadAt, vNow.timeIntervalSince(vLast) < kMinReloadInterval { // nobody looks: reload once a window shows up again
if AppActivity.shared.level == .hidden {
pendingReaderReload = true
return 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 lastReloadAt = vNow
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode) let vUrl = IndexStore.cacheURL(forServiceMode: bootMode)
let vCurrentEventId = lastEventId let vCurrentEventId = lastEventId
let vCurrentCount = index.count let vCurrentCount = index.count
ioQueue.async { [weak self] in ioQueue.async { [weak self] in
@@ -704,6 +913,7 @@ final class AppModel: ObservableObject {
// notifies observers that the records changed // notifies observers that the records changed
private func publishRecords() { private func publishRecords() {
lastIndexChangeAt = Date()
if indexedCount != index.count { if indexedCount != index.count {
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 // ContentView is the main window layout: a search bar bonded to the title
// bar via `.background(.bar)` (Liquid Glass on macOS 26, vibrant material // 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. // the right (toggleable via the toolbar), and a status bar at the bottom.
struct ContentView: View { struct ContentView: View {
// AppModel is intentionally not observed here: its counters change on // AppModel is intentionally not observed here: its counters change on
// every index update and would re-evaluate this body (and the Table) // every index update and would re-evaluate this body each time.
// each time. StatusBarView observes it instead. // StatusBarView observes it instead.
@EnvironmentObject var prefs: Preferences @EnvironmentObject var prefs: Preferences
@EnvironmentObject var access: AccessManager @EnvironmentObject var access: AccessManager
// per-window search model: owns this window's query + filtered slice so // per-window search model: owns this window's query + results so two
// two windows can run independent searches against the shared AppModel // windows can run independent searches against the shared AppModel
@EnvironmentObject var searchModel: WindowSearchModel @EnvironmentObject var searchModel: WindowSearchModel
@State private var selection: Set<FileRecord.ID> = [] // selected records, in row order (shared with the table, preview and
// drives the Table's drag-to-reorder and column-visibility customization. // status bar)
// Initial value is hydrated from UserDefaults so the user's column order @State private var selection: [FileRecord] = []
// survives app launches; subsequent changes flow back via .onChange. // window undo stack (Move to Trash can be undone)
@State private var columnCustomization: TableColumnCustomization<FileRecord> = ContentView.loadColumnCustomization() @Environment(\.undoManager) private var undoManager
// 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>?
// whether the right-hand preview pane is currently visible. Persisted // whether the right-hand preview pane is currently visible. Persisted
// across launches so the user's pane-visibility preference sticks. // across launches so the user's pane-visibility preference sticks.
@AppStorage("Allofit.showPreviewPane") private var showPreviewPane: Bool = true @AppStorage("Allofit.showPreviewPane") private var showPreviewPane: Bool = true
// search syntax popover
private nonisolated static let kColumnCustomizationKey = "Allofit.columnCustomization" @State private var showSyntaxHelp = false
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
}
}
)
}
var body: some View { var body: some View {
Group { Group {
@@ -70,256 +45,214 @@ struct ContentView: View {
Button { Button {
showPreviewPane.toggle() showPreviewPane.toggle()
} label: { } label: {
Image(systemName: showPreviewPane // a real label so VoiceOver reads it, shown as an icon
? "sidebar.right" Label(showPreviewPane ? "Hide Preview" : "Show Preview",
: "sidebar.squares.right") systemImage: showPreviewPane ? "sidebar.right" : "sidebar.squares.right")
.labelStyle(.iconOnly)
} }
.help(showPreviewPane ? "Hide preview" : "Show preview") .help(showPreviewPane ? "Hide preview" : "Show preview")
} }
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
SettingsLink { SettingsLink {
Image(systemName: "gearshape") Label("Settings", systemImage: "gearshape")
.labelStyle(.iconOnly)
} }
.help("Preferences (⌘,)") .help("Settings (⌘,)")
}
}
.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)
} }
} }
} }
// 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 { private var mainColumn: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
searchBar searchBar
resultsTable resultsTable
Divider() Divider()
StatusBarView() // isolated so its @Published refresh // isolated so its refreshes don't re-evaluate this body
// doesn't re-evaluate the Table closure 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 // MARK: Search bar
// =========================== // ===========================
// search field plus the Everything-style category filter
private var searchBar: some View { private var searchBar: some View {
HStack(spacing: 8) {
SearchField( SearchField(
text: $searchModel.query, text: $searchModel.query,
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg", placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
initiallyFirstResponder: true initiallyFirstResponder: true
) )
.frame(minHeight: 24) .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(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 8)
.background(.bar) .background(.bar)
} }
// =========================== // ===========================
// MARK: Results table // MARK: Results
// =========================== // ===========================
// native table with every result (see ResultsTable)
private var resultsTable: some View { private var resultsTable: some View {
// Uses the explicit `rows:` form of Table so `.draggable` lives on ResultsTable(
// TableRow rather than embedded in cell content. Cell-content records: searchModel.results,
// draggable installs a SwiftUI drag-gesture recognizer that races version: searchModel.resultsVersion,
// with NSTableView's mouseDown→selection event on macOS 26 and highlights: searchModel.highlights,
// occasionally eats left-clicks; row-level draggable doesn't. highlightEnabled: prefs.highlightMatches,
Table(of: FileRecord.self,
selection: $selection, selection: $selection,
sortOrder: sortOrderBinding, sort: $searchModel.sortDescriptor,
columnCustomization: $columnCustomization) { needsAuthorization: { vRecord in access.needsAuthorization(for: vRecord) },
TableColumn("Name", value: \FileRecord.name) { vRecord in perform: { vAction, vRecords in perform(inAction: vAction, inRecords: vRecords) }
HStack(spacing: 6) { )
Image(nsImage: IconCache.icon( // explains an empty list (loading, indexing, no match, filter on)
forName: vRecord.name, .overlay {
isDirectory: vRecord.isDirectory EmptyResultsView(stats: searchModel.stats, filter: searchModel.filter, hasQuery: !searchModel.query.isEmpty)
)) .allowsHitTesting(false)
.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)
} }
} }
}
.width(min: 200, ideal: 320)
.customizationID("name")
TableColumn("Path", value: \FileRecord.parentPath) { vRecord in // "3 selected (12 MB)" for the status bar, nil without a selection
Text(vRecord.parentPath) private var selectionSummary: String? {
.foregroundColor(.secondary) guard !selection.isEmpty else { return nil }
.truncationMode(.middle) let vBytes = selection.filter { !$0.isDirectory }.reduce(Int64(0)) { $0 + $1.size }
.lineLimit(1) let vCount = "\(selection.count.formatted()) selected"
.help(vRecord.parentPath) return vBytes > 0 ? "\(vCount) (\(Formatters.size(bytes: vBytes)))" : vCount
}
.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
}
} }
// =========================== // ===========================
// MARK: Selection actions // MARK: Actions
// =========================== // ===========================
private func revealSelection(inIds: Set<FileRecord.ID>) { // runs an action requested by the table (menu, keyboard, double-click)
// reveal in Finder shows the *original* file (not the staged copy), private func perform(inAction: ResultAction, inRecords: [FileRecord]) {
// since the user wants to navigate to the real location on disk switch inAction {
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) } case .primary:
NSWorkspace.shared.activateFileViewerSelecting(vUrls) switch prefs.primaryAction {
case .open: openRecords(inRecords)
case .reveal: revealRecords(inRecords)
} }
case .open: openRecords(inRecords)
private func quickLookSelection(inIds: Set<FileRecord.ID>) { case .openWith(let vApp): openRecords(inRecords, withApp: vApp)
// prefer the staged URL when one exists - QLPreviewPanel renders case .reveal: revealRecords(inRecords)
// it without permission issues, whereas the original would fail case .quickLook:
let vUrls = recordsFor(inIds: inIds).map { access.effectiveURL(for: $0) } // prefer the staged URL when one exists - QLPreviewPanel
QuickLookCoordinator.shared.show(inUrls: vUrls) // renders it without permission issues
} QuickLookCoordinator.shared.show(inUrls: inRecords.map { access.effectiveURL(for: $0) })
case .copyFiles:
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 }
NSPasteboard.general.clearContents() 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>) { // reveals the records in Finder (the original files, not staged copies)
// open the staged copy when available so the default app can read private func revealRecords(_ inRecords: [FileRecord]) {
// it; falls back to the original path for files we can read directly NSWorkspace.shared.activateFileViewerSelecting(inRecords.map { URL(fileURLWithPath: $0.fullPath) })
for vRecord in recordsFor(inIds: inIds) { }
// 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)) 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 // Extracted into its own View so its @Published-driven refreshes (cache
// load progress, indexed count changes during a scan, service-mode flip) // load progress, indexed count changes during a scan, service-mode flip,
// only re-evaluate this small leaf view rather than the ContentView body // the resource sampler's 2-second tick) only re-evaluate this small leaf
// that contains the Table. // view rather than the ContentView body that contains the results.
private struct StatusBarView: View { private struct StatusBarView: View {
@EnvironmentObject var model: AppModel @EnvironmentObject var model: AppModel
@EnvironmentObject var prefs: Preferences @EnvironmentObject var prefs: Preferences
@EnvironmentObject var searchModel: WindowSearchModel @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 { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -348,18 +292,29 @@ private struct StatusBarView: View {
Text("Indexing… \(model.indexedCount.formatted()) entries") Text("Indexing… \(model.indexedCount.formatted()) entries")
} else { } else {
Text(resultsText) Text(resultsText)
if let vSelection = selectionText {
Text("·")
Text(vSelection)
}
} }
Spacer() Spacer()
Text(model.isIndexer ? "Indexer" : "Reader") if freeze.isHeld {
.foregroundColor(.secondary) Label("Frozen", systemImage: "snowflake")
switch prefs.serviceMode { .foregroundColor(.accentColor)
case .none: EmptyView() .help("Holding ⌥ Option pauses list updates; release to catch up")
case .userAgent: Text("· User service").foregroundColor(.secondary)
case .rootDaemon: Text("· Root service").foregroundColor(.secondary)
} }
if !searchModel.query.isEmpty { HStack(spacing: 6) {
Text("· Filtered").foregroundColor(.secondary) 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(.horizontal, 12)
.padding(.vertical, 4) .padding(.vertical, 4)
@@ -367,14 +322,88 @@ private struct StatusBarView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
// "N results" plus how many are listed when the list is capped // "N results · M indexed"
private var resultsText: String { private var resultsText: String {
let vMatches = searchModel.matchCount let vCount = stats.matchCount
let vShown = searchModel.visibleRecords.count return "\(vCount.formatted()) \(vCount == 1 ? "result" : "results") · \(model.indexedCount.formatted()) indexed"
let vIndexed = model.indexedCount.formatted() }
if vShown < vMatches {
return "\(vMatches.formatted()) results (first \(vShown.formatted()) listed) · \(vIndexed) 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 // Formatters bundles the byte-count and date formatting used by both
// the Table's columns and the right-hand preview pane footer. Keeping // the Table's columns and the right-hand preview pane footer. Keeping
// the formatter instances cached at file scope avoids reconstructing // 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 { enum Formatters {
// file-size formatter (decimal units, like Finder)
private static let kSizeFormatter: ByteCountFormatter = { private static let kSizeFormatter: ByteCountFormatter = {
let vF = ByteCountFormatter() let vF = ByteCountFormatter()
vF.countStyle = .file vF.countStyle = .file
@@ -17,6 +18,24 @@ enum Formatters {
return kSizeFormatter.string(fromByteCount: inBytes) 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 = { private static let kDateFormatter: DateFormatter = {
let vF = DateFormatter() let vF = DateFormatter()
vF.dateStyle = .short vF.dateStyle = .short
@@ -24,7 +43,7 @@ enum Formatters {
return vF 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 { static func date(_ inDate: Date) -> String {
if inDate.timeIntervalSince1970 < 1 { return "-" } if inDate.timeIntervalSince1970 < 1 { return "-" }
return kDateFormatter.string(from: inDate) 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 // the file to preview; nil clears the view
let url: URL? 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 { guard let vView = QLPreviewView(frame: .zero, style: .normal) else {
return NSView() return NSView()
} }
@@ -23,50 +24,13 @@ struct QuickLookPreviewView: NSViewRepresentable {
return vView return vView
} }
func updateNSView(_ nsView: NSView, context: Context) { // shows the current file
guard let vQlView = nsView as? QLPreviewView else { return } func updateNSView(_ inView: NSView, context inContext: Context) {
guard let vQlView = inView as? QLPreviewView else { return }
vQlView.previewItem = (url as NSURL?) 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 // 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 // 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 // small metadata footer. If the file isn't user-readable the preview
@@ -79,11 +43,11 @@ struct PreviewPane: View {
@EnvironmentObject var searchModel: WindowSearchModel @EnvironmentObject var searchModel: WindowSearchModel
// passed in from ContentView (its @State) so this view re-renders // passed in from ContentView (its @State) so this view re-renders
// whenever the user's selection changes // 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? { private var selectedRecord: FileRecord? {
guard selection.count == 1, let vId = selection.first else { return nil } return selection.count == 1 ? selection.first : nil
return searchModel.visibleRecords.first(where: { $0.id == vId })
} }
var body: some View { 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 // MARK: QLPreviewPanelDataSource
// =========================== // ===========================
func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { // number of files shown by the panel
func numberOfPreviewItems(in inPanel: QLPreviewPanel!) -> Int {
return urls.count return urls.count
} }
func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! { // file shown at the given panel position
return urls[index] as NSURL 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. // listens and re-grabs first responder, mimicking the standard Find shortcut.
extension Notification.Name { extension Notification.Name {
static let allofitFocusSearch = Notification.Name("AllofitFocusSearch") 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 // SearchField wraps AppKit's NSSearchField so we get native macOS behaviors
@@ -17,17 +20,15 @@ struct SearchField: NSViewRepresentable {
@Binding var text: String @Binding var text: String
// placeholder shown when the field is empty // placeholder shown when the field is empty
var placeholder: String = "" 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 // set true to grab keyboard focus when the field is first inserted
var initiallyFirstResponder: Bool = false var initiallyFirstResponder: Bool = false
// builds and configures the underlying NSSearchField // builds and configures the underlying NSSearchField
func makeNSView(context: Context) -> NSSearchField { func makeNSView(context inContext: Context) -> NSSearchField {
let vField = NSSearchField() let vField = NSSearchField()
vField.placeholderString = placeholder vField.placeholderString = placeholder
vField.delegate = context.coordinator vField.delegate = inContext.coordinator
vField.target = context.coordinator vField.target = inContext.coordinator
vField.action = #selector(Coordinator.actionTriggered(_:)) vField.action = #selector(Coordinator.actionTriggered(_:))
// keep filtering live as the user types // keep filtering live as the user types
vField.sendsSearchStringImmediately = false vField.sendsSearchStringImmediately = false
@@ -54,7 +55,15 @@ struct SearchField: NSViewRepresentable {
vMenu.addItem(vClear) vMenu.addItem(vClear)
vField.searchMenuTemplate = vMenu 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 { if initiallyFirstResponder {
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -65,12 +74,13 @@ struct SearchField: NSViewRepresentable {
} }
// keeps the NSSearchField's stringValue in sync with our binding // keeps the NSSearchField's stringValue in sync with our binding
func updateNSView(_ nsView: NSSearchField, context: Context) { func updateNSView(_ inView: NSSearchField, context inContext: Context) {
if nsView.stringValue != text { if inView.stringValue != text {
nsView.stringValue = text inView.stringValue = text
} }
} }
// creates the delegate bridging AppKit events to SwiftUI
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
return Coordinator(self) return Coordinator(self)
} }
@@ -97,6 +107,7 @@ struct SearchField: NSViewRepresentable {
// pending auto-save task; cancelled+rescheduled on every keystroke // pending auto-save task; cancelled+rescheduled on every keystroke
private var autoSaveTask: Task<Void, Never>? private var autoSaveTask: Task<Void, Never>?
// binds to the SearchField and listens for the Find shortcut
init(_ inParent: SearchField) { init(_ inParent: SearchField) {
parent = inParent parent = inParent
super.init() super.init()
@@ -108,8 +119,17 @@ struct SearchField: NSViewRepresentable {
name: .allofitFocusSearch, name: .allofitFocusSearch,
object: nil 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 { deinit {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
@@ -125,9 +145,21 @@ struct SearchField: NSViewRepresentable {
vField.selectText(nil) 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 // fired on every keystroke; reset navigation and push text to SwiftUI
func controlTextDidChange(_ notification: Notification) { func controlTextDidChange(_ inNotification: Notification) {
guard let vField = notification.object as? NSSearchField else { return } guard let vField = inNotification.object as? NSSearchField else { return }
navigationIndex = nil navigationIndex = nil
let vValue = vField.stringValue let vValue = vField.stringValue
if parent.text != vValue { if parent.text != vValue {
@@ -162,27 +194,33 @@ struct SearchField: NSViewRepresentable {
} }
// NSSearchField's action target: fires on Return and on recent-pick // NSSearchField's action target: fires on Return and on recent-pick
@objc func actionTriggered(_ sender: NSSearchField) { @objc func actionTriggered(_ inSender: NSSearchField) {
if parent.text != sender.stringValue { if parent.text != inSender.stringValue {
parent.text = sender.stringValue parent.text = inSender.stringValue
} }
parent.onSubmit?()
} }
// intercept arrow-keys and Return for history nav and recents-update // intercept arrow-keys and Return for history nav and recents-update
func control(_ control: NSControl, func control(_ inControl: NSControl,
textView: NSTextView, textView inTextView: NSTextView,
doCommandBy commandSelector: Selector) -> Bool { doCommandBy inSelector: Selector) -> Bool {
guard let vField = control as? NSSearchField else { return false } guard let vField = inControl as? NSSearchField else { return false }
if commandSelector == #selector(NSResponder.moveUp(_:)) { // ↓ 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) navigateHistory(field: vField, inOlder: true)
return true return true
} }
if commandSelector == #selector(NSResponder.moveDown(_:)) { if inSelector == #selector(NSResponder.moveToEndOfParagraph(_:)) {
navigateHistory(field: vField, inOlder: false) navigateHistory(field: vField, inOlder: false)
return true return true
} }
if commandSelector == #selector(NSControl.insertNewline(_:)) { if inSelector == #selector(NSControl.insertNewline(_:)) {
addCurrentToRecents(field: vField) addCurrentToRecents(field: vField)
return false return false
} }
@@ -190,25 +228,25 @@ struct SearchField: NSViewRepresentable {
} }
// adds the current text to the recents list (deduped, newest-first) // adds the current text to the recents list (deduped, newest-first)
private func addCurrentToRecents(field: NSSearchField) { private func addCurrentToRecents(field inField: NSSearchField) {
let vQuery = field.stringValue.trimmingCharacters(in: .whitespaces) let vQuery = inField.stringValue.trimmingCharacters(in: .whitespaces)
guard !vQuery.isEmpty else { return } guard !vQuery.isEmpty else { return }
var vRecents = field.recentSearches var vRecents = inField.recentSearches
vRecents.removeAll { $0 == vQuery } vRecents.removeAll { $0 == vQuery }
vRecents.insert(vQuery, at: 0) vRecents.insert(vQuery, at: 0)
if vRecents.count > 30 { vRecents = Array(vRecents.prefix(30)) } if vRecents.count > 30 { vRecents = Array(vRecents.prefix(30)) }
field.recentSearches = vRecents inField.recentSearches = vRecents
navigationIndex = nil navigationIndex = nil
} }
// walks one step through the recent-searches list // walks one step through the recent-searches list
private func navigateHistory(field: NSSearchField, inOlder: Bool) { private func navigateHistory(field inField: NSSearchField, inOlder: Bool) {
let vHistory = field.recentSearches let vHistory = inField.recentSearches
if vHistory.isEmpty { return } if vHistory.isEmpty { return }
if inOlder { if inOlder {
if navigationIndex == nil { if navigationIndex == nil {
savedQuery = field.stringValue savedQuery = inField.stringValue
navigationIndex = 0 navigationIndex = 0
} else if let vI = navigationIndex, vI + 1 < vHistory.count { } else if let vI = navigationIndex, vI + 1 < vHistory.count {
navigationIndex = vI + 1 navigationIndex = vI + 1
@@ -219,7 +257,7 @@ struct SearchField: NSViewRepresentable {
navigationIndex = vI - 1 navigationIndex = vI - 1
} else { } else {
navigationIndex = nil navigationIndex = nil
field.stringValue = savedQuery inField.stringValue = savedQuery
parent.text = savedQuery parent.text = savedQuery
return return
} }
@@ -230,7 +268,7 @@ struct SearchField: NSViewRepresentable {
if let vIdx = navigationIndex, vIdx < vHistory.count { if let vIdx = navigationIndex, vIdx < vHistory.count {
let vValue = vHistory[vIdx] let vValue = vHistory[vIdx]
field.stringValue = vValue inField.stringValue = vValue
parent.text = vValue parent.text = vValue
} }
} }
+375 -56
View File
@@ -1,9 +1,10 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
import ServiceManagement
// SettingsView is the multi-tab Preferences window. It exposes roots, // SettingsView is the Preferences window, grouped like Everything's options:
// exclusions, mounted volume inclusion, background service installation, // General (app behavior), Indexes (what gets indexed), Performance (update
// and on-disk cache management. // timings) and Advanced (background service, cache, diagnostics).
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var model: AppModel @EnvironmentObject var model: AppModel
@@ -11,24 +12,202 @@ struct SettingsView: View {
var body: some View { var body: some View {
TabView { TabView {
RootsTab() GeneralTab()
.tabItem { Label("Roots", systemImage: "folder") } .tabItem { Label("General", systemImage: "gearshape") }
ExclusionsTab() IndexesTab()
.tabItem { Label("Exclusions", systemImage: "minus.circle") } .tabItem { Label("Indexes", systemImage: "folder") }
VolumesTab() PerformanceTab()
.tabItem { Label("Volumes", systemImage: "externaldrive") } .tabItem { Label("Performance", systemImage: "speedometer") }
ServiceTab() AdvancedTab()
.tabItem { Label("Service", systemImage: "gearshape.2") } .tabItem { Label("Advanced", systemImage: "wrench.and.screwdriver") }
CacheTab()
.tabItem { Label("Cache", systemImage: "internaldrive") }
DiagnosticsTab()
.tabItem { Label("Diagnostics", systemImage: "stethoscope") }
} }
.frame(width: 620, height: 480) .frame(width: 620, height: 500)
.padding() .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 // MARK: Roots tab
// =========================== // ===========================
@@ -112,7 +291,7 @@ private struct ExclusionsTab: View {
.frame(minHeight: 180) .frame(minHeight: 180)
HStack { HStack {
TextField("Path or tilde-expanded path", text: $newExclusion) TextField("Path, e.g. ~/Projects/build", text: $newExclusion)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
Button("Add") { Button("Add") {
let vTrim = newExclusion.trimmingCharacters(in: .whitespaces) 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() { private func chooseFolder() {
let vPanel = NSOpenPanel() let vPanel = NSOpenPanel()
vPanel.canChooseDirectories = true vPanel.canChooseDirectories = true
@@ -149,6 +329,7 @@ private struct ExclusionsTab: View {
} }
} }
// removes the currently-selected exclusion
private func removeSelected() { private func removeSelected() {
guard let vSel = selection else { return } guard let vSel = selection else { return }
prefs.excludedPaths.removeAll { $0 == vSel } 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 // 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 // status doesn't observe @Published changes since it reads file
// system state directly, so we need an explicit refresh signal // system state directly, so we need an explicit refresh signal
@State private var statusTick: Int = 0 @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? { private var scope: ServiceInstaller.Scope? {
switch prefs.serviceMode { switch chosenMode {
case .none: return nil case .none: return nil
case .userAgent: return .userAgent case .userAgent: return .userAgent
case .rootDaemon: return .rootDaemon 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 { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Background service") Text("Background service")
.font(.headline) .font(.headline)
Picker("Mode", selection: $prefs.serviceMode) { Picker("Mode", selection: modeBinding) {
Text("Off (GUI maintains the index)").tag(Preferences.ServiceMode.none) Text("Off (GUI maintains the index)").tag(Preferences.ServiceMode.none)
Text("User service (LaunchAgent, no admin needed)").tag(Preferences.ServiceMode.userAgent) Text("User service (LaunchAgent, no admin needed)").tag(Preferences.ServiceMode.userAgent)
Text("System service (LaunchDaemon as root, scans everything)").tag(Preferences.ServiceMode.rootDaemon) Text("System service (LaunchDaemon as root, scans everything)").tag(Preferences.ServiceMode.rootDaemon)
} }
.pickerStyle(.radioGroup) .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 { Group {
switch prefs.serviceMode { switch chosenMode {
case .none: case .none:
Text("The GUI process indexes and saves the cache itself.") Text("The GUI process indexes and saves the cache itself.")
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -310,7 +608,7 @@ private struct ServiceTab: View {
Text("• Prompt once for your administrator password (steps run as one privileged script).") Text("• Prompt once for your administrator password (steps run as one privileged script).")
.font(.caption) .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) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.padding(.top, 2) .padding(.top, 2)
@@ -330,23 +628,38 @@ private struct ServiceTab: View {
_ = statusTick // re-read the file-system status on each tick _ = statusTick // re-read the file-system status on each tick
return HStack { return HStack {
Button("Install") { 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") { Button("Stop") {
Task { await model.performStopService() } let vMode = chosenMode
Task { await model.performStopService(inMode: vMode) }
} }
.disabled(!vInstalled || !vRunning || model.isWorking) .disabled(!vInstalled || !vRunning || model.isWorking)
Button("Start") { Button("Start") {
Task { await model.performStartService() } let vMode = chosenMode
Task { await model.performStartService(inMode: vMode) }
} }
.disabled(!vInstalled || vRunning || model.isWorking) .disabled(!vInstalled || vRunning || model.isWorking)
Spacer() 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 prefs: Preferences
@EnvironmentObject var model: AppModel @EnvironmentObject var model: AppModel
// confirmation for Clear Cache
@State private var confirmClear = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -446,7 +761,7 @@ private struct CacheTab: View {
.foregroundColor(cacheFileExists() ? .primary : .red) .foregroundColor(cacheFileExists() ? .primary : .red)
} }
LabeledContent("Size on disk") { LabeledContent("Size on disk") {
Text(formatSize(inBytes: IndexStore.cacheFileSize(at: currentCacheURL()))) Text(Formatters.sizeOrDash(bytes: IndexStore.cacheFileSize(at: currentCacheURL())))
.font(.callout) .font(.callout)
.monospacedDigit() .monospacedDigit()
} }
@@ -485,10 +800,17 @@ private struct CacheTab: View {
if model.isWorking { if model.isWorking {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
} }
Button("Clear Cache", role: .destructive) { Button("Clear Cache…", role: .destructive) {
Task { await model.performClearCache() } confirmClear = true
} }
.disabled(model.isWorking) .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 { if !model.workMessage.isEmpty {
@@ -525,14 +847,6 @@ private struct CacheTab: View {
vFormatter.timeStyle = .medium vFormatter.timeStyle = .medium
return vFormatter.string(from: vMtime) 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) .textSelection(.enabled)
} }
LabeledContent("Cache size") { 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) .font(.callout)
.monospacedDigit() .monospacedDigit()
} }
@@ -584,7 +898,7 @@ private struct DiagnosticsTab: View {
LabeledContent("Indexer lock holder") { LabeledContent("Indexer lock holder") {
Text(daemonStatus) Text(daemonStatus)
.font(.callout) .font(.callout)
.foregroundColor(daemonStatus.contains("running") ? .primary : .red) .foregroundColor(daemonStatus.hasPrefix("running") ? .primary : .red)
} }
LabeledContent("Service plist") { LabeledContent("Service plist") {
Text(servicePlistStatus()) Text(servicePlistStatus())
@@ -594,7 +908,7 @@ private struct DiagnosticsTab: View {
Divider() Divider()
Text("Service log tail (/tmp/allofit-service.err)") Text("Service log tail (\(DiagnosticsTab.logPath(inMode: prefs.serviceMode) ?? "no service"))")
.font(.subheadline) .font(.subheadline)
ScrollView { ScrollView {
Text(serviceLogTail) Text(serviceLogTail)
@@ -609,8 +923,12 @@ private struct DiagnosticsTab: View {
HStack { HStack {
Button("Refresh now") { refresh() } Button("Refresh now") { refresh() }
Button("Open log") { 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() Spacer()
Text("auto-refresh every 2s") Text("auto-refresh every 2s")
.font(.caption) .font(.caption)
@@ -627,7 +945,7 @@ private struct DiagnosticsTab: View {
private func refresh() { private func refresh() {
Task.detached { Task.detached {
let vStatus = await DiagnosticsTab.computeDaemonStatus(inMode: Preferences.shared.serviceMode) 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 { await MainActor.run {
self.daemonStatus = vStatus self.daemonStatus = vStatus
self.serviceLogTail = vLog self.serviceLogTail = vLog
@@ -665,9 +983,18 @@ private struct DiagnosticsTab: View {
return "stale lock (pid \(vPid) not running)" return "stale lock (pid \(vPid) not running)"
} }
// reads the last 30 lines of the service stderr log file // log file of the service for a mode, nil when no service is used
private nonisolated static func readLogTail() -> String { nonisolated static func logPath(inMode: Preferences.ServiceMode) -> String? {
let vPath = "/tmp/allofit-service.err" 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)), guard let vData = try? Data(contentsOf: URL(fileURLWithPath: vPath)),
let vText = String(data: vData, encoding: .utf8) let vText = String(data: vData, encoding: .utf8)
else { else {
@@ -697,14 +1024,6 @@ private struct DiagnosticsTab: View {
return vF.string(from: vMtime) 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 // prominent reminder + shortcuts to enable Full Disk Access for the
// root daemon's binary. Without FDA, the initial scan still works // root daemon's binary. Without FDA, the initial scan still works
// (root has direct filesystem access) but FSEvents will not deliver // (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) scheduleFilter(inDelay: kTypingDebounceSeconds)
} }
} }
// the filtered, sorted and capped records currently shown in the table // the category filter chosen in the menu next to the search field
@Published private(set) var visibleRecords: [FileRecord] = [] @Published var filter: SearchFilter = .everything {
// total number of records matching the query (may exceed visibleRecords) didSet { scheduleFilter(inDelay: kTypingDebounceSeconds) }
@Published private(set) var matchCount: Int = 0 }
// 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 // strong ref to the shared index; the per-window WindowSearchModel does
// not outlive its window, so the shared model has a longer lifetime // 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 // pending filter+sort task, cancelled if a newer one supersedes it
private var filterTask: Task<Void, Never>? private var filterTask: Task<Void, Never>?
// maximum rows handed to SwiftUI Table for snappy scrolling // rows published first when a query matches more than this: the top of
private let kMaxVisibleRows = 2000 // 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 // delay after a keystroke / sort click: just enough to merge key repeats
private let kTypingDebounceSeconds: Double = 0.04 private let kTypingDebounceSeconds: Double = 0.04
// delay after an index change: merges bursts of FSEvents updates // delay after an index change: merges bursts of FSEvents updates
private let kIndexDebounceSeconds: Double = 0.3 private let kIndexDebounceSeconds: Double = 0.3
// records per parallel filter chunk // true while this model's window is on screen (not closed, minimized
private nonisolated static let kChunkSize = 16_384 // 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) { init(model inModel: AppModel) {
self.model = inModel self.model = inModel
self.sortDescriptor = Preferences.shared.lastSort self.sortDescriptor = Preferences.shared.lastSort
@@ -59,28 +86,126 @@ final class WindowSearchModel: ObservableObject {
// burst of updates never queues up copies of the index. // burst of updates never queues up copies of the index.
inModel.recordsChanged inModel.recordsChanged
.sink { [weak self] in .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) .store(in: &cancellables)
scheduleFilter(inDelay: 0) 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 // debounces filter rebuilds so we don't refilter on every keystroke
// or every FSEvents batch. The latest call wins: earlier pending // or every FSEvents batch. The latest call wins: earlier pending
// tasks are cancelled before they start (or between chunks). // 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() filterTask?.cancel()
// snapshot inputs on main; the detached task is self-contained // 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 vSort = sortDescriptor
let vRecords = model.allRecords let vRecords = model.allRecords
let vMax = kMaxVisibleRows
let vDelayNanos = UInt64(inDelay * 1_000_000_000) let vDelayNanos = UInt64(inDelay * 1_000_000_000)
filterTask = Task.detached(priority: .userInitiated) { [weak self] in filterTask = Task.detached(priority: .userInitiated) { [weak self] in
if vDelayNanos > 0 { if vDelayNanos > 0 {
try? await Task.sleep(nanoseconds: vDelayNanos) try? await Task.sleep(nanoseconds: vDelayNanos)
} }
if Task.isCancelled { return } if Task.isCancelled { return }
let vStartedAt = DispatchTime.now()
let vEngine = SearchEngine(inQuery: vQuery) let vEngine = SearchEngine(inQuery: vQuery)
// the chunks run on GCD worker threads, which can't see this // the chunks run on GCD worker threads, which can't see this
// task's cancellation - relay it through a shared flag // task's cancellation - relay it through a shared flag
@@ -96,40 +221,53 @@ final class WindowSearchModel: ObservableObject {
} }
guard let vPositions = vResult else { return } guard let vPositions = vResult else { return }
if Task.isCancelled { 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, inRecords: vRecords,
inPositions: vPositions, inPositions: vPositions,
inLimit: vMax, inLimit: WindowSearchModel.kQuickRows,
inDescriptor: vSort inDescriptor: vSort
) ))
if Task.isCancelled { return } 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 // hop back to main with DispatchQueue.main.async (rather than
// await MainActor.run) so the assignment is guaranteed to land // await MainActor.run) so the assignment lands on the next
// on the next runloop tick, avoiding NSTableView reentrance when // runloop tick, avoiding NSTableView reentrance when the
// the search field is mid-edit // search field is mid-edit
DispatchQueue.main.async { DispatchQueue.main.async {
guard let vSelf = self else { return } self?.publish(inResults: vTop, inCount: vCount, inHighlights: vHighlights, inMilliseconds: vQuickMs)
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 let vAll = ResultList(inStore: vRecords, inPositions: ResultSorter.topPositions(
// FileRecord equality catches mtime / size updates, so we inRecords: vRecords,
// only skip true no-op reassignments. inPositions: vPositions,
if vSelf.visibleRecords == vTop { return } inLimit: vCount,
vSelf.visibleRecords = vTop 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 // positions of the records matching the engine, computed in parallel
// chunks. Returns nil if the task was cancelled midway. // 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, inEngine: SearchEngine,
inCancelled: ManagedAtomicFlag) -> [Int32]? { inCancelled: ManagedAtomicFlag) -> [Int32]? {
if !inEngine.isActive { if !inEngine.isActive {
return (0..<Int32(inRecords.count)).map { $0 } 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 [] } if vChunkCount == 0 { return [] }
var vChunks = [[Int32]](repeating: [], count: vChunkCount) var vChunks = [[Int32]](repeating: [], count: vChunkCount)
vChunks.withUnsafeMutableBufferPointer { vOut in vChunks.withUnsafeMutableBufferPointer { vOut in
@@ -137,11 +275,11 @@ final class WindowSearchModel: ObservableObject {
nonisolated(unsafe) let vOutBase = vOut.baseAddress! nonisolated(unsafe) let vOutBase = vOut.baseAddress!
DispatchQueue.concurrentPerform(iterations: vChunkCount) { vChunk in DispatchQueue.concurrentPerform(iterations: vChunkCount) { vChunk in
if inCancelled.isSet { return } if inCancelled.isSet { return }
let vStart = vChunk * kChunkSize let vRecords = inRecords.chunk(at: vChunk)
let vEnd = min(vStart + kChunkSize, inRecords.count) let vBase = Int32(vChunk << RecordStore.kShift)
var vLocal: [Int32] = [] var vLocal: [Int32] = []
for vI in vStart..<vEnd where inEngine.match(inRecord: inRecords[vI]) { for (vI, vRecord) in vRecords.enumerated() where inEngine.match(inRecord: vRecord) {
vLocal.append(Int32(vI)) vLocal.append(vBase + Int32(vI))
} }
(vOutBase + vChunk).pointee = vLocal (vOutBase + vChunk).pointee = vLocal
} }
+69 -3
View File
@@ -89,7 +89,8 @@ struct IndexStateTests {
] ]
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"]) let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
#expect(vResult.changed) #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/a/new.txt"))
#expect(!vState.contains(inPath: "/r/hidden/x.txt")) #expect(!vState.contains(inPath: "/r/hidden/x.txt"))
#expect(vState.contains(inPath: "/r/moved/inside.txt")) #expect(vState.contains(inPath: "/r/moved/inside.txt"))
@@ -102,7 +103,45 @@ struct IndexStateTests {
vChanges.upserts = [Self.dir("/r/renamed")] vChanges.upserts = [Self.dir("/r/renamed")]
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"]) let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
#expect(!vState.contains(inPath: "/r/a/b/2.txt")) #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() { @Test func unchangedUpsertIsNotAChange() {
@@ -153,6 +192,33 @@ struct IndexStateTests {
#expect(Self.paths(vState) == ["/r", "/r/a", "/r/a/fresh.txt", "/r/c.txt"]) #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() { @Test func minimalRootsDropsNestedPaths() {
let vRoots = SubtreeMatcher.minimalRoots(inPaths: ["/a/b", "/a", "/a b/c", "/a", "/c"]) let vRoots = SubtreeMatcher.minimalRoots(inPaths: ["/a/b", "/a", "/a b/c", "/a", "/c"])
#expect(vRoots.sorted() == ["/a", "/a b/c", "/c"]) #expect(vRoots.sorted() == ["/a", "/a b/c", "/c"])
@@ -172,7 +238,7 @@ struct IndexStateTests {
let vUrl = FileManager.default.temporaryDirectory let vUrl = FileManager.default.temporaryDirectory
.appendingPathComponent("allofit-test-\(UUID().uuidString).bin") .appendingPathComponent("allofit-test-\(UUID().uuidString).bin")
defer { try? FileManager.default.removeItem(at: vUrl) } 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) IndexStore.save(inRecords: vRecords, inLastEventId: 42, to: vUrl)
let vLoaded = try #require(IndexStore.load(from: vUrl)) let vLoaded = try #require(IndexStore.load(from: vUrl))
#expect(vLoaded.lastEventId == 42) #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É", "/d/" + vDecomposed))
#expect(Self.matches("r?sum?.pdf", "/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")
}
} }