mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-06 21:07:27 +00:00
- Hover overlay: bounds-check the hovered row / column before asking the table for a cell view (NSTableView raised on a row removed by a reload, aborting the app), and refresh after the update pass instead of inside SwiftUI's updateNSView / constraints pass. - Status bar details use a click-through hover panel (0.5 s, updated live) instead of .help, whose delay restarted at every refresh. - First walks of root folders are announced: status bar "Indexing <folder>… N found" and per-folder status in Settings > Indexes > Folders, plus "Not available" for unreachable roots. - New roots and folders stream into the index every 3 s while walked, on a dedicated queue so long walks never delay FSEvents updates. - A new folder's own entry is merged only when its walk completes, so an interrupted walk is redone on the next launch or replay. - CHANGELOG: 1.0.10.
428 lines
15 KiB
Swift
428 lines
15 KiB
Swift
import SwiftUI
|
|
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), 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 each time.
|
|
// StatusBarView observes it instead.
|
|
@EnvironmentObject var prefs: Preferences
|
|
@EnvironmentObject var access: AccessManager
|
|
// 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
|
|
// 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
|
|
// search syntax popover
|
|
@State private var showSyntaxHelp = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if showPreviewPane {
|
|
HSplitView {
|
|
mainColumn
|
|
.layoutPriority(1)
|
|
.frame(minWidth: 460)
|
|
PreviewPane(selection: selection)
|
|
.frame(minWidth: 200, idealWidth: 360)
|
|
}
|
|
} else {
|
|
mainColumn
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
showPreviewPane.toggle()
|
|
} label: {
|
|
// 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 {
|
|
Label("Settings", systemImage: "gearshape")
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
.help("Settings (⌘,)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// search bar + results + status bar - everything except the preview pane
|
|
private var mainColumn: some View {
|
|
VStack(spacing: 0) {
|
|
searchBar
|
|
resultsTable
|
|
Divider()
|
|
// isolated so its refreshes don't re-evaluate this body
|
|
StatusBarView(stats: searchModel.stats, selectionText: selectionSummary)
|
|
}
|
|
}
|
|
|
|
// ===========================
|
|
// 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
|
|
// ===========================
|
|
|
|
// native table with every result (see ResultsTable)
|
|
private var resultsTable: some View {
|
|
ResultsTable(
|
|
records: searchModel.results,
|
|
version: searchModel.resultsVersion,
|
|
highlights: searchModel.highlights,
|
|
highlightEnabled: prefs.highlightMatches,
|
|
selection: $selection,
|
|
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)
|
|
}
|
|
}
|
|
|
|
// "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: Actions
|
|
// ===========================
|
|
|
|
// 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)
|
|
}
|
|
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.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) }
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================
|
|
// MARK: Status bar
|
|
// ===========================
|
|
|
|
// Extracted into its own View so its @Published-driven refreshes (cache
|
|
// 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) {
|
|
if model.isLoadingCache {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text("Loading index…")
|
|
} else if model.isIndexing {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text("Indexing… \(model.indexedCount.formatted()) entries")
|
|
} else {
|
|
Text(resultsText)
|
|
if let vSelection = selectionText {
|
|
Text("·")
|
|
Text(vSelection)
|
|
}
|
|
// a root folder being indexed for the first time
|
|
if !model.scans.isEmpty {
|
|
Text("·")
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(scanText)
|
|
.help(model.scans.map { $0.path }.joined(separator: "\n"))
|
|
}
|
|
}
|
|
Spacer()
|
|
if freeze.isHeld {
|
|
Label("Frozen", systemImage: "snowflake")
|
|
.foregroundColor(.accentColor)
|
|
.help("Holding ⌥ Option pauses list updates; release to catch up")
|
|
}
|
|
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()
|
|
// details after a short hover, updated live (see HoverDetailsAnchor)
|
|
.background(HoverDetailsAnchor(text: detailsText))
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
// "Indexing Spirtech2k… 12,345 found" (or "Indexing 2 folders…")
|
|
private var scanText: String {
|
|
let vFound = model.scans.reduce(0) { $0 + $1.found }.formatted()
|
|
if model.scans.count == 1, let vScan = model.scans.first {
|
|
return "Indexing \(vScan.name)… \(vFound) found"
|
|
}
|
|
return "Indexing \(model.scans.count) folders… \(vFound) found"
|
|
}
|
|
|
|
// "N results · M indexed"
|
|
private var resultsText: String {
|
|
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)
|
|
}
|
|
}
|
|
}
|