diff --git a/Sources/SpaceMatters/App/HeadlessScan.swift b/Sources/SpaceMatters/App/HeadlessScan.swift index 87a7a42..3b90939 100644 --- a/Sources/SpaceMatters/App/HeadlessScan.swift +++ b/Sources/SpaceMatters/App/HeadlessScan.swift @@ -46,6 +46,8 @@ enum HeadlessScan { let logical = root.aggLogical.load(ordering: .relaxed) let physical = root.aggPhysical.load(ordering: .relaxed) + let sparse = root.aggSparseExcess.load(ordering: .relaxed) + let compressed = root.aggCompressedExcess.load(ordering: .relaxed) let files = root.fileCount.load(ordering: .relaxed) let dirs = scanner.dirCount.load(ordering: .relaxed) let errors = scanner.errorCount.load(ordering: .relaxed) @@ -53,8 +55,10 @@ enum HeadlessScan { print("roots: \(paths.count)") print("files: \(files)") print("dirs: \(dirs)") - print("logical: \(logical) bytes (\(Format.bytes(logical)))") - print("physical: \(physical) bytes (\(Format.bytes(physical)))") + print("on-disk: \(physical) bytes (\(Format.bytes(physical)))") + print("apparent: \(logical) bytes (\(Format.bytes(logical)))") + if sparse > 0 { print("sparse: \(sparse) bytes not allocated (\(Format.bytes(sparse)))") } + if compressed > 0 { print("compressed:\(compressed) bytes saved (\(Format.bytes(compressed)))") } print("errors: \(errors)") print(String(format: "elapsed: %.3f s (%.0f files/s)", elapsed, Double(files) / max(elapsed, 0.001))) @@ -67,7 +71,7 @@ enum HeadlessScan { } } - let top = scanner.snapshotExtensions(metric: .physical, limit: 8) + let top = scanner.snapshotExtensions(limit: 8) if !top.isEmpty { print("top types:") for row in top { @@ -119,12 +123,12 @@ enum HeadlessScan { } } - print(String(format: "DONE in %.2fs — dirs=%d files=%d on-disk=%@ logical=%@", + print(String(format: "DONE in %.2fs — dirs=%d files=%d on-disk=%@ apparent=%@", Date().timeIntervalSince(start), scanner.directoryCount, root.fileCount.load(ordering: .relaxed), Format.bytes(root.aggPhysical.load(ordering: .relaxed)), Format.bytes(root.aggLogical.load(ordering: .relaxed)))) - for row in scanner.snapshotExtensions(metric: .physical, limit: 6) { + for row in scanner.snapshotExtensions(limit: 6) { print(" \(row.name): \(Format.bytes(row.physical)) (\(Format.count(row.count)) files)") } if let failure = scanner.failure { diff --git a/Sources/SpaceMatters/Model/FSNode.swift b/Sources/SpaceMatters/Model/FSNode.swift index f1c2f9c..49b3e82 100644 --- a/Sources/SpaceMatters/Model/FSNode.swift +++ b/Sources/SpaceMatters/Model/FSNode.swift @@ -26,6 +26,12 @@ final class FSNode { let aggLogical = Atomic(0) let aggPhysical = Atomic(0) let fileCount = Atomic(0) + /// Classified apparent-vs-on-disk gap for the subtree: bytes of content + /// length not backed by blocks in sparse files, and same for compressed + /// files. Explains *why* `aggLogical` dwarfs `aggPhysical` where it does — + /// sizes are always displayed on-disk; the gap surfaces as an annotation. + let aggSparseExcess = Atomic(0) + let aggCompressedExcess = Atomic(0) /// This directory's own immediate files. Atomic so the UI can read live /// totals at refresh rate while a worker is still filling them in — a node is @@ -33,10 +39,14 @@ final class FSNode { private let _directFilesLogical = Atomic(0) private let _directFilesPhysical = Atomic(0) private let _directFileCount = Atomic(0) + private let _directSparseExcess = Atomic(0) + private let _directCompressedExcess = Atomic(0) var directFilesLogical: Int64 { _directFilesLogical.load(ordering: .relaxed) } var directFilesPhysical: Int64 { _directFilesPhysical.load(ordering: .relaxed) } var directFileCount: Int64 { _directFileCount.load(ordering: .relaxed) } + var directSparseExcess: Int64 { _directSparseExcess.load(ordering: .relaxed) } + var directCompressedExcess: Int64 { _directCompressedExcess.load(ordering: .relaxed) } /// Extension that accounts for the most bytes among this directory's direct /// files — used to colour the treemap by file type. Guarded by `gTreeLock`: @@ -94,10 +104,13 @@ final class FSNode { /// Adjust this directory's own-file totals (after a file was removed). Single /// writer (MainActor post-scan, or the streamed reader thread), so a plain /// load/store on the atomics is race-free against the UI's atomic reads. - func adjustDirectFiles(logical: Int64, physical: Int64, count: Int64) { + func adjustDirectFiles(logical: Int64, physical: Int64, count: Int64, + sparseExcess: Int64 = 0, compressedExcess: Int64 = 0) { _directFilesLogical.store(max(0, directFilesLogical + logical), ordering: .relaxed) _directFilesPhysical.store(max(0, directFilesPhysical + physical), ordering: .relaxed) _directFileCount.store(max(0, directFileCount + count), ordering: .relaxed) + _directSparseExcess.store(max(0, directSparseExcess + sparseExcess), ordering: .relaxed) + _directCompressedExcess.store(max(0, directCompressedExcess + compressedExcess), ordering: .relaxed) } /// Zero this node's subtree aggregates ahead of an in-place re-scan (SPEC-02 @@ -106,6 +119,8 @@ final class FSNode { aggLogical.store(0, ordering: .relaxed) aggPhysical.store(0, ordering: .relaxed) fileCount.store(0, ordering: .relaxed) + aggSparseExcess.store(0, ordering: .relaxed) + aggCompressedExcess.store(0, ordering: .relaxed) } /// Called once by the owning worker after a directory's entries are read. @@ -114,11 +129,15 @@ final class FSNode { filesLogical: Int64, filesPhysical: Int64, fileCount: Int64, - dominantExt: ExtKey = .none + dominantExt: ExtKey = .none, + sparseExcess: Int64 = 0, + compressedExcess: Int64 = 0 ) { _directFilesLogical.store(filesLogical, ordering: .relaxed) _directFilesPhysical.store(filesPhysical, ordering: .relaxed) _directFileCount.store(fileCount, ordering: .relaxed) + _directSparseExcess.store(sparseExcess, ordering: .relaxed) + _directCompressedExcess.store(compressedExcess, ordering: .relaxed) gTreeLock.lock() _dominantExt = dominantExt _children = children @@ -128,20 +147,23 @@ final class FSNode { // MARK: Convenience + /// The size this app renders everywhere: allocated blocks — what deleting + /// frees, what fills the disk. (The apparent size is annotation-only.) @inline(__always) - func size(_ metric: SizeMetric) -> Int64 { - switch metric { - case .logical: return aggLogical.load(ordering: .relaxed) - case .physical: return aggPhysical.load(ordering: .relaxed) - } - } + var sizeOnDisk: Int64 { aggPhysical.load(ordering: .relaxed) } + /// Content length (`st_size` sum) — what a copy, upload or non-sparse-aware + /// backup of this subtree would write. Shown only where it notably diverges. @inline(__always) - func directFilesSize(_ metric: SizeMetric) -> Int64 { - switch metric { - case .logical: return directFilesLogical - case .physical: return directFilesPhysical - } + var sizeApparent: Int64 { aggLogical.load(ordering: .relaxed) } + + /// Non-nil when this subtree's apparent size notably exceeds its footprint + /// (sparse or compressed content) — drives the divergence badge. + var divergence: SizeDivergence? { + SizeDivergence.notable( + onDisk: sizeOnDisk, apparent: sizeApparent, + sparseExcess: aggSparseExcess.load(ordering: .relaxed), + compressedExcess: aggCompressedExcess.load(ordering: .relaxed)) } } @@ -154,13 +176,64 @@ extension FSNode: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } } -/// Which size to report. Physical (on-disk allocated) matches "space used" the -/// way WinDirStats does; logical is the byte length of the content. -enum SizeMetric: String, CaseIterable, Identifiable { - case physical - case logical - var id: String { rawValue } - var label: String { self == .physical ? "On disk" : "Logical" } +/// The gap between an item's *apparent* size (content length — the Finder's +/// "Size", what a copy/upload/backup writes) and its *on-disk* footprint +/// (allocated blocks — what deleting frees). +/// +/// The two split when files are sparse (VM/container disk images: huge declared +/// length, few real blocks) or transparently compressed. A 512 GiB sparse image +/// occupying 75 MiB is correct data that *reads* as a bug — so the app renders +/// on-disk sizes everywhere and explains the gap in place, where it's notable, +/// instead of offering a global "Logical" mode that re-weighted the whole map +/// with un-actionable numbers. +struct SizeDivergence: Equatable { + let onDisk: Int64 + let apparent: Int64 + /// Apparent bytes not backed by blocks, split by cause. May not sum to the + /// whole gap: streamed scans can't classify, and block padding offsets it. + let sparseExcess: Int64 + let compressedExcess: Int64 + + /// Badge threshold: the apparent size must dwarf the footprint enough to + /// mislead (≥ 1.5× and ≥ 8 MiB over). Block-padding noise never qualifies — + /// padding makes on-disk the *larger* of the two. + static let minDelta: Int64 = 8 << 20 + + static func notable(onDisk: Int64, apparent: Int64, + sparseExcess: Int64, compressedExcess: Int64) -> SizeDivergence? { + guard apparent - onDisk >= minDelta, apparent >= onDisk + onDisk / 2 else { return nil } + return SizeDivergence(onDisk: onDisk, apparent: apparent, + sparseExcess: sparseExcess, compressedExcess: compressedExcess) + } + + /// Dominant cause(s) — one is named when it explains ≥ 25% of the gap. + private var causes: (sparse: Bool, compressed: Bool) { + let gap = max(1, apparent - onDisk) + return (sparseExcess * 4 >= gap, compressedExcess * 4 >= gap) + } + + var label: String { + switch causes { + case (true, true): return "sparse + compressed" + case (true, false): return "sparse" + case (false, true): return "compressed" + case (false, false): return "sparse or compressed" + } + } + + /// Tooltip text: both figures plus what the gap means for the user. + var summary: String { + var s = "\(Format.bytes(onDisk)) on disk · \(Format.bytes(apparent)) apparent." + switch causes { + case (true, false), (true, true): + s += "\nSparse content (disk image, preallocated file): space is allocated on demand. A copy, upload or non-sparse backup can balloon to the apparent size." + case (false, true): + s += "\nCompressed by the filesystem: the content is larger than the space it occupies." + case (false, false): + s += "\nThe content is larger than the space it occupies (sparse or compressed files)." + } + return s + } } /// How shared storage is counted. **Attribution** (default) blames every hardlink diff --git a/Sources/SpaceMatters/Scanner/CommandScanner.swift b/Sources/SpaceMatters/Scanner/CommandScanner.swift index 25a368c..1775849 100644 --- a/Sources/SpaceMatters/Scanner/CommandScanner.swift +++ b/Sources/SpaceMatters/Scanner/CommandScanner.swift @@ -105,7 +105,7 @@ final class CommandScanner: ScanBackend { var directoryCount: Int64 { dirCountAtomic.load(ordering: .relaxed) } var scanErrorCount: Int64 { errAtomic.load(ordering: .relaxed) } - func snapshotExtensions(metric: SizeMetric, limit: Int) -> [ExtRow] { + func snapshotExtensions(limit: Int) -> [ExtRow] { extLock.lock() let copy = extStats extLock.unlock() @@ -113,7 +113,7 @@ final class CommandScanner: ScanBackend { var rows = copy.map { key, stat in ExtRow(key: key, name: key.displayName, logical: stat.logical, physical: stat.physical, count: stat.count) } - rows.sort { metric == .physical ? $0.physical > $1.physical : $0.logical > $1.logical } + rows.sort { $0.physical > $1.physical } if rows.count > limit { rows.removeLast(rows.count - limit) } return rows } @@ -222,6 +222,10 @@ final class CommandScanner: ScanBackend { if parent !== accParent { flushAccumulator() } accParent = parent accLogical += logical; accPhysical += physical; accCount += 1 + // `find` reports no BSD flags, but fewer blocks than bytes means + // holes — the Docker.raw / VM disk-image case this stream is full of. + // (ext4 has no transparent compression to mistake it for.) + if physical < logical { accSparseExcess += logical - physical } let key = ExtKey(fileName: name) extLock.lock() @@ -242,18 +246,21 @@ final class CommandScanner: ScanBackend { private var accLogical: Int64 = 0 private var accPhysical: Int64 = 0 private var accCount: Int64 = 0 + private var accSparseExcess: Int64 = 0 private func flushAccumulator() { guard let parent = accParent, accCount > 0 else { accParent = nil; return } - parent.adjustDirectFiles(logical: accLogical, physical: accPhysical, count: accCount) + parent.adjustDirectFiles(logical: accLogical, physical: accPhysical, count: accCount, + sparseExcess: accSparseExcess) var node: FSNode? = parent while let cur = node { cur.aggLogical.wrappingAdd(accLogical, ordering: .relaxed) cur.aggPhysical.wrappingAdd(accPhysical, ordering: .relaxed) cur.fileCount.wrappingAdd(accCount, ordering: .relaxed) + if accSparseExcess != 0 { cur.aggSparseExcess.wrappingAdd(accSparseExcess, ordering: .relaxed) } node = cur.parent } - accParent = nil; accLogical = 0; accPhysical = 0; accCount = 0 + accParent = nil; accLogical = 0; accPhysical = 0; accCount = 0; accSparseExcess = 0 } /// `nil` on Int64 overflow (≥ 20 digits) — the caller drops the record. diff --git a/Sources/SpaceMatters/Scanner/DirectoryScanner.swift b/Sources/SpaceMatters/Scanner/DirectoryScanner.swift index 6c707ab..39faeb6 100644 --- a/Sources/SpaceMatters/Scanner/DirectoryScanner.swift +++ b/Sources/SpaceMatters/Scanner/DirectoryScanner.swift @@ -171,8 +171,8 @@ final class DirectoryScanner: ScanBackend { extLock.unlock() } - /// Snapshot of the top `limit` extensions by `metric`, materialised as rows. - func snapshotExtensions(metric: SizeMetric, limit: Int) -> [ExtRow] { + /// Snapshot of the top `limit` extensions by on-disk size, materialised as rows. + func snapshotExtensions(limit: Int) -> [ExtRow] { extLock.lock() let copy = extStats extLock.unlock() @@ -180,9 +180,7 @@ final class DirectoryScanner: ScanBackend { var rows = copy.map { key, stat in ExtRow(key: key, name: key.displayName, logical: stat.logical, physical: stat.physical, count: stat.count) } - rows.sort { a, b in - metric == .physical ? a.physical > b.physical : a.logical > b.logical - } + rows.sort { $0.physical > $1.physical } if rows.count > limit { rows.removeLast(rows.count - limit) } return rows } @@ -298,6 +296,8 @@ final class DirectoryScanner: ScanBackend { var filesLogical: Int64 = 0 var filesPhysical: Int64 = 0 var fileCount: Int64 = 0 + var sparseExcess: Int64 = 0 + var compressedExcess: Int64 = 0 var localExt: [ExtKey: ExtStat] = [:] // Avoid a double slash when the parent is "/" so paths match skip entries. @@ -320,6 +320,7 @@ final class DirectoryScanner: ScanBackend { } else { var effL = entry.logicalSize var effP = entry.physicalSize + var effExcess = entry.apparentExcess // Exact mode: count a multi-linked inode's blocks only on its first // sighting; later hardlinks contribute 0 bytes (still 1 file entry). if exact && entry.linkCount > 1 { @@ -327,10 +328,12 @@ final class DirectoryScanner: ScanBackend { let firstSighting = seenInodes .insert(InodeKey(dev: entry.deviceID, ino: entry.fileID)).inserted linkLock.unlock() - if !firstSighting { effL = 0; effP = 0 } + if !firstSighting { effL = 0; effP = 0; effExcess = 0 } } filesLogical += effL filesPhysical += effP + if entry.isSparse { sparseExcess += effExcess } + else if entry.isCompressed { compressedExcess += effExcess } fileCount += 1 let key = ExtKey(name: entry.name, length: entry.nameLength) localExt[key, default: ExtStat()].add(logical: effL, physical: effP) @@ -356,7 +359,9 @@ final class DirectoryScanner: ScanBackend { filesLogical: filesLogical, filesPhysical: filesPhysical, fileCount: fileCount, - dominantExt: dominantExt + dominantExt: dominantExt, + sparseExcess: sparseExcess, + compressedExcess: compressedExcess ) // Propagate this directory's direct-file totals to itself and every @@ -368,6 +373,8 @@ final class DirectoryScanner: ScanBackend { n.aggLogical.wrappingAdd(filesLogical, ordering: .relaxed) n.aggPhysical.wrappingAdd(filesPhysical, ordering: .relaxed) n.fileCount.wrappingAdd(fileCount, ordering: .relaxed) + if sparseExcess != 0 { n.aggSparseExcess.wrappingAdd(sparseExcess, ordering: .relaxed) } + if compressedExcess != 0 { n.aggCompressedExcess.wrappingAdd(compressedExcess, ordering: .relaxed) } node = n.parent } } @@ -394,7 +401,10 @@ struct ExtRow: Identifiable { let count: Int64 var id: ExtKey { key } - func size(_ metric: SizeMetric) -> Int64 { - metric == .physical ? physical : logical + /// Non-nil when this type's apparent bytes dwarf its footprint (the .ext4 / + /// .raw disk-image case) — surfaced as a tooltip, sizes stay on-disk. + /// Cause split isn't tracked per extension; the generic wording covers it. + var divergence: SizeDivergence? { + SizeDivergence.notable(onDisk: physical, apparent: logical, sparseExcess: 0, compressedExcess: 0) } } diff --git a/Sources/SpaceMatters/Scanner/FSAttr.swift b/Sources/SpaceMatters/Scanner/FSAttr.swift index e6f929e..6dbfeb1 100644 --- a/Sources/SpaceMatters/Scanner/FSAttr.swift +++ b/Sources/SpaceMatters/Scanner/FSAttr.swift @@ -12,9 +12,16 @@ enum FSAttr { static let cmnName: UInt32 = 0x0000_0001 static let cmnDevID: UInt32 = 0x0000_0002 // dev_t, exact mode (inode dedup key) static let cmnObjType: UInt32 = 0x0000_0008 + static let cmnFlags: UInt32 = 0x0004_0000 // BSD st_flags (u_int32) — UF_COMPRESSED lives here static let cmnFileID: UInt32 = 0x0200_0000 // inode number (u_int64), exact mode static let cmnError: UInt32 = 0x2000_0000 + // BSD file flags (sys/stat.h) + /// File data is transparently compressed by the filesystem (AFSC/decmpfs). + /// Distinguishes "smaller on disk because compressed" from "smaller on disk + /// because sparse" — the two causes call for different user guidance. + static let ufCompressed: UInt32 = 0x0000_0020 + // Directory attribute bits static let dirMountStatus: UInt32 = 0x0000_0004 // ATTR_DIR_MOUNTSTATUS flags @@ -42,6 +49,9 @@ struct BulkEntry { let isMountPoint: Bool let logicalSize: Int64 let physicalSize: Int64 + /// BSD `st_flags` of the entry (0 when the filesystem doesn't report them, + /// e.g. some network mounts) — used to tell compressed apart from sparse. + let flags: UInt32 /// Inode number — only populated when the enumerator is `hardlinkAware` /// (exact counting mode); `0` otherwise. let fileID: UInt64 @@ -53,6 +63,21 @@ struct BulkEntry { /// Hardlink count — `0` unless `hardlinkAware`. `> 1` marks a file whose /// blocks are shared by several directory entries (dedup candidate). let linkCount: UInt32 + + /// Content stored compressed (APFS/HFS+ transparent compression): the + /// apparent size exceeds the footprint because the data shrank. + @inline(__always) + var isCompressed: Bool { flags & FSAttr.ufCompressed != 0 } + + /// Holes: fewer blocks allocated than the content length (VM/container disk + /// images, database preallocations…). Never true for block-padded regular + /// files — padding makes the physical size *larger*, not smaller. + @inline(__always) + var isSparse: Bool { !isCompressed && physicalSize < logicalSize } + + /// Bytes of apparent size not backed by disk blocks (0 for ordinary files). + @inline(__always) + var apparentExcess: Int64 { max(0, logicalSize - physicalSize) } } /// Enumerate every direct child of an open directory file descriptor, invoking @@ -68,7 +93,8 @@ func enumerateDirectory( ) -> Int { var attrList = attrlist() attrList.bitmapcount = 5 // ATTR_BIT_MAP_COUNT - attrList.commonattr = FSAttr.cmnReturnedAttrs | FSAttr.cmnName | FSAttr.cmnObjType | FSAttr.cmnError + attrList.commonattr = FSAttr.cmnReturnedAttrs | FSAttr.cmnName | FSAttr.cmnObjType + | FSAttr.cmnFlags | FSAttr.cmnError attrList.dirattr = FSAttr.dirMountStatus attrList.fileattr = FSAttr.fileTotalSize | FSAttr.fileAllocSize // Exact counting mode also needs the inode + link count to dedup hardlinks. @@ -139,6 +165,15 @@ func enumerateDirectory( off += 4 } + // ATTR_CMN_FLAGS (0x00040000) packs after OBJTYPE (0x08), before + // FILEID (0x02000000) — ascending-bit order. Gated on the returned + // mask: a filesystem that can't report flags just yields 0. + var flags: UInt32 = 0 + if commonReturned & FSAttr.cmnFlags != 0 { + flags = entry.loadUnaligned(fromByteOffset: off, as: UInt32.self) + off += 4 + } + // ATTR_CMN_FILEID (0x02000000) sorts after ATTR_CMN_OBJTYPE (0x08) in // the buffer's ascending-bit packing order. Present only in exact mode. var fileID: UInt64 = 0 @@ -186,6 +221,7 @@ func enumerateDirectory( isMountPoint: isMountPoint, logicalSize: logical, physicalSize: physical, + flags: flags, fileID: fileID, deviceID: deviceID, linkCount: linkCount diff --git a/Sources/SpaceMatters/Scanner/ScanBackend.swift b/Sources/SpaceMatters/Scanner/ScanBackend.swift index ac5e422..738ff7e 100644 --- a/Sources/SpaceMatters/Scanner/ScanBackend.swift +++ b/Sources/SpaceMatters/Scanner/ScanBackend.swift @@ -39,7 +39,7 @@ protocol ScanBackend: AnyObject { /// remote `find` missing, exited non-zero with no output). `nil` on success — /// per-entry permission errors are counted in `scanErrorCount` instead. var failure: String? { get } - func snapshotExtensions(metric: SizeMetric, limit: Int) -> [ExtRow] + func snapshotExtensions(limit: Int) -> [ExtRow] /// Subtract a deleted subtree's per-extension contribution from the live /// File-types table (A6). No-op for backends that don't support deletion. func subtractExtensions(_ delta: [ExtKey: ExtStat]) diff --git a/Sources/SpaceMatters/ViewModel/ScanController.swift b/Sources/SpaceMatters/ViewModel/ScanController.swift index 33e7994..6264238 100644 --- a/Sources/SpaceMatters/ViewModel/ScanController.swift +++ b/Sources/SpaceMatters/ViewModel/ScanController.swift @@ -30,10 +30,6 @@ final class ScanController { private(set) var version: UInt64 = 0 private(set) var extRows: [ExtRow] = [] - var metric: SizeMetric = .physical { - didSet { if metric != oldValue { extRows = scanner?.snapshotExtensions(metric: metric, limit: 250) ?? []; bump() } } - } - /// Exact vs attribution counting. Dedup happens at scan time, so flipping this /// re-scans the same target with the new semantics (host scans only; VM scans /// are always attribution). @@ -293,7 +289,15 @@ final class ScanController { private var lastSampleCount: Int64 = 0 private var tickIndex = 0 - var totalSize: Int64 { metric == .physical ? totalPhysical : totalLogical } + /// Everything the app displays is on-disk (allocated) size — the number that + /// matches what deleting frees. The logical total survives only as the + /// "apparent" annotation (`totalDivergence`). + var totalSize: Int64 { totalPhysical } + + /// Root-level apparent-vs-on-disk gap (sparse VM images, compressed system + /// files) — the stats strip surfaces it so a huge apparent size is explained + /// instead of silently dropped. + var totalDivergence: SizeDivergence? { root?.divergence } var isScanning: Bool { phase == .scanning } @@ -530,7 +534,17 @@ final class ScanController { let name: String let logical: Int64 let physical: Int64 - func size(_ m: SizeMetric) -> Int64 { m == .physical ? physical : logical } + /// UF_COMPRESSED was set — with the two sizes, enough to classify. + let compressed: Bool + + var sparseExcess: Int64 { compressed ? 0 : max(0, logical - physical) } + var compressedExcess: Int64 { compressed ? max(0, logical - physical) : 0 } + /// Non-nil when this file's declared length dwarfs its footprint — the + /// 512 GiB sparse snapshot that occupies 75 MiB. + var divergence: SizeDivergence? { + SizeDivergence.notable(onDisk: physical, apparent: logical, + sparseExcess: sparseExcess, compressedExcess: compressedExcess) + } } enum RowID: Hashable { @@ -562,13 +576,12 @@ final class ScanController { // Caches (not observed). @ObservationIgnored private var sortCache: [ObjectIdentifier: [FSNode]] = [:] - @ObservationIgnored private var sortCacheMetric: SizeMetric = .physical /// Cached file listing per directory, tagged with the directory's direct-file /// count so it stays valid during a live scan and only re-enumerates the disk /// when that directory's contents actually changed. Items are kept sorted by - /// `metric` (tagged too): `visibleRows()` runs at 10 Hz during a scan and - /// must not re-sort every expanded folder's files on each evaluation. - private struct FileListing { let count: Int64; let metric: SizeMetric; let items: [FileItem] } + /// on-disk size: `visibleRows()` runs at 10 Hz during a scan and must not + /// re-sort every expanded folder's files on each evaluation. + private struct FileListing { let count: Int64; let items: [FileItem] } @ObservationIgnored private var fileCache: [ObjectIdentifier: FileListing] = [:] /// Directories whose file listing is being enumerated off-main right now /// (prevents duplicate walks while `visibleRows()` polls at 10 Hz). @@ -578,7 +591,7 @@ final class ScanController { @ObservationIgnored private var nodePaths: [ObjectIdentifier: String] = [:] /// Size-independent treemap structure (per-node sorted children/weights). Lives /// here, with the other model caches, so a window resize reuses it and only the - /// rect placement reruns per frame. Invalidated on (metric, version) inside. + /// rect placement reruns per frame. Invalidated on `version` inside. @ObservationIgnored private var layoutCache = TreemapLayout.Cache() private static let maxFilesPerFolder = 2000 @@ -621,29 +634,28 @@ final class ScanController { } } - /// Children sorted by current metric, cached after the scan so repeated - /// outline rebuilds are O(1) lookups, not sorts. /// Squarified treemap layout for `root` at `rect`. The size-independent /// structure (per-node sorted children + weights) is memoised across calls and /// reused on resize — only the rect placement reruns per frame. Same output as - /// a fresh `TreemapLayout.compute`; the cache tracks the tree via (metric, - /// version), like `sortCache`/`fileCache`. + /// a fresh `TreemapLayout.compute`; the cache tracks the tree via `version`, + /// like `sortCache`/`fileCache`. func treemapLayout(root: FSNode, rect: CGRect, rootFiles: [FileTileInfo]?, needsRegions: Bool = true) -> TreemapLayout.Result { - layoutCache.invalidate(metric: metric, version: version, root: root) - return TreemapLayout.compute(root: root, rect: rect, metric: metric, rootFiles: rootFiles, + layoutCache.invalidate(version: version, root: root) + return TreemapLayout.compute(root: root, rect: rect, rootFiles: rootFiles, cache: layoutCache, needsRegions: needsRegions) } + /// Children sorted by on-disk size, cached after the scan so repeated + /// outline rebuilds are O(1) lookups, not sorts. func sortedChildren(_ node: FSNode) -> [FSNode] { let children = node.children if phase == .scanning { - return children.sorted { $0.size(metric) > $1.size(metric) } + return children.sorted { $0.sizeOnDisk > $1.sizeOnDisk } } - if sortCacheMetric != metric { sortCache.removeAll(); sortCacheMetric = metric } let key = ObjectIdentifier(node) if let cached = sortCache[key], cached.count == children.count { return cached } - let sorted = children.sorted { $0.size(metric) > $1.size(metric) } + let sorted = children.sorted { $0.sizeOnDisk > $1.sizeOnDisk } sortCache[key] = sorted return sorted } @@ -658,37 +670,29 @@ final class ScanController { // Valid as long as the folder's file count is unchanged — true across the // 10 Hz refresh during a scan (a folder's files are set once, atomically) // and forever after it. - if let cached = fileCache[key], cached.count == count { - if cached.metric == metric { return cached.items } - // Metric flipped: re-sort the cached listing, no disk I/O. - let resorted = cached.items.sorted { $0.size(metric) > $1.size(metric) } - fileCache[key] = FileListing(count: count, metric: metric, items: resorted) - return resorted - } + if let cached = fileCache[key], cached.count == count { return cached.items } // Cache miss: enumerate off the main actor — a 100k-file folder or a slow // network volume must not beach-ball the UI — and repaint when it lands. // Callers get an empty placeholder for the frame or two it takes. guard count > 0, let dirPath = path(for: node) else { - fileCache[key] = FileListing(count: count, metric: metric, items: []) + fileCache[key] = FileListing(count: count, items: []) return [] } guard !fileListingsInFlight.contains(key) else { return [] } fileListingsInFlight.insert(key) let epoch = treeEpoch - let m = metric Task { @MainActor in var items = await Task.detached(priority: .userInitiated) { Self.enumerateFiles(dirPath: dirPath) }.value self.fileListingsInFlight.remove(key) guard epoch == self.treeEpoch else { return } + items.sort { $0.physical > $1.physical } if items.count > Self.maxFilesPerFolder { - items.sort { $0.physical > $1.physical } items.removeLast(items.count - Self.maxFilesPerFolder) } - items.sort { $0.size(m) > $1.size(m) } - self.fileCache[key] = FileListing(count: node.directFileCount, metric: m, items: items) + self.fileCache[key] = FileListing(count: node.directFileCount, items: items) self.bump() } return [] @@ -704,7 +708,8 @@ final class ScanController { enumerateDirectory(fd: fd, buffer: buf, bufferSize: bufSize) { entry in guard !entry.isDirectory else { return } let name = String(decoding: UnsafeRawBufferPointer(start: entry.name, count: entry.nameLength), as: UTF8.self) - items.append(FileItem(name: name, logical: entry.logicalSize, physical: entry.physicalSize)) + items.append(FileItem(name: name, logical: entry.logicalSize, physical: entry.physicalSize, + compressed: entry.isCompressed)) } buf.deallocate() close(fd) @@ -794,10 +799,10 @@ final class ScanController { out.append(OutlineRow( kind: .directory(node), depth: depth, siblingMax: siblingMax, isExpandable: false, isExpanded: !kids.isEmpty, id: .dir(ObjectIdentifier(node)))) - let childMax = max(kids.first?.size(metric) ?? 1, 1) + let childMax = max(kids.first?.sizeOnDisk ?? 1, 1) for kid in kids { walk(kid, depth: depth + 1, siblingMax: childMax) } } - walk(root, depth: 0, siblingMax: max(root.size(metric), 1)) + walk(root, depth: 0, siblingMax: max(root.sizeOnDisk, 1)) return out } @@ -818,19 +823,18 @@ final class ScanController { guard isOpen else { return } let kids = sortedChildren(node) - // Pre-sorted by the current metric (cached). Files at zero size *in the - // current metric* are dropped — a space tool has nothing to say about - // them (macOS marker files like `.localized`, empty locks…), and a 0 B - // on-disk file that still has logical bytes reappears in Logical mode. - // Sorted descending → the zero tail is a prefix cut, not a filter pass. - let files = filesIn(node).prefix(while: { $0.size(metric) > 0 }) - let childMax = max(kids.first?.size(metric) ?? 0, files.first?.size(metric) ?? 0, 1) + // Pre-sorted by on-disk size (cached). Files occupying zero blocks are + // dropped — a space tool has nothing to say about them (macOS marker + // files like `.localized`, empty locks…). Sorted descending → the zero + // tail is a prefix cut, not a filter pass. + let files = filesIn(node).prefix(while: { $0.physical > 0 }) + let childMax = max(kids.first?.sizeOnDisk ?? 0, files.first?.physical ?? 0, 1) // Merge folders + files by size, biggest first. var di = 0, fi = 0 while di < kids.count || fi < files.count { - let dSize = di < kids.count ? kids[di].size(metric) : -1 - let fSize = fi < files.count ? files[fi].size(metric) : -1 + let dSize = di < kids.count ? kids[di].sizeOnDisk : -1 + let fSize = fi < files.count ? files[fi].physical : -1 if dSize >= fSize { walk(kids[di], depth: depth + 1, siblingMax: childMax) di += 1 @@ -848,7 +852,7 @@ final class ScanController { } } } - walk(root, depth: 0, siblingMax: max(root.size(metric), 1)) + walk(root, depth: 0, siblingMax: max(root.sizeOnDisk, 1)) return out } @@ -931,7 +935,7 @@ final class ScanController { applyDirectoryRemoval(node) if !extDelta.isEmpty { scanner?.subtractExtensions(extDelta) - extRows = scanner?.snapshotExtensions(metric: metric, limit: 250) ?? extRows + extRows = scanner?.snapshotExtensions(limit: 250) ?? extRows } return true } @@ -940,11 +944,15 @@ final class ScanController { let logical = node.aggLogical.load(ordering: .relaxed) let physical = node.aggPhysical.load(ordering: .relaxed) let count = node.fileCount.load(ordering: .relaxed) + let sparseE = node.aggSparseExcess.load(ordering: .relaxed) + let compressedE = node.aggCompressedExcess.load(ordering: .relaxed) var p = node.parent while let a = p { a.aggLogical.wrappingAdd(-logical, ordering: .relaxed) a.aggPhysical.wrappingAdd(-physical, ordering: .relaxed) a.fileCount.wrappingAdd(-count, ordering: .relaxed) + if sparseE != 0 { a.aggSparseExcess.wrappingAdd(-sparseE, ordering: .relaxed) } + if compressedE != 0 { a.aggCompressedExcess.wrappingAdd(-compressedE, ordering: .relaxed) } p = a.parent } noteAppliedPhysical(-physical) @@ -1006,12 +1014,15 @@ final class ScanController { guard await Self.diskRemove(path: path, permanently: permanently) else { return false } guard epoch == treeEpoch else { return true } // deleted on disk; tree is gone - parent.adjustDirectFiles(logical: -file.logical, physical: -file.physical, count: -1) + parent.adjustDirectFiles(logical: -file.logical, physical: -file.physical, count: -1, + sparseExcess: -file.sparseExcess, compressedExcess: -file.compressedExcess) var p: FSNode? = parent while let a = p { a.aggLogical.wrappingAdd(-file.logical, ordering: .relaxed) a.aggPhysical.wrappingAdd(-file.physical, ordering: .relaxed) a.fileCount.wrappingAdd(-1, ordering: .relaxed) + if file.sparseExcess != 0 { a.aggSparseExcess.wrappingAdd(-file.sparseExcess, ordering: .relaxed) } + if file.compressedExcess != 0 { a.aggCompressedExcess.wrappingAdd(-file.compressedExcess, ordering: .relaxed) } p = a.parent } noteAppliedPhysical(-file.physical) @@ -1019,7 +1030,7 @@ final class ScanController { var d = ExtStat() d.add(logical: file.logical, physical: file.physical) scanner?.subtractExtensions([ExtKey(fileName: file.name): d]) - extRows = scanner?.snapshotExtensions(metric: metric, limit: 250) ?? extRows + extRows = scanner?.snapshotExtensions(limit: 250) ?? extRows fileCache[ObjectIdentifier(parent)] = nil // re-enumerate fresh next time sortCache.removeAll() @@ -1064,6 +1075,8 @@ final class ScanController { let oldL = node.aggLogical.load(ordering: .relaxed) let oldP = node.aggPhysical.load(ordering: .relaxed) let oldC = node.fileCount.load(ordering: .relaxed) + let oldSparseE = node.aggSparseExcess.load(ordering: .relaxed) + let oldCompressedE = node.aggCompressedExcess.load(ordering: .relaxed) let oldDirs = Self.subtreeDirCount(node) // The File-types reconciliation needs a *single-threaded* disk walk of // the subtree's old per-extension bytes. On a refresh rooted near the @@ -1102,6 +1115,8 @@ final class ScanController { n.aggLogical.wrappingAdd(-oldL, ordering: .relaxed) n.aggPhysical.wrappingAdd(-oldP, ordering: .relaxed) n.fileCount.wrappingAdd(-oldC, ordering: .relaxed) + if oldSparseE != 0 { n.aggSparseExcess.wrappingAdd(-oldSparseE, ordering: .relaxed) } + if oldCompressedE != 0 { n.aggCompressedExcess.wrappingAdd(-oldCompressedE, ordering: .relaxed) } a = n.parent } node.zeroAggregates() @@ -1150,7 +1165,7 @@ final class ScanController { sortCache.removeAll(); fileCache.removeAll() refreshTotals() - extRows = scanner?.snapshotExtensions(metric: metric, limit: 250) ?? extRows + extRows = scanner?.snapshotExtensions(limit: 250) ?? extRows bump() // The rebuilt nodes have fresh identities: an active search would keep // matching the freed objects and silently drop this subtree's hits. @@ -1503,6 +1518,8 @@ final class ScanController { var filesLogical: Int64 = 0 var filesPhysical: Int64 = 0 var fileCount: Int64 = 0 + var sparseExcess: Int64 = 0 + var compressedExcess: Int64 = 0 var dominantExt: ExtKey = .none /// Direct child directories, after the same skip rules as the scanner /// (skip paths, foreign mount points) — so a structural diff against the @@ -1549,8 +1566,11 @@ final class ScanController { snap.filesLogical += entry.logicalSize snap.filesPhysical += entry.physicalSize snap.fileCount += 1 + if entry.isSparse { snap.sparseExcess += entry.apparentExcess } + else if entry.isCompressed { snap.compressedExcess += entry.apparentExcess } if entry.linkCount > 1 { snap.sawHardlinks = true } - snap.items.append(FileItem(name: name, logical: entry.logicalSize, physical: entry.physicalSize)) + snap.items.append(FileItem(name: name, logical: entry.logicalSize, physical: entry.physicalSize, + compressed: entry.isCompressed)) let key = ExtKey(name: entry.name, length: entry.nameLength) snap.ext[key, default: ExtStat()].add(logical: entry.logicalSize, physical: entry.physicalSize) } @@ -1622,7 +1642,7 @@ final class ScanController { if let known = extSnapshots[childPath], child.childCount == 0, let ds = scanner as? DirectoryScanner { ds.subtractExtensions(known) - extRows = ds.snapshotExtensions(metric: metric, limit: 250) + extRows = ds.snapshotExtensions(limit: 250) } else if child.fileCount.load(ordering: .relaxed) > 0 { typesDrifted = true } @@ -1653,7 +1673,7 @@ final class ScanController { noteAppliedPhysical(addedPhysical) if let ds = scanner as? DirectoryScanner { ds.mergeExtensions(sub.snapshotRawExtensions()) - extRows = ds.snapshotExtensions(metric: metric, limit: 250) + extRows = ds.snapshotExtensions(limit: 250) } } @@ -1661,13 +1681,18 @@ final class ScanController { let dLogical = snap.filesLogical - node.directFilesLogical let dPhysical = snap.filesPhysical - node.directFilesPhysical let dCount = snap.fileCount - node.directFileCount - if dLogical != 0 || dPhysical != 0 || dCount != 0 { - node.adjustDirectFiles(logical: dLogical, physical: dPhysical, count: dCount) + let dSparseE = snap.sparseExcess - node.directSparseExcess + let dCompressedE = snap.compressedExcess - node.directCompressedExcess + if dLogical != 0 || dPhysical != 0 || dCount != 0 || dSparseE != 0 || dCompressedE != 0 { + node.adjustDirectFiles(logical: dLogical, physical: dPhysical, count: dCount, + sparseExcess: dSparseE, compressedExcess: dCompressedE) var n: FSNode? = node while let a = n { a.aggLogical.wrappingAdd(dLogical, ordering: .relaxed) a.aggPhysical.wrappingAdd(dPhysical, ordering: .relaxed) a.fileCount.wrappingAdd(dCount, ordering: .relaxed) + if dSparseE != 0 { a.aggSparseExcess.wrappingAdd(dSparseE, ordering: .relaxed) } + if dCompressedE != 0 { a.aggCompressedExcess.wrappingAdd(dCompressedE, ordering: .relaxed) } n = a.parent } noteAppliedPhysical(dPhysical) @@ -1682,7 +1707,7 @@ final class ScanController { if oldExt != snap.ext, let ds = scanner as? DirectoryScanner { ds.subtractExtensions(oldExt) ds.mergeExtensions(snap.ext) - extRows = ds.snapshotExtensions(metric: metric, limit: 250) + extRows = ds.snapshotExtensions(limit: 250) } } else if dLogical != 0 || dPhysical != 0 || dCount != 0 { typesDrifted = true @@ -1692,12 +1717,11 @@ final class ScanController { // The snapshot *is* the fresh listing — the outline follows for free. var items = snap.items + items.sort { $0.physical > $1.physical } if items.count > Self.maxFilesPerFolder { - items.sort { $0.physical > $1.physical } items.removeLast(items.count - Self.maxFilesPerFolder) } - items.sort { $0.size(metric) > $1.size(metric) } - fileCache[ObjectIdentifier(node)] = FileListing(count: snap.fileCount, metric: metric, items: items) + fileCache[ObjectIdentifier(node)] = FileListing(count: snap.fileCount, items: items) refreshTotals() bump() @@ -1851,7 +1875,7 @@ final class ScanController { // often than the size refresh. tickIndex += 1 if final || tickIndex % 4 == 0 { - extRows = scanner.snapshotExtensions(metric: metric, limit: 250) + extRows = scanner.snapshotExtensions(limit: 250) } if scanner.isFinished && phase == .scanning { @@ -1868,7 +1892,7 @@ final class ScanController { } filesPerSecond = 0 stopTimer() - extRows = scanner.snapshotExtensions(metric: metric, limit: 250) + extRows = scanner.snapshotExtensions(limit: 250) } bump() diff --git a/Sources/SpaceMatters/Views/ContentView.swift b/Sources/SpaceMatters/Views/ContentView.swift index ff065f3..0168d0c 100644 --- a/Sources/SpaceMatters/Views/ContentView.swift +++ b/Sources/SpaceMatters/Views/ContentView.swift @@ -276,9 +276,18 @@ private struct Breadcrumb: View { Spacer(minLength: 8) if let sel = controller.selection { - Text(Format.bytes(sel.size(controller.metric))) - .font(.system(size: 11, weight: .medium).monospacedDigit()) - .foregroundStyle(theme.textSecondary) + // On-disk is the number; a notable apparent-size gap (sparse + // image, compressed content) is spelled out instead of hidden. + if let d = sel.divergence { + Text("\(Format.bytes(d.onDisk)) · \(Format.bytes(d.apparent)) apparent") + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .foregroundStyle(theme.textSecondary) + .help(d.summary) + } else { + Text(Format.bytes(sel.sizeOnDisk)) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .foregroundStyle(theme.textSecondary) + } } } } @@ -293,7 +302,7 @@ private struct Breadcrumb: View { .buttonStyle(.plain) .help(node.name) .accessibilityLabel(node.name) - .accessibilityValue(Format.bytes(node.size(controller.metric))) + .accessibilityValue(Format.bytes(node.sizeOnDisk)) .accessibilityHint(isCurrent ? "Current folder" : "Zoom to this folder") .accessibilityAddTraits(isCurrent ? [.isSelected, .isButton] : .isButton) } @@ -352,24 +361,6 @@ private struct ToolbarBar: View { } if controller.root != nil { - Picker("", selection: Binding(get: { controller.metric }, set: { controller.metric = $0 })) { - ForEach(SizeMetric.allCases) { Text($0.label).tag($0) } - } - .pickerStyle(.segmented) - .fixedSize() - .accessibilityLabel("Size metric") - .help( - """ - How sizes are measured: - - • On disk — the space files actually take up on your disk (allocated blocks). This is what matters for freeing up space, and matches `du`. - - • Logical — the size of the files' content. - - They differ because the disk stores files in fixed-size blocks (~4 KB): a tiny file still uses a whole block, so "On disk" is usually larger. macOS-compressed files are the exception (smaller on disk than their content). - """ - ) - // Counting mode — host scans only (VM scans are always attribution). if controller.isHostScan { Picker("", selection: Binding(get: { controller.countingMode }, set: { controller.countingMode = $0 })) { @@ -454,6 +445,13 @@ private struct StatsStrip: View { var body: some View { HStack(spacing: 16) { Stat(value: Format.bytes(controller.totalSize), label: "total", theme: theme) + // The apparent (content) total appears only when it notably exceeds + // what's on disk — sparse disk images, compressed system files. It + // can legitimately exceed the volume's capacity; the tooltip says why. + if let d = controller.totalDivergence { + Stat(value: Format.bytes(d.apparent), label: "apparent", theme: theme) + .help(d.summary) + } Stat(value: Format.count(controller.fileCount), label: "files", theme: theme) Stat(value: Format.count(controller.dirCount), label: "folders", theme: theme) if controller.isScanning { diff --git a/Sources/SpaceMatters/Views/DirectoryListView.swift b/Sources/SpaceMatters/Views/DirectoryListView.swift index b8521be..d04b644 100644 --- a/Sources/SpaceMatters/Views/DirectoryListView.swift +++ b/Sources/SpaceMatters/Views/DirectoryListView.swift @@ -20,7 +20,6 @@ struct DirectoryListView: View { let _ = controller.selectedRowIDs let _ = controller.revealTarget let _ = controller.expanded - let _ = controller.metric let rows = controller.visibleRows() DirectoryTable( @@ -84,7 +83,6 @@ struct OutlineRowView: View { let isSelected: Bool let isHovered: Bool let isDirty: Bool - let metric: SizeMetric let controller: ScanController let theme: Theme @@ -102,8 +100,17 @@ struct OutlineRowView: View { private var size: Int64 { switch row.kind { - case .directory(let node): return node.size(metric) - case .file(let file, _): return file.size(metric) + case .directory(let node): return node.sizeOnDisk + case .file(let file, _): return file.physical + } + } + + /// Notable apparent-vs-on-disk gap (sparse image, compressed content) — + /// shown as a dashed badge; the tooltip carries both figures and the cause. + private var divergence: SizeDivergence? { + switch row.kind { + case .directory(let node): return node.divergence + case .file(let file, _): return file.divergence } } @@ -152,6 +159,14 @@ struct OutlineRowView: View { Spacer(minLength: 8) + if let d = divergence { + Image(systemName: "circle.dashed") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(theme.textSecondary) + .help(d.summary) + .accessibilityLabel("Apparent size \(Format.bytes(d.apparent)), \(d.label)") + } + GeometryReader { geo in ZStack(alignment: .leading) { Capsule().fill(theme.barTrack) @@ -166,6 +181,7 @@ struct OutlineRowView: View { .font(.system(size: 11, weight: .medium).monospacedDigit()) .foregroundStyle(theme.textSecondary) .frame(width: 66, alignment: .trailing) + .help(divergence?.summary ?? "") } .padding(.leading, CGFloat(row.depth) * 14 + 8) .padding(.trailing, 10) diff --git a/Sources/SpaceMatters/Views/DirectoryTable.swift b/Sources/SpaceMatters/Views/DirectoryTable.swift index 904f122..adeba6a 100644 --- a/Sources/SpaceMatters/Views/DirectoryTable.swift +++ b/Sources/SpaceMatters/Views/DirectoryTable.swift @@ -164,7 +164,6 @@ struct DirectoryTable: NSViewRepresentable { isSelected: controller.selectedRowIDs.contains(row.id), isHovered: index == hoveredRow, isDirty: isDirty, - metric: controller.metric, controller: controller, theme: theme ) diff --git a/Sources/SpaceMatters/Views/TreemapLayout.swift b/Sources/SpaceMatters/Views/TreemapLayout.swift index 562fcef..5602bdc 100644 --- a/Sources/SpaceMatters/Views/TreemapLayout.swift +++ b/Sources/SpaceMatters/Views/TreemapLayout.swift @@ -3,8 +3,17 @@ import CoreGraphics /// One of the current zoom root's own files, rendered as its own tile (SPEC-05). struct FileTileInfo: Equatable { let name: String - let size: Int64 // already resolved to the metric used for layout + let size: Int64 // on-disk bytes (the only metric the map is weighted by) let extName: String // ".png" etc — colours the tile, matches the legend + /// Apparent-vs-on-disk gap, when notable (sparse image…) — hover pill only. + let divergence: SizeDivergence? + + init(name: String, size: Int64, extName: String, divergence: SizeDivergence? = nil) { + self.name = name + self.size = size + self.extName = extName + self.divergence = divergence + } } /// A laid-out rectangle in the treemap. @@ -49,7 +58,7 @@ enum TreemapLayout { /// discrete choices here (decided once, at a reference rect) and rerun only the /// continuous geometry (`placeRows`) per frame: replaying a fixed row/orientation /// decomposition on any rect tiles it exactly and continuously. The choices are - /// rebuilt only when the tree, metric or zoom root changes — never on resize. + /// rebuilt only when the tree or zoom root changes — never on resize. /// /// Held by the controller alongside `sortCache`/`fileCache`. At the reference /// rect the output is identical to a plain squarify, so the at-rest look is @@ -66,18 +75,16 @@ enum TreemapLayout { init(items: [Item], weights: [Double]) { self.items = items; self.weights = weights } } fileprivate var entries: [ObjectIdentifier: Entry] = [:] - private var metric: SizeMetric? private var version: UInt64 = .max private var rootID: ObjectIdentifier? - /// Drop the memo when the tree (version), metric or zoom root changed; keep it + /// Drop the memo when the tree (version) or zoom root changed; keep it /// across pure resizes. A changed root matters because a node cached as a small /// sub-region must re-decide its structure when it becomes the full-rect root. - func invalidate(metric: SizeMetric, version: UInt64, root: FSNode) { + func invalidate(version: UInt64, root: FSNode) { let rid = ObjectIdentifier(root) - if self.metric != metric || self.version != version || self.rootID != rid { + if self.version != version || self.rootID != rid { entries.removeAll(keepingCapacity: true) - self.metric = metric self.version = version self.rootID = rid } @@ -99,7 +106,6 @@ enum TreemapLayout { static func compute( root: FSNode, rect: CGRect, - metric: SizeMetric, rootFiles: [FileTileInfo]? = nil, cache: Cache? = nil, needsRegions: Bool = true, @@ -109,7 +115,7 @@ enum TreemapLayout { var result = Result() result.tiles.reserveCapacity(2048) if needsRegions { result.regions.reserveCapacity(2048) } - layout(node: root, rect: rect, depth: 0, metric: metric, files: rootFiles, + layout(node: root, rect: rect, depth: 0, files: rootFiles, minSide: minSide, maxDepth: maxDepth, cache: cache ?? Cache(), needsRegions: needsRegions, into: &result) return result @@ -129,7 +135,6 @@ enum TreemapLayout { node: FSNode, rect: CGRect, depth: Int, - metric: SizeMetric, files: [FileTileInfo]?, minSide: CGFloat, maxDepth: Int, @@ -150,7 +155,7 @@ enum TreemapLayout { if let cached = cache.entries[ObjectIdentifier(node)] { entry = cached } else { - let built = buildItems(node: node, depth: depth, metric: metric, files: files) + let built = buildItems(node: node, depth: depth, files: files) entry = Cache.Entry(items: built.items, weights: built.weights) cache.entries[ObjectIdentifier(node)] = entry } @@ -189,7 +194,7 @@ enum TreemapLayout { result.tiles.append(TreemapTile(rect: r, node: item.node, depth: depth + 1, isFileBlock: item.isFileBlock)) } else if entry.recurse[i] { // Sub-directories: no file refinement (files: nil) — B2. - layout(node: item.node, rect: r, depth: depth + 1, metric: metric, files: nil, + layout(node: item.node, rect: r, depth: depth + 1, files: nil, minSide: minSide, maxDepth: maxDepth, cache: cache, needsRegions: needsRegions, into: &result) } else { if needsRegions { result.regions[ObjectIdentifier(item.node)] = r } @@ -202,14 +207,14 @@ enum TreemapLayout { /// squarify decisions and placement can run without re-sorting on every frame. /// Internal: `TreemapWorld` builds its per-node entries from the same items. static func buildItems( - node: FSNode, depth: Int, metric: SizeMetric, files: [FileTileInfo]? + node: FSNode, depth: Int, files: [FileTileInfo]? ) -> (items: [Item], weights: [Double]) { var items: [Item] = [] for child in node.children { - let w = Double(child.size(metric)) + let w = Double(child.sizeOnDisk) if w > 0 { items.append(Item(weight: w, node: child, isFileBlock: false, file: nil)) } } - let directFiles = Double(node.directFilesSize(metric)) + let directFiles = Double(node.directFilesPhysical) if directFiles > 0 { if depth == 0, let files, !files.isEmpty { // SPEC-05: individual tiles for the zoom root's own files. diff --git a/Sources/SpaceMatters/Views/TreemapView.swift b/Sources/SpaceMatters/Views/TreemapView.swift index cc702db..87c85fd 100644 --- a/Sources/SpaceMatters/Views/TreemapView.swift +++ b/Sources/SpaceMatters/Views/TreemapView.swift @@ -45,7 +45,6 @@ struct TreemapView: View { version: controller.version, zoomRoot: controller.zoomRoot, zoomRequestID: controller.zoomRequestID, - metric: controller.metric, selection: controller.selection, selectedExt: controller.selectedExt, searchMatchIDs: controller.searchMatchIDs, @@ -68,11 +67,10 @@ struct TreemapView: View { /// largest children by share — so VoiceOver conveys the shape of the map. private var treemapSummary: String { guard let zoom = controller.zoomRoot else { return "" } - let m = controller.metric - let head = "Showing \(zoom.name), \(Format.bytes(zoom.size(m)))." - let total = max(zoom.size(m), 1) + let head = "Showing \(zoom.name), \(Format.bytes(zoom.sizeOnDisk))." + let total = max(zoom.sizeOnDisk, 1) let kids = controller.sortedChildren(zoom).prefix(5).map { - "\($0.name) \(Int((Double($0.size(m)) / Double(total) * 100).rounded())) percent" + "\($0.name) \(Int((Double($0.sizeOnDisk) / Double(total) * 100).rounded())) percent" } return kids.isEmpty ? head : head + " Largest: " + kids.joined(separator: ", ") } @@ -137,7 +135,6 @@ private struct TreemapRepresentable: NSViewRepresentable { let version: UInt64 let zoomRoot: FSNode? let zoomRequestID: Int - let metric: SizeMetric let selection: FSNode? let selectedExt: ExtKey? let searchMatchIDs: Set @@ -149,7 +146,7 @@ private struct TreemapRepresentable: NSViewRepresentable { let view = TreemapNSView(renderer: renderer) view.onHover = onHover view.apply(controller: controller, theme: theme, version: version, zoomRoot: zoomRoot, - zoomRequestID: zoomRequestID, metric: metric, selection: selection, + zoomRequestID: zoomRequestID, selection: selection, selectedExt: selectedExt, searchMatchIDs: searchMatchIDs, highlightVersion: highlightVersion) return view @@ -158,7 +155,7 @@ private struct TreemapRepresentable: NSViewRepresentable { func updateNSView(_ view: TreemapNSView, context: Context) { view.onHover = onHover view.apply(controller: controller, theme: theme, version: version, zoomRoot: zoomRoot, - zoomRequestID: zoomRequestID, metric: metric, selection: selection, + zoomRequestID: zoomRequestID, selection: selection, selectedExt: selectedExt, searchMatchIDs: searchMatchIDs, highlightVersion: highlightVersion) } @@ -181,7 +178,6 @@ final class TreemapNSView: NSView, CALayerDelegate { private var version: UInt64 = .max private var zoomRoot: FSNode? private var zoomRequestID: Int = .min - private var metric: SizeMetric = .physical private var selection: FSNode? private var selectedExt: ExtKey? private var searchMatchIDs: Set = [] @@ -369,7 +365,7 @@ final class TreemapNSView: NSView, CALayerDelegate { // MARK: SwiftUI → view func apply(controller: ScanController, theme: Theme, version: UInt64, zoomRoot: FSNode?, - zoomRequestID: Int, metric: SizeMetric, selection: FSNode?, selectedExt: ExtKey?, + zoomRequestID: Int, selection: FSNode?, selectedExt: ExtKey?, searchMatchIDs: Set, highlightVersion: Int) { self.controller = controller @@ -383,7 +379,7 @@ final class TreemapNSView: NSView, CALayerDelegate { while let parent = root?.parent { root = parent } let rootChanged = root !== scanRoot - let dataChanged = version != self.version || metric != self.metric || themeChanged + let dataChanged = version != self.version || themeChanged let navRequested = zoomRequestID != self.zoomRequestID && self.zoomRequestID != .min let firstApply = self.zoomRequestID == .min let highlightChanged = selectedExt != self.selectedExt @@ -393,7 +389,6 @@ final class TreemapNSView: NSView, CALayerDelegate { self.version = version self.zoomRoot = zoomRoot self.zoomRequestID = zoomRequestID - self.metric = metric self.selectedExt = selectedExt self.searchMatchIDs = searchMatchIDs self.highlightVersion = highlightVersion @@ -407,7 +402,7 @@ final class TreemapNSView: NSView, CALayerDelegate { overlayLayer.setNeedsDisplay() return } - world.sync(root: root, metric: metric, version: version) + world.sync(root: root, version: version) if rootChanged || firstApply { // A new scan (or first appearance): fresh world, camera at fit. @@ -427,7 +422,7 @@ final class TreemapNSView: NSView, CALayerDelegate { } if dataChanged { - // FSEvents refresh / deletion / metric change: entries revalidate + // FSEvents refresh / deletion: entries revalidate // lazily (ε-local), and whatever moved morphs (SPEC-10 §3.4). A pure // theme change only recolours — no motion to animate. And a *live // scan* teleports: it restructures violently at 10 Hz, so sliding @@ -662,7 +657,7 @@ final class TreemapNSView: NSView, CALayerDelegate { private func tileSize(_ tile: TreemapWorld.Tile) -> Int64 { if let file = tile.file { return file.size } - return tile.isFileBlock ? tile.node.directFilesSize(metric) : tile.node.size(metric) + return tile.isFileBlock ? tile.node.directFilesPhysical : tile.node.sizeOnDisk } /// File tiles for a folder whose block crossed the file-LOD threshold. @@ -680,7 +675,8 @@ final class TreemapNSView: NSView, CALayerDelegate { return [] // failed/blocked: keep the aggregate block } return items.map { - FileTileInfo(name: $0.name, size: $0.size(metric), extName: OutlineRowView.extDisplay($0.name)) + FileTileInfo(name: $0.name, size: $0.physical, extName: OutlineRowView.extDisplay($0.name), + divergence: $0.divergence) } } @@ -1050,10 +1046,22 @@ final class TreemapNSView: NSView, CALayerDelegate { private func hoverInfo(for tile: TreemapWorld.Tile) -> HoverInfo { if let file = tile.file { - return HoverInfo(title: file.name, isDirectory: false, sizeText: Format.bytes(file.size)) + return HoverInfo(title: file.name, isDirectory: false, + sizeText: Self.sizeText(onDisk: file.size, divergence: file.divergence)) } - return HoverInfo(title: hoverPath(tile.node), isDirectory: tile.node.isDirectory, - sizeText: Format.bytes(tile.node.size(metric))) + let node = tile.node + let divergence = tile.isFileBlock ? nil : node.divergence + return HoverInfo(title: hoverPath(node), isDirectory: node.isDirectory, + sizeText: Self.sizeText( + onDisk: tile.isFileBlock ? node.directFilesPhysical : node.sizeOnDisk, + divergence: divergence)) + } + + /// "75 MB · 512 GB apparent (sparse)" when the tile hides a divergence; + /// plain on-disk size otherwise. + private static func sizeText(onDisk: Int64, divergence: SizeDivergence?) -> String { + guard let d = divergence else { return Format.bytes(onDisk) } + return "\(Format.bytes(onDisk)) · \(Format.bytes(d.apparent)) apparent (\(d.label))" } /// Path of a node relative to the current zoom root — so identical folder names diff --git a/Sources/SpaceMatters/Views/TreemapWorld.swift b/Sources/SpaceMatters/Views/TreemapWorld.swift index 215e5a4..c948f0c 100644 --- a/Sources/SpaceMatters/Views/TreemapWorld.swift +++ b/Sources/SpaceMatters/Views/TreemapWorld.swift @@ -116,13 +116,11 @@ final class TreemapWorld { /// window lazily (re-bake with hysteresis — never mid-drag). private(set) var worldSize: CGSize - private var metric: SizeMetric private var version: UInt64 private var rootID: ObjectIdentifier? - init(aspect: CGFloat = 1.6, metric: SizeMetric = .physical) { + init(aspect: CGFloat = 1.6) { self.worldSize = Self.size(forAspect: aspect) - self.metric = metric self.version = 0 } @@ -172,7 +170,6 @@ final class TreemapWorld { private struct FileLayout { let count: Int64 - let metric: SizeMetric let files: [FileTileInfo] let unitRects: [CGRect] } @@ -182,18 +179,17 @@ final class TreemapWorld { // MARK: Synchronisation with the data - /// Align the world with the current tree state. A metric or root change is a - /// new world (full reset); a version change only marks entries stale — they - /// revalidate lazily, locally, on their next visit (ε-stable). - func sync(root: FSNode, metric: SizeMetric, version: UInt64) { + /// Align the world with the current tree state. A root change is a new world + /// (full reset); a version change only marks entries stale — they revalidate + /// lazily, locally, on their next visit (ε-stable). + func sync(root: FSNode, version: UInt64) { let rid = ObjectIdentifier(root) - if rid != rootID || metric != self.metric { + if rid != rootID { entries.removeAll(keepingCapacity: true) fileLayouts.removeAll(keepingCapacity: true) fileLayoutOrder.removeAll(keepingCapacity: true) - if rid != rootID { expanded.removeAll(keepingCapacity: true) } + expanded.removeAll(keepingCapacity: true) rootID = rid - self.metric = metric } self.version = version } @@ -327,7 +323,7 @@ final class TreemapWorld { pending: inout Bool) -> FileLayout? { let id = ObjectIdentifier(node) let count = node.directFileCount - if let cached = fileLayouts[id], cached.count == count, cached.metric == metric { + if let cached = fileLayouts[id], cached.count == count { return cached.files.isEmpty ? nil : cached } guard let listing = files(node) else { @@ -337,7 +333,7 @@ final class TreemapWorld { let sorted = listing.filter { $0.size > 0 }.sorted { $0.size > $1.size } let weights = sorted.map { Double($0.size) } let unit = CGRect(x: 0, y: 0, width: 1, height: 1) - let layout = FileLayout(count: count, metric: metric, files: sorted, + let layout = FileLayout(count: count, files: sorted, unitRects: TreemapLayout.squarifySorted(weights, into: unit)) fileLayouts[id] = layout fileLayoutOrder.removeAll { $0 == id } @@ -356,7 +352,7 @@ final class TreemapWorld { if entry.stamp != version { revalidate(entry, node: node, rect: rect) } return entry } - let built = TreemapLayout.buildItems(node: node, depth: 1, metric: metric, files: nil) + let built = TreemapLayout.buildItems(node: node, depth: 1, files: nil) let entry = Entry(items: built.items, weights: built.weights, stamp: version) decide(entry, rect: rect) entries[id] = entry @@ -370,7 +366,7 @@ final class TreemapWorld { /// geometry only (bit-stable structure, exact areas). Otherwise re-decide /// this node alone — a local move, animated by the caller's morph. private func revalidate(_ entry: Entry, node: FSNode, rect: CGRect) { - let built = TreemapLayout.buildItems(node: node, depth: 1, metric: metric, files: nil) + let built = TreemapLayout.buildItems(node: node, depth: 1, files: nil) defer { entry.stamp = version } let sameSet = built.items.count == entry.items.count && zip(built.items, entry.items).allSatisfy { $0.node === $1.node && $0.isFileBlock == $1.isFileBlock diff --git a/Sources/SpaceMatters/Views/TypeBreakdownView.swift b/Sources/SpaceMatters/Views/TypeBreakdownView.swift index c39f26f..28c6dc1 100644 --- a/Sources/SpaceMatters/Views/TypeBreakdownView.swift +++ b/Sources/SpaceMatters/Views/TypeBreakdownView.swift @@ -7,7 +7,7 @@ struct TypeBreakdownView: View { var body: some View { let rows = controller.extRows - let maxSize = rows.first?.size(controller.metric) ?? 1 + let maxSize = rows.first?.physical ?? 1 VStack(alignment: .leading, spacing: 0) { HStack { @@ -33,7 +33,7 @@ struct TypeBreakdownView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(rows) { row in - TypeRow(row: row, metric: controller.metric, maxSize: max(maxSize, 1), + TypeRow(row: row, maxSize: max(maxSize, 1), isSelected: controller.selectedExt == row.key) { controller.toggleExt(row.key) } @@ -47,7 +47,6 @@ struct TypeBreakdownView: View { private struct TypeRow: View { let row: ExtRow - let metric: SizeMetric let maxSize: Int64 let isSelected: Bool let onTap: () -> Void @@ -55,7 +54,7 @@ private struct TypeRow: View { @State private var hovering = false private var fraction: Double { - maxSize > 0 ? min(1, Double(row.size(metric)) / Double(maxSize)) : 0 + maxSize > 0 ? min(1, Double(row.physical) / Double(maxSize)) : 0 } var body: some View { @@ -80,10 +79,13 @@ private struct TypeRow: View { } .frame(height: 5) - Text(Format.bytes(row.size(metric))) + Text(Format.bytes(row.physical)) .font(.system(size: 11, weight: .medium).monospacedDigit()) .foregroundStyle(theme.textSecondary) .frame(width: 66, alignment: .trailing) + // Disk-image types (.raw, .ext4…) can declare far more than they + // occupy — the tooltip carries the apparent size and the why. + .help(row.divergence?.summary ?? "") Text(Format.count(row.count)) .font(.system(size: 10).monospacedDigit()) @@ -97,7 +99,7 @@ private struct TypeRow: View { .onTapGesture(perform: onTap) .onHover { hovering = $0 } .accessibilityElement(children: .ignore) - .accessibilityLabel("\(row.name), \(Format.bytes(row.size(metric))), \(Format.count(row.count)) files") + .accessibilityLabel("\(row.name), \(Format.bytes(row.physical)), \(Format.count(row.count)) files") .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) .accessibilityHint("Filter the treemap by this file type") } diff --git a/Tests/SpaceMattersTests/DeletionGuardTests.swift b/Tests/SpaceMattersTests/DeletionGuardTests.swift index f4d0c9a..dc6e8f4 100644 --- a/Tests/SpaceMattersTests/DeletionGuardTests.swift +++ b/Tests/SpaceMattersTests/DeletionGuardTests.swift @@ -71,7 +71,7 @@ import Foundation // The tree is untouched too — no aggregates were subtracted. #expect(NavigationTests.child(c.root!, "sub") === sub) - let file = ScanController.FileItem(name: "victim.bin", logical: 4096, physical: 4096) + let file = ScanController.FileItem(name: "victim.bin", logical: 4096, physical: 4096, compressed: false) let okFile = await c.remove(file: file, parent: sub, permanently: true) #expect(!okFile) #expect(FileManager.default.fileExists(atPath: fixture.appendingPathComponent("sub/victim.bin").path)) @@ -94,7 +94,7 @@ import Foundation #expect(c.isScanning) #expect(FileManager.default.fileExists(atPath: fixture.appendingPathComponent("sub").path)) - let file = ScanController.FileItem(name: "victim.bin", logical: 4096, physical: 4096) + let file = ScanController.FileItem(name: "victim.bin", logical: 4096, physical: 4096, compressed: false) let okFile = await c.remove(file: file, parent: sub, permanently: true) #expect(!okFile) #expect(FileManager.default.fileExists(atPath: fixture.appendingPathComponent("sub/victim.bin").path)) diff --git a/Tests/SpaceMattersTests/RemoteScanTests.swift b/Tests/SpaceMattersTests/RemoteScanTests.swift index 9d667a8..d9ef585 100644 --- a/Tests/SpaceMattersTests/RemoteScanTests.swift +++ b/Tests/SpaceMattersTests/RemoteScanTests.swift @@ -60,7 +60,7 @@ import Foundation #expect(root.aggLogical.load(ordering: .relaxed) == 4000 + 900) // 4900 #expect(scanner.directoryCount == 2) // root + sub - let exts = Set(scanner.snapshotExtensions(metric: .physical, limit: 10).map(\.name)) + let exts = Set(scanner.snapshotExtensions(limit: 10).map(\.name)) #expect(exts.contains(".dat") && exts.contains(".txt")) } } diff --git a/Tests/SpaceMattersTests/SizeDivergenceTests.swift b/Tests/SpaceMattersTests/SizeDivergenceTests.swift new file mode 100644 index 0000000..5348cd1 --- /dev/null +++ b/Tests/SpaceMattersTests/SizeDivergenceTests.swift @@ -0,0 +1,146 @@ +import Testing +import Foundation +@testable import SpaceMatters + +/// SPEC bug-fix: "Logical" as a global mode was removed — the apparent size now +/// surfaces as a per-item annotation (`SizeDivergence`) classified sparse vs +/// compressed. These tests pin the threshold policy, the scanner's per-file +/// classification (ATTR_CMN_FLAGS / UF_COMPRESSED), and the aggregate propagation. +@Suite struct SizeDivergenceTests { + + // MARK: Threshold policy (pure) + + @Test func notableRequiresBothRatioAndDelta() { + // Big ratio, tiny delta (a 100 B file "10× sparse"): noise, no badge. + #expect(SizeDivergence.notable(onDisk: 100, apparent: 1_000, sparseExcess: 900, compressedExcess: 0) == nil) + // Big delta, small ratio (a 100 GB folder 9 GB apparent over): 1.09× — no badge. + let gb: Int64 = 1 << 30 + #expect(SizeDivergence.notable(onDisk: 100 * gb, apparent: 109 * gb, sparseExcess: 9 * gb, compressedExcess: 0) == nil) + // Both met: badge. + #expect(SizeDivergence.notable(onDisk: gb, apparent: 2 * gb, sparseExcess: gb, compressedExcess: 0) != nil) + // The 512 GiB sparse snapshot occupying 75 MiB — the bug that started this. + let d = SizeDivergence.notable(onDisk: 75 << 20, apparent: 512 * gb, sparseExcess: 512 * gb - (75 << 20), compressedExcess: 0) + #expect(d?.label == "sparse") + // Fully sparse (0 blocks allocated) must qualify too. + #expect(SizeDivergence.notable(onDisk: 0, apparent: 100 << 20, sparseExcess: 100 << 20, compressedExcess: 0) != nil) + // Padding direction (on-disk > apparent) never qualifies. + #expect(SizeDivergence.notable(onDisk: 2 * gb, apparent: gb, sparseExcess: 0, compressedExcess: 0) == nil) + } + + @Test func labelFollowsDominantCause() { + let gb: Int64 = 1 << 30 + let sparse = SizeDivergence.notable(onDisk: gb, apparent: 3 * gb, sparseExcess: 2 * gb, compressedExcess: 0) + #expect(sparse?.label == "sparse") + let compressed = SizeDivergence.notable(onDisk: gb, apparent: 3 * gb, sparseExcess: 0, compressedExcess: 2 * gb) + #expect(compressed?.label == "compressed") + let both = SizeDivergence.notable(onDisk: gb, apparent: 3 * gb, sparseExcess: gb, compressedExcess: gb) + #expect(both?.label == "sparse + compressed") + // Unclassified gap (streamed scans, hardlink dedup): honest generic label. + let unknown = SizeDivergence.notable(onDisk: gb, apparent: 3 * gb, sparseExcess: 0, compressedExcess: 0) + #expect(unknown?.label == "sparse or compressed") + } + + // MARK: Scanner classification (integration, real APFS) + + /// A 100 MB `ftruncate` hole must be booked as sparse excess and propagate + /// to every ancestor — the com.apple.container snapshot case in miniature. + @Test func sparseFileClassifiedAndPropagated() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("mds-sparse-\(UUID().uuidString)") + let sub = root.appendingPathComponent("images") + try fm.createDirectory(at: sub, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + let sparsePath = sub.appendingPathComponent("disk.img").path + let fd = open(sparsePath, O_CREAT | O_WRONLY, 0o644) + #expect(fd >= 0) + let declared: Int64 = 100 << 20 // 100 MiB declared, ~0 allocated + #expect(ftruncate(fd, declared) == 0) + close(fd) + try Data(count: 50_000).write(to: sub.appendingPathComponent("real.bin")) + + let node = FSNode(name: root.lastPathComponent, parent: nil) + let scanner = DirectoryScanner(root: node, rootPath: root.path) + scanner.start() + while !scanner.isFinished { usleep(5_000) } + + let sparseExcess = node.aggSparseExcess.load(ordering: .relaxed) + #expect(sparseExcess >= declared - (1 << 20), "hole must dominate the excess (got \(sparseExcess))") + #expect(node.aggCompressedExcess.load(ordering: .relaxed) == 0) + + // The badge fires at the root and names the right cause. + let d = try #require(node.divergence) + #expect(d.label == "sparse") + #expect(d.apparent >= declared) + + // …and on the subdirectory holding the image (propagation, not just root). + let images = try #require(node.children.first { $0.name == "images" }) + #expect(images.aggSparseExcess.load(ordering: .relaxed) == sparseExcess) + } + + /// An APFS-compressed file (`ditto --hfsCompression`) must be booked as + /// compressed — not sparse — excess. + @Test func compressedFileClassified() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("mds-comp-\(UUID().uuidString)") + try fm.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + // Highly compressible payload, then ditto re-writes it compressed. + let plain = root.appendingPathComponent("plain.txt") + try Data(repeating: 0x41, count: 4 << 20).write(to: plain) + let squeezed = root.appendingPathComponent("squeezed.txt") + let p = Process() + p.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + p.arguments = ["--hfsCompression", plain.path, squeezed.path] + try p.run() + p.waitUntilExit() + #expect(p.terminationStatus == 0) + try fm.removeItem(at: plain) + + // Precondition: the filesystem really did compress (UF_COMPRESSED set). + var st = stat() + #expect(stat(squeezed.path, &st) == 0) + try #require(st.st_flags & UInt32(UF_COMPRESSED) != 0, "ditto did not compress — fixture invalid") + + let node = FSNode(name: root.lastPathComponent, parent: nil) + let scanner = DirectoryScanner(root: node, rootPath: root.path) + scanner.start() + while !scanner.isFinished { usleep(5_000) } + + #expect(node.aggCompressedExcess.load(ordering: .relaxed) > 0) + #expect(node.aggSparseExcess.load(ordering: .relaxed) == 0, + "a compressed file must never be double-booked as sparse") + #expect(node.aggLogical.load(ordering: .relaxed) >= 4 << 20) + } + + /// Exact mode books a hardlinked sparse file's excess once, like its bytes — + /// attribution mode charges every link. + @Test func exactModeDedupsSparseExcess() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appendingPathComponent("mds-sphl-\(UUID().uuidString)") + try fm.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + + let imgPath = root.appendingPathComponent("disk.img").path + let fd = open(imgPath, O_CREAT | O_WRONLY, 0o644) + #expect(fd >= 0) + let declared: Int64 = 20 << 20 + #expect(ftruncate(fd, declared) == 0) + close(fd) + try fm.linkItem(atPath: imgPath, toPath: root.appendingPathComponent("disk.link").path) + + func scan(exact: Bool) -> Int64 { + let node = FSNode(name: "r", parent: nil) + let scanner = DirectoryScanner(root: node, seeds: [.init(path: root.path, node: node)], exact: exact) + scanner.start() + while !scanner.isFinished { usleep(5_000) } + return node.aggSparseExcess.load(ordering: .relaxed) + } + + let attributed = scan(exact: false) + let exact = scan(exact: true) + #expect(attributed >= 2 * (declared - (1 << 20)), "attribution charges both links") + #expect(exact >= declared - (1 << 20) && exact < declared + (1 << 20), "exact counts the inode once") + } +} diff --git a/Tests/SpaceMattersTests/TreemapLayoutTests.swift b/Tests/SpaceMattersTests/TreemapLayoutTests.swift index 26c8e8f..6c726d6 100644 --- a/Tests/SpaceMattersTests/TreemapLayoutTests.swift +++ b/Tests/SpaceMattersTests/TreemapLayoutTests.swift @@ -27,7 +27,7 @@ import Foundation FileTileInfo(name: "a.png", size: 300, extName: ".png"), FileTileInfo(name: "b.png", size: 100, extName: ".png"), ] - let refined = TreemapLayout.compute(root: root, rect: rect, metric: .physical, rootFiles: files) + let refined = TreemapLayout.compute(root: root, rect: rect, rootFiles: files) let fileTiles = refined.tiles.filter { $0.file != nil } #expect(fileTiles.count == 2) #expect(Set(fileTiles.map { $0.file!.name }) == ["a.png", "b.png"]) @@ -35,7 +35,7 @@ import Foundation #expect(!refined.tiles.contains { $0.isFileBlock }) // files fully listed → no residual block // Overview (no rootFiles): a single aggregate block, no per-file tiles. - let overview = TreemapLayout.compute(root: root, rect: rect, metric: .physical) + let overview = TreemapLayout.compute(root: root, rect: rect) #expect(overview.tiles.allSatisfy { $0.file == nil }) #expect(overview.tiles.filter { $0.isFileBlock }.count == 1) } diff --git a/Tests/SpaceMattersTests/TreemapWorldTests.swift b/Tests/SpaceMattersTests/TreemapWorldTests.swift index 1b2bb13..4428a68 100644 --- a/Tests/SpaceMattersTests/TreemapWorldTests.swift +++ b/Tests/SpaceMattersTests/TreemapWorldTests.swift @@ -38,7 +38,7 @@ import Foundation @Test func cameraNeverMovesTheWorld() { let (root, a, _) = makeTree() let world = TreemapWorld() - world.sync(root: root, metric: .physical, version: 1) + world.sync(root: root, version: 1) let full = build(world, root: root, scale: 1) let zoomedVisible = CGRect(x: 0, y: 0, @@ -68,13 +68,13 @@ import Foundation @Test func smallDriftKeepsTheTiling() { let (root, a, _) = makeTree() let world = TreemapWorld() - world.sync(root: root, metric: .physical, version: 1) + world.sync(root: root, version: 1) let before = build(world, root: root, scale: 1) // +1 % on `a` — well under ε. a.aggPhysical.store(606, ordering: .relaxed) root.aggPhysical.store(1006, ordering: .relaxed) - world.sync(root: root, metric: .physical, version: 2) + world.sync(root: root, version: 2) let after = build(world, root: root, scale: 1) #expect(after.tiles.count == before.tiles.count) @@ -92,7 +92,7 @@ import Foundation @Test func lodExpansionFollowsProjectedSizeWithHysteresis() { let (root, _, _) = makeTree() let world = TreemapWorld() - world.sync(root: root, metric: .physical, version: 1) + world.sync(root: root, version: 1) // Children project far below collapseSide → the root (always expanded) // renders its underlay + aggregated children, nothing deeper. @@ -119,7 +119,7 @@ import Foundation @Test func fileBlockRefinesAtFileLODThreshold() { let (root, _, _) = makeTree() let world = TreemapWorld() - world.sync(root: root, metric: .physical, version: 1) + world.sync(root: root, version: 1) let files = [ FileTileInfo(name: "big.bin", size: 70, extName: ".bin"), @@ -139,7 +139,7 @@ import Foundation // A pending listing (nil) keeps the aggregate block and flags the build // so the view schedules a follow-up once the listing lands. let freshWorld = TreemapWorld() - freshWorld.sync(root: root, metric: .physical, version: 1) + freshWorld.sync(root: root, version: 1) let pending = freshWorld.build(root: root, visible: freshWorld.worldBounds, scale: (sx: 50, sy: 50), needsRegions: false, files: { _ in nil }) @@ -165,7 +165,7 @@ import Foundation root.aggPhysical.store(sizes.reduce(0, +), ordering: .relaxed) let world = TreemapWorld() - world.sync(root: root, metric: .physical, version: 1) + world.sync(root: root, version: 1) _ = build(world, root: root, scale: 1) // 40 ticks: the biggest child triples gradually (~3 % of total per step, @@ -174,7 +174,7 @@ import Foundation sizes[0] = 600 + Int64(step) * 30 kids[0].aggPhysical.store(sizes[0], ordering: .relaxed) root.aggPhysical.store(sizes.reduce(0, +), ordering: .relaxed) - world.sync(root: root, metric: .physical, version: UInt64(1 + step)) + world.sync(root: root, version: UInt64(1 + step)) let result = build(world, root: root, scale: 1) var worst: CGFloat = 1 for tile in result.tiles where tile.node !== root { @@ -190,7 +190,7 @@ import Foundation @Test func focusNodeFollowsTheCamera() { let (root, a, _) = makeTree() let world = TreemapWorld() - world.sync(root: root, metric: .physical, version: 1) + world.sync(root: root, version: 1) _ = build(world, root: root, scale: 1) #expect(world.focusNode(root: root, visible: world.worldBounds) === root)