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
92 changes: 91 additions & 1 deletion Sources/StorageScope/Stores/RecentsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,40 @@ struct RecentScanEntry: Codable, Identifiable, Hashable {
var id: String { path }
}

/// The UI state for recovering a recent scan whose security-scoped bookmark no
/// longer resolves. This state deliberately contains no AppKit behavior: the
/// owner of the store can present the recovery controls and perform folder
/// selection asynchronously without an alert opening an `NSOpenPanel` inline.
enum RecentScanRecoveryState: Equatable, Identifiable {
case idle
case needsAction(path: String)
case choosingFolder(path: String)

var id: String {
switch self {
case .idle:
return "idle"
case .needsAction(let path), .choosingFolder(let path):
return path
}
}

var path: String? {
switch self {
case .idle:
return nil
case .needsAction(let path), .choosingFolder(let path):
return path
}
}
}

enum RecentScanRecoveryAction: Equatable {
case chooseFolder
case forgetScan
case cancel
}

/// Serializes JSON encode + UserDefaults writes for `RecentsStore` and
/// `SearchRecentsStore`. A monotonic generation counter drops writes whose
/// generation is older than the latest committed one — this is the fix for the
Expand Down Expand Up @@ -52,6 +86,7 @@ final class RecentsStore: ObservableObject {
private static let writer = RecentStoreWriter<[RecentScanEntry]>(key: recentScanEntriesKey)

@Published private(set) var entries: [RecentScanEntry] = []
@Published private(set) var recoveryState: RecentScanRecoveryState = .idle
private var writeGeneration: UInt64 = 0

init() {
Expand All @@ -69,9 +104,64 @@ final class RecentsStore: ObservableObject {

func forget(path: String) {
entries.removeAll { $0.path == path }
if recoveryState.path == path {
recoveryState = .idle
}
persist()
}

/// Starts the explicit recovery flow for a recent scan whose bookmark could
/// not be resolved. Calling this only changes state; it never presents a
/// panel or an alert synchronously.
func requestRecovery(for path: String) {
guard !path.isEmpty else { return }
recoveryState = .needsAction(path: path)
}

/// Applies one of the recovery controls while the flow is awaiting a user
/// decision. `chooseFolder` moves to a separate state so its owner can start
/// an asynchronous folder picker and later call `completeRecovery()`.
@discardableResult
func applyRecoveryAction(_ action: RecentScanRecoveryAction) -> String? {
guard case .needsAction(let path) = recoveryState else { return nil }

switch action {
case .chooseFolder:
recoveryState = .choosingFolder(path: path)
return path
case .forgetScan:
forget(path: path)
return nil
case .cancel:
recoveryState = .idle
return nil
}
}

/// Returns the stale path to the caller that owns folder selection and
/// advances the state to `.choosingFolder`.
@discardableResult
func chooseFolderForRecovery() -> String? {
applyRecoveryAction(.chooseFolder)
}

/// Removes the stale recent entry and dismisses the recovery flow.
func forgetScanFromRecovery() {
_ = applyRecoveryAction(.forgetScan)
}

/// Dismisses the recovery flow without changing the recent entry.
func cancelRecovery() {
guard recoveryState != .idle else { return }
recoveryState = .idle
}

/// Called after the asynchronous folder picker or replacement scan finishes
/// (including when the picker is dismissed) to return to the idle state.
func completeRecovery() {
recoveryState = .idle
}

/// Rendered @Published mutations already updated `entries` synchronously on the main
/// actor so SwiftUI sees the change immediately; the heavy JSON encode + UserDefaults
/// write is dispatched to `RecentStoreWriter` (a serializing actor) so the main actor
Expand Down Expand Up @@ -110,4 +200,4 @@ final class RecentsStore: ObservableObject {
entries = legacyPaths.map { RecentScanEntry(path: $0, scannedAt: now, totalBytes: 0) }
UserDefaults.standard.removeObject(forKey: Self.legacyRecentScanPathsKey)
}
}
}
47 changes: 38 additions & 9 deletions Sources/StorageScope/Stores/ScanStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -612,8 +612,9 @@ func setSelectedView(_ view: SmartView) {

do {
guard let resolvedURL = try bookmarkStore.resolve(path: path) else {
errorMessage = "StorageScope needs you to choose this folder again before rescanning it in the sandbox."
chooseFolderAndScan(startingAt: URL(fileURLWithPath: path, isDirectory: true))
session.lastErrorCategory = .staleBookmark(path: path)
errorMessage = nil
recents.requestRecovery(for: path)
return
}

Expand All @@ -623,12 +624,41 @@ func setSelectedView(_ view: SmartView) {
session.lastErrorCategory = category
os_log("Reopen-bookmark failed for %{private}@: %{public}@",
log: Self.log, type: .error, path, String(describing: error))
errorMessage = category?.userMessage
?? "StorageScope could not reopen this folder bookmark. Choose it again to refresh access. \(error.localizedDescription)"
chooseFolderAndScan(startingAt: URL(fileURLWithPath: path, isDirectory: true))
errorMessage = nil
recents.requestRecovery(for: path)
}
}

/// Begins the explicit recent-scan recovery flow. The confirmation dialog is
/// dismissed before opening the folder panel so stale bookmark failures never
/// chain an alert directly into a synchronous panel presentation.
func chooseFolderForRecentRecovery() {
guard let path = recents.chooseFolderForRecovery() else { return }

Task { @MainActor [weak self] in
await Task.yield()
guard let self else { return }
defer { recents.completeRecovery() }

let startingURL = URL(fileURLWithPath: path, isDirectory: true)
guard let url = FileActionService.chooseFolder(
startingAt: startingURL,
message: "Choose the folder again to refresh StorageScope's access."
) else {
return
}
scanUserGrantedURL(url)
}
}

func forgetRecentScanRecovery() {
recents.forgetScanFromRecovery()
}

func cancelRecentScanRecovery() {
recents.cancelRecovery()
}

func scanVolume(_ url: URL) {
chooseFolderAndScan(startingAt: url)
}
Expand Down Expand Up @@ -732,16 +762,15 @@ func setSelectedView(_ view: SmartView) {
if let resolvedURL = try bookmarkStore.resolve(path: lastScannedURL.standardizedFileURL.path) {
scanUserGrantedURL(resolvedURL.url, access: resolvedURL.access)
} else {
chooseFolderAndScan(startingAt: lastScannedURL)
recents.requestRecovery(for: lastScannedURL.standardizedFileURL.path)
}
} catch {
let category = Self.categorize(error, fallbackPath: lastScannedURL.path)
session.lastErrorCategory = category
os_log("Rescan access failed for %{private}@: %{public}@",
log: Self.log, type: .error, lastScannedURL.path, String(describing: error))
errorMessage = category?.userMessage
?? "StorageScope needs refreshed access before rescanning. \(error.localizedDescription)"
chooseFolderAndScan(startingAt: lastScannedURL)
errorMessage = nil
recents.requestRecovery(for: lastScannedURL.standardizedFileURL.path)
}
}

Expand Down
49 changes: 45 additions & 4 deletions Sources/StorageScope/Views/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import AppKit
import SwiftUI

struct ContentView: View {
@ObservedObject var store: ScanStore
var onOpenSettings: () -> Void = {}
@State private var columnVisibility: NavigationSplitViewVisibility = .all
// 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
// The inspector is secondary, selection-driven UI. Start it closed so a cold
// launch gives the scan setup and results the full window; users can reveal it
// from the toolbar once they have something to inspect.
@State private var inspectorVisible = false
@FocusState private var searchFieldFocused: Bool

/// Search-field prompt that reflects what the active view actually filters —
Expand All @@ -33,6 +33,30 @@ struct ContentView: View {
}
}

private var recentRecoveryIsPresented: Binding<Bool> {
Binding(
get: {
if case .needsAction = store.recents.recoveryState {
return true
}
return false
},
set: { isPresented in
if !isPresented {
store.cancelRecentScanRecovery()
}
}
)
}

private var recentRecoveryMessage: String {
guard let path = store.recents.recoveryState.path else {
return "StorageScope could not reopen this recent scan."
}
let name = URL(fileURLWithPath: path).lastPathComponent.nonEmpty ?? "folder"
return "StorageScope lost access to \(name). Choose the folder again, forget this recent scan, or cancel."
}

var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
SidebarView(store: store)
Expand Down Expand Up @@ -155,6 +179,23 @@ struct ContentView: View {
} message: {
Text(store.errorMessage ?? "")
}
.confirmationDialog(
"Recent Scan Needs Attention",
isPresented: recentRecoveryIsPresented,
titleVisibility: .visible
) {
Button("Choose Folder") {
store.chooseFolderForRecentRecovery()
}
Button("Forget Scan", role: .destructive) {
store.forgetRecentScanRecovery()
}
Button("Cancel", role: .cancel) {
store.cancelRecentScanRecovery()
}
} message: {
Text(recentRecoveryMessage)
}
.sheet(item: $store.pendingTrashReviewPlan) { plan in
TrashConfirmationSheet(
plan: plan,
Expand Down
3 changes: 2 additions & 1 deletion Sources/StorageScope/Views/OverviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -391,10 +391,11 @@ private struct StorageMapRow: View, Equatable {
}
.buttonStyle(.pressableRow)
.onHover { isHovered = $0 }
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(displayName), \(StorageFormat.bytes(item.displaySize))")
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityHint("Selects this storage item")
.accessibilityAction(named: "Show in Folder Tree") { onShowInTree() }
.simultaneousGesture(TapGesture(count: 2).onEnded { onShowInTree() })
.contextMenu {
Button("Show in Folder Tree") { onShowInTree() }
Expand Down
59 changes: 58 additions & 1 deletion Tests/StorageScopeTests/ScanStoreRecoveryStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import Testing
@MainActor
@Suite("ScanStore recovery state")
struct ScanStoreRecoveryStateTests {
private func recoveryPath() -> String {
"/tmp/storagescope-recovery-" + UUID().uuidString
}

@Test("no scan, no filters: displayRecoveryState is .noScan")
func noScanNoFilters() {
let store = ScanStore()
Expand Down Expand Up @@ -53,4 +57,57 @@ struct ScanStoreRecoveryStateTests {
store.filters.searchText = ""
#expect(store.displayRecoveryState == .filteredEmpty)
}
}

@Test("stale bookmark enters the explicit recovery decision state")
func staleBookmarkStartsRecovery() {
let recents = RecentsStore()
let path = recoveryPath()

recents.requestRecovery(for: path)

#expect(recents.recoveryState == .needsAction(path: path))
#expect(recents.recoveryState.path == path)
}

@Test("Choose Folder transitions to folder selection and returns the stale path")
func chooseFolderTransitionsToSelection() {
let recents = RecentsStore()
let path = recoveryPath()
recents.requestRecovery(for: path)

let selectedPath = recents.chooseFolderForRecovery()

#expect(selectedPath == path)
#expect(recents.recoveryState == .choosingFolder(path: path))

recents.completeRecovery()
#expect(recents.recoveryState == .idle)
}

@Test("Forget Scan removes the stale recent and returns to idle")
func forgetScanTransitionsToIdleAndRemovesEntry() {
let recents = RecentsStore()
let path = recoveryPath()
recents.remember(URL(fileURLWithPath: path), scannedAt: Date(), totalBytes: 1)
recents.requestRecovery(for: path)

recents.forgetScanFromRecovery()

#expect(recents.recoveryState == .idle)
#expect(!recents.entries.contains { $0.path == path })
}

@Test("Cancel dismisses recovery without forgetting the recent")
func cancelTransitionsToIdleAndKeepsEntry() {
let recents = RecentsStore()
let path = recoveryPath()
recents.remember(URL(fileURLWithPath: path), scannedAt: Date(), totalBytes: 1)
recents.requestRecovery(for: path)

recents.cancelRecovery()

#expect(recents.recoveryState == .idle)
#expect(recents.entries.contains { $0.path == path })
recents.forget(path: path)
}
}
Loading