Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 74 additions & 8 deletions Sources/StorageScope/Stores/ScanStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,19 @@ func setSelectedView(_ view: SmartView) {
private var cachedReclaimPlan = ReclaimPlan(sections: [], primaryAction: nil)
private var cachedItemsKey: ItemsCacheKey?
private var cachedItems: [StorageItem] = []

/// Key for the type/category breakdown caches. Only the scan identity and the search
/// query feed those computations, so the key deliberately omits the other filter dims —
/// a size-filter or sort change must not evict a still-valid breakdown.
private struct TypeBreakdownCacheKey: Equatable {
let scanFinishedAt: Date
let query: String
}

private var cachedFilteredTypeBreakdownKey: TypeBreakdownCacheKey?
private var cachedFilteredTypeBreakdown: [FileTypeStat] = []
private var cachedFilteredCategoryBreakdownKey: TypeBreakdownCacheKey?
private var cachedFilteredCategoryBreakdown: [FileCategoryStat] = []
private var cachedOldLargeFilesKey: DerivedCacheKey?
private var cachedOldLargeFiles: [StorageItem] = []
private var cachedDuplicateGroupsKey: DerivedCacheKey?
Expand Down Expand Up @@ -328,9 +341,8 @@ func setSelectedView(_ view: SmartView) {
}

var canExpandAllTree: Bool {
guard let rootItem = scan?.rootItem else { return false }
let allContainers = allTreeContainerIDs(in: rootItem)
return !allContainers.allSatisfy { treeExpandedIDs.contains($0) }
let containers = treeContainerIDs
return !containers.isEmpty && !containers.isSubset(of: treeExpandedIDs)
}

var canCollapseTree: Bool {
Expand All @@ -341,8 +353,26 @@ func setSelectedView(_ view: SmartView) {
/// same retention criteria as the scan itself, so big scans only expand what's actually
/// visible — the renderer never sees the dropped children.
func expandEntireTree() {
guard let rootItem = scan?.rootItem else { return }
treeExpandedIDs = allTreeContainerIDs(in: rootItem)
guard scan != nil else { return }
treeExpandedIDs = treeContainerIDs
}

private var cachedTreeContainerIDsScanFinishedAt: Date?
private var cachedTreeContainerIDs: Set<String> = []

/// Container IDs of the retained tree, cached per scan identity. `canExpandAllTree` is
/// read on every TreeExplorerView body pass (toolbar enablement), and each selection or
/// expansion change re-evaluates that body — without this cache every click re-walked
/// the entire retained tree just to produce one Bool.
private var treeContainerIDs: Set<String> {
guard let scan else { return [] }
if cachedTreeContainerIDsScanFinishedAt == scan.finishedAt {
return cachedTreeContainerIDs
}
let ids = allTreeContainerIDs(in: scan.rootItem)
cachedTreeContainerIDsScanFinishedAt = scan.finishedAt
cachedTreeContainerIDs = ids
return ids
}

/// Collapses every container — reverts the tree to a single root row.
Expand Down Expand Up @@ -632,8 +662,16 @@ func setSelectedView(_ view: SmartView) {

func refreshMountedVolumes() {
cachedMountedVolumes = resolveMountedVolumes()
cachedVolumeCapacityDescriptions.removeAll()
}

/// Cached per volume URL for the same reason as `cachedMountedVolumes`: SidebarView
/// renders one capacity string per volume on every body pass, and the underlying
/// `resourceValues` fetch is synchronous disk I/O on the main actor. Outer Optional
/// distinguishes "not fetched yet" from a cached nil (values unavailable). Cleared
/// alongside the volume list in `refreshMountedVolumes()`.
private var cachedVolumeCapacityDescriptions: [URL: String?] = [:]

private func resolveMountedVolumes() -> [URL] {
let keys: [URLResourceKey] = [
.volumeNameKey,
Expand All @@ -659,6 +697,15 @@ func setSelectedView(_ view: SmartView) {
/// Used by the sidebar Volumes section to surface free vs total capacity before the user
/// commits to scanning a volume.
func volumeCapacityDescription(for url: URL) -> String? {
if let cached = cachedVolumeCapacityDescriptions[url] {
return cached
}
let description = resolveVolumeCapacityDescription(for: url)
cachedVolumeCapacityDescriptions[url] = description
return description
}

private func resolveVolumeCapacityDescription(for url: URL) -> String? {
let keys: Set<URLResourceKey> = [.volumeAvailableCapacityKey, .volumeTotalCapacityKey]
guard let values = try? url.resourceValues(forKeys: keys) else { return nil }
let free = values.volumeAvailableCapacity ?? 0
Expand Down Expand Up @@ -1538,19 +1585,31 @@ func setSelectedView(_ view: SmartView) {
guard !trimmedQuery.isEmpty else {
return scan.typeBreakdown
}
return scan.typeBreakdown.filter {
let key = TypeBreakdownCacheKey(scanFinishedAt: scan.finishedAt, query: trimmedQuery)
if cachedFilteredTypeBreakdownKey == key {
return cachedFilteredTypeBreakdown
}
let value = scan.typeBreakdown.filter {
$0.label.localizedCaseInsensitiveContains(trimmedQuery) ||
$0.category.rawValue.localizedCaseInsensitiveContains(trimmedQuery)
}
cachedFilteredTypeBreakdownKey = key
cachedFilteredTypeBreakdown = value
return value
}

var filteredCategoryBreakdown: [FileCategoryStat] {
guard let scan else {
return []
}
guard !filters.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
let trimmedQuery = filters.query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedQuery.isEmpty else {
return scan.categoryBreakdown
}
let key = TypeBreakdownCacheKey(scanFinishedAt: scan.finishedAt, query: trimmedQuery)
if cachedFilteredCategoryBreakdownKey == key {
return cachedFilteredCategoryBreakdown
}

let typeStats = filteredTypeBreakdown
var statsByCategory: [FileTypeStat.Category: (fileCount: Int, extensionCount: Int, totalBytes: Int64)] = [:]
Expand All @@ -1563,7 +1622,7 @@ func setSelectedView(_ view: SmartView) {
statsByCategory[stat.category] = categoryStat
}

return statsByCategory.map { category, stats in
let value = statsByCategory.map { category, stats in
FileCategoryStat(
category: category,
fileCount: stats.fileCount,
Expand All @@ -1577,6 +1636,9 @@ func setSelectedView(_ view: SmartView) {
}
return lhs.totalBytes > rhs.totalBytes
}
cachedFilteredCategoryBreakdownKey = key
cachedFilteredCategoryBreakdown = value
return value
}

func focusFileType(_ stat: FileTypeStat) {
Expand Down Expand Up @@ -1735,6 +1797,10 @@ func setSelectedView(_ view: SmartView) {
cachedDuplicateGroups = []
cachedVerifiedDuplicateGroupsKey = nil
cachedVerifiedDuplicateGroups = []
cachedFilteredTypeBreakdownKey = nil
cachedFilteredTypeBreakdown = []
cachedFilteredCategoryBreakdownKey = nil
cachedFilteredCategoryBreakdown = []
// cachedItemsKey/cachedItems intentionally NOT dropped here. `items(for:)`'s key
// comparison is the source of truth — its key normalizes irrelevant dims per view,
// so a filter change that doesn't affect the active view's output is a no-op
Expand Down
32 changes: 32 additions & 0 deletions Sources/StorageScope/Support/SizeBar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import SwiftUI

/// Shared fractional size bar used by row views (Tree Explorer, Type Breakdown,
/// Overview storage map). Drawn with `Canvas` rather than the previous per-row
/// `GeometryReader` track/overlay pair: `GeometryReader` adds a deferred layout
/// pass for every visible row, which compounds on scrolls through hundreds of
/// expanded tree rows. `Canvas` receives its resolved size directly and draws
/// the same two rounded rects in one pass with no extra layout feedback.
struct SizeBar: View {
let fraction: Double
private let fill: AnyShapeStyle
private let minimumWidth: CGFloat

init(fraction: Double, fill: some ShapeStyle, minimumWidth: CGFloat = 8) {
self.fraction = fraction
self.fill = AnyShapeStyle(fill)
self.minimumWidth = minimumWidth
}

var body: some View {
Canvas { context, size in
let track = Path(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 4)
context.fill(track, with: .style(.quaternary))
let width = min(max(minimumWidth, size.width * CGFloat(fraction)), size.width)
let bar = Path(
roundedRect: CGRect(x: 0, y: 0, width: width, height: size.height),
cornerRadius: 4
)
context.fill(bar, with: .style(fill))
}
}
}
5 changes: 3 additions & 2 deletions Sources/StorageScope/Views/DuplicateCandidatesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,8 @@ private struct DuplicateItemList: View {

var body: some View {
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
let lastItemID = items.last?.id
ForEach(items) { item in
let isKeeper = keeperItemID == item.id
DuplicateFileRow(
item: item,
Expand All @@ -292,7 +293,7 @@ private struct DuplicateItemList: View {
}
.equatable()

if index < items.index(before: items.endIndex) {
if item.id != lastItemID {
Divider()
}
}
Expand Down
5 changes: 3 additions & 2 deletions Sources/StorageScope/Views/KeeperComparisonSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,12 @@ struct KeeperComparisonSheet: View {
}

VStack(spacing: 0) {
ForEach(Array(copies.enumerated()), id: \.element.id) { index, item in
let lastItemID = copies.last?.id
ForEach(copies) { item in
KeeperComparisonRow(item: item, filters: store.filters, isKeeper: false) {
onSetKeeper(item)
}
if index < copies.index(before: copies.endIndex) {
if item.id != lastItemID {
Divider()
}
}
Expand Down
18 changes: 7 additions & 11 deletions Sources/StorageScope/Views/OverviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,8 @@ private struct SizeDistributionView: View {
.cardBackground()
} else {
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
let lastItemID = items.last?.id
ForEach(items) { item in
StorageMapRow(
item: item,
maxSize: maxSize,
Expand All @@ -288,7 +289,7 @@ private struct SizeDistributionView: View {
store.selectedItemID = item.id
}
.equatable()
if index < items.count - 1 {
if item.id != lastItemID {
Divider()
}
}
Expand Down Expand Up @@ -340,15 +341,10 @@ private struct StorageMapRow: View, Equatable {
.monospacedDigit()
}

GeometryReader { geometry in
RoundedRectangle(cornerRadius: 4)
.fill(.quaternary)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(.tint.opacity(0.85))
.frame(width: max(8, geometry.size.width * CGFloat(Double(item.displaySize) / Double(maxSize))))
}
}
SizeBar(
fraction: Double(item.displaySize) / Double(maxSize),
fill: .tint.opacity(0.85)
)
.frame(height: 8)
.accessibilityHidden(true)
}
Expand Down
5 changes: 3 additions & 2 deletions Sources/StorageScope/Views/TrashConfirmationSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,11 @@ private struct TrashReviewSection: View {
.foregroundStyle(.secondary)

VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
let lastItemID = items.last?.id
ForEach(items) { item in
TrashReviewRow(item: item, filters: filters, reveal: reveal, open: open, remove: remove, isMoving: isMoving)

if index < items.index(before: items.endIndex) {
if item.id != lastItemID {
Divider()
}
}
Expand Down
14 changes: 5 additions & 9 deletions Sources/StorageScope/Views/TreeExplorerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,11 @@ private struct TreeNodeRow: View {
.foregroundStyle(.secondary)
}

GeometryReader { geometry in
RoundedRectangle(cornerRadius: 4)
.fill(.quaternary)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(.tint.opacity(depth == 0 ? 0.85 : 0.62))
.frame(width: max(6, geometry.size.width * CGFloat(Double(item.displaySize) / Double(rootSize))))
}
}
SizeBar(
fraction: Double(item.displaySize) / Double(rootSize),
fill: .tint.opacity(depth == 0 ? 0.85 : 0.62),
minimumWidth: 6
)
.frame(height: 7)
// Purely decorative — the row's own accessibilityLabel/Value already
// states the size; without this, VoiceOver can land on an unlabeled bar.
Expand Down
26 changes: 8 additions & 18 deletions Sources/StorageScope/Views/TypeBreakdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,10 @@ struct TypeBreakdownView: View {
.font(.system(.body, design: .rounded).weight(.semibold))
.frame(width: 110, alignment: .leading)

GeometryReader { geometry in
RoundedRectangle(cornerRadius: 4)
.fill(.quaternary)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(stat.category.barTint.opacity(0.72))
.frame(width: max(8, geometry.size.width * CGFloat(Double(stat.totalBytes) / Double(maxCategoryBytes))))
}
}
SizeBar(
fraction: Double(stat.totalBytes) / Double(maxCategoryBytes),
fill: stat.category.barTint.opacity(0.72)
)
.frame(height: 10)
.accessibilityHidden(true)

Expand Down Expand Up @@ -128,15 +123,10 @@ private struct FileTypeRowLabel: View, Equatable {
.foregroundStyle(.secondary)
.frame(width: 86, alignment: .leading)

GeometryReader { geometry in
RoundedRectangle(cornerRadius: 4)
.fill(.quaternary)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(stat.category.barTint.opacity(0.72))
.frame(width: max(8, geometry.size.width * CGFloat(Double(stat.totalBytes) / Double(maxBytes))))
}
}
SizeBar(
fraction: Double(stat.totalBytes) / Double(maxBytes),
fill: stat.category.barTint.opacity(0.72)
)
.frame(height: 10)
.accessibilityHidden(true)

Expand Down
Loading
Loading