From da5226d307bd287217364c9cca16a8bea0519bfc Mon Sep 17 00:00:00 2001 From: Muddyblack <70057554+Muddyblack@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:35:42 +0200 Subject: [PATCH 1/2] feat: toggle off the Nix store measurement (#39) New measureStore setting (config key measure_store, NIXDATIFIER_MEASURE_STORE=off). Off skips the slow store walk: the window shows free space only, and `nixdatifier disk` (and so the TUI) prints just the free space. --- docs/cli.md | 1 + docs/configuration.md | 1 + hyprland/NixdatifierShell.qml | 1 + package/contents/config/main.xml | 4 ++++ package/contents/tools/sh/nixdatifier | 17 +++++++++++------ package/contents/tools/sh/tui | 1 + package/contents/ui/ApplicationView.qml | 1 + package/contents/ui/Engine.qml | 14 +++++++++++++- package/contents/ui/FullView.qml | 8 +++++++- package/contents/ui/Settings.qml | 1 + package/contents/ui/SettingsSchema.js | 8 ++++++++ package/contents/ui/configGeneral.qml | 2 ++ tests/test_helpers.py | 9 +++++++++ tests/tst_Engine.qml | 10 ++++++++++ 14 files changed, 70 insertions(+), 8 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 15883cf..465dca8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -147,6 +147,7 @@ There is no config file; it is flags and environment, so it works the same in a | `NIXDATIFIER_GITHUB_TOKEN_FILE` | file holding a GitHub token, used when `GITHUB_TOKEN` is unset (config key `github_token_file`). | | `NIXDATIFIER_CHECK_TARBALLS` | `off` skips tarball flake inputs when checking for updates (config key `check_tarballs`; on by default) | | `NIXDATIFIER_DEEP_INPUTS` | `on` checks every transitive flake input, not just the root (and wrapper) inputs (config key `deep_inputs`; off by default) | +| `NIXDATIFIER_MEASURE_STORE` | `off` skips the slow store measurement; `disk` and the window then show only free space (config key `measure_store`; on by default) | | `NIXDATIFIER_IGNORE_INPUTS` | comma-separated input names (globs allowed) to leave out of update checks (config key `ignored_inputs`; empty by default) | | `NIXDATIFIER_GITHUB_USE_GH` | `on` borrows the login of a signed-in `gh` when there is no token (config key `github_use_gh`; off by default, its token often has wide scopes) | diff --git a/docs/configuration.md b/docs/configuration.md index 5fb94fe..f103ab7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,6 +57,7 @@ reason; the widget also shows a notification when an upstream cannot be reached. | **Show delete generation action** | `true` | Shows Delete on generation cards. | | **Show update notifications** | `true` | Desktop notifications for new flake updates and finished actions. | | **Refresh generations when the popup opens** | `true` | Reloads generations and system info each time you open the popup. | +| **Measure the Nix store** | `true` | Walks the store for its size and the estimated reclaimable space. Off shows free space only (also `measure_store` in the CLI/TUI config). | | **Check flake inputs in the background** | `true` | Turns the update checks on or off. | | **Deployed secrets directory** | `""` | Empty detects `/run/secrets` or `/run/agenix.d`. | | **Encrypted source secrets path** | `""` | Empty detects a SOPS file in the flake directory. | diff --git a/hyprland/NixdatifierShell.qml b/hyprland/NixdatifierShell.qml index f786dea..129192b 100644 --- a/hyprland/NixdatifierShell.qml +++ b/hyprland/NixdatifierShell.qml @@ -63,6 +63,7 @@ ShellRoot { property bool enableHostDetect: true property bool checkTarballs: true property bool deepInputCheck: false + property bool measureStore: true property string ignoredInputs: "" property string customCommands: "[{\"label\":\"update\",\"cmd\":\"nix flake update\"},{\"label\":\"upnix\",\"cmd\":\"upnix\"}]" property bool showCommandButtons: true diff --git a/package/contents/config/main.xml b/package/contents/config/main.xml index f6fb62c..bd869b4 100644 --- a/package/contents/config/main.xml +++ b/package/contents/config/main.xml @@ -34,6 +34,10 @@ false + + + true + diff --git a/package/contents/tools/sh/nixdatifier b/package/contents/tools/sh/nixdatifier index b572e19..042ffb3 100755 --- a/package/contents/tools/sh/nixdatifier +++ b/package/contents/tools/sh/nixdatifier @@ -126,14 +126,14 @@ unknown_name() { # Saved settings (nixdatifier config): they fill in what the environment leaves # empty, so a variable or option on the command line still wins. CONFIG_FILE=${NIXDATIFIER_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/nixdatifier/config} -CONFIG_KEYS="flake config_repo secrets secrets_source persist priv commands check_interval notifications github_token_file github_use_gh check_tarballs deep_inputs ignored_inputs" +CONFIG_KEYS="flake config_repo secrets secrets_source persist priv commands check_interval notifications github_token_file github_use_gh check_tarballs deep_inputs ignored_inputs measure_store" config_var() { # config_var KEY: the environment variable a key stands for case $1 in flake) echo NIXDATIFIER_FLAKE ;; config_repo) echo NIXDATIFIER_CONFIG_REPO ;; secrets) echo NIXDATIFIER_SECRETS ;; secrets_source) echo NIXDATIFIER_SECRETS_SOURCE ;; persist) echo NIXDATIFIER_PERSIST ;; priv) echo NIXDATIFIER_PRIV ;; commands) echo NIXDATIFIER_COMMANDS ;; check_interval) echo NIXDATIFIER_CHECK_INTERVAL ;; notifications) echo NIXDATIFIER_NOTIFICATIONS ;; github_token_file) echo NIXDATIFIER_GITHUB_TOKEN_FILE ;; github_use_gh) echo NIXDATIFIER_GITHUB_USE_GH ;; check_tarballs) echo NIXDATIFIER_CHECK_TARBALLS ;; deep_inputs) echo NIXDATIFIER_DEEP_INPUTS ;; - ignored_inputs) echo NIXDATIFIER_IGNORE_INPUTS ;; esac + ignored_inputs) echo NIXDATIFIER_IGNORE_INPUTS ;; measure_store) echo NIXDATIFIER_MEASURE_STORE ;; esac } config_file_get() { [[ -f $CONFIG_FILE ]] && sed -n "s/^$1=//p" "$CONFIG_FILE" | tail -1; return 0; } COMMANDS_FROM_ENV=${NIXDATIFIER_COMMANDS:+1} @@ -454,6 +454,11 @@ cmd_disk() { kv '"path\t\(.path)", "exists\t\(.exists)", "closure\t\(.closureBytes | hb)", (.roots[] | "root\t\(.)"), (.referrers[] | "referrer\t\(.)")' return fi + if [[ ${NIXDATIFIER_MEASURE_STORE:-} == off ]]; then # the slow walk is switched off: only the free space + free=$("$here/diskusage" --free) || die 'could not read the free space' + jq -n --argjson f "$free" '{freeBytes: $f}' | kv '"free\t\(.freeBytes | hb)", "note\tstore measurement is off (measure_store)"' + return + fi IFS=$'\x1e' read -r total reclaimable free stamp < <(NOTE='Measuring the store…' cached disk:nar-v1 1800 -- "$here/diskusage") || die 'could not measure the store' jq -n --argjson t "$total" --argjson r "$reclaimable" --argjson f "$free" --argjson s "${stamp:-0}" \ '{storeBytes: $t, reclaimableBytes: $r, freeBytes: $f, measuredAt: $s}' | @@ -967,7 +972,7 @@ cmd_persist() { # The window keeps these settings in its own store (KDE config, a JSON file), # so it mirrors them into the config file through `config sync`: the terminal # view and the window then show and change the same values. GUI name:file key. -GUI_MAP="flakePath:flake configRepoPath:config_repo secretsPath:secrets secretsSourcePath:secrets_source impermanencePaths:persist checkInterval:check_interval customCommands:commands showNotifications:notifications githubTokenFile:github_token_file githubUseGh:github_use_gh checkTarballs:check_tarballs deepInputCheck:deep_inputs ignoredInputs:ignored_inputs" +GUI_MAP="flakePath:flake configRepoPath:config_repo secretsPath:secrets secretsSourcePath:secrets_source impermanencePaths:persist checkInterval:check_interval customCommands:commands showNotifications:notifications githubTokenFile:github_token_file githubUseGh:github_use_gh checkTarballs:check_tarballs deepInputCheck:deep_inputs ignoredInputs:ignored_inputs measureStore:measure_store" config_file_put() { # config_file_put KEY VALUE: set it, or remove it when empty mkdir -p "$(dirname "$CONFIG_FILE")" || die "cannot create $(dirname "$CONFIG_FILE")" { grep -v "^$1=" "$CONFIG_FILE" 2>/dev/null; [[ -z $2 ]] || echo "$1=$2"; } >"$CONFIG_FILE.new" && mv "$CONFIG_FILE.new" "$CONFIG_FILE" @@ -1000,7 +1005,7 @@ gui_sync() { # gui_sync pull|push JSON: JSON holds the window's current values showNotifications) case $v in true) v=on ;; false) v=off ;; *) v='' ;; esac [[ $mode == pull && $v == on ]] && v='' ;; # on is the default everywhere githubUseGh | deepInputCheck) case $v in true) v=on ;; *) v='' ;; esac ;; # off is the default - checkTarballs) case $v in false) v=off ;; *) v='' ;; esac ;; # on is the default + checkTarballs | measureStore) case $v in false) v=off ;; *) v='' ;; esac ;; # on is the default customCommands) v=$(commands_normalize "$v") || v='' [[ $mode == pull && $v == '[]' ]] && v='' ;; esac @@ -1020,7 +1025,7 @@ gui_sync() { # gui_sync pull|push JSON: JSON holds the window's current values showNotifications) [[ $v == on || $v == off ]] || continue; out=$(jq -c --arg g "$g" --argjson v "$([[ $v == on ]] && echo true || echo false)" '.[$g] = $v' <<<"$out") ;; # Always answer these, so clearing the file's value restores the default in the window. githubUseGh | deepInputCheck) out=$(jq -c --arg g "$g" --argjson v "$([[ $v == on ]] && echo true || echo false)" '.[$g] = $v' <<<"$out") ;; - checkTarballs) out=$(jq -c --arg g "$g" --argjson v "$([[ $v == off ]] && echo false || echo true)" '.[$g] = $v' <<<"$out") ;; + checkTarballs | measureStore) out=$(jq -c --arg g "$g" --argjson v "$([[ $v == off ]] && echo false || echo true)" '.[$g] = $v' <<<"$out") ;; customCommands) [[ -n $v ]] || continue; out=$(jq -c --arg g "$g" --arg v "$v" '.[$g] = $v' <<<"$out") ;; *) out=$(jq -c --arg g "$g" --arg v "$v" '.[$g] = $v' <<<"$out") ;; esac @@ -1046,7 +1051,7 @@ cmd_config() { [[ $key != commands ]] || v=$(commands_normalize "$v") || usage_error 'commands is a JSON list: [{"label":"update","cmd":"nix flake update"}]' [[ $key != notifications || -z $v || $v =~ ^(on|off)$ ]] || usage_error 'notifications takes on or off' [[ $key != github_use_gh || -z $v || $v =~ ^(on|off)$ ]] || usage_error 'github_use_gh takes on or off' - [[ $key != check_tarballs && $key != deep_inputs || -z $v || $v =~ ^(on|off)$ ]] || usage_error "$key takes on or off" + [[ $key != check_tarballs && $key != deep_inputs && $key != measure_store || -z $v || $v =~ ^(on|off)$ ]] || usage_error "$key takes on or off" [[ $key != check_interval || -z $v || $v =~ ^[1-9][0-9]*[mhd]$ ]] || usage_error 'check_interval takes minutes, hours or days: 30m, 6h, 1d' mkdir -p "$(dirname "$CONFIG_FILE")" || die "cannot create $(dirname "$CONFIG_FILE")" { grep -v "^$key=" "$CONFIG_FILE" 2>/dev/null; [[ -z $v ]] || echo "$key=$v"; } >"$CONFIG_FILE.new" && mv "$CONFIG_FILE.new" "$CONFIG_FILE" diff --git a/package/contents/tools/sh/tui b/package/contents/tools/sh/tui index 44bc97d..e172d0b 100755 --- a/package/contents/tools/sh/tui +++ b/package/contents/tools/sh/tui @@ -735,6 +735,7 @@ settings_desc() { # settings_desc KEY: what it is github_use_gh) echo 'on: borrow the GitHub CLI login (gh auth token) for release notes when no token file is set. Its token often has wide scopes, so it is off unless you switch it on. Empty: off.' ;; check_tarballs) echo 'off: skip tarball flake inputs (channels, .tar.zst) when checking for updates, here and in the window. Empty: on.' ;; deep_inputs) echo 'on: check every transitive flake input, not just the root (and wrapper) inputs. Costs one request per input. Empty: off.' ;; + measure_store) echo 'off: do not measure the Nix store (total and reclaimable size); only free space is shown, here and in the window. Empty: on.' ;; ignored_inputs) echo 'Flake inputs to skip when checking for updates: names separated by commas, globs allowed (nur, my-private/*). Empty: check them all.' ;; theme) echo 'Colour theme: deep, marina, midnight, full, plain or your own (see docs/cli.md). Enter cycles.' ;; layout) echo 'roomy: boxed sections with the logo. compact: no boxes. Enter switches.' ;; diff --git a/package/contents/ui/ApplicationView.qml b/package/contents/ui/ApplicationView.qml index a4999fb..df82c93 100644 --- a/package/contents/ui/ApplicationView.qml +++ b/package/contents/ui/ApplicationView.qml @@ -57,6 +57,7 @@ FullView { diskReclaimableBytes: engine.diskReclaimableBytes diskFreeBytes: engine.diskFreeBytes diskUsageError: engine.diskUsageError + measureStore: engine.settings.measureStore isProbingDiskUsage: engine._diskProbeRunning hashResult: engine.hashResult isProbingHash: engine.isProbingHash diff --git a/package/contents/ui/Engine.qml b/package/contents/ui/Engine.qml index 6d904d2..919a0ce 100644 --- a/package/contents/ui/Engine.qml +++ b/package/contents/ui/Engine.qml @@ -490,6 +490,11 @@ Item { property string diskUsageError: "" function probeDiskUsage(force) { + if (!root.settings.measureStore) { + // Only the cheap free-space read; the slow store walk stays off. + root.probeFreeDiskSpace(); + return; + } if (force) root._diskProbeDirty = true; if (root._diskProbeRunning) @@ -2767,7 +2772,7 @@ Item { property bool syncSettings: false property bool _syncReady: false property bool _syncApplying: false - readonly property var _syncKeys: ["flakePath", "configRepoPath", "secretsPath", "secretsSourcePath", "githubTokenFile", "githubUseGh", "checkTarballs", "deepInputCheck", "ignoredInputs", "impermanencePaths", "checkInterval", "customCommands", "showNotifications"] + readonly property var _syncKeys: ["flakePath", "configRepoPath", "secretsPath", "secretsSourcePath", "githubTokenFile", "githubUseGh", "checkTarballs", "deepInputCheck", "measureStore", "ignoredInputs", "impermanencePaths", "checkInterval", "customCommands", "showNotifications"] function _syncSnapshot() { const values = {}; for (const key of root._syncKeys) @@ -2854,6 +2859,13 @@ Item { function onDeepInputCheckChanged() { root.schedulePushSharedSettings(); } + function onMeasureStoreChanged() { + root.schedulePushSharedSettings(); + if (root.settings.measureStore) + root.probeDiskUsage(true); + else + root.probeFreeDiskSpace(); + } function onIgnoredInputsChanged() { root.schedulePushSharedSettings(); root.recheckAfterListChange(); diff --git a/package/contents/ui/FullView.qml b/package/contents/ui/FullView.qml index ae88c1d..d7c1cd2 100644 --- a/package/contents/ui/FullView.qml +++ b/package/contents/ui/FullView.qml @@ -79,6 +79,7 @@ Pane { required property real diskFreeBytes property string diskUsageError: "" property bool isProbingDiskUsage: false + property bool measureStore: true readonly property string storeLabel: UI.Theme.formatBytes(diskStoreBytes) || "—" readonly property string storeMeasurementHint: diskUsageError ? qsTr("Refresh failed; showing the last measurement. %1").arg(diskUsageError) : qsTr("Logical store contents and estimated GC candidates use NAR sizes. Actual space freed depends on shared files, compression and snapshots. Old generations remain protected until removed.") @@ -830,17 +831,19 @@ Pane { Layout.fillWidth: true Text { Layout.fillWidth: true - text: qsTr("Nix store · logical size") + text: fullView.measureStore ? qsTr("Nix store · logical size") : qsTr("Nix store · free space") color: fullView.textColor font.pixelSize: 10 * fullView.fs } Text { + visible: fullView.measureStore text: fullView.storeLabel color: "#93a5bd" font.pixelSize: 9 * fullView.fs } UI.ActionButton { objectName: "refreshStoreUsage" + visible: fullView.measureStore text: fullView.isProbingDiskUsage ? qsTr("Measuring…") : qsTr("Refresh") glyph: fullView.svg("ic_refresh") flatStyle: true @@ -852,6 +855,7 @@ Pane { } } Rectangle { + visible: fullView.measureStore Layout.fillWidth: true Layout.topMargin: 13 Layout.bottomMargin: 10 @@ -878,6 +882,7 @@ Pane { } RowLayout { Layout.fillWidth: true + Layout.topMargin: fullView.measureStore ? 0 : 10 Text { Layout.fillWidth: true text: qsTr("%1 free").arg(UI.Theme.formatBytes(fullView.diskFreeBytes) || "—") @@ -885,6 +890,7 @@ Pane { font.pixelSize: 9 * fullView.fs } Text { + visible: fullView.measureStore text: qsTr("≈ %1 reclaimable").arg(fullView.reclaimableLabel) color: UI.Theme.changed font.pixelSize: 9 * fullView.fs diff --git a/package/contents/ui/Settings.qml b/package/contents/ui/Settings.qml index 4304e0c..d956348 100644 --- a/package/contents/ui/Settings.qml +++ b/package/contents/ui/Settings.qml @@ -9,6 +9,7 @@ QtObject { property bool enableHostDetect: true property bool checkTarballs: true property bool deepInputCheck: false + property bool measureStore: true property string ignoredInputs: "" property string customCommands: "[{\"label\":\"update\",\"cmd\":\"nix flake update\"},{\"label\":\"upnix\",\"cmd\":\"upnix\"}]" property bool showCommandButtons: true diff --git a/package/contents/ui/SettingsSchema.js b/package/contents/ui/SettingsSchema.js index 2a87b2d..daaaefe 100644 --- a/package/contents/ui/SettingsSchema.js +++ b/package/contents/ui/SettingsSchema.js @@ -119,6 +119,14 @@ var fields = [ "help": "Input names separated by commas; globs work, e.g. nur, my-private/*. Use wrapper/input for inputs of a wrapper flake. Empty means every input is checked.", "type": "String" }, + { + "key": "measureStore", + "tab": "General", + "group": "Storage", + "label": "Measure the Nix store", + "help": "Walk the store to show its size and how much garbage collection could free. Turn off on big stores or slow disks; only free space is shown then.", + "type": "Bool" + }, { "key": "customCommands", "tab": "Commands", diff --git a/package/contents/ui/configGeneral.qml b/package/contents/ui/configGeneral.qml index 90603ee..56b941d 100644 --- a/package/contents/ui/configGeneral.qml +++ b/package/contents/ui/configGeneral.qml @@ -18,6 +18,7 @@ Kirigami.Page { property alias cfg_enableHostDetect: state.enableHostDetect property alias cfg_checkTarballs: state.checkTarballs property alias cfg_deepInputCheck: state.deepInputCheck + property alias cfg_measureStore: state.measureStore property alias cfg_ignoredInputs: state.ignoredInputs property alias cfg_customCommands: state.customCommands property alias cfg_showCommandButtons: state.showCommandButtons @@ -69,6 +70,7 @@ Kirigami.Page { property alias cfg_enableHostDetectDefault: defaults.enableHostDetect property alias cfg_checkTarballsDefault: defaults.checkTarballs property alias cfg_deepInputCheckDefault: defaults.deepInputCheck + property alias cfg_measureStoreDefault: defaults.measureStore property alias cfg_ignoredInputsDefault: defaults.ignoredInputs property alias cfg_customCommandsDefault: defaults.customCommands property alias cfg_showCommandButtonsDefault: defaults.showCommandButtons diff --git a/tests/test_helpers.py b/tests/test_helpers.py index d615f54..093ef81 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1396,6 +1396,15 @@ def test_probe_settings_are_shared_through_the_config_file(self): later = json.loads(self.cli_run("config", "sync", "pull", json.dumps(window)).stdout) self.assertIs(later["deepInputCheck"], False) + def test_measure_store_is_shared_and_validated(self): + window = {"measureStore": False} + self.cli_run("config", "sync", "pull", json.dumps(window)) + self.assertEqual(self.cli_run("config", "get", "measure_store").stdout.strip(), "off") + self.assertIn("takes on or off", self.cli_run("config", "set", "measure_store", "no").stderr) + self.cli_run("config", "unset", "measure_store") + later = json.loads(self.cli_run("config", "sync", "pull", json.dumps(window)).stdout) + self.assertIs(later["measureStore"], True) # clearing restores the default in the window + def test_window_settings_sync_both_ways(self): window = { "flakePath": "/etc/nixos", diff --git a/tests/tst_Engine.qml b/tests/tst_Engine.qml index 6f98eed..f4195b9 100644 --- a/tests/tst_Engine.qml +++ b/tests/tst_Engine.qml @@ -1044,6 +1044,16 @@ Item { core.probeDiskUsage(false); compare(jobs.length, 2); } + function test_store_measurement_can_be_switched_off() { + const original = cfg.measureStore; + cfg.measureStore = false; + jobs = []; + core.probeDiskUsage(true); + compare(jobs.length, 1); + verify(jobs[0].cmd.indexOf("diskusage") > 0 && jobs[0].cmd.indexOf(" --free") > 0, jobs[0].cmd); + verify(jobs[0].cmd.indexOf("disk:nar-v1") < 0, jobs[0].cmd); + cfg.measureStore = original; + } function test_disk_errors_preserve_last_measurement_and_allow_retry() { core.diskStoreBytes = 100; core.diskReclaimableBytes = 25; From 3276e23c0f3fbd4c0a619aa61a2eea58369d92d6 Mon Sep 17 00:00:00 2001 From: Muddyblack <70057554+Muddyblack@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:44:24 +0200 Subject: [PATCH 2/2] feat: with measuring off, a manual Measure still walks the store (#39) --- docs/cli.md | 2 +- docs/configuration.md | 2 +- package/contents/tools/sh/nixdatifier | 6 +++--- package/contents/tools/sh/tui | 2 +- package/contents/ui/ApplicationView.qml | 2 +- package/contents/ui/Engine.qml | 14 ++++++++++---- package/contents/ui/FullView.qml | 17 +++++++++-------- tests/tst_Engine.qml | 9 +++++++++ 8 files changed, 35 insertions(+), 19 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 465dca8..f829276 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -147,7 +147,7 @@ There is no config file; it is flags and environment, so it works the same in a | `NIXDATIFIER_GITHUB_TOKEN_FILE` | file holding a GitHub token, used when `GITHUB_TOKEN` is unset (config key `github_token_file`). | | `NIXDATIFIER_CHECK_TARBALLS` | `off` skips tarball flake inputs when checking for updates (config key `check_tarballs`; on by default) | | `NIXDATIFIER_DEEP_INPUTS` | `on` checks every transitive flake input, not just the root (and wrapper) inputs (config key `deep_inputs`; off by default) | -| `NIXDATIFIER_MEASURE_STORE` | `off` skips the slow store measurement; `disk` and the window then show only free space (config key `measure_store`; on by default) | +| `NIXDATIFIER_MEASURE_STORE` | `off` skips the slow store measurement; `disk` and the window then show only free space until you ask (`disk --refresh`, or Measure in the window; config key `measure_store`; on by default) | | `NIXDATIFIER_IGNORE_INPUTS` | comma-separated input names (globs allowed) to leave out of update checks (config key `ignored_inputs`; empty by default) | | `NIXDATIFIER_GITHUB_USE_GH` | `on` borrows the login of a signed-in `gh` when there is no token (config key `github_use_gh`; off by default, its token often has wide scopes) | diff --git a/docs/configuration.md b/docs/configuration.md index f103ab7..948e768 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,7 +57,7 @@ reason; the widget also shows a notification when an upstream cannot be reached. | **Show delete generation action** | `true` | Shows Delete on generation cards. | | **Show update notifications** | `true` | Desktop notifications for new flake updates and finished actions. | | **Refresh generations when the popup opens** | `true` | Reloads generations and system info each time you open the popup. | -| **Measure the Nix store** | `true` | Walks the store for its size and the estimated reclaimable space. Off shows free space only (also `measure_store` in the CLI/TUI config). | +| **Measure the Nix store** | `true` | Walks the store for its size and the estimated reclaimable space. Off shows free space only until you click Measure (also `measure_store` in the CLI/TUI config). | | **Check flake inputs in the background** | `true` | Turns the update checks on or off. | | **Deployed secrets directory** | `""` | Empty detects `/run/secrets` or `/run/agenix.d`. | | **Encrypted source secrets path** | `""` | Empty detects a SOPS file in the flake directory. | diff --git a/package/contents/tools/sh/nixdatifier b/package/contents/tools/sh/nixdatifier index 042ffb3..fc1dc59 100755 --- a/package/contents/tools/sh/nixdatifier +++ b/package/contents/tools/sh/nixdatifier @@ -22,7 +22,7 @@ help_entry() { generations) echo "generations [--limit N]#system generations, newest first#Marks the running (ACTIVE) and booted generation. JSON has storePath, nixosVersion and kernel." ;; updates) echo "updates [--all] [--check]#flake inputs behind upstream#Probes each input's remote (cached for the check interval, 1h by default, and shared with the window; --refresh checks again). --all also lists up-to-date and pinned inputs. --check exits 2 when an update exists, for cron and CI." ;; diff) echo "diff [--config] [A [B]]#what changed between generations (default: newest vs previous)#Package changes with sizes. --config shows the changes in your .nix files instead (needs recorded snapshots, see: help snapshot)." ;; - disk) echo "disk [STORE-PATH]#store size and what GC could free; or why a path is kept#With a store path: its GC roots, referrers and closure size." ;; + disk) echo "disk [STORE-PATH]#store size and what GC could free; or why a path is kept#With a store path: its GC roots, referrers and closure size. With measure_store off, only free space is shown; --refresh measures once." ;; health) echo "health#release/EOL, reboot needed, failed units, binary caches#Read only. Works on any distro with Nix; NixOS-only checks are left out elsewhere." ;; status) echo "status [--updates]#one-line summary for prompts and status bars#Running generation, reboot needed, failed units. --updates adds a (cached) upstream check. --json for scripts." ;; history) echo "history [list|clear]#what earlier switches, cleanups and updates did#Shared with the window: the last 50 runs, including their output (--json). clear forgets them (asks first)." ;; @@ -454,9 +454,9 @@ cmd_disk() { kv '"path\t\(.path)", "exists\t\(.exists)", "closure\t\(.closureBytes | hb)", (.roots[] | "root\t\(.)"), (.referrers[] | "referrer\t\(.)")' return fi - if [[ ${NIXDATIFIER_MEASURE_STORE:-} == off ]]; then # the slow walk is switched off: only the free space + if [[ ${NIXDATIFIER_MEASURE_STORE:-} == off ]] && ((!refresh)); then # the slow walk is off unless asked for (--refresh): only the free space free=$("$here/diskusage" --free) || die 'could not read the free space' - jq -n --argjson f "$free" '{freeBytes: $f}' | kv '"free\t\(.freeBytes | hb)", "note\tstore measurement is off (measure_store)"' + jq -n --argjson f "$free" '{freeBytes: $f}' | kv '"free\t\(.freeBytes | hb)", "note\tstore measurement is off; disk --refresh measures once"' return fi IFS=$'\x1e' read -r total reclaimable free stamp < <(NOTE='Measuring the store…' cached disk:nar-v1 1800 -- "$here/diskusage") || die 'could not measure the store' diff --git a/package/contents/tools/sh/tui b/package/contents/tools/sh/tui index e172d0b..525a574 100755 --- a/package/contents/tools/sh/tui +++ b/package/contents/tools/sh/tui @@ -735,7 +735,7 @@ settings_desc() { # settings_desc KEY: what it is github_use_gh) echo 'on: borrow the GitHub CLI login (gh auth token) for release notes when no token file is set. Its token often has wide scopes, so it is off unless you switch it on. Empty: off.' ;; check_tarballs) echo 'off: skip tarball flake inputs (channels, .tar.zst) when checking for updates, here and in the window. Empty: on.' ;; deep_inputs) echo 'on: check every transitive flake input, not just the root (and wrapper) inputs. Costs one request per input. Empty: off.' ;; - measure_store) echo 'off: do not measure the Nix store (total and reclaimable size); only free space is shown, here and in the window. Empty: on.' ;; + measure_store) echo 'off: do not measure the Nix store (total and reclaimable size); free space only until you ask (disk --refresh, or Measure in the window). Empty: on.' ;; ignored_inputs) echo 'Flake inputs to skip when checking for updates: names separated by commas, globs allowed (nur, my-private/*). Empty: check them all.' ;; theme) echo 'Colour theme: deep, marina, midnight, full, plain or your own (see docs/cli.md). Enter cycles.' ;; layout) echo 'roomy: boxed sections with the logo. compact: no boxes. Enter switches.' ;; diff --git a/package/contents/ui/ApplicationView.qml b/package/contents/ui/ApplicationView.qml index df82c93..594c7c9 100644 --- a/package/contents/ui/ApplicationView.qml +++ b/package/contents/ui/ApplicationView.qml @@ -104,7 +104,7 @@ FullView { onCompareRequested: (a, b) => engine.comparePair(a, b) onDiffViewModeChanged: engine.settings.diffViewMode = diffViewMode onRefreshRequested: () => engine.refreshGenerations() - onDiskUsageRefreshRequested: () => engine.probeDiskUsage(true) + onDiskUsageRefreshRequested: () => engine.probeDiskUsage(true, true) onCheckFlakeRequested: () => engine.checkFlakeUpdates() onSelectGen: n => engine.loadGenDetails(n) onCollapseGen: () => engine.selectedGenNum = -1 diff --git a/package/contents/ui/Engine.qml b/package/contents/ui/Engine.qml index 919a0ce..9d3eafd 100644 --- a/package/contents/ui/Engine.qml +++ b/package/contents/ui/Engine.qml @@ -489,12 +489,16 @@ Item { property bool _diskProbeDirty: false property string diskUsageError: "" - function probeDiskUsage(force) { - if (!root.settings.measureStore) { - // Only the cheap free-space read; the slow store walk stays off. + // `manual`: the person asked for it (the Measure button). With measuring + // switched off, only that walks the store; everything else reads free space. + property bool _diskProbeManual: false + function probeDiskUsage(force, manual) { + if (!root.settings.measureStore && !manual && !root._diskProbeManual) { root.probeFreeDiskSpace(); return; } + if (manual) + root._diskProbeManual = true; if (force) root._diskProbeDirty = true; if (root._diskProbeRunning) @@ -503,9 +507,11 @@ Item { if (root.autoStart && !root.expanded) return; const refresh = root._diskProbeDirty; + const manualRun = root._diskProbeManual; if (!refresh && root._lastDiskRequestMs > 0 && Date.now() - root._lastDiskRequestMs < 5000) return; root._diskProbeDirty = false; + root._diskProbeManual = false; root._diskProbeRunning = true; root._lastDiskRequestMs = Date.now(); sh(shq(root.scriptDir + "run") + " cached disk:nar-v1 " + (refresh ? 0 : Math.round(root.diskProbeTtlMs / 1000)) + " -- " + shq(root.scriptDir + "diskusage"), function (cmd, out, err, code) { @@ -513,7 +519,7 @@ Item { // A mutation/forced refresh arrived during this snapshot. Do not // display it or lose the request; run one fresh probe afterward. if (root._diskProbeDirty) { - root.probeDiskUsage(true); + root.probeDiskUsage(true, manualRun); return; } const p = (out || "").trim().split("\x1e"); diff --git a/package/contents/ui/FullView.qml b/package/contents/ui/FullView.qml index d7c1cd2..4dba253 100644 --- a/package/contents/ui/FullView.qml +++ b/package/contents/ui/FullView.qml @@ -80,6 +80,8 @@ Pane { property string diskUsageError: "" property bool isProbingDiskUsage: false property bool measureStore: true + // With measuring off the card shows free space only, until Measure was clicked. + readonly property bool showMeasured: measureStore || diskStoreBytes > 0 readonly property string storeLabel: UI.Theme.formatBytes(diskStoreBytes) || "—" readonly property string storeMeasurementHint: diskUsageError ? qsTr("Refresh failed; showing the last measurement. %1").arg(diskUsageError) : qsTr("Logical store contents and estimated GC candidates use NAR sizes. Actual space freed depends on shared files, compression and snapshots. Old generations remain protected until removed.") @@ -831,31 +833,30 @@ Pane { Layout.fillWidth: true Text { Layout.fillWidth: true - text: fullView.measureStore ? qsTr("Nix store · logical size") : qsTr("Nix store · free space") + text: fullView.showMeasured ? qsTr("Nix store · logical size") : qsTr("Nix store · free space") color: fullView.textColor font.pixelSize: 10 * fullView.fs } Text { - visible: fullView.measureStore + visible: fullView.showMeasured text: fullView.storeLabel color: "#93a5bd" font.pixelSize: 9 * fullView.fs } UI.ActionButton { objectName: "refreshStoreUsage" - visible: fullView.measureStore - text: fullView.isProbingDiskUsage ? qsTr("Measuring…") : qsTr("Refresh") + text: fullView.isProbingDiskUsage ? qsTr("Measuring…") : fullView.showMeasured ? qsTr("Refresh") : qsTr("Measure") glyph: fullView.svg("ic_refresh") flatStyle: true implicitHeight: 23 * fullView.fs font.pixelSize: 9 * fullView.fs - tip: fullView.diskUsageError || qsTr("Recalculate logical size, estimated reclaimable space and free space") + tip: fullView.diskUsageError || (fullView.showMeasured ? qsTr("Recalculate logical size, estimated reclaimable space and free space") : qsTr("Measure the store once: logical size and estimated reclaimable space")) enabled: !fullView.isProbingDiskUsage onClicked: fullView.diskUsageRefreshRequested() } } Rectangle { - visible: fullView.measureStore + visible: fullView.showMeasured Layout.fillWidth: true Layout.topMargin: 13 Layout.bottomMargin: 10 @@ -882,7 +883,7 @@ Pane { } RowLayout { Layout.fillWidth: true - Layout.topMargin: fullView.measureStore ? 0 : 10 + Layout.topMargin: fullView.showMeasured ? 0 : 10 Text { Layout.fillWidth: true text: qsTr("%1 free").arg(UI.Theme.formatBytes(fullView.diskFreeBytes) || "—") @@ -890,7 +891,7 @@ Pane { font.pixelSize: 9 * fullView.fs } Text { - visible: fullView.measureStore + visible: fullView.showMeasured text: qsTr("≈ %1 reclaimable").arg(fullView.reclaimableLabel) color: UI.Theme.changed font.pixelSize: 9 * fullView.fs diff --git a/tests/tst_Engine.qml b/tests/tst_Engine.qml index f4195b9..932728f 100644 --- a/tests/tst_Engine.qml +++ b/tests/tst_Engine.qml @@ -1045,6 +1045,7 @@ Item { compare(jobs.length, 2); } function test_store_measurement_can_be_switched_off() { + core._diskProbeRunning = false; const original = cfg.measureStore; cfg.measureStore = false; jobs = []; @@ -1052,6 +1053,14 @@ Item { compare(jobs.length, 1); verify(jobs[0].cmd.indexOf("diskusage") > 0 && jobs[0].cmd.indexOf(" --free") > 0, jobs[0].cmd); verify(jobs[0].cmd.indexOf("disk:nar-v1") < 0, jobs[0].cmd); + // The Measure button still walks the store once. + jobs = []; + core._lastDiskRequestMs = 0; + core.probeDiskUsage(true, true); + compare(jobs.length, 1); + verify(jobs[0].cmd.indexOf("cached disk:nar-v1 0") > 0, jobs[0].cmd); + jobs[0].callback(jobs[0].cmd, "100\x1e20\x1e500\x1e" + Math.floor(Date.now() / 1000), "", 0); + compare(core.diskStoreBytes, 100); cfg.measureStore = original; } function test_disk_errors_preserve_last_measurement_and_allow_retry() {