Skip to content

Commit 4099025

Browse files
Release StorageScope v0.7.1 (#119)
Co-authored-by: RasputinKaiser <178525839+RasputinKaiser@users.noreply.github.com>
1 parent 8ef9ffe commit 4099025

41 files changed

Lines changed: 1217 additions & 280 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ The app scans only folders the user grants through macOS folder selection or sto
1010

1111
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.
1212

13-
**Current release:** v0.7.0, focused on scan control, live results, pause/resume, folder exclusions, and redacted presentation mode for screen sharing.
13+
**Current release:** v0.7.1, focused on window sizing, keyboard cleanup navigation, and visual polish across the main storage views.
1414

15-
[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/)
15+
[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/)
1616

17-
![StorageScope v0.7.0 overview with redacted file and folder names](docs/images/storagescope-overview.png)
17+
![StorageScope v0.7.1 overview with redacted file and folder names](docs/images/storagescope-overview.png)
1818

19-
## What's New In v0.7.0
19+
## What's New In v0.7.1
2020

21-
- Live scan results stream into the storage views while the scan is still running.
22-
- Pause, resume, and cancel controls keep long scans understandable and interruptible.
23-
- Folder exclusions let scans skip folders such as `node_modules`, `.git`, and cache directories entirely.
24-
- Redaction mode masks file and folder names/paths with stable placeholders for screenshots and screen sharing.
25-
- Cleanup Review keeps verified duplicate reclaim separate from review-only cleanup suggestions.
21+
- Main and Settings windows now open at roomier default sizes so dense controls and cleanup review states are not clipped.
22+
- Cleanup Review, Folder Tree, and item tables support keyboard-first movement, reveal, and selection flows.
23+
- Storage views use cleaner row spacing, stronger empty/loading states, and steadier column behavior for large scans.
24+
- Overview, sidebar, tree, type breakdown, and duplicate-review surfaces were visually tuned for better scanning at a glance.
25+
- New tests cover keyboard selection, folder-tree reveal behavior, and tree navigation state.
2626

2727
## Highlights
2828

@@ -38,15 +38,15 @@ The name is intentional: StorageScope is not a black-box cleaner. It scopes stor
3838

3939
## Screenshots
4040

41-
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.
41+
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.
4242

43-
| Overview | Cleanup Review |
43+
| Cold Launch | Scanned Overview |
4444
| --- | --- |
45-
| ![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) |
45+
| ![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) |
4646

47-
| Privacy Setting |
48-
| --- |
49-
| ![StorageScope settings showing the Redact file and folder names toggle enabled](docs/images/storagescope-v070-settings-redaction.png) |
47+
| Cleanup Review | Privacy Setting |
48+
| --- | --- |
49+
| ![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) |
5050

5151
## Use Cases
5252

Sources/StorageScope/App/StorageScopeApp.swift

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSToolbarDelegate, NSM
5050
defer: false
5151
)
5252
window.title = windowTitle(for: store.scan)
53-
window.minSize = NSSize(width: 1180, height: 760)
53+
// Keep the floor below common built-in displays (1280×800, 1440×900). The
54+
// effective minimum used to be dictated by SwiftUI content constraints
55+
// (~1819pt with sidebar + inspector open) leaking through NSHostingView's
56+
// default sizingOptions — see UI_PLAN.md P0.1.
57+
window.minSize = NSSize(width: 1080, height: 700)
5458
window.center()
55-
window.setFrameAutosaveName("StorageScopeMainWindow")
56-
window.contentView = NSHostingView(rootView: ContentView(store: store, onOpenSettings: { [weak self] in self?.showSettings() }))
59+
// v2: the pre-0.8 autosave carried split-divider state from builds whose
60+
// minimum window width was ~1819pt; restoring it re-clipped the new layout.
61+
window.setFrameAutosaveName("StorageScopeMainWindow.v2")
62+
let hostingView = NSHostingView(rootView: ContentView(store: store, onOpenSettings: { [weak self] in self?.showSettings() }))
63+
// Without this, NSHostingView installs the SwiftUI hierarchy's min-size as
64+
// window constraints, overriding `window.minSize` and preventing the window
65+
// from fitting small displays. Layout min-widths are handled in the views
66+
// themselves (ViewThatFits fallbacks) instead.
67+
hostingView.sizingOptions = []
68+
window.contentView = hostingView
69+
clampFrameToVisibleScreen(window)
5770
window.toolbar = makeToolbar()
5871
window.toolbarStyle = .unified
5972
window.makeKeyAndOrderFront(nil)
@@ -71,6 +84,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSToolbarDelegate, NSM
7184
}
7285
}
7386

