mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-05 12:27:26 +00:00
Compare commits
7
Commits
1.0.6
...
876122ff98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
876122ff98 | ||
|
|
eb9ee9abd9 | ||
|
|
17137a6f43 | ||
|
|
cd1d8a756e | ||
|
|
132430e3cc | ||
|
|
547753cc4d | ||
|
|
35ffc86e46 |
@@ -62,7 +62,8 @@ jobs:
|
||||
| tee "Allofit-${{ steps.version.outputs.version }}.sha256"
|
||||
|
||||
- name: Publish GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
# pinned to a commit: this step holds a write token for the repo
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.tag }}
|
||||
name: Allofit ${{ steps.version.outputs.version }}
|
||||
|
||||
+116
@@ -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.
|
||||
@@ -35,22 +35,31 @@ Same syntax as Everything:
|
||||
| `"some/folder/**/path" IMG_????.heic` | path wildcards: `*` stays inside one folder name, `**` spans any number of folders (zero too) |
|
||||
| `photos/**/` | a trailing `/` means anything inside that folder |
|
||||
|
||||
The list shows the first 2,000 results in the chosen order; the status bar shows the total count.
|
||||
Every match is listed, in the order of the column you click; the filter menu next to the search box narrows to one kind of item, and the ? button shows this syntax in the app. Hidden files and folders (dot-files, `~/Library` when hidden) are not indexed.
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| ⌥Space | Show / hide Allofit from any app (changeable in Settings) |
|
||||
| ⌘F | Focus search |
|
||||
| ⌘R | Reindex |
|
||||
| ⌘, | Preferences |
|
||||
| ↑ ↓ | Cycle search history |
|
||||
| ↑ ↓ (in the search field) | Cycle search history |
|
||||
| Return | Open the selection (or reveal it, see Settings) |
|
||||
| ⌘Return | Reveal in Finder |
|
||||
| Space / ⌘Y | Quick Look |
|
||||
| ⌘C / ⌥⌘C | Copy the files / their paths |
|
||||
| ⌘⌫ | Move to Trash |
|
||||
| Hold ⌥ | Freeze the list: no background updates until released |
|
||||
| ⇧⌘R | Rebuild the index |
|
||||
| ⌘, | Settings |
|
||||
|
||||
Hovering a cut-off name or path for half a second shows it in full. In Finder, right-click a folder → Quick Actions → **Search in Allofit** to search inside it.
|
||||
|
||||
## Background service (optional)
|
||||
|
||||
**Settings → Service** installs a LaunchAgent (user) or LaunchDaemon (root) that keeps the index updated even when the app is closed.
|
||||
**Settings → Advanced → Service** installs a LaunchAgent (user) or LaunchDaemon (root) that keeps the index updated even when the app is closed. The chosen mode only takes effect once Install succeeds. Service logs go to `~/Library/Logs/Allofit/` (user) or `/Library/Logs/Allofit/` (root).
|
||||
|
||||
Root daemon mode needs **Full Disk Access** granted to its binary in **System Settings → Privacy & Security**, otherwise it won't see new files in `~/Documents`, `~/Desktop`, `~/Downloads`. The Diagnostics tab shows the exact path to add.
|
||||
Root daemon mode needs **Full Disk Access** granted to its binary in **System Settings → Privacy & Security**, otherwise it won't see new files in `~/Documents`, `~/Desktop`, `~/Downloads`. **Settings → Advanced → Diagnostics** shows the exact path to add. The root daemon's index is readable only by root and the user who installed it.
|
||||
|
||||
## Build from source
|
||||
|
||||
|
||||
@@ -103,6 +103,14 @@ echo "==> Assembling ${kAppName}.app"
|
||||
rm -rf "${vAppBundle}"
|
||||
mkdir -p "${vMacOSDir}" "${vResourcesDir}"
|
||||
cp "${vBinarySrc}" "${vMacOSDir}/${kAppName}"
|
||||
|
||||
# drop library search paths that point outside the system (the toolchain
|
||||
# embeds one into /Applications/Xcode.app): the same binary runs as the root
|
||||
# daemon, which must never look for libraries in an admin-writable folder
|
||||
while read -r vRpath; do
|
||||
[[ -z "${vRpath}" ]] && continue
|
||||
install_name_tool -delete_rpath "${vRpath}" "${vMacOSDir}/${kAppName}"
|
||||
done < <(otool -l "${vMacOSDir}/${kAppName}" | awk '/cmd LC_RPATH/{getline; getline; print $2}' | grep -v '^/usr/lib' || true)
|
||||
chmod +x "${vMacOSDir}/${kAppName}"
|
||||
|
||||
# ==================
|
||||
@@ -211,6 +219,54 @@ ${vIconPlistEntry}
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.utilities</string>
|
||||
<!-- text shown in the macOS privacy prompts triggered by the first scan -->
|
||||
<key>NSDesktopFolderUsageDescription</key>
|
||||
<string>Allofit indexes file names on your Desktop so they show up in searches. File contents are never read.</string>
|
||||
<key>NSDocumentsFolderUsageDescription</key>
|
||||
<string>Allofit indexes file names in Documents so they show up in searches. File contents are never read.</string>
|
||||
<key>NSDownloadsFolderUsageDescription</key>
|
||||
<string>Allofit indexes file names in Downloads so they show up in searches. File contents are never read.</string>
|
||||
<key>NSRemovableVolumesUsageDescription</key>
|
||||
<string>Allofit indexes file names on external drives you choose to include in searches.</string>
|
||||
<key>NSNetworkVolumesUsageDescription</key>
|
||||
<string>Allofit indexes file names on network volumes you choose to include in searches.</string>
|
||||
<!-- Finder > right-click a folder > Services / Quick Actions > Search in Allofit -->
|
||||
<key>NSServices</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSMenuItem</key>
|
||||
<dict>
|
||||
<key>default</key>
|
||||
<string>Search in Allofit</string>
|
||||
</dict>
|
||||
<key>NSMessage</key>
|
||||
<string>searchInAllofit</string>
|
||||
<key>NSPortName</key>
|
||||
<string>${kAppName}</string>
|
||||
<key>NSRequiredContext</key>
|
||||
<dict/>
|
||||
<key>NSSendFileTypes</key>
|
||||
<array>
|
||||
<string>public.folder</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<!-- folders dropped on the Dock icon / open -a Allofit <folder> start a search inside them -->
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Folder</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>None</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.folder</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
@@ -23,8 +23,11 @@ kSystemDaemonPlist="/Library/LaunchDaemons/${kServicePlistName}"
|
||||
kUserCacheDir="$HOME/Library/Application Support/Allofit"
|
||||
kSystemCacheDir="/Library/Application Support/Allofit"
|
||||
kUserPrefsPlist="$HOME/Library/Preferences/${kBundleId}.plist"
|
||||
# old /tmp log names (versions up to 1.0.9) and the current log folders
|
||||
kServiceLogStdout="/tmp/allofit-service.log"
|
||||
kServiceLogStderr="/tmp/allofit-service.err"
|
||||
kUserLogDir="$HOME/Library/Logs/Allofit"
|
||||
kSystemLogDir="/Library/Logs/Allofit"
|
||||
|
||||
# ==================
|
||||
# MARK: Args
|
||||
@@ -169,6 +172,15 @@ if [[ -f "$kServiceLogStdout" || -f "$kServiceLogStderr" ]]; then
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -d "$kUserLogDir" ]]; then
|
||||
echo "==> Removing user service logs"
|
||||
vRun rm -rf "$kUserLogDir"
|
||||
fi
|
||||
if [[ -d "$kSystemLogDir" ]]; then
|
||||
echo "==> Removing system service logs (needs sudo)"
|
||||
vNeedsSudo
|
||||
vRun sudo rm -rf "$kSystemLogDir"
|
||||
fi
|
||||
|
||||
# ==================
|
||||
# MARK: Build artifacts
|
||||
|
||||
+1
-1
@@ -6,6 +6,7 @@ import Foundation
|
||||
// anything else / no args launch the SwiftUI GUI
|
||||
@main
|
||||
struct Main {
|
||||
// entry point: routes to the GUI, the headless service or the CLI
|
||||
static func main() {
|
||||
let vArgs = Array(CommandLine.arguments.dropFirst())
|
||||
// No args: GUI mode. The Dock and Finder launch us with no args.
|
||||
@@ -16,7 +17,6 @@ struct Main {
|
||||
// Headless indexer daemon, invoked by launchd's plist.
|
||||
if vArgs.contains("--service") {
|
||||
AllofitService.run()
|
||||
return
|
||||
}
|
||||
// Anything else is a CLI command. Unknown commands print a hint
|
||||
// to stderr and exit 1 rather than silently launching the GUI.
|
||||
|
||||
@@ -136,6 +136,19 @@ enum FileIndexer {
|
||||
if inSkipHidden && (vValues.isHidden ?? false) {
|
||||
return nil
|
||||
}
|
||||
// live updates only: on a case-insensitive volume a case-only
|
||||
// rename (Foo -> foo) still resolves the old spelling, which would
|
||||
// leave a ghost "Foo" entry. If the name stored on disk differs from
|
||||
// the reported path by case only, the reported path is gone.
|
||||
if inSkipHidden, let vStored = try? vUrl.resourceValues(forKeys: [.nameKey]).name {
|
||||
let vReported = inURL.lastPathComponent
|
||||
// .nameKey shows a ":" in an HFS name as "/"; compare composed forms
|
||||
let vOnDisk = vStored.replacingOccurrences(of: "/", with: ":").precomposedStringWithCanonicalMapping
|
||||
let vAsReported = vReported.precomposedStringWithCanonicalMapping
|
||||
if vOnDisk != vAsReported && vOnDisk.lowercased() == vAsReported.lowercased() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// name/parent come from the path (not .nameKey) so that
|
||||
// parentPath + "/" + name always round-trips to the watched path,
|
||||
// which is what the id and the parent-is-indexed rule rely on
|
||||
@@ -149,10 +162,11 @@ enum FileIndexer {
|
||||
vName = inURL.lastPathComponent
|
||||
vParentRaw = inURL.deletingLastPathComponent().path
|
||||
}
|
||||
let vParent = inInterner?.intern(vParentRaw) ?? vParentRaw
|
||||
let vParent = inInterner?.intern(vParentRaw)
|
||||
return FileRecord(
|
||||
name: vName,
|
||||
parentPath: vParent,
|
||||
parentPath: vParent?.path ?? vParentRaw,
|
||||
parentLower: vParent?.folded,
|
||||
size: Int64(vValues.fileSize ?? 0),
|
||||
dateCreated: vValues.creationDate ?? .distantPast,
|
||||
dateModified: vValues.contentModificationDate ?? .distantPast,
|
||||
@@ -161,19 +175,20 @@ enum FileIndexer {
|
||||
}
|
||||
}
|
||||
|
||||
// PathInterner hands back one shared String instance per distinct value,
|
||||
// so the thousands of records of a directory share a single parentPath
|
||||
// buffer instead of each holding its own copy. Not thread-safe: use one
|
||||
// PathInterner hands back one shared String instance per distinct value
|
||||
// (plus its search form), so the thousands of records of a directory share
|
||||
// a single parentPath / parentLower buffer instead of each holding a copy. Not thread-safe: use one
|
||||
// per walk / load.
|
||||
final class PathInterner {
|
||||
|
||||
// distinct value -> shared instance
|
||||
private var table: [String: String] = [:]
|
||||
// distinct value -> (shared instance, shared search form)
|
||||
private var table: [String: (path: String, folded: String)] = [:]
|
||||
|
||||
// returns the shared instance equal to inString
|
||||
func intern(_ inString: String) -> String {
|
||||
// returns the shared instance equal to inString and its search form
|
||||
func intern(_ inString: String) -> (path: String, folded: String) {
|
||||
if let vShared = table[inString] { return vShared }
|
||||
table[inString] = inString
|
||||
return inString
|
||||
let vEntry = (path: inString, folded: FileRecord.sharedSearchForm(of: inString))
|
||||
table[inString] = vEntry
|
||||
return vEntry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,22 +25,29 @@ struct FileRecord: Identifiable, Hashable, Sendable {
|
||||
// lowercased, NFC-normalized name used for case-insensitive matching.
|
||||
// Shares the name's storage when the name is already in that form.
|
||||
let nameLower: String
|
||||
// lowercased, NFC-normalized parent path for path terms. Interned like
|
||||
// parentPath when built through a PathInterner, so a folder's entries
|
||||
// share one copy and path searches do no per-record string work.
|
||||
let parentLower: String
|
||||
|
||||
init(name: String,
|
||||
parentPath: String,
|
||||
size: Int64,
|
||||
dateCreated: Date,
|
||||
dateModified: Date,
|
||||
isDirectory: Bool) {
|
||||
self.id = FileRecord.pathHash(inParent: parentPath, inName: name)
|
||||
self.name = name
|
||||
self.parentPath = parentPath
|
||||
self.size = size
|
||||
self.dateCreated = dateCreated
|
||||
self.dateModified = dateModified
|
||||
self.isDirectory = isDirectory
|
||||
let vLower = FileRecord.searchForm(of: name)
|
||||
self.nameLower = (vLower == name) ? name : vLower
|
||||
// builds a record; inParentLower is the precomputed search form of the
|
||||
// parent path (computed here when nil)
|
||||
init(name inName: String,
|
||||
parentPath inParentPath: String,
|
||||
parentLower inParentLower: String? = nil,
|
||||
size inSize: Int64,
|
||||
dateCreated inDateCreated: Date,
|
||||
dateModified inDateModified: Date,
|
||||
isDirectory inIsDirectory: Bool) {
|
||||
self.id = FileRecord.pathHash(inParent: inParentPath, inName: inName)
|
||||
self.name = inName
|
||||
self.parentPath = inParentPath
|
||||
self.parentLower = inParentLower ?? FileRecord.sharedSearchForm(of: inParentPath)
|
||||
self.size = inSize
|
||||
self.dateCreated = inDateCreated
|
||||
self.dateModified = inDateModified
|
||||
self.isDirectory = inIsDirectory
|
||||
self.nameLower = FileRecord.sharedSearchForm(of: inName)
|
||||
}
|
||||
|
||||
// returns the absolute full path computed from parent and name
|
||||
@@ -63,6 +70,12 @@ struct FileRecord: Identifiable, Hashable, Sendable {
|
||||
return vLower.precomposedStringWithCanonicalMapping
|
||||
}
|
||||
|
||||
// search form that reuses the input's storage when nothing changes
|
||||
static func sharedSearchForm(of inString: String) -> String {
|
||||
let vForm = searchForm(of: inString)
|
||||
return vForm == inString ? inString : vForm
|
||||
}
|
||||
|
||||
// 64-bit FNV-1a offset basis and prime
|
||||
private static let kFnvOffset: UInt64 = 0xcbf2_9ce4_8422_2325
|
||||
private static let kFnvPrime: UInt64 = 0x0000_0100_0000_01b3
|
||||
|
||||
@@ -23,10 +23,6 @@ struct FSChange {
|
||||
var mustScanSubDirs: Bool {
|
||||
return flags & FSEventStreamEventFlags(kFSEventStreamEventFlagMustScanSubDirs) != 0
|
||||
}
|
||||
// true when this event references a directory
|
||||
var isDir: Bool {
|
||||
return flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemIsDir) != 0
|
||||
}
|
||||
}
|
||||
|
||||
// FileWatcher streams real-time filesystem change notifications using FSEvents.
|
||||
@@ -51,18 +47,11 @@ final class FileWatcher: @unchecked Sendable {
|
||||
// guards stream/handler against concurrent access
|
||||
private let lock = NSLock()
|
||||
|
||||
// stops the stream so its callback never outlives the watcher
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
|
||||
// returns the latest event id observed by the running stream (or 0)
|
||||
var latestEventId: UInt64 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let vStream = stream else { return 0 }
|
||||
return UInt64(FSEventStreamGetLatestEventId(vStream))
|
||||
}
|
||||
|
||||
// starts watching inRoots, delivering batches of changes to inHandler
|
||||
// starting from inSinceWhen (defaults to "now")
|
||||
func start(inRoots: [String],
|
||||
|
||||
@@ -35,13 +35,13 @@ struct ResolvedChanges: Sendable {
|
||||
struct IndexState {
|
||||
|
||||
// all indexed entries, unordered
|
||||
private(set) var records: [FileRecord] = []
|
||||
// (chunked: snapshots are cheap and a change copies only one chunk)
|
||||
private(set) var records = RecordStore()
|
||||
// FileRecord.id -> position in records
|
||||
private(set) var positions: [UInt64: Int] = [:]
|
||||
|
||||
// builds the state from a list of records, dropping duplicate paths
|
||||
init(inRecords: [FileRecord] = []) {
|
||||
records.reserveCapacity(inRecords.count)
|
||||
positions.reserveCapacity(inRecords.count)
|
||||
for vRecord in inRecords where positions[vRecord.id] == nil {
|
||||
positions[vRecord.id] = records.count
|
||||
@@ -57,12 +57,6 @@ struct IndexState {
|
||||
return positions[FileRecord.pathHash(inPath)] != nil
|
||||
}
|
||||
|
||||
// returns the record at an absolute path, if indexed
|
||||
func record(atPath inPath: String) -> FileRecord? {
|
||||
guard let vPos = positions[FileRecord.pathHash(inPath)] else { return nil }
|
||||
return records[vPos]
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Mutations
|
||||
// ===========================
|
||||
@@ -139,9 +133,10 @@ struct IndexState {
|
||||
// directories that newly appeared. inRoots are the configured roots,
|
||||
// whose own records are allowed without an indexed parent.
|
||||
mutating func apply(inChanges: ResolvedChanges,
|
||||
inRoots: Set<String>) -> (changed: Bool, rescans: [String]) {
|
||||
inRoots: Set<String>) -> (changed: Bool, rescans: [String], newFolders: [String]) {
|
||||
var vChanged = false
|
||||
var vRescans: [String] = []
|
||||
var vNewFolders: [String] = []
|
||||
|
||||
// removals first: a rename shows up as old path gone + new path present
|
||||
if !inChanges.removals.isEmpty {
|
||||
@@ -157,7 +152,7 @@ struct IndexState {
|
||||
let vIsNew = positions[vRecord.id] == nil
|
||||
if upsert(vRecord) { vChanged = true }
|
||||
if vIsNew && vRecord.isDirectory {
|
||||
vRescans.append(vRecord.fullPath)
|
||||
vNewFolders.append(vRecord.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +160,23 @@ struct IndexState {
|
||||
for vPath in inChanges.rescans where inRoots.contains(vPath) || contains(inPath: vPath) {
|
||||
vRescans.append(vPath)
|
||||
}
|
||||
return (vChanged, SubtreeMatcher.minimalRoots(inPaths: vRescans))
|
||||
let vMinimalRescans = SubtreeMatcher.minimalRoots(inPaths: vRescans)
|
||||
// a new folder inside a kernel rescan is walked by that rescan
|
||||
let vCovered = SubtreeMatcher(inRoots: vMinimalRescans)
|
||||
let vFolders = SubtreeMatcher.minimalRoots(inPaths: vNewFolders).filter { vFolder in
|
||||
!vMinimalRescans.contains(vFolder) && !vCovered.containsChild(inParentPath: (vFolder as NSString).deletingLastPathComponent)
|
||||
}
|
||||
return (vChanged, vMinimalRescans, vFolders)
|
||||
}
|
||||
|
||||
// adds the records found by walking folders that just appeared. Nothing
|
||||
// below such a folder can be indexed yet (except entries upserted from
|
||||
// the same FSEvents batch, which upsert replaces), so unlike
|
||||
// replaceSubtrees no O(n) removal pass over the whole index is needed.
|
||||
mutating func mergeNewFolders(inRecords: [FileRecord]) {
|
||||
for vRecord in inRecords {
|
||||
upsert(vRecord)
|
||||
}
|
||||
}
|
||||
|
||||
// drops records that fall outside the current configuration: excluded
|
||||
@@ -215,10 +226,7 @@ struct IndexState {
|
||||
// the slack can amount to hundreds of MB.
|
||||
func compacted() -> IndexState {
|
||||
var vResult = IndexState()
|
||||
vResult.records = Array(unsafeUninitializedCapacity: records.count) { vBuffer, vCount in
|
||||
_ = vBuffer.initialize(from: records)
|
||||
vCount = records.count
|
||||
}
|
||||
vResult.records = RecordStore(records)
|
||||
vResult.positions = Dictionary(minimumCapacity: positions.count)
|
||||
for (vKey, vValue) in positions {
|
||||
vResult.positions[vKey] = vValue
|
||||
|
||||
+115
-64
@@ -49,14 +49,11 @@ enum IndexStore {
|
||||
in: .userDomainMask
|
||||
).first!
|
||||
}
|
||||
let vDir = vBase.appendingPathComponent("Allofit", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
|
||||
// for the system-wide path the directory may have been created by the
|
||||
// root daemon - make sure non-root users (the GUI) can traverse it
|
||||
if inSystem {
|
||||
chmod(vDir.path, 0o755)
|
||||
}
|
||||
return vDir.appendingPathComponent("index.bin")
|
||||
// pure path computation (no disk access: it's called from views);
|
||||
// saveImpl creates the folder when it writes
|
||||
return vBase
|
||||
.appendingPathComponent("Allofit", isDirectory: true)
|
||||
.appendingPathComponent("index.bin")
|
||||
}
|
||||
|
||||
// returns the URL appropriate for a given service mode
|
||||
@@ -87,7 +84,7 @@ enum IndexStore {
|
||||
// ===========================
|
||||
|
||||
// writes the records and event id atomically to the default cacheURL
|
||||
static func save(inRecords: [FileRecord], inLastEventId: UInt64) {
|
||||
static func save(inRecords: some Collection<FileRecord>, inLastEventId: UInt64) {
|
||||
save(inRecords: inRecords, inLastEventId: inLastEventId, to: cacheURL)
|
||||
}
|
||||
|
||||
@@ -96,13 +93,14 @@ enum IndexStore {
|
||||
// of Foundation objects (the compressed NSData, NSURLs created by
|
||||
// FileManager.replaceItem, etc.) - if the caller is a long-running
|
||||
// block whose own pool never drains, those would accumulate forever.
|
||||
static func save(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
|
||||
static func save(inRecords: some Collection<FileRecord>, inLastEventId: UInt64, to inUrl: URL) {
|
||||
autoreleasepool {
|
||||
save_impl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
|
||||
saveImpl(inRecords: inRecords, inLastEventId: inLastEventId, to: inUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private static func save_impl(inRecords: [FileRecord], inLastEventId: UInt64, to inUrl: URL) {
|
||||
// encodes, LZ4-compresses and atomically writes the cache (body of save)
|
||||
private static func saveImpl(inRecords: some Collection<FileRecord>, inLastEventId: UInt64, to inUrl: URL) {
|
||||
var vPayload = Data()
|
||||
vPayload.reserveCapacity(16 + inRecords.count * 80)
|
||||
writeU64(into: &vPayload, value: inLastEventId)
|
||||
@@ -136,26 +134,51 @@ enum IndexStore {
|
||||
writeU64(into: &vEnvelope, value: vUncompressedSize)
|
||||
vEnvelope.append(vCompressed)
|
||||
|
||||
let vTmp = inUrl.appendingPathExtension("tmp")
|
||||
// the folder may not exist yet (first save). The system-wide one must
|
||||
// stay traversable by the GUI user; the index inside is owner-only
|
||||
// (see restrictAccess)
|
||||
let vDir = inUrl.deletingLastPathComponent()
|
||||
try? FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
|
||||
if getuid() == 0 {
|
||||
chmod(vDir.path, 0o755)
|
||||
}
|
||||
// unique per save: two saves running at once (reindex and autosave
|
||||
// on different queues) must not write the same temp file
|
||||
let vTmp = inUrl.deletingLastPathComponent()
|
||||
.appendingPathComponent(".\(inUrl.lastPathComponent).\(UUID().uuidString).tmp")
|
||||
do {
|
||||
try vEnvelope.write(to: vTmp, options: .atomic)
|
||||
_ = try FileManager.default.replaceItem(
|
||||
at: inUrl,
|
||||
withItemAt: vTmp,
|
||||
backupItemName: nil,
|
||||
options: [],
|
||||
resultingItemURL: nil
|
||||
)
|
||||
// the root daemon writes this file owned by root. The GUI runs
|
||||
// as the user and must be able to read it, so force world-read
|
||||
// permissions regardless of the process's umask.
|
||||
chmod(inUrl.path, 0o644)
|
||||
try vEnvelope.write(to: vTmp)
|
||||
// owner-only access, set on the temp file *before* it becomes
|
||||
// the cache, so the index (a listing of every file name) is
|
||||
// never readable by other local users, even for a moment
|
||||
restrictAccess(inPath: vTmp.path)
|
||||
// rename(2) is atomic and keeps the temp file's owner and mode
|
||||
// (FileManager.replaceItem may carry over the old file's)
|
||||
guard rename(vTmp.path, inUrl.path) == 0 else {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
} catch {
|
||||
NSLog("[Allofit] cache save failed at %@: %@", inUrl.path, "\(error)")
|
||||
try? FileManager.default.removeItem(at: vTmp)
|
||||
}
|
||||
}
|
||||
|
||||
// makes a cache file readable by its owner only. The root daemon hands
|
||||
// it to the user who installed the service (the GUI reads it as that
|
||||
// user); everyone else gets nothing.
|
||||
private static func restrictAccess(inPath: String) {
|
||||
if getuid() == 0,
|
||||
let vOwner = ProcessInfo.processInfo.environment["ALLOFIT_OWNER_USER"],
|
||||
let vEntry = getpwnam(vOwner) {
|
||||
chown(inPath, vEntry.pointee.pw_uid, vEntry.pointee.pw_gid)
|
||||
}
|
||||
chmod(inPath, 0o600)
|
||||
}
|
||||
|
||||
// largest uncompressed payload accepted from a cache file (a corrupt or
|
||||
// crafted header must not make the loader allocate without bound)
|
||||
private static let kMaxPayloadBytes: UInt64 = 8 << 30
|
||||
|
||||
// reads the persisted index back from the default cacheURL
|
||||
static func load() -> LoadResult? {
|
||||
return load(from: cacheURL)
|
||||
@@ -166,16 +189,18 @@ enum IndexStore {
|
||||
// String allocations don't linger in the caller's pool.
|
||||
static func load(from inUrl: URL) -> LoadResult? {
|
||||
return autoreleasepool {
|
||||
load_impl(from: inUrl)
|
||||
loadImpl(from: inUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private static func load_impl(from inUrl: URL) -> LoadResult? {
|
||||
// reads, decompresses and decodes the cache (body of load)
|
||||
private static func loadImpl(from inUrl: URL) -> LoadResult? {
|
||||
guard let vData = try? Data(contentsOf: inUrl) else { return nil }
|
||||
var vOffset = 0
|
||||
guard let vMagic = readU32(from: vData, offset: &vOffset), vMagic == kMagic else { return nil }
|
||||
guard let vVersion = readU32(from: vData, offset: &vOffset), vVersion == kVersion else { return nil }
|
||||
guard let _ = readU64(from: vData, offset: &vOffset) else { return nil } // uncompressed size, advisory only
|
||||
guard let vDeclaredSize = readU64(from: vData, offset: &vOffset),
|
||||
vDeclaredSize <= kMaxPayloadBytes else { return nil }
|
||||
|
||||
let vCompressed = vData.subdata(in: vOffset..<vData.count)
|
||||
let vPayload: Data
|
||||
@@ -185,6 +210,7 @@ enum IndexStore {
|
||||
NSLog("[Allofit] LZ4 decompression failed: \(error)")
|
||||
return nil
|
||||
}
|
||||
guard UInt64(vPayload.count) == vDeclaredSize else { return nil }
|
||||
|
||||
var vP = 0
|
||||
guard let vLastEventId = readU64(from: vPayload, offset: &vP) else { return nil }
|
||||
@@ -209,9 +235,11 @@ enum IndexStore {
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let vShared = vInterner.intern(vParent)
|
||||
vRecords.append(FileRecord(
|
||||
name: vName,
|
||||
parentPath: vInterner.intern(vParent),
|
||||
parentPath: vShared.path,
|
||||
parentLower: vShared.folded,
|
||||
size: vSize,
|
||||
dateCreated: Date(timeIntervalSince1970: vCreated),
|
||||
dateModified: Date(timeIntervalSince1970: vModified),
|
||||
@@ -225,71 +253,94 @@ enum IndexStore {
|
||||
// MARK: Binary helpers
|
||||
// ===========================
|
||||
|
||||
private static func writeU32(into ioData: inout Data, value: UInt32) {
|
||||
var vV = value.littleEndian
|
||||
// appends a little-endian UInt32
|
||||
private static func writeU32(into ioData: inout Data, value inValue: UInt32) {
|
||||
var vV = inValue.littleEndian
|
||||
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
|
||||
}
|
||||
private static func writeU64(into ioData: inout Data, value: UInt64) {
|
||||
var vV = value.littleEndian
|
||||
|
||||
// appends a little-endian UInt64
|
||||
private static func writeU64(into ioData: inout Data, value inValue: UInt64) {
|
||||
var vV = inValue.littleEndian
|
||||
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
|
||||
}
|
||||
private static func writeI64(into ioData: inout Data, value: Int64) {
|
||||
var vV = value.littleEndian
|
||||
|
||||
// appends a little-endian Int64
|
||||
private static func writeI64(into ioData: inout Data, value inValue: Int64) {
|
||||
var vV = inValue.littleEndian
|
||||
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
|
||||
}
|
||||
private static func writeF64(into ioData: inout Data, value: Double) {
|
||||
var vV = value.bitPattern.littleEndian
|
||||
|
||||
// appends a Double as its little-endian bit pattern
|
||||
private static func writeF64(into ioData: inout Data, value inValue: Double) {
|
||||
var vV = inValue.bitPattern.littleEndian
|
||||
withUnsafeBytes(of: &vV) { ioData.append(contentsOf: $0) }
|
||||
}
|
||||
private static func writeString(into ioData: inout Data, value: String) {
|
||||
let vBytes = Array(value.utf8)
|
||||
|
||||
// appends a UInt32 byte length followed by the UTF-8 bytes
|
||||
private static func writeString(into ioData: inout Data, value inValue: String) {
|
||||
let vBytes = Array(inValue.utf8)
|
||||
writeU32(into: &ioData, value: UInt32(vBytes.count))
|
||||
ioData.append(contentsOf: vBytes)
|
||||
}
|
||||
private static func readU8(from inData: Data, offset: inout Int) -> UInt8? {
|
||||
guard offset + 1 <= inData.count else { return nil }
|
||||
let vV = inData[inData.startIndex + offset]
|
||||
offset += 1
|
||||
|
||||
// reads one byte at ioOffset and advances it; nil past the end
|
||||
private static func readU8(from inData: Data, offset ioOffset: inout Int) -> UInt8? {
|
||||
guard ioOffset + 1 <= inData.count else { return nil }
|
||||
let vV = inData[inData.startIndex + ioOffset]
|
||||
ioOffset += 1
|
||||
return vV
|
||||
}
|
||||
private static func readU32(from inData: Data, offset: inout Int) -> UInt32? {
|
||||
guard offset + 4 <= inData.count else { return nil }
|
||||
|
||||
// reads a little-endian UInt32 at ioOffset and advances it
|
||||
private static func readU32(from inData: Data, offset ioOffset: inout Int) -> UInt32? {
|
||||
guard ioOffset + 4 <= inData.count else { return nil }
|
||||
let vOffset = ioOffset
|
||||
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> UInt32 in
|
||||
vPtr.loadUnaligned(fromByteOffset: offset, as: UInt32.self).littleEndian
|
||||
vPtr.loadUnaligned(fromByteOffset: vOffset, as: UInt32.self).littleEndian
|
||||
}
|
||||
offset += 4
|
||||
ioOffset += 4
|
||||
return vV
|
||||
}
|
||||
private static func readU64(from inData: Data, offset: inout Int) -> UInt64? {
|
||||
guard offset + 8 <= inData.count else { return nil }
|
||||
|
||||
// reads a little-endian UInt64 at ioOffset and advances it
|
||||
private static func readU64(from inData: Data, offset ioOffset: inout Int) -> UInt64? {
|
||||
guard ioOffset + 8 <= inData.count else { return nil }
|
||||
let vOffset = ioOffset
|
||||
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> UInt64 in
|
||||
vPtr.loadUnaligned(fromByteOffset: offset, as: UInt64.self).littleEndian
|
||||
vPtr.loadUnaligned(fromByteOffset: vOffset, as: UInt64.self).littleEndian
|
||||
}
|
||||
offset += 8
|
||||
ioOffset += 8
|
||||
return vV
|
||||
}
|
||||
private static func readI64(from inData: Data, offset: inout Int) -> Int64? {
|
||||
guard offset + 8 <= inData.count else { return nil }
|
||||
|
||||
// reads a little-endian Int64 at ioOffset and advances it
|
||||
private static func readI64(from inData: Data, offset ioOffset: inout Int) -> Int64? {
|
||||
guard ioOffset + 8 <= inData.count else { return nil }
|
||||
let vOffset = ioOffset
|
||||
let vV = inData.withUnsafeBytes { (vPtr: UnsafeRawBufferPointer) -> Int64 in
|
||||
vPtr.loadUnaligned(fromByteOffset: offset, as: Int64.self).littleEndian
|
||||
vPtr.loadUnaligned(fromByteOffset: vOffset, as: Int64.self).littleEndian
|
||||
}
|
||||
offset += 8
|
||||
ioOffset += 8
|
||||
return vV
|
||||
}
|
||||
private static func readF64(from inData: Data, offset: inout Int) -> Double? {
|
||||
guard let vBits = readU64(from: inData, offset: &offset) else { return nil }
|
||||
|
||||
// reads a Double stored as its little-endian bit pattern
|
||||
private static func readF64(from inData: Data, offset ioOffset: inout Int) -> Double? {
|
||||
guard let vBits = readU64(from: inData, offset: &ioOffset) else { return nil }
|
||||
return Double(bitPattern: vBits)
|
||||
}
|
||||
private static func readString(from inData: Data, offset: inout Int) -> String? {
|
||||
guard let vLen = readU32(from: inData, offset: &offset) else { return nil }
|
||||
|
||||
// reads a UInt32-length-prefixed UTF-8 string and advances ioOffset
|
||||
private static func readString(from inData: Data, offset ioOffset: inout Int) -> String? {
|
||||
guard let vLen = readU32(from: inData, offset: &ioOffset) else { return nil }
|
||||
let vLength = Int(vLen)
|
||||
guard offset + vLength <= inData.count else { return nil }
|
||||
let vStart = inData.startIndex + offset
|
||||
let vEnd = vStart + vLength
|
||||
offset += vLength
|
||||
guard ioOffset + vLength <= inData.count else { return nil }
|
||||
let vStart = ioOffset
|
||||
ioOffset += vLength
|
||||
// native decode (no Foundation bridging); invalid bytes are repaired
|
||||
return inData.withUnsafeBytes { vPtr in
|
||||
String(decoding: vPtr[(vStart - inData.startIndex)..<(vEnd - inData.startIndex)], as: UTF8.self)
|
||||
String(decoding: vPtr[vStart..<(vStart + vLength)], as: UTF8.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ final class IndexerLock {
|
||||
// open descriptor, -1 when not locked
|
||||
private var fileDescriptor: Int32 = -1
|
||||
|
||||
init(path: String) {
|
||||
self.path = path
|
||||
// creates an unlocked handle for the sentinel file at inPath
|
||||
init(path inPath: String) {
|
||||
self.path = inPath
|
||||
}
|
||||
|
||||
// releases the lock together with the handle
|
||||
deinit {
|
||||
unlock()
|
||||
}
|
||||
@@ -74,8 +76,8 @@ final class IndexerLock {
|
||||
}
|
||||
|
||||
// reads the pid currently holding the lock, if any (best-effort)
|
||||
static func readHolderPid(path: String) -> Int32? {
|
||||
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
||||
static func readHolderPid(path inPath: String) -> Int32? {
|
||||
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: inPath)),
|
||||
let vStr = String(data: vData, encoding: .utf8),
|
||||
let vPid = Int32(vStr.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
else { return nil }
|
||||
|
||||
@@ -39,15 +39,72 @@ final class Preferences: ObservableObject, @unchecked Sendable {
|
||||
@Published var serviceMode: ServiceMode {
|
||||
didSet { UserDefaults.standard.set(serviceMode.rawValue, forKey: Self.kServiceModeKey) }
|
||||
}
|
||||
// last search query, restored on next launch
|
||||
@Published var lastQuery: String {
|
||||
didSet { UserDefaults.standard.set(lastQuery, forKey: Self.kLastQueryKey) }
|
||||
}
|
||||
// last sort descriptor used, restored on next launch
|
||||
@Published var lastSort: FileSortDescriptor {
|
||||
didSet { UserDefaults.standard.set(lastSort.rawValue, forKey: Self.kLastSortKey) }
|
||||
}
|
||||
|
||||
// seconds file changes are collected before being applied to the index,
|
||||
// while Allofit is focused / another app is focused / no window shows
|
||||
@Published var updateDelayForeground: Double {
|
||||
didSet { UserDefaults.standard.set(updateDelayForeground, forKey: Self.kUpdateDelayForegroundKey) }
|
||||
}
|
||||
@Published var updateDelayBackground: Double {
|
||||
didSet { UserDefaults.standard.set(updateDelayBackground, forKey: Self.kUpdateDelayBackgroundKey) }
|
||||
}
|
||||
@Published var updateDelayHidden: Double {
|
||||
didSet { UserDefaults.standard.set(updateDelayHidden, forKey: Self.kUpdateDelayHiddenKey) }
|
||||
}
|
||||
// minimum seconds between two result refreshes caused by index changes,
|
||||
// while Allofit is focused / another app is focused
|
||||
@Published var refreshIntervalForeground: Double {
|
||||
didSet { UserDefaults.standard.set(refreshIntervalForeground, forKey: Self.kRefreshForegroundKey) }
|
||||
}
|
||||
@Published var refreshIntervalBackground: Double {
|
||||
didSet { UserDefaults.standard.set(refreshIntervalBackground, forKey: Self.kRefreshBackgroundKey) }
|
||||
}
|
||||
|
||||
// true to emphasize the matched parts of names and paths in the results
|
||||
@Published var highlightMatches: Bool {
|
||||
didSet { UserDefaults.standard.set(highlightMatches, forKey: Self.kHighlightMatchesKey) }
|
||||
}
|
||||
// what a double-click / Return on a result does
|
||||
@Published var primaryAction: PrimaryAction {
|
||||
didSet { UserDefaults.standard.set(primaryAction.rawValue, forKey: Self.kPrimaryActionKey) }
|
||||
}
|
||||
|
||||
// true to show the Allofit icon in the menu bar
|
||||
@Published var showMenuBarIcon: Bool {
|
||||
didSet { UserDefaults.standard.set(showMenuBarIcon, forKey: Self.kShowMenuBarIconKey) }
|
||||
}
|
||||
// system-wide shortcut that shows / hides the main window
|
||||
@Published var globalHotKey: HotKeyPreset {
|
||||
didSet { UserDefaults.standard.set(globalHotKey.rawValue, forKey: Self.kGlobalHotKeyKey) }
|
||||
}
|
||||
|
||||
// actions available for double-click / Return on a result
|
||||
enum PrimaryAction: String, CaseIterable, Identifiable {
|
||||
case open // open with the default app
|
||||
case reveal // reveal in Finder
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
// default values of the update / refresh timings, in seconds
|
||||
static let kDefaultUpdateDelayForeground: Double = 2
|
||||
static let kDefaultUpdateDelayBackground: Double = 15
|
||||
static let kDefaultUpdateDelayHidden: Double = 60
|
||||
static let kDefaultRefreshForeground: Double = 1
|
||||
static let kDefaultRefreshBackground: Double = 5
|
||||
|
||||
// puts every update / refresh timing back to its default
|
||||
func resetTimings() {
|
||||
updateDelayForeground = Self.kDefaultUpdateDelayForeground
|
||||
updateDelayBackground = Self.kDefaultUpdateDelayBackground
|
||||
updateDelayHidden = Self.kDefaultUpdateDelayHidden
|
||||
refreshIntervalForeground = Self.kDefaultRefreshForeground
|
||||
refreshIntervalBackground = Self.kDefaultRefreshBackground
|
||||
}
|
||||
|
||||
// service installation modes
|
||||
enum ServiceMode: String, CaseIterable, Identifiable {
|
||||
case none // no service: GUI indexes in its own process
|
||||
@@ -61,8 +118,16 @@ final class Preferences: ObservableObject, @unchecked Sendable {
|
||||
private static let kMountedKey = "Allofit.includeMountedVolumes"
|
||||
private static let kNetworkKey = "Allofit.includeNetworkVolumes"
|
||||
private static let kServiceModeKey = "Allofit.serviceMode"
|
||||
private static let kLastQueryKey = "Allofit.lastQuery"
|
||||
private static let kLastSortKey = "Allofit.lastSort"
|
||||
private static let kHighlightMatchesKey = "Allofit.highlightMatches"
|
||||
private static let kShowMenuBarIconKey = "Allofit.showMenuBarIcon"
|
||||
private static let kGlobalHotKeyKey = "Allofit.globalHotKey"
|
||||
private static let kPrimaryActionKey = "Allofit.primaryAction"
|
||||
private static let kUpdateDelayForegroundKey = "Allofit.updateDelayForeground"
|
||||
private static let kUpdateDelayBackgroundKey = "Allofit.updateDelayBackground"
|
||||
private static let kUpdateDelayHiddenKey = "Allofit.updateDelayHidden"
|
||||
private static let kRefreshForegroundKey = "Allofit.refreshIntervalForeground"
|
||||
private static let kRefreshBackgroundKey = "Allofit.refreshIntervalBackground"
|
||||
|
||||
// flushes pending UserDefaults writes to disk so the daemon (which reads
|
||||
// the plist file directly) picks up the latest settings on next start
|
||||
@@ -70,6 +135,7 @@ final class Preferences: ObservableObject, @unchecked Sendable {
|
||||
UserDefaults.standard.synchronize()
|
||||
}
|
||||
|
||||
// loads every setting, from the owner's plist when running as the root daemon
|
||||
private init() {
|
||||
// When we are the root daemon, the standard UserDefaults points at
|
||||
// /var/root/Library/Preferences/... which is *not* where the GUI user
|
||||
@@ -88,13 +154,28 @@ final class Preferences: ObservableObject, @unchecked Sendable {
|
||||
} else {
|
||||
serviceMode = .none
|
||||
}
|
||||
lastQuery = Self.readString(forKey: Self.kLastQueryKey, from: vSourceDict) ?? ""
|
||||
if let vRaw = Self.readString(forKey: Self.kLastSortKey, from: vSourceDict),
|
||||
let vSort = FileSortDescriptor(rawValue: vRaw) {
|
||||
lastSort = vSort
|
||||
} else {
|
||||
lastSort = .nameAscending
|
||||
}
|
||||
highlightMatches = Self.readBool(forKey: Self.kHighlightMatchesKey, from: vSourceDict) ?? true
|
||||
showMenuBarIcon = Self.readBool(forKey: Self.kShowMenuBarIconKey, from: vSourceDict) ?? true
|
||||
globalHotKey = Self.readString(forKey: Self.kGlobalHotKeyKey, from: vSourceDict)
|
||||
.flatMap(HotKeyPreset.init(rawValue:)) ?? .optionSpace
|
||||
primaryAction = Self.readString(forKey: Self.kPrimaryActionKey, from: vSourceDict)
|
||||
.flatMap(PrimaryAction.init(rawValue:)) ?? .open
|
||||
updateDelayForeground = Self.readDouble(forKey: Self.kUpdateDelayForegroundKey, from: vSourceDict)
|
||||
?? Self.kDefaultUpdateDelayForeground
|
||||
updateDelayBackground = Self.readDouble(forKey: Self.kUpdateDelayBackgroundKey, from: vSourceDict)
|
||||
?? Self.kDefaultUpdateDelayBackground
|
||||
updateDelayHidden = Self.readDouble(forKey: Self.kUpdateDelayHiddenKey, from: vSourceDict)
|
||||
?? Self.kDefaultUpdateDelayHidden
|
||||
refreshIntervalForeground = Self.readDouble(forKey: Self.kRefreshForegroundKey, from: vSourceDict)
|
||||
?? Self.kDefaultRefreshForeground
|
||||
refreshIntervalBackground = Self.readDouble(forKey: Self.kRefreshBackgroundKey, from: vSourceDict)
|
||||
?? Self.kDefaultRefreshBackground
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -133,6 +214,11 @@ final class Preferences: ObservableObject, @unchecked Sendable {
|
||||
if let vDict = inDict { return vDict[inKey] as? Bool }
|
||||
return UserDefaults.standard.object(forKey: inKey) as? Bool
|
||||
}
|
||||
// reads a number from either the supplied plist dict or UserDefaults
|
||||
private static func readDouble(forKey inKey: String, from inDict: [String: Any]?) -> Double? {
|
||||
if let vDict = inDict { return (vDict[inKey] as? NSNumber)?.doubleValue }
|
||||
return (UserDefaults.standard.object(forKey: inKey) as? NSNumber)?.doubleValue
|
||||
}
|
||||
// reads a string from either the supplied plist dict or UserDefaults
|
||||
private static func readString(forKey inKey: String, from inDict: [String: Any]?) -> String? {
|
||||
if let vDict = inDict { return vDict[inKey] as? String }
|
||||
@@ -153,6 +239,7 @@ final class Preferences: ObservableObject, @unchecked Sendable {
|
||||
return [FileManager.default.homeDirectoryForCurrentUser.path]
|
||||
}
|
||||
|
||||
// default exclusions: system noise for the daemon, caches and trash for a user
|
||||
private static func defaultExcludedPaths() -> [String] {
|
||||
if ProcessInfo.processInfo.environment["ALLOFIT_SYSTEM_INDEX"] == "1" {
|
||||
return [
|
||||
@@ -170,6 +257,9 @@ final class Preferences: ObservableObject, @unchecked Sendable {
|
||||
return [
|
||||
"~/Library/Caches",
|
||||
"~/Library/Containers",
|
||||
// other apps' shared data: reading it triggers macOS 15's
|
||||
// "access data from other apps" prompt
|
||||
"~/Library/Group Containers",
|
||||
"~/.Trash",
|
||||
"/private/var/folders"
|
||||
].map { ($0 as NSString).expandingTildeInPath }
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
// ResultSorter turns the matching positions of a search into the first N
|
||||
// records in the requested order. Only N rows are ever displayed, so instead
|
||||
// of sorting every match (seconds for an unfiltered million-entry index) it
|
||||
// keeps a bounded max-heap of the best N candidates: O(m log N), and in
|
||||
// practice about one comparison per match once the heap is warm.
|
||||
// ResultSorter orders the matching positions of a search. The full order of
|
||||
// a large result takes a while (~0.2 s for 600k entries), so the first rows
|
||||
// can be computed on their own: a bounded max-heap of the best N candidates
|
||||
// is O(m log N), about one comparison per match once the heap is warm, which
|
||||
// lets the top of the list appear before the complete sort finishes.
|
||||
enum ResultSorter {
|
||||
|
||||
// returns the first inLimit records of inPositions, ordered by inDescriptor
|
||||
static func top(inRecords: [FileRecord],
|
||||
static func top<C: RandomAccessCollection>(inRecords: C,
|
||||
inPositions: [Int32],
|
||||
inLimit: Int,
|
||||
inDescriptor: FileSortDescriptor) -> [FileRecord] {
|
||||
inDescriptor: FileSortDescriptor) -> [FileRecord]
|
||||
where C.Index == Int, C.Element == FileRecord {
|
||||
return topPositions(inRecords: inRecords, inPositions: inPositions, inLimit: inLimit, inDescriptor: inDescriptor)
|
||||
.map { inRecords[Int($0)] }
|
||||
}
|
||||
|
||||
// same as top, returning positions into inRecords instead of copies
|
||||
static func topPositions<C: RandomAccessCollection>(inRecords: C,
|
||||
inPositions: [Int32],
|
||||
inLimit: Int,
|
||||
inDescriptor: FileSortDescriptor) -> [Int32]
|
||||
where C.Index == Int, C.Element == FileRecord {
|
||||
let vLess = comparator(for: inDescriptor)
|
||||
// strict weak order on positions, ties broken by id for stability
|
||||
let vBefore: (Int32, Int32) -> Bool = { vA, vB in
|
||||
@@ -23,7 +34,15 @@ enum ResultSorter {
|
||||
}
|
||||
|
||||
if inPositions.count <= inLimit {
|
||||
return inPositions.sorted(by: vBefore).map { inRecords[Int($0)] }
|
||||
switch inDescriptor {
|
||||
case .nameAscending, .nameDescending, .pathAscending, .pathDescending:
|
||||
// string keys: comparing in place is as fast or faster
|
||||
// (moving tuples of strings during the sort costs more)
|
||||
return inPositions.sorted(by: vBefore)
|
||||
default:
|
||||
// numeric keys: ~10x faster sorted as a compact key array
|
||||
return sortedByKey(inRecords: inRecords, inPositions: inPositions, inDescriptor: inDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
// max-heap (worst candidate on top) of the best inLimit seen so far
|
||||
@@ -49,7 +68,36 @@ enum ResultSorter {
|
||||
vHeap[0] = vPos
|
||||
siftDown(0)
|
||||
}
|
||||
return vHeap.sorted(by: vBefore).map { inRecords[Int($0)] }
|
||||
return vHeap.sorted(by: vBefore)
|
||||
}
|
||||
|
||||
// complete sort by size or date. Each record's key is read once into a
|
||||
// compact array, which is then sorted: comparing through the records
|
||||
// copies two whole FileRecords (with their strings' retain / release)
|
||||
// per comparison - 539 ms vs 47 ms for 630k entries by date. Ties are
|
||||
// broken by id, matching the top-N path.
|
||||
private static func sortedByKey<C: RandomAccessCollection>(inRecords: C,
|
||||
inPositions: [Int32],
|
||||
inDescriptor: FileSortDescriptor) -> [Int32]
|
||||
where C.Index == Int, C.Element == FileRecord {
|
||||
let vDescending = inDescriptor == .sizeDescending
|
||||
|| inDescriptor == .createdDescending
|
||||
|| inDescriptor == .modifiedDescending
|
||||
var vKeys = inPositions.map { vPos -> (Double, UInt64, Int32) in
|
||||
let vRecord = inRecords[Int(vPos)]
|
||||
let vKey: Double
|
||||
switch inDescriptor {
|
||||
case .sizeAscending, .sizeDescending: vKey = Double(vRecord.size)
|
||||
case .createdAscending, .createdDescending: vKey = vRecord.dateCreated.timeIntervalSinceReferenceDate
|
||||
default: vKey = vRecord.dateModified.timeIntervalSinceReferenceDate
|
||||
}
|
||||
return (vKey, vRecord.id, vPos)
|
||||
}
|
||||
vKeys.sort { vA, vB in
|
||||
if vA.0 != vB.0 { return vDescending ? vA.0 > vB.0 : vA.0 < vB.0 }
|
||||
return vA.1 < vB.1
|
||||
}
|
||||
return vKeys.map(\.2)
|
||||
}
|
||||
|
||||
// primary ordering for each sort mode. Names compare on the folded form
|
||||
|
||||
@@ -46,6 +46,18 @@ struct SearchEngine: Sendable {
|
||||
let requiresDirectory: Bool?
|
||||
// true when the term was prefixed with "!"
|
||||
let negated: Bool
|
||||
// the term's text as typed (no "!" / modifier prefix), used to
|
||||
// emphasize matches in the results; empty for ext: and bare modifiers
|
||||
var text: String = ""
|
||||
}
|
||||
|
||||
// literal pieces of the query to emphasize in the results, matched
|
||||
// case- and accent-insensitively against the displayed name / folder
|
||||
struct Highlights: Equatable, Sendable {
|
||||
// pieces to emphasize in names
|
||||
var name: [String] = []
|
||||
// pieces to emphasize in folder paths
|
||||
var path: [String] = []
|
||||
}
|
||||
|
||||
// AND of OR-groups: every group must have at least one matching term
|
||||
@@ -68,8 +80,8 @@ struct SearchEngine: Sendable {
|
||||
}
|
||||
vJoinNext = false
|
||||
}
|
||||
// cheap name-only groups first: path terms build a lowercased
|
||||
// full path per record, so they only run on records that survived
|
||||
// cheap name-only groups first: path terms scan the folder path
|
||||
// too, so they only run on records that survived the name terms
|
||||
self.groups = vGroups.sorted { vA, vB in
|
||||
!vA.contains(where: { $0.matchesPath }) && vB.contains(where: { $0.matchesPath })
|
||||
}
|
||||
@@ -78,6 +90,25 @@ struct SearchEngine: Sendable {
|
||||
// true when this engine has any pattern that will filter results
|
||||
var isActive: Bool { !groups.isEmpty }
|
||||
|
||||
// what to emphasize in the results: the literal runs of every positive
|
||||
// name / path term (wildcards and folder separators split the runs)
|
||||
var highlights: Highlights {
|
||||
var vResult = Highlights()
|
||||
for vTerm in groups.joined() where !vTerm.negated && !vTerm.text.isEmpty {
|
||||
let vSeparators: Set<Character> = vTerm.matchesPath ? ["*", "?", "/"] : ["*", "?"]
|
||||
let vPieces = vTerm.text
|
||||
.split(whereSeparator: { vSeparators.contains($0) })
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
if vTerm.matchesPath {
|
||||
vResult.path.append(contentsOf: vPieces)
|
||||
} else {
|
||||
vResult.name.append(contentsOf: vPieces)
|
||||
}
|
||||
}
|
||||
return vResult
|
||||
}
|
||||
|
||||
// returns true if the record satisfies every AND-group of the query
|
||||
func match(inRecord: FileRecord) -> Bool {
|
||||
for vGroup in groups {
|
||||
@@ -179,7 +210,8 @@ struct SearchEngine: Sendable {
|
||||
pattern: vPattern,
|
||||
matchesPath: vIsPath,
|
||||
requiresDirectory: vRequiresDir,
|
||||
negated: vNegated
|
||||
negated: vNegated,
|
||||
text: String(vText)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -194,8 +226,7 @@ struct SearchEngine: Sendable {
|
||||
vResult = false
|
||||
} else if let vPattern = inTerm.pattern {
|
||||
if inTerm.matchesPath {
|
||||
let vPath = FileRecord.searchForm(of: inRecord.fullPath)
|
||||
vResult = matches(inPattern: vPattern, inTarget: vPath)
|
||||
vResult = matchesPath(inPattern: vPattern, inFoldedParent: inRecord.parentLower, inFoldedName: inRecord.nameLower)
|
||||
} else {
|
||||
vResult = matches(inPattern: vPattern, inTarget: inRecord.nameLower)
|
||||
}
|
||||
@@ -203,7 +234,7 @@ struct SearchEngine: Sendable {
|
||||
return inTerm.negated ? !vResult : vResult
|
||||
}
|
||||
|
||||
// runs a pattern against an already case-folded target string
|
||||
// runs a pattern against an already case-folded name
|
||||
private static func matches(inPattern: Pattern, inTarget: String) -> Bool {
|
||||
var vTarget = inTarget
|
||||
return vTarget.withUTF8 { vBytes in
|
||||
@@ -213,13 +244,35 @@ struct SearchEngine: Sendable {
|
||||
case .glob(let vGlob):
|
||||
return vGlob.withUnsafeBufferPointer { globMatch(inPattern: $0, inText: vBytes) }
|
||||
case .pathGlob(let vGlob):
|
||||
return vGlob.withUnsafeBufferPointer { pathGlobMatch(inPattern: $0, inText: vBytes) }
|
||||
return vGlob.withUnsafeBufferPointer {
|
||||
pathGlobMatch(inPattern: $0, inText: JoinedBytes(inHead: UnsafeBufferPointer(start: nil, count: 0), inTail: vBytes))
|
||||
}
|
||||
case .extensions(let vExts):
|
||||
return hasExtension(inName: vBytes, inExtensions: vExts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runs a path pattern against folder + "/" + name without building the
|
||||
// joined string (both parts already case-folded)
|
||||
private static func matchesPath(inPattern: Pattern, inFoldedParent: String, inFoldedName: String) -> Bool {
|
||||
var vName = inFoldedName
|
||||
var vParent = inFoldedParent
|
||||
return vName.withUTF8 { vNameBytes in
|
||||
vParent.withUTF8 { vParentBytes in
|
||||
let vText = JoinedBytes(inHead: vParentBytes, inTail: vNameBytes)
|
||||
switch inPattern {
|
||||
case .substring(let vNeedle):
|
||||
return joinedContains(inText: vText, inNeedle: vNeedle)
|
||||
case .pathGlob(let vGlob), .glob(let vGlob):
|
||||
return vGlob.withUnsafeBufferPointer { pathGlobMatch(inPattern: $0, inText: vText) }
|
||||
case .extensions(let vExts):
|
||||
return hasExtension(inName: vNameBytes, inExtensions: vExts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// substring search over raw bytes
|
||||
private static func containsBytes(inHaystack: UnsafeBufferPointer<UInt8>, inNeedle: [UInt8]) -> Bool {
|
||||
if inNeedle.isEmpty { return true }
|
||||
@@ -229,6 +282,23 @@ struct SearchEngine: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// substring search over folder + "/" + name: inside the folder part,
|
||||
// inside the name, or straddling the "/" between them
|
||||
private static func joinedContains(inText: JoinedBytes, inNeedle: [UInt8]) -> Bool {
|
||||
if containsBytes(inHaystack: inText.head, inNeedle: inNeedle) { return true }
|
||||
if containsBytes(inHaystack: inText.tail, inNeedle: inNeedle) { return true }
|
||||
if inNeedle.count < 2 || inNeedle.count > inText.count { return inNeedle.count == 1 && inText.hasSeparator && inNeedle[0] == UInt8(ascii: "/") }
|
||||
// window around the junction: up to needle-1 bytes on each side
|
||||
let vStart = max(0, inText.head.count - (inNeedle.count - 1))
|
||||
let vEnd = min(inText.count, inText.head.count + (inText.hasSeparator ? 1 : 0) + (inNeedle.count - 1))
|
||||
return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: vEnd - vStart) { vWindow in
|
||||
for vI in vStart..<vEnd {
|
||||
vWindow[vI - vStart] = inText[vI]
|
||||
}
|
||||
return containsBytes(inHaystack: UnsafeBufferPointer(vWindow), inNeedle: inNeedle)
|
||||
}
|
||||
}
|
||||
|
||||
// true when the name's extension (after the last dot) is in the list
|
||||
private static func hasExtension(inName: UnsafeBufferPointer<UInt8>, inExtensions: [[UInt8]]) -> Bool {
|
||||
guard let vDot = inName.lastIndex(of: UInt8(ascii: ".")), vDot > 0 else { return false }
|
||||
@@ -246,6 +316,13 @@ struct SearchEngine: Sendable {
|
||||
return vI
|
||||
}
|
||||
|
||||
// same as nextScalar, over a joined folder + name
|
||||
private static func nextScalar(in inText: JoinedBytes, after inIndex: Int) -> Int {
|
||||
var vI = inIndex + 1
|
||||
while vI < inText.count && (inText[vI] & 0xC0) == 0x80 { vI += 1 }
|
||||
return vI
|
||||
}
|
||||
|
||||
// anchored wildcard match: * = any run, ? = exactly one character.
|
||||
// Iterative with single-star backtracking (linear in practice).
|
||||
private static func globMatch(inPattern: UnsafeBufferPointer<UInt8>, inText: UnsafeBufferPointer<UInt8>) -> Bool {
|
||||
@@ -282,7 +359,7 @@ struct SearchEngine: Sendable {
|
||||
// (or only at the root when it starts with "/") and must end on one
|
||||
// (end of path, or a "/" in the path). A pattern ending with "/" matches
|
||||
// everything inside that folder.
|
||||
private static func pathGlobMatch(inPattern: UnsafeBufferPointer<UInt8>, inText: UnsafeBufferPointer<UInt8>) -> Bool {
|
||||
private static func pathGlobMatch(inPattern: UnsafeBufferPointer<UInt8>, inText: JoinedBytes) -> Bool {
|
||||
let kSlash = UInt8(ascii: "/")
|
||||
if inPattern.first == kSlash {
|
||||
return pathGlobFrom(inPattern: inPattern, inP: 0, inText: inText, inT: 0)
|
||||
@@ -297,7 +374,7 @@ struct SearchEngine: Sendable {
|
||||
// inP and text index inT
|
||||
private static func pathGlobFrom(inPattern: UnsafeBufferPointer<UInt8>,
|
||||
inP: Int,
|
||||
inText: UnsafeBufferPointer<UInt8>,
|
||||
inText: JoinedBytes,
|
||||
inT: Int) -> Bool {
|
||||
let kSlash = UInt8(ascii: "/")
|
||||
let kStar = UInt8(ascii: "*")
|
||||
@@ -343,3 +420,36 @@ struct SearchEngine: Sendable {
|
||||
return vT == inText.count || inText[vT] == kSlash || (vP > 0 && inPattern[vP - 1] == kSlash)
|
||||
}
|
||||
}
|
||||
|
||||
// JoinedBytes presents folder bytes + "/" + name bytes as one read-only
|
||||
// byte sequence, so path matching never allocates the joined path.
|
||||
private struct JoinedBytes {
|
||||
|
||||
// case-folded folder path ("" for none, "/" for the root)
|
||||
let head: UnsafeBufferPointer<UInt8>
|
||||
// case-folded name
|
||||
let tail: UnsafeBufferPointer<UInt8>
|
||||
// true when a "/" separates head and tail (not after "" or "/")
|
||||
let hasSeparator: Bool
|
||||
// total length including the separator
|
||||
let count: Int
|
||||
|
||||
// joins a folder and a name
|
||||
init(inHead: UnsafeBufferPointer<UInt8>, inTail: UnsafeBufferPointer<UInt8>) {
|
||||
head = inHead
|
||||
tail = inTail
|
||||
hasSeparator = !inHead.isEmpty && !(inHead.count == 1 && inHead[0] == UInt8(ascii: "/"))
|
||||
count = inHead.count + (hasSeparator ? 1 : 0) + inTail.count
|
||||
}
|
||||
|
||||
// byte at inIndex of the joined sequence
|
||||
subscript(inIndex: Int) -> UInt8 {
|
||||
if inIndex < head.count { return head[inIndex] }
|
||||
var vI = inIndex - head.count
|
||||
if hasSeparator {
|
||||
if vI == 0 { return UInt8(ascii: "/") }
|
||||
vI -= 1
|
||||
}
|
||||
return tail[vI]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ";")
|
||||
}
|
||||
}
|
||||
@@ -49,15 +49,15 @@ enum VolumeManager {
|
||||
|
||||
// returns the effective root URLs combining configured paths and mounted
|
||||
// volumes that match the include-mounted / include-network preferences.
|
||||
// Paths are standardized and nested roots dropped (a root inside another
|
||||
// Paths are canonicalized and nested roots dropped (a root inside another
|
||||
// root would otherwise be walked, watched and indexed twice).
|
||||
static func effectiveRoots(inPreferences: Preferences) -> [URL] {
|
||||
var vPaths: [String] = inPreferences.rootPaths.map { URL(fileURLWithPath: $0).standardizedFileURL.path }
|
||||
var vPaths: [String] = inPreferences.rootPaths.map { canonicalPath(inPath: $0) }
|
||||
if inPreferences.includeMountedVolumes || inPreferences.includeNetworkVolumes {
|
||||
for vVol in mountedVolumes() {
|
||||
if vVol.isNetwork && !inPreferences.includeNetworkVolumes { continue }
|
||||
if !vVol.isNetwork && !inPreferences.includeMountedVolumes { continue }
|
||||
vPaths.append(vVol.url.standardizedFileURL.path)
|
||||
vPaths.append(canonicalPath(inPath: vVol.url.path))
|
||||
}
|
||||
}
|
||||
let vMinimal = Set(SubtreeMatcher.minimalRoots(inPaths: vPaths))
|
||||
@@ -67,4 +67,18 @@ enum VolumeManager {
|
||||
.filter { vMinimal.contains($0) && vSeen.insert($0).inserted }
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
}
|
||||
|
||||
// the path the kernel reports for inPath (symlinks resolved, e.g.
|
||||
// /tmp -> /private/tmp), which is what FSEvents and the walker produce.
|
||||
// Foundation's standardizedFileURL / resolvingSymlinksInPath strip the
|
||||
// /private prefix instead, so a root under /tmp, /var or /etc would never
|
||||
// match its own entries. Falls back to the given path (minus a trailing
|
||||
// slash) when it doesn't exist, e.g. an unmounted volume.
|
||||
static func canonicalPath(inPath: String) -> String {
|
||||
if let vResolved = realpath(inPath, nil) {
|
||||
defer { free(vResolved) }
|
||||
return String(cString: vResolved)
|
||||
}
|
||||
return URL(fileURLWithPath: inPath).path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +127,14 @@ enum AllofitService {
|
||||
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: vMatcher)
|
||||
vState.lock.lock()
|
||||
let vResult = vState.index.apply(inChanges: vResolved, inRoots: vRootSet)
|
||||
vState.lastEventId = max(vState.lastEventId, vResolved.maxEventId)
|
||||
if vResult.changed { vState.dirty = true }
|
||||
// with no walk pending the batch is fully merged: advance the
|
||||
// saved event id now; otherwise only once the walks are in,
|
||||
// so a restart mid-walk replays the changes that caused them
|
||||
let vHasWalks = !vResult.rescans.isEmpty || !vResult.newFolders.isEmpty
|
||||
if !vHasWalks {
|
||||
vState.lastEventId = max(vState.lastEventId, vResolved.maxEventId)
|
||||
}
|
||||
vState.lock.unlock()
|
||||
if vResolved.historyDone {
|
||||
NSLog("[Allofit] FSEvents history replay done")
|
||||
@@ -136,22 +142,28 @@ enum AllofitService {
|
||||
vState.dirty = true
|
||||
vState.lock.unlock()
|
||||
}
|
||||
guard vHasWalks else { return }
|
||||
|
||||
// folders moved in, or history lost: walk them again
|
||||
// history lost (kernel rescans): replace those subtrees
|
||||
if !vResult.rescans.isEmpty {
|
||||
NSLog("[Allofit] rescanning %d subtree(s)", vResult.rescans.count)
|
||||
var vFresh: [FileRecord] = []
|
||||
for vPath in vResult.rescans {
|
||||
vFresh.append(contentsOf: FileIndexer.indexRoot(
|
||||
inRoot: URL(fileURLWithPath: vPath),
|
||||
inExclusions: vMatcher
|
||||
))
|
||||
}
|
||||
let vFresh = walk(inPaths: vResult.rescans, inExclusions: vMatcher)
|
||||
vState.lock.lock()
|
||||
vState.index.replaceSubtrees(inRoots: vResult.rescans, inRecords: vFresh)
|
||||
vState.dirty = true
|
||||
vState.lock.unlock()
|
||||
}
|
||||
// folders that appeared (created, moved or renamed in)
|
||||
if !vResult.newFolders.isEmpty {
|
||||
let vFresh = walk(inPaths: vResult.newFolders, inExclusions: vMatcher)
|
||||
vState.lock.lock()
|
||||
vState.index.mergeNewFolders(inRecords: vFresh)
|
||||
vState.dirty = true
|
||||
vState.lock.unlock()
|
||||
}
|
||||
vState.lock.lock()
|
||||
vState.lastEventId = max(vState.lastEventId, vResolved.maxEventId)
|
||||
vState.lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,8 +172,22 @@ enum AllofitService {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
// periodic save loop (background thread). 3-second check interval so
|
||||
// new files appear in the GUI within a few seconds of being created.
|
||||
// walks the given subtrees, returning every indexable entry below them
|
||||
private static func walk(inPaths: [String], inExclusions: ExclusionMatcher) -> [FileRecord] {
|
||||
var vResult: [FileRecord] = []
|
||||
for vPath in inPaths {
|
||||
autoreleasepool {
|
||||
vResult.append(contentsOf: FileIndexer.indexRoot(
|
||||
inRoot: URL(fileURLWithPath: vPath),
|
||||
inExclusions: inExclusions
|
||||
))
|
||||
}
|
||||
}
|
||||
return vResult
|
||||
}
|
||||
|
||||
// periodic save loop (background thread): checks every 3 s and saves
|
||||
// at most every 15 s, so new files reach the GUI within ~15 s.
|
||||
// Every N saves we also compact the in-memory containers so Swift's
|
||||
// Array/Dict capacity (which only grows on churn, never auto-shrinks)
|
||||
// doesn't drift into multi-GB territory after a day of heavy file
|
||||
@@ -176,9 +202,16 @@ enum AllofitService {
|
||||
private static func startAutosaveLoop(inState: State) {
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let kCompactEvery = 20
|
||||
// minimum gap between two saves: a save writes the whole index
|
||||
// (~25 MB for 600k entries) and makes the app reload all of it,
|
||||
// so under constant file activity every 3 s was ~8 MB/s of disk
|
||||
// writes plus over a second of CPU per cycle across both processes
|
||||
let kMinSaveIntervalSeconds: TimeInterval = 15
|
||||
var vSavesSinceCompact = 0
|
||||
var vLastSaveAt = Date.distantPast
|
||||
while true {
|
||||
sleep(3)
|
||||
if Date().timeIntervalSince(vLastSaveAt) < kMinSaveIntervalSeconds { continue }
|
||||
autoreleasepool {
|
||||
inState.lock.lock()
|
||||
let vShouldSave = inState.dirty
|
||||
@@ -190,6 +223,7 @@ enum AllofitService {
|
||||
NSLog("[Allofit] autosaving %d records (RSS %.1f MB)",
|
||||
vSnapshot.count, processFootprintMB())
|
||||
IndexStore.save(inRecords: vSnapshot, inLastEventId: vEventId)
|
||||
vLastSaveAt = Date()
|
||||
vSavesSinceCompact += 1
|
||||
if vSavesSinceCompact >= kCompactEvery {
|
||||
vSavesSinceCompact = 0
|
||||
|
||||
@@ -89,6 +89,7 @@ enum CLI {
|
||||
}
|
||||
}
|
||||
|
||||
// starts the installed service(s) named by the arguments
|
||||
private static func runStart(inArgs: [String]) {
|
||||
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled)
|
||||
guard !vScopes.isEmpty else {
|
||||
@@ -105,6 +106,7 @@ enum CLI {
|
||||
}
|
||||
}
|
||||
|
||||
// stops the running service(s) named by the arguments
|
||||
private static func runStop(inArgs: [String]) {
|
||||
let vScopes = scopesToActOn(inArgs: inArgs, defaultMode: .firstInstalled)
|
||||
guard !vScopes.isEmpty else {
|
||||
@@ -148,12 +150,14 @@ enum CLI {
|
||||
print("Service mode set to \(vMode.rawValue).")
|
||||
}
|
||||
|
||||
// stores the service mode in one preferences domain
|
||||
private static func writeMode(_ inMode: Preferences.ServiceMode, toDomain inDomain: String) {
|
||||
guard let vDefaults = UserDefaults(suiteName: inDomain) else { return }
|
||||
vDefaults.set(inMode.rawValue, forKey: kServiceModeKey)
|
||||
vDefaults.synchronize()
|
||||
}
|
||||
|
||||
// prints the install / run state of both service scopes
|
||||
private static func runStatus() {
|
||||
print("Bundle version: \(ServiceInstaller.bundleVersion())")
|
||||
for vScope in [ServiceInstaller.Scope.userAgent, .rootDaemon] {
|
||||
@@ -180,6 +184,7 @@ enum CLI {
|
||||
private static let kProcessName = "Allofit"
|
||||
private static let kServiceModeKey = "Allofit.serviceMode"
|
||||
|
||||
// which scopes a command targets when none is given
|
||||
private enum ScopeResolution {
|
||||
case firstInstalled
|
||||
case everyInstalled
|
||||
@@ -189,13 +194,13 @@ enum CLI {
|
||||
// - explicit `user`/`root` arg: just that one
|
||||
// - no arg, firstInstalled mode: the single installed scope, if any
|
||||
// - no arg, everyInstalled mode: all installed scopes
|
||||
private static func scopesToActOn(inArgs: [String], defaultMode: ScopeResolution) -> [ServiceInstaller.Scope] {
|
||||
private static func scopesToActOn(inArgs: [String], defaultMode inDefaultMode: ScopeResolution) -> [ServiceInstaller.Scope] {
|
||||
if let vScope = parseScope(inArgs.first) {
|
||||
return [vScope]
|
||||
}
|
||||
let vAll: [ServiceInstaller.Scope] = [.userAgent, .rootDaemon]
|
||||
let vInstalled = vAll.filter { ServiceInstaller.isInstalled(inScope: $0) }
|
||||
switch defaultMode {
|
||||
switch inDefaultMode {
|
||||
case .firstInstalled:
|
||||
return vInstalled.first.map { [$0] } ?? []
|
||||
case .everyInstalled:
|
||||
@@ -203,6 +208,7 @@ enum CLI {
|
||||
}
|
||||
}
|
||||
|
||||
// maps a user / root argument (and aliases) to a scope
|
||||
private static func parseScope(_ inArg: String?) -> ServiceInstaller.Scope? {
|
||||
guard let vArg = inArg else { return nil }
|
||||
switch vArg {
|
||||
@@ -215,6 +221,7 @@ enum CLI {
|
||||
}
|
||||
}
|
||||
|
||||
// human-readable name of a scope
|
||||
private static func label(for inScope: ServiceInstaller.Scope) -> String {
|
||||
switch inScope {
|
||||
case .userAgent: return "user agent"
|
||||
@@ -228,6 +235,7 @@ enum CLI {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// prints the command-line usage
|
||||
private static func printHelp() {
|
||||
print("""
|
||||
Usage: allofit [command [args]]
|
||||
|
||||
@@ -54,6 +54,9 @@ enum ElevatedAccess {
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700]
|
||||
)
|
||||
// creation attributes don't apply to an existing folder: enforce
|
||||
// owner-only access every time (the copies may be other users' files)
|
||||
chmod(vDir.path, 0o700)
|
||||
|
||||
// unique destination file, keeping the original extension so the
|
||||
// QLPreviewView / Launch Services can pick the right renderer
|
||||
@@ -63,12 +66,16 @@ enum ElevatedAccess {
|
||||
vDst.appendPathExtension(vExt)
|
||||
}
|
||||
|
||||
// cp + chown to the current user. The chmod restores plain user
|
||||
// rw / group+other r so the file is treated normally by QL etc.
|
||||
// Root only *reads* the original; the copy is written by the user
|
||||
// (sudo -u ... tee), so root never creates, chowns or chmods a path
|
||||
// inside a user-writable folder. A cp + chown + chmod as root could
|
||||
// be redirected through a swapped-in symlink / hard link to take
|
||||
// ownership of any system file. pipefail makes a failed read fail
|
||||
// the script instead of leaving an empty copy behind.
|
||||
let vScript = """
|
||||
cp \(AdminShell.quote(inUrl.path)) \(AdminShell.quote(vDst.path)) && \
|
||||
chown \(AdminShell.quote(NSUserName())) \(AdminShell.quote(vDst.path)) && \
|
||||
chmod 0644 \(AdminShell.quote(vDst.path))
|
||||
set -o pipefail; \
|
||||
/bin/cat \(AdminShell.quote(inUrl.path)) \
|
||||
| /usr/bin/sudo -u \(AdminShell.quote(NSUserName())) /usr/bin/tee \(AdminShell.quote(vDst.path)) > /dev/null
|
||||
"""
|
||||
_ = try AdminShell.run(vScript)
|
||||
return vDst
|
||||
|
||||
@@ -67,6 +67,10 @@ enum ServiceInstaller {
|
||||
at: vTarget.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try? FileManager.default.createDirectory(
|
||||
atPath: (logPath(inScope: .userAgent) as NSString).deletingLastPathComponent,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try vPlistData.write(to: vTarget, options: .atomic)
|
||||
_ = runLaunchctl(inArgs: ["unload", vTarget.path])
|
||||
let vResult = runLaunchctl(inArgs: ["load", "-w", vTarget.path])
|
||||
@@ -78,24 +82,30 @@ enum ServiceInstaller {
|
||||
// root daemon install: binary copy + plist install + boot
|
||||
// happen inside a single admin-priv script so the user
|
||||
// gets ONE password prompt covering all of it
|
||||
let vTmp = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("\(kLabel).plist")
|
||||
try vPlistData.write(to: vTmp, options: .atomic)
|
||||
// The plist travels inside the script as base64 (only
|
||||
// [A-Za-z0-9+/=], safe in single quotes) instead of a temp
|
||||
// file a user process could swap before root copies it in.
|
||||
let vPlistBase64 = vPlistData.base64EncodedString()
|
||||
let vTarget = rootDaemonPath()
|
||||
let vDaemonDir = (vDaemonBinary as NSString).deletingLastPathComponent
|
||||
let vLogDir = (logPath(inScope: .rootDaemon) as NSString).deletingLastPathComponent
|
||||
let vScript = """
|
||||
mkdir -p \(shellQuote(inString: vDaemonDir)) \
|
||||
&& rm -f \(shellQuote(inString: vDaemonBinary)) \
|
||||
&& cp \(shellQuote(inString: vBinary)) \(shellQuote(inString: vDaemonBinary)) \
|
||||
&& chown root:wheel \(shellQuote(inString: vDaemonBinary)) \
|
||||
&& chmod 755 \(shellQuote(inString: vDaemonBinary)) \
|
||||
&& printf '%s' \(shellQuote(inString: vVersionString)) > \(shellQuote(inString: vVersionFile)) \
|
||||
&& chmod 644 \(shellQuote(inString: vVersionFile)) \
|
||||
&& cp \(shellQuote(inString: vTmp.path)) \(shellQuote(inString: vTarget.path)) \
|
||||
&& chown root:wheel \(shellQuote(inString: vTarget.path)) \
|
||||
&& chmod 644 \(shellQuote(inString: vTarget.path)) \
|
||||
; /bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
|
||||
; /bin/launchctl bootstrap system \(shellQuote(inString: vTarget.path))
|
||||
mkdir -p \(AdminShell.quote(vDaemonDir)) \
|
||||
&& rm -f \(AdminShell.quote(vDaemonBinary)) \
|
||||
&& cp \(AdminShell.quote(vBinary)) \(AdminShell.quote(vDaemonBinary)) \
|
||||
&& chown root:wheel \(AdminShell.quote(vDaemonBinary)) \
|
||||
&& chmod 755 \(AdminShell.quote(vDaemonBinary)) \
|
||||
&& printf '%s' \(AdminShell.quote(vVersionString)) > \(AdminShell.quote(vVersionFile)) \
|
||||
&& chmod 644 \(AdminShell.quote(vVersionFile)) \
|
||||
&& mkdir -p \(AdminShell.quote(vLogDir)) \
|
||||
&& chown root:wheel \(AdminShell.quote(vLogDir)) \
|
||||
&& chmod 755 \(AdminShell.quote(vLogDir)) \
|
||||
&& printf '%s' \(AdminShell.quote(vPlistBase64)) | /usr/bin/base64 -D > \(AdminShell.quote(vTarget.path)) \
|
||||
&& /usr/bin/plutil -lint -s \(AdminShell.quote(vTarget.path)) \
|
||||
&& chown root:wheel \(AdminShell.quote(vTarget.path)) \
|
||||
&& chmod 644 \(AdminShell.quote(vTarget.path)) \
|
||||
; /bin/launchctl bootout system \(AdminShell.quote(vTarget.path)) 2>/dev/null \
|
||||
; /bin/launchctl bootstrap system \(AdminShell.quote(vTarget.path))
|
||||
"""
|
||||
try runWithAdminPrivileges(inScript: vScript)
|
||||
}
|
||||
@@ -117,8 +127,8 @@ enum ServiceInstaller {
|
||||
case .rootDaemon:
|
||||
let vTarget = rootDaemonPath()
|
||||
let vScript = """
|
||||
/bin/launchctl bootout system \(shellQuote(inString: vTarget.path)) 2>/dev/null \
|
||||
; rm -f \(shellQuote(inString: vTarget.path)) \(shellQuote(inString: vDaemonBinary)) \(shellQuote(inString: vVersionFile))
|
||||
/bin/launchctl bootout system \(AdminShell.quote(vTarget.path)) 2>/dev/null \
|
||||
; rm -f \(AdminShell.quote(vTarget.path)) \(AdminShell.quote(vDaemonBinary)) \(AdminShell.quote(vVersionFile))
|
||||
"""
|
||||
try runWithAdminPrivileges(inScript: vScript)
|
||||
}
|
||||
@@ -146,7 +156,7 @@ enum ServiceInstaller {
|
||||
}
|
||||
case .rootDaemon:
|
||||
let vPlist = rootDaemonPath().path
|
||||
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vPlist))"
|
||||
let vScript = "/bin/launchctl bootout system \(AdminShell.quote(vPlist))"
|
||||
try runWithAdminPrivileges(inScript: vScript)
|
||||
}
|
||||
}
|
||||
@@ -162,7 +172,7 @@ enum ServiceInstaller {
|
||||
}
|
||||
case .rootDaemon:
|
||||
let vPlist = rootDaemonPath().path
|
||||
let vScript = "/bin/launchctl bootstrap system \(shellQuote(inString: vPlist))"
|
||||
let vScript = "/bin/launchctl bootstrap system \(AdminShell.quote(vPlist))"
|
||||
try runWithAdminPrivileges(inScript: vScript)
|
||||
}
|
||||
}
|
||||
@@ -217,7 +227,7 @@ enum ServiceInstaller {
|
||||
case .rootDaemon:
|
||||
let vPlist = rootDaemonPath().path
|
||||
let vCache = inCacheURL.path
|
||||
let vScript = "/bin/launchctl bootout system \(shellQuote(inString: vPlist)) 2>/dev/null ; rm -f \(shellQuote(inString: vCache)) ; /bin/launchctl bootstrap system \(shellQuote(inString: vPlist))"
|
||||
let vScript = "/bin/launchctl bootout system \(AdminShell.quote(vPlist)) 2>/dev/null ; rm -f \(AdminShell.quote(vCache)) ; /bin/launchctl bootstrap system \(AdminShell.quote(vPlist))"
|
||||
try runWithAdminPrivileges(inScript: vScript)
|
||||
}
|
||||
}
|
||||
@@ -226,11 +236,13 @@ enum ServiceInstaller {
|
||||
// MARK: Internals
|
||||
// ===========================
|
||||
|
||||
// location of the per-user LaunchAgent plist
|
||||
private static func userAgentPath() -> URL {
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(kLabel).plist")
|
||||
}
|
||||
|
||||
// location of the system-wide LaunchDaemon plist
|
||||
private static func rootDaemonPath() -> URL {
|
||||
return URL(fileURLWithPath: "/Library/LaunchDaemons/\(kLabel).plist")
|
||||
}
|
||||
@@ -250,15 +262,32 @@ enum ServiceInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
// where the service's stdout / stderr go: a root-owned folder for the
|
||||
// daemon, the user's own Logs folder for the agent
|
||||
static func logPath(inScope: Scope) -> String {
|
||||
switch inScope {
|
||||
case .userAgent:
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Logs/Allofit/service.log").path
|
||||
case .rootDaemon:
|
||||
return "/Library/Logs/Allofit/service.log"
|
||||
}
|
||||
}
|
||||
|
||||
// resolves an absolute path to the currently running binary
|
||||
private static func resolveBinaryPath() throws -> String {
|
||||
let vArg0 = CommandLine.arguments[0]
|
||||
let vAbs = (vArg0 as NSString).standardizingPath
|
||||
if FileManager.default.fileExists(atPath: vAbs) { return vAbs }
|
||||
if let vUrl = Bundle.main.executableURL,
|
||||
// the loaded executable, not argv[0] (which a caller can set to
|
||||
// anything, including a relative path)
|
||||
if let vUrl = Bundle.main.executableURL?.resolvingSymlinksInPath(),
|
||||
vUrl.path.hasPrefix("/"),
|
||||
FileManager.default.fileExists(atPath: vUrl.path) {
|
||||
return vUrl.path
|
||||
}
|
||||
let vArg0 = CommandLine.arguments[0]
|
||||
let vAbs = (vArg0 as NSString).standardizingPath
|
||||
if vAbs.hasPrefix("/") && FileManager.default.fileExists(atPath: vAbs) {
|
||||
return vAbs
|
||||
}
|
||||
throw InstallError.binaryNotFound
|
||||
}
|
||||
|
||||
@@ -269,8 +298,10 @@ enum ServiceInstaller {
|
||||
"ProgramArguments": [inBinary, "--service"],
|
||||
"RunAtLoad": true,
|
||||
"KeepAlive": true,
|
||||
"StandardOutPath": "/tmp/allofit-service.log",
|
||||
"StandardErrorPath": "/tmp/allofit-service.err",
|
||||
// never /tmp: a fixed name there can be pre-created as a symlink
|
||||
// by any local user, and launchd opens it as root for the daemon
|
||||
"StandardOutPath": logPath(inScope: inScope),
|
||||
"StandardErrorPath": logPath(inScope: inScope),
|
||||
"ThrottleInterval": 10
|
||||
]
|
||||
if inScope == .rootDaemon {
|
||||
@@ -323,10 +354,4 @@ enum ServiceInstaller {
|
||||
throw InstallError.authorizationFailed(vErr.errorDescription ?? "\(vErr)")
|
||||
}
|
||||
}
|
||||
|
||||
// shell-quote helper, delegating to the shared AdminShell quoter so
|
||||
// both call sites use the same escaping rules
|
||||
private static func shellQuote(inString: String) -> String {
|
||||
return AdminShell.quote(inString)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +68,4 @@ final class AccessManager: ObservableObject {
|
||||
}
|
||||
authorizingIds.remove(vId)
|
||||
}
|
||||
|
||||
// wipes the in-memory mapping. Called after ElevatedAccess.cleanup()
|
||||
// removes the on-disk files so the two stay consistent.
|
||||
func reset() {
|
||||
stagedURLs.removeAll()
|
||||
authorizingIds.removeAll()
|
||||
lastError = nil
|
||||
}
|
||||
}
|
||||
|
||||
+198
-25
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import Combine
|
||||
|
||||
// AllofitApp is the SwiftUI App for the GUI mode. The real entry point lives
|
||||
// in Main.swift, which routes between this and the headless --service mode.
|
||||
@@ -30,10 +31,12 @@ struct AllofitApp: App {
|
||||
Button("About Allofit") { showAboutPanel() }
|
||||
}
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Reindex All") {
|
||||
// ⇧⌘R rather than ⌘R: a full rebuild is too costly to trigger
|
||||
// by accident
|
||||
Button("Rebuild Index") {
|
||||
Task { await model.performReindex() }
|
||||
}
|
||||
.keyboardShortcut("r", modifiers: [.command])
|
||||
.keyboardShortcut("r", modifiers: [.command, .shift])
|
||||
}
|
||||
// SwiftUI provides File > New Window (⌘N) automatically for a
|
||||
// WindowGroup; nothing to add here. Additional windows share the
|
||||
@@ -43,6 +46,13 @@ struct AllofitApp: App {
|
||||
// ⌘F focuses the search field. Standard Find-style shortcut.
|
||||
// SearchField's Coordinator observes the notification and calls
|
||||
// makeFirstResponder on its underlying NSSearchField.
|
||||
// Help: the syntax cheat sheet and the project page
|
||||
CommandGroup(replacing: .help) {
|
||||
Button("Search Syntax") { showSyntaxWindow() }
|
||||
Button("Allofit on GitHub") {
|
||||
NSWorkspace.shared.open(URL(string: "https://github.com/bitsycore/Allofit#search")!)
|
||||
}
|
||||
}
|
||||
CommandGroup(after: .pasteboard) {
|
||||
Button("Find") {
|
||||
NotificationCenter.default.post(name: .allofitFocusSearch, object: nil)
|
||||
@@ -73,6 +83,7 @@ struct AllofitWindowContent: View {
|
||||
let access: AccessManager
|
||||
@StateObject private var searchModel: WindowSearchModel
|
||||
|
||||
// creates the per-window content around the shared models
|
||||
init(model inModel: AppModel, access inAccess: AccessManager) {
|
||||
self.model = inModel
|
||||
self.access = inAccess
|
||||
@@ -89,6 +100,9 @@ struct AllofitWindowContent: View {
|
||||
.environmentObject(searchModel)
|
||||
.frame(minWidth: 760, minHeight: 480)
|
||||
.background(MainWindowMarker())
|
||||
.background(WindowVisibilityReporter { vVisible in
|
||||
searchModel.setWindowVisible(vVisible)
|
||||
})
|
||||
.onAppear {
|
||||
model.start()
|
||||
}
|
||||
@@ -109,7 +123,9 @@ struct AllofitWindowContent: View {
|
||||
// DispatchQueue.main.async sometimes ran before SwiftUI attached the view.
|
||||
struct MainWindowMarker: NSViewRepresentable {
|
||||
|
||||
// NSView that records its window as the main window
|
||||
final class MarkerView: NSView {
|
||||
// captures the window once the view is attached to it
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
guard let vWindow = self.window else { return }
|
||||
@@ -122,19 +138,114 @@ struct MainWindowMarker: NSViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
// creates the marker view
|
||||
func makeNSView(context inContext: Context) -> NSView {
|
||||
return MarkerView(frame: .zero)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
// rebind if SwiftUI ever swaps us into a different window
|
||||
if let vWindow = nsView.window, AppDelegate.mainWindow !== vWindow {
|
||||
// rebinds if SwiftUI ever swaps us into a different window
|
||||
func updateNSView(_ inView: NSView, context inContext: Context) {
|
||||
if let vWindow = inView.window, AppDelegate.mainWindow !== vWindow {
|
||||
vWindow.isReleasedWhenClosed = false
|
||||
AppDelegate.mainWindow = vWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WindowVisibilityReporter tells its owner whether the hosting window is
|
||||
// actually on screen (AppKit occlusion state), so a closed, minimized or
|
||||
// fully covered window can skip work nobody would see.
|
||||
struct WindowVisibilityReporter: NSViewRepresentable {
|
||||
|
||||
// called on main with true when the window becomes visible
|
||||
let onChange: (Bool) -> Void
|
||||
|
||||
// NSView that follows its window's occlusion notifications
|
||||
final class ReporterView: NSView {
|
||||
|
||||
// latest callback from the SwiftUI side
|
||||
var onChange: ((Bool) -> Void)?
|
||||
// occlusion observer for the current window
|
||||
private var observer: NSObjectProtocol?
|
||||
|
||||
// re-targets the observer whenever the view changes window
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
if let vObserver = observer {
|
||||
NotificationCenter.default.removeObserver(vObserver)
|
||||
observer = nil
|
||||
}
|
||||
guard let vWindow = window else {
|
||||
report(false)
|
||||
return
|
||||
}
|
||||
observer = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.didChangeOcclusionStateNotification,
|
||||
object: vWindow,
|
||||
queue: .main
|
||||
) { [weak self, weak vWindow] _ in
|
||||
guard let vWin = vWindow else { return }
|
||||
MainActor.assumeIsolated {
|
||||
self?.report(vWin.occlusionState.contains(.visible))
|
||||
}
|
||||
}
|
||||
report(vWindow.occlusionState.contains(.visible))
|
||||
}
|
||||
|
||||
// forwards a visibility change to the owner and the app-wide tracker
|
||||
private func report(_ inVisible: Bool) {
|
||||
onChange?(inVisible)
|
||||
AppActivity.shared.setWindow(ObjectIdentifier(self), visible: inVisible)
|
||||
}
|
||||
|
||||
// removes the occlusion observer; a destroyed window no longer
|
||||
// counts as visible
|
||||
deinit {
|
||||
if let vObserver = observer {
|
||||
NotificationCenter.default.removeObserver(vObserver)
|
||||
}
|
||||
let vId = ObjectIdentifier(self)
|
||||
DispatchQueue.main.async {
|
||||
AppActivity.shared.setWindow(vId, visible: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// creates the reporter view
|
||||
func makeNSView(context inContext: Context) -> NSView {
|
||||
let vView = ReporterView(frame: .zero)
|
||||
vView.onChange = onChange
|
||||
return vView
|
||||
}
|
||||
|
||||
// keeps the view's callback current
|
||||
func updateNSView(_ inView: NSView, context inContext: Context) {
|
||||
(inView as? ReporterView)?.onChange = onChange
|
||||
}
|
||||
}
|
||||
|
||||
// the Help > Search Syntax window (kept so a second click just brings it back)
|
||||
@MainActor private var syntaxWindow: NSWindow?
|
||||
|
||||
// shows the search syntax cheat sheet in a small utility window
|
||||
@MainActor private func showSyntaxWindow() {
|
||||
if syntaxWindow == nil {
|
||||
let vWindow = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 560, height: 420),
|
||||
styleMask: [.titled, .closable, .utilityWindow],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
vWindow.title = "Allofit Search Syntax"
|
||||
vWindow.isReleasedWhenClosed = false
|
||||
vWindow.contentView = NSHostingView(rootView: SyntaxHelpView())
|
||||
vWindow.setContentSize(vWindow.contentView?.fittingSize ?? NSSize(width: 560, height: 420))
|
||||
vWindow.center()
|
||||
syntaxWindow = vWindow
|
||||
}
|
||||
syntaxWindow?.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
// Opens the standard macOS About panel with a clickable GitHub link in the
|
||||
// Credits section. App name + version come from Info.plist automatically.
|
||||
private func showAboutPanel() {
|
||||
@@ -167,22 +278,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// nonisolated(unsafe) because all reads/writes happen on the main thread
|
||||
// (NSView callbacks + the AppKit delegate methods are all @MainActor).
|
||||
nonisolated(unsafe) static var mainWindow: NSWindow?
|
||||
// keeps the global-shortcut setting subscription alive
|
||||
private var hotKeySubscription: AnyCancellable?
|
||||
// keeps the menu-bar-icon setting subscription alive
|
||||
private var menuBarSubscription: AnyCancellable?
|
||||
// the menu bar icon, nil while hidden
|
||||
private var statusItem: NSStatusItem?
|
||||
|
||||
// called once when the application has finished launching
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
func applicationDidFinishLaunching(_ inNotification: Notification) {
|
||||
// run as a regular foreground app even when launched outside a .app
|
||||
// bundle - covers the SwiftPM "swift run" case
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
// shrink the AppKit help-tag (.help() / NSView.toolTip) hover delay.
|
||||
// The system default is ~2 s; that makes truncated Name/Path cells
|
||||
// feel unreadable. Registered as a default so a user-set value in
|
||||
// the global domain still wins. Seconds.
|
||||
UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 0.3])
|
||||
// shrink the AppKit help-tag (.help() / NSView.toolTip) hover delay
|
||||
// from the ~1-2 s system default. Registered as a default so a
|
||||
// user-set value in the global domain still wins. The value is in
|
||||
// milliseconds (it used to be registered as 0.3, read as 0 ms).
|
||||
UserDefaults.standard.register(defaults: ["NSInitialToolTipDelay": 250])
|
||||
// wipe any elevated-access staging files left over from a previous
|
||||
// run so a crash or hard-kill doesn't accumulate privileged copies
|
||||
// in ~/Library/Caches across sessions
|
||||
ElevatedAccess.cleanup()
|
||||
// Finder > Services > "Search in Allofit"
|
||||
NSApp.servicesProvider = ServicesProvider()
|
||||
NSUpdateDynamicServices()
|
||||
// system-wide shortcut, re-registered whenever the setting changes
|
||||
GlobalHotKey.shared.onPressed = { WindowPresenter.toggle() }
|
||||
hotKeySubscription = Preferences.shared.$globalHotKey
|
||||
.removeDuplicates()
|
||||
.sink { vPreset in
|
||||
MainActor.assumeIsolated {
|
||||
_ = GlobalHotKey.shared.register(inPreset: vPreset)
|
||||
}
|
||||
}
|
||||
// menu bar icon (Everything's tray icon), shown per the setting.
|
||||
// Plain AppKit: a SwiftUI MenuBarExtra scene crashed the app at
|
||||
// launch (runtime recursion resolving the App's scene type).
|
||||
menuBarSubscription = Preferences.shared.$showMenuBarIcon
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] vShow in
|
||||
MainActor.assumeIsolated {
|
||||
self?.setStatusItemVisible(vShow)
|
||||
}
|
||||
}
|
||||
// bring the main window to the front so it accepts keystrokes
|
||||
DispatchQueue.main.async {
|
||||
for vWindow in NSApp.windows where vWindow.canBecomeKey {
|
||||
@@ -195,14 +334,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
// called on clean Cmd+Q quit; wipes the elevated-access staging dir
|
||||
// so the user-readable copies of privileged files don't linger
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
func applicationWillTerminate(_ inNotification: Notification) {
|
||||
ElevatedAccess.cleanup()
|
||||
}
|
||||
|
||||
// keep the process alive when the user closes the last window: the index
|
||||
// stays in RAM and clicking the dock icon snaps a new window up instantly.
|
||||
// Cmd+Q still quits via the standard Quit menu item.
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ inSender: NSApplication) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -211,7 +350,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// front first. The action defers to whatever the File > New Window menu
|
||||
// item does (SwiftUI auto-generates that item for WindowGroup) so we stay
|
||||
// compatible with whichever underlying selector SwiftUI uses.
|
||||
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
|
||||
func applicationDockMenu(_ inSender: NSApplication) -> NSMenu? {
|
||||
let vMenu = NSMenu()
|
||||
let vItem = NSMenuItem(title: "New Window",
|
||||
action: #selector(newWindowFromDock(_:)),
|
||||
@@ -224,25 +363,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
// finds the ⌘N main-menu item (File > New Window) and re-invokes its
|
||||
// action. We match on the keyboard shortcut rather than the title so the
|
||||
// lookup survives localized menus.
|
||||
@objc func newWindowFromDock(_ sender: Any?) {
|
||||
guard let vMain = NSApp.mainMenu else { return }
|
||||
for vTop in vMain.items {
|
||||
guard let vSub = vTop.submenu else { continue }
|
||||
for vItem in vSub.items {
|
||||
if vItem.keyEquivalent == "n",
|
||||
vItem.keyEquivalentModifierMask == [.command],
|
||||
let vAction = vItem.action {
|
||||
NSApp.sendAction(vAction, to: vItem.target, from: nil)
|
||||
@objc func newWindowFromDock(_ inSender: Any?) {
|
||||
MainActor.assumeIsolated {
|
||||
WindowPresenter.openNewWindow()
|
||||
}
|
||||
}
|
||||
|
||||
// adds or removes the menu bar icon and its menu
|
||||
private func setStatusItemVisible(_ inVisible: Bool) {
|
||||
guard inVisible else {
|
||||
if let vItem = statusItem {
|
||||
NSStatusBar.system.removeStatusItem(vItem)
|
||||
statusItem = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
guard statusItem == nil else { return }
|
||||
let vItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
vItem.button?.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Allofit")
|
||||
let vMenu = NSMenu()
|
||||
vMenu.addItem(withTitle: "Show Allofit", action: #selector(statusShow(_:)), keyEquivalent: "").target = self
|
||||
vMenu.addItem(.separator())
|
||||
vMenu.addItem(withTitle: "Settings…", action: #selector(statusSettings(_:)), keyEquivalent: "").target = self
|
||||
vMenu.addItem(.separator())
|
||||
vMenu.addItem(withTitle: "Quit Allofit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "")
|
||||
vItem.menu = vMenu
|
||||
statusItem = vItem
|
||||
}
|
||||
|
||||
// menu bar > Show Allofit
|
||||
@objc private func statusShow(_ inSender: Any?) {
|
||||
MainActor.assumeIsolated {
|
||||
WindowPresenter.show()
|
||||
}
|
||||
}
|
||||
|
||||
// menu bar > Settings…
|
||||
@objc private func statusSettings(_ inSender: Any?) {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
||||
}
|
||||
|
||||
// folders dropped on the Dock icon or opened with the app
|
||||
// (open -a Allofit <folder>) start a search inside that folder
|
||||
func application(_ inApplication: NSApplication, open inUrls: [URL]) {
|
||||
guard let vFirst = inUrls.first else { return }
|
||||
let vIsDir = (try? vFirst.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||
WindowPresenter.search(inFolder: vIsDir ? vFirst.path : vFirst.deletingLastPathComponent().path)
|
||||
}
|
||||
|
||||
// dock-icon click while no windows are visible: re-show only the main
|
||||
// window and return false so AppKit doesn't run its default "unhide every
|
||||
// hidden window" action (which would also resurrect the Settings window).
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication,
|
||||
func applicationShouldHandleReopen(_ inSender: NSApplication,
|
||||
hasVisibleWindows inHasVisible: Bool) -> Bool {
|
||||
if inHasVisible { return true }
|
||||
if let vMain = AppDelegate.mainWindow {
|
||||
|
||||
@@ -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
@@ -33,9 +33,32 @@ private final class PendingChanges: @unchecked Sendable {
|
||||
private var changes: [FSChange] = []
|
||||
// true while a flush is scheduled but has not drained yet
|
||||
private var flushScheduled = false
|
||||
// guards changes and flushScheduled
|
||||
// how long changes are collected before a flush (follows AppActivity)
|
||||
private var delaySeconds: TimeInterval = 2
|
||||
// guards every field above
|
||||
private let lock = NSLock()
|
||||
|
||||
// current collection delay before a flush
|
||||
var coalesceDelay: TimeInterval {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return delaySeconds
|
||||
}
|
||||
|
||||
// changes the collection delay used for the next flushes
|
||||
func setCoalesceDelay(_ inSeconds: TimeInterval) {
|
||||
lock.lock()
|
||||
delaySeconds = inSeconds
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
// true when changes are waiting to be flushed
|
||||
var hasPending: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return !changes.isEmpty
|
||||
}
|
||||
|
||||
// appends a batch; returns true if the caller must schedule a flush
|
||||
func append(inChanges: [FSChange]) -> Bool {
|
||||
lock.lock()
|
||||
@@ -86,9 +109,12 @@ final class AppModel: ObservableObject {
|
||||
// the canonical index (records + id lookup), main-thread only
|
||||
private var index = IndexState()
|
||||
// every entry observed so far, in no particular order
|
||||
var allRecords: [FileRecord] { index.records }
|
||||
var allRecords: RecordStore { index.records }
|
||||
// fires on main after allRecords changed (no payload by design)
|
||||
let recordsChanged = PassthroughSubject<Void, Never>()
|
||||
// when the index last changed; read by the status bar on its own
|
||||
// refresh tick, so deliberately not @Published
|
||||
private(set) var lastIndexChangeAt: Date?
|
||||
// total number of entries indexed (used for the status bar)
|
||||
@Published private(set) var indexedCount: Int = 0
|
||||
// true while a full reindex is in progress
|
||||
@@ -100,8 +126,6 @@ final class AppModel: ObservableObject {
|
||||
|
||||
// how often the index is written to disk while running (seconds)
|
||||
private let kAutosaveSeconds: TimeInterval = 30
|
||||
// delay used to coalesce FSEvents batches before resolving them
|
||||
private let kChangeCoalesceSeconds: TimeInterval = 0.5
|
||||
// delay before a roots / exclusions edit is applied to the live index
|
||||
private let kConfigDebounceSeconds: TimeInterval = 1.0
|
||||
|
||||
@@ -117,6 +141,18 @@ final class AppModel: ObservableObject {
|
||||
private let pendingChanges = PendingChanges()
|
||||
// roots the watcher was started with; their own records need no parent
|
||||
private var watchedRoots: Set<String> = []
|
||||
// exclusions the running watcher resolves changes with
|
||||
private var currentExclusions: ExclusionMatcher?
|
||||
// subtree walks started but not merged yet
|
||||
private var rescansInFlight = 0
|
||||
// event id to save while rescans are in flight: the id from before the
|
||||
// changes that triggered them, so a quit or crash mid-walk replays those
|
||||
// changes instead of losing the folders' contents for good
|
||||
private var rescanFloorEventId: UInt64?
|
||||
// a reader-mode reload skipped (throttled or no window visible)
|
||||
private var pendingReaderReload = false
|
||||
// service mode the current role was started with (see activeServiceMode)
|
||||
private(set) var bootMode: Preferences.ServiceMode = .none
|
||||
// bumped whenever the whole index is replaced, so a subtree rescan that
|
||||
// started against the previous index doesn't merge into the new one
|
||||
private var indexGeneration = 0
|
||||
@@ -150,6 +186,7 @@ final class AppModel: ObservableObject {
|
||||
// timestamp of the last reloadFromCache() that actually swapped data in
|
||||
private var lastReloadAt: Date?
|
||||
|
||||
// wires up the quit and settings observers (loading happens in start)
|
||||
init() {
|
||||
// the cache file is loaded off-main in start() so the window
|
||||
// appears instantly; here we only wire up observers.
|
||||
@@ -162,6 +199,26 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
// slow index updates down while the app isn't being looked at, and
|
||||
// catch up at once when a window comes back
|
||||
AppActivity.shared.$level
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] vLevel in
|
||||
self?.activityChanged(inLevel: vLevel)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
// the delays are user settings: apply edits right away
|
||||
Publishers.Merge3(
|
||||
prefs.$updateDelayForeground,
|
||||
prefs.$updateDelayBackground,
|
||||
prefs.$updateDelayHidden
|
||||
)
|
||||
.dropFirst(3)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.activityChanged(inLevel: AppActivity.shared.level)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
// apply roots / exclusions / volume edits to the live index
|
||||
Publishers.Merge4(
|
||||
prefs.$rootPaths.map { _ in () },
|
||||
@@ -177,6 +234,18 @@ final class AppModel: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// the service mode actually in effect: the saved mode, but only if that
|
||||
// service is really installed. A mode saved without a working install
|
||||
// (or a service removed behind the app's back) falls back to indexing
|
||||
// in-process instead of waiting forever on a cache nobody writes.
|
||||
var activeServiceMode: Preferences.ServiceMode {
|
||||
switch prefs.serviceMode {
|
||||
case .none: return .none
|
||||
case .userAgent: return ServiceInstaller.isInstalled(inScope: .userAgent) ? .userAgent : .none
|
||||
case .rootDaemon: return ServiceInstaller.isInstalled(inScope: .rootDaemon) ? .rootDaemon : .none
|
||||
}
|
||||
}
|
||||
|
||||
// kicks off background activity for the first time. Subsequent calls are
|
||||
// no-ops so we don't double-bootstrap when the user reopens the window.
|
||||
// To force a re-bootstrap (e.g. after Install/Uninstall swaps the role),
|
||||
@@ -214,7 +283,12 @@ final class AppModel: ObservableObject {
|
||||
private func bootstrap() {
|
||||
tearDownActiveMode()
|
||||
|
||||
if prefs.serviceMode != .none {
|
||||
let vMode = activeServiceMode
|
||||
bootMode = vMode
|
||||
if vMode != prefs.serviceMode {
|
||||
NSLog("[Allofit GUI] service mode %@ saved but not installed, indexing in-process", prefs.serviceMode.rawValue)
|
||||
}
|
||||
if vMode != .none {
|
||||
isIndexer = false
|
||||
} else {
|
||||
let vLock = IndexerLock(path: IndexStore.lockURL(forSystem: false).path)
|
||||
@@ -228,12 +302,12 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
|
||||
isLoadingCache = true
|
||||
let vCacheURL = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
|
||||
let vCacheURL = IndexStore.cacheURL(forServiceMode: vMode)
|
||||
let vIsIndexer = isIndexer
|
||||
let vRoots = Set(VolumeManager.effectiveRoots(inPreferences: prefs).map { $0.path })
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
NSLog("[Allofit GUI] bootstrap: serviceMode=%@ isIndexer=%@ cacheURL=%@",
|
||||
prefs.serviceMode.rawValue,
|
||||
vMode.rawValue,
|
||||
vIsIndexer ? "true" : "false",
|
||||
vCacheURL.path)
|
||||
ioQueue.async { [weak self] in
|
||||
@@ -284,26 +358,44 @@ final class AppModel: ObservableObject {
|
||||
let vRoots = VolumeManager.effectiveRoots(inPreferences: prefs)
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
let vStartId = UInt64(FSEventsGetCurrentEventId())
|
||||
let vIoQueue = ioQueue
|
||||
// partial results only when there is nothing to show yet (first
|
||||
// run): a manual rebuild keeps the old index visible until the end
|
||||
let vPublishPartial = index.count == 0
|
||||
indexQueue.async { [weak self] in
|
||||
var vAccumulated: [FileRecord] = []
|
||||
// the results fill in while the walk runs (instead of an empty
|
||||
// window until the end of a scan that can take minutes)
|
||||
let kPartialPublishSeconds: TimeInterval = 3
|
||||
var vLastPublish = Date()
|
||||
for vRoot in vRoots {
|
||||
// per-root autoreleasepool so the file-enumeration's
|
||||
// autoreleased NSURL/NSDate/NSNumber objects don't pile
|
||||
// up across roots inside this long-running async block
|
||||
autoreleasepool {
|
||||
let vBaseline = vAccumulated.count
|
||||
let vList = FileIndexer.indexRoot(inRoot: vRoot, inExclusions: vMatcher) { vCount in
|
||||
FileIndexer.walkRoot(inRoot: vRoot, inExclusions: vMatcher) { vBatch in
|
||||
vAccumulated.append(contentsOf: vBatch)
|
||||
let vCount = vAccumulated.count
|
||||
let vNow = Date()
|
||||
guard vPublishPartial, vNow.timeIntervalSince(vLastPublish) >= kPartialPublishSeconds else {
|
||||
DispatchQueue.main.async { self?.indexedCount = vCount }
|
||||
return
|
||||
}
|
||||
vLastPublish = vNow
|
||||
let vPartial = IndexState(inRecords: vAccumulated)
|
||||
DispatchQueue.main.async {
|
||||
self?.indexedCount = vBaseline + vCount
|
||||
guard let vSelf = self, vSelf.isIndexing else { return }
|
||||
vSelf.replaceIndex(inState: vPartial)
|
||||
}
|
||||
}
|
||||
vAccumulated.append(contentsOf: vList)
|
||||
}
|
||||
}
|
||||
let vState = IndexState(inRecords: vAccumulated)
|
||||
// persist off-main before bouncing back so main never sees the
|
||||
// LZ4 compression cost
|
||||
// persist off-main (on the save queue, so it never races another
|
||||
// save) before bouncing back: main never sees the LZ4 cost
|
||||
vIoQueue.sync {
|
||||
IndexStore.save(inRecords: vState.records, inLastEventId: vStartId)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self?.applyFreshIndex(inState: vState, inEventId: vStartId)
|
||||
}
|
||||
@@ -332,7 +424,7 @@ final class AppModel: ObservableObject {
|
||||
// the daemon would re-write it on its next autosave, so we tunnel the
|
||||
// delete through the same stop/delete/start admin script.
|
||||
func performClearCache() async {
|
||||
switch prefs.serviceMode {
|
||||
switch activeServiceMode {
|
||||
case .none:
|
||||
IndexStore.clearCache(at: IndexStore.cacheURL(forServiceMode: .none))
|
||||
workMessage = "Cache cleared."
|
||||
@@ -347,11 +439,14 @@ final class AppModel: ObservableObject {
|
||||
// installs a launchd service for the current serviceMode preference,
|
||||
// off-main so the password dialog doesn't freeze the GUI. On success
|
||||
// the GUI hot-swaps into reader mode so the user doesn't need to relaunch.
|
||||
func performInstallService() async {
|
||||
let vOk = await runPrivilegedAction(inLabel: "Installing service") { vScope, _ in
|
||||
// inMode is the mode chosen in Settings: it is only saved once the
|
||||
// install succeeded, so a failed or cancelled install changes nothing
|
||||
func performInstallService(inMode: Preferences.ServiceMode) async {
|
||||
let vOk = await runPrivilegedAction(inLabel: "Installing service", inMode: inMode) { vScope, _ in
|
||||
try ServiceInstaller.install(inScope: vScope)
|
||||
}
|
||||
if vOk {
|
||||
prefs.serviceMode = inMode
|
||||
// give launchd a moment to bring the daemon up before we switch
|
||||
// the GUI into reader mode and start watching the cache file
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
@@ -362,8 +457,8 @@ final class AppModel: ObservableObject {
|
||||
// stops the running daemon for the current serviceMode preference
|
||||
// without uninstalling. Plist stays on disk so the next launch (or
|
||||
// performStartService) brings it back.
|
||||
func performStopService() async {
|
||||
await runPrivilegedAction(inLabel: "Stopping service") { vScope, _ in
|
||||
func performStopService(inMode: Preferences.ServiceMode? = nil) async {
|
||||
await runPrivilegedAction(inLabel: "Stopping service", inMode: inMode) { vScope, _ in
|
||||
try ServiceInstaller.stop(inScope: vScope)
|
||||
}
|
||||
}
|
||||
@@ -371,8 +466,8 @@ final class AppModel: ObservableObject {
|
||||
// starts a stopped-but-installed daemon for the current serviceMode
|
||||
// preference. Hot-swaps the GUI into reader mode on success so the
|
||||
// reader watcher picks up the daemon's first cache write.
|
||||
func performStartService() async {
|
||||
let vOk = await runPrivilegedAction(inLabel: "Starting service") { vScope, _ in
|
||||
func performStartService(inMode: Preferences.ServiceMode? = nil) async {
|
||||
let vOk = await runPrivilegedAction(inLabel: "Starting service", inMode: inMode) { vScope, _ in
|
||||
try ServiceInstaller.start(inScope: vScope)
|
||||
}
|
||||
if vOk {
|
||||
@@ -383,11 +478,16 @@ final class AppModel: ObservableObject {
|
||||
|
||||
// uninstalls the launchd service for the current serviceMode preference.
|
||||
// On success the GUI hot-swaps back into built-in indexer mode.
|
||||
func performUninstallService() async {
|
||||
let vOk = await runPrivilegedAction(inLabel: "Uninstalling service") { vScope, _ in
|
||||
func performUninstallService(inMode: Preferences.ServiceMode? = nil) async {
|
||||
let vMode = inMode ?? prefs.serviceMode
|
||||
let vOk = await runPrivilegedAction(inLabel: "Uninstalling service", inMode: vMode) { vScope, _ in
|
||||
try ServiceInstaller.uninstall(inScope: vScope)
|
||||
}
|
||||
if vOk {
|
||||
// back to the built-in indexer when the active service went away
|
||||
if vMode == prefs.serviceMode {
|
||||
prefs.serviceMode = .none
|
||||
}
|
||||
// give launchd a moment to actually tear the daemon down so its
|
||||
// indexer lock is released before the GUI tries to grab it
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
@@ -399,9 +499,11 @@ final class AppModel: ObservableObject {
|
||||
// publishing isWorking/workMessage so the ui can show progress. Returns
|
||||
// true if the action ran without throwing.
|
||||
@discardableResult
|
||||
// inMode defaults to the saved mode (the installed service)
|
||||
private func runPrivilegedAction(inLabel: String,
|
||||
inMode: Preferences.ServiceMode? = nil,
|
||||
inBody: @Sendable @escaping (ServiceInstaller.Scope, URL) throws -> Void) async -> Bool {
|
||||
let vMode = prefs.serviceMode
|
||||
let vMode = inMode ?? prefs.serviceMode
|
||||
guard vMode != .none else { return false }
|
||||
let vScope: ServiceInstaller.Scope = (vMode == .userAgent) ? .userAgent : .rootDaemon
|
||||
let vUrl = IndexStore.cacheURL(forServiceMode: vMode)
|
||||
@@ -427,13 +529,15 @@ final class AppModel: ObservableObject {
|
||||
|
||||
// writes the current index to disk (autosave, window close, replay end).
|
||||
// Snapshots on main (cheap COW), compresses off-main.
|
||||
func saveCache() {
|
||||
guard isIndexer else { return }
|
||||
// inForce saves even when nothing changed (to persist replay progress)
|
||||
func saveCache(inForce: Bool = false) {
|
||||
guard isIndexer, dirty || inForce else { return }
|
||||
let vRecords = index.records
|
||||
// lastEventId tracks what is actually merged into the index; the
|
||||
// stream's latest id can be ahead (changes still being resolved),
|
||||
// and saving that would make the next launch skip those changes
|
||||
let vEventId = lastEventId
|
||||
// and saving that would make the next launch skip those changes.
|
||||
// While subtree walks are pending, the id from before them is kept.
|
||||
let vEventId = rescanFloorEventId ?? lastEventId
|
||||
dirty = false
|
||||
ioQueue.async {
|
||||
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
|
||||
@@ -445,7 +549,7 @@ final class AppModel: ObservableObject {
|
||||
private func saveCacheNow() {
|
||||
guard isIndexer, dirty else { return }
|
||||
let vRecords = index.records
|
||||
let vEventId = lastEventId
|
||||
let vEventId = rescanFloorEventId ?? lastEventId
|
||||
dirty = false
|
||||
ioQueue.sync {
|
||||
IndexStore.save(inRecords: vRecords, inLastEventId: vEventId)
|
||||
@@ -476,14 +580,15 @@ final class AppModel: ObservableObject {
|
||||
lastEventId, vCurrentId)
|
||||
lastEventId = vCurrentId
|
||||
startWatching(inSinceWhen: vCurrentId)
|
||||
kickRescanSubtrees(inPaths: vRoots)
|
||||
// floor 0: an interrupted walk makes the next launch start over
|
||||
kickRescanSubtrees(inPaths: vRoots, inFloorEventId: 0)
|
||||
return
|
||||
}
|
||||
startWatching(inSinceWhen: lastEventId)
|
||||
// roots added since the cache was written have never been walked
|
||||
let vMissing = vRoots.filter { !index.contains(inPath: $0) }
|
||||
if !vMissing.isEmpty {
|
||||
kickRescanSubtrees(inPaths: vMissing)
|
||||
kickRescanSubtrees(inPaths: vMissing, inFloorEventId: lastEventId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,25 +602,60 @@ final class AppModel: ObservableObject {
|
||||
pendingChanges.reset()
|
||||
let vPending = pendingChanges
|
||||
let vQueue = indexQueue
|
||||
let vDelay = kChangeCoalesceSeconds
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
currentExclusions = vMatcher
|
||||
watcher.start(
|
||||
inRoots: vRoots,
|
||||
inSinceWhen: FSEventStreamEventId(inSinceWhen)
|
||||
) { [weak self] vChanges in
|
||||
guard vPending.append(inChanges: vChanges) else { return }
|
||||
vQueue.asyncAfter(deadline: .now() + vDelay) { [weak self] in
|
||||
let vResolved = IndexState.resolve(
|
||||
inChanges: vPending.drain(),
|
||||
inExclusions: vMatcher
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self?.applyResolvedChanges(inResolved: vResolved)
|
||||
}
|
||||
vQueue.asyncAfter(deadline: .now() + vPending.coalesceDelay) { [weak self] in
|
||||
AppModel.flush(inPending: vPending, inExclusions: vMatcher, inModel: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolves everything pending (on the calling background queue) and
|
||||
// merges the result on main
|
||||
private nonisolated static func flush(inPending: PendingChanges,
|
||||
inExclusions: ExclusionMatcher,
|
||||
inModel: AppModel?) {
|
||||
let vChanges = inPending.drain()
|
||||
// an early catch-up flush may already have taken everything
|
||||
guard !vChanges.isEmpty else { return }
|
||||
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: inExclusions)
|
||||
DispatchQueue.main.async { [weak inModel] in
|
||||
inModel?.applyResolvedChanges(inResolved: vResolved)
|
||||
}
|
||||
}
|
||||
|
||||
// adapts the update cadence to the visibility level (delays from
|
||||
// Settings > Performance). Longer collection windows when nobody looks
|
||||
// mean fewer wake-ups and fewer stats, since repeated writes to one file
|
||||
// collapse into one check. When the app becomes more visible (or a
|
||||
// delay is shortened), pending changes are applied right away.
|
||||
private func activityChanged(inLevel: AppActivity.Level) {
|
||||
// reader mode: a reload skipped while hidden happens now
|
||||
if inLevel != .hidden && pendingReaderReload && !isIndexer {
|
||||
pendingReaderReload = false
|
||||
reloadFromCache()
|
||||
}
|
||||
let vDelay: TimeInterval
|
||||
switch inLevel {
|
||||
case .foreground: vDelay = prefs.updateDelayForeground
|
||||
case .background: vDelay = prefs.updateDelayBackground
|
||||
case .hidden: vDelay = prefs.updateDelayHidden
|
||||
}
|
||||
let vPrevious = pendingChanges.coalesceDelay
|
||||
pendingChanges.setCoalesceDelay(vDelay)
|
||||
guard vDelay < vPrevious, isIndexer, pendingChanges.hasPending, let vMatcher = currentExclusions else { return }
|
||||
let vPending = pendingChanges
|
||||
indexQueue.async { [weak self] in
|
||||
AppModel.flush(inPending: vPending, inExclusions: vMatcher, inModel: self)
|
||||
}
|
||||
}
|
||||
|
||||
// saves the index every kAutosaveSeconds while it has unsaved changes
|
||||
private func startAutosaveTimer() {
|
||||
autosaveTimer?.invalidate()
|
||||
// .common mode so the timer fires even while SwiftUI is busy
|
||||
@@ -533,6 +673,7 @@ final class AppModel: ObservableObject {
|
||||
// in-memory work; the stats were done off-main by IndexState.resolve)
|
||||
private func applyResolvedChanges(inResolved: ResolvedChanges) {
|
||||
guard isIndexer else { return }
|
||||
let vEventIdBefore = lastEventId
|
||||
let vResult = index.apply(inChanges: inResolved, inRoots: watchedRoots)
|
||||
lastEventId = max(lastEventId, inResolved.maxEventId)
|
||||
if vResult.changed {
|
||||
@@ -540,24 +681,32 @@ final class AppModel: ObservableObject {
|
||||
publishRecords()
|
||||
}
|
||||
if !vResult.rescans.isEmpty {
|
||||
kickRescanSubtrees(inPaths: vResult.rescans)
|
||||
kickRescanSubtrees(inPaths: vResult.rescans, inFloorEventId: vEventIdBefore)
|
||||
}
|
||||
if !vResult.newFolders.isEmpty {
|
||||
kickRescanSubtrees(inPaths: vResult.newFolders, inFloorEventId: vEventIdBefore, inNewFolders: true)
|
||||
}
|
||||
// persist right after a relaunch replay finishes so the next launch
|
||||
// resumes from here instead of replaying the same history again
|
||||
if inResolved.historyDone {
|
||||
NSLog("[Allofit GUI] FSEvents history replay done, saving cache")
|
||||
saveCache()
|
||||
saveCache(inForce: true)
|
||||
}
|
||||
}
|
||||
|
||||
// re-walks the given subtrees in the background, then swaps their
|
||||
// content in on main. Used for kernel-requested rescans, folders that
|
||||
// were moved/renamed into the tree, and newly added roots.
|
||||
private func kickRescanSubtrees(inPaths: [String]) {
|
||||
// inFloorEventId is saved instead of lastEventId until the walk is
|
||||
// merged. inNewFolders: the folders just appeared, so their records are
|
||||
// merged without the O(n) removal pass of a kernel-requested rescan.
|
||||
private func kickRescanSubtrees(inPaths: [String], inFloorEventId: UInt64, inNewFolders: Bool = false) {
|
||||
let vRoots = SubtreeMatcher.minimalRoots(inPaths: inPaths)
|
||||
guard !vRoots.isEmpty else { return }
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
let vGeneration = indexGeneration
|
||||
rescanFloorEventId = min(rescanFloorEventId ?? inFloorEventId, inFloorEventId)
|
||||
rescansInFlight += 1
|
||||
indexQueue.async { [weak self] in
|
||||
var vRecords: [FileRecord] = []
|
||||
for vPath in vRoots {
|
||||
@@ -572,8 +721,18 @@ final class AppModel: ObservableObject {
|
||||
}
|
||||
let vFinal = vRecords
|
||||
DispatchQueue.main.async {
|
||||
guard let vSelf = self, vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
|
||||
guard let vSelf = self else { return }
|
||||
vSelf.rescansInFlight -= 1
|
||||
if vSelf.rescansInFlight <= 0 {
|
||||
vSelf.rescansInFlight = 0
|
||||
vSelf.rescanFloorEventId = nil
|
||||
}
|
||||
guard vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
|
||||
if inNewFolders {
|
||||
vSelf.index.mergeNewFolders(inRecords: vFinal)
|
||||
} else {
|
||||
vSelf.index.replaceSubtrees(inRoots: vRoots, inRecords: vFinal)
|
||||
}
|
||||
vSelf.dirty = true
|
||||
vSelf.publishRecords()
|
||||
}
|
||||
@@ -591,6 +750,10 @@ final class AppModel: ObservableObject {
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
let vState = index
|
||||
let vGeneration = indexGeneration
|
||||
// replay point: changes merged after this snapshot (and those whose
|
||||
// subtree walks are still running) would be lost when the pruned
|
||||
// copy replaces the live index, so the watcher restarts from here
|
||||
let vSnapshotEventId = rescanFloorEventId ?? lastEventId
|
||||
watcher.stop()
|
||||
indexQueue.async { [weak self] in
|
||||
var vPruned = vState
|
||||
@@ -600,21 +763,49 @@ final class AppModel: ObservableObject {
|
||||
NSLog("[Allofit GUI] reconcile: %d records pruned", vRemoved)
|
||||
vSelf.replaceIndex(inState: vPruned)
|
||||
vSelf.dirty = true
|
||||
vSelf.startWatching(inSinceWhen: vSelf.lastEventId)
|
||||
vSelf.lastEventId = vSnapshotEventId
|
||||
vSelf.startWatching(inSinceWhen: vSnapshotEventId)
|
||||
let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) }
|
||||
if !vMissing.isEmpty {
|
||||
vSelf.kickRescanSubtrees(inPaths: vMissing)
|
||||
vSelf.kickRescanSubtrees(inPaths: vMissing, inFloorEventId: vSnapshotEventId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drops paths (and what's below them) from the index right away. Used
|
||||
// after Move to Trash from Allofit: the watcher ignores this process's
|
||||
// own file operations, so FSEvents would never report the removal
|
||||
func forget(inPaths: [String]) {
|
||||
guard isIndexer, !inPaths.isEmpty else { return }
|
||||
if index.removeSubtrees(inPaths: inPaths) > 0 {
|
||||
dirty = true
|
||||
publishRecords()
|
||||
}
|
||||
}
|
||||
|
||||
// (re)adds paths this app created or moved back itself (undo of Move to
|
||||
// Trash), which FSEvents doesn't report to it. Resolved like FSEvents
|
||||
// changes, so restored folders get walked too.
|
||||
func notice(inPaths: [String]) {
|
||||
guard isIndexer, !inPaths.isEmpty else { return }
|
||||
let vChanges = inPaths.map { FSChange(path: $0, flags: 0, eventId: 0) }
|
||||
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
|
||||
indexQueue.async { [weak self] in
|
||||
let vResolved = IndexState.resolve(inChanges: vChanges, inExclusions: vMatcher)
|
||||
DispatchQueue.main.async {
|
||||
self?.applyResolvedChanges(inResolved: vResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Reader mode
|
||||
// ===========================
|
||||
|
||||
// follows the cache written by another indexer (service or second instance)
|
||||
private func startReaderMode() {
|
||||
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
|
||||
let vUrl = IndexStore.cacheURL(forServiceMode: bootMode)
|
||||
let vDir = vUrl.deletingLastPathComponent().path
|
||||
let vTarget = vUrl.path
|
||||
NSLog("[Allofit GUI] reader mode: watching cache at %@", vTarget)
|
||||
@@ -666,12 +857,30 @@ final class AppModel: ObservableObject {
|
||||
// we don't need to re-render the whole list at FSEvents' rate -
|
||||
// the next tick will catch any newer state.
|
||||
private func reloadFromCache() {
|
||||
let vNow = Date()
|
||||
if let vLast = lastReloadAt, vNow.timeIntervalSince(vLast) < kMinReloadInterval {
|
||||
guard !isIndexer else { return }
|
||||
// nobody looks: reload once a window shows up again
|
||||
if AppActivity.shared.level == .hidden {
|
||||
pendingReaderReload = true
|
||||
return
|
||||
}
|
||||
let vNow = Date()
|
||||
if let vLast = lastReloadAt, vNow.timeIntervalSince(vLast) < kMinReloadInterval {
|
||||
// throttled: catch up when the interval ends instead of
|
||||
// dropping the change (the mtime poll has already seen it)
|
||||
if !pendingReaderReload {
|
||||
pendingReaderReload = true
|
||||
let vWait = kMinReloadInterval - vNow.timeIntervalSince(vLast)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + vWait) { [weak self] in
|
||||
guard let vSelf = self, vSelf.pendingReaderReload else { return }
|
||||
vSelf.pendingReaderReload = false
|
||||
vSelf.reloadFromCache()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
pendingReaderReload = false
|
||||
lastReloadAt = vNow
|
||||
let vUrl = IndexStore.cacheURL(forServiceMode: prefs.serviceMode)
|
||||
let vUrl = IndexStore.cacheURL(forServiceMode: bootMode)
|
||||
let vCurrentEventId = lastEventId
|
||||
let vCurrentCount = index.count
|
||||
ioQueue.async { [weak self] in
|
||||
@@ -704,6 +913,7 @@ final class AppModel: ObservableObject {
|
||||
|
||||
// notifies observers that the records changed
|
||||
private func publishRecords() {
|
||||
lastIndexChangeAt = Date()
|
||||
if indexedCount != index.count {
|
||||
indexedCount = index.count
|
||||
}
|
||||
|
||||
+280
-251
@@ -3,53 +3,28 @@ import AppKit
|
||||
|
||||
// ContentView is the main window layout: a search bar bonded to the title
|
||||
// bar via `.background(.bar)` (Liquid Glass on macOS 26, vibrant material
|
||||
// on macOS 15), a results table on the left, a Quick Look preview pane on
|
||||
// on macOS 15), the results list on the left, a Quick Look preview pane on
|
||||
// the right (toggleable via the toolbar), and a status bar at the bottom.
|
||||
struct ContentView: View {
|
||||
|
||||
// AppModel is intentionally not observed here: its counters change on
|
||||
// every index update and would re-evaluate this body (and the Table)
|
||||
// each time. StatusBarView observes it instead.
|
||||
// every index update and would re-evaluate this body each time.
|
||||
// StatusBarView observes it instead.
|
||||
@EnvironmentObject var prefs: Preferences
|
||||
@EnvironmentObject var access: AccessManager
|
||||
// per-window search model: owns this window's query + filtered slice so
|
||||
// two windows can run independent searches against the shared AppModel
|
||||
// per-window search model: owns this window's query + results so two
|
||||
// windows can run independent searches against the shared AppModel
|
||||
@EnvironmentObject var searchModel: WindowSearchModel
|
||||
@State private var selection: Set<FileRecord.ID> = []
|
||||
// drives the Table's drag-to-reorder and column-visibility customization.
|
||||
// Initial value is hydrated from UserDefaults so the user's column order
|
||||
// survives app launches; subsequent changes flow back via .onChange.
|
||||
@State private var columnCustomization: TableColumnCustomization<FileRecord> = ContentView.loadColumnCustomization()
|
||||
// debounced background save task for columnCustomization changes.
|
||||
// Cancelled+rescheduled per change so a drag (which fires onChange on
|
||||
// every micro-update) only runs JSONEncoder once, off-main.
|
||||
@State private var columnSaveTask: Task<Void, Never>?
|
||||
// selected records, in row order (shared with the table, preview and
|
||||
// status bar)
|
||||
@State private var selection: [FileRecord] = []
|
||||
// window undo stack (Move to Trash can be undone)
|
||||
@Environment(\.undoManager) private var undoManager
|
||||
// whether the right-hand preview pane is currently visible. Persisted
|
||||
// across launches so the user's pane-visibility preference sticks.
|
||||
@AppStorage("Allofit.showPreviewPane") private var showPreviewPane: Bool = true
|
||||
|
||||
private nonisolated static let kColumnCustomizationKey = "Allofit.columnCustomization"
|
||||
private nonisolated static let kColumnSaveDebounceNanos: UInt64 = 300_000_000
|
||||
|
||||
// Computed binding for the Table's sortOrder: reads/writes the
|
||||
// per-window searchModel.sortDescriptor so clicking a column header
|
||||
// only re-sorts this window. The last-clicked sort is mirrored into
|
||||
// Preferences so a fresh window opens with the most recent choice.
|
||||
private var sortOrderBinding: Binding<[KeyPathComparator<FileRecord>]> {
|
||||
Binding(
|
||||
get: { [Self.comparatorFor(inDescriptor: searchModel.sortDescriptor)] },
|
||||
set: { vNewOrder in
|
||||
guard let vFirst = vNewOrder.first else { return }
|
||||
let vDescriptor = Self.mapSortOrder(inComparator: vFirst)
|
||||
// defer one runloop tick so we don't write back into the
|
||||
// model while NSTableView is still in its sort delegate
|
||||
// callback (avoids the reentrant-operation AppKit warning)
|
||||
DispatchQueue.main.async {
|
||||
searchModel.sortDescriptor = vDescriptor
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// search syntax popover
|
||||
@State private var showSyntaxHelp = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -70,256 +45,214 @@ struct ContentView: View {
|
||||
Button {
|
||||
showPreviewPane.toggle()
|
||||
} label: {
|
||||
Image(systemName: showPreviewPane
|
||||
? "sidebar.right"
|
||||
: "sidebar.squares.right")
|
||||
// a real label so VoiceOver reads it, shown as an icon
|
||||
Label(showPreviewPane ? "Hide Preview" : "Show Preview",
|
||||
systemImage: showPreviewPane ? "sidebar.right" : "sidebar.squares.right")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.help(showPreviewPane ? "Hide preview" : "Show preview")
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
SettingsLink {
|
||||
Image(systemName: "gearshape")
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.help("Preferences (⌘,)")
|
||||
}
|
||||
}
|
||||
.onChange(of: columnCustomization) { _, vNew in
|
||||
// Debounced + off-main save. SwiftUI fires onChange on every
|
||||
// micro-update during a column drag - encoding synchronously
|
||||
// on main here would freeze the drag delegate. Cancel any
|
||||
// pending task and reschedule so we encode at most once per
|
||||
// drag (300 ms after the user lets go).
|
||||
columnSaveTask?.cancel()
|
||||
columnSaveTask = Task.detached(priority: .utility) {
|
||||
try? await Task.sleep(nanoseconds: Self.kColumnSaveDebounceNanos)
|
||||
if Task.isCancelled { return }
|
||||
ContentView.saveColumnCustomization(vNew)
|
||||
.help("Settings (⌘,)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// search bar + table + status bar - everything except the preview pane
|
||||
// search bar + results + status bar - everything except the preview pane
|
||||
private var mainColumn: some View {
|
||||
VStack(spacing: 0) {
|
||||
searchBar
|
||||
resultsTable
|
||||
Divider()
|
||||
StatusBarView() // isolated so its @Published refresh
|
||||
// doesn't re-evaluate the Table closure
|
||||
// isolated so its refreshes don't re-evaluate this body
|
||||
StatusBarView(stats: searchModel.stats, selectionText: selectionSummary)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Column customization persistence
|
||||
// ===========================
|
||||
|
||||
private static func loadColumnCustomization() -> TableColumnCustomization<FileRecord> {
|
||||
guard let vData = UserDefaults.standard.data(forKey: kColumnCustomizationKey),
|
||||
let vCustom = try? JSONDecoder().decode(
|
||||
TableColumnCustomization<FileRecord>.self,
|
||||
from: vData
|
||||
)
|
||||
else {
|
||||
return TableColumnCustomization<FileRecord>()
|
||||
}
|
||||
return vCustom
|
||||
}
|
||||
|
||||
private nonisolated static func saveColumnCustomization(_ inValue: TableColumnCustomization<FileRecord>) {
|
||||
guard let vData = try? JSONEncoder().encode(inValue) else { return }
|
||||
UserDefaults.standard.set(vData, forKey: kColumnCustomizationKey)
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Search bar
|
||||
// ===========================
|
||||
|
||||
// search field plus the Everything-style category filter
|
||||
private var searchBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
SearchField(
|
||||
text: $searchModel.query,
|
||||
placeholder: "Search files… e.g. Start*.pdf · *.png | *.jpg",
|
||||
initiallyFirstResponder: true
|
||||
)
|
||||
.frame(minHeight: 24)
|
||||
// category filter, ANDed with the query
|
||||
Picker("Filter", selection: $searchModel.filter) {
|
||||
ForEach(SearchFilter.allCases) { vFilter in
|
||||
Label(vFilter.title, systemImage: vFilter.symbolName).tag(vFilter)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
.fixedSize()
|
||||
.help("Show only one kind of item")
|
||||
// search syntax cheat sheet
|
||||
Button {
|
||||
showSyntaxHelp.toggle()
|
||||
} label: {
|
||||
Label("Search Syntax", systemImage: "questionmark.circle")
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Search syntax")
|
||||
.popover(isPresented: $showSyntaxHelp, arrowEdge: .bottom) {
|
||||
SyntaxHelpView()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Results table
|
||||
// MARK: Results
|
||||
// ===========================
|
||||
|
||||
// native table with every result (see ResultsTable)
|
||||
private var resultsTable: some View {
|
||||
// Uses the explicit `rows:` form of Table so `.draggable` lives on
|
||||
// TableRow rather than embedded in cell content. Cell-content
|
||||
// draggable installs a SwiftUI drag-gesture recognizer that races
|
||||
// with NSTableView's mouseDown→selection event on macOS 26 and
|
||||
// occasionally eats left-clicks; row-level draggable doesn't.
|
||||
Table(of: FileRecord.self,
|
||||
ResultsTable(
|
||||
records: searchModel.results,
|
||||
version: searchModel.resultsVersion,
|
||||
highlights: searchModel.highlights,
|
||||
highlightEnabled: prefs.highlightMatches,
|
||||
selection: $selection,
|
||||
sortOrder: sortOrderBinding,
|
||||
columnCustomization: $columnCustomization) {
|
||||
TableColumn("Name", value: \FileRecord.name) { vRecord in
|
||||
HStack(spacing: 6) {
|
||||
Image(nsImage: IconCache.icon(
|
||||
forName: vRecord.name,
|
||||
isDirectory: vRecord.isDirectory
|
||||
))
|
||||
.resizable()
|
||||
.frame(width: 16, height: 16)
|
||||
Text(vRecord.name)
|
||||
.lineLimit(1)
|
||||
.help(vRecord.name)
|
||||
// When the preview pane is closed, surface the
|
||||
// elevate-permission affordance on the selected row
|
||||
// itself so the user has a way to authorize without
|
||||
// having to open the pane first. needsAuthorization
|
||||
// is a stat() call so we only invoke it for the row
|
||||
// that's actually selected.
|
||||
if !showPreviewPane,
|
||||
selection.count == 1,
|
||||
selection.contains(vRecord.id),
|
||||
access.needsAuthorization(for: vRecord) {
|
||||
Spacer(minLength: 4)
|
||||
// pass access explicitly: Table cells live in
|
||||
// detached NSHostingViews that don't reliably
|
||||
// inherit @EnvironmentObject, which was the cause
|
||||
// of repeated EnvironmentObject.error() crashes
|
||||
AuthorizeBadge(access: access, record: vRecord)
|
||||
sort: $searchModel.sortDescriptor,
|
||||
needsAuthorization: { vRecord in access.needsAuthorization(for: vRecord) },
|
||||
perform: { vAction, vRecords in perform(inAction: vAction, inRecords: vRecords) }
|
||||
)
|
||||
// explains an empty list (loading, indexing, no match, filter on)
|
||||
.overlay {
|
||||
EmptyResultsView(stats: searchModel.stats, filter: searchModel.filter, hasQuery: !searchModel.query.isEmpty)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width(min: 200, ideal: 320)
|
||||
.customizationID("name")
|
||||
|
||||
TableColumn("Path", value: \FileRecord.parentPath) { vRecord in
|
||||
Text(vRecord.parentPath)
|
||||
.foregroundColor(.secondary)
|
||||
.truncationMode(.middle)
|
||||
.lineLimit(1)
|
||||
.help(vRecord.parentPath)
|
||||
}
|
||||
.width(min: 200, ideal: 380)
|
||||
.customizationID("path")
|
||||
|
||||
TableColumn("Size", value: \FileRecord.size) { vRecord in
|
||||
Text(vRecord.isDirectory ? "-" : Formatters.size(bytes: vRecord.size))
|
||||
.foregroundColor(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(90)
|
||||
.customizationID("size")
|
||||
|
||||
TableColumn("Created", value: \FileRecord.dateCreated) { vRecord in
|
||||
Text(Formatters.date(vRecord.dateCreated))
|
||||
.foregroundColor(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(140)
|
||||
.customizationID("created")
|
||||
|
||||
TableColumn("Modified", value: \FileRecord.dateModified) { vRecord in
|
||||
Text(Formatters.date(vRecord.dateModified))
|
||||
.foregroundColor(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(140)
|
||||
.customizationID("modified")
|
||||
} rows: {
|
||||
ForEach(searchModel.visibleRecords) { vRecord in
|
||||
TableRow(vRecord)
|
||||
.draggable(URL(fileURLWithPath: vRecord.fullPath))
|
||||
}
|
||||
}
|
||||
.contextMenu(forSelectionType: FileRecord.ID.self) { vIds in
|
||||
Button("Open") { openSelection(inIds: vIds) }
|
||||
Button("Reveal in Finder") { revealSelection(inIds: vIds) }
|
||||
Button("Quick Look") { quickLookSelection(inIds: vIds) }
|
||||
Divider()
|
||||
Button("Copy Path") { copyPaths(inIds: vIds) }
|
||||
} primaryAction: { vIds in
|
||||
openSelection(inIds: vIds)
|
||||
}
|
||||
// Finder-style spacebar Quick Look. .onKeyPress only fires when the
|
||||
// view (Table) has keyboard focus, so spaces typed into the search
|
||||
// field still produce literal spaces in the query.
|
||||
.onKeyPress(.space) {
|
||||
guard !selection.isEmpty else { return .ignored }
|
||||
let vUrls = recordsFor(inIds: selection)
|
||||
.map { URL(fileURLWithPath: $0.fullPath) }
|
||||
QuickLookCoordinator.shared.show(inUrls: vUrls)
|
||||
return .handled
|
||||
}
|
||||
// "3 selected (12 MB)" for the status bar, nil without a selection
|
||||
private var selectionSummary: String? {
|
||||
guard !selection.isEmpty else { return nil }
|
||||
let vBytes = selection.filter { !$0.isDirectory }.reduce(Int64(0)) { $0 + $1.size }
|
||||
let vCount = "\(selection.count.formatted()) selected"
|
||||
return vBytes > 0 ? "\(vCount) (\(Formatters.size(bytes: vBytes)))" : vCount
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Selection actions
|
||||
// MARK: Actions
|
||||
// ===========================
|
||||
|
||||
private func revealSelection(inIds: Set<FileRecord.ID>) {
|
||||
// reveal in Finder shows the *original* file (not the staged copy),
|
||||
// since the user wants to navigate to the real location on disk
|
||||
let vUrls = recordsFor(inIds: inIds).map { URL(fileURLWithPath: $0.fullPath) }
|
||||
NSWorkspace.shared.activateFileViewerSelecting(vUrls)
|
||||
// runs an action requested by the table (menu, keyboard, double-click)
|
||||
private func perform(inAction: ResultAction, inRecords: [FileRecord]) {
|
||||
switch inAction {
|
||||
case .primary:
|
||||
switch prefs.primaryAction {
|
||||
case .open: openRecords(inRecords)
|
||||
case .reveal: revealRecords(inRecords)
|
||||
}
|
||||
|
||||
private func quickLookSelection(inIds: Set<FileRecord.ID>) {
|
||||
// prefer the staged URL when one exists - QLPreviewPanel renders
|
||||
// it without permission issues, whereas the original would fail
|
||||
let vUrls = recordsFor(inIds: inIds).map { access.effectiveURL(for: $0) }
|
||||
QuickLookCoordinator.shared.show(inUrls: vUrls)
|
||||
}
|
||||
|
||||
private func copyPaths(inIds: Set<FileRecord.ID>) {
|
||||
// always copy the original path - the staged tmp path is an
|
||||
// implementation detail that has no meaning outside this session
|
||||
let vPaths = recordsFor(inIds: inIds).map { $0.fullPath }
|
||||
case .open: openRecords(inRecords)
|
||||
case .openWith(let vApp): openRecords(inRecords, withApp: vApp)
|
||||
case .reveal: revealRecords(inRecords)
|
||||
case .quickLook:
|
||||
// prefer the staged URL when one exists - QLPreviewPanel
|
||||
// renders it without permission issues
|
||||
QuickLookCoordinator.shared.show(inUrls: inRecords.map { access.effectiveURL(for: $0) })
|
||||
case .copyFiles:
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(vPaths.joined(separator: "\n"), forType: .string)
|
||||
NSPasteboard.general.writeObjects(inRecords.map { URL(fileURLWithPath: $0.fullPath) as NSURL })
|
||||
case .copyNames:
|
||||
copyLines(inRecords.map(\.name))
|
||||
case .copyPaths:
|
||||
// always the original path - a staged copy's tmp path means
|
||||
// nothing outside this session
|
||||
copyLines(inRecords.map(\.fullPath))
|
||||
case .trash:
|
||||
trashRecords(inRecords)
|
||||
case .authorize:
|
||||
if let vRecord = inRecords.first {
|
||||
Task { await access.authorize(vRecord) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openSelection(inIds: Set<FileRecord.ID>) {
|
||||
// open the staged copy when available so the default app can read
|
||||
// it; falls back to the original path for files we can read directly
|
||||
for vRecord in recordsFor(inIds: inIds) {
|
||||
// reveals the records in Finder (the original files, not staged copies)
|
||||
private func revealRecords(_ inRecords: [FileRecord]) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(inRecords.map { URL(fileURLWithPath: $0.fullPath) })
|
||||
}
|
||||
|
||||
// puts one string per line on the pasteboard
|
||||
private func copyLines(_ inLines: [String]) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(inLines.joined(separator: "\n"), forType: .string)
|
||||
}
|
||||
|
||||
// moves the records to the Trash: their rows go away at once, ⌘Z puts
|
||||
// them back, and a failure is reported instead of silently ignored
|
||||
private func trashRecords(_ inRecords: [FileRecord]) {
|
||||
let vUrls = inRecords.map { URL(fileURLWithPath: $0.fullPath) }
|
||||
let vUndo = undoManager
|
||||
NSWorkspace.shared.recycle(vUrls) { vTrashed, vError in
|
||||
// look the results up with the very URL objects passed in (a
|
||||
// folder's URL gains a trailing slash, rebuilt ones wouldn't match)
|
||||
var vMoved: [(record: FileRecord, original: URL, trashed: URL)] = []
|
||||
for (vRecord, vUrl) in zip(inRecords, vUrls) {
|
||||
if let vInTrash = vTrashed[vUrl] {
|
||||
vMoved.append((vRecord, vUrl, vInTrash))
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if !vMoved.isEmpty {
|
||||
searchModel.removeTrashed(inRecords: vMoved.map(\.record))
|
||||
let vGone = Set(vMoved.map(\.record.id))
|
||||
selection.removeAll { vGone.contains($0.id) }
|
||||
registerPutBack(inMoves: vMoved.map { ($0.original, $0.trashed) }, inUndo: vUndo)
|
||||
}
|
||||
if let vError = vError {
|
||||
let vAlert = NSAlert(error: vError)
|
||||
vAlert.messageText = vMoved.isEmpty
|
||||
? "Couldn't move to the Trash"
|
||||
: "Some items couldn't be moved to the Trash"
|
||||
vAlert.runModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registers "Undo Move to Trash": moves the items back and tells the
|
||||
// index (the watcher ignores this app's own file operations)
|
||||
private func registerPutBack(inMoves: [(original: URL, trashed: URL)], inUndo: UndoManager?) {
|
||||
guard let vUndo = inUndo else { return }
|
||||
let vModel = searchModel
|
||||
vUndo.registerUndo(withTarget: vModel) { vTarget in
|
||||
var vRestored: [String] = []
|
||||
for vMove in inMoves where (try? FileManager.default.moveItem(at: vMove.trashed, to: vMove.original)) != nil {
|
||||
vRestored.append(vMove.original.path)
|
||||
}
|
||||
vTarget.noticeRestored(inPaths: vRestored)
|
||||
}
|
||||
vUndo.setActionName(inMoves.count == 1 ? "Move to Trash" : "Move \(inMoves.count) Items to Trash")
|
||||
}
|
||||
|
||||
// opens the records with a specific app
|
||||
private func openRecords(_ inRecords: [FileRecord], withApp inApp: URL) {
|
||||
let vUrls = inRecords.map { access.effectiveURL(for: $0) }
|
||||
NSWorkspace.shared.open(vUrls, withApplicationAt: inApp, configuration: NSWorkspace.OpenConfiguration())
|
||||
}
|
||||
|
||||
// opens the records with their default app (the staged copy when one
|
||||
// exists, so the app can read it)
|
||||
private func openRecords(_ inRecords: [FileRecord]) {
|
||||
for vRecord in inRecords {
|
||||
NSWorkspace.shared.open(access.effectiveURL(for: vRecord))
|
||||
}
|
||||
}
|
||||
|
||||
private func recordsFor(inIds: Set<FileRecord.ID>) -> [FileRecord] {
|
||||
return searchModel.visibleRecords.filter { inIds.contains($0.id) }
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Sort mapping
|
||||
// ===========================
|
||||
|
||||
private static func mapSortOrder(inComparator: KeyPathComparator<FileRecord>) -> FileSortDescriptor {
|
||||
let vAsc = inComparator.order == .forward
|
||||
let vKp = inComparator.keyPath
|
||||
if vKp == \FileRecord.name { return vAsc ? .nameAscending : .nameDescending }
|
||||
if vKp == \FileRecord.parentPath { return vAsc ? .pathAscending : .pathDescending }
|
||||
if vKp == \FileRecord.size { return vAsc ? .sizeAscending : .sizeDescending }
|
||||
if vKp == \FileRecord.dateCreated { return vAsc ? .createdAscending : .createdDescending }
|
||||
if vKp == \FileRecord.dateModified { return vAsc ? .modifiedAscending : .modifiedDescending }
|
||||
return .nameAscending
|
||||
}
|
||||
|
||||
private static func comparatorFor(inDescriptor: FileSortDescriptor) -> KeyPathComparator<FileRecord> {
|
||||
switch inDescriptor {
|
||||
case .nameAscending: return KeyPathComparator(\FileRecord.name, order: .forward)
|
||||
case .nameDescending: return KeyPathComparator(\FileRecord.name, order: .reverse)
|
||||
case .pathAscending: return KeyPathComparator(\FileRecord.parentPath, order: .forward)
|
||||
case .pathDescending: return KeyPathComparator(\FileRecord.parentPath, order: .reverse)
|
||||
case .sizeAscending: return KeyPathComparator(\FileRecord.size, order: .forward)
|
||||
case .sizeDescending: return KeyPathComparator(\FileRecord.size, order: .reverse)
|
||||
case .createdAscending: return KeyPathComparator(\FileRecord.dateCreated, order: .forward)
|
||||
case .createdDescending: return KeyPathComparator(\FileRecord.dateCreated, order: .reverse)
|
||||
case .modifiedAscending: return KeyPathComparator(\FileRecord.dateModified, order: .forward)
|
||||
case .modifiedDescending: return KeyPathComparator(\FileRecord.dateModified, order: .reverse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -327,14 +260,25 @@ struct ContentView: View {
|
||||
// ===========================
|
||||
|
||||
// Extracted into its own View so its @Published-driven refreshes (cache
|
||||
// load progress, indexed count changes during a scan, service-mode flip)
|
||||
// only re-evaluate this small leaf view rather than the ContentView body
|
||||
// that contains the Table.
|
||||
// load progress, indexed count changes during a scan, service-mode flip,
|
||||
// the resource sampler's 2-second tick) only re-evaluate this small leaf
|
||||
// view rather than the ContentView body that contains the results.
|
||||
private struct StatusBarView: View {
|
||||
|
||||
@EnvironmentObject var model: AppModel
|
||||
@EnvironmentObject var prefs: Preferences
|
||||
@EnvironmentObject var searchModel: WindowSearchModel
|
||||
// per-window match count and search time
|
||||
@ObservedObject var stats: SearchStats
|
||||
// "3 selected (12 MB)", nil without a selection
|
||||
let selectionText: String?
|
||||
// process memory / CPU sampler shared by all windows
|
||||
@ObservedObject private var process = ProcessStats.shared
|
||||
// freeze key state, shown while held
|
||||
@ObservedObject private var freeze = FreezeKey.shared
|
||||
|
||||
// CPU use above which the figure is highlighted (100 = one core)
|
||||
private let kBusyCpuPercent: Double = 50
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
@@ -348,18 +292,29 @@ private struct StatusBarView: View {
|
||||
Text("Indexing… \(model.indexedCount.formatted()) entries")
|
||||
} else {
|
||||
Text(resultsText)
|
||||
if let vSelection = selectionText {
|
||||
Text("·")
|
||||
Text(vSelection)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(model.isIndexer ? "Indexer" : "Reader")
|
||||
.foregroundColor(.secondary)
|
||||
switch prefs.serviceMode {
|
||||
case .none: EmptyView()
|
||||
case .userAgent: Text("· User service").foregroundColor(.secondary)
|
||||
case .rootDaemon: Text("· Root service").foregroundColor(.secondary)
|
||||
if freeze.isHeld {
|
||||
Label("Frozen", systemImage: "snowflake")
|
||||
.foregroundColor(.accentColor)
|
||||
.help("Holding ⌥ Option pauses list updates; release to catch up")
|
||||
}
|
||||
if !searchModel.query.isEmpty {
|
||||
Text("· Filtered").foregroundColor(.secondary)
|
||||
HStack(spacing: 6) {
|
||||
Text(String(format: "%.0f ms", stats.lastSearchMilliseconds))
|
||||
Text("·")
|
||||
Text("RAM \(Formatters.memory(bytes: process.footprintBytes))")
|
||||
Text("·")
|
||||
Text(String(format: "CPU %.0f%%", process.cpuPercent))
|
||||
.foregroundColor(process.cpuPercent >= kBusyCpuPercent ? .orange : .secondary)
|
||||
Text("·")
|
||||
Text(roleText)
|
||||
}
|
||||
.monospacedDigit()
|
||||
.help(detailsText)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
@@ -367,14 +322,88 @@ private struct StatusBarView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// "N results" plus how many are listed when the list is capped
|
||||
// "N results · M indexed"
|
||||
private var resultsText: String {
|
||||
let vMatches = searchModel.matchCount
|
||||
let vShown = searchModel.visibleRecords.count
|
||||
let vIndexed = model.indexedCount.formatted()
|
||||
if vShown < vMatches {
|
||||
return "\(vMatches.formatted()) results (first \(vShown.formatted()) listed) · \(vIndexed) indexed"
|
||||
let vCount = stats.matchCount
|
||||
return "\(vCount.formatted()) \(vCount == 1 ? "result" : "results") · \(model.indexedCount.formatted()) indexed"
|
||||
}
|
||||
|
||||
// indexer / reader role, plus the service kind when one is used
|
||||
private var roleText: String {
|
||||
let vRole = model.isIndexer ? "Indexer" : "Reader"
|
||||
switch prefs.serviceMode {
|
||||
case .none: return vRole
|
||||
case .userAgent: return vRole + " · User service"
|
||||
case .rootDaemon: return vRole + " · Root service"
|
||||
}
|
||||
}
|
||||
|
||||
// multi-line tooltip with the full set of figures
|
||||
private var detailsText: String {
|
||||
let vCacheBytes = IndexStore.cacheFileSize(at: IndexStore.cacheURL(forServiceMode: prefs.serviceMode))
|
||||
let vUpdated = model.lastIndexChangeAt.map { $0.formatted(.relative(presentation: .named)) } ?? "never"
|
||||
let vLines = [
|
||||
"Memory: \(Formatters.memory(bytes: process.footprintBytes)) (peak \(Formatters.memory(bytes: process.peakFootprintBytes)))",
|
||||
String(format: "CPU: %.1f%% (100%% = one core)", process.cpuPercent) + " · \(process.threadCount) threads",
|
||||
String(format: "Last search: %.1f ms over ", stats.lastSearchMilliseconds) + "\(model.indexedCount.formatted()) entries",
|
||||
"Index updated: \(vUpdated)",
|
||||
"Cache on disk: \(Formatters.sizeOrDash(bytes: vCacheBytes))",
|
||||
"Roots: \(prefs.rootPaths.count) · Role: \(roleText)"
|
||||
]
|
||||
return vLines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Empty state
|
||||
// ===========================
|
||||
|
||||
// EmptyResultsView is shown over an empty results list and says why it's
|
||||
// empty: the index is loading or being built, nothing matches, or a filter
|
||||
// hides everything. It observes AppModel itself so ContentView doesn't have to.
|
||||
private struct EmptyResultsView: View {
|
||||
|
||||
@EnvironmentObject var model: AppModel
|
||||
// match count of this window
|
||||
@ObservedObject var stats: SearchStats
|
||||
// active category filter
|
||||
let filter: SearchFilter
|
||||
// true when something is typed in the search field
|
||||
let hasQuery: Bool
|
||||
|
||||
var body: some View {
|
||||
if stats.matchCount == 0 {
|
||||
VStack(spacing: 8) {
|
||||
if model.isLoadingCache {
|
||||
ProgressView()
|
||||
Text("Loading the index…")
|
||||
.font(.headline)
|
||||
} else if model.isIndexing {
|
||||
ProgressView()
|
||||
Text("Building the index…")
|
||||
.font(.headline)
|
||||
Text("\(model.indexedCount.formatted()) files so far. Results appear as they are found.")
|
||||
.font(.callout)
|
||||
} else if model.indexedCount == 0 {
|
||||
Text("Nothing indexed yet")
|
||||
.font(.headline)
|
||||
Text("Add folders in Settings > Indexes.")
|
||||
.font(.callout)
|
||||
} else {
|
||||
Text("No results")
|
||||
.font(.headline)
|
||||
if filter != .everything {
|
||||
Text("Only \(filter.title.lowercased()) are shown. Choose Everything in the filter menu to search all items.")
|
||||
.font(.callout)
|
||||
} else if hasQuery {
|
||||
Text("Click ? next to the search field for the search syntax.")
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 360)
|
||||
}
|
||||
return "\(vMatches.formatted()) results · \(vIndexed) indexed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import Foundation
|
||||
// Formatters bundles the byte-count and date formatting used by both
|
||||
// the Table's columns and the right-hand preview pane footer. Keeping
|
||||
// the formatter instances cached at file scope avoids reconstructing
|
||||
// them per row render, which would be expensive at 5 000 rows.
|
||||
// them per row render, which would be expensive at thousands of rows.
|
||||
enum Formatters {
|
||||
|
||||
// file-size formatter (decimal units, like Finder)
|
||||
private static let kSizeFormatter: ByteCountFormatter = {
|
||||
let vF = ByteCountFormatter()
|
||||
vF.countStyle = .file
|
||||
@@ -17,6 +18,24 @@ enum Formatters {
|
||||
return kSizeFormatter.string(fromByteCount: inBytes)
|
||||
}
|
||||
|
||||
// memory formatter (binary units, like Activity Monitor)
|
||||
private static let kMemoryFormatter: ByteCountFormatter = {
|
||||
let vF = ByteCountFormatter()
|
||||
vF.countStyle = .memory
|
||||
return vF
|
||||
}()
|
||||
|
||||
// memory amount, e.g. "312 MB" (binary units, like Activity Monitor)
|
||||
static func memory(bytes inBytes: UInt64) -> String {
|
||||
return kMemoryFormatter.string(fromByteCount: Int64(clamping: inBytes))
|
||||
}
|
||||
|
||||
// same as size(bytes:), but "-" for zero / unknown (missing files)
|
||||
static func sizeOrDash(bytes inBytes: Int64) -> String {
|
||||
return inBytes <= 0 ? "-" : size(bytes: inBytes)
|
||||
}
|
||||
|
||||
// short date + short time, in the user's locale
|
||||
private static let kDateFormatter: DateFormatter = {
|
||||
let vF = DateFormatter()
|
||||
vF.dateStyle = .short
|
||||
@@ -24,7 +43,7 @@ enum Formatters {
|
||||
return vF
|
||||
}()
|
||||
|
||||
// short date+time, with em-dash for sentinel "no date" values
|
||||
// short date+time, with "-" for sentinel "no date" values
|
||||
static func date(_ inDate: Date) -> String {
|
||||
if inDate.timeIntervalSince1970 < 1 { return "-" }
|
||||
return kDateFormatter.string(from: inDate)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ struct QuickLookPreviewView: NSViewRepresentable {
|
||||
// the file to preview; nil clears the view
|
||||
let url: URL?
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
// creates the embedded Quick Look view
|
||||
func makeNSView(context inContext: Context) -> NSView {
|
||||
guard let vView = QLPreviewView(frame: .zero, style: .normal) else {
|
||||
return NSView()
|
||||
}
|
||||
@@ -23,50 +24,13 @@ struct QuickLookPreviewView: NSViewRepresentable {
|
||||
return vView
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
guard let vQlView = nsView as? QLPreviewView else { return }
|
||||
// shows the current file
|
||||
func updateNSView(_ inView: NSView, context inContext: Context) {
|
||||
guard let vQlView = inView as? QLPreviewView else { return }
|
||||
vQlView.previewItem = (url as NSURL?)
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorizeBadge is the small lock icon that appears at the right of the
|
||||
// selected row when the preview pane is closed. Clicking it kicks off the
|
||||
// sudo cp + chown via AdminShell - the system prompts for the password
|
||||
// the first time inside the admin-auth-cache window.
|
||||
//
|
||||
// access is taken as an @ObservedObject property (not @EnvironmentObject)
|
||||
// because this view is hosted inside a SwiftUI Table cell, and Table cell
|
||||
// content is rendered in its own NSHostingView. That hosting view does
|
||||
// not reliably inherit the parent's environment objects; a missing
|
||||
// access lookup triggers EnvironmentObject.error() → SIGTRAP. Passing
|
||||
// the AccessManager explicitly sidesteps that entire failure mode.
|
||||
struct AuthorizeBadge: View {
|
||||
|
||||
@ObservedObject var access: AccessManager
|
||||
let record: FileRecord
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
Task { await access.authorize(record) }
|
||||
} label: {
|
||||
if access.isAuthorizing(record.id) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(access.isAuthorizing(record.id))
|
||||
.help(access.isAuthorizing(record.id)
|
||||
? "Authorizing…"
|
||||
: "Authorize to read this file")
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewPane is the right-hand side panel in the main window. When
|
||||
// exactly one row is selected it renders a Quick Look preview plus a
|
||||
// small metadata footer. If the file isn't user-readable the preview
|
||||
@@ -79,11 +43,11 @@ struct PreviewPane: View {
|
||||
@EnvironmentObject var searchModel: WindowSearchModel
|
||||
// passed in from ContentView (its @State) so this view re-renders
|
||||
// whenever the user's selection changes
|
||||
let selection: Set<FileRecord.ID>
|
||||
let selection: [FileRecord]
|
||||
|
||||
// the record to preview: only with exactly one selected
|
||||
private var selectedRecord: FileRecord? {
|
||||
guard selection.count == 1, let vId = selection.first else { return nil }
|
||||
return searchModel.visibleRecords.first(where: { $0.id == vId })
|
||||
return selection.count == 1 ? selection.first : nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -28,11 +28,13 @@ final class QuickLookCoordinator: NSObject, @preconcurrency QLPreviewPanelDataSo
|
||||
// MARK: QLPreviewPanelDataSource
|
||||
// ===========================
|
||||
|
||||
func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
|
||||
// number of files shown by the panel
|
||||
func numberOfPreviewItems(in inPanel: QLPreviewPanel!) -> Int {
|
||||
return urls.count
|
||||
}
|
||||
|
||||
func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> QLPreviewItem! {
|
||||
return urls[index] as NSURL
|
||||
// file shown at the given panel position
|
||||
func previewPanel(_ inPanel: QLPreviewPanel!, previewItemAt inIndex: Int) -> QLPreviewItem! {
|
||||
return urls[inIndex] as NSURL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import AppKit
|
||||
// listens and re-grabs first responder, mimicking the standard Find shortcut.
|
||||
extension Notification.Name {
|
||||
static let allofitFocusSearch = Notification.Name("AllofitFocusSearch")
|
||||
// posted by the search field (object: its window) when ↓ is pressed:
|
||||
// that window's results list takes the keyboard focus
|
||||
static let allofitFocusResults = Notification.Name("AllofitFocusResults")
|
||||
}
|
||||
|
||||
// SearchField wraps AppKit's NSSearchField so we get native macOS behaviors
|
||||
@@ -17,17 +20,15 @@ struct SearchField: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
// placeholder shown when the field is empty
|
||||
var placeholder: String = ""
|
||||
// fired when the user presses Return inside the field
|
||||
var onSubmit: (() -> Void)? = nil
|
||||
// set true to grab keyboard focus when the field is first inserted
|
||||
var initiallyFirstResponder: Bool = false
|
||||
|
||||
// builds and configures the underlying NSSearchField
|
||||
func makeNSView(context: Context) -> NSSearchField {
|
||||
func makeNSView(context inContext: Context) -> NSSearchField {
|
||||
let vField = NSSearchField()
|
||||
vField.placeholderString = placeholder
|
||||
vField.delegate = context.coordinator
|
||||
vField.target = context.coordinator
|
||||
vField.delegate = inContext.coordinator
|
||||
vField.target = inContext.coordinator
|
||||
vField.action = #selector(Coordinator.actionTriggered(_:))
|
||||
// keep filtering live as the user types
|
||||
vField.sendsSearchStringImmediately = false
|
||||
@@ -54,7 +55,15 @@ struct SearchField: NSViewRepresentable {
|
||||
vMenu.addItem(vClear)
|
||||
vField.searchMenuTemplate = vMenu
|
||||
|
||||
context.coordinator.field = vField
|
||||
inContext.coordinator.field = vField
|
||||
// a query sent from outside before this window existed
|
||||
if let vPending = WindowPresenter.pendingQuery {
|
||||
WindowPresenter.pendingQuery = nil
|
||||
vField.stringValue = vPending
|
||||
DispatchQueue.main.async {
|
||||
inContext.coordinator.parent.text = vPending
|
||||
}
|
||||
}
|
||||
|
||||
if initiallyFirstResponder {
|
||||
DispatchQueue.main.async {
|
||||
@@ -65,12 +74,13 @@ struct SearchField: NSViewRepresentable {
|
||||
}
|
||||
|
||||
// keeps the NSSearchField's stringValue in sync with our binding
|
||||
func updateNSView(_ nsView: NSSearchField, context: Context) {
|
||||
if nsView.stringValue != text {
|
||||
nsView.stringValue = text
|
||||
func updateNSView(_ inView: NSSearchField, context inContext: Context) {
|
||||
if inView.stringValue != text {
|
||||
inView.stringValue = text
|
||||
}
|
||||
}
|
||||
|
||||
// creates the delegate bridging AppKit events to SwiftUI
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(self)
|
||||
}
|
||||
@@ -97,6 +107,7 @@ struct SearchField: NSViewRepresentable {
|
||||
// pending auto-save task; cancelled+rescheduled on every keystroke
|
||||
private var autoSaveTask: Task<Void, Never>?
|
||||
|
||||
// binds to the SearchField and listens for the Find shortcut
|
||||
init(_ inParent: SearchField) {
|
||||
parent = inParent
|
||||
super.init()
|
||||
@@ -108,8 +119,17 @@ struct SearchField: NSViewRepresentable {
|
||||
name: .allofitFocusSearch,
|
||||
object: nil
|
||||
)
|
||||
// queries sent from outside (Finder service, folder dropped on
|
||||
// the Dock icon) replace the text of the key window's field
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleSetQuery(_:)),
|
||||
name: .allofitSetQuery,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
// stops listening for the Find shortcut
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
@@ -125,9 +145,21 @@ struct SearchField: NSViewRepresentable {
|
||||
vField.selectText(nil)
|
||||
}
|
||||
|
||||
// puts an external query in the key window's field, caret at the end
|
||||
// so the user can keep typing more terms
|
||||
@objc private func handleSetQuery(_ inNotification: Notification) {
|
||||
guard let vQuery = inNotification.object as? String,
|
||||
let vField = field, vField.window?.isKeyWindow == true else { return }
|
||||
WindowPresenter.pendingQuery = nil
|
||||
vField.stringValue = vQuery
|
||||
parent.text = vQuery
|
||||
vField.window?.makeFirstResponder(vField)
|
||||
vField.currentEditor()?.selectedRange = NSRange(location: (vQuery as NSString).length, length: 0)
|
||||
}
|
||||
|
||||
// fired on every keystroke; reset navigation and push text to SwiftUI
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
guard let vField = notification.object as? NSSearchField else { return }
|
||||
func controlTextDidChange(_ inNotification: Notification) {
|
||||
guard let vField = inNotification.object as? NSSearchField else { return }
|
||||
navigationIndex = nil
|
||||
let vValue = vField.stringValue
|
||||
if parent.text != vValue {
|
||||
@@ -162,27 +194,33 @@ struct SearchField: NSViewRepresentable {
|
||||
}
|
||||
|
||||
// NSSearchField's action target: fires on Return and on recent-pick
|
||||
@objc func actionTriggered(_ sender: NSSearchField) {
|
||||
if parent.text != sender.stringValue {
|
||||
parent.text = sender.stringValue
|
||||
@objc func actionTriggered(_ inSender: NSSearchField) {
|
||||
if parent.text != inSender.stringValue {
|
||||
parent.text = inSender.stringValue
|
||||
}
|
||||
parent.onSubmit?()
|
||||
}
|
||||
|
||||
// intercept arrow-keys and Return for history nav and recents-update
|
||||
func control(_ control: NSControl,
|
||||
textView: NSTextView,
|
||||
doCommandBy commandSelector: Selector) -> Bool {
|
||||
guard let vField = control as? NSSearchField else { return false }
|
||||
if commandSelector == #selector(NSResponder.moveUp(_:)) {
|
||||
func control(_ inControl: NSControl,
|
||||
textView inTextView: NSTextView,
|
||||
doCommandBy inSelector: Selector) -> Bool {
|
||||
guard let vField = inControl as? NSSearchField else { return false }
|
||||
// ↓ goes to the results (like Everything); history moves to
|
||||
// ↑ / ⌥↑ (older) and ⌥↓ (newer)
|
||||
if inSelector == #selector(NSResponder.moveDown(_:)) {
|
||||
NotificationCenter.default.post(name: .allofitFocusResults, object: vField.window)
|
||||
return true
|
||||
}
|
||||
if inSelector == #selector(NSResponder.moveUp(_:))
|
||||
|| inSelector == #selector(NSResponder.moveToBeginningOfParagraph(_:)) {
|
||||
navigateHistory(field: vField, inOlder: true)
|
||||
return true
|
||||
}
|
||||
if commandSelector == #selector(NSResponder.moveDown(_:)) {
|
||||
if inSelector == #selector(NSResponder.moveToEndOfParagraph(_:)) {
|
||||
navigateHistory(field: vField, inOlder: false)
|
||||
return true
|
||||
}
|
||||
if commandSelector == #selector(NSControl.insertNewline(_:)) {
|
||||
if inSelector == #selector(NSControl.insertNewline(_:)) {
|
||||
addCurrentToRecents(field: vField)
|
||||
return false
|
||||
}
|
||||
@@ -190,25 +228,25 @@ struct SearchField: NSViewRepresentable {
|
||||
}
|
||||
|
||||
// adds the current text to the recents list (deduped, newest-first)
|
||||
private func addCurrentToRecents(field: NSSearchField) {
|
||||
let vQuery = field.stringValue.trimmingCharacters(in: .whitespaces)
|
||||
private func addCurrentToRecents(field inField: NSSearchField) {
|
||||
let vQuery = inField.stringValue.trimmingCharacters(in: .whitespaces)
|
||||
guard !vQuery.isEmpty else { return }
|
||||
var vRecents = field.recentSearches
|
||||
var vRecents = inField.recentSearches
|
||||
vRecents.removeAll { $0 == vQuery }
|
||||
vRecents.insert(vQuery, at: 0)
|
||||
if vRecents.count > 30 { vRecents = Array(vRecents.prefix(30)) }
|
||||
field.recentSearches = vRecents
|
||||
inField.recentSearches = vRecents
|
||||
navigationIndex = nil
|
||||
}
|
||||
|
||||
// walks one step through the recent-searches list
|
||||
private func navigateHistory(field: NSSearchField, inOlder: Bool) {
|
||||
let vHistory = field.recentSearches
|
||||
private func navigateHistory(field inField: NSSearchField, inOlder: Bool) {
|
||||
let vHistory = inField.recentSearches
|
||||
if vHistory.isEmpty { return }
|
||||
|
||||
if inOlder {
|
||||
if navigationIndex == nil {
|
||||
savedQuery = field.stringValue
|
||||
savedQuery = inField.stringValue
|
||||
navigationIndex = 0
|
||||
} else if let vI = navigationIndex, vI + 1 < vHistory.count {
|
||||
navigationIndex = vI + 1
|
||||
@@ -219,7 +257,7 @@ struct SearchField: NSViewRepresentable {
|
||||
navigationIndex = vI - 1
|
||||
} else {
|
||||
navigationIndex = nil
|
||||
field.stringValue = savedQuery
|
||||
inField.stringValue = savedQuery
|
||||
parent.text = savedQuery
|
||||
return
|
||||
}
|
||||
@@ -230,7 +268,7 @@ struct SearchField: NSViewRepresentable {
|
||||
|
||||
if let vIdx = navigationIndex, vIdx < vHistory.count {
|
||||
let vValue = vHistory[vIdx]
|
||||
field.stringValue = vValue
|
||||
inField.stringValue = vValue
|
||||
parent.text = vValue
|
||||
}
|
||||
}
|
||||
|
||||
+375
-56
@@ -1,9 +1,10 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import ServiceManagement
|
||||
|
||||
// SettingsView is the multi-tab Preferences window. It exposes roots,
|
||||
// exclusions, mounted volume inclusion, background service installation,
|
||||
// and on-disk cache management.
|
||||
// SettingsView is the Preferences window, grouped like Everything's options:
|
||||
// General (app behavior), Indexes (what gets indexed), Performance (update
|
||||
// timings) and Advanced (background service, cache, diagnostics).
|
||||
struct SettingsView: View {
|
||||
|
||||
@EnvironmentObject var model: AppModel
|
||||
@@ -11,24 +12,202 @@ struct SettingsView: View {
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
RootsTab()
|
||||
.tabItem { Label("Roots", systemImage: "folder") }
|
||||
ExclusionsTab()
|
||||
.tabItem { Label("Exclusions", systemImage: "minus.circle") }
|
||||
VolumesTab()
|
||||
.tabItem { Label("Volumes", systemImage: "externaldrive") }
|
||||
ServiceTab()
|
||||
.tabItem { Label("Service", systemImage: "gearshape.2") }
|
||||
CacheTab()
|
||||
.tabItem { Label("Cache", systemImage: "internaldrive") }
|
||||
DiagnosticsTab()
|
||||
.tabItem { Label("Diagnostics", systemImage: "stethoscope") }
|
||||
GeneralTab()
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
IndexesTab()
|
||||
.tabItem { Label("Indexes", systemImage: "folder") }
|
||||
PerformanceTab()
|
||||
.tabItem { Label("Performance", systemImage: "speedometer") }
|
||||
AdvancedTab()
|
||||
.tabItem { Label("Advanced", systemImage: "wrench.and.screwdriver") }
|
||||
}
|
||||
.frame(width: 620, height: 480)
|
||||
.frame(width: 620, height: 500)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: General tab
|
||||
// ===========================
|
||||
|
||||
// GeneralTab holds the app-level behavior: menu bar icon, global shortcut,
|
||||
// start at login, and how results react to double-click / highlighting.
|
||||
private struct GeneralTab: View {
|
||||
|
||||
@EnvironmentObject var prefs: Preferences
|
||||
// availability of the chosen global shortcut
|
||||
@ObservedObject private var hotKey = GlobalHotKey.shared
|
||||
// current login item state, read from the system
|
||||
@State private var loginStatus = SMAppService.mainApp.status
|
||||
// last error from registering the login item
|
||||
@State private var loginError: String?
|
||||
|
||||
var body: some View {
|
||||
// plain sections like the other tabs: a .grouped Form drew hairline
|
||||
// panel borders at fractional positions that flickered on refresh
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Access")
|
||||
.font(.headline)
|
||||
Form {
|
||||
// checkboxes get a left-column label like the pickers, so every
|
||||
// control starts on the same vertical line
|
||||
LabeledContent("Menu bar:") {
|
||||
Toggle("Show Allofit in the menu bar", isOn: $prefs.showMenuBarIcon)
|
||||
}
|
||||
Picker("Global shortcut:", selection: $prefs.globalHotKey) {
|
||||
ForEach(HotKeyPreset.allCases) { vPreset in
|
||||
Text(vPreset.title).tag(vPreset)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 260)
|
||||
if !hotKey.isAvailable {
|
||||
Text("This shortcut is already used by another app. Pick another one.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
LabeledContent("Login:") {
|
||||
Toggle("Start Allofit at login", isOn: loginBinding)
|
||||
}
|
||||
if loginStatus == .requiresApproval {
|
||||
HStack {
|
||||
Text("Approve Allofit in System Settings > General > Login Items.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
Button("Open Login Items") { SMAppService.openSystemSettingsLoginItems() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
if let vError = loginError {
|
||||
Text(vError)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Results")
|
||||
.font(.headline)
|
||||
Form {
|
||||
Picker("Double-click or Return:", selection: $prefs.primaryAction) {
|
||||
Text("Opens the file").tag(Preferences.PrimaryAction.open)
|
||||
Text("Reveals it in Finder").tag(Preferences.PrimaryAction.reveal)
|
||||
}
|
||||
.frame(maxWidth: 360)
|
||||
LabeledContent("Highlighting:") {
|
||||
Toggle("Highlight matches in names and paths", isOn: $prefs.highlightMatches)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Finder")
|
||||
.font(.headline)
|
||||
Text("Right-click a folder in Finder and choose Quick Actions (or Services) > Search in Allofit to search inside it. Dropping a folder on the Dock icon does the same.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer()
|
||||
}
|
||||
.onAppear { loginStatus = SMAppService.mainApp.status }
|
||||
}
|
||||
|
||||
// registers / unregisters the app as a login item
|
||||
private var loginBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { loginStatus == .enabled || loginStatus == .requiresApproval },
|
||||
set: { vEnabled in
|
||||
do {
|
||||
if vEnabled {
|
||||
try SMAppService.mainApp.register()
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
loginError = nil
|
||||
} catch {
|
||||
loginError = "Couldn't change the login item: \(error.localizedDescription)"
|
||||
}
|
||||
loginStatus = SMAppService.mainApp.status
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Grouping tabs
|
||||
// ===========================
|
||||
|
||||
// IndexesTab groups what gets indexed: root folders, exclusions, volumes
|
||||
private struct IndexesTab: View {
|
||||
|
||||
// sections of the tab
|
||||
private enum Section: String, CaseIterable, Identifiable {
|
||||
case folders = "Folders"
|
||||
case exclusions = "Exclusions"
|
||||
case volumes = "Volumes"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
// section currently shown
|
||||
@State private var section: Section = .folders
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("", selection: $section) {
|
||||
ForEach(Section.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
// pinned to the top so every section starts right below the
|
||||
// picker (a section without a trailing Spacer would otherwise
|
||||
// be centered vertically)
|
||||
Group {
|
||||
switch section {
|
||||
case .folders: RootsTab()
|
||||
case .exclusions: ExclusionsTab()
|
||||
case .volumes: VolumesTab()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AdvancedTab groups the power-user pages: background service, cache file
|
||||
// and live diagnostics
|
||||
private struct AdvancedTab: View {
|
||||
|
||||
// sections of the tab
|
||||
private enum Section: String, CaseIterable, Identifiable {
|
||||
case service = "Service"
|
||||
case cache = "Cache"
|
||||
case diagnostics = "Diagnostics"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
// section currently shown
|
||||
@State private var section: Section = .service
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("", selection: $section) {
|
||||
ForEach(Section.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
// pinned to the top, see IndexesTab
|
||||
Group {
|
||||
switch section {
|
||||
case .service: ServiceTab()
|
||||
case .cache: CacheTab()
|
||||
case .diagnostics: DiagnosticsTab()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Roots tab
|
||||
// ===========================
|
||||
@@ -112,7 +291,7 @@ private struct ExclusionsTab: View {
|
||||
.frame(minHeight: 180)
|
||||
|
||||
HStack {
|
||||
TextField("Path or tilde-expanded path", text: $newExclusion)
|
||||
TextField("Path, e.g. ~/Projects/build", text: $newExclusion)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Add") {
|
||||
let vTrim = newExclusion.trimmingCharacters(in: .whitespaces)
|
||||
@@ -137,6 +316,7 @@ private struct ExclusionsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
// presents an Open panel and adds the picked folders as exclusions
|
||||
private func chooseFolder() {
|
||||
let vPanel = NSOpenPanel()
|
||||
vPanel.canChooseDirectories = true
|
||||
@@ -149,6 +329,7 @@ private struct ExclusionsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
// removes the currently-selected exclusion
|
||||
private func removeSelected() {
|
||||
guard let vSel = selection else { return }
|
||||
prefs.excludedPaths.removeAll { $0 == vSel }
|
||||
@@ -210,7 +391,100 @@ private struct VolumesTab: View {
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: service tab
|
||||
// MARK: Performance tab
|
||||
// ===========================
|
||||
|
||||
// PerformanceTab sets how quickly file changes reach the index and the open
|
||||
// search results, per app state. Longer delays when Allofit isn't being
|
||||
// looked at keep it nearly idle in the background; focusing a window always
|
||||
// catches up at once.
|
||||
private struct PerformanceTab: View {
|
||||
|
||||
@EnvironmentObject var prefs: Preferences
|
||||
|
||||
// preset choices offered by each picker, in seconds
|
||||
private let kForegroundDelays: [Double] = [0.5, 1, 2, 3, 5, 10]
|
||||
private let kBackgroundDelays: [Double] = [3, 5, 10, 15, 30, 60, 120]
|
||||
private let kHiddenDelays: [Double] = [15, 30, 60, 120, 300, 600]
|
||||
private let kForegroundRefreshes: [Double] = [0.5, 1, 2, 3, 5]
|
||||
private let kBackgroundRefreshes: [Double] = [1, 3, 5, 10, 20, 30]
|
||||
// FSEvents' own delivery latency, added to the estimates
|
||||
private let kEventLatency: Double = 0.2
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Index updates")
|
||||
.font(.headline)
|
||||
Text("How long file changes are collected before they are applied to the index. Longer delays mean less work: repeated writes to the same file are checked only once.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Form {
|
||||
secondsPicker("While Allofit is focused", inChoices: kForegroundDelays, inValue: $prefs.updateDelayForeground)
|
||||
secondsPicker("While another app is focused", inChoices: kBackgroundDelays, inValue: $prefs.updateDelayBackground)
|
||||
secondsPicker("With no window visible", inChoices: kHiddenDelays, inValue: $prefs.updateDelayHidden)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Result refresh")
|
||||
.font(.headline)
|
||||
Text("Minimum time between two refreshes of the open search results when files change. Typing always searches immediately.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Form {
|
||||
secondsPicker("While Allofit is focused", inChoices: kForegroundRefreshes, inValue: $prefs.refreshIntervalForeground)
|
||||
secondsPicker("While another app is focused", inChoices: kBackgroundRefreshes, inValue: $prefs.refreshIntervalBackground)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text(summaryText)
|
||||
.font(.callout)
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Restore Defaults") { prefs.resetTimings() }
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// a picker over preset durations; a stored value outside the presets
|
||||
// (set by hand) is kept selectable instead of showing a blank picker
|
||||
private func secondsPicker(_ inLabel: String, inChoices: [Double], inValue: Binding<Double>) -> some View {
|
||||
let vChoices = Array(Set(inChoices + [inValue.wrappedValue])).sorted()
|
||||
return Picker(inLabel, selection: inValue) {
|
||||
ForEach(vChoices, id: \.self) { vSeconds in
|
||||
Text(Self.durationText(inSeconds: vSeconds)).tag(vSeconds)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 360)
|
||||
}
|
||||
|
||||
// estimated delay before a changed file appears in the results
|
||||
private var summaryText: String {
|
||||
let vForeground = kEventLatency + prefs.updateDelayForeground + prefs.refreshIntervalForeground
|
||||
let vBackground = kEventLatency + prefs.updateDelayBackground + prefs.refreshIntervalBackground
|
||||
return "A changed file shows up in the results after about \(Self.durationText(inSeconds: vForeground)) while Allofit is focused, and \(Self.durationText(inSeconds: vBackground)) while another app is. With no window visible nothing is refreshed; showing a window applies every pending change at once."
|
||||
}
|
||||
|
||||
// "0.5 s", "15 s", "1 min", "1 min 30 s"
|
||||
private static func durationText(inSeconds: Double) -> String {
|
||||
if inSeconds < 60 {
|
||||
let vRounded = (inSeconds * 10).rounded() / 10
|
||||
return vRounded == vRounded.rounded() ? "\(Int(vRounded)) s" : String(format: "%.1f s", vRounded)
|
||||
}
|
||||
let vMinutes = Int(inSeconds) / 60
|
||||
let vRest = Int(inSeconds) % 60
|
||||
return vRest == 0 ? "\(vMinutes) min" : "\(vMinutes) min \(vRest) s"
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// MARK: Service tab
|
||||
// ===========================
|
||||
|
||||
// ServiceTab manages the LaunchAgent / LaunchDaemon that keeps the index
|
||||
@@ -225,29 +499,53 @@ private struct ServiceTab: View {
|
||||
// status doesn't observe @Published changes since it reads file
|
||||
// system state directly, so we need an explicit refresh signal
|
||||
@State private var statusTick: Int = 0
|
||||
// mode picked in the radio group; only saved by a successful Install
|
||||
// (or Uninstall), so picking a mode alone never leaves the app waiting
|
||||
// on a service that doesn't exist
|
||||
@State private var draftMode: Preferences.ServiceMode?
|
||||
// confirmation for Uninstall
|
||||
@State private var confirmUninstall = false
|
||||
|
||||
// the mode shown and acted on: the draft, else the saved mode
|
||||
private var chosenMode: Preferences.ServiceMode {
|
||||
return draftMode ?? prefs.serviceMode
|
||||
}
|
||||
|
||||
// launchd scope of the chosen mode, nil for Off
|
||||
private var scope: ServiceInstaller.Scope? {
|
||||
switch prefs.serviceMode {
|
||||
switch chosenMode {
|
||||
case .none: return nil
|
||||
case .userAgent: return .userAgent
|
||||
case .rootDaemon: return .rootDaemon
|
||||
}
|
||||
}
|
||||
|
||||
// the radio group writes the draft, not the preference
|
||||
private var modeBinding: Binding<Preferences.ServiceMode> {
|
||||
Binding(get: { chosenMode }, set: { draftMode = $0 })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Background service")
|
||||
.font(.headline)
|
||||
|
||||
Picker("Mode", selection: $prefs.serviceMode) {
|
||||
Picker("Mode", selection: modeBinding) {
|
||||
Text("Off (GUI maintains the index)").tag(Preferences.ServiceMode.none)
|
||||
Text("User service (LaunchAgent, no admin needed)").tag(Preferences.ServiceMode.userAgent)
|
||||
Text("System service (LaunchDaemon as root, scans everything)").tag(Preferences.ServiceMode.rootDaemon)
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
|
||||
if prefs.serviceMode != .none && model.activeServiceMode == .none {
|
||||
Text("The saved service isn't installed, so Allofit is indexing on its own. Install it again or choose Off.")
|
||||
.font(.callout)
|
||||
.foregroundColor(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Group {
|
||||
switch prefs.serviceMode {
|
||||
switch chosenMode {
|
||||
case .none:
|
||||
Text("The GUI process indexes and saves the cache itself.")
|
||||
.foregroundColor(.secondary)
|
||||
@@ -310,7 +608,7 @@ private struct ServiceTab: View {
|
||||
Text("• Prompt once for your administrator password (steps run as one privileged script).")
|
||||
.font(.caption)
|
||||
}
|
||||
Text("Reinstall any time you rebuild Allofit.app - the on-disk copy doesn't auto-update.")
|
||||
Text("Reinstall after updating Allofit: the service runs its own copy of the app, which doesn't update by itself.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 2)
|
||||
@@ -330,23 +628,38 @@ private struct ServiceTab: View {
|
||||
_ = statusTick // re-read the file-system status on each tick
|
||||
return HStack {
|
||||
Button("Install") {
|
||||
Task { await model.performInstallService() }
|
||||
let vMode = chosenMode
|
||||
Task {
|
||||
await model.performInstallService(inMode: vMode)
|
||||
draftMode = nil
|
||||
}
|
||||
.disabled(prefs.serviceMode == .none || model.isWorking)
|
||||
Button("Uninstall") {
|
||||
Task { await model.performUninstallService() }
|
||||
}
|
||||
.disabled(prefs.serviceMode == .none || !vInstalled || model.isWorking)
|
||||
.disabled(chosenMode == .none || model.isWorking)
|
||||
Button("Uninstall…") { confirmUninstall = true }
|
||||
.disabled(chosenMode == .none || !vInstalled || model.isWorking)
|
||||
Button("Stop") {
|
||||
Task { await model.performStopService() }
|
||||
let vMode = chosenMode
|
||||
Task { await model.performStopService(inMode: vMode) }
|
||||
}
|
||||
.disabled(!vInstalled || !vRunning || model.isWorking)
|
||||
Button("Start") {
|
||||
Task { await model.performStartService() }
|
||||
let vMode = chosenMode
|
||||
Task { await model.performStartService(inMode: vMode) }
|
||||
}
|
||||
.disabled(!vInstalled || vRunning || model.isWorking)
|
||||
Spacer()
|
||||
}
|
||||
.confirmationDialog("Uninstall the background service?", isPresented: $confirmUninstall) {
|
||||
Button("Uninstall", role: .destructive) {
|
||||
let vMode = chosenMode
|
||||
Task {
|
||||
await model.performUninstallService(inMode: vMode)
|
||||
draftMode = nil
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Allofit goes back to indexing on its own while it's open.")
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -428,6 +741,8 @@ private struct CacheTab: View {
|
||||
|
||||
@EnvironmentObject var prefs: Preferences
|
||||
@EnvironmentObject var model: AppModel
|
||||
// confirmation for Clear Cache
|
||||
@State private var confirmClear = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
@@ -446,7 +761,7 @@ private struct CacheTab: View {
|
||||
.foregroundColor(cacheFileExists() ? .primary : .red)
|
||||
}
|
||||
LabeledContent("Size on disk") {
|
||||
Text(formatSize(inBytes: IndexStore.cacheFileSize(at: currentCacheURL())))
|
||||
Text(Formatters.sizeOrDash(bytes: IndexStore.cacheFileSize(at: currentCacheURL())))
|
||||
.font(.callout)
|
||||
.monospacedDigit()
|
||||
}
|
||||
@@ -485,10 +800,17 @@ private struct CacheTab: View {
|
||||
if model.isWorking {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Button("Clear Cache", role: .destructive) {
|
||||
Task { await model.performClearCache() }
|
||||
Button("Clear Cache…", role: .destructive) {
|
||||
confirmClear = true
|
||||
}
|
||||
.disabled(model.isWorking)
|
||||
.confirmationDialog("Clear the index cache?", isPresented: $confirmClear) {
|
||||
Button("Clear and Rebuild", role: .destructive) {
|
||||
Task { await model.performClearCache() }
|
||||
}
|
||||
} message: {
|
||||
Text("The whole index is rebuilt from scratch, which can take a few minutes.")
|
||||
}
|
||||
}
|
||||
|
||||
if !model.workMessage.isEmpty {
|
||||
@@ -525,14 +847,6 @@ private struct CacheTab: View {
|
||||
vFormatter.timeStyle = .medium
|
||||
return vFormatter.string(from: vMtime)
|
||||
}
|
||||
|
||||
// formats a byte count as a human-friendly short string
|
||||
private func formatSize(inBytes: Int64) -> String {
|
||||
if inBytes <= 0 { return "-" }
|
||||
let vF = ByteCountFormatter()
|
||||
vF.countStyle = .file
|
||||
return vF.string(fromByteCount: inBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -572,7 +886,7 @@ private struct DiagnosticsTab: View {
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
LabeledContent("Cache size") {
|
||||
Text(formatSize(IndexStore.cacheFileSize(at: IndexStore.cacheURL(forServiceMode: prefs.serviceMode))))
|
||||
Text(Formatters.sizeOrDash(bytes: IndexStore.cacheFileSize(at: IndexStore.cacheURL(forServiceMode: prefs.serviceMode))))
|
||||
.font(.callout)
|
||||
.monospacedDigit()
|
||||
}
|
||||
@@ -584,7 +898,7 @@ private struct DiagnosticsTab: View {
|
||||
LabeledContent("Indexer lock holder") {
|
||||
Text(daemonStatus)
|
||||
.font(.callout)
|
||||
.foregroundColor(daemonStatus.contains("running") ? .primary : .red)
|
||||
.foregroundColor(daemonStatus.hasPrefix("running") ? .primary : .red)
|
||||
}
|
||||
LabeledContent("Service plist") {
|
||||
Text(servicePlistStatus())
|
||||
@@ -594,7 +908,7 @@ private struct DiagnosticsTab: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Service log tail (/tmp/allofit-service.err)")
|
||||
Text("Service log tail (\(DiagnosticsTab.logPath(inMode: prefs.serviceMode) ?? "no service"))")
|
||||
.font(.subheadline)
|
||||
ScrollView {
|
||||
Text(serviceLogTail)
|
||||
@@ -609,8 +923,12 @@ private struct DiagnosticsTab: View {
|
||||
HStack {
|
||||
Button("Refresh now") { refresh() }
|
||||
Button("Open log") {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: "/tmp/allofit-service.err"))
|
||||
// revealed rather than opened: Finder never runs it
|
||||
if let vPath = DiagnosticsTab.logPath(inMode: prefs.serviceMode) {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: vPath)])
|
||||
}
|
||||
}
|
||||
.disabled(DiagnosticsTab.logPath(inMode: prefs.serviceMode) == nil)
|
||||
Spacer()
|
||||
Text("auto-refresh every 2s")
|
||||
.font(.caption)
|
||||
@@ -627,7 +945,7 @@ private struct DiagnosticsTab: View {
|
||||
private func refresh() {
|
||||
Task.detached {
|
||||
let vStatus = await DiagnosticsTab.computeDaemonStatus(inMode: Preferences.shared.serviceMode)
|
||||
let vLog = DiagnosticsTab.readLogTail()
|
||||
let vLog = DiagnosticsTab.readLogTail(inPath: DiagnosticsTab.logPath(inMode: Preferences.shared.serviceMode))
|
||||
await MainActor.run {
|
||||
self.daemonStatus = vStatus
|
||||
self.serviceLogTail = vLog
|
||||
@@ -665,9 +983,18 @@ private struct DiagnosticsTab: View {
|
||||
return "stale lock (pid \(vPid) not running)"
|
||||
}
|
||||
|
||||
// reads the last 30 lines of the service stderr log file
|
||||
private nonisolated static func readLogTail() -> String {
|
||||
let vPath = "/tmp/allofit-service.err"
|
||||
// log file of the service for a mode, nil when no service is used
|
||||
nonisolated static func logPath(inMode: Preferences.ServiceMode) -> String? {
|
||||
switch inMode {
|
||||
case .none: return nil
|
||||
case .userAgent: return ServiceInstaller.logPath(inScope: .userAgent)
|
||||
case .rootDaemon: return ServiceInstaller.logPath(inScope: .rootDaemon)
|
||||
}
|
||||
}
|
||||
|
||||
// reads the last 30 lines of the service log file
|
||||
private nonisolated static func readLogTail(inPath: String?) -> String {
|
||||
guard let vPath = inPath else { return "(no background service: Allofit indexes in its own process)" }
|
||||
guard let vData = try? Data(contentsOf: URL(fileURLWithPath: vPath)),
|
||||
let vText = String(data: vData, encoding: .utf8)
|
||||
else {
|
||||
@@ -697,14 +1024,6 @@ private struct DiagnosticsTab: View {
|
||||
return vF.string(from: vMtime)
|
||||
}
|
||||
|
||||
// formats a byte count as a human-friendly short string
|
||||
private func formatSize(_ inBytes: Int64) -> String {
|
||||
if inBytes <= 0 { return "-" }
|
||||
let vF = ByteCountFormatter()
|
||||
vF.countStyle = .file
|
||||
return vF.string(fromByteCount: inBytes)
|
||||
}
|
||||
|
||||
// prominent reminder + shortcuts to enable Full Disk Access for the
|
||||
// root daemon's binary. Without FDA, the initial scan still works
|
||||
// (root has direct filesystem access) but FSEvents will not deliver
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,21 @@ final class WindowSearchModel: ObservableObject {
|
||||
scheduleFilter(inDelay: kTypingDebounceSeconds)
|
||||
}
|
||||
}
|
||||
// the filtered, sorted and capped records currently shown in the table
|
||||
@Published private(set) var visibleRecords: [FileRecord] = []
|
||||
// total number of records matching the query (may exceed visibleRecords)
|
||||
@Published private(set) var matchCount: Int = 0
|
||||
// the category filter chosen in the menu next to the search field
|
||||
@Published var filter: SearchFilter = .everything {
|
||||
didSet { scheduleFilter(inDelay: kTypingDebounceSeconds) }
|
||||
}
|
||||
// every record matching the query, sorted (the table shows them all;
|
||||
// only the rows on screen get views)
|
||||
@Published private(set) var results = ResultList()
|
||||
// bumped on every change of results, so the table can tell a new list
|
||||
// from the same one without comparing hundreds of thousands of records
|
||||
@Published private(set) var resultsVersion = 0
|
||||
// what to emphasize in the shown results (always matches results)
|
||||
@Published private(set) var highlights = SearchEngine.Highlights()
|
||||
// match count and timing for the status bar (observed separately so
|
||||
// their updates don't re-render the table)
|
||||
let stats = SearchStats()
|
||||
|
||||
// strong ref to the shared index; the per-window WindowSearchModel does
|
||||
// not outlive its window, so the shared model has a longer lifetime
|
||||
@@ -42,15 +53,31 @@ final class WindowSearchModel: ObservableObject {
|
||||
// pending filter+sort task, cancelled if a newer one supersedes it
|
||||
private var filterTask: Task<Void, Never>?
|
||||
|
||||
// maximum rows handed to SwiftUI Table for snappy scrolling
|
||||
private let kMaxVisibleRows = 2000
|
||||
// rows published first when a query matches more than this: the top of
|
||||
// the list appears in milliseconds, the complete sort follows (a full
|
||||
// sort of 600k entries takes ~0.2 s)
|
||||
private nonisolated static let kQuickRows = 2000
|
||||
// above this many results, skip the "same list" comparison (it would
|
||||
// cost more than just reloading the visible rows)
|
||||
private nonisolated static let kCompareLimit = 5000
|
||||
// delay after a keystroke / sort click: just enough to merge key repeats
|
||||
private let kTypingDebounceSeconds: Double = 0.04
|
||||
// delay after an index change: merges bursts of FSEvents updates
|
||||
private let kIndexDebounceSeconds: Double = 0.3
|
||||
// records per parallel filter chunk
|
||||
private nonisolated static let kChunkSize = 16_384
|
||||
// true while this model's window is on screen (not closed, minimized
|
||||
// or fully covered)
|
||||
private var isWindowVisible = true
|
||||
// true while the user holds the freeze key (⌥ Option)
|
||||
private var isFrozen = false
|
||||
// true when the index changed while the window was not visible or
|
||||
// the list was frozen
|
||||
private var isStale = false
|
||||
// true while a throttled index refilter is waiting to run
|
||||
private var isIndexRefreshScheduled = false
|
||||
// when the last index-driven refilter started
|
||||
private var lastIndexRefreshAt = Date.distantPast
|
||||
|
||||
// subscribes to index changes and runs the first filter
|
||||
init(model inModel: AppModel) {
|
||||
self.model = inModel
|
||||
self.sortDescriptor = Preferences.shared.lastSort
|
||||
@@ -59,28 +86,126 @@ final class WindowSearchModel: ObservableObject {
|
||||
// burst of updates never queues up copies of the index.
|
||||
inModel.recordsChanged
|
||||
.sink { [weak self] in
|
||||
self?.scheduleFilter(inDelay: self?.kIndexDebounceSeconds ?? 0.3)
|
||||
self?.indexDidChange()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
// holding ⌥ freezes the list; releasing it catches up once
|
||||
FreezeKey.shared.$isHeld
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] vHeld in
|
||||
self?.setFrozen(vHeld)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
scheduleFilter(inDelay: 0)
|
||||
}
|
||||
|
||||
// reported by the window: refilters on reappearance if the index
|
||||
// changed while the window was off screen
|
||||
func setWindowVisible(_ inVisible: Bool) {
|
||||
isWindowVisible = inVisible
|
||||
if inVisible && isStale && !isFrozen {
|
||||
isStale = false
|
||||
scheduleFilter(inDelay: 0)
|
||||
}
|
||||
}
|
||||
|
||||
// freeze key pressed / released: while frozen, index changes don't
|
||||
// touch the list; on release it is refreshed once, keeping the scroll
|
||||
// position (no quick partial list)
|
||||
private func setFrozen(_ inFrozen: Bool) {
|
||||
isFrozen = inFrozen
|
||||
if !inFrozen && isStale && isWindowVisible {
|
||||
isStale = false
|
||||
scheduleFilter(inDelay: 0, inProgressive: false)
|
||||
}
|
||||
}
|
||||
|
||||
// drops trashed records: their rows at once, and from the shared index
|
||||
// (FSEvents doesn't report this process's own file operations)
|
||||
func removeTrashed(inRecords: [FileRecord]) {
|
||||
let vIds = Set(inRecords.map(\.id))
|
||||
results = results.removing(inIds: vIds)
|
||||
resultsVersion &+= 1
|
||||
stats.matchCount = results.count
|
||||
model.forget(inPaths: inRecords.map(\.fullPath))
|
||||
}
|
||||
|
||||
// publishes a result list (and its count / highlights) on main
|
||||
private func publish(inResults: ResultList,
|
||||
inCount: Int,
|
||||
inHighlights: SearchEngine.Highlights,
|
||||
inMilliseconds: Double?) {
|
||||
if stats.matchCount != inCount { stats.matchCount = inCount }
|
||||
if let vMs = inMilliseconds { stats.lastSearchMilliseconds = vMs }
|
||||
if highlights != inHighlights { highlights = inHighlights }
|
||||
// skip a no-op reassignment of a small identical list (full record
|
||||
// equality also catches size / date updates)
|
||||
if inResults.count <= Self.kCompareLimit && results.count == inResults.count && results.elementsEqual(inResults) {
|
||||
return
|
||||
}
|
||||
results = inResults
|
||||
resultsVersion &+= 1
|
||||
}
|
||||
|
||||
// files put back from the Trash (undo): adds them to the index again
|
||||
func noticeRestored(inPaths: [String]) {
|
||||
model.notice(inPaths: inPaths)
|
||||
}
|
||||
|
||||
// throttled reaction to an index change: nothing while off screen,
|
||||
// otherwise at most one refilter per refresh interval. The
|
||||
// refilter reads the newest records when it runs, so skipped
|
||||
// notifications lose nothing.
|
||||
private func indexDidChange() {
|
||||
guard isWindowVisible && !isFrozen else {
|
||||
isStale = true
|
||||
return
|
||||
}
|
||||
if isIndexRefreshScheduled { return }
|
||||
isIndexRefreshScheduled = true
|
||||
let vSinceLast = Date().timeIntervalSince(lastIndexRefreshAt)
|
||||
// minimum gap between two index-driven refilters (Settings >
|
||||
// Performance). Typing is unaffected; this caps the cost of an
|
||||
// expensive query left open while files change constantly.
|
||||
let vPrefs = Preferences.shared
|
||||
let vInterval = AppActivity.shared.level == .foreground
|
||||
? vPrefs.refreshIntervalForeground
|
||||
: vPrefs.refreshIntervalBackground
|
||||
let vWait = max(kIndexDebounceSeconds, vInterval - vSinceLast)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + vWait) { [weak self] in
|
||||
guard let vSelf = self else { return }
|
||||
vSelf.isIndexRefreshScheduled = false
|
||||
guard vSelf.isWindowVisible && !vSelf.isFrozen else {
|
||||
vSelf.isStale = true
|
||||
return
|
||||
}
|
||||
vSelf.lastIndexRefreshAt = Date()
|
||||
// no quick partial list here: it would briefly cut the list to
|
||||
// its first rows and lose the scroll position of a user
|
||||
// browsing further down
|
||||
vSelf.scheduleFilter(inDelay: 0, inProgressive: false)
|
||||
}
|
||||
}
|
||||
|
||||
// debounces filter rebuilds so we don't refilter on every keystroke
|
||||
// or every FSEvents batch. The latest call wins: earlier pending
|
||||
// tasks are cancelled before they start (or between chunks).
|
||||
private func scheduleFilter(inDelay: Double) {
|
||||
// inProgressive publishes the first rows before the complete sort
|
||||
// when there are many results (typing, sort and filter changes).
|
||||
private func scheduleFilter(inDelay: Double, inProgressive: Bool = true) {
|
||||
filterTask?.cancel()
|
||||
// snapshot inputs on main; the detached task is self-contained
|
||||
let vQuery = query
|
||||
let vQuery = filter.apply(toQuery: query)
|
||||
let vHighlights = SearchEngine(inQuery: query).highlights
|
||||
let vSort = sortDescriptor
|
||||
let vRecords = model.allRecords
|
||||
let vMax = kMaxVisibleRows
|
||||
let vDelayNanos = UInt64(inDelay * 1_000_000_000)
|
||||
filterTask = Task.detached(priority: .userInitiated) { [weak self] in
|
||||
if vDelayNanos > 0 {
|
||||
try? await Task.sleep(nanoseconds: vDelayNanos)
|
||||
}
|
||||
if Task.isCancelled { return }
|
||||
let vStartedAt = DispatchTime.now()
|
||||
let vEngine = SearchEngine(inQuery: vQuery)
|
||||
// the chunks run on GCD worker threads, which can't see this
|
||||
// task's cancellation - relay it through a shared flag
|
||||
@@ -96,40 +221,53 @@ final class WindowSearchModel: ObservableObject {
|
||||
}
|
||||
guard let vPositions = vResult else { return }
|
||||
if Task.isCancelled { return }
|
||||
let vTop = ResultSorter.top(
|
||||
let vCount = vPositions.count
|
||||
// timing covers the filter plus the first displayable sort
|
||||
var vMilliseconds = Double(DispatchTime.now().uptimeNanoseconds - vStartedAt.uptimeNanoseconds) / 1_000_000
|
||||
if inProgressive && vCount > WindowSearchModel.kQuickRows {
|
||||
let vTop = ResultList(inStore: vRecords, inPositions: ResultSorter.topPositions(
|
||||
inRecords: vRecords,
|
||||
inPositions: vPositions,
|
||||
inLimit: vMax,
|
||||
inLimit: WindowSearchModel.kQuickRows,
|
||||
inDescriptor: vSort
|
||||
)
|
||||
))
|
||||
if Task.isCancelled { return }
|
||||
let vCount = vPositions.count
|
||||
vMilliseconds = Double(DispatchTime.now().uptimeNanoseconds - vStartedAt.uptimeNanoseconds) / 1_000_000
|
||||
let vQuickMs = vMilliseconds
|
||||
// hop back to main with DispatchQueue.main.async (rather than
|
||||
// await MainActor.run) so the assignment is guaranteed to land
|
||||
// on the next runloop tick, avoiding NSTableView reentrance when
|
||||
// the search field is mid-edit
|
||||
// await MainActor.run) so the assignment lands on the next
|
||||
// runloop tick, avoiding NSTableView reentrance when the
|
||||
// search field is mid-edit
|
||||
DispatchQueue.main.async {
|
||||
guard let vSelf = self else { return }
|
||||
if vSelf.matchCount != vCount { vSelf.matchCount = vCount }
|
||||
// Skip the @Published fire when the resulting list is
|
||||
// identical to what the Table is already showing. Full
|
||||
// FileRecord equality catches mtime / size updates, so we
|
||||
// only skip true no-op reassignments.
|
||||
if vSelf.visibleRecords == vTop { return }
|
||||
vSelf.visibleRecords = vTop
|
||||
self?.publish(inResults: vTop, inCount: vCount, inHighlights: vHighlights, inMilliseconds: vQuickMs)
|
||||
}
|
||||
}
|
||||
let vAll = ResultList(inStore: vRecords, inPositions: ResultSorter.topPositions(
|
||||
inRecords: vRecords,
|
||||
inPositions: vPositions,
|
||||
inLimit: vCount,
|
||||
inDescriptor: vSort
|
||||
))
|
||||
if Task.isCancelled { return }
|
||||
let vFinalMs: Double? = (inProgressive && vCount > WindowSearchModel.kQuickRows)
|
||||
? nil
|
||||
: Double(DispatchTime.now().uptimeNanoseconds - vStartedAt.uptimeNanoseconds) / 1_000_000
|
||||
DispatchQueue.main.async {
|
||||
self?.publish(inResults: vAll, inCount: vCount, inHighlights: vHighlights, inMilliseconds: vFinalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// positions of the records matching the engine, computed in parallel
|
||||
// chunks. Returns nil if the task was cancelled midway.
|
||||
private nonisolated static func matchingPositions(inRecords: [FileRecord],
|
||||
private nonisolated static func matchingPositions(inRecords: RecordStore,
|
||||
inEngine: SearchEngine,
|
||||
inCancelled: ManagedAtomicFlag) -> [Int32]? {
|
||||
if !inEngine.isActive {
|
||||
return (0..<Int32(inRecords.count)).map { $0 }
|
||||
}
|
||||
let vChunkCount = (inRecords.count + kChunkSize - 1) / kChunkSize
|
||||
// one parallel job per store chunk, reading its records directly
|
||||
let vChunkCount = inRecords.chunkCount
|
||||
if vChunkCount == 0 { return [] }
|
||||
var vChunks = [[Int32]](repeating: [], count: vChunkCount)
|
||||
vChunks.withUnsafeMutableBufferPointer { vOut in
|
||||
@@ -137,11 +275,11 @@ final class WindowSearchModel: ObservableObject {
|
||||
nonisolated(unsafe) let vOutBase = vOut.baseAddress!
|
||||
DispatchQueue.concurrentPerform(iterations: vChunkCount) { vChunk in
|
||||
if inCancelled.isSet { return }
|
||||
let vStart = vChunk * kChunkSize
|
||||
let vEnd = min(vStart + kChunkSize, inRecords.count)
|
||||
let vRecords = inRecords.chunk(at: vChunk)
|
||||
let vBase = Int32(vChunk << RecordStore.kShift)
|
||||
var vLocal: [Int32] = []
|
||||
for vI in vStart..<vEnd where inEngine.match(inRecord: inRecords[vI]) {
|
||||
vLocal.append(Int32(vI))
|
||||
for (vI, vRecord) in vRecords.enumerated() where inEngine.match(inRecord: vRecord) {
|
||||
vLocal.append(vBase + Int32(vI))
|
||||
}
|
||||
(vOutBase + vChunk).pointee = vLocal
|
||||
}
|
||||
|
||||
@@ -89,7 +89,8 @@ struct IndexStateTests {
|
||||
]
|
||||
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
|
||||
#expect(vResult.changed)
|
||||
#expect(vResult.rescans == ["/r/moved"])
|
||||
#expect(vResult.newFolders == ["/r/moved"])
|
||||
#expect(vResult.rescans.isEmpty)
|
||||
#expect(vState.contains(inPath: "/r/a/new.txt"))
|
||||
#expect(!vState.contains(inPath: "/r/hidden/x.txt"))
|
||||
#expect(vState.contains(inPath: "/r/moved/inside.txt"))
|
||||
@@ -102,7 +103,45 @@ struct IndexStateTests {
|
||||
vChanges.upserts = [Self.dir("/r/renamed")]
|
||||
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
|
||||
#expect(!vState.contains(inPath: "/r/a/b/2.txt"))
|
||||
#expect(vResult.rescans == ["/r/renamed"])
|
||||
#expect(vResult.newFolders == ["/r/renamed"])
|
||||
}
|
||||
|
||||
@Test func newFolderInsideKernelRescanIsWalkedOnce() {
|
||||
var vState = Self.sampleState()
|
||||
var vChanges = ResolvedChanges()
|
||||
vChanges.upserts = [Self.dir("/r/a/fresh")]
|
||||
vChanges.rescans = ["/r/a"]
|
||||
let vResult = vState.apply(inChanges: vChanges, inRoots: ["/r"])
|
||||
#expect(vResult.rescans == ["/r/a"])
|
||||
#expect(vResult.newFolders.isEmpty)
|
||||
// merging a new folder's walk only upserts (no removal pass)
|
||||
vState.mergeNewFolders(inRecords: [Self.dir("/r/a/fresh"), Self.file("/r/a/fresh/x.txt")])
|
||||
#expect(vState.contains(inPath: "/r/a/fresh/x.txt"))
|
||||
#expect(vState.contains(inPath: "/r/a/b/2.txt"))
|
||||
}
|
||||
|
||||
@Test func fullSortMatchesComparatorForNumericKeys() {
|
||||
let vRecords = (0..<3000).map { Self.file("/r/f\($0)", size: Int64(($0 * 7919) % 97)) }
|
||||
let vPositions = (0..<Int32(vRecords.count)).map { $0 }
|
||||
for vSort in [FileSortDescriptor.sizeAscending, .sizeDescending] {
|
||||
let vFull = ResultSorter.topPositions(inRecords: vRecords, inPositions: vPositions, inLimit: vRecords.count, inDescriptor: vSort)
|
||||
let vHeap = ResultSorter.topPositions(inRecords: vRecords, inPositions: vPositions, inLimit: 100, inDescriptor: vSort)
|
||||
#expect(Array(vFull.prefix(100)) == vHeap, "sort \(vSort.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func caseOnlyRenameDropsTheOldSpelling() throws {
|
||||
let vDir = FileManager.default.temporaryDirectory.appendingPathComponent("allofit-case-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: vDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: vDir) }
|
||||
let vOld = vDir.appendingPathComponent("Report.txt")
|
||||
let vNew = vDir.appendingPathComponent("report.txt")
|
||||
try Data("x".utf8).write(to: vOld)
|
||||
try FileManager.default.moveItem(at: vOld, to: vNew)
|
||||
// the old spelling still opens on a case-insensitive volume, but must
|
||||
// not be re-added as a separate entry
|
||||
#expect(FileIndexer.makeRecord(inURL: vOld, inSkipHidden: true) == nil)
|
||||
#expect(FileIndexer.makeRecord(inURL: vNew, inSkipHidden: true)?.name == "report.txt")
|
||||
}
|
||||
|
||||
@Test func unchangedUpsertIsNotAChange() {
|
||||
@@ -153,6 +192,33 @@ struct IndexStateTests {
|
||||
#expect(Self.paths(vState) == ["/r", "/r/a", "/r/a/fresh.txt", "/r/c.txt"])
|
||||
}
|
||||
|
||||
@Test func rootsAreCanonicalized() {
|
||||
// /tmp is a symlink: FSEvents and the walker report /private/tmp
|
||||
#expect(VolumeManager.canonicalPath(inPath: "/tmp") == "/private/tmp")
|
||||
#expect(VolumeManager.canonicalPath(inPath: "/Users/") == "/Users")
|
||||
#expect(VolumeManager.canonicalPath(inPath: "/no/such/root/") == "/no/such/root")
|
||||
}
|
||||
|
||||
@Test func recordStoreChunksAndCopiesOnWrite() {
|
||||
var vStore = RecordStore()
|
||||
let vTotal = RecordStore.kChunkSize * 2 + 10
|
||||
for vI in 0..<vTotal { vStore.append(Self.file("/r/f\(vI)")) }
|
||||
#expect(vStore.count == vTotal)
|
||||
#expect(vStore[RecordStore.kChunkSize].name == "f\(RecordStore.kChunkSize)")
|
||||
// a snapshot keeps its values when the original changes
|
||||
let vSnapshot = vStore
|
||||
vStore[5] = Self.file("/r/changed")
|
||||
#expect(vSnapshot[5].name == "f5")
|
||||
#expect(vStore[5].name == "changed")
|
||||
for _ in 0..<11 { vStore.removeLast() }
|
||||
#expect(vStore.count == RecordStore.kChunkSize * 2 - 1)
|
||||
#expect(vStore.last?.name == "f\(RecordStore.kChunkSize * 2 - 2)")
|
||||
// result lists read through the snapshot
|
||||
let vList = ResultList(inStore: vSnapshot, inPositions: [3, 1])
|
||||
#expect(vList.map(\.name) == ["f3", "f1"])
|
||||
#expect(vList.removing(inIds: [vSnapshot[3].id]).map(\.name) == ["f1"])
|
||||
}
|
||||
|
||||
@Test func minimalRootsDropsNestedPaths() {
|
||||
let vRoots = SubtreeMatcher.minimalRoots(inPaths: ["/a/b", "/a", "/a b/c", "/a", "/c"])
|
||||
#expect(vRoots.sorted() == ["/a", "/a b/c", "/c"])
|
||||
@@ -172,7 +238,7 @@ struct IndexStateTests {
|
||||
let vUrl = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("allofit-test-\(UUID().uuidString).bin")
|
||||
defer { try? FileManager.default.removeItem(at: vUrl) }
|
||||
let vRecords = Self.sampleState().records
|
||||
let vRecords = Array(Self.sampleState().records)
|
||||
IndexStore.save(inRecords: vRecords, inLastEventId: 42, to: vUrl)
|
||||
let vLoaded = try #require(IndexStore.load(from: vUrl))
|
||||
#expect(vLoaded.lastEventId == 42)
|
||||
|
||||
@@ -104,4 +104,19 @@ struct SearchEngineTests {
|
||||
#expect(Self.matches("RÉSUMÉ", "/d/" + vDecomposed))
|
||||
#expect(Self.matches("r?sum?.pdf", "/d/" + vDecomposed))
|
||||
}
|
||||
|
||||
@Test func highlightsKeepLiteralRuns() {
|
||||
let vHigh = SearchEngine(inQuery: "IMG_????.heic !draft \"some/folder/**/path\" ext:pdf").highlights
|
||||
#expect(vHigh.name == ["IMG_", ".heic"])
|
||||
#expect(vHigh.path == ["some", "folder", "path"])
|
||||
}
|
||||
|
||||
@Test func filtersAreAndedWithTheQuery() {
|
||||
#expect(Self.matches(SearchFilter.pictures.apply(toQuery: "img"), "/d/img_1.HEIC"))
|
||||
#expect(!Self.matches(SearchFilter.pictures.apply(toQuery: "img"), "/d/img_1.txt"))
|
||||
#expect(Self.matches(SearchFilter.folders.apply(toQuery: ""), "/d/src", isDirectory: true))
|
||||
#expect(Self.matches(SearchFilter.applications.apply(toQuery: "xcode"), "/Applications/Xcode.app", isDirectory: true))
|
||||
#expect(!Self.matches(SearchFilter.applications.apply(toQuery: "xcode"), "/d/xcode.app.zip"))
|
||||
#expect(SearchFilter.everything.apply(toQuery: "a b") == "a b")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user