// // FileItem.swift // MUA // // Created by Mitchel Volkering on 21/12/2025. // import Foundation import SwiftUI /// Represents a file or folder in the disk analysis /// Using nonisolated(unsafe) to allow creation and mutation from any thread /// This is safe because we only mutate during scanning (single-threaded per item) /// and then only read from the main thread after scanning completes final class FileItem: Identifiable, Hashable, @unchecked Sendable { let id = UUID() let url: URL let name: String let isDirectory: Bool nonisolated(unsafe) var size: Int64 nonisolated(unsafe) var children: [FileItem] nonisolated(unsafe) weak var parent: FileItem? /// Color for visualization - assigned based on file type or folder depth var color: Color { if isDirectory { return folderColor } return fileTypeColor } private var folderColor: Color { let colors: [Color] = [ .blue, .purple, .pink, .orange, .yellow, .green, .teal, .cyan, .indigo, .mint ] let hash = abs(name.hashValue) return colors[hash % colors.count] } private var fileTypeColor: Color { let ext = url.pathExtension.lowercased() switch ext { case "app", "dmg", "pkg": return .purple case "jpg", "jpeg", "png", "gif", "heic", "raw", "svg": return .pink case "mp4", "mov", "avi", "mkv", "m4v": return .red case "mp3", "wav", "aac", "flac", "m4a": return .orange case "zip", "tar", "gz", "rar", "7z": return .yellow case "pdf", "doc", "docx", "txt", "rtf": return .blue case "swift", "js", "py", "html", "css", "json": return .green case "xls", "xlsx", "csv", "numbers": return .teal default: return .gray } } /// Formatted size string var formattedSize: String { ByteCountFormatter.string(fromByteCount: size, countStyle: .file) } /// Percentage of parent's size func percentageOfParent() -> Double { guard let parent = parent, parent.size > 0 else { return 100 } return (Double(size) / Double(parent.size)) * 100 } /// System icon for file type var icon: String { if isDirectory { return "folder.fill" } let ext = url.pathExtension.lowercased() switch ext { case "app": return "app.fill" case "dmg", "pkg": return "shippingbox.fill" case "jpg", "jpeg", "png", "gif", "heic", "raw", "svg": return "photo.fill" case "mp4", "mov", "avi", "mkv", "m4v": return "film.fill" case "mp3", "wav", "aac", "flac", "m4a": return "music.note" case "zip", "tar", "gz", "rar", "7z": return "doc.zipper" case "pdf": return "doc.richtext.fill" case "doc", "docx", "txt", "rtf": return "doc.text.fill" case "swift", "js", "py", "html", "css", "json": return "chevron.left.forwardslash.chevron.right" case "xls", "xlsx", "csv", "numbers": return "tablecells.fill" default: return "doc.fill" } } nonisolated init(url: URL, isDirectory: Bool, size: Int64 = 0, children: [FileItem] = [], parent: FileItem? = nil) { self.url = url self.name = url.lastPathComponent self.isDirectory = isDirectory self.size = size self.children = children self.parent = parent } static func == (lhs: FileItem, rhs: FileItem) -> Bool { lhs.id == rhs.id } func hash(into hasher: inout Hasher) { hasher.combine(id) } /// Get top N children by size func topChildren(count: Int = 10) -> [FileItem] { children.sorted { $0.size > $1.size }.prefix(count).map { $0 } } /// Calculate total count of all descendants var descendantCount: Int { if !isDirectory { return 0 } return children.reduce(0) { $0 + 1 + $1.descendantCount } } }