For contributors. End-user docs (downloading, preferences, macOS quirks) live in DOCUMENTATION.md; the elevator pitch and keybindings table live in the README.
Prerequisites: Java 21+ and Maven 3.9+.
mvn javafx:runThis runs the app on HotSpot via the javafx-maven-plugin. The plugin downloads JavaFX automatically.
To pass CLI arguments to the launched app — the -debug flag in particular — use -Djavafx.args=... (this is the
plugin's user property for <commandlineArgs>; the more common -Dexec.args belongs to a different plugin and is
silently ignored here):
mvn javafx:run -Djavafx.args="-debug"Prerequisites: GraalVM 21 LTS with
native-image, Maven 3.9+, and the platform toolchain:
- Windows: Visual Studio 2022 with the "Desktop development with C++" workload.
- macOS: Xcode Command Line Tools.
- Linux:
gcc, plus dev headers for X11/GTK as required by JavaFX.
mvn -Pnative gluonfx:build gluonfx:nativerunThe native binary will be at target/gluonfx/<arch>-<os>/diskspace[.exe].
Native builds are pinned to GraalVM 21 LTS — GraalVM 25's Substrate VM has a JNI version regression that breaks JavaFX
glassat startup. Thegluonfx-maven-pluginreadsGRAALVM_HOME(notJAVA_HOME) —scripts/build-native.ps1overrides both.
Linux only: recent GraalVM 21.x builds moved their static libraries into a
glibc/subdirectory thatgluonfx-maven-plugin1.0.27 (Substrate 0.0.68) doesn't know about, sogluonfx:buildfails at the link step withMissing library libjvm.a not in linkpath …/lib/svm/clibraries/linux-amd64. Until upstream (gluonhq/substrate#1318) ships a fix, runbash scripts/patch-graalvm-static-libs-linux.shonce after installing/upgrading GraalVM — it symlinks the libraries to the location Substrate expects. CI does this automatically.
DiskSpace is instrumented for JDK Flight Recorder and ships four
custom events that make it straightforward to correlate user interactions, scans, and per-frame render cost in a
recording. The native image is built with --enable-monitoring=jfr so production binaries can be profiled the same
way as JVM-mode runs.
The -debug flag turns on FINE logging and starts a JFR recording dumped on exit:
# JVM dev (note: -Djavafx.args, NOT -Dexec.args — the javafx-maven-plugin uses its own property)
mvn javafx:run -Djavafx.args="-debug"
# Native binary
./DiskSpace -debugThe output filename is diskspace.jfr (JVM) or diskspace-native.jfr (native), written to the working directory.
Override with -Ddiskspace.jfr.file=/path/to/out.jfr if you want a different name or location.
The recording uses JFR's profile preset — method profiling at higher rates, lock contention, and TLAB allocations —
so the data you actually want for performance investigation is captured out of the box. Overhead is a few percent of
CPU; if profile can't be loaded for some reason (older JDK, stripped JFR), it transparently falls back to default
settings.
Open the .jfr file in JDK Mission Control 10.
The AI Insights view is the recommended starting point — it surfaces hot methods, GC pressure, and lock
contention in plain prose so you can skip the manual click-through. From there:
- Method Profiling → Hot Methods: where the FX thread is actually spending its CPU. Cross-reference with the Render custom event below to attribute hot frames to a specific visualization mode and trigger.
- Memory → Allocations in New TLAB: catches per-frame allocation pressure. The render path was rewritten once
already after JMC pointed out a per-render
IdentityHashMap+Long.valueOfallocation storm in TimSort. - Event Browser → DiskSpace category: the custom events are grouped under
DiskSpace / UIandDiskSpace / Scannerso you can filter on them directly.
Five event types ship in-tree (all under the se.hirt.diskspace.* namespace) and answer questions that built-in JFR
events can't:
| Event | Type | When it fires | Useful for |
|---|---|---|---|
se.hirt.diskspace.Scan |
Duration | One per scan, start → completion / cancellation | Strategy + outcome + file/byte counts per scan. Correlates downstream events via scanId. |
se.hirt.diskspace.VisualizationActive |
Duration | One per window of a particular mode being on screen | "Did the user actually look at voronoi?" Render-count field tells you how many frames painted per window. |
se.hirt.diskspace.Navigation |
Duration | One per user folder navigation, click → first render at root | End-to-end user-perceived latency for a navigation. Carries direction (drill-in / drill-out / jump), fromPath / toPath, nodeCount, siteCount. Every Render fired during it shares the same navId so the join is unambiguous. |
se.hirt.diskspace.Render |
Duration | Per repaint of the canvas on the FX thread | Per-frame cost attributed by trigger (resize / mode-change / scan-update / user / anim / …). The navId joins back to the Navigation event that caused it (or 0 for autonomous repaints); siteCount records post-LOD cell count. |
se.hirt.diskspace.UserAction |
Instant | Keypress or right-click menu action | Reconstructs the user's interaction timeline; pairs with renders to attribute pauses to a specific input. |
Every event from a given scan run carries the same scanId correlation id, so filtering on it gives you "everything
that happened during scan #N" in one slice. Navigation adds a second correlation key — navId — stamped onto every
Render fired during the navigation's begin → commit window, so you can directly join "click → renders it caused"
without timestamp overlap. The Render event also captures nodeCount, siteCount, width, and height, so you can
plot render-cost-vs-tree-size or render-cost-vs-canvas-size without leaving JMC. siteCount is post-LOD: for
Voronoi it's typically 100–400 even on million-node trees (see
DOCUMENTATION.md § Level of detail); for sunburst and heatmap it's 0, meaning
"no LOD in play — use nodeCount instead". Compare the two fields to read the LOD ratio directly off any frame.
Gluon-distributed GraalVM historically shipped a broken FlightRecorder engine (gluonhq/substrate#1354) — recordings open but contain no events. CI native builds use Oracle GraalVM directly; if you're rolling your own and the recording is empty, check which distribution is on
GRAALVM_HOME.
End-user behaviour and the two built-in modes are covered in DOCUMENTATION.md § Coloring modes; this section is the implementer's recipe.
A new mode is two things: an implementation of ColoringMode, and one line added to the registry list in
se.hirt.diskspace.ui.render.ColoringModes. That's the whole contract — no service files, no reflection, no
build-time wiring tricks, no JVM-vs-native special cases. Submit the addition as a PR and it ships in the next
release.
se.hirt.diskspace.ui.render.ColoringMode is the factory — DiskSpace asks each registered mode for a fresh resolver
when a new tab is opened:
public interface ColoringMode {
String id(); // stable persistence key, kebab-case
String displayName(); // appears in the Preferences picker
String description(); // one or two sentences, shown under the picker
NodeColorResolver createResolver(ColorScheme scheme, ToIntFunction<DirectoryNode> rankOf);
}se.hirt.diskspace.ui.render.NodeColorResolver is the per-DiskView worker — it answers "what colour for this
node?" and gets four optional lifecycle hooks (each with a default no-op) for cache invalidation:
public interface NodeColorResolver {
Color colorFor(DirectoryNode node);
default void setScanRoot(DirectoryNode root) {
}
default void setHiddenNode(DirectoryNode hidden) {
}
default void onScanComplete() {
}
default void stabilizeFinalizedTopLevels() {
}
}rankOf returns the child's index in its parent's size-descending sibling list — 0 is the largest child. Use it if
you want a rank-aware palette; ignore it for absolute-size schemes.
package com.example.diskspace;
import javafx.scene.paint.Color;
import se.hirt.diskspace.model.DirectoryNode;
import se.hirt.diskspace.ui.render.ColoringMode;
import se.hirt.diskspace.ui.render.NodeColorResolver;
import se.hirt.diskspace.ui.theme.ColorScheme;
import java.util.function.ToIntFunction;
public final class RainbowColoringMode implements ColoringMode {
@Override
public String id() {
return "rainbow";
}
@Override
public String displayName() {
return "Rainbow";
}
@Override
public String description() {
return "Sibling rank picks a hue from a 12-step rainbow palette. " + "Useful for spotting which children dominate at a glance.";
}
@Override
public NodeColorResolver createResolver(ColorScheme scheme, ToIntFunction<DirectoryNode> rankOf) {
return node -> Color.hsb((rankOf.applyAsInt(node) * 30) % 360, 0.65, 0.85);
}
}Register it by adding one entry to the list in ColoringModes.MODES:
private static final List<ColoringMode> MODES = List.of(new ClassicColoringMode(), new BlackAndWhiteColoringMode(),
new RainbowColoringMode()); // ← your modeThe new mode shows up in the Preferences picker the next time the app starts. List order is display order; the first entry is also the default for fresh installs, so don't reorder casually.
- Cache per node if your
colorForis non-trivial. The renderer calls it every frame for every visible node; even a small allocation per call will show up in JFR. SeeNodeColorResolverImplfor the pattern used by the Classic mode. - Invalidate on
setScanRootandonScanComplete— sibling ranks shift while a scan is live, so colours derived from rank or final size order should be dropped at those transitions.stabilizeFinalizedTopLevelsis called once per live tick if you want fine-grained invalidation per top-level folder as it finishes. - Honour the
ColorSchemeif your mode is theme-aware —scheme.surface()is the right background colour for the sunburst hub / scan-root sector. - Pick a stable
id. It's the key written tosettings.properties. Changing it later silently resets every user's saved preference to the default. The id also de-duplicates against built-ins — providers with a colliding id are logged and skipped at startup.
Pushing a v* tag triggers .github/workflows/release.yml, which builds native binaries for
Linux / macOS / Windows and creates a GitHub Release with all artifacts attached. macOS binaries
are signed and notarised inside CI using the secrets the workflow already references.
CI publishes the Windows release with only the unsigned bare
diskspace-<v>-windows-x86_64.exe. The Inno Setup installer is built and signed locally as part
of the post-release signing step, so the installer always wraps an already-signed binary — if
Inno wrapped an unsigned exe (as it did in CI before this split), the user would end up with an
unsigned binary in Program Files that AV/SmartScreen rescans could still flag, defeating most
of the point of signing.
scripts/sign-release.ps1 does the whole post-release flow in
one shot:
- Downloads the unsigned bare exe from the GitHub release.
- Signs it with the Certum cert (SimplySign Desktop must be logged in).
- Builds the Inno Setup installer locally, wrapping the signed bare exe.
- Signs the installer.
- Uploads bare exe (clobbering the unsigned one) and the new installer.
Why all signing is post-release rather than in CI: Certum's cloud signing requires SimplySign Desktop + an OTP push from the mobile app, and GitHub-hosted Windows runners are headless (no GUI session to drive SimplySign).
One-time setup on your signing machine:
-
Install SimplySign Desktop and the SimplySign mobile app, paired against your Certum account.
-
Install Inno Setup 6 (https://jrsoftware.org/isdl.php). The script defaults to
C:\Program Files (x86)\Inno Setup 6\ISCC.exe; pass-Isccif yours is elsewhere. -
Install the Windows SDK (gets you
signtool.exe; the script auto-finds it underC:\Program Files (x86)\Windows Kits\10\bin\…\x64). -
Install the GitHub CLI (
gh) and authenticate against the repo. -
Add to your PowerShell
$PROFILE:$env:DISKSPACE_SIGN_THUMBPRINT = '<SHA1 thumbprint of your Certum cert>'
The thumbprint isn't sensitive (it's readable from any signed binary). It lives in your profile rather than the repo so cert renewal is a one-line edit on your machine, not a commit. Always pin by thumbprint — never use
signtool /a, which can silently pick the wrong cert if your store has more than one (e.g. a stray self-signed dev cert).
Per release:
-
Push the
v*tag and wait for the release workflow to publish (~15 min for the full matrix). The release will show only the bare Windows.exeat this point; no installer yet. -
Open SimplySign Desktop, log in (OTP from the mobile app).
-
Run:
.\scripts\sign-release.ps1 -Tag v0.2.8
~30 seconds. The script signs the bare exe, builds and signs the installer, and uploads both to the release.
SmartScreen reputation for the cert builds over downloads + time — fresh Certum OSS certs ship without instant SmartScreen trust, so early-release users may still see "Windows protected your PC" until reputation accumulates. Edge's download warning ("not commonly downloaded") fades on the same curve.
mvn javafx:runcomplains about missing JavaFX modules: ensureJAVA_HOMEpoints to a JDK 21+ and that Maven is using it (mvn -v). The plugin downloads JavaFX automatically.- Native build fails on Windows: open a "x64 Native Tools Command Prompt for VS 2022" and run
mvn -Pnative gluonfx:buildfrom there — the C++ toolchain must be onPATH. - Native build fails on Linux with
Missing library libjvm.a: GraalVM static-library layout changed; runbash scripts/patch-graalvm-static-libs-linux.shonce. See the Linux note in Native image. - JFR file is empty in a native build: make sure the build includes
--enable-monitoring=jfrand-R:+FlightRecorder(set in thenativeprofile), and that the link step pulls inlibmanagement_ext(set in thenative-mac/native-linuxprofiles). Gluon-distributed GraalVM historically shipped a broken FlightRecorder engine (gluonhq/substrate#1354) — build against Oracle GraalVM for a working JFR.
End-user-facing issues (blank window, Wayland pipeline fallback) live in DOCUMENTATION.md § Troubleshooting.
Contributions made in collaboration with AI tools are welcome. Use whatever helps you work.
That said, the AI is a collaborator, not the contributor. You are responsible for reviewing and testing every change before you open a PR: read the diff line by line, understand what it does and why, and run it. Submitting code you haven't read or exercised, on the grounds that "the model said it works", is not enough.
When you put your name on a PR, you are vouching for it. You are ultimately responsible for the contribution, regardless of how much of it an AI wrote.