ScanStore Architecture

How ScanStore coordinates SwiftUI state, why it owns sub-stores via closure-injected hooks, and what's planned for v0.5.0+.

1. ScanStore: the coordinator

ScanStore is the root SwiftUI state object for the StorageScope Mac app. It lives in Sources/StorageScope/Stores/ScanStore.swift and is declared as @MainActor final class ScanStore: ObservableObject.

It is constructed exactly once in the app delegate (Sources/StorageScope/App/StorageScopeApp.swift) as private let store = ScanStore(), then injected into the view tree as an @ObservedObject on ContentView, SidebarView, DetailView, InspectorView, TreeExplorerView, CleanupReviewView, DuplicateCandidatesView, TypeBreakdownView, OverviewView, and SettingsView.

ScanStore owns the active scan, scan session state, selection state, the duplicate hash cache, security-scoped bookmark store, and the derived item/cleanup/reclaim caches that power the UI. It also composes three sub-stores that own narrower responsibility:

The v0.5.0 era is deliberately splitting further responsibility out of ScanStore into additional sub-stores. Section 5 enumerates what's planned.

Why sub-stores? A single monolithic ObservableObject makes SwiftUI observation too coarse: any @Published mutation invalidates the whole view tree that observes the store. Carving selection, filters, and verification into their own ObservableObjects lets SwiftUI re-render only the views that observe the changed sub-store.

2. Sub-store extraction pattern (v0.5.0 Tier 2)

The canonical pattern for a v0.5.0 sub-store declares it as a lazy var on ScanStore and wires three closure-injected hooks so the sub-store can read parent state and ask the parent to coordinate, without holding an owning reference back into ScanStore:

lazy var filters = FilterStore(
    scanLookup: { [weak self] in self?.scan },
    coordinateInvalidate: { [weak self] in self?.invalidateDerivedCaches() },
    reportError: { [weak self] in self?.errorMessage = $0 }
)

The three closure-injected hooks

Current state vs. canonical pattern. FilterStore today only opts into scanLookup and coordinateInvalidate — it has no user-facing error path of its own. OnDemandVerificationStore uses all three hooks (scanLookup, coordinateInvalidate, reportError). The three-hook shape above is the v0.5.0 canonical form new sub-stores should follow.

Why lazy var

Sub-stores capture self in their init via closures. If they were initialized eagerly during ScanStore.init, the closures would close over not-yet-initialized stored properties and a later access through self?.scan (a computed property that reads session.scan) would be undefined behavior. lazy var defers construction until first access, by which point ScanStore.init has finished and every stored property is initialized.

Why [weak self]

The closures live for the lifetime of the sub-store, which is owned by ScanStore. A strong capture would create a retain cycle (ScanStore → sub-store → closure → ScanStore) and prevent the coordinator from deallocating when the app tears down the window. [weak self] lets the closure safely no-op once the store is gone, and never extends ScanStore's lifetime.

3. The filterBinding bridge

SwiftUI's dynamic member lookup on @ObservedObject ($store.query, $store.filters.query) does not produce a Binding<String> when the path crosses an ObservableObject sub-store boundary. Only the @Observable macro (Swift Observation) traverses that boundary automatically; under the classic ObservableObject + @Published world StorageScope uses today, a sub-store property does not project a typed Binding<T> the way $store.query does.

So FilterStore's filter fields — searchText, query, sizeFilter, sortOption, fileTypeFocus, cleanupLaneFilter, oldFileAgeDays, includeHiddenFiles — need a hand-rolled bridge to be consumable from Picker, Stepper, Toggle, and .searchable(text:):

internal func filterBinding<T>(
    _ keyPath: ReferenceWritableKeyPath<FilterStore, T>
) -> Binding<T> {
    Binding(
        get: { self.filters[keyPath: keyPath] },
        set: { self.filters[keyPath: keyPath] = $0 }
    )
}

Usage:

.searchable(text: store.filterBinding(\.searchText), prompt: "Filter")

Picker("Sort", selection: store.filterBinding(\.sortOption)) {
    Text("Size").tag(ItemSortOption.sizeDescending)
    Text("Name").tag(ItemSortOption.nameAscending)
}

Stepper(
    value: store.filterBinding(\.oldFileAgeDays),
    in: 30...365,
    step: 30
) {
    Text("Older than \(store.oldFileAgeDays) days")
}

