mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-06 21:07:27 +00:00
Compare commits
1
Commits
876122ff98
...
1.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b751b6ea4 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user