diff --git a/.jules/bolt.md b/.jules/bolt.md index dd1b466..f20f6da 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -21,3 +21,7 @@ ## 2026-03-19 - FileManager Enumerator Pre-fetching **Learning:** Any property requested via `resourceValues(forKeys:)` inside a `FileManager.enumerator` loop must also be in `includingPropertiesForKeys` — otherwise `URL.resourceValues` falls back to a synchronous `stat()` per file, turning bulk reads into O(N) disk I/O. **Action:** Keep the keys array passed to `resourceValues(forKeys:)` a subset of the prefetch list passed to `FileManager.enumerator(at:includingPropertiesForKeys:)`. + +## 2024-06-25 - O(1) Index Maps for @Published Array Toggles +**Learning:** When toggling the selection state of an item within a massive `@Published` array (like `nodeModulesItems`), sequentially searching for the element's index via `firstIndex(where:)` causes O(n) CPU latency on every user click. +**Action:** Replace `firstIndex(where:)` with an O(1) dictionary-based index map (`[UUID: Int]`). Rebuild the dictionary via `enumerated().reduce(into: [:])` immediately after the main array is fully populated (e.g., after a scan operation completes), and include a defensive boundary and ID check fallback for robustness. diff --git a/Sources/Cacheout/ViewModels/CacheoutViewModel.swift b/Sources/Cacheout/ViewModels/CacheoutViewModel.swift index 0e8464b..60998b7 100644 --- a/Sources/Cacheout/ViewModels/CacheoutViewModel.swift +++ b/Sources/Cacheout/ViewModels/CacheoutViewModel.swift @@ -54,6 +54,9 @@ class CacheoutViewModel: ObservableObject { @Published var nodeModulesItems: [NodeModulesItem] = [] @Published var isNodeModulesScanning = false + private var scanResultIndexMap: [UUID: Int] = [:] + private var nodeModulesIndexMap: [UUID: Int] = [:] + /// Increments on every completed scan — views can use .task(id:) to react @Published var scanGeneration: Int = 0 @@ -154,9 +157,11 @@ class CacheoutViewModel: ObservableObject { async let nmResults = nodeModulesScanner.scan() scanResults = await cacheResults + scanResultIndexMap = scanResults.enumerated().reduce(into: [:]) { $0[$1.element.id] = $1.offset } isScanning = false nodeModulesItems = await nmResults + nodeModulesIndexMap = nodeModulesItems.enumerated().reduce(into: [:]) { $0[$1.element.id] = $1.offset } isNodeModulesScanning = false // Track scan completion for reactive UI updates @@ -166,7 +171,9 @@ class CacheoutViewModel: ObservableObject { } func toggleSelection(for id: UUID) { - if let index = scanResults.firstIndex(where: { $0.id == id }) { + if let index = scanResultIndexMap[id], index < scanResults.count, scanResults[index].id == id { + scanResults[index].isSelected.toggle() + } else if let index = scanResults.firstIndex(where: { $0.id == id }) { scanResults[index].isSelected.toggle() } } @@ -191,7 +198,9 @@ class CacheoutViewModel: ObservableObject { // MARK: - Node Modules selection func toggleNodeModulesSelection(for id: UUID) { - if let i = nodeModulesItems.firstIndex(where: { $0.id == id }) { + if let i = nodeModulesIndexMap[id], i < nodeModulesItems.count, nodeModulesItems[i].id == id { + nodeModulesItems[i].isSelected.toggle() + } else if let i = nodeModulesItems.firstIndex(where: { $0.id == id }) { nodeModulesItems[i].isSelected.toggle() } }