Must be internal, not private. The first cut of this bridge shipped as private func in v0.4.5 PR #38, which made it invisible to the views outside the ScanStore file and broke Picker/Stepper bindings in SidebarView and CleanupReviewView. The access modifier is now explicitly internal. If you copy this shape into a new sub-store, keep the modifier explicit.

When StorageScope migrates to the @Observable macro (see Section 5, Tier 5), this bridge becomes unnecessary — Bindable(store.filters).$searchText will produce a typed Binding<String> without per-keyPath shimming.

4. Derived cache invalidation

ScanStore derives several expensive views of the scan on demand — filtered items, cleanup candidates, old-large-files, the reclaim plan, and total reclaimable bytes. Each derived view is cached against an Equatable cache key that folds together the inputs that would change the output:

private struct ItemsCacheKey: Equatable {
    let scanFinishedAt: Date
    let view: SmartView
    let query: String
    let sizeFilter: SizeFilter
    let sortOption: ItemSortOption
    let cleanupLaneFilter: CleanupLaneFilter
    let fileTypeFocus: String?
    let ignoredCleanupCandidateIDs: Set<String>
}

private struct DerivedCacheKey: Equatable {
    let scanFinishedAt: Date
    let query: String
    let sizeFilter: SizeFilter
    let sortOption: ItemSortOption
    let cleanupLaneFilter: CleanupLaneFilter
    let ignoredCleanupCandidateIDs: Set<String>
}

Each cache is stored as a paired key/value slot on ScanStore: cachedItemsKey/cachedItems, cachedCleanupCandidatesKey/cachedCleanupCandidates, cachedOldLargeFilesKey/cachedOldLargeFiles, cachedReclaimPlanKey/cachedReclaimPlan, cachedPotentialReclaimableBytesKey/cachedPotentialReclaimableBytes.

invalidateDerivedCaches() drops every cache key/value pair in one pass:

private func invalidateDerivedCaches() {
    cachedCleanupCandidatesKey = nil
    cachedCleanupCandidates = []
    cachedPotentialReclaimableBytesKey = nil
    cachedPotentialReclaimableBytes = 0
    cachedReclaimPlanKey = nil
    cachedReclaimPlan = ReclaimPlan(sections: [], primaryAction: nil)
    cachedOldLargeFilesKey = nil
    cachedOldLargeFiles = []
    invalidateItemsCache()
}

private func invalidateItemsCache() {
    cachedItemsKey = nil
    cachedItems = []
}

Sub-stores must not touch these caches directly. coordinateInvalidate is the only sanctioned way for a sub-store to drop the parent's caches. A sub-store that wrote to cachedItemsKey directly would free itself from spelling its own cache inputs in ItemsCacheKey, and then drift the next time an input was added. Always go through coordinateInvalidate().

5. What's next (Tiers 3–5)

The v0.5.0 sub-store extraction is sequenced into tiers. FilterStore (Tier 2) and OnDemandVerificationStore (Tier 1b) shipped; the remaining tiers are planned:

6. Store hierarchy

ASCII rendering of the coordinator ↔ sub-store relationship. Boxes are ObservableObject types; arrows show the closure direction (coordinateInvalidate flows up to the parent; weak self capture is what lets the closure no-op after ScanStore deallocs).

+------------------------------------------+
|             ScanStore                    |
|             (@MainActor                  |
|              ObservableObject)           |
|                                          |
|  +------------+  +-------------+         |
|  | Recents    |  | OnDemand    |         |
|  | Store      |  | Verification|         |
|  |            |  | Store       |         |
|  +------------+  +-------------+         |
|                                          |
|  +-----------------+                     |
|  |  FilterStore    |                     |
|  |  - searchText   |                     |
|  |  - query        |                     |
|  |  - fileTypeFocus|                     |
|  |  - sizeFilter   |                     |
|  |  - sortOption   |                     |
|  |  - cleanupLane  |                     |
|  |  - ActiveFilter |                     |
|  +--------+--------+                     |
|           |                              |
|    coordinateInvalidate → drop           |
|         derived caches                   |
+------------------------------------------+
        ^
        | weak self capture
        |
+------------------------------------------+
|  FilterStore delegate closures           |
|  - scanLookup: () -> StorageScan?        |
|  - coordinateInvalidate: () -> Void      |
|  - reportError: (String) -> Void         |
+------------------------------------------+

Boxes are not memory ownership boxes; they are responsibility boxes. The sub-stores are owned by ScanStore (either eagerly via let or lazily via lazy var), and the closures above are how a sub-store talks back up.

See also: source on GitHub · release history · FAQ · Keyboard Shortcuts