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
12 changes: 12 additions & 0 deletions Sources/StorageScope/Stores/FilterStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ final class FilterStore: ObservableObject {
didSet { coordinateInvalidate() }
}

/// Scanner inclusion threshold for same-size duplicate candidacy, in MB. Default lowered
/// from the old fixed 100MB (see `ScanOptionPolicy`) because at 100MB nearly every file
/// in a typical folder (photos, PDFs, code, installers under a few dozen MB) never became
/// a candidate, so Duplicate Review found almost nothing for most users' real folders.
/// 10MB still excludes the long tail of tiny files (source, text, thumbnails) that would
/// blow up hashing volume, while catching the common duplicate cases. The verify byte/file
/// budget (`ScanOptions.duplicateVerificationByteLimit`/`maxDuplicateVerificationFiles`)
/// remains the wall-time safety valve regardless of how low this goes.
@Published var duplicateCandidateThresholdMB: Int = 10 {
didSet { coordinateInvalidate() }
}

/// Set when the user clicks a row on `TypeBreakdownView` — narrows downstream views
/// (currently `.largestFiles`) to files whose `fileExtension` matches. Cleared on its own
/// when the user dismisses the corresponding chip, or anytime `query` changes so the user
Expand Down
38 changes: 35 additions & 3 deletions Sources/StorageScope/Stores/ScanStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import os
final class ScanStore: ObservableObject {
private static let overviewItemCap = 100

/// Single source of truth for the ranked-list cap passed to `ScanOptions` below, and
/// surfaced read-only via `scanRankedResultsCap` so the Overview disclosure can state
/// the real bound instead of a second hardcoded copy of "800" drifting out of sync.
private static let rankedResultsCap = 800

/// os_signpost surface for Instruments. Mirrors FileSystemScanner's subsystem so app-side
/// and scanner-side spans group together when filtering by subsystem in Instruments.
private static let log = OSLog(subsystem: "com.rasputinkaiser.StorageScope", category: "scan")
Expand All @@ -27,6 +32,7 @@ final class ScanStore: ObservableObject {
struct ScanOptionsSnapshot: Equatable {
let includeHiddenFiles: Bool
let oldFileAgeDays: Int
let duplicateCandidateThresholdMB: Int
}

private struct DerivedCacheKey: Equatable {
Expand Down Expand Up @@ -223,6 +229,12 @@ func setSelectedView(_ view: SmartView) {
}

var oldFileAgeDays: Int { filters.oldFileAgeDays }
var duplicateCandidateThresholdMB: Int { filters.duplicateCandidateThresholdMB }
var scanRankedResultsCap: Int { Self.rankedResultsCap }
/// `maxRetainedItems` isn't overridden when constructing `ScanOptions` in `scan(_:)`,
/// so the type's own default is the true bound — read it from a default-initialized
/// instance rather than hardcoding a second copy of the number.
var scanRetainedItemsCap: Int { ScanOptions().maxRetainedItems }

var activeView: SmartView {
selectedView ?? .overview
Expand Down Expand Up @@ -534,7 +546,25 @@ func setSelectedView(_ view: SmartView) {
scan(url)
}

private var cachedMountedVolumes: [URL]?

/// Cached so SidebarView's body — which re-evaluates on every store change,
/// including per-tick scan progress — doesn't redo `FileManager` volume I/O on
/// every render. Refreshed on `SidebarView.onAppear` (app launch) and after every
/// successful scan completes, so a volume mounted/unmounted mid-session is picked
/// up the next time the user finishes a scan rather than only on relaunch.
var mountedVolumes: [URL] {
if let cachedMountedVolumes { return cachedMountedVolumes }
let resolved = resolveMountedVolumes()
cachedMountedVolumes = resolved
return resolved
}

func refreshMountedVolumes() {
cachedMountedVolumes = resolveMountedVolumes()
}

private func resolveMountedVolumes() -> [URL] {
let keys: [URLResourceKey] = [
.volumeNameKey,
.volumeIsBrowsableKey,
Expand Down Expand Up @@ -667,8 +697,8 @@ func setSelectedView(_ view: SmartView) {
includeHidden: filters.includeHiddenFiles,
oldFileAgeDays: filters.oldFileAgeDays,
largeFileThreshold: thresholds.largeFileThreshold,
duplicateCandidateThreshold: thresholds.duplicateCandidateThreshold,
maxRankedResults: 800
duplicateCandidateThreshold: Int64(filters.duplicateCandidateThresholdMB) * 1_000_000,
maxRankedResults: Self.rankedResultsCap
)
let optionsSnapshot = currentScanOptions
let scanCancellation = ScanCancellation()
Expand Down Expand Up @@ -785,6 +815,7 @@ func setSelectedView(_ view: SmartView) {
currentPath: "Scan complete"
)
isScanning = false
refreshMountedVolumes()
clearActiveScan(scanID, cancellation: scanCancellation)
} catch FileSystemScannerError.cancelled {
guard isCurrentScan(scanID, cancellation: scanCancellation) else {
Expand Down Expand Up @@ -1541,7 +1572,8 @@ func setSelectedView(_ view: SmartView) {
private var currentScanOptions: ScanOptionsSnapshot {
ScanOptionsSnapshot(
includeHiddenFiles: filters.includeHiddenFiles,
oldFileAgeDays: filters.oldFileAgeDays
oldFileAgeDays: filters.oldFileAgeDays,
duplicateCandidateThresholdMB: filters.duplicateCandidateThresholdMB
)
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/StorageScope/Support/CardBackground.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ extension View {
/// default radius matches the existing 8/8 split that ships today.
func cardBackground(_ material: Material = .regular, radius: CGFloat = 12) -> some View {
background(material, in: RoundedRectangle(cornerRadius: radius))
// Soft layered elevation instead of a flat fill — adapts to light/dark via
// NSColor.shadowColor rather than a fixed black, and stays subtle enough not
// to compete with WelcomeCapabilityCard's own stronger hover shadow.
.shadow(color: Color(nsColor: .shadowColor).opacity(0.05), radius: 1, y: 1)
.shadow(color: Color(nsColor: .shadowColor).opacity(0.05), radius: 4, y: 2)
}

/// Selection highlight tint. Default opacity (0.18) matches list rows and cards;
Expand Down
13 changes: 9 additions & 4 deletions Sources/StorageScope/Support/FilterRecoveryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,16 @@ struct FilterRecoveryView: View {
}
}
} actions: {
Button(clearTitle) {
clearAction()
// Only show Clear when there's actually something to clear — with no active
// filters this button was previously a visible no-op, which is what made BUG-1's
// "no items" empty states read as broken rather than as honest "nothing here".
if !filters.isEmpty {
Button(clearTitle) {
clearAction()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}

Expand Down
18 changes: 18 additions & 0 deletions Sources/StorageScope/Support/PressableRowButtonStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import SwiftUI

/// Tactile press feedback for plain-styled row buttons (StorageItemRow, TreeNodeRow,
/// StorageMapRow, DuplicateFileRow, CleanupCandidateRow). Visuals (hover tint, selection
/// background) stay exactly as `.plain` already renders them; this only adds the press
/// scale. 0.96 per the interface-polish rubric — below 0.95 reads as exaggerated for a
/// list row.
struct PressableRowButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.96 : 1)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
}
}

extension ButtonStyle where Self == PressableRowButtonStyle {
static var pressableRow: PressableRowButtonStyle { PressableRowButtonStyle() }
}
2 changes: 1 addition & 1 deletion Sources/StorageScope/Views/CleanupReviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ private struct CleanupCandidateRow: View, Equatable {
.background(isHovered && !isSelected ? Color.primary.opacity(0.04) : Color.clear, in: RoundedRectangle(cornerRadius: 8))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(.pressableRow)
.onHover { isHovered = $0 }
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(candidate.item.name), \(candidate.kind.displayName), \(StorageFormat.bytes(candidate.reclaimableBytes))")
Expand Down
14 changes: 11 additions & 3 deletions Sources/StorageScope/Views/DuplicateCandidatesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ struct DuplicateCandidatesView: View {
FilterRecoveryView(
title: "No Duplicate Leads",
systemImage: "checkmark.seal",
description: store.hasActiveDisplayFilters ? "No duplicate leads match the active display filters." : "Try scanning a broader folder.",
description: store.hasActiveDisplayFilters
? "No duplicate leads match the active display filters."
: "No files at or above \(store.duplicateCandidateThresholdMB) MB had matching sizes. Lower the duplicate threshold in Settings to include smaller files.",
filters: store.activeDisplayFilterDescriptions,
state: store.displayRecoveryState,
clearTitle: "Clear Filters"
Expand Down Expand Up @@ -278,6 +280,7 @@ private struct DuplicateItemList: View {
} onCopyPath: {
onCopyPath(item)
}
.equatable()

if index < items.index(before: items.endIndex) {
Divider()
Expand All @@ -287,7 +290,7 @@ private struct DuplicateItemList: View {
}
}

private struct DuplicateFileRow: View {
private struct DuplicateFileRow: View, Equatable {
let item: StorageItem
let isKeeper: Bool
let isSelected: Bool
Expand All @@ -298,6 +301,11 @@ private struct DuplicateFileRow: View {
let onCopyPath: () -> Void
@State private var isHovered = false

// Excludes the closures: not Equatable, and freshly allocated per render anyway.
static func == (lhs: DuplicateFileRow, rhs: DuplicateFileRow) -> Bool {
lhs.item == rhs.item && lhs.isKeeper == rhs.isKeeper && lhs.isSelected == rhs.isSelected
}

var body: some View {
Button(action: onSelect) {
HStack {
Expand Down Expand Up @@ -325,7 +333,7 @@ private struct DuplicateFileRow: View {
.contentShape(Rectangle())
.background(isHovered && !isSelected ? Color.primary.opacity(0.04) : Color.clear)
}
.buttonStyle(.plain)
.buttonStyle(.pressableRow)
.onHover { isHovered = $0 }
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.name), file, \(StorageFormat.bytes(item.displaySize))\(isKeeper ? ", keeper" : "")")
Expand Down
56 changes: 51 additions & 5 deletions Sources/StorageScope/Views/OverviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ struct OverviewView: View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if store.scan != nil {
ScanLimitsDisclosure(
rankedResultsCap: store.scanRankedResultsCap,
duplicateThresholdMB: store.duplicateCandidateThresholdMB,
retainedItemsCap: store.scanRetainedItemsCap
)

ReclaimPlanView(
plan: store.reclaimPlan,
activeFilters: store.activeCleanupFilterDescriptions
Expand All @@ -17,9 +23,10 @@ struct OverviewView: View {
perform(action)
}

SizeDistributionView(store: store)

let allOverviewItems = store.items(for: .overview)

SizeDistributionView(store: store, overviewItems: allOverviewItems)

let overviewItems = Array(allOverviewItems.prefix(12))
let isFiltered = store.hasActiveDisplayFilters
HStack(alignment: .top, spacing: 16) {
Expand Down Expand Up @@ -78,6 +85,33 @@ struct OverviewView: View {
}
}

/// Collapsed-by-default disclosure stating the active scan bounds. Without this, a ranked
/// list capped at 800 or a duplicate group missing because a file fell under the configured
/// threshold reads as "broken" rather than "working as designed" — see 6.1-PLAN.md U-3.
private struct ScanLimitsDisclosure: View {
let rankedResultsCap: Int
let duplicateThresholdMB: Int
let retainedItemsCap: Int

var body: some View {
DisclosureGroup {
VStack(alignment: .leading, spacing: 4) {
Text("Largest Files, Largest Folders, and similar ranked lists show the top \(rankedResultsCap.formatted()) matches.")
Text("Duplicate detection considers files \(duplicateThresholdMB) MB or larger.")
Text("Folder Tree and Storage Map retain up to \(retainedItemsCap.formatted()) of the largest items.")
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 4)
} label: {
Label("Scan limits", systemImage: "info.circle")
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
.accessibilityLabel("Scan limits disclosure")
}
}

private func previewCountLabel(visible: Int, total: Int) -> String {
guard total > visible else {
return "\(visible.formatted()) items"
Expand Down Expand Up @@ -209,10 +243,10 @@ private struct ReclaimPlanSectionCard: View {

private struct SizeDistributionView: View {
@ObservedObject var store: ScanStore
let overviewItems: [StorageScopeCore.StorageItem]

var body: some View {
VStack(alignment: .leading, spacing: 12) {
let overviewItems = store.items(for: .overview)
let items = Array(overviewItems.prefix(10))

VStack(alignment: .leading, spacing: 3) {
Expand Down Expand Up @@ -249,6 +283,7 @@ private struct SizeDistributionView: View {
) {
store.selectedItemID = item.id
}
.equatable()
if index < items.count - 1 {
Divider()
}
Expand All @@ -269,13 +304,19 @@ private struct SizeDistributionView: View {
}
}

private struct StorageMapRow: View {
private struct StorageMapRow: View, Equatable {
let item: StorageScopeCore.StorageItem
let maxSize: Int64
let isSelected: Bool
let onTap: () -> Void
@State private var isHovered = false

// Excludes `onTap`: closures aren't Equatable and every row's closure is
// freshly allocated per render anyway, same pattern as StorageItemRow.
static func == (lhs: StorageMapRow, rhs: StorageMapRow) -> Bool {
lhs.item == rhs.item && lhs.maxSize == rhs.maxSize && lhs.isSelected == rhs.isSelected
}

var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
Expand Down Expand Up @@ -303,6 +344,7 @@ private struct StorageMapRow: View {
}
}
.frame(height: 8)
.accessibilityHidden(true)
}
}
.padding(.horizontal, 12)
Expand All @@ -311,8 +353,12 @@ private struct StorageMapRow: View {
.selectionBackground(isSelected: isSelected)
.background(isHovered && !isSelected ? Color.primary.opacity(0.04) : Color.clear)
}
.buttonStyle(.plain)
.buttonStyle(.pressableRow)
.onHover { isHovered = $0 }
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.name), \(StorageFormat.bytes(item.displaySize))")
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityHint("Selects this storage item")
}
}

Expand Down
3 changes: 2 additions & 1 deletion Sources/StorageScope/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct SettingsView: View {
SettingsSection(title: "Scan Options") {
Toggle("Include hidden files", isOn: store.filterBinding(\.includeHiddenFiles))
Stepper("Treat files older than \(store.oldFileAgeDays) days as old", value: store.filterBinding(\.oldFileAgeDays), in: 30...1440, step: 30)
Stepper("Detect duplicates \(store.duplicateCandidateThresholdMB) MB or larger", value: store.filterBinding(\.duplicateCandidateThresholdMB), in: 1...500, step: 1)

if let status = store.scanOptionsStatusText {
HStack {
Expand All @@ -26,7 +27,7 @@ struct SettingsView: View {
}
}

SettingsFootnote("Hidden files and old-file age affect scan results. Existing results keep their previous scan options until you rescan.")
SettingsFootnote("Hidden files, old-file age, and the duplicate threshold affect scan results. Existing results keep their previous scan options until you rescan.")
}

Divider()
Expand Down
4 changes: 4 additions & 0 deletions Sources/StorageScope/Views/SidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ struct SidebarView: View {
ScanStatusFooter(store: store)
}
.background(.bar)
.onAppear {
store.refreshMountedVolumes()
}
}
}

Expand Down Expand Up @@ -283,6 +286,7 @@ private struct ScanStatusFooter: View {
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(2)
.help(scan.rootURL.path)
} else {
Label("No scan yet", systemImage: "internaldrive")
.font(.headline)
Expand Down
3 changes: 2 additions & 1 deletion Sources/StorageScope/Views/StorageItemTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ private struct StorageItemRow: View, Equatable {
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.help(item.url.path)
}
}
.frame(minWidth: 220, maxWidth: .infinity, alignment: .leading)
Expand All @@ -296,7 +297,7 @@ private struct StorageItemRow: View, Equatable {
.selectionBackground(isSelected: isSelected)
.background(isHovered && !isSelected ? Color.primary.opacity(0.04) : Color.clear)
}
.buttonStyle(.plain)
.buttonStyle(.pressableRow)
.onHover { isHovered = $0 }
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.name), \(StorageFormat.label(for: item.kind)), \(StorageFormat.bytes(item.displaySize))")
Expand Down
Loading
Loading