TreeMap updates

This commit is contained in:
Alin
2024-08-01 18:09:32 -06:00
parent e0d41e76f4
commit e425219c99
7 changed files with 379 additions and 77 deletions
+6 -2
View File
@@ -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 = "<group>"; };
C736E7642C2DF1C9009EDB6A /* AppPathsFetch-Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppPathsFetch-Async.swift"; sourceTree = "<group>"; };
C74412C62BBF249000DDFCA8 /* AppPathsFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPathsFetch.swift; sourceTree = "<group>"; };
C74D76B62C5C4ABC00748DD1 /* TreeMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeMap.swift; sourceTree = "<group>"; };
C74FDC622BCD833D00B8960F /* Conditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conditions.swift; sourceTree = "<group>"; };
C76D08472AF83C3F00D07867 /* Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update.swift; sourceTree = "<group>"; };
C76D08492AF83C6E00D07867 /* General.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = General.swift; sourceTree = "<group>"; };
@@ -180,6 +182,8 @@
C77B901B2AF193A3009CC655 /* Styles.swift */,
C7D31D502AFF00F300C7ED9E /* Locations.swift */,
C74FDC622BCD833D00B8960F /* Conditions.swift */,
C7DE672A2BA6343D00EB1633 /* MenuBarItem.swift */,
C7CF47232B3B3F1700979C5F /* WindowSettings.swift */,
);
path = Logic;
sourceTree = "<group>";
@@ -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 = "<group>";
@@ -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 */,
+24 -2
View File
@@ -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,10 +145,30 @@ 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 }
}
}
enum Arch {
case arm
@@ -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
}
+261
View File
@@ -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..<ld.content.count, id: \.self) { i in
let file = items[ld.content[i].index]
let maxDimension = max(ld.content[i].w, ld.content[i].h) / 5
ZStack {
Rectangle()
// .foregroundStyle(randomColor())
// .foregroundStyle(colorForItem(file))
.fill(gradientForItem(file))
.brightness(hoveredIndex == i ? 0.1 : 0)
.frame(width: ld.content[i].w, height: ld.content[i].h)
.border(.black, width: 0.5)
.onTapGesture {
onItemSelected(file)
}
.overlay {
if hoveredIndex == i {
VStack {
Text(file.name)
.minimumScaleFactor(0.5)
.lineLimit(2)
Text("\(formatSize(file.size))")
.minimumScaleFactor(0.5)
.lineLimit(1)
}
.padding(5)
.padding(.horizontal, 2)
.background(.background.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 8))
.zIndex(1)
} else {
// Image(systemName: file.isDirectory ? "folder.fill" : "doc.fill")
// .resizable()
// .aspectRatio(contentMode: .fit)
// .frame(width: maxDimension, height: maxDimension)
// .foregroundStyle(.primary.opacity(0.5))
}
}
}
.onHover { isHovering in
withAnimation {
hoveredIndex = isHovering ? i : nil
hoveredItem = isHovering ? file : nil
}
}
}
}
private func colorForItem(_ item: Item) -> 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
}
+85 -73
View File
@@ -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
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 {
// 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 {
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 {
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()
}
},
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 {
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)
}
))
.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)
}
updateTotalSizes()
Button("") {
selectedSortAlpha.toggle()
updateMemoizedFiles(for: searchZ, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha, force: true)
}
))
.toggleStyle(SimpleCheckboxToggleStyle())
.help("All checkboxes")
.buttonStyle(SimpleButtonStyle(icon: selectedSortAlpha ? "textformat.abc" : "textformat.123", help: selectedSortAlpha ? "Sorted alphabetically" : "Sorted by size"))
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"))
}
.padding(.horizontal)
.padding(.vertical)
Divider()
.padding(.horizontal)
.padding(.vertical)
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)
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)
}
}
}
}
}
.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)