From 46b8c4ebb2fbc5380163ac6f5670e3ed7aa423db Mon Sep 17 00:00:00 2001 From: NikkyWay <243857824+NikkyWay@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:48:18 +0200 Subject: [PATCH 1/2] feat: make cleanup navigation reliable --- CHANGELOG.md | 12 + README.md | 4 +- Sources/OpenDiskTreeApp/AppModel.swift | 229 ++++++++++--- Sources/OpenDiskTreeApp/ContentView.swift | 48 +-- Sources/OpenDiskTreeApp/InspectorView.swift | 47 ++- .../OpenDiskTreeApp/ResultsTableView.swift | 48 ++- Sources/OpenDiskTreeCore/DiskScanner.swift | 43 ++- Sources/OpenDiskTreeCore/ScanStore.swift | 301 +++++++++++++++--- Support/Info.plist | 4 +- .../CoreBehaviorTests.swift | 84 +++++ 10 files changed, 662 insertions(+), 158 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a286f6f..94d9cc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to OpenDiskTree are recorded here. +## 1.3.3 — 2026-09-03 + +- Stop presenting permanent macOS system access denials as actionable scan errors during a full-disk scan. +- Continue reporting protected user data, explicit folder-scan failures and non-permission I/O errors. +- Remove previously stored permanent system access denials when opening an older local index. +- Prevent a slower folder query from restoring stale rows after navigating back to the parent directory. +- Retry an empty current folder selection and show an explicit loading state instead of an unexplained blank table. +- Recalculate only affected parent folders after a Trash operation instead of blocking navigation with a full-index aggregation. +- Finish interrupted snapshot bookkeeping on launch when an item already reached the system Trash. +- Prevent background history maintenance from removing a recent running scan or the last usable snapshot. +- Add Command- and Shift-click multi-selection with one reviewed Trash operation for the selected batch. + ## 1.3.2 — 2026-08-09 - Apply incremental results directly to the current snapshot instead of copying every unchanged row into another multi-gigabyte snapshot. diff --git a/README.md b/README.md index 81ca37c..7a42edc 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ The command creates `OpenDiskTree-arm64.dmg`, `OpenDiskTree-arm64.zip` and `SHA2 Use **Scan Folder** for a normal directory or external local disk. **Scan Full Disk** starts at `/` and avoids mounted external, network and APFS backing volumes so the same data is not counted twice. -macOS protects Mail, Messages, browser data and several other directories. To include them, open **Full Disk Access…**, enable OpenDiskTree in System Settings, quit the app and launch it again. The scanner never installs a privileged helper; paths that remain inaccessible are counted and exported as scan errors. +macOS protects Mail, Messages, browser data and several other directories. To include them, open **Full Disk Access…**, enable OpenDiskTree in System Settings, quit the app and launch it again. The scanner never installs a privileged helper. Unexpected access failures are counted and exported as scan errors; permanently closed system-owned paths are skipped quietly during a full-disk scan because they cannot be opened by a non-privileged app even with Full Disk Access. When you choose a folder through **Scan Folder**, OpenDiskTree stores a macOS security-scoped bookmark for that folder and reuses it on the next launch. The first access still requires the normal macOS confirmation. Scanning the entire `/` volume is governed by the separate Full Disk Access setting; rebuilding an ad-hoc local copy can make macOS treat it as a new app identity and ask again. @@ -70,6 +70,8 @@ The app starts a separate quick overview while the exact scan is preparing. It l While a scan is running, the status bar shows elapsed time, processing rate and the current path. Folder totals and the treemap settle after the final aggregation pass; until then the interface labels them as calculating rather than displaying a misleading zero. +The results table supports normal macOS multi-selection with **Command-click** and **Shift-click**. The inspector summarizes the selected size and statuses, and can move the reviewed selection to the system Trash in one operation. Strict cleanup rules still block the whole batch if it contains application-managed or protected data; successful and failed items are recorded separately. + ## Understanding the labels - **Safe to delete** is reserved for disposable data such as diagnostic logs, with a reason visible in the inspector. diff --git a/Sources/OpenDiskTreeApp/AppModel.swift b/Sources/OpenDiskTreeApp/AppModel.swift index 65bde45..95cfd83 100644 --- a/Sources/OpenDiskTreeApp/AppModel.swift +++ b/Sources/OpenDiskTreeApp/AppModel.swift @@ -26,10 +26,11 @@ final class AppModel: ObservableObject { @Published var currentScan: ScanRecord? @Published var items: [ScannedItem] = [] @Published var directoryItems: [ScannedItem] = [] - @Published var selectedItem: ScannedItem? + @Published var selectedItems: [ScannedItem] = [] @Published var showingLargestItems = false @Published var progress = ScanProgress() @Published var isScanning = false + @Published var isLoadingResults = false @Published var isPreliminary = false @Published var isPaused = false @Published var isFindingDuplicates = false @@ -52,7 +53,7 @@ final class AppModel: ObservableObject { @Published var sort: ItemSort = .allocatedSize @Published var currentParentID: Int64? @Published var navigationStack: [ScannedItem] = [] - @Published var pendingTrash: ScannedItem? + @Published var pendingTrashItems: [ScannedItem] = [] @Published var pendingTrashNeedsRiskConfirmation = false @Published var pendingHistoryDeletion: ScanRecord? @Published var userRules: [CleanupRule] = [] @@ -75,6 +76,7 @@ final class AppModel: ObservableObject { private var maintenanceTask: Task? private var activeSecurityScopedURL: URL? private var loadedDirectoryIDs = Set() + private var resultsReloadGeneration: UInt64 = 0 private let bookmarkDefaultsKey = "securityScopedScanBookmarks" private let journalDefaultsKey = "fseventsBaselineIDs" private let duplicateFinder = DuplicateFinder() @@ -109,6 +111,33 @@ final class AppModel: ObservableObject { set { intensityRaw = newValue.rawValue } } + var selectedItem: ScannedItem? { selectedItems.first } + var selectedItemIDs: Set { Set(selectedItems.map(\.id)) } + + var pendingTrashTitle: String { + if pendingTrashNeedsRiskConfirmation { + return pendingTrashItems.count == 1 + ? "This item is not classified as safe" + : "Some selected items are not classified as safe" + } + return pendingTrashItems.count == 1 + ? "Move this item to the Trash?" + : "Move \(pendingTrashItems.count.formatted()) items to the Trash?" + } + + var pendingTrashSummary: String { + guard !pendingTrashItems.isEmpty else { return "" } + if let item = pendingTrashItems.first, pendingTrashItems.count == 1 { + return "\(item.path)\n\n\(item.classification.reason)" + } + let bytes = pendingTrashItems.reduce(UInt64(0)) { $0 &+ $1.allocatedBytes } + let statuses = Dictionary(grouping: pendingTrashItems, by: \.classification.status) + .sorted { $0.key.riskRank > $1.key.riskRank } + .map { "\($0.key.localizedTitle): \($0.value.count.formatted())" } + .joined(separator: "\n") + return "Total on disk: \(HumanFormat.size(bytes))\n\n\(statuses)" + } + var filter: ItemFilter { let extensions = Set( extensionFilter.split(separator: ",").map { @@ -173,7 +202,7 @@ final class AppModel: ObservableObject { currentScan = nil currentParentID = 1 navigationStack = [] - selectedItem = nil + selectedItems = [] items = [] directoryItems = [] loadedDirectoryIDs.removeAll(keepingCapacity: true) @@ -259,7 +288,7 @@ final class AppModel: ObservableObject { isPreliminary = false currentParentID = 1 navigationStack = [] - selectedItem = nil + selectedItems = [] let root = try DiskScanner.makeRootItem(scanID: record.id, id: 1, url: url, engine: engine) try await store.insert([root]) let batchWriter = ScanBatchWriter(store: store) @@ -478,7 +507,7 @@ final class AppModel: ObservableObject { showingLargestItems = false currentParentID = 1 navigationStack = [] - selectedItem = nil + selectedItems = [] loadedDirectoryIDs.removeAll(keepingCapacity: true) Task { try? await reloadResults() } } @@ -495,7 +524,7 @@ final class AppModel: ObservableObject { currentScan = nil items = [] directoryItems = [] - selectedItem = nil + selectedItems = [] } await loadRecentScans() statusMessage = "Scan snapshot removed. Files on disk were not changed." @@ -505,17 +534,30 @@ final class AppModel: ObservableObject { func reloadResults() async throws { guard let store, let scanID = currentScan?.id else { return } + resultsReloadGeneration &+= 1 + let generation = resultsReloadGeneration + isLoadingResults = true + defer { + if resultsReloadGeneration == generation { isLoadingResults = false } + } let parent = currentParentID ?? 1 + let requestedFilter = filter + let requestedSort = sort + let requestedLargestItems = showingLargestItems let loadedItems: [ScannedItem] - if showingLargestItems { + if requestedLargestItems { loadedItems = try await store.fetchLargest( - scanID: scanID, containers: false, filter: filter, sort: sort, limit: 2_000) + scanID: scanID, containers: false, filter: requestedFilter, sort: requestedSort, limit: 2_000) } else { loadedItems = try await store.fetchChildren( - scanID: scanID, parentID: parent, filter: filter, sort: sort) + scanID: scanID, parentID: parent, filter: requestedFilter, sort: requestedSort) } + guard resultsRequestIsCurrent( + generation: generation, scanID: scanID, parentID: parent, + filter: requestedFilter, sort: requestedSort, showingLargestItems: requestedLargestItems + ) else { return } if items != loadedItems { items = loadedItems } - if !showingLargestItems && (directoryItems.isEmpty || parent == 1) { + if !requestedLargestItems && (directoryItems.isEmpty || parent == 1) { // Do not decode thousands of directory rows just to open the last snapshot. // The outline starts with the root and its immediate children; deeper // branches are reached through the table and are loaded on demand. @@ -528,14 +570,16 @@ final class AppModel: ObservableObject { } else { loadedDirectories = [] } + guard resultsRequestIsCurrent( + generation: generation, scanID: scanID, parentID: parent, + filter: requestedFilter, sort: requestedSort, showingLargestItems: requestedLargestItems + ) else { return } if directoryItems != loadedDirectories { directoryItems = loadedDirectories } } - if let selectedItem { - if let refreshed = loadedItems.first(where: { $0.id == selectedItem.id }) { - if refreshed != selectedItem { self.selectedItem = refreshed } - } else { - self.selectedItem = nil - } + if !selectedItems.isEmpty { + let loadedByID = Dictionary(uniqueKeysWithValues: loadedItems.map { ($0.id, $0) }) + let refreshedSelection = selectedItems.compactMap { loadedByID[$0.id] } + if refreshedSelection != selectedItems { selectedItems = refreshedSelection } } } @@ -568,7 +612,9 @@ final class AppModel: ObservableObject { showingLargestItems = true currentParentID = 1 navigationStack = [] - selectedItem = nil + selectedItems = [] + items = [] + isLoadingResults = true statusMessage = "Showing the largest files in this scan." Task { try? await reloadResults() } } @@ -576,38 +622,68 @@ final class AppModel: ObservableObject { func navigate(into item: ScannedItem) { showingLargestItems = false guard item.kind.canHaveChildren else { - selectedItem = item + selectedItems = [item] return } navigationStack.append(item) currentParentID = item.id - selectedItem = nil + selectedItems = [] + items = [] + isLoadingResults = true Task { try? await reloadResults() } } func navigateFromTree(_ item: ScannedItem) { showingLargestItems = false guard currentParentID != item.id else { - if selectedItem != item { selectedItem = item } + if selectedItems != [item] { selectedItems = [item] } + if items.isEmpty { + isLoadingResults = true + Task { try? await reloadResults() } + } return } currentParentID = item.id navigationStack = [item] - selectedItem = item + selectedItems = [item] + items = [] + isLoadingResults = true Task { try? await reloadResults() } } func selectItem(_ item: ScannedItem?) { - if selectedItem != item { selectedItem = item } + selectItems(item.map { [$0] } ?? []) + } + + func selectItems(_ items: [ScannedItem]) { + if selectedItems != items { selectedItems = items } } func navigateBack() { if !navigationStack.isEmpty { navigationStack.removeLast() } currentParentID = navigationStack.last?.id ?? 1 - selectedItem = nil + selectedItems = [] + items = [] + isLoadingResults = true Task { try? await reloadResults() } } + private func resultsRequestIsCurrent( + generation: UInt64, + scanID: Int64, + parentID: Int64, + filter requestedFilter: ItemFilter, + sort requestedSort: ItemSort, + showingLargestItems requestedLargestItems: Bool + ) -> Bool { + resultsReloadGeneration == generation + && currentScan?.id == scanID + && (currentParentID ?? 1) == parentID + && filter == requestedFilter + && sort.rawValue == requestedSort.rawValue + && showingLargestItems == requestedLargestItems + } + func reveal(_ item: ScannedItem) { let url = URL(fileURLWithPath: item.path) if FileManager.default.fileExists(atPath: item.path) { @@ -618,48 +694,90 @@ final class AppModel: ObservableObject { } } - func requestTrash(_ item: ScannedItem) { - switch FileActionPolicy.trashAuthorization(for: item, strictMode: strictDeletion) { - case .allowed: - pendingTrashNeedsRiskConfirmation = false - pendingTrash = item - case .requiresRiskConfirmation: - pendingTrashNeedsRiskConfirmation = true - pendingTrash = item - case .blocked(let reason): - errorMessage = reason + func requestTrash(_ item: ScannedItem) { requestTrash([item]) } + + func requestTrash(_ requestedItems: [ScannedItem]) { + var seen = Set() + let uniqueItems = requestedItems.filter { seen.insert($0.id).inserted } + guard !uniqueItems.isEmpty else { return } + + var needsRiskConfirmation = false + for item in uniqueItems { + switch FileActionPolicy.trashAuthorization(for: item, strictMode: strictDeletion) { + case .allowed: + break + case .requiresRiskConfirmation: + needsRiskConfirmation = true + case .blocked(let reason): + errorMessage = uniqueItems.count == 1 ? reason : "\(item.name): \(reason)" + return + } } + pendingTrashNeedsRiskConfirmation = needsRiskConfirmation + pendingTrashItems = uniqueItems } func confirmTrash() { - guard let item = pendingTrash, let store, let scanID = currentScan?.id else { return } - pendingTrash = nil + let requestedItems = pendingTrashItems + guard !requestedItems.isEmpty, let store, let scanID = currentScan?.id else { return } + pendingTrashItems = [] Task { - var resultingURL: NSURL? - do { - guard FileActionPolicy.currentIdentityMatches(item) else { - throw CocoaError( - .fileNoSuchFile, - userInfo: [ - NSLocalizedDescriptionKey: - "The item changed after the scan. Scan again before moving it to the Trash." - ]) + var trashedIDs: [Int64] = [] + var failures: [String] = [] + for item in requestedItems { + var resultingURL: NSURL? + do { + guard FileActionPolicy.currentIdentityMatches(item) else { + throw CocoaError( + .fileNoSuchFile, + userInfo: [ + NSLocalizedDescriptionKey: + "The item changed after the scan. Scan again before moving it to the Trash." + ]) + } + try FileManager.default.trashItem( + at: URL(fileURLWithPath: item.path), resultingItemURL: &resultingURL) + try await store.recordCleanupAction( + scanID: scanID, item: item, outcome: "trashed", message: resultingURL?.path) + trashedIDs.append(item.id) + } catch { + try? await store.recordCleanupAction( + scanID: scanID, item: item, outcome: "failed", message: error.localizedDescription) + failures.append("\(item.name): \(error.localizedDescription)") } - try FileManager.default.trashItem( - at: URL(fileURLWithPath: item.path), resultingItemURL: &resultingURL) - try await store.recordCleanupAction( - scanID: scanID, item: item, outcome: "trashed", message: resultingURL?.path) - try await store.markTrashed(scanID: scanID, itemIDs: [item.id]) - statusMessage = "Moved \(item.name) to the Trash." + } + if !trashedIDs.isEmpty { + do { + try await store.markTrashed(scanID: scanID, itemIDs: trashedIDs) + } catch { + failures.append("Could not update the scan snapshot: \(error.localizedDescription)") + } + } + selectedItems.removeAll { trashedIDs.contains($0.id) } + if let refreshedScan = try? await store.fetchScan(scanID), currentScan?.id == scanID { + currentScan = refreshedScan + } + if failures.isEmpty { + statusMessage = trashedIDs.count == 1 + ? "Moved one item to the Trash." + : "Moved \(trashedIDs.count.formatted()) items to the Trash." + } else { + statusMessage = "Moved \(trashedIDs.count.formatted()) of \(requestedItems.count.formatted()) items to the Trash." + errorMessage = failures.prefix(8).joined(separator: "\n") + } + do { try await reloadResults() } catch { - try? await store.recordCleanupAction( - scanID: scanID, item: item, outcome: "failed", message: error.localizedDescription) errorMessage = error.localizedDescription } } } + func cancelPendingTrash() { + pendingTrashItems = [] + pendingTrashNeedsRiskConfirmation = false + } + func findDuplicates() { guard let store, let scanID = currentScan?.id, !isFindingDuplicates else { return } isFindingDuplicates = true @@ -767,10 +885,15 @@ final class AppModel: ObservableObject { } private func bootstrap() async { - await loadRecentScans() if let store { + do { + try await store.reconcileInterruptedCleanupActions() + } catch { + errorMessage = "Could not finish a previous Trash update: \(error.localizedDescription)" + } userRules = (try? await store.loadUserRules()) ?? [] } + await loadRecentScans() } private func loadRecentScans() async { diff --git a/Sources/OpenDiskTreeApp/ContentView.swift b/Sources/OpenDiskTreeApp/ContentView.swift index ec8abec..010ffc8 100644 --- a/Sources/OpenDiskTreeApp/ContentView.swift +++ b/Sources/OpenDiskTreeApp/ContentView.swift @@ -34,18 +34,16 @@ struct ContentView: View { Text(model.errorMessage ?? "") } .confirmationDialog( - model.pendingTrashNeedsRiskConfirmation - ? "This item is not classified as safe" : "Move this item to the Trash?", + model.pendingTrashTitle, isPresented: Binding( - get: { model.pendingTrash != nil }, set: { if !$0 { model.pendingTrash = nil } }), + get: { !model.pendingTrashItems.isEmpty }, + set: { if !$0 { model.cancelPendingTrash() } }), titleVisibility: .visible ) { Button("Move to Trash", role: .destructive) { model.confirmTrash() } - Button("Cancel", role: .cancel) { model.pendingTrash = nil } + Button("Cancel", role: .cancel) { model.cancelPendingTrash() } } message: { - if let item = model.pendingTrash { - Text("\(item.path)\n\n\(item.classification.reason)") - } + Text(model.pendingTrashSummary) } .confirmationDialog( "Remove this scan snapshot?", @@ -276,9 +274,11 @@ struct ContentView: View { Divider() Menu("Complete scan") { exportButtons(scope: .entireScan) } Menu("Current filter") { exportButtons(scope: .filtered(model.filter)) } - if let item = model.selectedItem { - Menu("Selected item") { - exportButtons(scope: .selection(itemIDs: [item.id], includeDescendants: true)) + if !model.selectedItems.isEmpty { + Menu(model.selectedItems.count == 1 ? "Selected item" : "Selected items") { + exportButtons( + scope: .selection( + itemIDs: model.selectedItems.map(\.id), includeDescendants: true)) } } } label: { @@ -305,15 +305,23 @@ struct ContentView: View { ) .frame(minWidth: 150, idealWidth: 185, maxWidth: 235, maxHeight: .infinity) VSplitView { - ResultsTableView( - items: model.items, - selectedID: model.selectedItem?.id, - isScanning: model.isScanning, - onSelect: model.selectItem, - onOpen: { $0.kind.canHaveChildren ? model.navigate(into: $0) : model.reveal($0) }, - onReveal: model.reveal, - onTrash: model.requestTrash - ) + ZStack { + ResultsTableView( + items: model.items, + selectedIDs: model.selectedItemIDs, + isScanning: model.isScanning, + onSelect: model.selectItems, + onOpen: { $0.kind.canHaveChildren ? model.navigate(into: $0) : model.reveal($0) }, + onReveal: model.reveal, + onTrash: model.requestTrash + ) + if model.isLoadingResults && !model.isScanning { + ProgressView("Loading folder…") + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) + } + } .frame(minWidth: 400, minHeight: 260, idealHeight: 390, maxHeight: .infinity) TreemapView( items: model.items, @@ -327,7 +335,7 @@ struct ContentView: View { } .frame(minWidth: 400, maxWidth: .infinity, maxHeight: .infinity) InspectorView( - item: model.selectedItem, onReveal: model.reveal, onTrash: model.requestTrash, + items: model.selectedItems, onReveal: model.reveal, onTrash: model.requestTrash, onOpenSourceApp: model.openSourceApplication ) .frame(minWidth: 220, idealWidth: 245, maxWidth: 285, maxHeight: .infinity) diff --git a/Sources/OpenDiskTreeApp/InspectorView.swift b/Sources/OpenDiskTreeApp/InspectorView.swift index d2be7bb..313aa19 100644 --- a/Sources/OpenDiskTreeApp/InspectorView.swift +++ b/Sources/OpenDiskTreeApp/InspectorView.swift @@ -2,14 +2,14 @@ import OpenDiskTreeCore import SwiftUI struct InspectorView: View { - let item: ScannedItem? + let items: [ScannedItem] let onReveal: (ScannedItem) -> Void - let onTrash: (ScannedItem) -> Void + let onTrash: ([ScannedItem]) -> Void let onOpenSourceApp: (ScannedItem) -> Void var body: some View { Group { - if let item { + if items.count == 1, let item = items.first { ScrollView { VStack(alignment: .leading, spacing: 14) { Label( @@ -37,13 +37,38 @@ struct InspectorView: View { Label(String(localized: "action.reveal"), systemImage: "finder") } Button(role: .destructive) { - onTrash(item) + onTrash(items) } label: { Label(String(localized: "action.trash"), systemImage: "trash") } } .padding() } + } else if !items.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Label("\(items.count.formatted()) items selected", systemImage: "checkmark.circle") + .font(.headline) + LabeledContent("On disk", value: HumanFormat.size(totalAllocatedBytes)) + LabeledContent("Logical", value: HumanFormat.size(totalLogicalBytes)) + Divider() + ForEach(statusCounts, id: \.status) { entry in + HStack { + Label(entry.status.localizedTitle, systemImage: entry.status.symbol) + .foregroundStyle(entry.status.color) + Spacer() + Text(entry.count.formatted()).monospacedDigit() + } + } + Divider() + Button(role: .destructive) { + onTrash(items) + } label: { + Label("Move selected to Trash", systemImage: "trash") + } + } + .padding() + } } else { ContentUnavailableView( "No selection", systemImage: "cursorarrow.click", @@ -52,4 +77,18 @@ struct InspectorView: View { } .frame(minWidth: 220, idealWidth: 250) } + + private var totalAllocatedBytes: UInt64 { + items.reduce(0) { $0 &+ $1.allocatedBytes } + } + + private var totalLogicalBytes: UInt64 { + items.reduce(0) { $0 &+ $1.logicalBytes } + } + + private var statusCounts: [(status: SafetyStatus, count: Int)] { + Dictionary(grouping: items, by: \.classification.status) + .map { (status: $0.key, count: $0.value.count) } + .sorted { $0.status.riskRank > $1.status.riskRank } + } } diff --git a/Sources/OpenDiskTreeApp/ResultsTableView.swift b/Sources/OpenDiskTreeApp/ResultsTableView.swift index 8cdd179..e058b91 100644 --- a/Sources/OpenDiskTreeApp/ResultsTableView.swift +++ b/Sources/OpenDiskTreeApp/ResultsTableView.swift @@ -4,19 +4,19 @@ import SwiftUI struct ResultsTableView: NSViewRepresentable { let items: [ScannedItem] - let selectedID: Int64? + let selectedIDs: Set let isScanning: Bool - let onSelect: (ScannedItem?) -> Void + let onSelect: ([ScannedItem]) -> Void let onOpen: (ScannedItem) -> Void let onReveal: (ScannedItem) -> Void - let onTrash: (ScannedItem) -> Void + let onTrash: ([ScannedItem]) -> Void func makeCoordinator() -> Coordinator { Coordinator(parent: self) } func makeNSView(context: Context) -> NSScrollView { let table = NSTableView() table.usesAlternatingRowBackgroundColors = true - table.allowsMultipleSelection = false + table.allowsMultipleSelection = true table.rowHeight = 24 table.intercellSpacing = NSSize(width: 8, height: 1) table.delegate = context.coordinator @@ -94,14 +94,13 @@ struct ResultsTableView: NSViewRepresentable { renderedIsScanning = parent.isScanning table.reloadData() } - let desiredRow = parent.selectedID.flatMap { id in - parent.items.firstIndex(where: { $0.id == id }) - } - if let desiredRow { - if table.selectedRow != desiredRow { - table.selectRowIndexes(IndexSet(integer: desiredRow), byExtendingSelection: false) + let desiredRows = IndexSet( + parent.items.indices.filter { parent.selectedIDs.contains(parent.items[$0].id) }) + if !desiredRows.isEmpty { + if table.selectedRowIndexes != desiredRows { + table.selectRowIndexes(desiredRows, byExtendingSelection: false) } - } else if table.selectedRow >= 0 { + } else if !table.selectedRowIndexes.isEmpty { table.deselectAll(nil) } } @@ -155,12 +154,11 @@ struct ResultsTableView: NSViewRepresentable { func tableViewSelectionDidChange(_ notification: Notification) { guard !isSynchronizingSelection else { return } - guard let row = table?.selectedRow, row >= 0, row < parent.items.count else { - if parent.selectedID != nil { parent.onSelect(nil) } - return + guard let table else { return } + let selection = table.selectedRowIndexes.compactMap { row in + row < parent.items.count ? parent.items[row] : nil } - let item = parent.items[row] - if item.id != parent.selectedID { parent.onSelect(item) } + if Set(selection.map(\.id)) != parent.selectedIDs { parent.onSelect(selection) } } @objc func doubleClick() { @@ -169,12 +167,28 @@ struct ResultsTableView: NSViewRepresentable { } @objc func reveal() { if let item = selectedItem { parent.onReveal(item) } } - @objc func trash() { if let item = selectedItem { parent.onTrash(item) } } + @objc func trash() { + let items = contextualItems + if !items.isEmpty { parent.onTrash(items) } + } private var selectedItem: ScannedItem? { guard let row = table?.clickedRow ?? table?.selectedRow, row >= 0, row < parent.items.count else { return nil } return parent.items[row] } + + private var contextualItems: [ScannedItem] { + guard let table else { return [] } + let clickedRow = table.clickedRow + if clickedRow >= 0, clickedRow < parent.items.count, + !table.selectedRowIndexes.contains(clickedRow) + { + return [parent.items[clickedRow]] + } + return table.selectedRowIndexes.compactMap { row in + row < parent.items.count ? parent.items[row] : nil + } + } } } diff --git a/Sources/OpenDiskTreeCore/DiskScanner.swift b/Sources/OpenDiskTreeCore/DiskScanner.swift index 89620b0..2a62da6 100644 --- a/Sources/OpenDiskTreeCore/DiskScanner.swift +++ b/Sources/OpenDiskTreeCore/DiskScanner.swift @@ -303,14 +303,20 @@ public final class DiskScanner: Sendable { progress.currentPath = outcome.directory.path if let error = outcome.error { - progress.inaccessible += 1 - errors.append( - ScanErrorRecord( - scanID: scanID, - path: outcome.directory.path, - code: error.code, - message: error.message - )) + if Self.shouldReportDirectoryReadError( + path: outcome.directory.path, + rootPath: rootPath, + code: error.code + ) { + progress.inaccessible += 1 + errors.append( + ScanErrorRecord( + scanID: scanID, + path: outcome.directory.path, + code: error.code, + message: error.message + )) + } } else if let listing = outcome.listing { if listing.usedBulkAPI { bulkCount += 1 } else { fallbackCount += 1 } @@ -619,4 +625,25 @@ public final class DiskScanner: Sendable { ] return excluded.contains(where: { path == $0 || path.hasPrefix($0 + "/") }) } + + static func shouldReportDirectoryReadError(path: String, rootPath: String, code: Int32) -> Bool { + guard rootPath == "/", code == EACCES || code == EPERM else { return true } + + // A non-privileged macOS app cannot traverse these system-owned locations, + // even with Full Disk Access. They are expected gaps in a full-disk scan, + // not actionable scan failures. Explicit folder scans still report them. + let protectedSystemPaths = [ + "/System", + "/private/var", + "/private/etc/cups/certs", + "/usr/sbin/authserver", + "/Library/Application Support/Apple/AssetCache/Data", + "/Library/Application Support/Apple/ParentalControls/Users", + "/Library/Caches/com.apple.amsengagementd.classicdatavault", + "/Library/Caches/com.apple.aned", + "/Library/Caches/com.apple.aneuserd", + "/Library/Caches/com.apple.iconservices.store", + ] + return !protectedSystemPaths.contains { path == $0 || path.hasPrefix($0 + "/") } + } } diff --git a/Sources/OpenDiskTreeCore/ScanStore.swift b/Sources/OpenDiskTreeCore/ScanStore.swift index 51e5a9f..137fb0c 100644 --- a/Sources/OpenDiskTreeCore/ScanStore.swift +++ b/Sources/OpenDiskTreeCore/ScanStore.swift @@ -629,10 +629,14 @@ public actor ScanStore { try connection.execute("PRAGMA mmap_size=268435456") try connection.execute("BEGIN IMMEDIATE") do { - // A new process cannot resume a transaction owned by a previous app - // process. Remove those abandoned snapshots, plus obsolete partial - // snapshots that already have a newer successful replacement. - try connection.execute("DELETE FROM scans WHERE state='running'") + // Leave recent running rows alone: another OpenDiskTree process may own + // them. Only a row that has been abandoned for a full day is eligible. + try connection.execute( + """ + DELETE FROM scans + WHERE state='running' + AND started_at < strftime('%Y-%m-%dT%H:%M:%SZ','now','-1 day') + """) try connection.execute( "DELETE FROM scans WHERE state='cancelled' AND item_count=-1") try connection.execute( @@ -658,6 +662,19 @@ public actor ScanStore { sql: sql ) try connection.stepDone(statement, sql: sql) + let usableCountSQL = + "SELECT COUNT(*) FROM scans WHERE state IN ('completed','cancelled','failed') AND item_count>=0" + let usableCountStatement = try connection.prepare(usableCountSQL) + let usableCount = + sqlite3_step(usableCountStatement) == SQLITE_ROW + ? sqlite3_column_int64(usableCountStatement, 0) : 0 + sqlite3_finalize(usableCountStatement) + if usableCount == 0 { + throw StoreError.sqlite( + code: SQLITE_ABORT, + message: "History maintenance refused to remove the last usable snapshot.", + sql: usableCountSQL) + } try connection.execute( "DELETE FROM cleanup_actions WHERE scan_id NOT IN (SELECT id FROM scans)") // Incremental updates mark removed subtrees immediately so foreground @@ -1054,6 +1071,32 @@ public actor ScanStore { ) } + /// Finishes snapshot updates when the app stopped after Finder accepted a + /// Trash operation but before `markTrashed` could commit its bookkeeping. + public func reconcileInterruptedCleanupActions() throws { + let sql = """ + SELECT action.scan_id,action.item_id,action.original_path + FROM cleanup_actions action + JOIN items item ON item.scan_id=action.scan_id AND item.id=action.item_id + WHERE action.outcome='trashed' AND item.is_deleted=0 + ORDER BY action.id + """ + let statement = try database.prepare(sql) + var missingItems: [Int64: [Int64]] = [:] + while sqlite3_step(statement) == SQLITE_ROW { + let scanID = sqlite3_column_int64(statement, 0) + let itemID = sqlite3_column_int64(statement, 1) + let path = Self.text(statement, 2) ?? "" + if !path.isEmpty, !FileManager.default.fileExists(atPath: path) { + missingItems[scanID, default: []].append(itemID) + } + } + sqlite3_finalize(statement) + for (scanID, itemIDs) in missingItems { + try markTrashed(scanID: scanID, itemIDs: itemIDs) + } + } + public func errors(scanID: Int64) throws -> [ScanErrorRecord] { let sql = "SELECT scan_id, path, error_code, message FROM scan_errors WHERE scan_id=? ORDER BY id" @@ -1233,35 +1276,132 @@ public actor ScanStore { public func markTrashed(scanID: Int64, itemIDs: [Int64]) throws { guard !itemIDs.isEmpty else { return } + try database.execute( + "CREATE TEMP TABLE IF NOT EXISTS odt_trash_targets(item_id INTEGER PRIMARY KEY)") + try database.execute( + "CREATE TEMP TABLE IF NOT EXISTS odt_trash_ancestors(item_id INTEGER PRIMARY KEY, depth INTEGER NOT NULL)") + try database.execute( + "CREATE TEMP TABLE IF NOT EXISTS odt_trash_descendants(item_id INTEGER PRIMARY KEY)") + try database.execute( + """ + CREATE TEMP TABLE IF NOT EXISTS odt_folder_rollups( + parent_id INTEGER PRIMARY KEY, + logical_bytes INTEGER NOT NULL, + allocated_bytes INTEGER NOT NULL, + risk_rank INTEGER NOT NULL, + non_review INTEGER NOT NULL, + child_count INTEGER NOT NULL, + protected_rule INTEGER NOT NULL + ) + """) try database.execute("BEGIN IMMEDIATE") do { - for id in itemIDs { + try database.execute("DELETE FROM odt_trash_targets") + try database.execute("DELETE FROM odt_trash_ancestors") + try database.execute("DELETE FROM odt_trash_descendants") + for id in Set(itemIDs) { try executeBound( - """ - WITH RECURSIVE descendants(id) AS ( - SELECT ? - UNION ALL - SELECT child.id FROM items child JOIN descendants parent ON child.parent_id=parent.id WHERE child.scan_id=? - ) - UPDATE items SET is_deleted=1 WHERE scan_id=? AND id IN (SELECT id FROM descendants) - """, [.integer(id), .integer(scanID), .integer(scanID)]) + "INSERT OR IGNORE INTO odt_trash_targets(item_id) VALUES(?)", [.integer(id)]) + } + + // Capture only the lineage above the selected rows. The previous implementation + // recomputed every directory in a multi-million-item scan after moving one file, + // which kept all navigation queries waiting behind the ScanStore actor. + try executeBound( + """ + WITH RECURSIVE ancestors(item_id,parent_id,depth) AS ( + SELECT parent.id,parent.parent_id,parent.depth + FROM odt_trash_targets target + JOIN items selected ON selected.scan_id=? AND selected.id=target.item_id + JOIN items parent ON parent.scan_id=selected.scan_id AND parent.id=selected.parent_id + UNION + SELECT parent.id,parent.parent_id,parent.depth + FROM ancestors child + JOIN items parent ON parent.scan_id=? AND parent.id=child.parent_id + ) + INSERT OR IGNORE INTO odt_trash_ancestors(item_id,depth) + SELECT item_id,depth FROM ancestors + """, [.integer(scanID), .integer(scanID)]) + + try executeBound( + """ + WITH RECURSIVE descendants(id) AS ( + SELECT item.id + FROM odt_trash_targets target + JOIN items item ON item.scan_id=? AND item.id=target.item_id AND item.is_deleted=0 + UNION + SELECT child.id + FROM items child + JOIN descendants parent ON child.parent_id=parent.id + WHERE child.scan_id=? AND child.is_deleted=0 + ) + INSERT OR IGNORE INTO odt_trash_descendants(item_id) + SELECT id FROM descendants + """, [.integer(scanID), .integer(scanID)]) + let deletedItemCount = try scalarInt( + "SELECT COUNT(*) FROM odt_trash_descendants", []) + try executeBound( + """ + UPDATE items SET is_deleted=1 + WHERE scan_id=? AND id IN (SELECT item_id FROM odt_trash_descendants) + """, [.integer(scanID)]) + + let maximumAncestorDepth = try scalarInt( + "SELECT COALESCE(MAX(depth),-1) FROM odt_trash_ancestors", []) + if maximumAncestorDepth >= 0 { + for depth in stride(from: maximumAncestorDepth, through: 0, by: -1) { + try database.execute("DELETE FROM odt_folder_rollups") + try executeBound( + """ + INSERT INTO odt_folder_rollups( + parent_id,logical_bytes,allocated_bytes,risk_rank,non_review,child_count,protected_rule + ) + SELECT parent.id, + COALESCE(SUM(child.logical_bytes),0), + COALESCE(SUM(child.allocated_bytes),0), + COALESCE(MAX(CASE child.safety_status + WHEN 'do_not_touch' THEN 5 + WHEN 'delete_via_source_app' THEN 4 + WHEN 'mixed' THEN 3 + WHEN 'review' THEN 2 + WHEN 'recreated_automatically' THEN 1 + ELSE 0 END),0), + COALESCE(SUM(CASE WHEN child.id IS NULL OR child.safety_status='review' THEN 0 ELSE 1 END),0), + COUNT(child.id),COALESCE(MAX(child.is_protected_rule),0) + FROM odt_trash_ancestors affected + JOIN items parent ON parent.scan_id=? AND parent.id=affected.item_id + LEFT JOIN items child ON child.scan_id=parent.scan_id + AND child.parent_id=parent.id AND child.is_deleted=0 + WHERE affected.depth=? + GROUP BY parent.id + """, [.integer(scanID), .integer(depth)]) + let parentSQL = "SELECT parent_id FROM odt_folder_rollups" + let parentStatement = try database.prepare(parentSQL) + var parentIDs: [Int64] = [] + while sqlite3_step(parentStatement) == SQLITE_ROW { + parentIDs.append(sqlite3_column_int64(parentStatement, 0)) + } + sqlite3_finalize(parentStatement) + for parentID in parentIDs { + try updateDirectoriesFromFolderRollups( + scanID: scanID, depth: Int(depth), itemID: parentID) + } + } } + + try executeBound( + """ + UPDATE scans SET + item_count=MAX(0,item_count-?), + logical_bytes=COALESCE((SELECT logical_bytes FROM items WHERE scan_id=? AND parent_id IS NULL AND is_deleted=0),0), + allocated_bytes=COALESCE((SELECT allocated_bytes FROM items WHERE scan_id=? AND parent_id IS NULL AND is_deleted=0),0) + WHERE id=? + """, [.integer(deletedItemCount), .integer(scanID), .integer(scanID), .integer(scanID)]) try database.execute("COMMIT") } catch { try? database.execute("ROLLBACK") throw error } - let maximumDepth = try scalarInt( - "SELECT COALESCE(MAX(depth),0) FROM items WHERE scan_id=?", [.integer(scanID)]) - try aggregateDirectories(scanID: scanID, maximumDepth: Int(maximumDepth)) - try executeBound( - """ - UPDATE scans SET - item_count=(SELECT COUNT(*) FROM items WHERE scan_id=? AND is_deleted=0), - logical_bytes=COALESCE((SELECT logical_bytes FROM items WHERE scan_id=? AND parent_id IS NULL),0), - allocated_bytes=COALESCE((SELECT allocated_bytes FROM items WHERE scan_id=? AND parent_id IS NULL),0) - WHERE id=? - """, [.integer(scanID), .integer(scanID), .integer(scanID), .integer(scanID)]) } public func loadUserRules() throws -> [CleanupRule] { @@ -1347,35 +1487,7 @@ public actor ScanStore { WHERE parent.scan_id=? AND parent.depth=? AND parent.kind IN ('directory','package') GROUP BY parent.id """, [.integer(scanID), .integer(Int64(depth))]) - try executeBound( - """ - UPDATE items AS parent SET - logical_bytes = parent.own_logical_bytes + COALESCE((SELECT logical_bytes FROM odt_folder_rollups WHERE parent_id=parent.id),0), - allocated_bytes = parent.accounted_allocated_bytes + COALESCE((SELECT allocated_bytes FROM odt_folder_rollups WHERE parent_id=parent.id),0), - safety_status = CASE - WHEN parent.is_protected_rule=1 AND parent.safety_status IN ('do_not_touch','delete_via_source_app') THEN parent.safety_status - WHEN rollup.risk_rank=5 THEN 'do_not_touch' - WHEN rollup.risk_rank=4 THEN 'delete_via_source_app' - WHEN rollup.risk_rank=0 THEN 'safe_to_delete' - WHEN rollup.risk_rank<=1 THEN 'recreated_automatically' - WHEN rollup.risk_rank=2 AND rollup.non_review=0 THEN 'review' - ELSE 'mixed' END, - reason = CASE - WHEN parent.is_protected_rule=1 AND parent.safety_status IN ('do_not_touch','delete_via_source_app') THEN parent.reason - WHEN rollup.risk_rank=5 THEN 'This folder contains protected data and must not be removed directly.' - WHEN rollup.risk_rank=4 THEN 'This folder contains application-managed data. Use the source application for cleanup.' - WHEN rollup.risk_rank=0 THEN 'All scanned contents matched safe cleanup rules.' - WHEN rollup.risk_rank<=1 THEN 'All scanned contents are disposable or can be rebuilt automatically.' - WHEN rollup.risk_rank=2 AND rollup.non_review=0 THEN 'No trusted cleanup rule matched this folder or its contents.' - ELSE 'This folder contains mixed safety statuses. Inspect its contents before cleanup.' END, - rule_id = CASE - WHEN parent.is_protected_rule=1 AND parent.safety_status IN ('do_not_touch','delete_via_source_app') THEN parent.rule_id - ELSE 'aggregate.folder' END, - is_protected_rule = MAX(parent.is_protected_rule, rollup.protected_rule) - FROM odt_folder_rollups rollup - WHERE parent.scan_id=? AND parent.depth=? AND parent.kind IN ('directory','package') - AND parent.id=rollup.parent_id - """, [.integer(scanID), .integer(Int64(depth))]) + try updateDirectoriesFromFolderRollups(scanID: scanID, depth: depth) } if ownsTransaction { try database.execute("COMMIT") } } catch { @@ -1384,6 +1496,43 @@ public actor ScanStore { } } + private func updateDirectoriesFromFolderRollups( + scanID: Int64, depth: Int, itemID: Int64? = nil + ) throws { + let itemClause = itemID == nil ? "" : " AND parent.id=?" + var values: [SQLValue] = [.integer(scanID), .integer(Int64(depth))] + if let itemID { values.append(.integer(itemID)) } + try executeBound( + """ + UPDATE items AS parent SET + logical_bytes = parent.own_logical_bytes + COALESCE((SELECT logical_bytes FROM odt_folder_rollups WHERE parent_id=parent.id),0), + allocated_bytes = parent.accounted_allocated_bytes + COALESCE((SELECT allocated_bytes FROM odt_folder_rollups WHERE parent_id=parent.id),0), + safety_status = CASE + WHEN parent.is_protected_rule=1 AND parent.safety_status IN ('do_not_touch','delete_via_source_app') THEN parent.safety_status + WHEN rollup.risk_rank=5 THEN 'do_not_touch' + WHEN rollup.risk_rank=4 THEN 'delete_via_source_app' + WHEN rollup.risk_rank=0 THEN 'safe_to_delete' + WHEN rollup.risk_rank<=1 THEN 'recreated_automatically' + WHEN rollup.risk_rank=2 AND rollup.non_review=0 THEN 'review' + ELSE 'mixed' END, + reason = CASE + WHEN parent.is_protected_rule=1 AND parent.safety_status IN ('do_not_touch','delete_via_source_app') THEN parent.reason + WHEN rollup.risk_rank=5 THEN 'This folder contains protected data and must not be removed directly.' + WHEN rollup.risk_rank=4 THEN 'This folder contains application-managed data. Use the source application for cleanup.' + WHEN rollup.risk_rank=0 THEN 'All scanned contents matched safe cleanup rules.' + WHEN rollup.risk_rank<=1 THEN 'All scanned contents are disposable or can be rebuilt automatically.' + WHEN rollup.risk_rank=2 AND rollup.non_review=0 THEN 'No trusted cleanup rule matched this folder or its contents.' + ELSE 'This folder contains mixed safety statuses. Inspect its contents before cleanup.' END, + rule_id = CASE + WHEN parent.is_protected_rule=1 AND parent.safety_status IN ('do_not_touch','delete_via_source_app') THEN parent.rule_id + ELSE 'aggregate.folder' END, + is_protected_rule = MAX(parent.is_protected_rule, rollup.protected_rule) + FROM odt_folder_rollups rollup + WHERE parent.scan_id=? AND parent.depth=? AND parent.kind IN ('directory','package') + AND parent.id=rollup.parent_id\(itemClause) + """, values) + } + /// Applies the scanner's single-pass bottom-up aggregation. The previous SQL /// implementation rescanned a multi-million-row table once per path depth; /// deeply nested dependency trees made that final phase longer than the disk @@ -1674,6 +1823,52 @@ public actor ScanStore { "ALTER TABLE scans ADD COLUMN journal_complete INTEGER NOT NULL DEFAULT 1") try database.execute("PRAGMA user_version=3") } + if version < 4 { + try removeExpectedFullDiskAccessErrors(database) + try database.execute("PRAGMA user_version=4") + } + } + + private static func removeExpectedFullDiskAccessErrors(_ database: SQLiteConnection) throws { + let query = """ + SELECT error.id,error.path,error.error_code + FROM scan_errors AS error + JOIN scans AS scan ON scan.id=error.scan_id + WHERE scan.root_path='/' + """ + let statement = try database.prepare(query) + defer { sqlite3_finalize(statement) } + var removableIDs: [Int64] = [] + while sqlite3_step(statement) == SQLITE_ROW { + let id = sqlite3_column_int64(statement, 0) + let path = text(statement, 1) ?? "" + let code = sqlite3_column_int(statement, 2) + if !DiskScanner.shouldReportDirectoryReadError(path: path, rootPath: "/", code: code) { + removableIDs.append(id) + } + } + guard !removableIDs.isEmpty else { return } + + try database.execute("BEGIN IMMEDIATE") + do { + let deleteSQL = "DELETE FROM scan_errors WHERE id=?" + let deleteStatement = try database.prepare(deleteSQL) + defer { sqlite3_finalize(deleteStatement) } + for id in removableIDs { + try database.bind([.integer(id)], to: deleteStatement, sql: deleteSQL) + try database.stepDone(deleteStatement, sql: deleteSQL) + } + try database.execute( + """ + UPDATE scans SET inaccessible_count=( + SELECT COUNT(*) FROM scan_errors WHERE scan_errors.scan_id=scans.id + ) WHERE root_path='/' + """) + try database.execute("COMMIT") + } catch { + try? database.execute("ROLLBACK") + throw error + } } private func append(filter: ItemFilter, clauses: inout [String], values: inout [SQLValue]) { diff --git a/Support/Info.plist b/Support/Info.plist index 47a25f7..17e88af 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -9,8 +9,8 @@ CFBundleNameOpenDiskTree CFBundleDisplayNameOpenDiskTree CFBundlePackageTypeAPPL - CFBundleShortVersionString1.3.2 - CFBundleVersion10 + CFBundleShortVersionString1.3.3 + CFBundleVersion11 LSMinimumSystemVersion14.0 LSApplicationCategoryTypepublic.app-category.utilities NSHighResolutionCapable diff --git a/Tests/OpenDiskTreeCoreTests/CoreBehaviorTests.swift b/Tests/OpenDiskTreeCoreTests/CoreBehaviorTests.swift index fef169a..2c0070b 100644 --- a/Tests/OpenDiskTreeCoreTests/CoreBehaviorTests.swift +++ b/Tests/OpenDiskTreeCoreTests/CoreBehaviorTests.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import CSQLite import Testing @@ -88,6 +89,28 @@ private func scanFixture(_ root: URL, databaseURL: URL) async throws -> (ScanSto rootDeviceID: 10, entryDeviceID: 11, isMountPoint: true, crossSelectedVolume: true)) } +@Test func fullDiskScanDoesNotReportPermanentSystemAccessDenials() { + #expect( + !DiskScanner.shouldReportDirectoryReadError( + path: "/System/Library/Caches/com.apple.some-service", rootPath: "/", code: EPERM)) + #expect( + !DiskScanner.shouldReportDirectoryReadError( + path: "/private/var/db/fseventsd", rootPath: "/", code: EACCES)) + #expect( + !DiskScanner.shouldReportDirectoryReadError( + path: "/Library/Caches/com.apple.aned", rootPath: "/", code: EPERM)) + + #expect( + DiskScanner.shouldReportDirectoryReadError( + path: "/Users/example/Library/Mail", rootPath: "/", code: EPERM)) + #expect( + DiskScanner.shouldReportDirectoryReadError( + path: "/private/var/db/fseventsd", rootPath: "/private/var", code: EACCES)) + #expect( + DiskScanner.shouldReportDirectoryReadError( + path: "/System/Library", rootPath: "/", code: EIO)) +} + @Test func cancelledSnapshotUsesLiveProgressTotals() async throws { let workspace = try TestWorkspace() defer { workspace.remove() } @@ -534,6 +557,67 @@ private func scanFixture(_ root: URL, databaseURL: URL) async throws -> (ScanSto #expect(updated.allocatedBytes == 0) } +@Test func trashRecalculatesOnlyAffectedAncestorTotals() async throws { + let workspace = try TestWorkspace() + defer { workspace.remove() } + let root = workspace.url.appendingPathComponent("selective-cleanup", isDirectory: true) + let removedDirectory = root.appendingPathComponent("removed", isDirectory: true) + let retainedDirectory = root.appendingPathComponent("retained", isDirectory: true) + try FileManager.default.createDirectory(at: removedDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: retainedDirectory, withIntermediateDirectories: true) + try write( + String(repeating: "x", count: 8_192), + to: removedDirectory.appendingPathComponent("cache.bin")) + try write( + String(repeating: "y", count: 16_384), + to: retainedDirectory.appendingPathComponent("keep.bin")) + + let (store, scan) = try await scanFixture( + root, databaseURL: workspace.url.appendingPathComponent("selective-cleanup.sqlite")) + let rootChildren = try await store.fetchChildren(scanID: scan.id, parentID: 1) + let removed = try #require(rootChildren.first { $0.name == "removed" }) + let retainedBefore = try #require(rootChildren.first { $0.name == "retained" }) + let removedFile = try #require( + await store.fetchChildren(scanID: scan.id, parentID: removed.id).first) + + try await store.markTrashed(scanID: scan.id, itemIDs: [removedFile.id]) + + let removedAfter = try #require(await store.fetchItem(scanID: scan.id, itemID: removed.id)) + let retainedAfter = try #require(await store.fetchItem(scanID: scan.id, itemID: retainedBefore.id)) + let rootAfter = try #require(await store.fetchItem(scanID: scan.id, itemID: 1)) + #expect(removedAfter.logicalBytes == 0) + #expect(removedAfter.allocatedBytes == 0) + #expect(retainedAfter.logicalBytes == retainedBefore.logicalBytes) + #expect(retainedAfter.allocatedBytes == retainedBefore.allocatedBytes) + #expect(rootAfter.logicalBytes == retainedAfter.logicalBytes) + #expect(rootAfter.allocatedBytes == retainedAfter.allocatedBytes) +} + +@Test func interruptedTrashBookkeepingIsRecoveredOnLaunch() async throws { + let workspace = try TestWorkspace() + defer { workspace.remove() } + let root = workspace.url.appendingPathComponent("interrupted-cleanup", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let fileURL = root.appendingPathComponent("already-moved.bin") + try write(String(repeating: "x", count: 8_192), to: fileURL) + let (store, scan) = try await scanFixture( + root, databaseURL: workspace.url.appendingPathComponent("interrupted-cleanup.sqlite")) + let file = try #require( + await store.fetchLargest(scanID: scan.id, containers: false, limit: 1).first) + + try await store.recordCleanupAction( + scanID: scan.id, item: file, outcome: "trashed", message: nil) + try FileManager.default.removeItem(at: fileURL) + try await store.reconcileInterruptedCleanupActions() + + let visibleChildren = try await store.fetchChildren(scanID: scan.id, parentID: 1) + let updated = try #require(await store.fetchScan(scan.id)) + #expect(visibleChildren.isEmpty) + #expect(updated.itemCount == 1) + #expect(updated.logicalBytes == 0) + #expect(updated.allocatedBytes == 0) +} + @Test func identityCheckDetectsReplacementAfterScan() async throws { let workspace = try TestWorkspace() defer { workspace.remove() } From f893d219f74fb1559b3c4aa397fcfe2d5c390583 Mon Sep 17 00:00:00 2001 From: NikkyWay <243857824+NikkyWay@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:00:49 +0200 Subject: [PATCH 2/2] feat: expose inaccessible scan paths --- CHANGELOG.md | 1 + README.md | 2 + Sources/OpenDiskTreeApp/AppModel.swift | 41 ++++- Sources/OpenDiskTreeApp/ContentView.swift | 25 +++- Sources/OpenDiskTreeApp/ScanErrorsView.swift | 148 +++++++++++++++++++ 5 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 Sources/OpenDiskTreeApp/ScanErrorsView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d9cc0..7b9b94c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to OpenDiskTree are recorded here. - Recalculate only affected parent folders after a Trash operation instead of blocking navigation with a full-index aggregation. - Finish interrupted snapshot bookkeeping on launch when an item already reached the system Trash. - Prevent background history maintenance from removing a recent running scan or the last usable snapshot. +- Make the inaccessible counter open a searchable error list with copy and Finder actions, including live errors while a scan is running. - Add Command- and Shift-click multi-selection with one reviewed Trash operation for the selected batch. ## 1.3.2 — 2026-08-09 diff --git a/README.md b/README.md index 7a42edc..0f61042 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Use **Scan Folder** for a normal directory or external local disk. **Scan Full D macOS protects Mail, Messages, browser data and several other directories. To include them, open **Full Disk Access…**, enable OpenDiskTree in System Settings, quit the app and launch it again. The scanner never installs a privileged helper. Unexpected access failures are counted and exported as scan errors; permanently closed system-owned paths are skipped quietly during a full-disk scan because they cannot be opened by a non-privileged app even with Full Disk Access. +The orange **inaccessible** count is a button. Open it to inspect every path and system error, search the list, copy it for troubleshooting or reveal the nearest available parent directory in Finder. These entries do not stop the scan, but totals below an inaccessible directory can be incomplete. + When you choose a folder through **Scan Folder**, OpenDiskTree stores a macOS security-scoped bookmark for that folder and reuses it on the next launch. The first access still requires the normal macOS confirmation. Scanning the entire `/` volume is governed by the separate Full Disk Access setting; rebuilding an ad-hoc local copy can make macOS treat it as a new app identity and ask again. Balanced mode limits I/O pressure. Turbo mode uses larger batches and more parallel directory reads. Both modes keep a bounded pool continuously occupied instead of waiting for the slowest directory in a batch. Metadata batches are written through an ordered SQLite pipeline with backpressure, so traversal and persistence overlap without retaining an unbounded number of files in memory. Folder totals and safety labels are accumulated once, bottom-up, while the scan is still hot; finalization updates each directory exactly once instead of rescanning the item table for every path depth. New stores use 32 KiB pages and make the composite item identity the table key instead of maintaining an unused hidden rowid. SQLite gets a bounded metadata cache and read-only index mapping so large B-trees do not repeatedly fall back to SSD reads. The hot navigation, size-sort and path indexes remain available, while status and duplicate grouping use cold-path scans instead of charging every discovered item for another permanent B-tree write. Once the new snapshot is visible, retention cleanup removes older snapshots and checkpoints the scan WAL transactionally on a background connection instead of extending the finalization screen. A full-disk scan stops at nested mounted volumes such as Simulator runtimes instead of walking the same operating-system data again. A cancelled scan stays marked partial and never replaces a successful comparison snapshot. diff --git a/Sources/OpenDiskTreeApp/AppModel.swift b/Sources/OpenDiskTreeApp/AppModel.swift index 95cfd83..7858a17 100644 --- a/Sources/OpenDiskTreeApp/AppModel.swift +++ b/Sources/OpenDiskTreeApp/AppModel.swift @@ -37,6 +37,9 @@ final class AppModel: ObservableObject { @Published var isExporting = false @Published var exportProgress: ExportProgress? @Published var duplicateProgress: DuplicateProgress? + @Published var scanErrors: [ScanErrorRecord] = [] + @Published var showScanErrors = false + @Published var isLoadingScanErrors = false @Published var statusMessage = "Choose a folder or disk to begin." @Published var errorMessage: String? @Published var searchText = "" @@ -203,6 +206,7 @@ final class AppModel: ObservableObject { currentParentID = 1 navigationStack = [] selectedItems = [] + scanErrors = [] items = [] directoryItems = [] loadedDirectoryIDs.removeAll(keepingCapacity: true) @@ -329,7 +333,13 @@ final class AppModel: ObservableObject { } } }, - onErrors: { errors in try await store.insert(errors: errors) }, + onErrors: { [weak self] errors in + try await store.insert(errors: errors) + Task { @MainActor [weak self] in + guard self?.currentScan?.id == record.id else { return } + self?.scanErrors.append(contentsOf: errors) + } + }, onProgress: { [weak self] update in // The core scan must never wait for a complex SwiftUI layout pass. // Updates are coalesced naturally by the main run loop. @@ -508,12 +518,41 @@ final class AppModel: ObservableObject { currentParentID = 1 navigationStack = [] selectedItems = [] + scanErrors = [] loadedDirectoryIDs.removeAll(keepingCapacity: true) Task { try? await reloadResults() } } func requestDeleteHistory(_ scan: ScanRecord) { pendingHistoryDeletion = scan } + func presentScanErrors() { + guard currentScan != nil else { return } + showScanErrors = true + guard !isScanning, let store, let scanID = currentScan?.id else { return } + isLoadingScanErrors = true + Task { + defer { isLoadingScanErrors = false } + do { + let loaded = try await store.errors(scanID: scanID) + guard currentScan?.id == scanID else { return } + scanErrors = loaded + } catch { + errorMessage = error.localizedDescription + } + } + } + + func revealParent(of error: ScanErrorRecord) { + var url = URL(fileURLWithPath: error.path) + if !FileManager.default.fileExists(atPath: error.path) { + url.deleteLastPathComponent() + } + while url.path != "/", !FileManager.default.fileExists(atPath: url.path) { + url.deleteLastPathComponent() + } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + func confirmDeleteHistory() { guard let scan = pendingHistoryDeletion, let store else { return } pendingHistoryDeletion = nil diff --git a/Sources/OpenDiskTreeApp/ContentView.swift b/Sources/OpenDiskTreeApp/ContentView.swift index 010ffc8..a236936 100644 --- a/Sources/OpenDiskTreeApp/ContentView.swift +++ b/Sources/OpenDiskTreeApp/ContentView.swift @@ -60,6 +60,9 @@ struct ContentView: View { .sheet(isPresented: $model.showRuleEditor) { RuleEditorView(rules: model.userRules, previewItems: model.items, onSave: model.saveRules) } + .sheet(isPresented: $model.showScanErrors) { + ScanErrorsView(model: model) + } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -372,11 +375,15 @@ struct ContentView: View { .monospacedDigit() .foregroundStyle(.secondary) if model.progress.inaccessible > 0 { - Label( - "\(model.progress.inaccessible.formatted()) inaccessible", - systemImage: "exclamationmark.triangle" - ) + Button(action: model.presentScanErrors) { + Label( + "\(model.progress.inaccessible.formatted()) inaccessible", + systemImage: "exclamationmark.triangle" + ) + } + .buttonStyle(.plain) .foregroundStyle(.orange) + .help("Show paths that could not be scanned") } Spacer() Text(model.statusMessage).foregroundStyle(.secondary) @@ -401,8 +408,14 @@ struct ContentView: View { Label("\(scan.itemCount.formatted()) items", systemImage: "doc.on.doc") Label(HumanFormat.size(scan.allocatedBytes), systemImage: "internaldrive") if scan.inaccessibleCount > 0 { - Label("\(scan.inaccessibleCount) inaccessible", systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) + Button(action: model.presentScanErrors) { + Label( + "\(scan.inaccessibleCount.formatted()) inaccessible", + systemImage: "exclamationmark.triangle") + } + .buttonStyle(.plain) + .foregroundStyle(.orange) + .help("Show paths that could not be scanned") } } Text(model.statusMessage) diff --git a/Sources/OpenDiskTreeApp/ScanErrorsView.swift b/Sources/OpenDiskTreeApp/ScanErrorsView.swift new file mode 100644 index 0000000..26d6ce2 --- /dev/null +++ b/Sources/OpenDiskTreeApp/ScanErrorsView.swift @@ -0,0 +1,148 @@ +import AppKit +import Darwin +import OpenDiskTreeCore +import SwiftUI + +struct ScanErrorsView: View { + @ObservedObject var model: AppModel + @Environment(\.dismiss) private var dismiss + @State private var searchText = "" + @State private var selectedID: Int? + + private struct Row: Identifiable { + let id: Int + let error: ScanErrorRecord + } + + private var rows: [Row] { + model.scanErrors.enumerated().compactMap { offset, error in + guard searchText.isEmpty + || error.path.localizedCaseInsensitiveContains(searchText) + || error.message.localizedCaseInsensitiveContains(searchText) + || String(error.code).contains(searchText) + else { return nil } + return Row(id: offset, error: error) + } + } + + private var selectedError: ScanErrorRecord? { + guard let selectedID, model.scanErrors.indices.contains(selectedID) else { return nil } + return model.scanErrors[selectedID] + } + + private var likelyMissingFullDiskAccess: Bool { + model.scanErrors.lazy.filter { + $0.code == EPERM && $0.path.contains("/Library/") + }.prefix(10).count == 10 + } + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Label("Paths not scanned", systemImage: "exclamationmark.triangle.fill") + .font(.title2.weight(.semibold)) + .foregroundStyle(.orange) + Spacer() + Text(model.scanErrors.count.formatted()) + .font(.title3.monospacedDigit().weight(.semibold)) + .foregroundStyle(.secondary) + } + Text( + "OpenDiskTree could not list these locations. The rest of the scan is valid, but folder totals below an inaccessible path may be incomplete." + ) + .font(.callout) + .foregroundStyle(.secondary) + if likelyMissingFullDiskAccess { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.shield") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 3) { + Text("Full Disk Access is probably not active for this build.") + .fontWeight(.semibold) + Text( + "Most failures are privacy-protected user data. Enable the installed /Applications/OpenDiskTree.app, quit and reopen it, then run a full rescan." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button("Open Settings…", action: model.openFullDiskAccessSettings) + } + .padding(10) + .background(.orange.opacity(0.09), in: RoundedRectangle(cornerRadius: 8)) + } + TextField("Search path, reason or error code", text: $searchText) + .textFieldStyle(.roundedBorder) + } + .padding(16) + + Divider() + + if model.isLoadingScanErrors && model.scanErrors.isEmpty { + ProgressView("Loading errors…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if rows.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "No inaccessible paths" : "No matching paths", + systemImage: searchText.isEmpty ? "checkmark.circle" : "magnifyingglass") + } else { + List(rows, selection: $selectedID) { row in + VStack(alignment: .leading, spacing: 5) { + Text(row.error.path) + .font(.body.monospaced()) + .lineLimit(2) + .truncationMode(.middle) + HStack(spacing: 8) { + Text("Error \(row.error.code)") + .font(.caption.monospacedDigit().weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.secondary.opacity(0.12), in: Capsule()) + Text(row.error.message) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.vertical, 4) + .tag(row.id) + .contextMenu { + Button("Copy path") { copy(row.error.path) } + Button("Show parent in Finder") { model.revealParent(of: row.error) } + } + } + .listStyle(.inset) + } + + Divider() + + HStack(spacing: 10) { + Button("Copy all") { + copy( + model.scanErrors.map { "\($0.path)\t\($0.code)\t\($0.message)" } + .joined(separator: "\n")) + } + .disabled(model.scanErrors.isEmpty) + Button("Copy path") { + if let selectedError { copy(selectedError.path) } + } + .disabled(selectedError == nil) + Button("Show parent in Finder") { + if let selectedError { model.revealParent(of: selectedError) } + } + .disabled(selectedError == nil) + Spacer() + Button("Done", action: dismiss.callAsFunction) + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + .frame(minWidth: 720, idealWidth: 860, minHeight: 460, idealHeight: 600) + } + + private func copy(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +}