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
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
43 changes: 38 additions & 5 deletions Sources/StorageScope/App/StorageScopeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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] {
Expand Down
230 changes: 230 additions & 0 deletions Sources/StorageScope/Stores/ScanStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading
Loading