diff --git a/README.md b/README.md index e565f17..5417890 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,19 @@ The app scans only folders the user grants through macOS folder selection or sto The name is intentional: StorageScope is not a black-box cleaner. It scopes storage pressure, separates verified duplicates from review-only suggestions, and helps the user decide what to reclaim. -**Current release:** v0.7.0, focused on scan control, live results, pause/resume, folder exclusions, and redacted presentation mode for screen sharing. +**Current release:** v0.7.1, focused on window sizing, keyboard cleanup navigation, and visual polish across the main storage views. -[Download v0.7.0](https://github.com/RasputinKaiser/StorageScope/releases/download/v0.7.0/StorageScope-0.7.0.dmg) · [Changelog](docs/changelog.html) · [Privacy](PRIVACY.md) · [GitHub Pages](https://rasputinkaiser.github.io/StorageScope/) +[Download v0.7.1](https://github.com/RasputinKaiser/StorageScope/releases/download/v0.7.1/StorageScope-0.7.1.dmg) · [Changelog](docs/changelog.html) · [Privacy](PRIVACY.md) · [GitHub Pages](https://rasputinkaiser.github.io/StorageScope/) -![StorageScope v0.7.0 overview with redacted file and folder names](docs/images/storagescope-overview.png) +![StorageScope v0.7.1 overview with redacted file and folder names](docs/images/storagescope-overview.png) -## What's New In v0.7.0 +## What's New In v0.7.1 -- Live scan results stream into the storage views while the scan is still running. -- Pause, resume, and cancel controls keep long scans understandable and interruptible. -- Folder exclusions let scans skip folders such as `node_modules`, `.git`, and cache directories entirely. -- Redaction mode masks file and folder names/paths with stable placeholders for screenshots and screen sharing. -- Cleanup Review keeps verified duplicate reclaim separate from review-only cleanup suggestions. +- Main and Settings windows now open at roomier default sizes so dense controls and cleanup review states are not clipped. +- Cleanup Review, Folder Tree, and item tables support keyboard-first movement, reveal, and selection flows. +- Storage views use cleaner row spacing, stronger empty/loading states, and steadier column behavior for large scans. +- Overview, sidebar, tree, type breakdown, and duplicate-review surfaces were visually tuned for better scanning at a glance. +- New tests cover keyboard selection, folder-tree reveal behavior, and tree navigation state. ## Highlights @@ -38,15 +38,15 @@ The name is intentional: StorageScope is not a black-box cleaner. It scopes stor ## Screenshots -The screenshots below were captured from the v0.7.0 macOS build with redaction mode enabled, so placeholder names are visible while sizes, counts, and cleanup classifications remain real. +The scanned-state screenshots below were captured from the v0.7.1 macOS build with redaction mode enabled, so placeholder names are visible while sizes, counts, and cleanup classifications remain real. -| Overview | Cleanup Review | +| Cold Launch | Scanned Overview | | --- | --- | -| ![StorageScope overview showing reclaim lanes and redacted folder names](docs/images/storagescope-v070-overview-redacted.png) | ![StorageScope cleanup review showing verified duplicate reclaim and redacted file names](docs/images/storagescope-v070-cleanup-redacted.png) | +| ![StorageScope cold launch showing the redesigned welcome state](docs/images/storagescope-v071-cold-launch.png) | ![StorageScope overview showing reclaim lanes and redacted folder names](docs/images/storagescope-v071-overview-redacted.png) | -| Privacy Setting | -| --- | -| ![StorageScope settings showing the Redact file and folder names toggle enabled](docs/images/storagescope-v070-settings-redaction.png) | +| Cleanup Review | Privacy Setting | +| --- | --- | +| ![StorageScope cleanup review showing verified duplicate reclaim and redacted file names](docs/images/storagescope-v071-cleanup-redacted.png) | ![StorageScope settings showing the Redact file and folder names toggle enabled](docs/images/storagescope-v071-settings-redaction.png) | ## Use Cases diff --git a/Sources/StorageScope/App/StorageScopeApp.swift b/Sources/StorageScope/App/StorageScopeApp.swift index 5281f24..5d5ab05 100644 --- a/Sources/StorageScope/App/StorageScopeApp.swift +++ b/Sources/StorageScope/App/StorageScopeApp.swift @@ -50,10 +50,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSToolbarDelegate, NSM defer: false ) window.title = windowTitle(for: store.scan) - window.minSize = NSSize(width: 1180, height: 760) + // Keep the floor below common built-in displays (1280×800, 1440×900). The + // effective minimum used to be dictated by SwiftUI content constraints + // (~1819pt with sidebar + inspector open) leaking through NSHostingView's + // default sizingOptions — see UI_PLAN.md P0.1. + window.minSize = NSSize(width: 1080, height: 700) window.center() - window.setFrameAutosaveName("StorageScopeMainWindow") - window.contentView = NSHostingView(rootView: ContentView(store: store, onOpenSettings: { [weak self] in self?.showSettings() })) + // v2: the pre-0.8 autosave carried split-divider state from builds whose + // minimum window width was ~1819pt; restoring it re-clipped the new layout. + window.setFrameAutosaveName("StorageScopeMainWindow.v2") + let hostingView = NSHostingView(rootView: ContentView(store: store, onOpenSettings: { [weak self] in self?.showSettings() })) + // Without this, NSHostingView installs the SwiftUI hierarchy's min-size as + // window constraints, overriding `window.minSize` and preventing the window + // from fitting small displays. Layout min-widths are handled in the views + // themselves (ViewThatFits fallbacks) instead. + hostingView.sizingOptions = [] + window.contentView = hostingView + clampFrameToVisibleScreen(window) window.toolbar = makeToolbar() window.toolbarStyle = .unified window.makeKeyAndOrderFront(nil) @@ -71,6 +84,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSToolbarDelegate, NSM } } + /// A frame autosaved on a large external display must never restore off-screen + /// on a smaller one (UI_PLAN.md P0.5). Runs after `setFrameAutosaveName` has + /// restored the previous frame. + private func clampFrameToVisibleScreen(_ window: NSWindow) { + guard let screen = window.screen ?? NSScreen.main else { return } + let visible = screen.visibleFrame + var frame = window.frame + guard !visible.contains(frame) else { return } + frame.size.width = min(frame.width, visible.width) + frame.size.height = min(frame.height, visible.height) + frame.origin.x = max(visible.minX, min(frame.origin.x, visible.maxX - frame.width)) + frame.origin.y = max(visible.minY, min(frame.origin.y, visible.maxY - frame.height)) + window.setFrame(frame, display: false) + } + private func windowTitle(for scan: StorageScan?) -> String { guard let scan else { return "StorageScope" @@ -183,9 +211,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSToolbarDelegate, NSM return } + // Resizable so no section is ever cut off without recourse (UI_PLAN.md P2); + // the grouped Form scrolls, and min sizes come from SettingsView's frame. let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 560, height: 460), - styleMask: [.titled, .closable], + contentRect: NSRect(x: 0, y: 0, width: 560, height: 680), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false ) @@ -258,6 +288,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSToolbarDelegate, NSM let cleanupLane = CleanupLaneFilter(developerFixtureValue: value) { store.filters.cleanupLaneFilter = cleanupLane } + if environment["STORAGESCOPE_DEVELOPER_REDACTION"] == "1" { + store.filters.redactionEnabled = true + } } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { diff --git a/Sources/StorageScope/Stores/ScanStore.swift b/Sources/StorageScope/Stores/ScanStore.swift index 6f486f5..d6def5a 100644 --- a/Sources/StorageScope/Stores/ScanStore.swift +++ b/Sources/StorageScope/Stores/ScanStore.swift @@ -1938,3 +1938,233 @@ extension ScanStore { return .scanInternal(message: error.localizedDescription) } } + +// MARK: - Display-layer relative paths (UI_PLAN.md P1.2) + +extension ScanStore { + /// Row-subtitle path relative to the scan root ("Media Projects/render.mov" + /// instead of "/Volumes/Sample/fixture-scan/Media Projects/render.mov"). The root is + /// already shown in the scan header, so repeating the absolute prefix on every + /// row was pure noise. Falls back to the redacted full path when redaction is + /// on, and to the absolute path for items outside the root (shouldn't happen). + func displayRelativePath(for item: StorageItem) -> String { + guard !filters.redactionEnabled else { return filters.displayPath(for: item) } + guard let rootURL = scan?.rootURL else { return item.url.path } + return Self.relativePath(of: item.url, under: rootURL) ?? item.url.path + } + + /// Relative-path variant of `FilterStore.displayParentPath(for:)` — the + /// containing folder only, relative to the scan root. + func displayRelativeParentPath(for item: StorageItem) -> String { + guard !filters.redactionEnabled else { return filters.displayParentPath(for: item) } + let parent = item.url.deletingLastPathComponent() + guard let rootURL = scan?.rootURL else { return parent.path } + return Self.relativePath(of: parent, under: rootURL) ?? parent.path + } + + /// Path of `url` relative to `root`, or nil when `url` isn't inside `root`. + /// The root itself maps to its display name rather than an empty string. + static func relativePath(of url: URL, under root: URL) -> String? { + let rootPath = root.standardizedFileURL.path + let path = url.standardizedFileURL.path + guard path.hasPrefix(rootPath) else { return nil } + let suffix = path.dropFirst(rootPath.count).drop(while: { $0 == "/" }) + guard !suffix.isEmpty else { return root.lastPathComponent } + return String(suffix) + } +} + +// MARK: - Folder Tree drill-down (UI_PLAN.md UX round 2) + +extension ScanStore { + /// Verified-duplicate reclaim total, surfaced as a sidebar badge so the user can + /// see where reclaimable space lives before clicking through. + var verifiedReclaimableBytes: Int64 { + verifiedDuplicateGroups.reduce(Int64(0)) { $0 + $1.reclaimableBytes } + } + + /// Jumps to the Folder Tree with `item` selected and every ancestor expanded — + /// the drill-down behind Storage Map rows and the "Show in Folder Tree" context + /// menu action. If the item wasn't retained in the tree (pruned by the retained- + /// items cap), the tree still opens at the root rather than failing silently. + func revealInFolderTree(_ item: StorageItem) { + if let root = scan?.rootItem { + var chain: [String] = [] + if Self.ancestorChain(from: root, to: item.id, chain: &chain) { + treeExpandedIDs.formUnion(chain) + selectedItemID = item.id + } else { + treeExpandedIDs.insert(root.id) + } + } + selectedView = .tree + } + + /// Depth-first path of container IDs from `node` down to (excluding) `targetID`. + /// Returns false when the target isn't in the retained tree. + private static func ancestorChain(from node: StorageItem, to targetID: String, chain: inout [String]) -> Bool { + if node.id == targetID { return true } + guard node.isContainer, !node.children.isEmpty else { return false } + chain.append(node.id) + for child in node.children { + if ancestorChain(from: child, to: targetID, chain: &chain) { + return true + } + } + chain.removeLast() + return false + } +} + +// MARK: - Keyboard selection (UI_PLAN.md UX round 3) + +extension ScanStore { + /// Moves the selection by `offset` within the active view's ranked items — + /// the model behind arrow-key navigation in the item tables. Selects the first + /// item when nothing is selected yet. Returns the newly selected ID so the view + /// can scroll it into sight, or nil when there is nothing to select. + @discardableResult + func selectAdjacentItem(offset: Int) -> String? { + let items = items(for: activeView) + guard !items.isEmpty else { return nil } + + let newIndex: Int + if let currentIndex = items.firstIndex(where: { $0.id == selectedItemID }) { + newIndex = min(max(currentIndex + offset, 0), items.count - 1) + } else { + newIndex = offset >= 0 ? 0 : items.count - 1 + } + + let id = items[newIndex].id + selectedItemID = id + return id + } +} + +// MARK: - Tree, cleanup, and type-ahead keyboard navigation (UI_PLAN.md UX round 4) + +extension ScanStore { + /// The Folder Tree rows currently on screen, in visual order: a depth-first walk + /// of the retained tree that descends only into expanded containers and applies + /// the same child filters as `TreeNodeRow` (size threshold + search subtree + /// matches). This is the model behind ↑/↓ in the tree. + func visibleTreeItems() -> [StorageItem] { + guard let root = scan?.rootItem else { return [] } + var result: [StorageItem] = [] + var stack: [StorageItem] = [root] + let threshold = filters.sizeFilter.threshold + while let node = stack.popLast() { + result.append(node) + guard node.isContainer, treeExpandedIDs.contains(node.id) else { continue } + // Reverse so the stack pops children in display order. + for child in node.children.reversed() + where child.displaySize >= threshold && (searchSubtreeMatchIDs?.contains(child.id) ?? true) { + stack.append(child) + } + } + return result + } + + /// ↑/↓ in the Folder Tree: moves the selection through the visible rows, + /// clamping at both ends. Selects the root when nothing is selected yet. + @discardableResult + func selectAdjacentTreeItem(offset: Int) -> String? { + selectAdjacent(in: visibleTreeItems().map(\.id), offset: offset) + } + + /// ← in the Folder Tree: collapses the selected container if it's expanded, + /// otherwise walks up to the parent — mirrors NSOutlineView/Finder list view. + @discardableResult + func collapseOrAscendTreeSelection() -> String? { + let visible = visibleTreeItems() + guard let selected = visible.first(where: { $0.id == selectedItemID }) else { + return selectAdjacentTreeItem(offset: 1) + } + if selected.isContainer, treeExpandedIDs.contains(selected.id) { + treeExpandedIDs.remove(selected.id) + return selected.id + } + guard let root = scan?.rootItem, let parent = Self.parent(of: selected.id, under: root) else { + return selected.id + } + selectedItemID = parent.id + return parent.id + } + + /// → in the Folder Tree: expands the selected container, or steps into its + /// first visible child when it's already expanded. + @discardableResult + func expandOrDescendTreeSelection() -> String? { + let visible = visibleTreeItems() + guard let selected = visible.first(where: { $0.id == selectedItemID }) else { + return selectAdjacentTreeItem(offset: 1) + } + guard selected.isContainer, !selected.children.isEmpty else { return selected.id } + if !treeExpandedIDs.contains(selected.id) { + treeExpandedIDs.insert(selected.id) + return selected.id + } + let after = visibleTreeItems() + if let index = after.firstIndex(where: { $0.id == selected.id }), index + 1 < after.count { + selectedItemID = after[index + 1].id + return after[index + 1].id + } + return selected.id + } + + /// ↑/↓ in Cleanup Review: moves the row selection through the visible + /// candidates. Space then toggles via `toggleSelectedCleanupCandidate()`. + @discardableResult + func selectAdjacentCleanupCandidate(offset: Int) -> String? { + selectAdjacent(in: cleanupCandidates.map(\.item.id), offset: offset) + } + + /// Space in Cleanup Review: toggles the check on the selected candidate. + func toggleSelectedCleanupCandidate() { + guard let candidate = cleanupCandidates.first(where: { $0.item.id == selectedItemID }) else { return } + toggleCleanupCandidate(candidate) + } + + /// Type-to-select in the ranked tables: jumps to the first item whose display + /// name starts with `prefix` (case-insensitive), like Finder. + @discardableResult + func selectItem(matchingPrefix prefix: String) -> String? { + let normalized = prefix.lowercased() + guard !normalized.isEmpty else { return nil } + let ranked = items(for: activeView) + guard let match = ranked.first(where: { filters.displayName(for: $0).lowercased().hasPrefix(normalized) }) else { + return nil + } + selectedItemID = match.id + return match.id + } + + /// Escape: clears an active search. Returns false when there was nothing to + /// clear so the caller can pass the key press on. + @discardableResult + func clearSearchIfActive() -> Bool { + guard !filters.searchText.isEmpty else { return false } + filters.searchText = "" + return true + } + + private func selectAdjacent(in ids: [String], offset: Int) -> String? { + guard !ids.isEmpty else { return nil } + let newIndex: Int + if let currentIndex = ids.firstIndex(where: { $0 == selectedItemID }) { + newIndex = min(max(currentIndex + offset, 0), ids.count - 1) + } else { + newIndex = offset >= 0 ? 0 : ids.count - 1 + } + selectedItemID = ids[newIndex] + return ids[newIndex] + } + + private static func parent(of targetID: String, under node: StorageItem) -> StorageItem? { + for child in node.children { + if child.id == targetID { return node } + if let found = parent(of: targetID, under: child) { return found } + } + return nil + } +} diff --git a/Sources/StorageScope/Views/CleanupReviewView.swift b/Sources/StorageScope/Views/CleanupReviewView.swift index 21cb17d..dbbdd3e 100644 --- a/Sources/StorageScope/Views/CleanupReviewView.swift +++ b/Sources/StorageScope/Views/CleanupReviewView.swift @@ -3,8 +3,44 @@ import SwiftUI struct CleanupReviewView: View { @ObservedObject var store: ScanStore + /// Keyboard navigation focus: ↑/↓ move through candidates, Space toggles the + /// check on the selected row — batch review without touching the mouse + /// (UX round 4). + @FocusState private var listFocused: Bool var body: some View { + ScrollViewReader { proxy in + scrollContent + .focusable() + .focusEffectDisabled() + .focused($listFocused) + .onKeyPress(.upArrow) { + scrollToSelection(store.selectAdjacentCleanupCandidate(offset: -1), proxy: proxy) + return .handled + } + .onKeyPress(.downArrow) { + scrollToSelection(store.selectAdjacentCleanupCandidate(offset: 1), proxy: proxy) + return .handled + } + .onKeyPress(.space) { + guard store.cleanupCandidates.contains(where: { $0.item.id == store.selectedItemID }) else { + return .ignored + } + store.toggleSelectedCleanupCandidate() + return .handled + } + .onKeyPress(.escape) { + store.clearSearchIfActive() ? .handled : .ignored + } + } + } + + private func scrollToSelection(_ id: String?, proxy: ScrollViewProxy) { + guard let id else { return } + proxy.scrollTo(id, anchor: nil) + } + + private var scrollContent: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .firstTextBaseline) { @@ -66,7 +102,7 @@ struct CleanupReviewView: View { .font(.caption) .foregroundStyle(.orange) .padding(.horizontal, 10) - .padding(.vertical, 7) + .padding(.vertical, 8) .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) } @@ -81,29 +117,38 @@ struct CleanupReviewView: View { ) { store.resetCleanupFilters() } - .frame(minHeight: 340) + .frame(minHeight: 220) } else { LazyVStack(spacing: 10) { ForEach(store.cleanupCandidates) { candidate in CleanupCandidateRow( candidate: candidate, displayName: store.filters.displayName(for: candidate.item), - displayPath: store.filters.displayPath(for: candidate.item), + displayPath: store.displayRelativePath(for: candidate.item), isChecked: store.selectedCleanupCandidateIDs.contains(candidate.id), isSelected: store.selectedItemID == candidate.item.id, canTrash: store.canMoveItemToTrash(candidate.item), // Excluding mid-scan would silently no-op: excludeFolder() // routes through rescan(), which guards on !isScanning. canExclude: candidate.item.isContainer && candidate.item.id != store.scan?.rootItem.id && !store.isScanning, - onToggle: { store.toggleCleanupCandidate(candidate) }, + // Clicking both toggles and selects, so a follow-up + // Space/↑/↓ continues from the clicked row. + onToggle: { + store.selectedItemID = candidate.item.id + store.toggleCleanupCandidate(candidate) + listFocused = true + }, onIgnore: { store.ignoreCleanupCandidate(candidate) }, onReveal: { store.selectedItemID = candidate.item.id; store.revealSelectedItem() }, onOpen: { store.selectedItemID = candidate.item.id; store.openSelectedItem() }, onCopyPath: { store.selectedItemID = candidate.item.id; store.copySelectedPath() }, onTrash: { store.moveCleanupCandidateToTrash(candidate) }, - onExclude: { store.excludeFolder(candidate.item) } + onExclude: { store.excludeFolder(candidate.item) }, + onShowInTree: { store.revealInFolderTree(candidate.item) } ) .equatable() + // Anchor for keyboard-driven scroll-follow (UX round 4). + .id(candidate.item.id) } } } @@ -307,6 +352,8 @@ private struct CleanupCandidateRow: View, Equatable { let onCopyPath: () -> Void let onTrash: () -> Void let onExclude: () -> Void + /// Context-menu drill-down into the Folder Tree (UX round 2). + let onShowInTree: () -> Void @State private var isHovered = false static func == (lhs: CleanupCandidateRow, rhs: CleanupCandidateRow) -> Bool { @@ -361,7 +408,7 @@ private struct CleanupCandidateRow: View, Equatable { Text(displayPath) .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } @@ -382,6 +429,7 @@ private struct CleanupCandidateRow: View, Equatable { Button(isChecked ? "Unselect" : "Select") { onToggle() } Button("Ignore Candidate") { onIgnore() } Divider() + Button("Show in Folder Tree") { onShowInTree() } Button("Reveal in Finder") { onReveal() } Button("Open") { onOpen() } Button("Copy Path") { onCopyPath() } @@ -429,7 +477,7 @@ private struct IgnoredCleanupSection: View { Text(store.filters.displayName(for: candidate.item)) .font(.caption.weight(.semibold)) .lineLimit(1) - Text(store.filters.displayPath(for: candidate.item)) + Text(store.displayRelativePath(for: candidate.item)) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/Sources/StorageScope/Views/ContentView.swift b/Sources/StorageScope/Views/ContentView.swift index c75d55e..e626a31 100644 --- a/Sources/StorageScope/Views/ContentView.swift +++ b/Sources/StorageScope/Views/ContentView.swift @@ -1,10 +1,13 @@ +import AppKit import SwiftUI struct ContentView: View { @ObservedObject var store: ScanStore var onOpenSettings: () -> Void = {} @State private var columnVisibility: NavigationSplitViewVisibility = .all - @State private var inspectorVisible = true + // The inspector is secondary UI and must never be the reason the window + // doesn't fit the display — start it closed on narrow screens (UI_PLAN.md P0.3). + @State private var inspectorVisible = (NSScreen.main?.visibleFrame.width ?? 1440) >= 1350 @FocusState private var searchFieldFocused: Bool /// Search-field prompt that reflects what the active view actually filters — @@ -33,13 +36,29 @@ struct ContentView: View { var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { SidebarView(store: store) - .navigationSplitViewColumnWidth(min: 230, ideal: 270, max: 330) + // Ideal stays close to min: NavigationSplitView satisfies every + // column's *ideal* before it compresses toward min — inflated ideals + // made it slide the sidebar off-screen at 1280pt (UI_PLAN.md P0.2). + // 240 fits the smart-view titles plus reclaim badges; total ideals + // (240 + 700 + 260) still clear a 1280pt window comfortably. + .navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 330) } detail: { DetailView(store: store) - .inspector(isPresented: $inspectorVisible) { - InspectorView(store: store) - .inspectorColumnWidth(min: 300, ideal: 330, max: 380) - } + // Cap the *reported* ideal width. NavigationSplitView sizes the + // detail column to its content's ideal (ViewThatFits and ScrollView + // report the widest branch at an unspecified proposal) and slides + // the sidebar off-screen instead of compressing — the + // navigationSplitViewColumnWidth spec alone did not override that. + // The views adapt down to ~600pt via ViewThatFits fallbacks. + .frame(minWidth: 500, idealWidth: 560, maxWidth: .infinity) + .navigationSplitViewColumnWidth(min: 500, ideal: 560) + } + // Attached to the split view (not nested in the detail column): nesting made + // the inspector participate in the detail column's width negotiation, which + // slid the sidebar off-screen and clipped the inspector at ≤1308pt windows. + .inspector(isPresented: $inspectorVisible) { + InspectorView(store: store) + .inspectorColumnWidth(min: 260, ideal: 260, max: 380) } .searchable(text: store.filterBinding(\.searchText), placement: .toolbar, prompt: searchPrompt) .searchSuggestions { diff --git a/Sources/StorageScope/Views/DetailView.swift b/Sources/StorageScope/Views/DetailView.swift index e71e79d..bca561a 100644 --- a/Sources/StorageScope/Views/DetailView.swift +++ b/Sources/StorageScope/Views/DetailView.swift @@ -36,7 +36,9 @@ struct DetailView: View { .transition(.opacity) .animation(.easeInOut(duration: 0.18), value: store.activeView) } - .padding(.leading, 28) + // Leading inset between the split divider and content. Was 28pt — + // an offset baked into every view's minimum width (UI_PLAN.md P0.4). + .padding(.leading, 8) } } } @@ -90,6 +92,54 @@ private struct ScanHeaderView: View { @ObservedObject var store: ScanStore var body: some View { + // Full header (title + metric cards) only on Overview. Every other view + // repeats those numbers at the cost of ~130pt of working space, so they + // get a single-line strip instead (UI_PLAN.md P1.1). + if store.activeView == .overview { + fullHeader + } else { + compactHeader + } + } + + private var compactHeader: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(titleText) + .font(.headline) + .lineLimit(1) + .layoutPriority(1) + + Text(pathText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 12) + + if store.isScanning { + ProgressView() + .controlSize(.small) + } + + Text(compactMetricsText) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .layoutPriority(1) + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + + private var compactMetricsText: String { + let footprint = StorageFormat.bytes(store.scan?.totalBytes ?? store.progress.totalBytes) + let items = (store.scan?.scannedItemCount ?? store.progress.scannedItemCount).formatted() + let reviewable = StorageFormat.bytes(store.potentialReclaimableBytes) + return "\(footprint) · \(items) items · \(reviewable) reviewable" + } + + private var fullHeader: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 4) { @@ -109,36 +159,60 @@ private struct ScanHeaderView: View { } } - HStack(spacing: 12) { - MetricCard( - title: "Footprint", - value: StorageFormat.bytes(store.scan?.totalBytes ?? store.progress.totalBytes), - systemImage: "internaldrive.fill", - tint: .blue - ) - MetricCard( - title: "Items", - value: (store.scan?.scannedItemCount ?? store.progress.scannedItemCount).formatted(), - systemImage: "square.stack.3d.up.fill", - tint: .green - ) - MetricCard( - title: "Large Files", - value: (store.scan?.largestFiles.count ?? 0).formatted(), - systemImage: "doc.text.magnifyingglass", - tint: .orange - ) - MetricCard( - title: "Reviewable", - value: StorageFormat.bytes(store.potentialReclaimableBytes), - systemImage: "checklist", - tint: .purple - ) + // Four cards in a row when they fit; a 2×2 grid on narrow windows so the + // header never dictates a >900pt minimum width (UI_PLAN.md P0.2). + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + metricCards.0 + metricCards.1 + metricCards.2 + metricCards.3 + } + + Grid(horizontalSpacing: 12, verticalSpacing: 12) { + GridRow { + metricCards.0 + metricCards.1 + } + GridRow { + metricCards.2 + metricCards.3 + } + } } } .padding(20) } + private var metricCards: (MetricCard, MetricCard, MetricCard, MetricCard) { + ( + MetricCard( + title: "Footprint", + value: StorageFormat.bytes(store.scan?.totalBytes ?? store.progress.totalBytes), + systemImage: "internaldrive.fill", + tint: .blue + ), + MetricCard( + title: "Items", + value: (store.scan?.scannedItemCount ?? store.progress.scannedItemCount).formatted(), + systemImage: "square.stack.3d.up.fill", + tint: .green + ), + MetricCard( + title: "Large Files", + value: (store.scan?.largestFiles.count ?? 0).formatted(), + systemImage: "doc.text.magnifyingglass", + tint: .orange + ), + MetricCard( + title: "Reviewable", + value: StorageFormat.bytes(store.potentialReclaimableBytes), + systemImage: "checklist", + tint: .purple + ) + ) + } + private var titleText: String { guard let rootURL = store.scan?.rootURL else { return "Scanning..." } guard store.filters.redactionEnabled else { return rootURL.lastPathComponent.nonEmpty ?? rootURL.path } @@ -222,7 +296,7 @@ private struct ScanNoticeView: View { } } .padding(.horizontal, 12) - .padding(.vertical, 9) + .padding(.vertical, 8) .cardBackground() .padding(.horizontal, 20) .padding(.bottom, 12) diff --git a/Sources/StorageScope/Views/DuplicateCandidatesView.swift b/Sources/StorageScope/Views/DuplicateCandidatesView.swift index ebc977e..0ff4997 100644 --- a/Sources/StorageScope/Views/DuplicateCandidatesView.swift +++ b/Sources/StorageScope/Views/DuplicateCandidatesView.swift @@ -33,7 +33,7 @@ struct DuplicateCandidatesView: View { ) { store.resetDisplayFilters() } - .frame(minHeight: 320) + .frame(minHeight: 220) } else { LazyVStack(spacing: 12) { if !store.verifiedDuplicateGroups.isEmpty { @@ -144,12 +144,13 @@ private struct VerifiedDuplicateGroupCard: View { Text("One copy is the keeper and is never moved to Trash. Use a row's menu to reassign the keeper.") .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) DuplicateItemList( items: group.items, filters: store.filters, + parentPath: { store.displayRelativeParentPath(for: $0) }, selectedItemID: store.selectedItemID, keeperItemID: store.keeperItemID(for: group), onSetKeeper: { item in store.setKeeper(itemID: item.id, for: group) }, @@ -241,6 +242,7 @@ private struct DuplicateGroupCard: View { DuplicateItemList( items: visibleItems, filters: store.filters, + parentPath: { store.displayRelativeParentPath(for: $0) }, selectedItemID: store.selectedItemID, keeperItemID: nil, onSetKeeper: nil, @@ -259,6 +261,10 @@ private struct DuplicateGroupCard: View { private struct DuplicateItemList: View { let items: [StorageItem] @ObservedObject var filters: FilterStore + /// Row-trailing path, relative to the scan root (UI_PLAN.md P1.2). Injected as a + /// closure because this list only holds `filters`, not the `ScanStore` that knows + /// the scan root; observing `filters` keeps redaction toggles reactive. + let parentPath: (StorageItem) -> String let selectedItemID: String? var keeperItemID: String? = nil var onSetKeeper: ((StorageItem) -> Void)? = nil @@ -276,7 +282,7 @@ private struct DuplicateItemList: View { DuplicateFileRow( item: item, displayName: filters.displayName(for: item), - displayParentPath: filters.displayParentPath(for: item), + displayParentPath: parentPath(item), isKeeper: isKeeper, isSelected: selectedItemID == item.id, onSetKeeper: onSetKeeper != nil && !isKeeper ? { onSetKeeper?(item) } : nil @@ -344,7 +350,7 @@ private struct DuplicateFileRow: View, Equatable { .lineLimit(1) .truncationMode(.middle) } - .padding(.vertical, 7) + .padding(.vertical, 8) .contentShape(Rectangle()) .background(isHovered && !isSelected ? Color.primary.opacity(0.04) : Color.clear) } diff --git a/Sources/StorageScope/Views/KeeperComparisonSheet.swift b/Sources/StorageScope/Views/KeeperComparisonSheet.swift index 8f7a85f..7b0003e 100644 --- a/Sources/StorageScope/Views/KeeperComparisonSheet.swift +++ b/Sources/StorageScope/Views/KeeperComparisonSheet.swift @@ -153,7 +153,7 @@ private struct KeeperComparisonRow: View { Label(StorageFormat.date(item.modifiedAt), systemImage: "clock") } .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) } Spacer(minLength: 12) diff --git a/Sources/StorageScope/Views/OverviewView.swift b/Sources/StorageScope/Views/OverviewView.swift index c630d34..a9757ec 100644 --- a/Sources/StorageScope/Views/OverviewView.swift +++ b/Sources/StorageScope/Views/OverviewView.swift @@ -29,43 +29,32 @@ struct OverviewView: View { let overviewItems = Array(allOverviewItems.prefix(12)) let isFiltered = store.hasActiveDisplayFilters - HStack(alignment: .top, spacing: 16) { - StorageItemTable( - title: isFiltered ? "Matching Children" : "Largest Children", - subtitle: isFiltered ? "Immediate children matching the active filters" : "Immediate storage pressure under the scanned root", - items: overviewItems, - store: store, - compact: true, - countLabel: previewCountLabel(visible: overviewItems.count, total: allOverviewItems.count) - ) - - VStack(spacing: 16) { - InsightCard( - title: isFiltered ? "Largest Matching File" : "Largest File", - item: store.items(for: .largestFiles).first, - systemImage: "doc.fill", - filters: store.filters - ) { item in - store.selectedItemID = item.id - } - InsightCard( - title: isFiltered ? "Largest Matching Folder" : "Largest Folder", - item: store.items(for: .largestFolders).first, - systemImage: "folder.fill", - filters: store.filters - ) { item in - store.selectedItemID = item.id - } - InsightCard( - title: isFiltered ? "Oldest Matching Large File" : "Oldest Large File", - item: store.oldLargeFiles.first, - systemImage: "clock.fill", - filters: store.filters - ) { item in - store.selectedItemID = item.id - } + let childrenTable = StorageItemTable( + title: isFiltered ? "Matching Children" : "Largest Children", + subtitle: isFiltered ? "Immediate children matching the active filters" : "Immediate storage pressure under the scanned root", + items: overviewItems, + store: store, + compact: true, + countLabel: previewCountLabel(visible: overviewItems.count, total: allOverviewItems.count) + ) + + // Side-by-side when the detail column is wide enough; otherwise the + // insight cards drop below the table instead of squeezing it + // (UI_PLAN.md P1.4 pulled forward to keep 1280×800 clip-free). + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 16) { + childrenTable + .frame(minWidth: 560) + + insightCards(isFiltered: isFiltered) + .frame(width: 280) + } + + VStack(alignment: .leading, spacing: 16) { + childrenTable + + insightCards(isFiltered: isFiltered) } - .frame(width: 280) } } } @@ -73,6 +62,35 @@ struct OverviewView: View { } } + private func insightCards(isFiltered: Bool) -> some View { + VStack(spacing: 16) { + InsightCard( + title: isFiltered ? "Largest Matching File" : "Largest File", + item: store.items(for: .largestFiles).first, + systemImage: "doc.fill", + filters: store.filters + ) { item in + store.selectedItemID = item.id + } + InsightCard( + title: isFiltered ? "Largest Matching Folder" : "Largest Folder", + item: store.items(for: .largestFolders).first, + systemImage: "folder.fill", + filters: store.filters + ) { item in + store.selectedItemID = item.id + } + InsightCard( + title: isFiltered ? "Oldest Matching Large File" : "Oldest Large File", + item: store.oldLargeFiles.first, + systemImage: "clock.fill", + filters: store.filters + ) { item in + store.selectedItemID = item.id + } + } + } + private func perform(_ action: ReclaimPlanAction) { switch action { case .reviewVerifiedDuplicates: @@ -284,10 +302,14 @@ private struct SizeDistributionView: View { item: item, maxSize: maxSize, isSelected: store.selectedItemID == item.id, - displayName: store.filters.displayName(for: item) - ) { - store.selectedItemID = item.id - } + displayName: store.filters.displayName(for: item), + onTap: { + store.selectedItemID = item.id + }, + onShowInTree: { + store.revealInFolderTree(item) + } + ) .equatable() if item.id != lastItemID { Divider() @@ -315,6 +337,8 @@ private struct StorageMapRow: View, Equatable { let isSelected: Bool let displayName: String let onTap: () -> Void + /// Drill-down: opens this folder in the Folder Tree with ancestors expanded. + let onShowInTree: () -> Void @State private var isHovered = false // Excludes `onTap`: closures aren't Equatable and every row's closure is @@ -336,6 +360,16 @@ private struct StorageMapRow: View, Equatable { Text(displayName) .lineLimit(1) Spacer() + if isHovered { + Button(action: onShowInTree) { + Image(systemName: "arrow.right.circle") + .foregroundStyle(.tint) + } + .buttonStyle(.borderless) + .controlSize(.small) + .help("Show in Folder Tree") + .accessibilityLabel("Show \(displayName) in Folder Tree") + } Text(StorageFormat.bytes(item.displaySize)) .foregroundStyle(.secondary) .monospacedDigit() @@ -361,6 +395,10 @@ private struct StorageMapRow: View, Equatable { .accessibilityLabel("\(displayName), \(StorageFormat.bytes(item.displaySize))") .accessibilityValue(isSelected ? "Selected" : "Not selected") .accessibilityHint("Selects this storage item") + .simultaneousGesture(TapGesture(count: 2).onEnded { onShowInTree() }) + .contextMenu { + Button("Show in Folder Tree") { onShowInTree() } + } } } diff --git a/Sources/StorageScope/Views/SettingsView.swift b/Sources/StorageScope/Views/SettingsView.swift index 0871757..76a429f 100644 --- a/Sources/StorageScope/Views/SettingsView.swift +++ b/Sources/StorageScope/Views/SettingsView.swift @@ -1,5 +1,9 @@ import SwiftUI +/// Native grouped settings form (UI_PLAN.md P2). Replaces the previous hand-rolled +/// fixed-width VStack: `Form` + `.formStyle(.grouped)` provides macOS-standard section +/// chrome, label/control alignment, and sensible resizing for free. The hosting +/// window (AppDelegate.showSettings) is resizable with the form as its content. struct SettingsView: View { @ObservedObject var store: ScanStore @State private var showingClearCacheAlert = false @@ -7,12 +11,29 @@ struct SettingsView: View { @State private var newExcludedPath: String = "" var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 18) { - SettingsSection(title: "Scan Options") { + Form { + Section { 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) + + LabeledContent("Treat files as old after") { + Stepper( + "\(store.oldFileAgeDays) days", + value: store.filterBinding(\.oldFileAgeDays), + in: 30...1440, + step: 30 + ) + .monospacedDigit() + } + + LabeledContent("Detect duplicates at or above") { + Stepper( + "\(store.duplicateCandidateThresholdMB) MB", + value: store.filterBinding(\.duplicateCandidateThresholdMB), + in: 1...500, + step: 1 + ) + .monospacedDigit() + } if let status = store.scanOptionsStatusText { HStack { @@ -27,31 +48,29 @@ struct SettingsView: View { .disabled(!store.canRescan) } } - + } header: { + Text("Scan Options") + } footer: { SettingsFootnote("Hidden files, old-file age, and the duplicate threshold affect scan results. Existing results keep their previous scan options until you rescan.") } - Divider() - - SettingsSection(title: "Excluded Folders") { + Section { Toggle("Exclude folders", isOn: store.filterBinding(\.excludeFoldersEnabled)) - VStack(alignment: .leading, spacing: 6) { - ForEach(store.filters.excludedPaths, id: \.self) { path in - HStack { - Text(path) - .font(.callout) - .lineLimit(1) - .truncationMode(.middle) - Spacer() - Button { - store.filters.excludedPaths.removeAll { $0 == path } - } label: { - Image(systemName: "minus.circle") - } - .buttonStyle(.borderless) - .help("Remove this exclusion") + ForEach(store.filters.excludedPaths, id: \.self) { path in + HStack { + Text(path) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button { + store.filters.excludedPaths.removeAll { $0 == path } + } label: { + Image(systemName: "minus.circle") } + .buttonStyle(.borderless) + .help("Remove this exclusion") } } @@ -66,33 +85,33 @@ struct SettingsView: View { } .disabled(newExcludedPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - + } header: { + Text("Excluded Folders") + } footer: { SettingsFootnote("Folder names (e.g. node_modules) match anywhere in the tree. Paths starting with ~ or / match only that exact folder and its contents. Excluded folders are skipped entirely during the next scan.") } - Divider() - - SettingsSection(title: "Display Filters") { + Section { Picker("Visible size", selection: store.filterBinding(\.sizeFilter)) { ForEach(SizeFilter.allCases) { filter in Text(filter.title).tag(filter) } } - + } header: { + Text("Display Filters") + } footer: { SettingsFootnote("Size changes only filter the current view. They do not rescan the folder.") } - Divider() - - SettingsSection(title: "Privacy") { + Section { Toggle("Redact file & folder names", isOn: store.filterBinding(\.redactionEnabled)) - + } header: { + Text("Privacy") + } footer: { SettingsFootnote("Replaces file and folder names and paths shown in the app with generic placeholders. Sizes, dates, and counts stay real. Trash, move, and reveal-in-Finder still act on the real files.") } - Divider() - - SettingsSection(title: "Duplicate Hash Cache") { + Section { LabeledContent("Stored entries", value: cacheSnapshot.entryCount.formatted()) if let lastPersistedAt = cacheSnapshot.lastPersistedAt { @@ -107,16 +126,14 @@ struct SettingsView: View { Label("Clear Cache", systemImage: "trash") } .disabled(cacheSnapshot.entryCount == 0) - + } header: { + Text("Duplicate Hash Cache") + } footer: { SettingsFootnote("Cached SHA-256 hashes make rescans faster by skipping unchanged files. Clearing forces a full re-hash on the next scan. Cache lives in your user Caches directory and never leaves the Mac.") } - - Spacer(minLength: 0) } - .padding(20) - .frame(width: 520, alignment: .topLeading) - } // ScrollView - .frame(width: 520) + .formStyle(.grouped) + .frame(minWidth: 480, idealWidth: 560, minHeight: 420, idealHeight: 680) .task { refreshCacheSnapshot() } .alert("Clear Duplicate Hash Cache?", isPresented: $showingClearCacheAlert) { Button("Cancel", role: .cancel) {} @@ -142,21 +159,6 @@ private struct CacheSnapshot: Equatable { let lastPersistedAt: Date? } -private struct SettingsSection: View { - let title: String - @ViewBuilder var content: Content - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(title) - .font(.headline) - - content - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - private struct SettingsFootnote: View { let text: String diff --git a/Sources/StorageScope/Views/SidebarView.swift b/Sources/StorageScope/Views/SidebarView.swift index 173f01e..7724156 100644 --- a/Sources/StorageScope/Views/SidebarView.swift +++ b/Sources/StorageScope/Views/SidebarView.swift @@ -13,7 +13,8 @@ struct SidebarView: View { ForEach(SmartView.allCases) { view in SidebarSmartButton( view: view, - isSelected: store.selectedView == view + isSelected: store.selectedView == view, + badge: badge(for: view) ) { store.selectedView = view } @@ -91,6 +92,21 @@ struct SidebarView: View { store.refreshMountedVolumes() } } + + /// Reclaim-size badges on the two views where space can actually be recovered — + /// the user sees where the money is before clicking through (UX round 2). + private func badge(for view: SmartView) -> String? { + switch view { + case .cleanupReview: + let bytes = store.potentialReclaimableBytes + return bytes > 0 ? StorageFormat.bytes(bytes) : nil + case .duplicateCandidates: + let bytes = store.verifiedReclaimableBytes + return bytes > 0 ? StorageFormat.bytes(bytes) : nil + default: + return nil + } + } } private struct SidebarSectionTitle: View { @@ -112,6 +128,7 @@ private struct SidebarSectionTitle: View { private struct SidebarSmartButton: View { let view: SmartView let isSelected: Bool + var badge: String? = nil let action: () -> Void var body: some View { @@ -125,16 +142,33 @@ private struct SidebarSmartButton: View { Text(view.title) .lineLimit(1) - Text(view.subtitle) - .font(.caption) - .foregroundStyle(isSelected ? Color.primary.opacity(0.75) : Color.secondary) - .lineLimit(1) + // Badge shares the subtitle line: the title never truncates for + // it, and the subtitle is the least important text in the row. + HStack(spacing: 6) { + Text(view.subtitle) + .font(.caption) + .foregroundStyle(isSelected ? Color.primary.opacity(0.75) : Color.secondary) + .lineLimit(1) + + if let badge { + Spacer(minLength: 0) + + Text(badge) + .font(.caption2.weight(.medium).monospacedDigit()) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(.quaternary.opacity(0.6), in: Capsule()) + .layoutPriority(1) + .accessibilityLabel("\(badge) reclaimable") + } + } } Spacer(minLength: 0) } .padding(.horizontal, 8) - .padding(.vertical, 7) + .padding(.vertical, 8) .contentShape(Rectangle()) .selectionBackground(isSelected: isSelected, tint: 0.24, radius: 6) } @@ -212,7 +246,7 @@ private struct SidebarPathButton: View { if let subtitle { Text(subtitle) .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) .lineLimit(1) } } @@ -283,24 +317,24 @@ private struct ScanStatusFooter: View { if store.isScanPaused { Text("Scan paused — resume to continue") .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) } else if let scanStartedAt = store.scanStartedAt { TimelineView(.periodic(from: scanStartedAt, by: 1)) { context in HStack(spacing: 6) { if store.progress.itemsPerSecond > 0 { Text("~\(Int(store.progress.itemsPerSecond.rounded())) items/sec") .font(.caption2.monospacedDigit()) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) } Text(elapsedText(from: scanStartedAt, to: context.date)) .font(.caption2.monospacedDigit()) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) } } } Text(currentPathDisplay) .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) .lineLimit(2) .accessibilityLabel("Current scan path") } else if let scan = store.scan { @@ -311,7 +345,7 @@ private struct ScanStatusFooter: View { .foregroundStyle(.secondary) Text(rootPathDisplay(for: scan.rootURL)) .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) .lineLimit(2) .help(store.filters.redactionEnabled ? "" : scan.rootURL.path) } else { diff --git a/Sources/StorageScope/Views/StorageItemTable.swift b/Sources/StorageScope/Views/StorageItemTable.swift index c5aae34..7ff3881 100644 --- a/Sources/StorageScope/Views/StorageItemTable.swift +++ b/Sources/StorageScope/Views/StorageItemTable.swift @@ -1,3 +1,4 @@ +import QuickLook import StorageScopeCore import SwiftUI @@ -8,6 +9,15 @@ struct StorageItemTable: View { @ObservedObject var store: ScanStore var compact = false var countLabel: String? + /// Focus target for keyboard navigation: clicking any row moves focus here so + /// ↑/↓/Space work immediately after a click (UX round 3). + @FocusState private var tableFocused: Bool + /// Space-bar Quick Look for the selected item — preview before deciding to trash. + @State private var quickLookURL: URL? + /// Type-to-select buffer (Finder-style): letters accumulate while typed within + /// 0.8s of each other, then the buffer resets lazily on the next key. + @State private var typeAheadBuffer = "" + @State private var typeAheadLastKeyAt = Date.distantPast var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -55,39 +65,91 @@ struct StorageItemTable: View { } .frame(minHeight: 220) } else { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(items) { item in - StorageItemRow( - item: item, - isSelected: store.selectedItemID == item.id, - searchText: store.filters.searchText, - displayName: store.filters.displayName(for: item), - displayPath: store.filters.displayPath(for: item), - redactionEnabled: store.filters.redactionEnabled, - canTrash: store.canMoveItemToTrash(item), - // Excluding mid-scan would silently no-op: excludeFolder() - // routes through rescan(), which guards on !isScanning. - canExclude: item.isContainer && item.id != store.scan?.rootItem.id && !store.isScanning, - onSelect: { store.selectedItemID = item.id }, - onOpen: { store.selectedItemID = item.id; store.openSelectedItem() }, - onReveal: { store.selectedItemID = item.id; store.revealSelectedItem() }, - onCopyPath: { store.selectedItemID = item.id; store.copySelectedPath() }, - onTrash: { store.selectedItemID = item.id; store.moveSelectedItemToTrash() }, - onExclude: { store.excludeFolder(item) } - ) - .equatable() - Divider() + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(items) { item in + StorageItemRow( + item: item, + isSelected: store.selectedItemID == item.id, + searchText: store.filters.searchText, + displayName: store.filters.displayName(for: item), + displayPath: store.displayRelativePath(for: item), + fullPath: store.filters.displayPath(for: item), + redactionEnabled: store.filters.redactionEnabled, + canTrash: store.canMoveItemToTrash(item), + // Excluding mid-scan would silently no-op: excludeFolder() + // routes through rescan(), which guards on !isScanning. + canExclude: item.isContainer && item.id != store.scan?.rootItem.id && !store.isScanning, + onSelect: { store.selectedItemID = item.id; tableFocused = true }, + onOpen: { store.selectedItemID = item.id; store.openSelectedItem() }, + onReveal: { store.selectedItemID = item.id; store.revealSelectedItem() }, + onCopyPath: { store.selectedItemID = item.id; store.copySelectedPath() }, + onTrash: { store.selectedItemID = item.id; store.moveSelectedItemToTrash() }, + onExclude: { store.excludeFolder(item) }, + onShowInTree: { store.revealInFolderTree(item) }, + onQuickLook: { store.selectedItemID = item.id; quickLookURL = item.url } + ) + .equatable() + .id(item.id) + Divider() + } } } + .frame(minHeight: listMinHeight) + .focusable() + .focusEffectDisabled() + .focused($tableFocused) + .onKeyPress(.upArrow) { + moveSelection(-1, proxy: proxy) + return .handled + } + .onKeyPress(.downArrow) { + moveSelection(1, proxy: proxy) + return .handled + } + .onKeyPress(.space) { + guard let selected = items.first(where: { $0.id == store.selectedItemID }) else { return .ignored } + quickLookURL = selected.url + return .handled + } + .onKeyPress(.escape) { + store.clearSearchIfActive() ? .handled : .ignored + } + .onKeyPress(characters: .alphanumerics, phases: .down) { press in + typeToSelect(press.characters, proxy: proxy) + return .handled + } + .quickLookPreview($quickLookURL) } - .frame(minHeight: listMinHeight) } } .cardBackground() } } + /// Finder-style type-to-select: consecutive keystrokes within 0.8s form a prefix; + /// a longer pause starts a fresh one. No Timer — the buffer resets lazily on the + /// next keystroke, so idle cost is zero. + private func typeToSelect(_ characters: String, proxy: ScrollViewProxy) { + let now = Date() + if now.timeIntervalSince(typeAheadLastKeyAt) > 0.8 { + typeAheadBuffer = "" + } + typeAheadLastKeyAt = now + typeAheadBuffer += characters + guard let id = store.selectItem(matchingPrefix: typeAheadBuffer) else { return } + proxy.scrollTo(id, anchor: nil) + } + + /// Arrow-key navigation: the selection math lives in `ScanStore.selectAdjacentItem` + /// (unit-tested); the view only scrolls the result into sight. No animation on the + /// scroll — keyboard repeat (holding ↓) should track instantly, not fight a spring. + private func moveSelection(_ offset: Int, proxy: ScrollViewProxy) { + guard let id = store.selectAdjacentItem(offset: offset) else { return } + proxy.scrollTo(id, anchor: nil) + } + /// Scales the scroll area to its content rather than reserving ~420pt for a sparse /// result set. Old Large Files frequently surfaces 1-3 files; leaving the old 420pt /// reserved produced the "600px of void below one row" complaint. When there are @@ -177,9 +239,8 @@ private struct StorageItemHeader: View { SortableColumnHeader(label: "Size", width: 96, alignment: .trailing, isActive: sortOption == .sizeDescending, indicator: .down) { onSortChange(.sizeDescending) } - SortableColumnHeader(label: "Kind", width: 94, alignment: .leading, isActive: sortOption == .kind, indicator: .up) { - onSortChange(.kind) - } + // No "Kind" column: it read "File" for nearly every row (UI_PLAN.md P1.3). + // Kind still exists as a sort option and in the row's accessibility label. SortableColumnHeader(label: "Modified", width: 112, alignment: .leading, isActive: isModifiedActive, indicator: sortOption == .modifiedNewest ? .down : .up) { onSortChange(toggleModified) } @@ -187,7 +248,7 @@ private struct StorageItemHeader: View { .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .padding(.horizontal, 12) - .padding(.vertical, 9) + .padding(.vertical, 8) } private var isModifiedActive: Bool { @@ -249,7 +310,10 @@ private struct StorageItemRow: View, Equatable { let isSelected: Bool let searchText: String let displayName: String + /// Shown under the name — relative to the scan root (UI_PLAN.md P1.2). let displayPath: String + /// Absolute (or redacted) path, surfaced via hover help only. + let fullPath: String let redactionEnabled: Bool let canTrash: Bool let canExclude: Bool @@ -259,6 +323,10 @@ private struct StorageItemRow: View, Equatable { let onCopyPath: () -> Void let onTrash: () -> Void let onExclude: () -> Void + /// Context-menu drill-down into the Folder Tree (UX round 2). + let onShowInTree: () -> Void + /// Quick Look preview — also bound to Space at the table level (UX round 3). + let onQuickLook: () -> Void @State private var isHovered = false // Closures are excluded: they're stable references back to the store and @@ -269,6 +337,7 @@ private struct StorageItemRow: View, Equatable { && lhs.searchText == rhs.searchText && lhs.canTrash == rhs.canTrash && lhs.canExclude == rhs.canExclude && lhs.displayName == rhs.displayName && lhs.displayPath == rhs.displayPath + && lhs.fullPath == rhs.fullPath && lhs.redactionEnabled == rhs.redactionEnabled } @@ -294,7 +363,7 @@ private struct StorageItemRow: View, Equatable { .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) - .help(displayPath) + .help(fullPath) } } .frame(minWidth: 220, maxWidth: .infinity, alignment: .leading) @@ -303,10 +372,6 @@ private struct StorageItemRow: View, Equatable { .font(.system(.body, design: .rounded).monospacedDigit()) .frame(width: 96, alignment: .trailing) - Text(StorageFormat.label(for: item.kind)) - .foregroundStyle(.secondary) - .frame(width: 94, alignment: .leading) - Text(StorageFormat.relativeOrAbsoluteDate(item.modifiedAt)) .foregroundStyle(.secondary) .frame(width: 112, alignment: .leading) @@ -325,6 +390,9 @@ private struct StorageItemRow: View, Equatable { .accessibilityHint("Selects this storage item") .simultaneousGesture(TapGesture(count: 2).onEnded { onOpen() }) .contextMenu { + Button("Quick Look") { onQuickLook() } + Button("Show in Folder Tree") { onShowInTree() } + Divider() Button("Reveal in Finder") { onReveal() } Button("Open") { onOpen() } Button("Copy Path") { onCopyPath() } diff --git a/Sources/StorageScope/Views/TrashConfirmationSheet.swift b/Sources/StorageScope/Views/TrashConfirmationSheet.swift index d40abde..f606be8 100644 --- a/Sources/StorageScope/Views/TrashConfirmationSheet.swift +++ b/Sources/StorageScope/Views/TrashConfirmationSheet.swift @@ -233,7 +233,7 @@ private struct TrashReviewRow: View { Text(item.reason) .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(.secondary) .lineLimit(2) } diff --git a/Sources/StorageScope/Views/TreeExplorerView.swift b/Sources/StorageScope/Views/TreeExplorerView.swift index c7fb44f..dfdacb9 100644 --- a/Sources/StorageScope/Views/TreeExplorerView.swift +++ b/Sources/StorageScope/Views/TreeExplorerView.swift @@ -1,10 +1,56 @@ +import QuickLook import StorageScopeCore import SwiftUI struct TreeExplorerView: View { @ObservedObject var store: ScanStore + /// Keyboard navigation focus — clicking any row moves focus here so + /// ↑/↓/←/→/Space work immediately after a click (UX round 4). + @FocusState private var treeFocused: Bool + @State private var quickLookURL: URL? var body: some View { + ScrollViewReader { proxy in + scrollContent + .focusable() + .focusEffectDisabled() + .focused($treeFocused) + .onKeyPress(.upArrow) { + scrollToSelection(store.selectAdjacentTreeItem(offset: -1), proxy: proxy) + return .handled + } + .onKeyPress(.downArrow) { + scrollToSelection(store.selectAdjacentTreeItem(offset: 1), proxy: proxy) + return .handled + } + .onKeyPress(.leftArrow) { + scrollToSelection(store.collapseOrAscendTreeSelection(), proxy: proxy) + return .handled + } + .onKeyPress(.rightArrow) { + scrollToSelection(store.expandOrDescendTreeSelection(), proxy: proxy) + return .handled + } + .onKeyPress(.space) { + guard let selected = store.visibleTreeItems().first(where: { $0.id == store.selectedItemID }) else { + return .ignored + } + quickLookURL = selected.url + return .handled + } + .onKeyPress(.escape) { + store.clearSearchIfActive() ? .handled : .ignored + } + .quickLookPreview($quickLookURL) + } + } + + private func scrollToSelection(_ id: String?, proxy: ScrollViewProxy) { + guard let id else { return } + proxy.scrollTo(id, anchor: nil) + } + + private var scrollContent: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 3) { @@ -60,7 +106,7 @@ struct TreeExplorerView: View { searchText: store.filters.searchText, redactionEnabled: store.filters.redactionEnabled, displayName: { store.filters.displayName(for: $0) }, - selectItem: { store.selectedItemID = $0 }, + selectItem: { store.selectedItemID = $0; treeFocused = true }, openItem: { store.selectedItemID = $0.id; store.openSelectedItem() }, revealItem: { store.selectedItemID = $0.id; store.revealSelectedItem() }, copyItemPath: { store.selectedItemID = $0.id; store.copySelectedPath() }, @@ -82,7 +128,7 @@ struct TreeExplorerView: View { systemImage: "list.bullet.indent", description: Text("Choose a folder to build a navigable storage tree.") ) - .frame(minHeight: 360) + .frame(minHeight: 220) } } .padding(20) @@ -220,6 +266,8 @@ private struct TreeNodeRow: View { Button("Move to Trash", role: .destructive) { trashItem(item) } .disabled(!canTrashItem(item)) } + // Anchor for keyboard-driven scroll-follow (UX round 4). + .id(item.id) if isExpanded { ForEach(visibleChildren) { child in diff --git a/Sources/StorageScope/Views/TypeBreakdownView.swift b/Sources/StorageScope/Views/TypeBreakdownView.swift index c7b0499..99f1085 100644 --- a/Sources/StorageScope/Views/TypeBreakdownView.swift +++ b/Sources/StorageScope/Views/TypeBreakdownView.swift @@ -31,7 +31,7 @@ struct TypeBreakdownView: View { ) { store.resetDisplayFilters() } - .frame(minHeight: 320) + .frame(minHeight: 220) .cardBackground() } else { VStack(alignment: .leading, spacing: 10) { @@ -68,7 +68,7 @@ struct TypeBreakdownView: View { .frame(width: 86, alignment: .trailing) } .padding(.horizontal, 14) - .padding(.vertical, 11) + .padding(.vertical, 12) .accessibilityElement(children: .ignore) .accessibilityLabel("\(stat.category.rawValue) category, \(StorageFormat.bytes(stat.totalBytes))") .accessibilityValue("\(stat.extensionCountLabel), \(stat.fileCountLabel)") @@ -145,7 +145,7 @@ private struct FileTypeRowLabel: View, Equatable { .help(isFocused ? "Filtered by this type" : "") } .padding(.horizontal, 14) - .padding(.vertical, 11) + .padding(.vertical, 12) .contentShape(Rectangle()) .selectionBackground(isSelected: isFocused) } diff --git a/Tests/StorageScopeTests/ScanStoreKeyboardSelectionTests.swift b/Tests/StorageScopeTests/ScanStoreKeyboardSelectionTests.swift new file mode 100644 index 0000000..a4edc73 --- /dev/null +++ b/Tests/StorageScopeTests/ScanStoreKeyboardSelectionTests.swift @@ -0,0 +1,63 @@ +import Foundation +import StorageScopeCore +import Testing +@testable import StorageScope + +@MainActor +@Suite("ScanStore keyboard selection") +struct ScanStoreKeyboardSelectionTests { + @Test("arrow keys walk the ranked list, clamp at both ends, and start from the first item") + func adjacentSelectionWalksAndClamps() async throws { + let store = ScanStore() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("kbd-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + // Distinct sizes so the ranked order is deterministic (largest first). + for (index, name) in ["big.bin", "mid.bin", "small.bin"].enumerated() { + try Data(repeating: 0, count: 4096 * (3 - index)).write(to: root.appendingPathComponent(name)) + } + + store.scanDeveloperFixturePath(root.path) + try await waitForScanToFinish(store) + store.selectedView = .largestFiles + + let ranked = store.items(for: .largestFiles) + guard ranked.count >= 3 else { + Issue.record("expected 3 ranked files, got \(ranked.count)") + return + } + + // No selection yet: ↓ selects the first item. + #expect(store.selectAdjacentItem(offset: 1) == ranked[0].id) + // Walk down twice. + #expect(store.selectAdjacentItem(offset: 1) == ranked[1].id) + #expect(store.selectAdjacentItem(offset: 1) == ranked[2].id) + // Clamp at the bottom. + #expect(store.selectAdjacentItem(offset: 1) == ranked[2].id) + // Walk back up and clamp at the top. + #expect(store.selectAdjacentItem(offset: -1) == ranked[1].id) + #expect(store.selectAdjacentItem(offset: -1) == ranked[0].id) + #expect(store.selectAdjacentItem(offset: -1) == ranked[0].id) + #expect(store.selectedItemID == ranked[0].id) + } + + @Test("selection is a no-op when the active view has no items") + func noItemsNoSelection() { + let store = ScanStore() + store.selectedView = .largestFiles + #expect(store.selectAdjacentItem(offset: 1) == nil) + #expect(store.selectedItemID == nil) + } + + private func waitForScanToFinish(_ store: ScanStore, timeout: Duration = .seconds(5)) async throws { + let start = ContinuousClock.now + while store.isScanning || store.scan == nil { + if ContinuousClock.now - start > timeout { + throw TimeoutError() + } + try await Task.sleep(for: .milliseconds(25)) + } + } + + private struct TimeoutError: Error {} +} diff --git a/Tests/StorageScopeTests/ScanStoreRevealInFolderTreeTests.swift b/Tests/StorageScopeTests/ScanStoreRevealInFolderTreeTests.swift new file mode 100644 index 0000000..25b03fb --- /dev/null +++ b/Tests/StorageScopeTests/ScanStoreRevealInFolderTreeTests.swift @@ -0,0 +1,105 @@ +import Foundation +import StorageScopeCore +import Testing +@testable import StorageScope + +@MainActor +@Suite("ScanStore Folder Tree drill-down") +struct ScanStoreRevealInFolderTreeTests { + @Test("revealInFolderTree selects the item, expands ancestors, and switches to the tree view") + func revealDeepItemExpandsAncestors() async throws { + let store = ScanStore() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("reveal-\(UUID().uuidString)", isDirectory: true) + let nested = root.appendingPathComponent("Media", isDirectory: true).appendingPathComponent("Renders", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data(repeating: 0, count: 4096).write(to: nested.appendingPathComponent("clip.mov")) + + store.scanDeveloperFixturePath(root.path) + try await waitForScanToFinish(store) + + guard let rootItem = store.scan?.rootItem else { + Issue.record("expected a scanned root item") + return + } + guard let target = findItem(named: "clip.mov", under: rootItem) else { + Issue.record("expected clip.mov in the retained tree") + return + } + + store.revealInFolderTree(target) + + #expect(store.selectedView == .tree) + #expect(store.selectedItemID == target.id) + // Every ancestor container (root → Media → Renders) must be expanded so the + // selected row is actually visible. + var node = rootItem + while node.id != target.id { + #expect(store.treeExpandedIDs.contains(node.id), "ancestor \(node.name) should be expanded") + guard let next = node.children.first(where: { findItem(named: "clip.mov", under: $0) != nil || $0.id == target.id }) else { + Issue.record("broken ancestor chain at \(node.name)") + return + } + node = next + } + } + + @Test("revealInFolderTree still opens the tree when the item is not in the retained tree") + func revealUnretainedItemFallsBackToRoot() async throws { + let store = ScanStore() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("reveal-miss-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data(repeating: 0, count: 1024).write(to: root.appendingPathComponent("real.bin")) + + store.scanDeveloperFixturePath(root.path) + try await waitForScanToFinish(store) + + guard let rootItem = store.scan?.rootItem, let anyChild = rootItem.children.first else { + Issue.record("expected a scanned root with a child") + return + } + // Forge an item that isn't part of the retained tree (id derives from the + // URL, so a URL outside the scan root can't match any retained node). + let ghost = StorageItem( + url: root.appendingPathComponent("ghost-\(UUID().uuidString).bin"), + kind: .file, + byteSize: 1, + allocatedSize: 1, + modifiedAt: nil, + immediateChildCount: 0, + descendantCount: 0, + isReadable: true + ) + _ = anyChild + + store.revealInFolderTree(ghost) + + #expect(store.selectedView == .tree) + #expect(store.treeExpandedIDs.contains(rootItem.id)) + } + + // MARK: - Helpers + + private func findItem(named name: String, under node: StorageItem) -> StorageItem? { + if node.name == name { return node } + for child in node.children { + if let found = findItem(named: name, under: child) { + return found + } + } + return nil + } + + private func waitForScanToFinish(_ store: ScanStore, timeout: Duration = .seconds(5)) async throws { + let start = ContinuousClock.now + while store.isScanning || store.scan == nil { + if ContinuousClock.now - start > timeout { + throw TimeoutError() + } + try await Task.sleep(for: .milliseconds(25)) + } + } + + private struct TimeoutError: Error {} +} diff --git a/Tests/StorageScopeTests/ScanStoreTreeKeyboardTests.swift b/Tests/StorageScopeTests/ScanStoreTreeKeyboardTests.swift new file mode 100644 index 0000000..bf4bba3 --- /dev/null +++ b/Tests/StorageScopeTests/ScanStoreTreeKeyboardTests.swift @@ -0,0 +1,133 @@ +import Foundation +import StorageScopeCore +import Testing +@testable import StorageScope + +@MainActor +@Suite("ScanStore tree keyboard navigation and type-ahead") +struct ScanStoreTreeKeyboardTests { + /// Fixture: root / Media / Renders / clip.mov plus root / note.txt + private func makeScannedStore() async throws -> (ScanStore, root: URL, cleanup: () -> Void) { + let store = ScanStore() + let root = FileManager.default.temporaryDirectory.appendingPathComponent("treekbd-\(UUID().uuidString)", isDirectory: true) + let nested = root.appendingPathComponent("Media", isDirectory: true).appendingPathComponent("Renders", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try Data(repeating: 0, count: 8192).write(to: nested.appendingPathComponent("clip.mov")) + try Data(repeating: 0, count: 4096).write(to: root.appendingPathComponent("note.txt")) + + store.scanDeveloperFixturePath(root.path) + try await waitForScanToFinish(store) + return (store, root, { try? FileManager.default.removeItem(at: root) }) + } + + @Test("visibleTreeItems descends only into expanded containers, in display order") + func visibleItemsRespectExpansion() async throws { + let (store, _, cleanup) = try await makeScannedStore() + defer { cleanup() } + guard let root = store.scan?.rootItem else { return } + + // Nothing expanded: only the root row is visible. + store.treeExpandedIDs = [] + #expect(store.visibleTreeItems().map(\.id) == [root.id]) + + // Expanding the root surfaces its children, but not grandchildren. + store.treeExpandedIDs = [root.id] + let visible = store.visibleTreeItems() + #expect(visible.count == 1 + root.children.count) + #expect(!visible.contains(where: { $0.name == "Renders" })) + + // Fully expanded: DFS order — a child's subtree appears before the next sibling. + store.expandEntireTree() + let all = store.visibleTreeItems() + let names = all.map(\.name) + guard let media = names.firstIndex(of: "Media"), let renders = names.firstIndex(of: "Renders"), + let clip = names.firstIndex(of: "clip.mov") else { + Issue.record("expected Media/Renders/clip.mov in \(names)") + return + } + #expect(media < renders && renders < clip) + } + + @Test("left arrow collapses an expanded folder, then ascends to the parent") + func leftArrowCollapsesThenAscends() async throws { + let (store, _, cleanup) = try await makeScannedStore() + defer { cleanup() } + guard let root = store.scan?.rootItem, + let media = root.children.first(where: { $0.name == "Media" }) else { return } + + store.expandEntireTree() + store.selectedItemID = media.id + + // First ← collapses Media (selection stays). + #expect(store.collapseOrAscendTreeSelection() == media.id) + #expect(!store.treeExpandedIDs.contains(media.id)) + #expect(store.selectedItemID == media.id) + + // Second ← ascends to the root. + #expect(store.collapseOrAscendTreeSelection() == root.id) + #expect(store.selectedItemID == root.id) + } + + @Test("right arrow expands a collapsed folder, then steps into the first child") + func rightArrowExpandsThenDescends() async throws { + let (store, _, cleanup) = try await makeScannedStore() + defer { cleanup() } + guard let root = store.scan?.rootItem, + let media = root.children.first(where: { $0.name == "Media" }) else { return } + + store.treeExpandedIDs = [root.id] + store.selectedItemID = media.id + + // First → expands Media. + #expect(store.expandOrDescendTreeSelection() == media.id) + #expect(store.treeExpandedIDs.contains(media.id)) + + // Second → steps into Media's first visible child (Renders). + let descended = store.expandOrDescendTreeSelection() + #expect(store.visibleTreeItems().first(where: { $0.id == descended })?.name == "Renders") + } + + @Test("type-ahead selects the first display-name prefix match, case-insensitively") + func typeAheadSelectsPrefixMatch() async throws { + let (store, _, cleanup) = try await makeScannedStore() + defer { cleanup() } + store.selectedView = .largestFiles + + guard let id = store.selectItem(matchingPrefix: "NO") else { + Issue.record("expected a match for prefix 'NO'") + return + } + #expect(store.items(for: .largestFiles).first(where: { $0.id == id })?.name == "note.txt") + #expect(store.selectItem(matchingPrefix: "zzz") == nil) + } + + @Test("escape clears an active search exactly once") + func escapeClearsSearch() { + let store = ScanStore() + #expect(store.clearSearchIfActive() == false) + store.filters.searchText = "cache" + #expect(store.clearSearchIfActive() == true) + #expect(store.filters.searchText.isEmpty) + #expect(store.clearSearchIfActive() == false) + } + + @Test("cleanup candidate selection is nil-safe without a scan") + func cleanupSelectionNilSafe() { + let store = ScanStore() + #expect(store.selectAdjacentCleanupCandidate(offset: 1) == nil) + store.toggleSelectedCleanupCandidate() // must not crash + #expect(store.selectedItemID == nil) + } + + private func waitForScanToFinish(_ store: ScanStore, timeout: Duration = .seconds(5)) async throws { + let start = ContinuousClock.now + while store.isScanning || store.scan == nil { + if ContinuousClock.now - start > timeout { + throw TimeoutError() + } + try await Task.sleep(for: .milliseconds(25)) + } + } + + private struct TimeoutError: Error {} +} diff --git a/VERSION b/VERSION index faef31a..39e898a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.7.1 diff --git a/docs/architecture.html b/docs/architecture.html index 3675ff8..07dca2d 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -42,7 +42,7 @@