diff --git a/Pearcleaner/Logic/AppState.swift b/Pearcleaner/Logic/AppState.swift index 3e2835c..1a9b037 100644 --- a/Pearcleaner/Logic/AppState.swift +++ b/Pearcleaner/Logic/AppState.swift @@ -19,7 +19,7 @@ class AppState: ObservableObject @Published var zombieFile: ZombieFile @Published var sortedApps: [AppInfo] = [] @Published var selectedItems = Set() - @Published var selectedZombieItems = Set() +// @Published var selectedZombieItems = Set() @Published var alertType = AlertType.off @Published var currentView = CurrentDetailsView.empty @Published var showAlert: Bool = false diff --git a/Pearcleaner/Logic/Locations.swift b/Pearcleaner/Logic/Locations.swift index 9c30e69..72cb03d 100644 --- a/Pearcleaner/Logic/Locations.swift +++ b/Pearcleaner/Logic/Locations.swift @@ -99,7 +99,7 @@ class Locations: ObservableObject { "/Library/PrivilegedHelperTools", "/private/var/db/receipts", cacheDir, - tempDir +// tempDir // Stop listing these files as there's a ton of them and they get cleaned up by the OS anyways ]) } } diff --git a/Pearcleaner/Logic/Logic.swift b/Pearcleaner/Logic/Logic.swift index 80ae42b..d57ad89 100644 --- a/Pearcleaner/Logic/Logic.swift +++ b/Pearcleaner/Logic/Logic.swift @@ -110,7 +110,7 @@ func listAppSupportDirectories() -> [String] { // Load app paths on launch -func reversePreloader(allApps: [AppInfo], appState: AppState, locations: Locations, reverseAddon: Bool = false, completion: @escaping () -> Void = {}) { +func reversePreloader(allApps: [AppInfo], appState: AppState, locations: Locations, fsm: FolderSettingsManager, reverseAddon: Bool = false, completion: @escaping () -> Void = {}) { let dispatchGroup = DispatchGroup() appState.appInfoStore.removeAll() @@ -127,7 +127,7 @@ func reversePreloader(allApps: [AppInfo], appState: AppState, locations: Locatio func checkAllAppsProcessed(retryCount: Int = 0, maxRetry: Int = 120) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if appState.appInfoStore.count == allApps.count { - ReversePathsSearcher(appState: appState, locations: locations).reversePathsSearch() { + ReversePathsSearcher(appState: appState, locations: locations, fsm: fsm).reversePathsSearch() { updateOnMain { appState.showProgress = false } diff --git a/Pearcleaner/Logic/ReversePathsFetch.swift b/Pearcleaner/Logic/ReversePathsFetch.swift index 53e640d..894573d 100644 --- a/Pearcleaner/Logic/ReversePathsFetch.swift +++ b/Pearcleaner/Logic/ReversePathsFetch.swift @@ -11,6 +11,7 @@ import AppKit class ReversePathsSearcher { private let appState: AppState private let locations: Locations + private let fsm: FolderSettingsManager private let fileManager = FileManager.default private var collection: [URL] = [] private var fileSize: [URL: Int64] = [:] @@ -18,9 +19,10 @@ class ReversePathsSearcher { private var fileIcon: [URL: NSImage?] = [:] private let dispatchGroup = DispatchGroup() - init(appState: AppState, locations: Locations) { + init(appState: AppState, locations: Locations, fsm: FolderSettingsManager) { self.appState = appState self.locations = locations + self.fsm = fsm } @@ -64,6 +66,11 @@ class ReversePathsSearcher { let formattedItemName = itemName.pearFormat() let itemPath = itemURL.path.pearFormat() let itemLastPathComponent = itemURL.lastPathComponent.pearFormat() + let exclusionList = fsm.fileFolderPathsZ.map { $0.pearFormat() } + + if exclusionList.contains(itemPath) || itemPath.contains("dsstore") || itemPath.contains("daemonnameoridentifierhere") { + return + } guard !skipReverse.contains(where: { formattedItemName.contains($0) }), !allPaths.contains(where: { $0 == itemPath || $0.hasSuffix("/\(itemLastPathComponent)") }), diff --git a/Pearcleaner/Logic/Utilities.swift b/Pearcleaner/Logic/Utilities.swift index 9a8b195..09964e2 100644 --- a/Pearcleaner/Logic/Utilities.swift +++ b/Pearcleaner/Logic/Utilities.swift @@ -476,7 +476,8 @@ extension Int { // --- Extend String to remove periods, spaces and lowercase the string extension String { func pearFormat() -> String { - return self.replacingOccurrences(of: ".", with: "").replacingOccurrences(of: " ", with: "").replacingOccurrences(of: "-", with: "").lowercased() + // Remove all non-alphanumeric characters using regular expression and convert to lowercase + return self.replacingOccurrences(of: "[^a-zA-Z0-9]", with: "", options: .regularExpression).lowercased() } } diff --git a/Pearcleaner/Settings/About.swift b/Pearcleaner/Settings/About.swift index 92b9eea..1c6cfc6 100644 --- a/Pearcleaner/Settings/About.swift +++ b/Pearcleaner/Settings/About.swift @@ -28,6 +28,32 @@ struct AboutSettingsTab: View { } + HStack{ + Image(systemName: "star") + .resizable() + .scaledToFit() + .frame(width: 20, height: 20) + .padding(.trailing) + + VStack(alignment: .leading){ + Text("GitHub") + Text("Submit a bug or feature request via the repo") + .font(.callout) + .foregroundStyle(Color("mode").opacity(0.5)) + + } + Spacer() + Button(""){ + NSWorkspace.shared.open(URL(string: "https://github.com/alienator88/Pearcleaner/issues/new/choose")!) + } + .buttonStyle(SimpleButtonStyle(icon: "link", help: "View")) + + } + .padding(5) + .padding(.leading) + .padding(.top, 5) + + Divider() .padding() @@ -67,30 +93,6 @@ struct AboutSettingsTab: View { .padding(.leading) - HStack{ - Image(systemName: "n.circle") - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - .padding(.trailing) - - VStack(alignment: .leading){ - Text("Namelix") - Text("Logo and branding generation") - .font(.callout) - .foregroundStyle(Color("mode").opacity(0.5)) - - } - Spacer() - Button(""){ - NSWorkspace.shared.open(URL(string: "https://namelix.com/")!) - } - .buttonStyle(SimpleButtonStyle(icon: "link", help: "View")) - - } - .padding(5) - .padding(.leading) - HStack{ Image(systemName: "applescript") @@ -149,7 +151,7 @@ struct AboutSettingsTab: View { Spacer() - Text("Made with ❤️ by Alin Lupascu (dev@itsalin.com)").font(.footnote).padding(.bottom) + Text("Made with ❤️ by Alin Lupascu").font(.footnote).padding(.bottom) } .padding(20) .frame(width: 500, height: 600) diff --git a/Pearcleaner/Settings/Folders.swift b/Pearcleaner/Settings/Folders.swift index 738b94d..e74448c 100644 --- a/Pearcleaner/Settings/Folders.swift +++ b/Pearcleaner/Settings/Folders.swift @@ -21,17 +21,31 @@ struct FolderSettingsTab: View { @EnvironmentObject var locations: Locations @EnvironmentObject var fsm: FolderSettingsManager @State private var isHovered = false - @State private var isHoveredPlus = false var body: some View { Form { - VStack { + VStack(spacing: 0) { HStack(spacing: 0) { Text("Apps").font(.title2) - InfoButton(text: "Locations that will be searched for .app files. Click a non-default path to remove it. Add new folders below or drag/drop a folder over the list.", color: nil, label: "") + InfoButton(text: "Locations that will be searched for .app files. Click a non-default path to remove it. Add new folders using the + button or drag/drop over the list. Non-default paths can't be removed.", color: nil, label: "") Spacer() + + Button("") { + selectFolder() + } + .buttonStyle(SimpleButtonStyle(icon: "plus", help: "Add folder")) + .onHover(perform: { hovering in + if hovering { + NSCursor.pointingHand.push() + + } else { + NSCursor.pop() + } + }) + } + .padding(.bottom, 5) ScrollView { @@ -92,39 +106,93 @@ struct FolderSettingsTab: View { } + Divider() + .padding(.vertical) + // === LEFTOVER FILES ================================================================================================ + HStack(spacing: 0) { + Text("Leftover Files").font(.title2) + InfoButton(text: "Add files or folders that will be ignored when searching for leftover files. Click a path to remove it from the list. Add new files/folders using the + button or drag/drop over the list.", color: nil, label: "") + Spacer() - // === OTHER ================================================================================================ - - ZStack { - RoundedRectangle(cornerRadius: 10) - .fill(Color("mode").opacity(0.1)) -// .strokeBorder(Color("mode").opacity(0.1), lineWidth: 2) - .frame(width: 300, height: 100) - - Image(systemName: "plus") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 30, height: 30) - .foregroundStyle(isHoveredPlus ? Color("mode") : Color("mode").opacity(0.5)) - } - .padding(.top) - .onTapGesture { - selectFolder() - } - .onHover { hovering in - withAnimation(Animation.easeInOut(duration: 0.4)) { - isHoveredPlus = hovering - } - if isHoveredPlus { - NSCursor.pointingHand.push() - } else { - NSCursor.pop() + Button("") { + selectFilesFoldersZ() } + .buttonStyle(SimpleButtonStyle(icon: "plus", help: "Add file/folder")) + .onHover(perform: { hovering in + if hovering { + NSCursor.pointingHand.push() + } else { + NSCursor.pop() + } + }) } + .padding(.bottom, 5) + ScrollView { + VStack(spacing: 5) { + if fsm.fileFolderPathsZ.count == 0 { + HStack { + Text("No files or folders added") + .font(.callout) + .opacity(0.5) + .padding(5) + Spacer() + } + .disabled(true) + } + ForEach(fsm.fileFolderPathsZ.indices, id: \.self) { index in + HStack { + + Text(fsm.fileFolderPathsZ[index]) + .font(.callout) + .padding(5) + Spacer() + } + .onHover { hovering in + withAnimation(Animation.easeInOut(duration: 0.4)) { + isHovered = hovering + } + if isHovered { + NSCursor.disappearingItem.push() + } else { + NSCursor.pop() + } + } + .onTapGesture { + fsm.removePathZ(at: index) + } + + if index != fsm.fileFolderPathsZ.indices.last { + Divider().opacity(0.5) + } + } + + } + + } + .scrollIndicators(.automatic) + .padding() + .background(Color("mode").opacity(0.05)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .onDrop(of: ["public.file-url"], isTargeted: nil) { providers -> Bool in + providers.forEach { provider in + provider.loadDataRepresentation(forTypeIdentifier: "public.file-url") { (data, error) in + guard let data = data, error == nil, + let url = URL(dataRepresentation: data, relativeTo: nil) else { + printOS("FSM: Failed to load URL") + return + } + updateOnMain { + fsm.addPathZ(url.path) + } + } + } + return true + } + @@ -134,7 +202,7 @@ struct FolderSettingsTab: View { } .padding(20) - .frame(width: 500, height: 420) + .frame(width: 500, height: 600) } @@ -157,46 +225,100 @@ struct FolderSettingsTab: View { } } + + private func selectFilesFoldersZ() { + let dialog = NSOpenPanel() + dialog.title = "Choose files or folders" + dialog.showsResizeIndicator = false + dialog.showsHiddenFiles = true + dialog.canChooseDirectories = true + dialog.canCreateDirectories = false + dialog.canChooseFiles = true + + if dialog.runModal() == NSApplication.ModalResponse.OK { + if let result = dialog.url { + fsm.addPathZ(result.path) + } + } else { + return + } + } + } class FolderSettingsManager: ObservableObject { @Published var folderPaths: [String] = [] - private let userDefaultsKey = "settings.folders.apps" + @Published var fileFolderPathsZ: [String] = [] + private let appsKey = "settings.folders.apps" + private let zombieKey = "settings.folders.zombie" let defaultPaths = ["/Applications", "\(NSHomeDirectory())/Applications"] init() { loadDefaultPathsIfNeeded() } + + + // Application folders ////////////////////////////////////////////////////////////////////////////////// private func loadDefaultPathsIfNeeded() { - var paths = UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? defaultPaths - if paths.count < 2 { - paths = defaultPaths + var appsPaths = UserDefaults.standard.stringArray(forKey: appsKey) ?? defaultPaths + let zombiePaths = UserDefaults.standard.stringArray(forKey: zombieKey) ?? [] + if appsPaths.count < 2 { + appsPaths = defaultPaths } - UserDefaults.standard.set(paths, forKey: userDefaultsKey) - self.folderPaths = paths + UserDefaults.standard.set(appsPaths, forKey: appsKey) + self.folderPaths = appsPaths + self.fileFolderPathsZ = zombiePaths } func addPath(_ path: String) { if !self.folderPaths.contains(path) { self.folderPaths.append(path) - UserDefaults.standard.set(self.folderPaths, forKey: userDefaultsKey) + UserDefaults.standard.set(self.folderPaths, forKey: appsKey) } } func removePath(at index: Int) { guard self.folderPaths.indices.contains(index) else { return } self.folderPaths.remove(at: index) // Update local state - UserDefaults.standard.set(self.folderPaths, forKey: userDefaultsKey) + UserDefaults.standard.set(self.folderPaths, forKey: appsKey) } func refreshPaths() { - self.folderPaths = UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? defaultPaths + self.folderPaths = UserDefaults.standard.stringArray(forKey: appsKey) ?? defaultPaths } func getPaths() -> [String] { - return UserDefaults.standard.stringArray(forKey: userDefaultsKey) ?? defaultPaths + return UserDefaults.standard.stringArray(forKey: appsKey) ?? defaultPaths } + + + + // Leftover files ////////////////////////////////////////////////////////////////////////////////// + func addPathZ(_ path: String) { + let sanitizedPath = path.hasPrefix("/private") ? String(path.dropFirst(8)) : path + + if !self.fileFolderPathsZ.contains(sanitizedPath) { + self.fileFolderPathsZ.append(sanitizedPath) + UserDefaults.standard.set(self.fileFolderPathsZ, forKey: zombieKey) + } + } + + func removePathZ(at index: Int) { + guard self.fileFolderPathsZ.indices.contains(index) else { return } + self.fileFolderPathsZ.remove(at: index) // Update local state + UserDefaults.standard.set(self.fileFolderPathsZ, forKey: zombieKey) + } + + func refreshPathsZ() { + self.fileFolderPathsZ = UserDefaults.standard.stringArray(forKey: zombieKey) ?? [] + } + + func getPathsZ() -> [String] { + return UserDefaults.standard.stringArray(forKey: zombieKey) ?? [] + } + + } diff --git a/Pearcleaner/Views/FilesView.swift b/Pearcleaner/Views/FilesView.swift index 8bea7ca..88d68c4 100644 --- a/Pearcleaner/Views/FilesView.swift +++ b/Pearcleaner/Views/FilesView.swift @@ -18,11 +18,9 @@ struct FilesView: View { @AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false @AppStorage("settings.general.selectedSort") var selectedSortAlpha: Bool = true @AppStorage("settings.general.sizeType") var sizeType: String = "Real" - @State private var localKey = UUID() @Environment(\.colorScheme) var colorScheme @Binding var showPopover: Bool @Binding var search: String - var regularWin: Bool @State private var elapsedTime = 0 @State private var timer: Timer? = nil @@ -247,20 +245,12 @@ struct FilesView: View { VStack { FileDetailsItem(size: fileSize, sizeL: fileSizeL, icon: iconImage, path: path) .padding(.vertical, 5) -// .padding(.leading, 40) -// if index < sort.count - 1 { -// Divider().padding(.leading, 40).opacity(0.5) -// } } } } } .padding() - .onChange(of: sizeType) { _ in - localKey = UUID() - } - .id(localKey) } diff --git a/Pearcleaner/Views/MiniMode.swift b/Pearcleaner/Views/MiniMode.swift index d48b61d..b5f586f 100644 --- a/Pearcleaner/Views/MiniMode.swift +++ b/Pearcleaner/Views/MiniMode.swift @@ -178,10 +178,10 @@ struct MiniAppView: View { .popover(isPresented: $showPopover, arrowEdge: .trailing) { VStack { if appState.currentView == .files { - FilesView(showPopover: $showPopover, search: $search, regularWin: false) + FilesView(showPopover: $showPopover, search: $search) .id(appState.appInfo.id) } else if appState.currentView == .zombie { - ZombieView(showPopover: $showPopover, search: $search, regularWin: false) + ZombieView(showPopover: $showPopover, search: $search) .id(appState.appInfo.id) } diff --git a/Pearcleaner/Views/RegularMode.swift b/Pearcleaner/Views/RegularMode.swift index 4fbf068..44ac2da 100644 --- a/Pearcleaner/Views/RegularMode.swift +++ b/Pearcleaner/Views/RegularMode.swift @@ -59,10 +59,10 @@ struct RegularMode: View { if appState.currentView == .empty || appState.currentView == .apps { AppDetailsEmptyView(showPopover: $showPopover) } else if appState.currentView == .files { - FilesView(showPopover: $showPopover, search: $search, regularWin: true) + FilesView(showPopover: $showPopover, search: $search) .id(appState.appInfo.id) } else if appState.currentView == .zombie { - ZombieView(showPopover: $showPopover, search: $search, regularWin: true) + ZombieView(showPopover: $showPopover, search: $search) .id(appState.appInfo.id) } } diff --git a/Pearcleaner/Views/Searchbar.swift b/Pearcleaner/Views/Searchbar.swift index 2d84263..3393ee9 100644 --- a/Pearcleaner/Views/Searchbar.swift +++ b/Pearcleaner/Views/Searchbar.swift @@ -11,6 +11,7 @@ struct Searchbar: View { @EnvironmentObject var appState: AppState @EnvironmentObject var themeSettings: ThemeSettings @EnvironmentObject var locations: Locations + @EnvironmentObject var fsm: FolderSettingsManager var glass: Bool var sidebarWidth: Double var menubarEnabled: Bool @@ -53,7 +54,7 @@ struct Searchbar: View { showPopover = false } } - .buttonStyle(SimpleButtonStyle(icon: "plus.square.dashed", label: "Drop Target", help: "Drop Target")) + .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Drop Target", help: "Drop Target", size: 5)) } @@ -62,12 +63,11 @@ struct Searchbar: View { withAnimation(.easeInOut(duration: 0.5)) { showPopover = false appState.appInfo = .empty - appState.selectedZombieItems = [] if appState.zombieFile.fileSize.keys.isEmpty { appState.currentView = .zombie appState.showProgress.toggle() showPopover.toggle() - reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true) + reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, fsm: fsm, reverseAddon: true) } else { appState.currentView = .zombie showPopover.toggle() @@ -92,7 +92,7 @@ struct Searchbar: View { Button("Quit") { NSApp.terminate(nil) } - .buttonStyle(SimpleButtonStyle(icon: "x.circle.fill", label: "Quit Pearcleaner", help: "Quit Pearcleaner")) + .buttonStyle(SimpleButtonStyle(icon: "circle.fill", label: "Quit Pearcleaner", help: "Quit Pearcleaner", size: 5)) } } @@ -130,9 +130,11 @@ struct SimpleSearchStyle: TextFieldStyle { @State var darker: Bool = false @State var glass: Bool = false @State var padding: CGFloat = 5 + @State var sidebar: Bool = true @EnvironmentObject var appState: AppState @EnvironmentObject var themeSettings: ThemeSettings @AppStorage("settings.general.mini") private var mini: Bool = false + @AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false func _body(configuration: TextField) -> some View { @@ -141,7 +143,7 @@ struct SimpleSearchStyle: TextFieldStyle { .fill(darker ? themeSettings.themeColor.darker(by: 5) : themeSettings.themeColor) .allowsHitTesting(false) .frame(height: 30) - .opacity(glass ? 0.0 : 1.0) + .opacity((glass && (sidebar || !mini && !menubarEnabled)) || mini || menubarEnabled ? 0.0 : 1.0) ZStack { @@ -202,12 +204,13 @@ struct SearchBar: View { @State var darker: Bool = false @State var glass: Bool = false @State var padding: CGFloat = 5 + @State var sidebar: Bool = true @EnvironmentObject var appState: AppState var body: some View { HStack { TextField("", text: $search) - .textFieldStyle(SimpleSearchStyle(trash: true, text: $search, darker: darker, glass: glass, padding: padding)) + .textFieldStyle(SimpleSearchStyle(trash: true, text: $search, darker: darker, glass: glass, padding: padding, sidebar: sidebar)) } } } diff --git a/Pearcleaner/Views/ZombieView.swift b/Pearcleaner/Views/ZombieView.swift index d326701..39e488d 100644 --- a/Pearcleaner/Views/ZombieView.swift +++ b/Pearcleaner/Views/ZombieView.swift @@ -11,6 +11,7 @@ import SwiftUI struct ZombieView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var locations: Locations + @EnvironmentObject var fsm: FolderSettingsManager @State private var showPop: Bool = false @AppStorage("settings.general.mini") private var mini: Bool = false @AppStorage("settings.general.glass") private var glass: Bool = false @@ -18,55 +19,61 @@ struct ZombieView: View { @AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false @AppStorage("settings.general.selectedSort") var selectedSortAlpha: Bool = true @AppStorage("settings.general.sizeType") var sizeType: String = "Real" - @State private var localKey = UUID() @Environment(\.colorScheme) var colorScheme @Binding var showPopover: Bool @Binding var search: String @State private var searchZ: String = "" - var regularWin: Bool @State private var elapsedTime = 0 @State private var timer: Timer? = nil + @State private var selectedZombieItemsLocal: Set = [] + @State private var memoizedFiles: [URL] = [] + @State private var lastSearchTermUsed: String? = nil + @State private var totalRealSize: Int64 = 0 + @State private var totalLogicalSize: Int64 = 0 + @State private var totalRealSizeUninstallBtn: String = "" + @State private var totalLogicalSizeUninstallBtn: String = "" + @State private var totalFinderSizeUninstallBtn: String = "" var body: some View { - let totalSelectedZombieSize: (real: String, logical: String, finder: String) = { - var totalReal: Int64 = 0 - var totalLogical: Int64 = 0 +// let totalSelectedZombieSize: (real: String, logical: String, finder: String) = { +// var totalReal: Int64 = 0 +// var totalLogical: Int64 = 0 +// +// for url in selectedZombieItemsLocal { +// let realSize = appState.zombieFile.fileSize[url] ?? 0 +// let logicalSize = appState.zombieFile.fileSizeLogical[url] ?? 0 +// totalReal += realSize +// totalLogical += logicalSize +// } +// return (formatByte(size: totalReal).human, formatByte(size:totalLogical).human, "\(formatByte(size: totalLogical).byte) (\(formatByte(size: totalReal).human))") +// }() - for url in appState.selectedZombieItems { - let realSize = appState.zombieFile.fileSize[url] ?? 0 - let logicalSize = appState.zombieFile.fileSizeLogical[url] ?? 0 - totalReal += realSize - totalLogical += logicalSize - } - return (formatByte(size: totalReal).human, formatByte(size:totalLogical).human, "\(formatByte(size: totalLogical).byte) (\(formatByte(size: totalReal).human))") - }() +// let filteredAndSortedFiles: ([URL], Int64, Int64) = { +// let fileSizeReal = appState.zombieFile.fileSize +// let fileSizeLogical = appState.zombieFile.fileSizeLogical +// let filteredFilesReal = fileSizeReal.filter { (url, _) in +// searchZ.isEmpty || url.lastPathComponent.localizedCaseInsensitiveContains(searchZ) +// } +// let filteredFilesLogical = fileSizeLogical.filter { (url, _) in +// searchZ.isEmpty || url.lastPathComponent.localizedCaseInsensitiveContains(searchZ) +// } +// let filesToSort = (sizeType == "Real" || sizeType == "Finder" ? filteredFilesReal : filteredFilesLogical) +// let sortedFilteredFiles = filesToSort.sorted(by: { +// if selectedSortAlpha { +// return $0.key.lastPathComponent.pearFormat() < $1.key.lastPathComponent.pearFormat() +// } else { +// return $0.value > $1.value +// } +// }).map { $0.key } +// let totalSize = filteredFilesReal.values.reduce(0, +) +// let totalSizeL = filteredFilesLogical.values.reduce(0, +) +// return (sortedFilteredFiles, totalSize, totalSizeL) +// }() - let filteredAndSortedFiles: ([URL], Int64, Int64) = { - let fileSizeReal = appState.zombieFile.fileSize - let fileSizeLogical = appState.zombieFile.fileSizeLogical - let filteredFilesReal = fileSizeReal.filter { (url, _) in - searchZ.isEmpty || url.lastPathComponent.localizedCaseInsensitiveContains(searchZ) - } - let filteredFilesLogical = fileSizeLogical.filter { (url, _) in - searchZ.isEmpty || url.lastPathComponent.localizedCaseInsensitiveContains(searchZ) - } - let filesToSort = (sizeType == "Real" || sizeType == "Finder" ? filteredFilesReal : filteredFilesLogical) - let sortedFilteredFiles = filesToSort.sorted(by: { - if selectedSortAlpha { - return $0.key.lastPathComponent.pearFormat() < $1.key.lastPathComponent.pearFormat() - } else { - return $0.value > $1.value - } - }).map { $0.key } - let totalSize = filteredFilesReal.values.reduce(0, +) - let totalSizeL = filteredFilesLogical.values.reduce(0, +) - return (sortedFilteredFiles, totalSize, totalSizeL) - }() - - let displaySizeTotal = sizeType == "Real" ? formatByte(size: filteredAndSortedFiles.1).human : - sizeType == "Logical" ? formatByte(size: filteredAndSortedFiles.2).human : - "\(formatByte(size: filteredAndSortedFiles.2).byte) (\(formatByte(size: filteredAndSortedFiles.1).human))" +// let displaySizeTotal = sizeType == "Real" ? formatByte(size: filteredAndSortedFiles.1).human : +// sizeType == "Logical" ? formatByte(size: filteredAndSortedFiles.2).human : +// "\(formatByte(size: filteredAndSortedFiles.2).byte) (\(formatByte(size: filteredAndSortedFiles.1).human))" VStack(alignment: .center) { if appState.showProgress { @@ -156,7 +163,7 @@ struct ZombieView: View { VStack(alignment: .trailing, spacing: 5) { Text("\(displaySizeTotal)").font(.title).fontWeight(.bold).help("Total size on disk") - Text("\(appState.zombieFile.fileSize.count == 1 ? "\(appState.selectedZombieItems.count) / \(appState.zombieFile.fileSize.count) item" : "\(appState.selectedZombieItems.count) / \(appState.zombieFile.fileSize.count) items")") + Text("\(selectedZombieItemsLocal.count) / \(searchZ.isEmpty ? appState.zombieFile.fileSize.count : memoizedFiles.count) \(appState.zombieFile.fileSize.count == 1 ? "item" : "items")") .font(.callout).foregroundStyle(Color("mode").opacity(0.5)) } @@ -170,23 +177,51 @@ struct ZombieView: View { // Item selection and sorting toolbar HStack { Toggle("", isOn: Binding( - get: { appState.selectedZombieItems.count == appState.zombieFile.fileSize.count }, - set: { newValue in - updateOnMain { - appState.selectedZombieItems = newValue ? Set(appState.zombieFile.fileSize.keys) : [] + 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() } )) .toggleStyle(SimpleCheckboxToggleStyle()) .help("All checkboxes") - SearchBar(search: $searchZ, darker: true, glass: glass) + 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")) @@ -201,11 +236,11 @@ struct ZombieView: View { ScrollView() { LazyVStack { - ForEach(Array(filteredAndSortedFiles.0.enumerated()), id: \.element) { index, file in + 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) + ZombieFileDetailsItem(size: fileSize, sizeL: fileSizeL, icon: iconImage, path: file, isSelected: self.binding(for: file)) .padding(.vertical, 5) } } @@ -213,11 +248,7 @@ struct ZombieView: View { } .padding() - .onChange(of: sizeType) { _ in - localKey = UUID() - } } - .id(localKey) @@ -231,14 +262,14 @@ struct ZombieView: View { updateOnMain { appState.zombieFile = .empty appState.showProgress.toggle() - reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, reverseAddon: true) + reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, fsm: fsm, reverseAddon: true) } } .buttonStyle(RescanButton()) - Button("\(sizeType == "Logical" ? totalSelectedZombieSize.logical : sizeType == "Finder" ? totalSelectedZombieSize.finder : totalSelectedZombieSize.real)") { + Button("\(sizeType == "Logical" ? totalLogicalSizeUninstallBtn : sizeType == "Finder" ? totalFinderSizeUninstallBtn : totalRealSizeUninstallBtn)") { Task { - if appState.selectedZombieItems.count == appState.zombieFile.fileSize.keys.count { + if selectedZombieItemsLocal.count == appState.zombieFile.fileSize.keys.count { updateOnMain { appState.zombieFile = .empty search = "" @@ -253,7 +284,7 @@ struct ZombieView: View { } - let selectedItemsArray = Array(appState.selectedZombieItems) + let selectedItemsArray = Array(selectedZombieItemsLocal) moveFilesToTrash(at: selectedItemsArray) { withAnimation { @@ -261,18 +292,22 @@ struct ZombieView: View { } updateOnMain { // Remove items from the list - appState.zombieFile.fileSize = appState.zombieFile.fileSize.filter { !appState.selectedZombieItems.contains($0.key) } + appState.zombieFile.fileSize = appState.zombieFile.fileSize.filter { !selectedZombieItemsLocal.contains($0.key) } // Update the selectedZombieFiles to remove references that are no longer present - appState.selectedZombieItems.removeAll() + selectedZombieItemsLocal.removeAll() + updateTotalSizes() + } } +// updateMemoizedFiles(for: searchZ, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha, force: true) + } } - .buttonStyle(UninstallButton(isEnabled: !appState.selectedZombieItems.isEmpty)) - .disabled(appState.selectedZombieItems.isEmpty) + .buttonStyle(UninstallButton(isEnabled: !selectedZombieItemsLocal.isEmpty)) + .disabled(selectedZombieItemsLocal.isEmpty) } @@ -281,10 +316,112 @@ struct ZombieView: View { .transition(.opacity) .padding([.horizontal, .bottom], 20) .padding(.top, !mini ? 10 : 0) + .onAppear { + updateMemoizedFiles(for: searchZ, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha, force: true) + } } } } + + private func binding(for file: URL) -> Binding { + Binding( + get: { self.selectedZombieItemsLocal.contains(file) }, + set: { isSelected in + if isSelected { + self.selectedZombieItemsLocal.insert(file) + } else { + self.selectedZombieItemsLocal.remove(file) + } + updateTotalSizes() + } + ) + } + + + private func updateMemoizedFiles(for searchTerm: String, sizeType: String, selectedSortAlpha: Bool, force: Bool = false) { + if !force && searchTerm == lastSearchTermUsed && self.sizeType == sizeType && self.selectedSortAlpha == selectedSortAlpha { + return + } + + let results = filterAndSortFiles(for: searchTerm, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha) + memoizedFiles = results.files + totalRealSize = results.totalRealSize + totalLogicalSize = results.totalLogicalSize + lastSearchTermUsed = searchTerm + self.sizeType = sizeType + self.selectedSortAlpha = selectedSortAlpha + updateTotalSizes() + } + + private func filterAndSortFiles(for searchTerm: String, sizeType: String, selectedSortAlpha: Bool) -> (files: [URL], totalRealSize: Int64, totalLogicalSize: Int64) { + let fileSizeReal = appState.zombieFile.fileSize + let fileSizeLogical = appState.zombieFile.fileSizeLogical + + let filteredFilesReal = fileSizeReal.filter { url, _ in searchTerm.isEmpty || url.lastPathComponent.localizedCaseInsensitiveContains(searchTerm) } + let filteredFilesLogical = fileSizeLogical.filter { url, _ in searchTerm.isEmpty || url.lastPathComponent.localizedCaseInsensitiveContains(searchTerm) } + + let filesToSort = sizeType == "Real" || sizeType == "Finder" ? filteredFilesReal : filteredFilesLogical + let sortedFilteredFiles = filesToSort.sorted { (left, right) -> Bool in + if selectedSortAlpha { + return left.key.lastPathComponent.pearFormat() < right.key.lastPathComponent.pearFormat() + } else { + return left.value > right.value + } + }.map(\.key) + + let totalRealSize = filteredFilesReal.values.reduce(0, +) + let totalLogicalSize = filteredFilesLogical.values.reduce(0, +) + + return (sortedFilteredFiles, totalRealSize, totalLogicalSize) + } + + func calculateTotalSelectedZombieSize() -> (real: String, logical: String, finder: String) { + var totalReal: Int64 = 0 + var totalLogical: Int64 = 0 + + for url in selectedZombieItemsLocal { + let realSize = appState.zombieFile.fileSize[url] ?? 0 + let logicalSize = appState.zombieFile.fileSizeLogical[url] ?? 0 + totalReal += realSize + totalLogical += logicalSize + } + + return (formatByte(size: totalReal).human, + formatByte(size: totalLogical).human, + "\(formatByte(size: totalLogical).byte) (\(formatByte(size: totalReal).human))") + } + + private func updateTotalSizes() { + let sizes = calculateTotalSelectedZombieSize() + totalRealSizeUninstallBtn = sizes.real + totalLogicalSizeUninstallBtn = sizes.logical + totalFinderSizeUninstallBtn = "\(sizes.logical) (\(sizes.real))" + } + + private var displaySizeText: String { + switch sizeType { + case "Logical": + return totalLogicalSizeUninstallBtn + case "Finder": + return totalFinderSizeUninstallBtn + default: + return totalRealSizeUninstallBtn + } + } + + + private var displaySizeTotal: String { + switch sizeType { + case "Real": + return formatByte(size: totalRealSize).human + case "Logical": + return formatByte(size: totalLogicalSize).human + default: + return "\(formatByte(size: totalLogicalSize).byte) (\(formatByte(size: totalRealSize).human))" + } + } + } @@ -297,22 +434,26 @@ struct ZombieFileDetailsItem: View { let sizeL: Int64? let icon: Image? let path: URL + @Binding var isSelected: Bool var body: some View { HStack(alignment: .center, spacing: 20) { - Toggle("", isOn: Binding( - get: { self.appState.selectedZombieItems.contains(self.path) }, - set: { isChecked in - if isChecked { - self.appState.selectedZombieItems.insert(self.path) - } else { - self.appState.selectedZombieItems.remove(self.path) - } - } - )) + 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() diff --git a/Pearcleaner/Windows/WindowSettings.swift b/Pearcleaner/Windows/WindowSettings.swift index 9f3e406..dcec40f 100644 --- a/Pearcleaner/Windows/WindowSettings.swift +++ b/Pearcleaner/Windows/WindowSettings.swift @@ -27,10 +27,21 @@ class WindowSettings { func loadWindowSettings() -> NSRect { + // Retrieve window size let width = CGFloat(UserDefaults.standard.float(forKey: mini ? windowWidthKeyMini : windowWidthKey)) let height = CGFloat(UserDefaults.standard.float(forKey: mini ? windowHeightKeyMini : windowHeightKey)) - let x = CGFloat(UserDefaults.standard.float(forKey: windowXKey)) - let y = CGFloat(UserDefaults.standard.float(forKey: windowYKey)) + + // Set default middle position if not set in UserDefaults + var x = CGFloat(UserDefaults.standard.float(forKey: windowXKey)) + var y = CGFloat(UserDefaults.standard.float(forKey: windowYKey)) + + if UserDefaults.standard.object(forKey: windowXKey) == nil || UserDefaults.standard.object(forKey: windowYKey) == nil { + // Set window to center of the screen if not set + let screenSize = NSScreen.main?.frame.size ?? NSSize(width: 800, height: 600) // Fallback screen size + x = (screenSize.width - width) / 2 + y = (screenSize.height - height) / 2 + } + return NSRect(x: x, y: y, width: width, height: height) } @@ -49,7 +60,6 @@ class WindowSettings { newWindow.titleVisibility = .hidden newWindow.setFrameAutosaveName("Pearcleaner") newWindow.contentView = NSHostingView(rootView: contentView()) -// self.window = newWindow self.windows.append(newWindow) newWindow.makeKeyAndOrderFront(nil) }