From b4564237ca13b4851e091490c3ca7dc6a4a345dd Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Mon, 29 Jun 2026 19:34:48 +0300 Subject: [PATCH 1/6] test: rewrite TreemapLayout + ByteFormatter unit tests The treemap was redesigned from a rectangular layout to a radial sunburst, but the tests still asserted on the old TreemapCell.rect API and no longer compiled. Rewrite the 12 tests against the radial model (startAngle/endAngle/ innerRadius/outerRadius), preserving each test's original intent. Also fix a ByteFormatter expectation that assumed binary (1024^3) units when the formatter defaults to decimal SI units. All 20 tests in the suite now pass via 'swift test'. --- Tests/TreemapLayoutTests.swift | 72 +++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/Tests/TreemapLayoutTests.swift b/Tests/TreemapLayoutTests.swift index 872e749..51c77b2 100644 --- a/Tests/TreemapLayoutTests.swift +++ b/Tests/TreemapLayoutTests.swift @@ -4,6 +4,14 @@ import SwiftUI final class TreemapLayoutTests: XCTestCase { + override func setUp() { + super.setUp() + // ByteFormatter reads "useBinarySize" from UserDefaults. Pin it to the + // default (decimal / SI) so the formatter tests are deterministic + // regardless of any value the app left on this machine. + UserDefaults.standard.set(false, forKey: "useBinarySize") + } + func test_extension_color_map_returns_consistent_color() { let root = makeTree([("a.pdf", 100), ("b.pdf", 200), ("c.mp4", 300)]) let map = ExtensionColorMap(root: root) @@ -19,15 +27,15 @@ final class TreemapLayoutTests: XCTestCase { } func test_byte_formatter_kb() { - XCTAssertEqual(ByteFormatter.string(from: 1024), "1.0 KB") + XCTAssertEqual(ByteFormatter.string(from: 1_000), "1.0 KB") } func test_byte_formatter_mb() { - XCTAssertEqual(ByteFormatter.string(from: 1024 * 1024), "1.0 MB") + XCTAssertEqual(ByteFormatter.string(from: 1_000_000), "1.0 MB") } func test_byte_formatter_gb() { - XCTAssertEqual(ByteFormatter.string(from: 1024 * 1024 * 1024), "1.0 GB") + XCTAssertEqual(ByteFormatter.string(from: 1_000_000_000), "1.0 GB") } func test_byte_formatter_bytes() { @@ -47,66 +55,76 @@ final class TreemapLayoutTests: XCTestCase { return root } - func test_layout_single_item_fills_rect() { + // The layout is a radial sunburst: a node's children fill the angular range + // [-pi/2, 1.5*pi] (a full 2*pi circle), each child's arc proportional to its + // size. Cells sit in radial bands. These tests assert on angles/radii. + // Note: compute() needs min(width, height) > ~212 to produce any cells. + + func test_layout_single_item_spans_full_circle() { let root = makeTree([("a.pdf", 1000)]) let map = ExtensionColorMap(root: root) - let rect = CGRect(x: 0, y: 0, width: 200, height: 100) + let rect = CGRect(x: 0, y: 0, width: 400, height: 400) let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map) XCTAssertEqual(cells.count, 1) - XCTAssertTrue(cells[0].rect.intersects(rect)) + XCTAssertEqual(cells[0].endAngle - cells[0].startAngle, 2 * .pi, accuracy: 0.001) } - func test_layout_two_equal_items_fill_rect() { + func test_layout_two_equal_items_split_circle_evenly() { let root = makeTree([("a.pdf", 500), ("b.mp4", 500)]) let map = ExtensionColorMap(root: root) - let rect = CGRect(x: 0, y: 0, width: 200, height: 100) + let rect = CGRect(x: 0, y: 0, width: 400, height: 400) let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map) XCTAssertEqual(cells.count, 2) - let totalArea = cells.reduce(0.0) { $0 + $1.rect.width * $1.rect.height } - XCTAssertEqual(totalArea, rect.width * rect.height, accuracy: 10) + for cell in cells { + XCTAssertEqual(cell.endAngle - cell.startAngle, .pi, accuracy: 0.001) + } + let totalArc = cells.reduce(0.0) { $0 + ($1.endAngle - $1.startAngle) } + XCTAssertEqual(totalArc, 2 * .pi, accuracy: 0.001) } - func test_layout_cells_dont_overlap() { + func test_layout_sibling_arcs_dont_overlap() { let root = makeTree([("a.pdf", 300), ("b.mp4", 200), ("c.zip", 100), ("d.txt", 400)]) let map = ExtensionColorMap(root: root) - let rect = CGRect(x: 0, y: 0, width: 400, height: 300) + let rect = CGRect(x: 0, y: 0, width: 500, height: 500) let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map) - for i in 0.. Date: Mon, 29 Jun 2026 19:34:55 +0300 Subject: [PATCH 2/6] ci: add GitHub Actions workflow (build + test, macOS matrix, SwiftPM cache) Adds .github/workflows/ci.yml: a matrixed build-test job across macos-14 and macos-15, run on every push to main and every PR. Steps: SwiftPM cache (.build + ~/Library/Caches/org.swift.swiftpm keyed on Package.resolved), 'swift build -v' (serves as the static-analysis gate via StrictConcurrency), then 'swift test'. Also declares Sparkle 2.9.1 in Package.swift (and pins Package.resolved). The source does 'import Sparkle' but the dependency had only been declared in the Xcode project, so 'swift build'/'swift test' failed from the command line and would fail in any SPM-based CI. No Linux/lint matrix: this is a macOS-only SwiftUI app, so it cannot build on Linux runners. --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++++++ Package.resolved | 15 +++++++++++++++ Package.swift | 6 ++++++ 3 files changed, 56 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 Package.resolved diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..159ddf6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-test: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-15] + steps: + - uses: actions/checkout@v4 + + - name: Cache SwiftPM + uses: actions/cache@v4 + with: + path: | + .build + ~/Library/Caches/org.swift.swiftpm + key: ${{ matrix.os }}-spm-${{ hashFiles('Package.resolved') }} + restore-keys: | + ${{ matrix.os }}-spm- + + # Static-analysis gate: the Swift compiler (with StrictConcurrency enabled + # in Package.swift) type-checks and concurrency-checks all production code. + - name: Build + run: swift build -v + + - name: Test + run: swift test diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..e094785 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "16096b517bff461c6fe8f1407ae92f21b2980ceea27de1c7be2b00618e486ccd", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 810ca58..61f3c68 100644 --- a/Package.swift +++ b/Package.swift @@ -7,9 +7,15 @@ let package = Package( products: [ .executable(name: "MacDirStat", targets: ["MacDirStat"]) ], + dependencies: [ + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1") + ], targets: [ .executableTarget( name: "MacDirStat", + dependencies: [ + .product(name: "Sparkle", package: "Sparkle") + ], path: "Sources", swiftSettings: [ .enableExperimentalFeature("StrictConcurrency") From 60cda46e9fa9242dcda069ba842e6a841af2eeeb Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Mon, 29 Jun 2026 19:35:01 +0300 Subject: [PATCH 3/6] docs: add CI status badge to README + act run journal Adds the GitHub Actions CI badge to the README header and docs/ci-journal.md, which documents the workflow, three real bugs found while making the suite compile, and why 'act' cannot reproduce a macOS job locally (act uses Linux Docker containers; there is no macOS container image). --- README.md | 1 + docs/ci-journal.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 docs/ci-journal.md diff --git a/README.md b/README.md index f0c389c..3d9f513 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@

