From 26f8478274db80b47bc328a539910f7ffe458f49 Mon Sep 17 00:00:00 2001 From: Alin Date: Fri, 2 Aug 2024 22:13:01 -0600 Subject: [PATCH] v3.8.3 --- Pearcleaner.xcodeproj/project.pbxproj | 34 ++- Pearcleaner/Logic/AppState.swift | 46 ++-- Pearcleaner/Logic/ReversePathsFetch.swift | 3 - Pearcleaner/PearDisk/Data.swift | 184 ++++++++++++++++ Pearcleaner/PearDisk/FileSearch.swift | 87 ++++++++ Pearcleaner/PearDisk/FolderDetail.swift | 202 ++++++++++++++++++ Pearcleaner/PearDisk/PearDiskView.swift | 96 +++++++++ Pearcleaner/{Views => PearDisk}/TreeMap.swift | 65 +++--- Pearcleaner/Views/RegularMode.swift | 117 +++++----- Pearcleaner/Views/ZombieView.swift | 139 +++++------- 10 files changed, 778 insertions(+), 195 deletions(-) create mode 100644 Pearcleaner/PearDisk/Data.swift create mode 100644 Pearcleaner/PearDisk/FileSearch.swift create mode 100644 Pearcleaner/PearDisk/FolderDetail.swift create mode 100644 Pearcleaner/PearDisk/PearDiskView.swift rename Pearcleaner/{Views => PearDisk}/TreeMap.swift (84%) diff --git a/Pearcleaner.xcodeproj/project.pbxproj b/Pearcleaner.xcodeproj/project.pbxproj index 10f2db9..359ea8e 100644 --- a/Pearcleaner.xcodeproj/project.pbxproj +++ b/Pearcleaner.xcodeproj/project.pbxproj @@ -14,6 +14,10 @@ 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 */; }; + C74D76BD2C5D2DF300748DD1 /* FileSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74D76B92C5D2DF300748DD1 /* FileSearch.swift */; }; + C74D76BE2C5D2DF300748DD1 /* PearDiskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74D76BA2C5D2DF300748DD1 /* PearDiskView.swift */; }; + C74D76BF2C5D2DF300748DD1 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74D76BB2C5D2DF300748DD1 /* Data.swift */; }; + C74D76C02C5D2DF300748DD1 /* FolderDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C74D76BC2C5D2DF300748DD1 /* FolderDetail.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 */; }; @@ -101,6 +105,10 @@ 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 = ""; }; + C74D76B92C5D2DF300748DD1 /* FileSearch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileSearch.swift; sourceTree = ""; }; + C74D76BA2C5D2DF300748DD1 /* PearDiskView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PearDiskView.swift; sourceTree = ""; }; + C74D76BB2C5D2DF300748DD1 /* Data.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; + C74D76BC2C5D2DF300748DD1 /* FolderDetail.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FolderDetail.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 = ""; }; @@ -204,6 +212,18 @@ name = Frameworks; sourceTree = ""; }; + C74D76B82C5D2D7900748DD1 /* PearDisk */ = { + isa = PBXGroup; + children = ( + C74D76BA2C5D2DF300748DD1 /* PearDiskView.swift */, + C74D76BB2C5D2DF300748DD1 /* Data.swift */, + C74D76B92C5D2DF300748DD1 /* FileSearch.swift */, + C74D76B62C5C4ABC00748DD1 /* TreeMap.swift */, + C74D76BC2C5D2DF300748DD1 /* FolderDetail.swift */, + ); + path = PearDisk; + sourceTree = ""; + }; C76D08462AF83C2F00D07867 /* Settings */ = { isa = PBXGroup; children = ( @@ -257,6 +277,7 @@ C786F1A72C29E599001B3800 /* Builds */, C76D084F2AF84E0100D07867 /* Resources */, C76D08462AF83C2F00D07867 /* Settings */, + C74D76B82C5D2D7900748DD1 /* PearDisk */, C77B901F2AF1B390009CC655 /* Views */, C72893072AFD442600C8C1CD /* Logic */, ); @@ -274,7 +295,6 @@ C7FEBA102BDC422200AE195F /* AppSearchView.swift */, C7D31D492AFEB26700C7ED9E /* AppListItems.swift */, C7EA06322C1CBE2000F872FC /* ConditionBuilderView.swift */, - C74D76B62C5C4ABC00748DD1 /* TreeMap.swift */, ); path = Views; sourceTree = ""; @@ -431,17 +451,20 @@ buildActionMask = 2147483647; files = ( C7DD49EA2BAB4D8100CCBA16 /* AppInfoFetch.swift in Sources */, + C74D76BD2C5D2DF300748DD1 /* FileSearch.swift in Sources */, C79947962C34A794007279CC /* Tips.swift in Sources */, C76D08482AF83C3F00D07867 /* Update.swift in Sources */, C7FB173B2B96321300B96F9A /* AppsListView.swift in Sources */, C77B90162AF19377009CC655 /* AppState.swift in Sources */, C74D76B72C5C4ABC00748DD1 /* TreeMap.swift in Sources */, + C74D76C02C5D2DF300748DD1 /* FolderDetail.swift in Sources */, C7DD49EE2BAB7F6000CCBA16 /* ReversePathsFetch.swift in Sources */, C7EA06332C1CBE2000F872FC /* ConditionBuilderView.swift in Sources */, C7DD49F02BABA14400CCBA16 /* Folders.swift in Sources */, C7D31D512AFF00F300C7ED9E /* Locations.swift in Sources */, C77B901A2AF1938F009CC655 /* Logic.swift in Sources */, C77B901C2AF193A3009CC655 /* Styles.swift in Sources */, + C74D76BF2C5D2DF300748DD1 /* Data.swift in Sources */, C7045A282B03E71D00376976 /* MiniMode.swift in Sources */, C7ED86B12BA8A35500F13DE4 /* Interface.swift in Sources */, C72893052AFD42E600C8C1CD /* DeepLink.swift in Sources */, @@ -452,6 +475,7 @@ C77B90042AF18E2E009CC655 /* PearcleanerApp.swift in Sources */, C76D08512AF850F300D07867 /* About.swift in Sources */, C77B90182AF19382009CC655 /* AppCommands.swift in Sources */, + C74D76BE2C5D2DF300748DD1 /* PearDiskView.swift in Sources */, C7D31D4A2AFEB26700C7ED9E /* AppListItems.swift in Sources */, C77B901E2AF1A9DB009CC655 /* SettingsWindow.swift in Sources */, C74412C72BBF249000DDFCA8 /* AppPathsFetch.swift in Sources */, @@ -533,8 +557,8 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - APP_BUILD = 56; - APP_VERSION = 3.8.2; + APP_BUILD = 57; + APP_VERSION = 3.8.3; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -603,8 +627,8 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - APP_BUILD = 56; - APP_VERSION = 3.8.2; + APP_BUILD = 57; + APP_VERSION = 3.8.3; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; diff --git a/Pearcleaner/Logic/AppState.swift b/Pearcleaner/Logic/AppState.swift index 1f87d2e..9bb502e 100644 --- a/Pearcleaner/Logic/AppState.swift +++ b/Pearcleaner/Logic/AppState.swift @@ -65,8 +65,7 @@ class AppState: ObservableObject { id: UUID(), fileSize: [:], fileSizeLogical: [:], - fileIcon: [:], - isDirectory: [:] + fileIcon: [:] ) updateExtensionStatus() @@ -134,7 +133,6 @@ 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, +) @@ -145,29 +143,31 @@ struct ZombieFile: Identifiable, Equatable, Hashable { } - static let empty = ZombieFile(id: UUID(), fileSize: [:], fileSizeLogical: [:], fileIcon: [:], isDirectory: [:]) + static let empty = ZombieFile(id: UUID(), fileSize: [:], fileSizeLogical: [:], fileIcon: [:]) } -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 } - } -} +//extension ZombieFile { +// func toItems() -> [Item] { +// var items: [Item] = [] +// +// for (url, size) in self.fileSize { +// let name = url.lastPathComponent +// let isDirectory = url.hasDirectoryPath +// +// let item = Item( +// url: url, +// name: name, +// size: size, +// isDirectory: isDirectory +// ) +// +// items.append(item) +// } +// +// return items +// } +//} enum Arch { diff --git a/Pearcleaner/Logic/ReversePathsFetch.swift b/Pearcleaner/Logic/ReversePathsFetch.swift index 238ddb1..0ce4ac5 100644 --- a/Pearcleaner/Logic/ReversePathsFetch.swift +++ b/Pearcleaner/Logic/ReversePathsFetch.swift @@ -18,7 +18,6 @@ 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] @@ -113,7 +112,6 @@ class ReversePathsSearcher { fileSize[path] = size.real fileSizeLogical[path] = size.logical fileIcon[path] = getIconForFileOrFolderNS(atPath: path) - isDirectory[path] = path.hasDirectoryPath } } @@ -123,7 +121,6 @@ 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/PearDisk/Data.swift b/Pearcleaner/PearDisk/Data.swift new file mode 100644 index 0000000..a505f7e --- /dev/null +++ b/Pearcleaner/PearDisk/Data.swift @@ -0,0 +1,184 @@ +//// +//// Item.swift +//// PearDisk +//// +//// Created by Alin Lupascu on 7/17/24. +//// +// +//import Foundation +//import SwiftData +// +//// Data Model +//@Model +//final class Item: Identifiable { +// @Attribute(.unique) 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() +// } +//} +// +// +//// Wipe peardisk.sqlite file +//func wipeSwiftDataStorage() async { +// let fileManager = FileManager.default +// guard let urlApp = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { +// print("Could not find the Application Support directory.") +// return +// } +// +// let peardiskURL = urlApp.appendingPathComponent("PearDisk").appendingPathComponent("Database") +// +// do { +// +// if fileManager.fileExists(atPath: peardiskURL.path) { +// try fileManager.removeItem(at: peardiskURL) +// print("\(peardiskURL.lastPathComponent) has been deleted.") +// } +// } catch { +// print("Error deleting SwiftData storage: \(error)") +// } +//} +// +//func wipeSwiftDataStorage2() async { +// guard let container = try? ModelContainer(for: Item.self) else { +// print("Failed to create ModelContainer.") +// return +// } +// +// let urlApp = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first +// +// if let urlApp = urlApp { +// let url = urlApp.appendingPathComponent("PearDisk").appendingPathComponent("peardisk.sqlite") +// if FileManager.default.fileExists(atPath: url.path) { +// print("Peardisk.sqlite: \(url.path(percentEncoded: false))") +// } +// } else { +// fatalError("Could not find the Application Support directory.") +// } +// +// +// await MainActor.run { +// let context = container.mainContext +// +// do { +// try context.delete(model: Item.self) +// try context.save() +// print("Peardisk.sqlite has been successfully wiped.") +// } catch { +// print("Error wiping SwiftData storage: \(error)") +// } +// } +//} +// +// +// +// +//// Print size of peardisk.sqlite file +//func printSwiftDataCacheSize() async { +// // Get the Application Support directory URL for your app +// guard let applicationSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { +// print("Could not find the Application Support directory.") +// return +// } +// +// // Create the custom directory URL +// let customDirectoryURL = applicationSupportDirectory.appendingPathComponent("PearDisk").appendingPathComponent("Database") +// let storeURL = customDirectoryURL.appendingPathComponent("peardisk").appendingPathExtension("sqlite") +// +// // Check if the store URL exists +// guard FileManager.default.fileExists(atPath: storeURL.path) else { +// print("Store file does not exist.") +// return +// } +// +// // Initialize the ModelContainer with the custom store URL +// let modelConfiguration = ModelConfiguration(url: storeURL) +// +// do { +// let container = try ModelContainer(for: Item.self, configurations: modelConfiguration) +// +// await MainActor.run { +// let context = container.mainContext +// let fetchDescriptor = FetchDescriptor() +// +// do { +// let count = try context.fetchCount(fetchDescriptor) +// print("Number of Item objects: \(count)") +// +// // Get the size of the store file directly +// let fileManager = FileManager.default +// do { +// let attributes = try fileManager.attributesOfItem(atPath: storeURL.path) +// if let size = attributes[.size] as? Int64 { +// let sizeMB = Double(size) / (1024 * 1024) +// print("Total size of SwiftData storage: \(String(format: "%.2f", sizeMB)) MB") +// } else { +// print("Could not retrieve file size.") +// } +// } catch { +// print("Could not access file at \(storeURL.path): \(error)") +// } +// } catch { +// print("Error fetching data: \(error)") +// } +// } +// } catch { +// print("Could not create ModelContainer: \(error)") +// } +//} +// +// +// +//// Setup Data Model +//func createSharedModelContainer() -> ModelContainer { +// do { +// // Get the Application Support directory URL for your app +// guard let applicationSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { +// fatalError("Could not find the Application Support directory.") +// } +// +// // Create the custom directory URL +// let customDirectoryURL = applicationSupportDirectory.appendingPathComponent("PearDisk", isDirectory: true).appendingPathComponent("Database", isDirectory: true) +// +// // Create the directory if it doesn't exist +// do { +// try FileManager.default.createDirectory(at: customDirectoryURL, withIntermediateDirectories: true, attributes: nil) +// } catch { +// fatalError("Could not create directory: \(error)") +// } +// +// // Create the store URL with the desired filename inside the custom directory +// let storeURL = customDirectoryURL.appendingPathComponent("peardisk").appendingPathExtension("sqlite") +// +// // Ensure the directory is accessible for reading and writing +// let fileManager = FileManager.default +// var isDirectory: ObjCBool = true +// if !fileManager.fileExists(atPath: customDirectoryURL.path, isDirectory: &isDirectory) { +// fatalError("Directory does not exist.") +// } +// +// if !fileManager.isWritableFile(atPath: customDirectoryURL.path) { +// fatalError("Directory is not writable.") +// } +// +// if !fileManager.isReadableFile(atPath: customDirectoryURL.path) { +// fatalError("Directory is not readable.") +// } +// +// let modelConfiguration = ModelConfiguration(url: storeURL) +// return try ModelContainer(for: Item.self, configurations: modelConfiguration) +// } catch { +// fatalError("Could not create ModelContainer: \(error)") +// } +//} diff --git a/Pearcleaner/PearDisk/FileSearch.swift b/Pearcleaner/PearDisk/FileSearch.swift new file mode 100644 index 0000000..887356f --- /dev/null +++ b/Pearcleaner/PearDisk/FileSearch.swift @@ -0,0 +1,87 @@ +// +// FileSearch.swift +// Pearcleaner +// +// Created by Alin Lupascu on 7/20/24. +// + +import Foundation +import SwiftData +import AlinFoundation + +enum FastFileSearchError: Error { + case invalidPath + case failedToReadDirectory + case failedToGetFileInfo +} + +class FastFileSearch { + private let queue = DispatchQueue(label: "com.alienator88.Pearcleaner.disk", qos: .userInitiated, attributes: .concurrent) + + func search(url: URL, completion: @escaping (Result<[Item], Error>) -> Void) { + queue.async { + do { + let items = try self.searchRecursive(url: url) + let sortedItems = items.sorted { $0.size > $1.size } + DispatchQueue.main.async { + completion(.success(sortedItems)) + } + } catch { + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + } + + private func searchRecursive(url: URL) throws -> [Item] { + var items: [Item] = [] + + guard let path = url.path.cString(using: .utf8) else { + throw FastFileSearchError.invalidPath + } + + guard let dir = opendir(path) else { + throw FastFileSearchError.failedToReadDirectory + } + defer { closedir(dir) } + + while let entry = readdir(dir) { + let name = withUnsafePointer(to: entry.pointee.d_name) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: Int(entry.pointee.d_namlen)) { + String(cString: $0) + } + } + + let exclude = [".", "..", ".DS_Store", ".localized"] + if exclude.contains(name) { continue } + + let fullURL = url.appendingPathComponent(name) + let fullPath = fullURL.path + + var st = stat() + guard lstat(fullPath, &st) == 0 else { + print("Failed to get file info for \(fullPath)") + continue + } + + let isDirectory = (st.st_mode & S_IFMT) == S_IFDIR + if isDirectory { + // Recursively search subdirectories + do { + let subItems = try searchRecursive(url: fullURL) + items.append(contentsOf: subItems) + } catch { + print("Error processing directory \(fullPath): \(error)") + } + } else { + // Add non-directory files to the items array + let size = Int64(st.st_size) + let item = Item(url: fullURL, name: name, size: size) + items.append(item) + } + } + + return items + } +} diff --git a/Pearcleaner/PearDisk/FolderDetail.swift b/Pearcleaner/PearDisk/FolderDetail.swift new file mode 100644 index 0000000..f1cfb40 --- /dev/null +++ b/Pearcleaner/PearDisk/FolderDetail.swift @@ -0,0 +1,202 @@ +// +// FolderDetailView.swift +// Pearcleaner +// +// Created by Alin Lupascu on 7/20/24. +// + +import Foundation +import SwiftUI + +//MARK: Detail View +struct FolderDetailView: View { + let rootURL: URL + @Binding var currentPath: URL? + @State private var currentItem: Item? + @State private var childItems: [Item] = [] + @State private var isLoading = false + @State private var errorMessage: String? + @State private var forwardStack: [URL] = [] + @State private var hoveredItem: Item? + + private var fastSearch: FastFileSearch + + init(rootURL: URL, currentPath: Binding) { + self.rootURL = rootURL + self._currentPath = currentPath + self.fastSearch = FastFileSearch() + } + + var body: some View { + VStack { + + Spacer() + + if isLoading { + ProgressView("Loading...") + } else if let errorMessage = errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .padding() + } else if let currentItem = currentItem { + if !childItems.isEmpty { + + TreeMapChart(items: childItems, onItemSelected: { selectedItem in + NSWorkspace.shared.selectFile(selectedItem.url.path, inFileViewerRootedAtPath: selectedItem.url.deletingLastPathComponent().path) + }, hoveredItem: $hoveredItem) + + HStack { + Text("Total size: \(ByteCountFormatter.string(fromByteCount: currentItem.size, countStyle: .file))") + Spacer() + + if let hoveredItem = hoveredItem { + Text("\(hoveredItem.name) (\(formatSize(hoveredItem.size)))") + } + + Spacer() + + VStack(alignment: .trailing) { + HStack { + RoundedRectangle(cornerRadius: 2) + .fill(LinearGradient( + gradient: Gradient(colors: [Color.cyan, Color.blue]), + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 10, height: 10) + Text("Directory") + .padding(.trailing) + RoundedRectangle(cornerRadius: 2) + .fill(LinearGradient( + gradient: Gradient(colors: [Color.pink, Color.orange]), + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .frame(width: 10, height: 10) + Text("File") + } + + } + + + } + .padding(5) + + } else { + Text("This folder is empty or there was an error loading its contents.") + .padding(5) + } + } else { + Text("No data available") + .padding(5) + } + + Spacer() + } + .padding() + .onChange(of: currentPath) { newValue in + if let newPath = newValue { + loadContents(url: newPath) + } + } + // Might need this in another app where I won't use a Navigation View +// .onAppear { +// loadContents(url: rootURL) +// } + .toolbar { + +// ToolbarItem(placement: .navigation) { +// Button { +// goBack() +// } label: { +// Image(systemName: "arrow.left") +// } +// .disabled(currentPath == rootURL) +// } + +// ToolbarItem(placement: .navigation) { +// Button { +// goForward() +// } label: { +// Image(systemName: "arrow.right") +// } +// .disabled(forwardStack.isEmpty) +// } + + ToolbarItem(placement: .principal) { + if let currentPath = currentPath { + Text(currentPath.path) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + .padding(5) + .padding(.horizontal, 2) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.primary) + .opacity(0.05) + } + + } + } + + ToolbarItemGroup(placement: .automatic) { + Spacer() + + Button { + printItemsCount() + } label: { + Image(systemName: "number.square") + } + + Button { + refreshCurrentFolder() + } label: { + Image(systemName: "arrow.counterclockwise") + } +// Button { +// Task { +// await wipeSwiftDataStorage() +// } +// } label: { +// Image(systemName: "xmark.bin") +// } +// Button { +// Task { +// await printSwiftDataCacheSize() +// } +// } label: { +// Image(systemName: "number.square") +// } + } + } + } + + private func loadContents(url: URL, wipeCache: Bool = false) { + isLoading = true + errorMessage = nil + + fastSearch.search(url: url) { result in + switch result { + case .success(let items): + self.childItems = items + self.currentPath = url + self.currentItem = Item(url: url, name: url.lastPathComponent, size: items.reduce(0) { $0 + $1.size }) + case .failure(let error): + self.errorMessage = "Error loading contents: \(error.localizedDescription)" + print("Error loading contents: \(error)") + } + self.isLoading = false + } + } + + private func refreshCurrentFolder() { + if let currentPath = currentPath { + loadContents(url: currentPath, wipeCache: true) + } + } + + private func printItemsCount() { + print("Number of items: \(childItems.count)") + } +} diff --git a/Pearcleaner/PearDisk/PearDiskView.swift b/Pearcleaner/PearDisk/PearDiskView.swift new file mode 100644 index 0000000..66103f8 --- /dev/null +++ b/Pearcleaner/PearDisk/PearDiskView.swift @@ -0,0 +1,96 @@ +// +// PearDiskView.swift +// Pearcleaner +// +// Created by Alin Lupascu on 7/17/24. +// + +import SwiftUI +import AlinFoundation + +//MARK: Main View +struct PearDiskView: View { + @EnvironmentObject var themeManager: ThemeManager + @State private var selectedVolume: URL? + @State private var currentPath: URL? + + var body: some View { + NavigationSplitView { + SidebarView(selectedVolume: $selectedVolume) + } detail: { + if let volume = selectedVolume { + FolderDetailView(rootURL: volume, currentPath: $currentPath) + } else { + Text("Select a volume") + } + } + .onChange(of: selectedVolume) { path in + currentPath = path + } + } +} + + +//MARK: Sidebar +struct SidebarView: View { + @EnvironmentObject var themeManager: ThemeManager + @Binding var selectedVolume: URL? + @State private var volumes: [URL] = [] + + var body: some View { + List(volumes, id: \.self) { volume in + Text(volume.lastPathComponent) + .onTapGesture { + selectedVolume = volume + } + } + .onAppear { + loadVolumes() + } + .background(backgroundView(themeManager: themeManager)) + } + + private func loadVolumes() { + let fileManager = FileManager.default + + // Define the base paths for the directories you want to scan + let basePaths = [ + URL(fileURLWithPath: "/Volumes"), + URL(fileURLWithPath: "/Users"), + URL(fileURLWithPath: "/Users/alin"), + ] + + for basePath in basePaths { + do { + let urls = try fileManager.contentsOfDirectory(at: basePath, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) + + // Iterate over each URL in the directory + for url in urls { + if url.isSymlink() { + // If the item is a symbolic link, resolve it + let resolvedUrl = url.resolvingSymlinksInPath() + volumes.append(resolvedUrl) + } else { + volumes.append(url) + } + } + } catch { + print("Error loading volumes: \(error)") + } + } + } + +} + + +extension URL { + public func isSymlink() -> Bool { + do { + let _ = try self.checkResourceIsReachable() + let resourceValues = try self.resourceValues(forKeys: [.isSymbolicLinkKey]) + return resourceValues.isSymbolicLink == true + } catch { + return false + } + } +} diff --git a/Pearcleaner/Views/TreeMap.swift b/Pearcleaner/PearDisk/TreeMap.swift similarity index 84% rename from Pearcleaner/Views/TreeMap.swift rename to Pearcleaner/PearDisk/TreeMap.swift index 9bd81b3..c778cce 100644 --- a/Pearcleaner/Views/TreeMap.swift +++ b/Pearcleaner/PearDisk/TreeMap.swift @@ -9,23 +9,7 @@ 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] @@ -33,7 +17,7 @@ struct TreeMapChart: View { @Binding var hoveredItem: Item? var displayedItems: [Item] { - items.filter { $0.size >= 1_048_576 } // Filter to keep only items with size >= 1MB + 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 } @@ -91,7 +75,7 @@ struct TreeMapView: View { private var content: some View { ForEach(0.. Color { - item.isDirectory ? .blue : .gray - } +// private func colorForItem(_ item: Item) -> Color { +// item.isDirectory ? .blue : .gray +// } private func gradientForItem(_ item: Item) -> LinearGradient { - item.isDirectory ? LinearGradient( + LinearGradient( gradient: Gradient(colors: [Color.cyan, Color.blue]), startPoint: .topLeading, endPoint: .bottomTrailing - ) : LinearGradient( - gradient: Gradient(colors: [Color.pink, Color.orange]), - startPoint: .topLeading, - endPoint: .bottomTrailing ) +// 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 +// ) } } @@ -172,7 +155,23 @@ func randomColor() -> Color { brightness: Double.random(in: 0.90...1.0)) } -//MARK: =================================================================================================================== +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 = false) { + self.url = url + self.name = name + self.size = size + self.isDirectory = isDirectory + } +} + +//MARK: Layout Generator ============================================================================================================ // Layout enum Direction { diff --git a/Pearcleaner/Views/RegularMode.swift b/Pearcleaner/Views/RegularMode.swift index 4819ab9..6e7171b 100644 --- a/Pearcleaner/Views/RegularMode.swift +++ b/Pearcleaner/Views/RegularMode.swift @@ -23,63 +23,80 @@ struct RegularMode: View { @Binding var showPopover: Bool @State private var showMenu = false @State var isMenuBar: Bool = false + @State private var isDrillView: Bool = false var body: some View { - HStack(alignment: .center, spacing: 0) { - - // App List - HStack(spacing: 0){ - - if appState.reload { - VStack { - Spacer() - ProgressView() { - Text("Gathering app details") - .font(.callout) - .foregroundStyle(.primary.opacity(0.5)) - .padding(5) - } - Spacer() - } - .frame(width: sidebarWidth) - .padding(.vertical) - } else { - AppSearchView(glass: glass, menubarEnabled: menubarEnabled, mini: mini, search: $search, showPopover: $showPopover, isMenuBar: $isMenuBar) - .frame(width: sidebarWidth) - } - - } - .background(backgroundView(themeManager: themeManager, darker: true, glass: glass)) - .transition(.opacity) - - SlideableDivider(dimension: $sidebarWidth) - .background(backgroundView(themeManager: themeManager)) - .zIndex(3) - - - // Details View - HStack(spacing: 0) { - Spacer() - Group { - if appState.currentView == .empty || appState.currentView == .apps { - AppDetailsEmptyView(showPopover: $showPopover) - } else if appState.currentView == .files { - FilesView(showPopover: $showPopover, search: $search) - .id(appState.appInfo.id) - } else if appState.currentView == .zombie { - ZombieView(showPopover: $showPopover, search: $search) - .id(appState.appInfo.id) - } - } - .transition(.opacity) + if isDrillView { + VStack { + Toggle("Drill", isOn: $isDrillView).padding() + PearDiskView() + .environmentObject(appState) Spacer() } - .zIndex(2) .background(backgroundView(themeManager: themeManager)) + .frame(minWidth: 900, minHeight: 600) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .edgesIgnoringSafeArea(.all) + } else { + HStack(alignment: .center, spacing: 0) { +// Toggle("", isOn: $isDrillView) + + // App List + HStack(spacing: 0){ + + if appState.reload { + VStack { + Spacer() + ProgressView() { + Text("Gathering app details") + .font(.callout) + .foregroundStyle(.primary.opacity(0.5)) + .padding(5) + } + Spacer() + } + .frame(width: sidebarWidth) + .padding(.vertical) + } else { + AppSearchView(glass: glass, menubarEnabled: menubarEnabled, mini: mini, search: $search, showPopover: $showPopover, isMenuBar: $isMenuBar) + .frame(width: sidebarWidth) + } + + } + .background(backgroundView(themeManager: themeManager, darker: true, glass: glass)) + .transition(.opacity) + + SlideableDivider(dimension: $sidebarWidth) + .background(backgroundView(themeManager: themeManager)) + .zIndex(3) + + + // Details View + HStack(spacing: 0) { + Spacer() + Group { + if appState.currentView == .empty || appState.currentView == .apps { + AppDetailsEmptyView(showPopover: $showPopover) + } else if appState.currentView == .files { + FilesView(showPopover: $showPopover, search: $search) + .id(appState.appInfo.id) + } else if appState.currentView == .zombie { + ZombieView(showPopover: $showPopover, search: $search) + .id(appState.appInfo.id) + } + } + .transition(.opacity) + Spacer() + } + .zIndex(2) + .background(backgroundView(themeManager: themeManager)) + } + .frame(minWidth: 900, minHeight: 600) + .edgesIgnoringSafeArea(.all) } - .frame(minWidth: 900, minHeight: 600) - .edgesIgnoringSafeArea(.all) + + } } diff --git a/Pearcleaner/Views/ZombieView.swift b/Pearcleaner/Views/ZombieView.swift index 0216ccc..af1bdf6 100644 --- a/Pearcleaner/Views/ZombieView.swift +++ b/Pearcleaner/Views/ZombieView.swift @@ -36,8 +36,6 @@ 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 { @@ -172,109 +170,88 @@ struct ZombieView: View { .padding(.top, 0) } - 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: { + // 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 { - // All items are selected if no filter is applied and all items are selected - return selectedZombieItemsLocal.count == appState.zombieFile.fileSize.count + // Select all files if no filter is applied + selectedZombieItemsLocal = Set(appState.zombieFile.fileSize.keys) } else { - // All currently filtered files are selected when a filter is applied - return Set(memoizedFiles).isSubset(of: selectedZombieItemsLocal) && selectedZombieItemsLocal.count == memoizedFiles.count + // Select only filtered files if a filter is applied + selectedZombieItemsLocal.formUnion(memoizedFiles) } - }, - 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 { - 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) - } + // Deselect only filtered files if a filter is applied + selectedZombieItemsLocal.subtract(memoizedFiles) } - - 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) } - - - Button("") { - selectedSortAlpha.toggle() - updateMemoizedFiles(for: searchZ, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha, force: true) + updateTotalSizes() } - .buttonStyle(SimpleButtonStyle(icon: selectedSortAlpha ? "textformat.abc" : "textformat.123", help: selectedSortAlpha ? "Sorted alphabetically" : "Sorted by size")) + )) + .toggleStyle(SimpleCheckboxToggleStyle()) + .help("All checkboxes") - } - .padding(.horizontal) - .padding(.vertical) - - - Divider() + SearchBar(search: $searchZ, darker: true, glass: glass, sidebar: false) .padding(.horizontal) + .onChange(of: searchZ) { newValue in + updateMemoizedFiles(for: newValue, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha) + } - 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) - } + + 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) + + + 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)