1 Commits
Author SHA1 Message Date
Bitsy 8b751b6ea4 Fix hover crash on list changes, show first-walk progress, stream new roots
- 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.
2026-10-06 10:09:47 +02:00
7 changed files with 312 additions and 20 deletions
+17
View File
@@ -2,6 +2,23 @@
All notable changes to Allofit are documented here. All notable changes to Allofit are documented here.
## [1.0.10] - 2026-10-06
### Fixed
- **Crash** when the results list changed while the pointer rested on a cut-off name or path (for example right after adding a folder): the hover overlay read a row that no longer existed.
- **Status bar details** appeared late or not at all, because the help tag restarted its delay at every refresh. They now show 0.5 s after hovering and update live.
### Added
- **Indexing progress for new folders:** while a folder is indexed for the first time, the status bar shows "Indexing <folder>… N found", and Settings → Indexes → Folders shows the progress next to it, or "Not available" when the folder can't be reached (an unmounted drive or share).
### Changed
- **Large new folders** (a network share, an external drive) show their files as they are found, every few seconds, instead of only at the end of the walk.
- **Walks no longer delay live updates:** reading a big new folder runs separately, so changes elsewhere keep appearing.
- **Interrupted walks are redone:** a new folder only counts as indexed once its walk is complete, so quitting mid-walk resumes it on the next launch instead of leaving it half indexed.
## [1.0.9] - 2026-10-02 ## [1.0.9] - 2026-10-02
### Added ### Added
+6 -1
View File
@@ -150,10 +150,15 @@ struct IndexState {
let vIsRoot = inRoots.contains(vRecord.fullPath) let vIsRoot = inRoots.contains(vRecord.fullPath)
guard vIsRoot || contains(inPath: vRecord.parentPath) else { continue } guard vIsRoot || contains(inPath: vRecord.parentPath) else { continue }
let vIsNew = positions[vRecord.id] == nil let vIsNew = positions[vRecord.id] == nil
if upsert(vRecord) { vChanged = true } // a folder that just appeared is not added here: its walk adds
// it (with its contents) once complete. Until then it looks
// missing, so an interrupted walk is redone on the next launch
// or replay instead of leaving a folder with partial contents.
if vIsNew && vRecord.isDirectory { if vIsNew && vRecord.isDirectory {
vNewFolders.append(vRecord.fullPath) vNewFolders.append(vRecord.fullPath)
continue
} }
if upsert(vRecord) { vChanged = true }
} }
// kernel rescans only for subtrees that belong to the index // kernel rescans only for subtrees that belong to the index
+89 -13
View File
@@ -123,6 +123,22 @@ final class AppModel: ObservableObject {
@Published private(set) var isLoadingCache: Bool = false @Published private(set) var isLoadingCache: Bool = false
// true when this process owns the index (got the lock or built-in mode) // true when this process owns the index (got the lock or built-in mode)
@Published private(set) var isIndexer: Bool = false @Published private(set) var isIndexer: Bool = false
// root folders being indexed for the first time (or re-read entirely),
// with how many entries were found so far: shown in the status bar and
// next to the folder in Settings
@Published private(set) var scans: [ScanProgress] = []
// one announced walk of a root folder
struct ScanProgress: Identifiable, Equatable {
// canonical path of the folder being walked
let path: String
// entries found so far
var found: Int
// identity for SwiftUI lists
var id: String { path }
// folder name for display ("Spirtech2k")
var name: String { (path as NSString).lastPathComponent }
}
// how often the index is written to disk while running (seconds) // how often the index is written to disk while running (seconds)
private let kAutosaveSeconds: TimeInterval = 30 private let kAutosaveSeconds: TimeInterval = 30
@@ -133,6 +149,12 @@ final class AppModel: ObservableObject {
// background queues isolated by concern - keeps the slow stuff off main // background queues isolated by concern - keeps the slow stuff off main
private let indexQueue = DispatchQueue(label: "allofit.index", qos: .utility) private let indexQueue = DispatchQueue(label: "allofit.index", qos: .utility)
private let ioQueue = DispatchQueue(label: "allofit.io", qos: .utility) private let ioQueue = DispatchQueue(label: "allofit.io", qos: .utility)
// subtree walks (new roots, new or rescanned folders): separate from
// indexQueue so a long walk never delays live updates
private let walkQueue = DispatchQueue(label: "allofit.walk", qos: .utility)
// while walking a new root or folder, what's found so far is added to
// the index this often
private let kWalkStreamSeconds: TimeInterval = 3
// watcher used in indexer mode for live updates // watcher used in indexer mode for live updates
private let watcher = FileWatcher() private let watcher = FileWatcher()
// watcher used in reader mode to detect cache file refreshes // watcher used in reader mode to detect cache file refreshes
@@ -581,14 +603,15 @@ final class AppModel: ObservableObject {
lastEventId = vCurrentId lastEventId = vCurrentId
startWatching(inSinceWhen: vCurrentId) startWatching(inSinceWhen: vCurrentId)
// floor 0: an interrupted walk makes the next launch start over // floor 0: an interrupted walk makes the next launch start over
kickRescanSubtrees(inPaths: vRoots, inFloorEventId: 0) kickRescanSubtrees(inPaths: vRoots, inFloorEventId: 0, inAnnounce: true)
return return
} }
startWatching(inSinceWhen: lastEventId) startWatching(inSinceWhen: lastEventId)
// roots added since the cache was written have never been walked // roots added since the cache was written have never been walked
let vMissing = vRoots.filter { !index.contains(inPath: $0) } let vMissing = vRoots.filter { !index.contains(inPath: $0) }
if !vMissing.isEmpty { if !vMissing.isEmpty {
kickRescanSubtrees(inPaths: vMissing, inFloorEventId: lastEventId) // nothing below a missing root is indexed: stream it in
kickRescanSubtrees(inPaths: vMissing, inFloorEventId: lastEventId, inNewFolders: true, inAnnounce: true)
} }
} }
@@ -700,44 +723,97 @@ final class AppModel: ObservableObject {
// inFloorEventId is saved instead of lastEventId until the walk is // inFloorEventId is saved instead of lastEventId until the walk is
// merged. inNewFolders: the folders just appeared, so their records are // merged. inNewFolders: the folders just appeared, so their records are
// merged without the O(n) removal pass of a kernel-requested rescan. // merged without the O(n) removal pass of a kernel-requested rescan.
private func kickRescanSubtrees(inPaths: [String], inFloorEventId: UInt64, inNewFolders: Bool = false) { // inAnnounce lists the walk in `scans` (root folders only: the small
// folders appearing all the time would make the status bar flicker)
private func kickRescanSubtrees(inPaths: [String], inFloorEventId: UInt64, inNewFolders: Bool = false, inAnnounce: Bool = false) {
let vRoots = SubtreeMatcher.minimalRoots(inPaths: inPaths) let vRoots = SubtreeMatcher.minimalRoots(inPaths: inPaths)
guard !vRoots.isEmpty else { return } guard !vRoots.isEmpty else { return }
if inAnnounce {
for vRoot in vRoots where !scans.contains(where: { $0.path == vRoot }) {
scans.append(ScanProgress(path: vRoot, found: 0))
}
}
let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths) let vMatcher = ExclusionMatcher(inExclusions: prefs.excludedPaths)
let vGeneration = indexGeneration let vGeneration = indexGeneration
rescanFloorEventId = min(rescanFloorEventId ?? inFloorEventId, inFloorEventId) rescanFloorEventId = min(rescanFloorEventId ?? inFloorEventId, inFloorEventId)
rescansInFlight += 1 rescansInFlight += 1
indexQueue.async { [weak self] in let vStreamSeconds = kWalkStreamSeconds
// own queue: a long walk (a whole network share) must not hold up
// the FSEvents updates resolved on indexQueue
walkQueue.async { [weak self] in
var vRecords: [FileRecord] = [] var vRecords: [FileRecord] = []
var vLastStream = Date()
var vLastProgress = Date()
// the walked folders' own entries are only merged at the very end,
// so an interrupted walk leaves them "missing" and gets redone
let vRootIds = Set(vRoots.map { FileRecord.pathHash($0) })
var vHeldRoots: [FileRecord] = []
for vPath in vRoots { for vPath in vRoots {
var vFound = 0
// per-subtree autoreleasepool keeps the walk's autoreleased // per-subtree autoreleasepool keeps the walk's autoreleased
// URL/stat objects from accumulating across subtrees // URL/stat objects from accumulating across subtrees
autoreleasepool { autoreleasepool {
vRecords.append(contentsOf: FileIndexer.indexRoot( FileIndexer.walkRoot(inRoot: URL(fileURLWithPath: vPath), inExclusions: vMatcher) { vBatch in
inRoot: URL(fileURLWithPath: vPath), vRecords.append(contentsOf: vBatch)
inExclusions: vMatcher vFound += vBatch.count
)) if inAnnounce && Date().timeIntervalSince(vLastProgress) >= 0.5 {
vLastProgress = Date()
let vCount = vFound
DispatchQueue.main.async {
self?.updateScan(inPath: vPath, inFound: vCount)
} }
} }
let vFinal = vRecords // new folders / roots: nothing below them is indexed
// yet, so what's found so far can be shown right away
guard inNewFolders, Date().timeIntervalSince(vLastStream) >= vStreamSeconds else { return }
vLastStream = Date()
vHeldRoots.append(contentsOf: vRecords.filter { vRootIds.contains($0.id) })
let vChunk = vRecords.filter { !vRootIds.contains($0.id) }
vRecords = []
DispatchQueue.main.async {
self?.mergeWalked(inRecords: vChunk, inGeneration: vGeneration)
}
}
}
}
let vFinal = vRecords + vHeldRoots
DispatchQueue.main.async { DispatchQueue.main.async {
guard let vSelf = self else { return } guard let vSelf = self else { return }
if inAnnounce {
vSelf.scans.removeAll { vRoots.contains($0.path) }
}
vSelf.rescansInFlight -= 1 vSelf.rescansInFlight -= 1
if vSelf.rescansInFlight <= 0 { if vSelf.rescansInFlight <= 0 {
vSelf.rescansInFlight = 0 vSelf.rescansInFlight = 0
vSelf.rescanFloorEventId = nil vSelf.rescanFloorEventId = nil
} }
guard vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
if inNewFolders { if inNewFolders {
vSelf.index.mergeNewFolders(inRecords: vFinal) vSelf.mergeWalked(inRecords: vFinal, inGeneration: vGeneration)
} else { } else {
guard vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
vSelf.index.replaceSubtrees(inRoots: vRoots, inRecords: vFinal) vSelf.index.replaceSubtrees(inRoots: vRoots, inRecords: vFinal)
}
vSelf.dirty = true vSelf.dirty = true
vSelf.publishRecords() vSelf.publishRecords()
} }
} }
} }
}
// publishes the progress of an announced walk
private func updateScan(inPath: String, inFound: Int) {
guard let vIndex = scans.firstIndex(where: { $0.path == inPath }) else { return }
scans[vIndex].found = inFound
}
// adds walked records below folders that weren't indexed (no removal
// pass), unless the index was replaced since the walk started
private func mergeWalked(inRecords: [FileRecord], inGeneration: Int) {
guard isIndexer, indexGeneration == inGeneration, !inRecords.isEmpty else { return }
index.mergeNewFolders(inRecords: inRecords)
dirty = true
publishRecords()
}
// applies edited roots / exclusions / volume options to the live index // applies edited roots / exclusions / volume options to the live index
// without a full reindex: prunes what fell out of scope, walks roots // without a full reindex: prunes what fell out of scope, walks roots
@@ -767,7 +843,7 @@ final class AppModel: ObservableObject {
vSelf.startWatching(inSinceWhen: vSnapshotEventId) vSelf.startWatching(inSinceWhen: vSnapshotEventId)
let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) } let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) }
if !vMissing.isEmpty { if !vMissing.isEmpty {
vSelf.kickRescanSubtrees(inPaths: vMissing, inFloorEventId: vSnapshotEventId) vSelf.kickRescanSubtrees(inPaths: vMissing, inFloorEventId: vSnapshotEventId, inNewFolders: true, inAnnounce: true)
} }
} }
} }
+19 -1
View File
@@ -296,6 +296,14 @@ private struct StatusBarView: View {
Text("·") Text("·")
Text(vSelection) 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() Spacer()
if freeze.isHeld { if freeze.isHeld {
@@ -314,7 +322,8 @@ private struct StatusBarView: View {
Text(roleText) Text(roleText)
} }
.monospacedDigit() .monospacedDigit()
.help(detailsText) // details after a short hover, updated live (see HoverDetailsAnchor)
.background(HoverDetailsAnchor(text: detailsText))
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 4) .padding(.vertical, 4)
@@ -322,6 +331,15 @@ private struct StatusBarView: View {
.foregroundColor(.secondary) .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" // "N results · M indexed"
private var resultsText: String { private var resultsText: String {
let vCount = stats.matchCount let vCount = stats.matchCount
+150 -3
View File
@@ -437,6 +437,12 @@ final class ResultsNSTableView: NSTableView {
// shows the full text if that cell is still hovered and cut off // shows the full text if that cell is still hovered and cut off
private func showHoverOverlay(inRow: Int, inColumn: Int) { private func showHoverOverlay(inRow: Int, inColumn: Int) {
// the list may have shrunk since the pointer settled on this cell;
// NSTableView raises (and the app aborts) on an out-of-range row
guard inRow >= 0, inRow < numberOfRows, inColumn >= 0, inColumn < numberOfColumns else {
endHover()
return
}
guard let vCurrent = hoveredCell, vCurrent.row == inRow, vCurrent.column == inColumn, guard let vCurrent = hoveredCell, vCurrent.row == inRow, vCurrent.column == inColumn,
let vWindow = window, vWindow.isKeyWindow || vWindow.isMainWindow, let vWindow = window, vWindow.isKeyWindow || vWindow.isMainWindow,
let vCell = view(atColumn: inColumn, row: inRow, makeIfNecessary: false) as? ResultCellView, let vCell = view(atColumn: inColumn, row: inRow, makeIfNecessary: false) as? ResultCellView,
@@ -452,9 +458,16 @@ final class ResultsNSTableView: NSTableView {
// after a reload: redraw the overlay for the still-hovered cell (its // after a reload: redraw the overlay for the still-hovered cell (its
// content may have changed), or hide it if that text now fits // content may have changed), or hide it if that text now fits
func refreshHover() { func refreshHover() {
guard let vCurrent = hoveredCell, hoverTimer?.isValid != true else { return } guard hoveredCell != nil, hoverTimer?.isValid != true else { return }
layoutSubtreeIfNeeded() // runs after the current update pass: this is called from SwiftUI's
showHoverOverlay(inRow: vCurrent.row, inColumn: vCurrent.column) // updateNSView, often in the middle of a layout / constraints pass,
// where asking the table for cell views (or forcing a layout) is
// unsafe. The hovered cell is re-checked then (the reload may have
// removed its row).
DispatchQueue.main.async { [weak self] in
guard let vSelf = self, let vCurrent = vSelf.hoveredCell, vSelf.hoverTimer?.isValid != true else { return }
vSelf.showHoverOverlay(inRow: vCurrent.row, inColumn: vCurrent.column)
}
} }
// listens for ↓ in the search field of the same window // listens for ↓ in the search field of the same window
@@ -714,6 +727,39 @@ final class HoverOverlay {
vPanel.orderFront(nil) vPanel.orderFront(nil)
} }
// shows multi-line details (plain text) just above inAnchor (screen
// coordinates), right-aligned with it and kept on screen
func showDetails(inText: String, inAnchor: NSRect, inParent: NSWindow) {
let vPanel = panel ?? makePanel()
let vText = NSAttributedString(string: inText, attributes: [
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
.foregroundColor: NSColor.labelColor
])
label.attributedStringValue = vText
let vPad = NSSize(width: 8, height: 6)
let vTextSize = vText.boundingRect(
with: NSSize(width: 10_000, height: 10_000),
options: [.usesLineFragmentOrigin, .usesFontLeading]
).size
var vFrame = NSRect(
x: inAnchor.maxX - ceil(vTextSize.width) - vPad.width * 2,
y: inAnchor.maxY + 4,
width: ceil(vTextSize.width) + vPad.width * 2,
height: ceil(vTextSize.height) + vPad.height * 2
)
if let vScreen = inParent.screen?.visibleFrame {
vFrame.origin.x = min(max(vFrame.minX, vScreen.minX), vScreen.maxX - vFrame.width)
vFrame.origin.y = min(vFrame.minY, vScreen.maxY - vFrame.height)
}
vPanel.setFrame(vFrame, display: true)
label.frame = NSRect(x: vPad.width, y: vPad.height, width: ceil(vTextSize.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 // hides the overlay
func hide() { func hide() {
guard let vPanel = panel, vPanel.isVisible else { return } guard let vPanel = panel, vPanel.isVisible else { return }
@@ -738,6 +784,8 @@ final class HoverOverlay {
vBackground.layer?.borderColor = NSColor.separatorColor.cgColor vBackground.layer?.borderColor = NSColor.separatorColor.cgColor
vPanel.contentView = vBackground vPanel.contentView = vBackground
label.lineBreakMode = .byClipping label.lineBreakMode = .byClipping
label.maximumNumberOfLines = 0
label.usesSingleLineMode = false
vBackground.addSubview(label) vBackground.addSubview(label)
panel = vPanel panel = vPanel
return vPanel return vPanel
@@ -790,3 +838,102 @@ final class HeaderMenuStates: NSObject, NSMenuDelegate {
} }
} }
} }
// HoverDetailsAnchor shows a multi-line details text in HoverOverlay after
// the pointer rests on its area for kDelaySeconds, keeps it while hovered and
// updates it in place when the text changes. A help tag (.help) restarts its
// delay every time its text changes, so on a status bar refreshing every
// second or two it appeared late or not at all.
struct HoverDetailsAnchor: NSViewRepresentable {
// the details to show (re-evaluated by SwiftUI on each refresh)
let text: String
// hover delay, same as the result cells' overlay
static let kDelaySeconds: TimeInterval = 0.5
// creates the tracking view
func makeNSView(context inContext: Context) -> AnchorView {
let vView = AnchorView(frame: .zero)
vView.text = text
return vView
}
// passes the latest text; a visible overlay is updated in place
func updateNSView(_ inView: AnchorView, context inContext: Context) {
inView.text = text
}
// AnchorView tracks the pointer over its bounds; clicks pass through
final class AnchorView: NSView {
// latest details text
var text = "" {
didSet {
if isShowing && text != oldValue { show() }
}
}
// pending display after the hover delay
private var timer: Timer?
// true while this anchor's details are on screen
private var isShowing = false
// follows the pointer over the visible part of the view
override func updateTrackingAreas() {
super.updateTrackingAreas()
for vArea in trackingAreas where vArea.owner === self {
removeTrackingArea(vArea)
}
addTrackingArea(NSTrackingArea(
rect: .zero,
options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect],
owner: self,
userInfo: nil
))
}
// starts the hover delay
override func mouseEntered(with inEvent: NSEvent) {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: HoverDetailsAnchor.kDelaySeconds, repeats: false) { [weak self] _ in
MainActor.assumeIsolated {
self?.show()
}
}
}
// pointer left: cancel or hide
override func mouseExited(with inEvent: NSEvent) {
dismiss()
}
// never takes clicks (the status bar text stays selectable / inert)
override func hitTest(_ inPoint: NSPoint) -> NSView? {
return nil
}
// leaving the window hides the details
override func viewWillMove(toWindow inWindow: NSWindow?) {
dismiss()
super.viewWillMove(toWindow: inWindow)
}
// shows (or refreshes) the details above the anchor
private func show() {
guard let vWindow = window, !text.isEmpty else { return }
let vFrame = vWindow.convertToScreen(convert(bounds, to: nil))
HoverOverlay.shared.showDetails(inText: text, inAnchor: vFrame, inParent: vWindow)
isShowing = true
}
// cancels a pending display and hides a shown one
private func dismiss() {
timer?.invalidate()
timer = nil
if isShowing {
isShowing = false
HoverOverlay.shared.hide()
}
}
}
}
+25
View File
@@ -224,7 +224,11 @@ private struct RootsTab: View {
Text("Root folders to index") Text("Root folders to index")
.font(.headline) .font(.headline)
List(prefs.rootPaths, id: \.self, selection: $selection) { vPath in List(prefs.rootPaths, id: \.self, selection: $selection) { vPath in
HStack {
Text(vPath) Text(vPath)
Spacer()
rootStatus(for: vPath)
}
} }
.frame(minHeight: 200) .frame(minHeight: 200)
@@ -249,6 +253,27 @@ private struct RootsTab: View {
} }
} }
// state shown next to a root: being indexed (with the count found so
// far), or unreachable (e.g. a network share that isn't mounted)
@ViewBuilder
private func rootStatus(for inPath: String) -> some View {
let vCanonical = VolumeManager.canonicalPath(inPath: inPath)
if let vScan = model.scans.first(where: { $0.path == vCanonical }) {
HStack(spacing: 6) {
ProgressView()
.controlSize(.small)
Text("Indexing… \(vScan.found.formatted()) found")
.font(.caption)
.foregroundColor(.secondary)
}
} else if !FileManager.default.fileExists(atPath: inPath) {
Text("Not available")
.font(.caption)
.foregroundColor(.orange)
.help("This folder can't be reached right now (an unmounted drive or network share?). Its entries stay searchable until it's removed.")
}
}
// presents an Open panel to pick a folder, then appends it to rootPaths // presents an Open panel to pick a folder, then appends it to rootPaths
private func addFolder() { private func addFolder() {
let vPanel = NSOpenPanel() let vPanel = NSOpenPanel()
+4
View File
@@ -93,6 +93,10 @@ struct IndexStateTests {
#expect(vResult.rescans.isEmpty) #expect(vResult.rescans.isEmpty)
#expect(vState.contains(inPath: "/r/a/new.txt")) #expect(vState.contains(inPath: "/r/a/new.txt"))
#expect(!vState.contains(inPath: "/r/hidden/x.txt")) #expect(!vState.contains(inPath: "/r/hidden/x.txt"))
// the new folder and its content come from its walk, merged at once
#expect(!vState.contains(inPath: "/r/moved"))
#expect(!vState.contains(inPath: "/r/moved/inside.txt"))
vState.mergeNewFolders(inRecords: [Self.dir("/r/moved"), Self.file("/r/moved/inside.txt")])
#expect(vState.contains(inPath: "/r/moved/inside.txt")) #expect(vState.contains(inPath: "/r/moved/inside.txt"))
} }