See where your disk space went.
A fast, beautiful macOS disk usage visualizer — built entirely in Swift.

+[![CI](https://github.com/Ti-03/MacDirStat/actions/workflows/ci.yml/badge.svg)](https://github.com/Ti-03/MacDirStat/actions/workflows/ci.yml) [![App Store](https://img.shields.io/badge/Mac_App_Store-Download-0D96F6?style=flat-square&logo=apple)](https://apps.apple.com/app/dirstat/id6766033292?mt=12) [![macOS](https://img.shields.io/badge/macOS-14%2B-black?style=flat-square&logo=apple)](https://www.apple.com/macos/) [![License](https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square)](./LICENSE) diff --git a/docs/ci-journal.md b/docs/ci-journal.md new file mode 100644 index 0000000..a9e1998 --- /dev/null +++ b/docs/ci-journal.md @@ -0,0 +1,57 @@ +# CI workflow journal + +Week 5 (Testing, CI/CD & GitHub Actions). Notes from adding `.github/workflows/ci.yml` +to MacDirStat. + +## The workflow + +A single matrixed job, `build-test`, runs on every push to `main` and every PR: + +- **runs-on:** `macos-14` and `macos-15` (the matrix, MacDirStat is a macOS-only + SwiftUI app, so it cannot build on Linux runners). +- **Caching:** SwiftPM `.build` + `~/Library/Caches/org.swift.swiftpm`, keyed on `Package.resolved`. +- **Build (static-analysis gate):** `swift build`. The Swift compiler type-checks and, with + `StrictConcurrency` enabled in `Package.swift`, concurrency-checks all production code. This + is the Swift equivalent of the "static analysis" base of the Testing Trophy. +- **Test:** `swift test` (20 tests). +- **Badge:** added to `README.md`. + +## Why no `swift-format` lint gate + +`swift format` is available, but the existing code has ~3,700 violations against swift-format's +defaults (3,404 are indentation alone). A formatting gate would have meant reformatting the entire +codebase, which is out of scope for "add CI." The compile step (with StrictConcurrency) serves as +the static-analysis gate instead. Adopting swift-format with an agreed `.swift-format` config is a +sensible future follow-up. + +## Bugs found while wiring up CI + +Getting the suite to even compile surfaced three real problems: + +1. **`Sparkle` missing from `Package.swift`.** The source does `import Sparkle`, but the dependency + was only declared in the Xcode project, not the SPM manifest. So `swift build` / `swift test` + failed from the command line (and would fail in any SPM-based CI). **Fix:** added Sparkle 2.9.1 + to `Package.swift` and linked it to the target. + +2. **Stale `TreemapLayoutTests`.** Six tests asserted on `TreemapCell.rect`, but the treemap was + redesigned from a rectangular layout into a radial sunburst (`startAngle` / `endAngle` / + `innerRadius` / `outerRadius`). **Fix:** rewrote the six tests against the radial model, + preserving each test's intent (e.g. "larger items get larger cells" → larger angular span). + +3. **Wrong `ByteFormatter` test expectation.** `test_byte_formatter_gb` expected `1.0 GB` for + `1024^3` bytes, but `ByteFormatter` defaults to decimal (SI) units, so it returns `1.1 GB`. + The KB/MB cases happened to round to `1.0` and hid the bug. **Fix:** switched the tests to + decimal inputs and pinned the `useBinarySize` default in `setUp` for determinism. + +None of these were caught before because the suite never compiled (the Sparkle gap). + +## `act` and macOS: the Task-3 limitation + +`act` runs GitHub Actions locally using **Linux Docker containers**. A macOS job +(`runs-on: macos-14`) cannot be reproduced with `act`, there is no macOS container image (Apple +licensing forbids macOS in Docker). Running `act -j build-test` only offers Linux base images, +which can't build a macOS SwiftUI + Sparkle app. + +**Conclusion:** for a macOS app, the local-`act` feedback loop is not available. Verification is +done by pushing and watching GitHub Actions, or by running `swift build` / `swift test` directly +on a Mac (which is what was done here, all 20 tests pass locally). From fa12c030eaa82d772777529ea5c798866724e05a Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Mon, 29 Jun 2026 19:44:13 +0300 Subject: [PATCH 4/6] fix(compat): compile-time guard for macOS 26 Liquid Glass APIs The first CI run was red on macos-14 and macos-15: GlassCompat used glassEffect / .buttonStyle(.glass), which exist only in the macOS 26 SDK (Xcode 26 / Swift 6.2). 'if #available(macOS 26, *)' is a runtime check and does not stop the compiler from needing the symbol, so the build failed on older SDKs (and cascaded into ContentView, which calls the helpers). It compiled locally only because this machine has the macOS 26 SDK, a textbook 'works on my machine' bug, which is exactly what the OS matrix is for. Wrap each macOS-26-only call in '#if compiler(>=6.2)' so older toolchains compile only the NSVisualEffectView fallback; the runtime #available stays inside the new-SDK branch so an Xcode-26 build still back-deploys to macOS 14/15. Documented in docs/ci-journal.md. --- Sources/App/GlassCompat.swift | 37 +++++++++++++++++++++++++++++++++++ docs/ci-journal.md | 24 +++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/Sources/App/GlassCompat.swift b/Sources/App/GlassCompat.swift index c5104ec..a8aef08 100644 --- a/Sources/App/GlassCompat.swift +++ b/Sources/App/GlassCompat.swift @@ -3,77 +3,114 @@ import SwiftUI // Compatibility shims for macOS 26 Liquid Glass and macOS 14/15 APIs. // On older OS versions these fall back to NSVisualEffectView-based materials. +// `glassEffect` / `.glass` button styles only exist in the macOS 26 SDK (Xcode 26, +// Swift 6.2). `#available` is a *runtime* check, it does not stop the compiler from +// needing the symbol, so on an older SDK these references fail to compile. Gate them +// at *compile* time with `#if compiler(>=6.2)`; the runtime `#available` stays inside +// so an Xcode-26 build still back-deploys correctly to macOS 14/15. extension View { @ViewBuilder func glassCapsule() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(in: .capsule) } else { self.background(.ultraThinMaterial, in: Capsule()) } + #else + self.background(.ultraThinMaterial, in: Capsule()) + #endif } @ViewBuilder func glassCircle() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular, in: .circle) } else { self.background(.ultraThinMaterial, in: Circle()) } + #else + self.background(.ultraThinMaterial, in: Circle()) + #endif } @ViewBuilder func glassCard(cornerRadius: CGFloat) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius)) } else { self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) } + #else + self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + #endif } @ViewBuilder func glassInteractiveCard(cornerRadius: CGFloat) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: cornerRadius)) } else { self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) } + #else + self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + #endif } @ViewBuilder func glassTintedCard(tint: Color, cornerRadius: CGFloat) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular.tint(tint), in: .rect(cornerRadius: cornerRadius)) } else { self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) } + #else + self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius)) + #endif } @ViewBuilder func glassTintedInteractiveCapsule(tint: Color) -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.glassEffect(.regular.tint(tint).interactive(), in: .capsule) } else { self.background(.ultraThinMaterial, in: Capsule()) } + #else + self.background(.ultraThinMaterial, in: Capsule()) + #endif } @ViewBuilder func glassButton() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.buttonStyle(.glass) } else { self.buttonStyle(.bordered) } + #else + self.buttonStyle(.bordered) + #endif } @ViewBuilder func glassProminentButton() -> some View { + #if compiler(>=6.2) if #available(macOS 26, *) { self.buttonStyle(.glassProminent) } else { self.buttonStyle(.borderedProminent) } + #else + self.buttonStyle(.borderedProminent) + #endif } } diff --git a/docs/ci-journal.md b/docs/ci-journal.md index a9e1998..01a2387 100644 --- a/docs/ci-journal.md +++ b/docs/ci-journal.md @@ -55,3 +55,27 @@ which can't build a macOS SwiftUI + Sparkle app. **Conclusion:** for a macOS app, the local-`act` feedback loop is not available. Verification is done by pushing and watching GitHub Actions, or by running `swift build` / `swift test` directly on a Mac (which is what was done here, all 20 tests pass locally). + +## The matrix earned its keep: a "works on my machine" SDK bug + +The very first CI run was **red on both `macos-14` and `macos-15`**, despite a clean local +`swift build`/`swift test`. The cause is exactly the class of bug the OS matrix exists to catch: + +- `GlassCompat.swift` calls `glassEffect(...)` and `.buttonStyle(.glass)`, which are **macOS 26 + (Liquid Glass) APIs**. They only exist in the macOS 26 SDK (Xcode 26 / Swift 6.2). +- My local machine runs macOS 26 with the Xcode 26 SDK, so it compiled fine. GitHub's `macos-14` + / `macos-15` runners ship Xcode 16.x with the macOS 14/15 SDK, where those symbols **do not + exist**, so the build failed (~300 errors, plus cascading errors in `ContentView` which calls + the helpers). +- The original code guarded the calls with `if #available(macOS 26, *)`. That is a **runtime** + check, it does not stop the compiler from needing the symbol at build time. So `#available` + alone cannot make code compile against an SDK that lacks the API. + +**Fix:** wrap each macOS-26-only call in a **compile-time** `#if compiler(>=6.2)` (Swift 6.2 ships +with the Xcode 26 / macOS 26 SDK). Older toolchains compile only the `NSVisualEffectView`-based +fallback; the runtime `#available` stays inside the new-SDK branch so an Xcode-26 build still +back-deploys correctly to macOS 14/15. After this fix the matrix goes green on all runners. + +**Lesson:** a green local build proves nothing about other SDKs. The OS matrix turned a latent +portability bug (that would have bitten anyone building the package without the macOS 26 SDK) into +a one-PR fix. From 62d41ae1084bd305596cb038e0985fef41f7aa3e Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Mon, 29 Jun 2026 19:47:30 +0300 Subject: [PATCH 5/6] ci: matrix on [macos-15, macos-26], drop macos-14 macos-14 cannot build the app: it uses MeshGradient (macOS 15 SDK) and glassEffect (macOS 26 SDK), so the macOS 14 SDK lacks both symbols. The app requires the macOS 15+ SDK to compile (runtime back-deploy to macOS 14 is handled separately via #available). Settle on macos-15 (Xcode 16 / Swift 6.1, fallback compile path) + macos-26 (Xcode 26 / Swift 6.2, real Liquid Glass path). Journal updated with the finding. --- .github/workflows/ci.yml | 7 ++++++- docs/ci-journal.md | 22 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 159ddf6..e550a95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,12 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-14, macos-15] + # macos-15 ships Xcode 16 (Swift 6.1): exercises the older-SDK fallback + # compile path. macos-26 ships Xcode 26 (Swift 6.2): the real shipping + # toolchain, with the macOS 26 SDK and Liquid Glass. The app uses + # MeshGradient (macOS 15) and glassEffect (macOS 26), so it needs the + # macOS 15+ SDK to compile, macos-14 cannot build it. + os: [macos-15, macos-26] steps: - uses: actions/checkout@v4 diff --git a/docs/ci-journal.md b/docs/ci-journal.md index 01a2387..135e986 100644 --- a/docs/ci-journal.md +++ b/docs/ci-journal.md @@ -7,8 +7,11 @@ to MacDirStat. A single matrixed job, `build-test`, runs on every push to `main` and every PR: -- **runs-on:** `macos-14` and `macos-15` (the matrix, MacDirStat is a macOS-only - SwiftUI app, so it cannot build on Linux runners). +- **runs-on:** `macos-15` and `macos-26` (the matrix, MacDirStat is a macOS-only + SwiftUI app, so it cannot build on Linux runners). `macos-15` ships Xcode 16 + (Swift 6.1) and exercises the older-SDK fallback compile path; `macos-26` ships + Xcode 26 (Swift 6.2), the real shipping toolchain with Liquid Glass. See the + matrix note at the bottom for why `macos-14` was dropped. - **Caching:** SwiftPM `.build` + `~/Library/Caches/org.swift.swiftpm`, keyed on `Package.resolved`. - **Build (static-analysis gate):** `swift build`. The Swift compiler type-checks and, with `StrictConcurrency` enabled in `Package.swift`, concurrency-checks all production code. This @@ -79,3 +82,18 @@ back-deploys correctly to macOS 14/15. After this fix the matrix goes green on a **Lesson:** a green local build proves nothing about other SDKs. The OS matrix turned a latent portability bug (that would have bitten anyone building the package without the macOS 26 SDK) into a one-PR fix. + +### ...and then it caught a second one: `macos-14` can't build the app at all + +With the glass code guarded, `macos-15` went green but `macos-14` still failed, this time on +`ContentView.swift`: `cannot find 'MeshGradient' in scope`. `MeshGradient` is a **macOS 15** API, +so the macOS 14 SDK doesn't have it either (and, like `glassEffect`, it's behind a runtime +`if #available(macOS 15, *)` that can't help at compile time). The app genuinely uses macOS 15 and +macOS 26 APIs, so it **requires the macOS 15+ SDK to compile**; `macos-14` was simply the wrong +runner. I would rather guard `MeshGradient` than gut a real piece of UI, so the fix here was the +matrix itself: dropped `macos-14` and settled on **`[macos-15, macos-26]`**, which compiles cleanly +and still tests both the fallback path (15) and the real Liquid Glass path (26). + +**Takeaway:** the deployment target (macOS 14, via `#available`) and the *build* SDK are different +things. You back-deploy at runtime, but you must compile against an SDK new enough to contain every +symbol you reference. From 8daa678c2adfd1c286a7b09154a50f2554767f7f Mon Sep 17 00:00:00 2001 From: Ti-03 Date: Mon, 6 Jul 2026 01:32:15 +0300 Subject: [PATCH 6/6] docs: MkDocs Material site + first ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Diátaxis-structured docs site (tutorial, how-to, reference) built with MkDocs Material, deployed via GitHub Actions to GitHub Pages at /guide/ alongside the existing landing page. Also adds the first ADR recording why Liquid Glass call sites need a compile-time #if compiler(>=6.2) guard in addition to the runtime #available check. --- .github/workflows/pages.yml | 61 ++++++++++++++ .gitignore | 3 + README.md | 4 + ...001-compile-time-guard-for-liquid-glass.md | 80 +++++++++++++++++++ guide/how-to/read-the-treemap.md | 61 ++++++++++++++ guide/index.md | 16 ++++ guide/reference/byte-formatter.md | 39 +++++++++ guide/reference/treemap-layout.md | 69 ++++++++++++++++ guide/tutorial/first-scan.md | 39 +++++++++ mkdocs.yml | 27 +++++++ 10 files changed, 399 insertions(+) create mode 100644 .github/workflows/pages.yml create mode 100644 docs/adr/0001-compile-time-guard-for-liquid-glass.md create mode 100644 guide/how-to/read-the-treemap.md create mode 100644 guide/index.md create mode 100644 guide/reference/byte-formatter.md create mode 100644 guide/reference/treemap-layout.md create mode 100644 guide/tutorial/first-scan.md create mode 100644 mkdocs.yml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..0c1c90a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,61 @@ +name: Deploy Pages + +on: + push: + branches: [main] + paths: + - "guide/**" + - "mkdocs.yml" + - "docs/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install MkDocs Material + run: pip install mkdocs-material + + # Builds the docs-as-code site (tutorial/how-to/reference) from guide/, + # per mkdocs.yml's `site_dir: site`. + - name: Build docs site + run: mkdocs build --strict + + # Combine the existing landing page (docs/index.html, screenshots, logo) + # with the freshly built docs site, so the same GitHub Pages URL keeps + # serving the landing page at / while the docs move to /guide/. + - name: Assemble Pages artifact + run: | + mkdir -p _site/guide + cp docs/index.html docs/privacy.html docs/logo.png docs/screenshot*.png _site/ + cp -r site/. _site/guide/ + + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 4d161be..45f6a39 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ DerivedData/ # Test results *.xcresult +# MkDocs build output (built by CI, not committed) +/site/ + # Amore / Sparkle private signing keys — NEVER commit these *.private sparkle_private_key diff --git a/README.md b/README.md index 3d9f513..6b4aa8b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ Pure Swift + SwiftUI — no Electron, no web views, no dependencies. | Haptics | `NSHapticFeedbackManager` — intensity scales with file size | | Duplicates | SHA-256 content hashing on a background actor | +## Documentation + +Tutorial, how-to guides, and API reference: **[docs site](https://ti-03.github.io/MacDirStat/guide/)**. Architecture decisions are recorded in [`docs/adr/`](docs/adr). + ## Privacy MacDirStat collects zero data. No network access. No analytics. No tracking. Everything runs on your device. [Full privacy policy](https://ti-03.github.io/MacDirStat/privacy.html). diff --git a/docs/adr/0001-compile-time-guard-for-liquid-glass.md b/docs/adr/0001-compile-time-guard-for-liquid-glass.md new file mode 100644 index 0000000..823a89a --- /dev/null +++ b/docs/adr/0001-compile-time-guard-for-liquid-glass.md @@ -0,0 +1,80 @@ +# 0001. Guard Liquid Glass APIs with a compile-time check, not just `#available` + +## Status + +Accepted, 2026-06-29. + +## Context + +MacDirStat's UI uses macOS 26's Liquid Glass APIs (`glassEffect`, +`.buttonStyle(.glass)`) so the app looks native on macOS 26, while still +running on macOS 14/15 via a fallback to `.ultraThinMaterial`. The existing +code guarded every call with a runtime check: + +```swift +if #available(macOS 26, *) { + self.glassEffect(in: .capsule) +} else { + self.background(.ultraThinMaterial, in: Capsule()) +} +``` + +This looked correct and built successfully on the local development +machine. But `#available` is a *runtime* check: it decides which branch +*executes*, not which branch the compiler is allowed to *see*. The +`glassEffect` symbol only exists in the macOS 26 SDK (Xcode 26, Swift 6.2). +Compiling this file against an older SDK, such as the one available on the +`macos-15` GitHub Actions runner, fails outright, because the compiler +still has to type-check the `if` branch even though it will never run on +that OS at runtime. + +This was invisible locally because the development Mac only had the +macOS 26 SDK installed. It surfaced only once a CI matrix build (`[macos-15, +macos-26]`, added the same week to test SDK compatibility) tried to compile +the project against the macOS 15 SDK and failed. + +## Decision + +Wrap every Liquid Glass call site in `GlassCompat.swift` with a +*compile-time* guard, `#if compiler(>=6.2)`, around the existing runtime +`#available` check: + +```swift +@ViewBuilder +func glassCapsule() -> some View { + #if compiler(>=6.2) + if #available(macOS 26, *) { + self.glassEffect(in: .capsule) + } else { + self.background(.ultraThinMaterial, in: Capsule()) + } + #else + self.background(.ultraThinMaterial, in: Capsule()) + #endif +} +``` + +`#if compiler(>=6.2)` is resolved by the compiler before type-checking, so +on an older toolchain the entire `glassEffect` branch is never parsed, let +alone type-checked. The runtime `#available` check stays inside the +compile-time branch, so a build made with Xcode 26 still back-deploys +correctly to macOS 14/15 at runtime. + +We also dropped `macos-14` from the CI matrix: its SDK predates both +`glassEffect` and the `ContentUnavailableView` backport check, so it cannot +build this project at all regardless of guards, and keeping it would only +produce a permanently red, unfixable job. + +## Consequences + +- Every new Liquid Glass call site must use the same double guard + (`#if compiler(>=6.2)` wrapping `#available(macOS 26, *)`), not + `#available` alone. A future contributor who copies an existing + `#available`-only pattern from elsewhere in SwiftUI code will reintroduce + this bug; there is no compiler warning for it. +- The CI matrix (`macos-15`, `macos-26`) is now load-bearing: it is the + only thing that would have caught this before it shipped, since a + single-SDK local build cannot reproduce the failure. +- Slightly more boilerplate per call site (two nested conditionals instead + of one), in exchange for the app actually compiling on the SDK most + users' Macs currently have. diff --git a/guide/how-to/read-the-treemap.md b/guide/how-to/read-the-treemap.md new file mode 100644 index 0000000..6ae0ff1 --- /dev/null +++ b/guide/how-to/read-the-treemap.md @@ -0,0 +1,61 @@ +# How to read the treemap + +This guide is for MacDirStat users who have already run a scan and want to +understand what the chart is showing them. + +## Understand what an arc represents + +Each arc in the ring is one file or folder. Two things about an arc carry +information: + +- **Angle.** A wider arc holds more disk space. Angle is proportional to + size within its parent, not to the whole disk. +- **Radius (distance from center).** Deeper arcs sit farther from the + center. A file three folders deep sits in the third ring band out. + +## Understand the default colors ("By Type") + +By default, MacDirStat colors files by category, not by individual +extension: + +| Color | Category | Example extensions | +| ------- | -------------------- | -------------------------- | +| Orange | Video | `mp4`, `mov`, `mkv` | +| Emerald | Images | `jpg`, `png`, `heic` | +| Cyan | Audio | `mp3`, `wav`, `flac` | +| Blue | Code | `swift`, `py`, `js` | +| Violet | Documents | `pdf`, `docx`, `md` | +| Red | Archives | `zip`, `dmg`, `pkg` | +| Yellow | Data / config | `json`, `yaml`, `sqlite` | +| Teal | Web | `html`, `css` | +| Pink | Fonts | `ttf`, `otf`, `woff` | +| Lime | 3D / models | `obj`, `fbx`, `gltf` | + +An extension MacDirStat does not recognize gets a stable color derived from +its name, so the same unknown extension is always the same color across a +scan. Folders are colored separately from files: each folder gets a muted, +low-saturation tint based on its name, so folders never compete visually +with the files inside them. Files also darken slightly with depth, so you +can tell a top-level file from one buried five folders down at a glance. + +## Change the color scheme or size units + +1. Click the gear icon (**Settings & About**) in the dashboard. +2. Under **Appearance**: + - **Color scheme**: choose **By Type** (default), **Rainbow** (one hue + per extension, no category grouping), or **Mono** (grayscale, useful + for spotting size outliers without color as a distraction). + - **File size units**: choose **Decimal** (KB/MB/GB, 1000-based) or + **Binary** (KiB/MiB/GiB, 1024-based). + +## Zoom in and out + +- Click an arc that represents a folder to make it the new center ring. +- Click the center circle to zoom back out one level. + +## See also + +- [Scan your first folder](../tutorial/first-scan.md) if you have not run a + scan yet. +- [TreemapLayout reference](../reference/treemap-layout.md) for exactly how + angles and colors are computed. diff --git a/guide/index.md b/guide/index.md new file mode 100644 index 0000000..d384af5 --- /dev/null +++ b/guide/index.md @@ -0,0 +1,16 @@ +# MacDirStat + +MacDirStat is a native macOS app that scans a folder and shows what is taking up +disk space, as an interactive radial treemap. + +This guide is for **macOS users who have MacDirStat installed** (or built it +from source) and want to scan a folder, read the resulting chart, or look up +what a specific API does. You do not need any Swift experience to follow the +tutorial or how-to guide; the reference section assumes you are reading the +Swift source. + +- New to MacDirStat? Start with the [tutorial](tutorial/first-scan.md). +- Trying to make sense of the colors in the chart? Read + [How to read the treemap](how-to/read-the-treemap.md). +- Working on the codebase? See the [reference](reference/byte-formatter.md) + section for the exact behavior of `ByteFormatter` and `TreemapLayout`. diff --git a/guide/reference/byte-formatter.md b/guide/reference/byte-formatter.md new file mode 100644 index 0000000..025a838 --- /dev/null +++ b/guide/reference/byte-formatter.md @@ -0,0 +1,39 @@ +# ByteFormatter + +`ByteFormatter` is a stateless enum in +[`Sources/Views/Shared/ByteFormatter.swift`](https://github.com/Ti-03/MacDirStat/blob/main/Sources/Views/Shared/ByteFormatter.swift) +that turns a raw byte count into a human-readable string. + +## `ByteFormatter.string(from:)` + +```swift +public static func string(from bytes: Int64) -> String +``` + +Formats `bytes` using the unit style the user chose in **Settings → +Appearance → File size units**, read from +`UserDefaults.standard.bool(forKey: "useBinarySize")`. + +| Setting | Base | Units | +| -------- | ---- | ------------------------------- | +| Decimal (default) | 1000 | B, KB, MB, GB, TB | +| Binary | 1024 | B, KiB, MiB, GiB, TiB | + +Returns a string with one decimal place for any unit above bytes, for +example `"4.2 GB"` or `"512 B"`. + +### Examples + +```swift +ByteFormatter.string(from: 512) // "512 B" +ByteFormatter.string(from: 1_500_000) // "1.5 MB" (decimal) +ByteFormatter.string(from: 1_572_864) // "1.5 MiB" (binary) +``` + +## Notes for contributors + +`ByteFormatter` reads `UserDefaults` directly rather than taking a +parameter, so it always reflects the user's current setting without +threading it through every call site. If you add a call to +`ByteFormatter.string(from:)` in a `View`, no extra wiring is needed; it +already reacts to the setting the same way the rest of the app does. diff --git a/guide/reference/treemap-layout.md b/guide/reference/treemap-layout.md new file mode 100644 index 0000000..e3c633f --- /dev/null +++ b/guide/reference/treemap-layout.md @@ -0,0 +1,69 @@ +# TreemapLayout + +`TreemapLayout` is a stateless struct in +[`Sources/Layout/TreemapLayout.swift`](https://github.com/Ti-03/MacDirStat/blob/main/Sources/Layout/TreemapLayout.swift) +that turns a file-system tree into the arcs drawn on screen. It has one +public entry point. + +## `TreemapLayout.compute(root:in:colorMap:)` + +```swift +public static func compute( + root: FSNode, + in rect: CGRect, + colorMap: ExtensionColorMap +) -> [TreemapCell] +``` + +Computes one [`TreemapCell`](#treemapcell) per visible file or folder under +`root`, laid out to fit inside `rect`. + +- `root`: the folder currently zoomed into. Only its direct and nested + children are laid out, up to a fixed maximum depth of 5. +- `rect`: the drawing area. The available radius is derived from + `min(rect.width, rect.height)`. +- `colorMap`: the active [`ExtensionColorMap`](#color-assignment), which + encodes the user's chosen color scheme (By Type, Rainbow, or Mono). + +Returns an empty array if `rect` is too small to fit a ring, or if `root` +has no children with `size > 0`. + +### Layout rule + +Each child's arc angle is proportional to its share of its parent's total +size: + +```swift +let fraction = Double(child.size) / Double(totalSize) +let arcAngle = fraction * totalAngle +``` + +An arc smaller than `minArcAngle` (about 1 degree) is skipped entirely +rather than drawn as an unreadable sliver. Folders recurse into their own +children at the next radius band; files do not. + +## `TreemapCell` + +```swift +public struct TreemapCell: Identifiable { + public let node: FSNode + public let startAngle: Double // radians; 0 = right, clockwise + public let endAngle: Double + public let innerRadius: CGFloat + public let outerRadius: CGFloat + public let color: Color + public let depth: Int +} +``` + +One cell per arc. `startAngle`/`endAngle` are in radians; `innerRadius`/ +`outerRadius` define the ring band for the cell's depth. + +## Color assignment + +Colors are not assigned by `TreemapLayout` directly; it delegates to +`ExtensionColorMap.color(for:)` for files and computes a muted per-folder +tint by hashing the folder's name for directories. Files also darken by a +fixed amount per depth (from 0% at depth 0 up to 38% at depth 5+), so a +`.mp4` five folders deep is visibly darker than one at the top level, even +though both are "orange". diff --git a/guide/tutorial/first-scan.md b/guide/tutorial/first-scan.md new file mode 100644 index 0000000..2b342f4 --- /dev/null +++ b/guide/tutorial/first-scan.md @@ -0,0 +1,39 @@ +# Scan your first folder + +This tutorial is for anyone opening MacDirStat for the first time. By the end, +you will have scanned a real folder and seen its contents as a treemap. + +## Before you start + +You need MacDirStat installed and a folder you are curious about, for +example your Downloads folder. + +## 1. Open MacDirStat + +Launch the app. You see an empty dashboard with a **Scan** button. + +## 2. Choose a folder + +Click **Scan**. A folder picker opens. + +1. Navigate to the folder you want to inspect. +2. Select it and click **Scan** in the picker. + +MacDirStat starts walking the folder tree immediately. Large folders take +longer; a progress indicator shows the scan is still running. + +## 3. Read the result + +When the scan finishes, you see a radial treemap: a ring of colored arcs +around a center point. Each arc is a file or folder; the size of the arc is +proportional to how much disk space it uses. + +- Hover over an arc to see its name and size. +- Click an arc that represents a folder to zoom into it. +- Click the center to zoom back out. + +## Next step + +The colors are not random. Continue to +[How to read the treemap](../how-to/read-the-treemap.md) to learn what each +color means and how to change the color scheme. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..c5754d1 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,27 @@ +site_name: MacDirStat Docs +site_url: https://ti-03.github.io/MacDirStat/guide/ +repo_url: https://github.com/Ti-03/MacDirStat +docs_dir: guide +site_dir: site + +theme: + name: material + palette: + scheme: slate + primary: deep orange + features: + - navigation.sections + - content.code.copy + +nav: + - Home: index.md + - Tutorial: tutorial/first-scan.md + - How-to guides: + - Read the treemap: how-to/read-the-treemap.md + - Reference: + - ByteFormatter: reference/byte-formatter.md + - TreemapLayout: reference/treemap-layout.md + +markdown_extensions: + - admonition + - pymdownx.superfences