From 54896033c6733dc6f9f0cf6daf13b4d7be98cd1b Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Fri, 28 Aug 2026 07:55:03 +0200 Subject: [PATCH] feat(flake): add a quickshell hot-reload devShell hosts/terra/home.nix ships dotfiles/quickshell via xdg.configFile, which copies the tree into the store: ~/.config/quickshell is a read-only symlink into /nix/store and every QML tweak costs a nixos-rebuild. quickshell does hot-reload on file save -- but only for the files it watches, which are those frozen store copies. `nix develop` now swaps the running shell to the working tree (`qs -p`) and swaps it back on exit, so QML edits need no rebuild at all. The swap starts the dev instance FIRST and kills the packaged one only once dev is confirmed up. A QML error in the working tree then leaves you on your normal bar instead of no bar, which matters because a broken save is exactly when you would be running this. Liveness is "did `qs list -j` return json" -- it exits 0 whether or not it found anything, so the exit code says nothing. Every kill is scoped to one config (`qs kill` = default, `qs kill -p` = that path). A blanket kill would also take out unrelated instances; pkgs/rishot.nix is one. Three guards on the auto-swap, all learned by testing it: - interactive only. `nix develop --command X` EXECs X, replacing the shell that set the `trap ... EXIT`, so the restore never runs and you are left on the dev instance. Non-interactive use gets `nix develop -c qs-dev`. - WAYLAND_DISPLAY, so entering the shell over ssh cannot kill the desktop's bar and leave nothing in its place. - a sentinel, so a nested `nix develop` does not swap and restore twice. Deliberately not wired to direnv (no .envrc): programs.direnv is enabled for this user, so a `use flake` would swap the running desktop shell on every `cd` into the checkout. Verified end to end on terra: swap, hot-reload of a working-tree edit, and restore, plus both the interactive and non-interactive paths. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgcWkm3t6BDQktYHQvb8Hx --- flake.nix | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/flake.nix b/flake.nix index fc1b2a8..457279b 100644 --- a/flake.nix +++ b/flake.nix @@ -552,5 +552,113 @@ machine.crash() ''; }; + + # `nix develop` — hot-reload loop for dotfiles/quickshell. + # + # hosts/terra/home.nix ships the shell via `xdg.configFile."quickshell"`, + # which COPIES the tree into the store, so ~/.config/quickshell is a + # read-only symlink into /nix/store and every QML tweak costs a + # nixos-rebuild. quickshell DOES hot-reload on file save — but only for + # the files it is watching, which are those frozen store copies. Pointing + # it at the working tree with `qs -p` restores edit-save-see, no rebuild. + # + # quickshell keys instance identity on the CONFIG PATH, so a working-tree + # instance and the store-backed one are two different instances that would + # both map layer-shell bars onto every output. Hence a swap, not a second + # instance — and the swap starts dev FIRST, killing the packaged shell + # only once dev is confirmed up, so a QML error in the working tree leaves + # you on your normal bar instead of no bar at all. + # + # Every kill is scoped to one config (`qs kill` = default only, `qs kill + # -p` = that path only). A blanket kill would also take out unrelated + # quickshell instances — pkgs/rishot.nix is one. + # + # Deliberately NOT wired to direnv (no .envrc in this repo): programs.direnv + # is enabled for this user, so a `use flake` would swap the running desktop + # shell on every `cd` into the checkout, including over ssh. + devShells.${system}.default = + let + pkgs = nixpkgs.legacyPackages.${system}; + # Same nixpkgs terra's home.nix takes pkgs.quickshell from, so the dev + # instance is the identical build to the packaged one. + qs = "${nixpkgs.legacyPackages.${system}.quickshell}/bin/qs"; + git = "${nixpkgs.legacyPackages.${system}.git}/bin/git"; + + # Resolved at RUN time, not build time: the entire point is to run the + # working tree, and `self` here is only a store snapshot of it. + preamble = '' + root="$(${git} rev-parse --show-toplevel 2>/dev/null || pwd)" + cfg="$root/dotfiles/quickshell" + if [ ! -f "$cfg/shell.qml" ]; then + echo "no shell.qml under $cfg — run this from the homelab checkout" >&2 + exit 1 + fi + # `qs list` exits 0 whether or not it found anything, and only emits + # json when it did — so "json came back" is the liveness test. + running() { ${qs} list -p "$1" -j 2>/dev/null | grep -q '"id"'; } + prod_running() { ${qs} list -j 2>/dev/null | grep -q '"id"'; } + ''; + + qs-dev = pkgs.writeShellScriptBin "qs-dev" '' + set -uo pipefail + ${preamble} + + if [ -z "''${WAYLAND_DISPLAY:-}" ]; then + echo "qs-dev: no WAYLAND_DISPLAY — refusing to swap the desktop shell" >&2 + exit 1 + fi + + if running "$cfg"; then + echo "qs-dev: already running from $cfg" + exit 0 + fi + + ${qs} -d -p "$cfg" + + # Confirm it came up before touching the packaged shell. + for _ in $(seq 1 50); do + running "$cfg" && break + sleep 0.1 + done + + if ! running "$cfg"; then + echo "qs-dev: dev shell failed to start — packaged shell left alone" >&2 + echo "qs-dev: run 'qs -p $cfg' in the foreground to see the QML error" >&2 + exit 1 + fi + + ${qs} kill || true + echo "qs-dev: live on $cfg — edits there now hot-reload" + ''; + + qs-prod = pkgs.writeShellScriptBin "qs-prod" '' + set -uo pipefail + ${preamble} + + running "$cfg" && ${qs} kill -p "$cfg" || true + prod_running || ${qs} -d + echo "qs-prod: back on ~/.config/quickshell" + ''; + in + pkgs.mkShell { + packages = [ pkgs.quickshell qs-dev qs-prod ]; + + # Swap on entry, swap back on exit. Three guards: + # - interactive only ($- has i). `nix develop --command X` EXECs X, + # replacing the shell that set the trap, so the restore would + # never run and you'd be left on the dev instance. Non-interactive + # use gets the explicit `nix develop -c qs-dev` instead. + # - WAYLAND_DISPLAY, so entering the shell over ssh cannot kill the + # desktop's bar and leave nothing in its place. + # - a sentinel, so a nested `nix develop` does not swap (and then + # restore) a second time. + shellHook = '' + if [[ $- == *i* ]] && [ -n "''${WAYLAND_DISPLAY:-}" ] && [ -z "''${HOMELAB_QS_DEV:-}" ]; then + export HOMELAB_QS_DEV=1 + qs-dev && trap qs-prod EXIT + fi + echo "homelab devshell — qs-dev (working tree) / qs-prod (packaged); exit restores" + ''; + }; }; }