From e425219c99a5c50f29c3dd224117d080b5c12a49 Mon Sep 17 00:00:00 2001 From: Alin Date: Thu, 1 Aug 2024 18:09:32 -0600 Subject: [PATCH] TreeMap updates --- Pearcleaner.xcodeproj/project.pbxproj | 8 +- Pearcleaner/Logic/AppState.swift | 26 +- .../{Views => Logic}/MenuBarItem.swift | 0 Pearcleaner/Logic/ReversePathsFetch.swift | 3 + .../{Views => Logic}/WindowSettings.swift | 0 Pearcleaner/Views/TreeMap.swift | 261 ++++++++++++++++++ Pearcleaner/Views/ZombieView.swift | 142 +++++----- 7 files changed, 371 insertions(+), 69 deletions(-) rename Pearcleaner/{Views => Logic}/MenuBarItem.swift (100%) rename Pearcleaner/{Views => Logic}/WindowSettings.swift (100%) create mode 100644 Pearcleaner/Views/TreeMap.swift diff --git a/Pearcleaner.xcodeproj/project.pbxproj b/Pearcleaner.xcodeproj/project.pbxproj index 2da9585..10f2db9 100644 --- a/Pearcleaner.xcodeproj/project.pbxproj +++ b/Pearcleaner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ C72893122AFD51EA00C8C1CD /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72893112AFD51EA00C8C1CD /* main.swift */; }; C736E7652C2DF1C9009EDB6A /* AppPathsFetch-Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = C736E7642C2DF1C9009EDB6A /* AppPathsFetch-Async.swift */; }; C74412C72BBF249000DDFCA8 /* AppPathsFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74412C62BBF249000DDFCA8 /* AppPathsFetch.swift */; }; + C74D76B72C5C4ABC00748DD1 /* TreeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74D76B62C5C4ABC00748DD1 /* TreeMap.swift */; }; C74FDC632BCD833D00B8960F /* Conditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74FDC622BCD833D00B8960F /* Conditions.swift */; }; C7575D392B0182DE006A600A /* PearcleanerSentinel in CopyFiles */ = {isa = PBXBuildFile; fileRef = C728930F2AFD51EA00C8C1CD /* PearcleanerSentinel */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; C76D08482AF83C3F00D07867 /* Update.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76D08472AF83C3F00D07867 /* Update.swift */; }; @@ -99,6 +100,7 @@ C72893112AFD51EA00C8C1CD /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; C736E7642C2DF1C9009EDB6A /* AppPathsFetch-Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppPathsFetch-Async.swift"; sourceTree = ""; }; C74412C62BBF249000DDFCA8 /* AppPathsFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPathsFetch.swift; sourceTree = ""; }; + C74D76B62C5C4ABC00748DD1 /* TreeMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeMap.swift; sourceTree = ""; }; C74FDC622BCD833D00B8960F /* Conditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conditions.swift; sourceTree = ""; }; C76D08472AF83C3F00D07867 /* Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update.swift; sourceTree = ""; }; C76D08492AF83C6E00D07867 /* General.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = General.swift; sourceTree = ""; }; @@ -180,6 +182,8 @@ C77B901B2AF193A3009CC655 /* Styles.swift */, C7D31D502AFF00F300C7ED9E /* Locations.swift */, C74FDC622BCD833D00B8960F /* Conditions.swift */, + C7DE672A2BA6343D00EB1633 /* MenuBarItem.swift */, + C7CF47232B3B3F1700979C5F /* WindowSettings.swift */, ); path = Logic; sourceTree = ""; @@ -270,8 +274,7 @@ C7FEBA102BDC422200AE195F /* AppSearchView.swift */, C7D31D492AFEB26700C7ED9E /* AppListItems.swift */, C7EA06322C1CBE2000F872FC /* ConditionBuilderView.swift */, - C7DE672A2BA6343D00EB1633 /* MenuBarItem.swift */, - C7CF47232B3B3F1700979C5F /* WindowSettings.swift */, + C74D76B62C5C4ABC00748DD1 /* TreeMap.swift */, ); path = Views; sourceTree = ""; @@ -432,6 +435,7 @@ C76D08482AF83C3F00D07867 /* Update.swift in Sources */, C7FB173B2B96321300B96F9A /* AppsListView.swift in Sources */, C77B90162AF19377009CC655 /* AppState.swift in Sources */, + C74D76B72C5C4ABC00748DD1 /* TreeMap.swift in Sources */, C7DD49EE2BAB7F6000CCBA16 /* ReversePathsFetch.swift in Sources */, C7EA06332C1CBE2000F872FC /* ConditionBuilderView.swift in Sources */, C7DD49F02BABA14400CCBA16 /* Folders.swift in Sources */, diff --git a/Pearcleaner/Logic/AppState.swift b/Pearcleaner/Logic/AppState.swift index 84c7554..1f87d2e 100644 --- a/Pearcleaner/Logic/AppState.swift +++ b/Pearcleaner/Logic/AppState.swift @@ -65,7 +65,8 @@ class AppState: ObservableObject { id: UUID(), fileSize: [:], fileSizeLogical: [:], - fileIcon: [:] + fileIcon: [:], + isDirectory: [:] ) updateExtensionStatus() @@ -133,6 +134,7 @@ struct ZombieFile: Identifiable, Equatable, Hashable { var fileSize: [URL:Int64] var fileSizeLogical: [URL:Int64] var fileIcon: [URL:NSImage?] + var isDirectory: [URL:Bool] var totalSize: Int64 { return fileSize.values.reduce(0, +) @@ -143,8 +145,28 @@ struct ZombieFile: Identifiable, Equatable, Hashable { } - static let empty = ZombieFile(id: UUID(), fileSize: [:], fileSizeLogical: [:], fileIcon: [:]) + static let empty = ZombieFile(id: UUID(), fileSize: [:], fileSizeLogical: [:], fileIcon: [:], isDirectory: [:]) + +} + +extension ZombieFile { + /// Converts the data in ZombieFile into a list of Item instances. + func toItems() -> [Item] { + var items = [Item]() + + // Iterate over each URL in fileSize to get corresponding properties + for (url, size) in fileSize { + let name = url.lastPathComponent + let isDir = isDirectory[url] ?? false + let parent = url.deletingLastPathComponent() + + // Create an Item instance for each URL + let item = Item(url: url, name: name, size: size, isDirectory: isDir, parentURL: parent) + items.append(item) + } + return items//.sorted { $0.size > $1.size } + } } diff --git a/Pearcleaner/Views/MenuBarItem.swift b/Pearcleaner/Logic/MenuBarItem.swift similarity index 100% rename from Pearcleaner/Views/MenuBarItem.swift rename to Pearcleaner/Logic/MenuBarItem.swift diff --git a/Pearcleaner/Logic/ReversePathsFetch.swift b/Pearcleaner/Logic/ReversePathsFetch.swift index 0ce4ac5..238ddb1 100644 --- a/Pearcleaner/Logic/ReversePathsFetch.swift +++ b/Pearcleaner/Logic/ReversePathsFetch.swift @@ -18,6 +18,7 @@ class ReversePathsSearcher { private var fileSize: [URL: Int64] = [:] private var fileSizeLogical: [URL: Int64] = [:] private var fileIcon: [URL: NSImage?] = [:] + private var isDirectory: [URL: Bool] = [:] private let dispatchGroup = DispatchGroup() private let sortedApps: [AppInfo] @@ -112,6 +113,7 @@ class ReversePathsSearcher { fileSize[path] = size.real fileSizeLogical[path] = size.logical fileIcon[path] = getIconForFileOrFolderNS(atPath: path) + isDirectory[path] = path.hasDirectoryPath } } @@ -121,6 +123,7 @@ class ReversePathsSearcher { updatedZombieFile.fileSize = self.fileSize updatedZombieFile.fileSizeLogical = self.fileSizeLogical updatedZombieFile.fileIcon = self.fileIcon + updatedZombieFile.isDirectory = self.isDirectory self.appState.zombieFile = updatedZombieFile self.appState.showProgress = false } diff --git a/Pearcleaner/Views/WindowSettings.swift b/Pearcleaner/Logic/WindowSettings.swift similarity index 100% rename from Pearcleaner/Views/WindowSettings.swift rename to Pearcleaner/Logic/WindowSettings.swift diff --git a/Pearcleaner/Views/TreeMap.swift b/Pearcleaner/Views/TreeMap.swift new file mode 100644 index 0000000..9bd81b3 --- /dev/null +++ b/Pearcleaner/Views/TreeMap.swift @@ -0,0 +1,261 @@ +// +// TreeMap.swift +// Pearcleaner +// +// Created by Alin Lupascu on 8/1/24. +// + +import Foundation +import SwiftUI +import AlinFoundation + +final class Item: Identifiable { + let url: URL + let name: String + var size: Int64 + let isDirectory: Bool + let parentURL: URL? + let timestamp: Date + + init(url: URL, name: String, size: Int64, isDirectory: Bool, parentURL: URL? = nil) { + self.url = url + self.name = name + self.size = size + self.isDirectory = isDirectory + self.parentURL = parentURL + self.timestamp = Date() + } +} + +struct TreeMapChart: View { + let items: [Item] + var onItemSelected: (Item) -> Void + @Binding var hoveredItem: Item? + + var displayedItems: [Item] { + items.filter { $0.size >= 1_048_576 } // Filter to keep only items with size >= 1MB + .sorted(by: { $0.size > $1.size }) // Then sort the remaining items by size + } + + var body: some View { + + GeometryReader { proxy in + + let ld = calculateLayout( + w: proxy.size.width, + h: proxy.size.height, + data: displayedItems.map { Double($0.size) }, + from: 0 + ) + + TreeMapView(ld: ld, items: displayedItems, onItemSelected: onItemSelected, hoveredItem: $hoveredItem) + + } + .ignoresSafeArea() + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + +struct TreeMapView: View { + let ld: LayoutData + let items: [Item] + var onItemSelected: (Item) -> Void + @Binding var hoveredItem: Item? + @State private var hoveredIndex: Int? + + var body: some View { + Group { + if ld.direction == .h { + HStack(spacing: 0) { + VStack(spacing: 0) { + content + } + if let child = ld.child { + TreeMapView(ld: child, items: items, onItemSelected: onItemSelected, hoveredItem: $hoveredItem) + } + } + } else { + VStack(spacing: 0) { + HStack(spacing: 0) { + content + } + + if let child = ld.child { + TreeMapView(ld: child, items: items, onItemSelected: onItemSelected, hoveredItem: $hoveredItem) + } + } + } + } + } + + private var content: some View { + ForEach(0.. Color { + item.isDirectory ? .blue : .gray + } + + private func gradientForItem(_ item: Item) -> LinearGradient { + item.isDirectory ? LinearGradient( + gradient: Gradient(colors: [Color.cyan, Color.blue]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) : LinearGradient( + gradient: Gradient(colors: [Color.pink, Color.orange]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + +} + + +func formatSize(_ size: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useAll] + formatter.countStyle = .file + return formatter.string(fromByteCount: size) +} + +func randomColor() -> Color { + Color(hue: Double.random(in: 0.0...1.0), + saturation: Double.random(in: 0.08...0.18), + brightness: Double.random(in: 0.90...1.0)) +} + +//MARK: =================================================================================================================== + +// Layout +enum Direction { + case h + case v +} + +class LayoutData { + var direction: Direction = .h + var content: [(index: Int, w: Double, h: Double)] = [] + var child: LayoutData? = nil +} + +func calculateLayout(w: Double, h: Double, data: [Double], from: Int) -> LayoutData { + + let returnData = LayoutData() + if data.isEmpty { + print("TreeMap layout data is empty") + return returnData + } + let dataToArea = w * h / data[from...].reduce(0.0, +) + if w < h { + returnData.direction = .v + } else { + returnData.direction = .h + } + let mainLength = min(w, h) + var currentIndex = from + var area = data[currentIndex] * dataToArea + var crossLength = area / mainLength + + var cellRatio = mainLength / crossLength + cellRatio = max(cellRatio, 1.0 / cellRatio) + + while currentIndex + 1 < data.count { + let newIndex = currentIndex + 1 + let newArea = area + data[newIndex] * dataToArea + let newCrossLength = newArea / mainLength + var newCellRatio = data[newIndex] * dataToArea / newCrossLength / newCrossLength + newCellRatio = max(newCellRatio, 1.0 / newCellRatio) + + if newCellRatio < cellRatio { + currentIndex = newIndex + area = newArea + crossLength = newCrossLength + cellRatio = newCellRatio + } else { + break + } + } + + switch returnData.direction { + case .h: + for i in from...currentIndex { + returnData.content.append(( + index: i, + w: crossLength, + h: data[i] * dataToArea / crossLength)) + } + case .v: + for i in from...currentIndex { + returnData.content.append(( + index: i, + w: data[i] * dataToArea / crossLength, + h: crossLength)) + } + } + + if currentIndex != data.count - 1 { + switch returnData.direction { + case .h: + returnData.child = calculateLayout( + w: w - crossLength, + h: h, + data: data, + from: currentIndex + 1) + case .v: + returnData.child = calculateLayout( + w: w, + h: h - crossLength, + data: data, + from: currentIndex + 1) + } + } + return returnData +} diff --git a/Pearcleaner/Views/ZombieView.swift b/Pearcleaner/Views/ZombieView.swift index 844523b..0216ccc 100644 --- a/Pearcleaner/Views/ZombieView.swift +++ b/Pearcleaner/Views/ZombieView.swift @@ -36,6 +36,8 @@ struct ZombieView: View { @State private var totalRealSizeUninstallBtn: String = "" @State private var totalLogicalSizeUninstallBtn: String = "" @State private var totalFinderSizeUninstallBtn: String = "" + @State private var isTreeMap: Bool = false + @State private var hoveredItem: Item? var body: some View { @@ -170,88 +172,109 @@ struct ZombieView: View { .padding(.top, 0) } - // Item selection and sorting toolbar - HStack { - Toggle("", isOn: Binding( - get: { - if searchZ.isEmpty { - // All items are selected if no filter is applied and all items are selected - return selectedZombieItemsLocal.count == appState.zombieFile.fileSize.count - } else { - // All currently filtered files are selected when a filter is applied - return Set(memoizedFiles).isSubset(of: selectedZombieItemsLocal) && selectedZombieItemsLocal.count == memoizedFiles.count - } - }, - set: { newValue in - if newValue { + Toggle("Tree Map View", isOn: $isTreeMap) + + + if isTreeMap { + TreeMapChart(items: appState.zombieFile.toItems(), onItemSelected: { selectedItem in + // if selectedItem.isDirectory { + // drillDown(to: selectedItem) + // } else { + // NSWorkspace.shared.selectFile(selectedItem.url.path, inFileViewerRootedAtPath: selectedItem.url.deletingLastPathComponent().path) + // } + }, hoveredItem: $hoveredItem) + } else { + // Item selection and sorting toolbar + HStack { + Toggle("", isOn: Binding( + get: { if searchZ.isEmpty { - // Select all files if no filter is applied - selectedZombieItemsLocal = Set(appState.zombieFile.fileSize.keys) + // All items are selected if no filter is applied and all items are selected + return selectedZombieItemsLocal.count == appState.zombieFile.fileSize.count } else { - // Select only filtered files if a filter is applied - selectedZombieItemsLocal.formUnion(memoizedFiles) + // All currently filtered files are selected when a filter is applied + return Set(memoizedFiles).isSubset(of: selectedZombieItemsLocal) && selectedZombieItemsLocal.count == memoizedFiles.count } - } else { - if searchZ.isEmpty { - // Deselect all files if no filter is applied - selectedZombieItemsLocal.removeAll() + }, + set: { newValue in + if newValue { + if searchZ.isEmpty { + // Select all files if no filter is applied + selectedZombieItemsLocal = Set(appState.zombieFile.fileSize.keys) + } else { + // Select only filtered files if a filter is applied + selectedZombieItemsLocal.formUnion(memoizedFiles) + } } else { - // Deselect only filtered files if a filter is applied - selectedZombieItemsLocal.subtract(memoizedFiles) + if searchZ.isEmpty { + // Deselect all files if no filter is applied + selectedZombieItemsLocal.removeAll() + } else { + // Deselect only filtered files if a filter is applied + selectedZombieItemsLocal.subtract(memoizedFiles) + } } + + updateTotalSizes() } + )) + .toggleStyle(SimpleCheckboxToggleStyle()) + .help("All checkboxes") - updateTotalSizes() - } - )) - .toggleStyle(SimpleCheckboxToggleStyle()) - .help("All checkboxes") + + SearchBar(search: $searchZ, darker: true, glass: glass, sidebar: false) + .padding(.horizontal) + .onChange(of: searchZ) { newValue in + updateMemoizedFiles(for: newValue, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha) + } - SearchBar(search: $searchZ, darker: true, glass: glass, sidebar: false) - .padding(.horizontal) - .onChange(of: searchZ) { newValue in - updateMemoizedFiles(for: newValue, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha) + + Button("") { + selectedSortAlpha.toggle() + updateMemoizedFiles(for: searchZ, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha, force: true) } + .buttonStyle(SimpleButtonStyle(icon: selectedSortAlpha ? "textformat.abc" : "textformat.123", help: selectedSortAlpha ? "Sorted alphabetically" : "Sorted by size")) - Button("") { - selectedSortAlpha.toggle() - updateMemoizedFiles(for: searchZ, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha, force: true) } - .buttonStyle(SimpleButtonStyle(icon: selectedSortAlpha ? "textformat.abc" : "textformat.123", help: selectedSortAlpha ? "Sorted alphabetically" : "Sorted by size")) - + .padding(.horizontal) + .padding(.vertical) - } - .padding(.horizontal) - .padding(.vertical) + Divider() + .padding(.horizontal) - Divider() - .padding(.horizontal) - ScrollView() { - LazyVStack { - ForEach(memoizedFiles, id: \.self) { file in - if let fileSize = appState.zombieFile.fileSize[file], let fileSizeL = appState.zombieFile.fileSizeLogical[file], let fileIcon = appState.zombieFile.fileIcon[file] { - let iconImage = fileIcon.map(Image.init(nsImage:)) - VStack { - ZombieFileDetailsItem(size: fileSize, sizeL: fileSizeL, icon: iconImage, path: file, isSelected: self.binding(for: file)) - .padding(.vertical, 5) + ScrollView() { + LazyVStack { + ForEach(memoizedFiles, id: \.self) { file in + if let fileSize = appState.zombieFile.fileSize[file], let fileSizeL = appState.zombieFile.fileSizeLogical[file], let fileIcon = appState.zombieFile.fileIcon[file], let iconImage = fileIcon.map(Image.init(nsImage:)) { + VStack { + ZombieFileDetailsItem(size: fileSize, sizeL: fileSizeL, icon: iconImage, path: file, isSelected: self.binding(for: file)) + .padding(.vertical, 5) + } } } - } + } + .padding() } - .padding() } + + + + + Spacer() HStack() { + Text(hoveredItem?.name ?? "") + Spacer() InfoButton(text: "Leftover file search is not 100% accurate as it doesn't have any uninstalled app bundles to check against for file exclusion. This does a best guess search for files/folders and excludes the ones that have overlap with your currently installed applications. Please confirm files marked for deletion really do belong to uninstalled applications.", color: .orange, warning: true, edge: .top) @@ -484,18 +507,6 @@ struct ZombieFileDetailsItem: View { Toggle("", isOn: $isSelected) .toggleStyle(SimpleCheckboxToggleStyle()) -// Toggle("", isOn: Binding( -// get: { self.selectedZombieItemsLocal.contains(self.path) }, -// set: { isChecked in -// if isChecked { -// self.selectedZombieItemsLocal.insert(self.path) -// } else { -// self.selectedZombieItemsLocal.remove(self.path) -// } -// } -// )) -// .toggleStyle(SimpleCheckboxToggleStyle()) - if let appIcon = icon { appIcon .resizable() @@ -528,6 +539,7 @@ struct ZombieFileDetailsItem: View { if let imageView = folderImages(for: path.path) { imageView } + } Text(path.path)