// // ZombieView.swift // Pearcleaner // // Created by Alin Lupascu on 2/26/24. // import Foundation import SwiftUI import AlinFoundation struct ZombieView: View { @EnvironmentObject var appState: AppState @EnvironmentObject var locations: Locations @EnvironmentObject var fsm: FolderSettingsManager @State private var showPop: Bool = false @AppStorage("settings.general.leftoverWarning") private var warning: Bool = false @State private var showAlert = false @AppStorage("settings.general.mini") private var mini: Bool = false @AppStorage("settings.general.glass") private var glass: Bool = false @AppStorage("settings.sentinel.enable") private var sentinel: Bool = false @AppStorage("settings.menubar.enabled") private var menubarEnabled: Bool = false @AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true @AppStorage("settings.general.selectedSort") var selectedSortAlpha: Bool = true @AppStorage("settings.general.sizeType") var sizeType: String = "Real" @AppStorage("settings.general.confirmAlert") private var confirmAlert: Bool = false @Environment(\.colorScheme) var colorScheme @Binding var showPopover: Bool @Binding var search: String @State private var searchZ: String = "" @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 = "" var body: some View { VStack(alignment: .center) { if appState.showProgress { VStack { Group { Spacer() HStack(spacing: 10) { Text("Searching the file system").font(.title3) .foregroundStyle(.primary.opacity(0.5)) ProgressView().controlSize(.small) } Spacer() } .transition(.opacity) } .padding(50) .transition(.opacity) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { // Titlebar HStack(spacing: 0) { Spacer() if mini || menubarEnabled { Button("Close") { updateOnMain { appState.appInfo = AppInfo.empty search = "" appState.currentView = .apps showPopover = false } } .buttonStyle(SimpleButtonStyle(icon: "x.circle", iconFlip: "x.circle.fill", help: String(localized: "Close"))) } } .padding(.top, (mini || menubarEnabled) ? 6 : 30) .padding(.trailing, (mini || menubarEnabled) ? 6 : 0) VStack(spacing: 0) { // Main Group HStack(alignment: .center) { VStack(alignment: .leading, spacing: 5){ PearGroupBox(header: { HStack(alignment: .center, spacing: 15) { Image(systemName: "doc.badge.clock.fill") .resizable() .scaledToFit() .frame(width: 50, height: 50) .symbolRenderingMode(.hierarchical) VStack(alignment: .leading){ Text("Orphaned Files").font(.title).fontWeight(.bold) Text("Remaining files and folders from previous applications") .font(.callout).foregroundStyle(.primary.opacity(0.5)) } } }, content: { HStack(spacing: 20) { VStack(alignment: .leading, spacing: 5) { Text("Total size of files:") .font(.callout).fontWeight(.bold) Text("") .font(.footnote) } Spacer() VStack(alignment: .trailing, spacing: 5) { Text("\(displaySizeTotal)").font(.callout).fontWeight(.bold)//.help("Total size on disk") Text("\(selectedZombieItemsLocal.count) \(String(localized: "of")) \(searchZ.isEmpty ? appState.zombieFile.fileSize.count : memoizedFiles.count) \(appState.zombieFile.fileSize.count == 1 ? "\(String(localized: "item"))" : "\(String(localized: "items"))")") .font(.footnote).foregroundStyle(.secondary) } } }) } .padding(.horizontal, 20) .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 { 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, 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: "line.3.horizontal.decrease.circle", label: String(localized: selectedSortAlpha ? "Name" : "Size"), help: String(localized: selectedSortAlpha ? "Sorted alphabetically" : "Sorted by size"), size: 16)) } .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, memoizedFiles: $memoizedFiles, isSelected: self.binding(for: file)) .padding(.vertical, 5) } } } } .padding() } Spacer() HStack() { 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) Button("Rescan") { updateOnMain { appState.zombieFile = .empty appState.showProgress.toggle() reversePreloader(allApps: appState.sortedApps, appState: appState, locations: locations, fsm: fsm) } } .buttonStyle(RescanButton()) Button("\(sizeType == "Logical" ? totalLogicalSizeUninstallBtn : totalRealSizeUninstallBtn)") { showCustomAlert(enabled: confirmAlert, title: String(localized: "Warning"), message: String(localized: "Are you sure you want to remove these files?"), style: .warning, onOk: { Task { let selectedItemsArray = Array(selectedZombieItemsLocal) moveFilesToTrash(appState: appState, at: selectedItemsArray) { success in guard success else { return } if selectedZombieItemsLocal.count == appState.zombieFile.fileSize.keys.count { updateOnMain { appState.zombieFile = .empty search = "" searchZ = "" withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { if mini || menubarEnabled { appState.currentView = .apps showPopover = false } else { appState.currentView = .empty } } } } else { // Remove items from the list appState.zombieFile.fileSize = appState.zombieFile.fileSize.filter { !selectedZombieItemsLocal.contains($0.key) } appState.zombieFile.fileSizeLogical = appState.zombieFile.fileSizeLogical.filter { !selectedZombieItemsLocal.contains($0.key) } appState.zombieFile.fileIcon = appState.zombieFile.fileIcon.filter { !selectedZombieItemsLocal.contains($0.key) } // Clear the selection selectedZombieItemsLocal.removeAll() // Update memoized files and total sizes updateMemoizedFiles(for: searchZ, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha, force: true) } } } }) } .buttonStyle(UninstallButton(isEnabled: !selectedZombieItemsLocal.isEmpty)) .disabled(selectedZombieItemsLocal.isEmpty) } .padding(.top) } .transition(.opacity) .padding([.horizontal, .bottom], 20) .padding(.top, !mini ? 10 : 0) .onAppear { updateMemoizedFiles(for: searchZ, sizeType: sizeType, selectedSortAlpha: selectedSortAlpha, force: true) } .sheet(isPresented: $showAlert, content: { VStack(spacing: 10) { Text("Important") .font(.headline) Divider() Spacer() Text("Orphaned 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.") .font(.subheadline) Spacer() Button("Close") { warning = true showAlert = false } .buttonStyle(SimpleButtonStyle(icon: "x.circle.fill", label: String(localized: "Close"), help: String(localized: "Dismiss"))) Spacer() } .padding(15) .frame(width: 400, height: 250) .background(GlassEffect(material: .hudWindow, blendingMode: .behindWindow)) }) } } .onAppear { if !warning { showAlert = 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" ? 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 } private var displaySizeText: String { switch sizeType { case "Logical": return totalLogicalSizeUninstallBtn case "Real": return totalRealSizeUninstallBtn 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))" } } } struct ZombieFileDetailsItem: View { @EnvironmentObject var appState: AppState @EnvironmentObject var fsm: FolderSettingsManager @State private var isHovered = false @AppStorage("settings.general.sizeType") var sizeType: String = "Real" @AppStorage("settings.interface.animationEnabled") private var animationEnabled: Bool = true let size: Int64? let sizeL: Int64? let icon: Image? let path: URL @Binding var memoizedFiles: [URL] @Binding var isSelected: Bool var body: some View { HStack(alignment: .center, spacing: 20) { Toggle("", isOn: $isSelected) .toggleStyle(SimpleCheckboxToggleStyle()) if let appIcon = icon { appIcon .resizable() .aspectRatio(contentMode: .fit) .frame(width: 30, height: 30) .clipShape(RoundedRectangle(cornerRadius: 8)) } VStack(alignment: .leading, spacing: 5) { HStack(alignment: .center) { Text(showLocalized(url: path)) .font(.title3) .lineLimit(1) .truncationMode(.tail) .help(path.lastPathComponent) .overlay{ if (isHovered) { VStack { Spacer() RoundedRectangle(cornerRadius: 10) .fill(.primary.opacity(0.5)) .frame(height: 1.5) .offset(y: 3) } } } if let imageView = folderImages(for: path.path) { imageView } } Text(path.path) .font(.footnote) .lineLimit(1) .truncationMode(.tail) .opacity(0.5) .help(path.path) } .onHover { hovering in withAnimation(Animation.easeInOut(duration: animationEnabled ? 0.35 : 0)) { self.isHovered = hovering } } .onTapGesture { NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: path.deletingLastPathComponent().path) } Spacer() let displaySize = sizeType == "Real" ? formatByte(size: size!).human : formatByte(size: sizeL!).human Text("\(displaySize)") } .contextMenu { if path.pathExtension == "app" { Button("Open \(path.deletingPathExtension().lastPathComponent)") { NSWorkspace.shared.open(path) } Divider() } Button("Copy Path") { copyToClipboard(text: path.path) } Button("View in Finder") { NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: path.deletingLastPathComponent().path) } Divider() Button("Exclude") { fsm.addPathZ(path.path) memoizedFiles.removeAll { $0 == path } } .help("This adds the file/folder to the Exclusions list. Edit the exclusions list from Settings > Folders tab") } } }