diff --git a/Sources/StorageScope/Stores/RecentsStore.swift b/Sources/StorageScope/Stores/RecentsStore.swift index 09cde2d..e9409e2 100644 --- a/Sources/StorageScope/Stores/RecentsStore.swift +++ b/Sources/StorageScope/Stores/RecentsStore.swift @@ -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 @@ -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() { @@ -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 @@ -110,4 +200,4 @@ final class RecentsStore: ObservableObject { entries = legacyPaths.map { RecentScanEntry(path: $0, scannedAt: now, totalBytes: 0) } UserDefaults.standard.removeObject(forKey: Self.legacyRecentScanPathsKey) } -} \ No newline at end of file +} diff --git a/Sources/StorageScope/Stores/ScanStore.swift b/Sources/StorageScope/Stores/ScanStore.swift index d6def5a..8394152 100644 --- a/Sources/StorageScope/Stores/ScanStore.swift +++ b/Sources/StorageScope/Stores/ScanStore.swift @@ -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 } @@ -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) } @@ -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) } } diff --git a/Sources/StorageScope/Views/ContentView.swift b/Sources/StorageScope/Views/ContentView.swift index e626a31..74b7d03 100644 --- a/Sources/StorageScope/Views/ContentView.swift +++ b/Sources/StorageScope/Views/ContentView.swift @@ -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 — @@ -33,6 +33,30 @@ struct ContentView: View { } } + private var recentRecoveryIsPresented: Binding { + 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) @@ -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, diff --git a/Sources/StorageScope/Views/OverviewView.swift b/Sources/StorageScope/Views/OverviewView.swift index a9757ec..dcf3b57 100644 --- a/Sources/StorageScope/Views/OverviewView.swift +++ b/Sources/StorageScope/Views/OverviewView.swift @@ -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() } diff --git a/Tests/StorageScopeTests/ScanStoreRecoveryStateTests.swift b/Tests/StorageScopeTests/ScanStoreRecoveryStateTests.swift index efcccf5..fd35fe4 100644 --- a/Tests/StorageScopeTests/ScanStoreRecoveryStateTests.swift +++ b/Tests/StorageScopeTests/ScanStoreRecoveryStateTests.swift @@ -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() @@ -53,4 +57,57 @@ struct ScanStoreRecoveryStateTests { store.filters.searchText = "" #expect(store.displayRecoveryState == .filteredEmpty) } -} \ No newline at end of file + + @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) + } +}