87+
/// A frame autosaved on a large external display must never restore off-screen
88+
/// on a smaller one (UI_PLAN.md P0.5). Runs after `setFrameAutosaveName` has
89+
/// restored the previous frame.
90+
private func clampFrameToVisibleScreen(_ window: NSWindow) {
91+
guard let screen = window.screen ?? NSScreen.main else { return }
92+
let visible = screen.visibleFrame
93+
var frame = window.frame
94+
guard !visible.contains(frame) else { return }
95+
frame.size.width = min(frame.width, visible.width)
96+
frame.size.height = min(frame.height, visible.height)
97+
frame.origin.x = max(visible.minX, min(frame.origin.x, visible.maxX - frame.width))
98+
frame.origin.y = max(visible.minY, min(frame.origin.y, visible.maxY - frame.height))
99+
window.setFrame(frame, display: false)
100+
}
101+
74102
private func windowTitle(for scan: StorageScan?) -> String {
75103
guard let scan else {
76104
return "StorageScope"
@@ -183,9 +211,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSToolbarDelegate, NSM
183211
return
184212
}
185213

214+
// Resizable so no section is ever cut off without recourse (UI_PLAN.md P2);
215+
// the grouped Form scrolls, and min sizes come from SettingsView's frame.
186216
let window = NSWindow(
187-
contentRect: NSRect(x: 0, y: 0, width: 560, height: 460),
188-
styleMask: [.titled, .closable],
217+
contentRect: NSRect(x: 0, y: 0, width: 560, height: 680),
218+
styleMask: [.titled, .closable, .resizable],
189219
backing: .buffered,
190220
defer: false
191221
)
@@ -258,6 +288,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSToolbarDelegate, NSM
258288
let cleanupLane = CleanupLaneFilter(developerFixtureValue: value) {
259289
store.filters.cleanupLaneFilter = cleanupLane
260290
}
291+
if environment["STORAGESCOPE_DEVELOPER_REDACTION"] == "1" {
292+
store.filters.redactionEnabled = true
293+
}
261294
}
262295

