mirror of
https://github.com/bitsycore/Allofit.git
synced 2026-10-06 12:57:26 +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.
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -150,10 +150,15 @@ struct IndexState {
|
||||
let vIsRoot = inRoots.contains(vRecord.fullPath)
|
||||
guard vIsRoot || contains(inPath: vRecord.parentPath) else { continue }
|
||||
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 {
|
||||
vNewFolders.append(vRecord.fullPath)
|
||||
continue
|
||||
}
|
||||
if upsert(vRecord) { vChanged = true }
|
||||
}
|
||||
|
||||
// kernel rescans only for subtrees that belong to the index
|
||||
|
||||
+90
-14
@@ -123,6 +123,22 @@ final class AppModel: ObservableObject {
|
||||
@Published private(set) var isLoadingCache: Bool = false
|
||||
// true when this process owns the index (got the lock or built-in mode)
|
||||
@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)
|
||||
private let kAutosaveSeconds: TimeInterval = 30
|
||||
@@ -133,6 +149,12 @@ final class AppModel: ObservableObject {
|
||||
// background queues isolated by concern - keeps the slow stuff off main
|
||||
private let indexQueue = DispatchQueue(label: "allofit.index", 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
|
||||
private let watcher = FileWatcher()
|
||||
// watcher used in reader mode to detect cache file refreshes
|
||||
@@ -581,14 +603,15 @@ final class AppModel: ObservableObject {
|
||||
lastEventId = vCurrentId
|
||||
startWatching(inSinceWhen: vCurrentId)
|
||||
// floor 0: an interrupted walk makes the next launch start over
|
||||
kickRescanSubtrees(inPaths: vRoots, inFloorEventId: 0)
|
||||
kickRescanSubtrees(inPaths: vRoots, inFloorEventId: 0, inAnnounce: true)
|
||||
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, inFloorEventId: lastEventId)
|
||||
// nothing below a missing root is indexed: stream it in
|
||||
kickRescanSubtrees(inPaths: vMissing, inFloorEventId: lastEventId, inNewFolders: true, inAnnounce: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,45 +723,98 @@ final class AppModel: ObservableObject {
|
||||
// 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) {
|
||||
// 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)
|
||||
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 vGeneration = indexGeneration
|
||||
rescanFloorEventId = min(rescanFloorEventId ?? inFloorEventId, inFloorEventId)
|
||||
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 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 {
|
||||
var vFound = 0
|
||||
// per-subtree autoreleasepool keeps the walk's autoreleased
|
||||
// URL/stat objects from accumulating across subtrees
|
||||
autoreleasepool {
|
||||
vRecords.append(contentsOf: FileIndexer.indexRoot(
|
||||
inRoot: URL(fileURLWithPath: vPath),
|
||||
inExclusions: vMatcher
|
||||
))
|
||||
FileIndexer.walkRoot(inRoot: URL(fileURLWithPath: vPath), inExclusions: vMatcher) { vBatch in
|
||||
vRecords.append(contentsOf: vBatch)
|
||||
vFound += vBatch.count
|
||||
if inAnnounce && Date().timeIntervalSince(vLastProgress) >= 0.5 {
|
||||
vLastProgress = Date()
|
||||
let vCount = vFound
|
||||
DispatchQueue.main.async {
|
||||
self?.updateScan(inPath: vPath, inFound: vCount)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
let vFinal = vRecords + vHeldRoots
|
||||
DispatchQueue.main.async {
|
||||
guard let vSelf = self else { return }
|
||||
if inAnnounce {
|
||||
vSelf.scans.removeAll { vRoots.contains($0.path) }
|
||||
}
|
||||
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)
|
||||
vSelf.mergeWalked(inRecords: vFinal, inGeneration: vGeneration)
|
||||
} else {
|
||||
guard vSelf.isIndexer, vSelf.indexGeneration == vGeneration else { return }
|
||||
vSelf.index.replaceSubtrees(inRoots: vRoots, inRecords: vFinal)
|
||||
vSelf.dirty = true
|
||||
vSelf.publishRecords()
|
||||
}
|
||||
vSelf.dirty = true
|
||||
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
|
||||
// without a full reindex: prunes what fell out of scope, walks roots
|
||||
// that are new, and restarts the watcher on the new root set
|
||||
@@ -767,7 +843,7 @@ final class AppModel: ObservableObject {
|
||||
vSelf.startWatching(inSinceWhen: vSnapshotEventId)
|
||||
let vMissing = vRoots.filter { !vSelf.index.contains(inPath: $0) }
|
||||
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(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 {
|
||||
@@ -314,7 +322,8 @@ private struct StatusBarView: View {
|
||||
Text(roleText)
|
||||
}
|
||||
.monospacedDigit()
|
||||
.help(detailsText)
|
||||
// details after a short hover, updated live (see HoverDetailsAnchor)
|
||||
.background(HoverDetailsAnchor(text: detailsText))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
@@ -322,6 +331,15 @@ private struct StatusBarView: View {
|
||||
.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
|
||||
|
||||
@@ -437,6 +437,12 @@ final class ResultsNSTableView: NSTableView {
|
||||
|
||||
// shows the full text if that cell is still hovered and cut off
|
||||
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,
|
||||
let vWindow = window, vWindow.isKeyWindow || vWindow.isMainWindow,
|
||||
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
|
||||
// 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)
|
||||
guard hoveredCell != nil, hoverTimer?.isValid != true else { return }
|
||||
// runs after the current update pass: this is called from SwiftUI's
|
||||
// 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
|
||||
@@ -714,6 +727,39 @@ final class HoverOverlay {
|
||||
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
|
||||
func hide() {
|
||||
guard let vPanel = panel, vPanel.isVisible else { return }
|
||||
@@ -738,6 +784,8 @@ final class HoverOverlay {
|
||||
vBackground.layer?.borderColor = NSColor.separatorColor.cgColor
|
||||
vPanel.contentView = vBackground
|
||||
label.lineBreakMode = .byClipping
|
||||
label.maximumNumberOfLines = 0
|
||||
label.usesSingleLineMode = false
|
||||
vBackground.addSubview(label)
|
||||
panel = 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")
|
||||
.font(.headline)
|
||||
List(prefs.rootPaths, id: \.self, selection: $selection) { vPath in
|
||||
Text(vPath)
|
||||
HStack {
|
||||
Text(vPath)
|
||||
Spacer()
|
||||
rootStatus(for: vPath)
|
||||
}
|
||||
}
|
||||
.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
|
||||
private func addFolder() {
|
||||
let vPanel = NSOpenPanel()
|
||||
|
||||
@@ -93,6 +93,10 @@ struct IndexStateTests {
|
||||
#expect(vResult.rescans.isEmpty)
|
||||
#expect(vState.contains(inPath: "/r/a/new.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"))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user