263296
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {

Sources/StorageScope/Stores/ScanStore.swift

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1938,3 +1938,233 @@ extension ScanStore {
19381938
return .scanInternal(message: error.localizedDescription)
19391939
}
19401940
}
1941+
1942+
// MARK: - Display-layer relative paths (UI_PLAN.md P1.2)
1943+
1944+
extension ScanStore {
1945+
/// Row-subtitle path relative to the scan root ("Media Projects/render.mov"
1946+
/// instead of "/Volumes/Sample/fixture-scan/Media Projects/render.mov"). The root is
1947+
/// already shown in the scan header, so repeating the absolute prefix on every
1948+
/// row was pure noise. Falls back to the redacted full path when redaction is
1949+
/// on, and to the absolute path for items outside the root (shouldn't happen).
1950+
func displayRelativePath(for item: StorageItem) -> String {
1951+
guard !filters.redactionEnabled else { return filters.displayPath(for: item) }
1952+
guard let rootURL = scan?.rootURL else { return item.url.path }
1953+
return Self.relativePath(of: item.url, under: rootURL) ?? item.url.path
1954+
}
1955+
1956+
/// Relative-path variant of `FilterStore.displayParentPath(for:)` — the
1957+
/// containing folder only, relative to the scan root.
1958+
func displayRelativeParentPath(for item: StorageItem) -> String {
1959+
guard !filters.redactionEnabled else { return filters.displayParentPath(for: item) }
1960+
let parent = item.url.deletingLastPathComponent()
1961+
guard let rootURL = scan?.rootURL else { return parent.path }
1962+
return Self.relativePath(of: parent, under: rootURL) ?? parent.path
1963+
}
1964+
1965+
/// Path of `url` relative to `root`, or nil when `url` isn't inside `root`.
1966+
/// The root itself maps to its display name rather than an empty string.
1967+
static func relativePath(of url: URL, under root: URL) -> String? {
1968+
let rootPath = root.standardizedFileURL.path
1969+
let path = url.standardizedFileURL.path
1970+
guard path.hasPrefix(rootPath) else { return nil }
1971+
let suffix = path.dropFirst(rootPath.count).drop(while: { $0 == "/" })
1972+
guard !suffix.isEmpty else { return root.lastPathComponent }
1973+
return String(suffix)
1974+
}
1975+
}
1976+
1977+
// MARK: - Folder Tree drill-down (UI_PLAN.md UX round 2)
1978+
1979+
extension ScanStore {
1980+
/// Verified-duplicate reclaim total, surfaced as a sidebar badge so the user can
1981+
/// see where reclaimable space lives before clicking through.
1982+
var verifiedReclaimableBytes: Int64 {
1983+
verifiedDuplicateGroups.reduce(Int64(0)) { $0 + $1.reclaimableBytes }
1984+
}
1985+
1986+
/// Jumps to the Folder Tree with `item` selected and every ancestor expanded —
1987+
/// the drill-down behind Storage Map rows and the "Show in Folder Tree" context
1988+
/// menu action. If the item wasn't retained in the tree (pruned by the retained-
1989+
/// items cap), the tree still opens at the root rather than failing silently.
1990+
func revealInFolderTree(_ item: StorageItem) {
1991+
if let root = scan?.rootItem {
1992+
var chain: [String] = []
1993+
if Self.ancestorChain(from: root, to: item.id, chain: &chain) {
1994+
treeExpandedIDs.formUnion(chain)
1995+
selectedItemID = item.id
1996+
} else {
1997+
treeExpandedIDs.insert(root.id)
1998+
}
1999+
}
2000+
selectedView = .tree
2001+
}
2002+
2003+
/// Depth-first path of container IDs from `node` down to (excluding) `targetID`.
2004+
/// Returns false when the target isn't in the retained tree.
2005+
private static func ancestorChain(from node: StorageItem, to targetID: String, chain: inout [String]) -> Bool {
2006+
if node.id == targetID { return true }
2007+
guard node.isContainer, !node.children.isEmpty else { return false }
2008+
chain.append(node.id)
2009+
for child in node.children {
2010+
if ancestorChain(from: child, to: targetID, chain: &chain) {
2011+
return true
2012+
}
2013+
}
2014+
chain.removeLast()
2015+
return false
2016+
}
2017+
}
2018+
2019+
// MARK: - Keyboard selection (UI_PLAN.md UX round 3)
2020+
2021+
extension ScanStore {
2022+
/// Moves the selection by `offset` within the active view's ranked items —
2023+
/// the model behind arrow-key navigation in the item tables. Selects the first
2024+
/// item when nothing is selected yet. Returns the newly selected ID so the view
2025+
/// can scroll it into sight, or nil when there is nothing to select.
2026+
@discardableResult
2027+
func selectAdjacentItem(offset: Int) -> String? {
2028+
let items = items(for: activeView)
2029+
guard !items.isEmpty else { return nil }
2030+
2031+
let newIndex: Int
2032+
if let currentIndex = items.firstIndex(where: { $0.id == selectedItemID }) {
2033+
newIndex = min(max(currentIndex + offset, 0), items.count - 1)
2034+
} else {
2035+
newIndex = offset >= 0 ? 0 : items.count - 1
2036+
}
2037+
2038+
let id = items[newIndex].id
2039+
selectedItemID = id
2040+
return id
2041+
}
2042+
}
2043+
2044+
// MARK: - Tree, cleanup, and type-ahead keyboard navigation (UI_PLAN.md UX round 4)
2045+
2046+
extension ScanStore {
2047+
/// The Folder Tree rows currently on screen, in visual order: a depth-first walk
2048+
/// of the retained tree that descends only into expanded containers and applies
2049+
/// the same child filters as `TreeNodeRow` (size threshold + search subtree
2050+
/// matches). This is the model behind ↑/↓ in the tree.
2051+
func visibleTreeItems() -> [StorageItem] {
2052+
guard let root = scan?.rootItem else { return [] }
2053+
var result: [StorageItem] = []
2054+
var stack: [StorageItem] = [root]
2055+
let threshold = filters.sizeFilter.threshold
2056+
while let node = stack.popLast() {
2057+
result.append(node)
2058+
guard node.isContainer, treeExpandedIDs.contains(node.id) else { continue }
2059+
// Reverse so the stack pops children in display order.
2060+
for child in node.children.reversed()
2061+
where child.displaySize >= threshold && (searchSubtreeMatchIDs?.contains(child.id) ?? true) {
2062+
stack.append(child)
2063+
}
2064+
}
2065+
return result
2066+
}
2067+
2068+
/// ↑/↓ in the Folder Tree: moves the selection through the visible rows,
2069+
/// clamping at both ends. Selects the root when nothing is selected yet.
2070+
@discardableResult
2071+
func selectAdjacentTreeItem(offset: Int) -> String? {
2072+
selectAdjacent(in: visibleTreeItems().map(\.id), offset: offset)
2073+
}
2074+
2075+
/// ← in the Folder Tree: collapses the selected container if it's expanded,
2076+
/// otherwise walks up to the parent — mirrors NSOutlineView/Finder list view.
2077+
@discardableResult
2078+
func collapseOrAscendTreeSelection() -> String? {
2079+
let visible = visibleTreeItems()
2080+
guard let selected = visible.first(where: { $0.id == selectedItemID }) else {
2081+
return selectAdjacentTreeItem(offset: 1)
2082+
}
2083+
if selected.isContainer, treeExpandedIDs.contains(selected.id) {
2084+
treeExpandedIDs.remove(selected.id)
2085+
return selected.id
2086+
}
2087+
guard let root = scan?.rootItem, let parent = Self.parent(of: selected.id, under: root) else {
2088+
return selected.id
2089+
}
2090+
selectedItemID = parent.id
2091+
return parent.id
2092+
}
2093+
2094+
/// → in the Folder Tree: expands the selected container, or steps into its
2095+
/// first visible child when it's already expanded.
2096+
@discardableResult
2097+
func expandOrDescendTreeSelection() -> String? {
2098+
let visible = visibleTreeItems()
2099+
guard let selected = visible.first(where: { $0.id == selectedItemID }) else {
2100+
return selectAdjacentTreeItem(offset: 1)
2101+
}
2102+
guard selected.isContainer, !selected.children.isEmpty else { return selected.id }
2103+
if !treeExpandedIDs.contains(selected.id) {
2104+
treeExpandedIDs.insert(selected.id)
2105+
return selected.id
2106+
}
2107+
let after = visibleTreeItems()
2108+
if let index = after.firstIndex(where: { $0.id == selected.id }), index + 1 < after.count {
2109+
selectedItemID = after[index + 1].id
2110+
return after[index + 1].id
2111+
}
2112+
return selected.id
2113+
}
2114+
2115+
/// ↑/↓ in Cleanup Review: moves the row selection through the visible
2116+
/// candidates. Space then toggles via `toggleSelectedCleanupCandidate()`.
2117+
@discardableResult
2118+
func selectAdjacentCleanupCandidate(offset: Int) -> String? {
2119+
selectAdjacent(in: cleanupCandidates.map(\.item.id), offset: offset)
2120+
}
2121+
2122+
/// Space in Cleanup Review: toggles the check on the selected candidate.
2123+
func toggleSelectedCleanupCandidate() {
2124+
guard let candidate = cleanupCandidates.first(where: { $0.item.id == selectedItemID }) else { return }
2125+
toggleCleanupCandidate(candidate)
2126+
}
2127+
2128+
/// Type-to-select in the ranked tables: jumps to the first item whose display
2129+
/// name starts with `prefix` (case-insensitive), like Finder.
2130+
@discardableResult
2131+
func selectItem(matchingPrefix prefix: String) -> String? {
2132+
let normalized = prefix.lowercased()
2133+
guard !normalized.isEmpty else { return nil }
2134+
let ranked = items(for: activeView)
2135+
guard let match = ranked.first(where: { filters.displayName(for: $0).lowercased().hasPrefix(normalized) }) else {
2136+
return nil
2137+
}
2138+
selectedItemID = match.id
2139+
return match.id
2140+
}
2141+
2142+
/// Escape: clears an active search. Returns false when there was nothing to
2143+
/// clear so the caller can pass the key press on.
2144+
@discardableResult
2145+
func clearSearchIfActive() -> Bool {
2146+
guard !filters.searchText.isEmpty else { return false }
2147+
filters.searchText = ""
2148+
return true
2149+
}
2150+
2151+
private func selectAdjacent(in ids: [String], offset: Int) -> String? {
2152+
guard !ids.isEmpty else { return nil }
2153+
let newIndex: Int
2154+
if let currentIndex = ids.firstIndex(where: { $0 == selectedItemID }) {
2155+
newIndex = min(max(currentIndex + offset, 0), ids.count - 1)
2156+
} else {
2157+
newIndex = offset >= 0 ? 0 : ids.count - 1
2158+
}
2159+
selectedItemID = ids[newIndex]
2160+
return ids[newIndex]
2161+
}
2162+
2163+
private static func parent(of targetID: String, under node: StorageItem) -> StorageItem? {
2164+
for child in node.children {
2165+
if child.id == targetID { return node }
2166+
if let found = parent(of: targetID, under: child) { return found }
2167+
}
2168+
return nil
2169+
}
2170+
}

0 commit comments

Comments
 (0)