feat(menu): Back on every submenu + working snapshot fallback

Two menu improvements.

Back everywhere: every list menu now ends with a "↩ Back" entry that
returns one level up — power, theme, power-profile, clipboard, files,
capture, not just Tools/System — so you no longer have to Esc out and
reopen to back up. A `back` helper + a shared `BACK` label keep it uniform;
matched exactly so it can't collide with clipboard/filename content. Esc
still quits instantly.

Snapshot fallback: btrfs-assistant 2.2 segfaults on 26.05 (libbtrfsutil
ABI), so `nomarchy-menu snapshot` now launches nomarchy-snapshots instead —
a keyboard-driven snapper browser/restore. snapper is root-only here and
rofi can't run as root under Wayland, so it runs in a terminal via sudo
(one password prompt) and uses fzf to pick: browse/diff (read-only),
restore files (undochange), or roll back (root config only), each behind a
typed-`yes` confirmation — safer than a one-click rofi rollback.
btrfs-assistant stays installed for when nixpkgs fixes it.

Verified: flake check clean; home generation builds and the menu passes
bash -n with all Back + snapshot wiring; nomarchy-snapshots passes
shellcheck (writeShellApplication) in a snapper-enabled system build.
Pending an on-machine check of the restore/rollback paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-06-19 19:54:42 +01:00
parent a6d6860054
commit 0e42763aea
3 changed files with 115 additions and 25 deletions

View File

@@ -51,6 +51,11 @@ how to override it. Items marked ✓ are shipped.
one-off — any new feature that earns a menu entry must be placed in the one-off — any new feature that earns a menu entry must be placed in the
right submenu (don't let the root creep back to a flat list), with its right submenu (don't let the root creep back to a flat list), with its
direct `SUPER+CTRL+<mnemonic>` bind and self-gating as applicable. direct `SUPER+CTRL+<mnemonic>` bind and self-gating as applicable.
-**Back everywhere:** every list menu now ends with a `↩ Back` entry that
returns one level up (power/theme/power-profile/clipboard/files/capture,
not just Tools/System), so you never have to Esc out and reopen. A `back`
helper + a shared `BACK` label keep it uniform; matched exactly so it can't
collide with clipboard/filename content. Esc still quits instantly.
-**Menu modules from rofi plugins:** the old `calc` flow committed the -**Menu modules from rofi plugins:** the old `calc` flow committed the
expression blind (result only in the *next* menu's `-mesg`) and `qalc -t` expression blind (result only in the *next* menu's `-mesg`) and `qalc -t`
misparsed common phrasings (`15% of 200``rem(15, 1 B)`, the natural- misparsed common phrasings (`15% of 200``rem(15, 1 B)`, the natural-
@@ -454,11 +459,19 @@ how to override it. Items marked ✓ are shipped.
artifact and will crash on hardware too). The menu wiring is correct and artifact and will crash on hardware too). The menu wiring is correct and
the app is installed, but the GUI doesn't open. The snapshot *backend* the app is installed, but the GUI doesn't open. The snapshot *backend*
(the actual snapshots) is unaffected. Fix path: a nixpkgs override aligning (the actual snapshots) is unaffected. Fix path: a nixpkgs override aligning
its libbtrfsutil, or wait for an upstream bump; meanwhile the menu entry its libbtrfsutil, or wait for an upstream bump. ✓ **Fallback shipped:**
is a no-op when the binary crashes. Fallback if it lingers: a rofi-based the menu now launches `nomarchy-snapshots` (system-side, gated on
snapshot menu (no btrfs-assistant dependency). `nomarchy.system.snapper`) instead — a keyboard-driven snapper
- Remaining (optional): a keyboard-driven rofi browse for quick glances; browser/restore with no btrfs-assistant dependency. snapper is root-only
boot-from-snapshot needs a systemd-boot equivalent of grub-btrfs. here (no `ALLOW_USERS`) and rofi can't run as root under Wayland, so it
runs in a terminal via `sudo` (one password prompt) and uses `fzf` to pick:
browse/diff (read-only), restore files (`undochange`), or roll back (root
config only) — each behind a typed-`yes` confirmation, which is *safer*
than the fat-fingerable one-click rofi rollback this entry originally
avoided. btrfs-assistant stays installed for when nixpkgs fixes it.
**Pending an on-machine check of the restore/rollback paths.**
- Remaining (optional): boot-from-snapshot needs a systemd-boot equivalent
of grub-btrfs.
- **Update awareness:** updates are manual today (`sys-update`/`home-update`); - **Update awareness:** updates are manual today (`sys-update`/`home-update`);
add a Waybar indicator / notification when flake inputs are stale or a new add a Waybar indicator / notification when flake inputs are stale or a new
nixpkgs rev is available (optionally with a pending-change count). Augments, nixpkgs rev is available (optionally with a pending-change count). Augments,

View File

@@ -64,6 +64,14 @@ let
# rofi has show-icons (on globally; passed explicitly on the icon menus). # rofi has show-icons (on globally; passed explicitly on the icon menus).
row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; } row() { printf '%s\0icon\x1f%s\n' "$1" "$2"; }
# General menu convention: every list menu ends with a " Back" entry that
# returns one level up (so you never have to Esc out and reopen). `back`
# emits it with an icon; plain pick-lists append the bare label instead.
# Always matched EXACTLY (" Back") so it can't collide with clipboard or
# filename content. The arrow + go-previous icon read as "go back".
BACK=" Back"
back() { row "$BACK" go-previous; }
case "''${1:-}" in case "''${1:-}" in
power) power)
choice=$( { choice=$( {
@@ -73,8 +81,10 @@ let
row "Hibernate" system-hibernate row "Hibernate" system-hibernate
row "Reboot" system-reboot row "Reboot" system-reboot
row "Shutdown" system-shutdown row "Shutdown" system-shutdown
back
} | rofi -dmenu -show-icons -p Power) || exit 0 } | rofi -dmenu -show-icons -p Power) || exit 0
case "$choice" in case "$choice" in
"$BACK") exec "$0" ;;
*Lock) loginctl lock-session ;; *Lock) loginctl lock-session ;;
*Logout) hyprctl dispatch exit ;; *Logout) hyprctl dispatch exit ;;
*Suspend) systemctl suspend ;; *Suspend) systemctl suspend ;;
@@ -92,17 +102,19 @@ let
exit 0 exit 0
fi fi
cur=$(powerprofilesctl get 2>/dev/null) cur=$(powerprofilesctl get 2>/dev/null)
choice=$(powerprofilesctl list 2>/dev/null \ choice=$( { powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p'; printf '%s\n' "$BACK"; } \
| sed -nE 's/^[* ] ([a-z-]+):$/\1/p' \
| rofi -dmenu -p "profile (now: $cur)") || exit 0 | rofi -dmenu -p "profile (now: $cur)") || exit 0
[ "$choice" = "$BACK" ] && exec "$0" system
[ -n "$choice" ] && powerprofilesctl set "$choice" ;; [ -n "$choice" ] && powerprofilesctl set "$choice" ;;
theme) theme)
choice=$(nomarchy-theme-sync list | rofi -dmenu -p theme) || exit 0 choice=$( { nomarchy-theme-sync list; printf '%s\n' "$BACK"; } | rofi -dmenu -p theme) || exit 0
[ "$choice" = "$BACK" ] && exec "$0"
[ -n "$choice" ] && exec nomarchy-theme-sync apply "$choice" ;; [ -n "$choice" ] && exec nomarchy-theme-sync apply "$choice" ;;
clipboard) clipboard)
sel=$(cliphist list | rofi -dmenu -p clip) || exit 0 sel=$( { cliphist list; printf '%s\n' "$BACK"; } | rofi -dmenu -p clip) || exit 0
[ "$sel" = "$BACK" ] && exec "$0" tools
printf '%s' "$sel" | cliphist decode | wl-copy ;; printf '%s' "$sel" | cliphist decode | wl-copy ;;
calc) calc)
@@ -114,10 +126,10 @@ let
-calc-command "echo -n '{result}' | wl-copy" ;; -calc-command "echo -n '{result}' | wl-copy" ;;
files) files)
sel=$(fd . "$HOME" --type f --hidden --exclude .git --exclude .cache 2>/dev/null \ sel=$( { fd . "$HOME" --type f --hidden --exclude .git --exclude .cache 2>/dev/null \
| head -n 50000 \ | head -n 50000 | sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \
| sed "s|^$HOME/||" \
| rofi -dmenu -p file) || exit 0 | rofi -dmenu -p file) || exit 0
[ "$sel" = "$BACK" ] && exec "$0" tools
[ -n "$sel" ] && exec xdg-open "$HOME/$sel" ;; [ -n "$sel" ] && exec xdg-open "$HOME/$sel" ;;
emoji) emoji)
@@ -145,10 +157,12 @@ let
row "Region file" applets-screenshooter row "Region file" applets-screenshooter
row "Full screen clipboard" camera-photo row "Full screen clipboard" camera-photo
row "Full screen file" camera-photo row "Full screen file" camera-photo
back
} | rofi -dmenu -show-icons -p Capture) || exit 0 } | rofi -dmenu -show-icons -p Capture) || exit 0
dir="$HOME/Pictures/Screenshots" dir="$HOME/Pictures/Screenshots"
file="$dir/$(date +%Y%m%d-%H%M%S).png" file="$dir/$(date +%Y%m%d-%H%M%S).png"
case "$choice" in case "$choice" in
"$BACK") exec "$0" tools ;;
*"Region clipboard"*) grim -g "$(slurp)" - | wl-copy ;; *"Region clipboard"*) grim -g "$(slurp)" - | wl-copy ;;
*"Region file"*) mkdir -p "$dir"; grim -g "$(slurp)" "$file" \ *"Region file"*) mkdir -p "$dir"; grim -g "$(slurp)" "$file" \
&& notify-send "Screenshot saved" "$file" ;; && notify-send "Screenshot saved" "$file" ;;
@@ -187,12 +201,14 @@ let
notify-send "Night light" "Not available (nomarchy.nightlight off?)."; exit 0 ;; notify-send "Night light" "Not available (nomarchy.nightlight off?)."; exit 0 ;;
snapshot) snapshot)
# btrfs-assistant: snapshot browse / diff / restore / rollback over # btrfs-assistant 2.2 segfaults on 26.05 (libbtrfsutil ABI), so launch
# snapper, elevating via polkit. Shipped system-side with # the keyboard-driven snapper browser instead: a terminal running it
# nomarchy.system.snapper; self-gate so the entry no-ops elsewhere. # via sudo (snapper is root-only here one password prompt), with
command -v btrfs-assistant >/dev/null 2>&1 \ # browse/diff, restore (undochange) and rollback, each confirmed.
|| { notify-send "Snapshots" "btrfs-assistant isn't installed (BTRFS snapshots off?)."; exit 0; } # Self-gated to nomarchy.system.snapper, so it no-ops elsewhere.
exec btrfs-assistant ;; command -v nomarchy-snapshots >/dev/null 2>&1 \
|| { notify-send "Snapshots" "Snapshot tools unavailable (nomarchy.system.snapper off?)."; exit 0; }
exec ${cfg.terminal} -e sudo nomarchy-snapshots ;;
tools) tools)
# Tools submenu utilities you invoke. Each leaf is also reachable # Tools submenu utilities you invoke. Each leaf is also reachable
@@ -205,9 +221,10 @@ let
row "Web search" system-search row "Web search" system-search
row "Capture" applets-screenshooter row "Capture" applets-screenshooter
row "Ask Claude" internet-chat row "Ask Claude" internet-chat
row "Back" go-previous back
} | rofi -dmenu -show-icons -p Tools) || exit 0 } | rofi -dmenu -show-icons -p Tools) || exit 0
case "$choice" in case "$choice" in
"$BACK") exec "$0" ;;
*Calc*) exec "$0" calc ;; *Calc*) exec "$0" calc ;;
*Clipboard*) exec "$0" clipboard ;; *Clipboard*) exec "$0" clipboard ;;
*Emoji*) exec "$0" emoji ;; *Emoji*) exec "$0" emoji ;;
@@ -215,7 +232,6 @@ let
*Web*) exec "$0" web ;; *Web*) exec "$0" web ;;
*Capture*) exec "$0" capture ;; *Capture*) exec "$0" capture ;;
*Ask*) exec "$0" ask ;; *Ask*) exec "$0" ask ;;
*Back*) exec "$0" ;;
esac ;; esac ;;
system) system)
@@ -229,20 +245,20 @@ let
row "Do Not Disturb" notification-disabled row "Do Not Disturb" notification-disabled
systemctl --user cat hyprsunset.service >/dev/null 2>&1 \ systemctl --user cat hyprsunset.service >/dev/null 2>&1 \
&& row "Night light" weather-clear-night && row "Night light" weather-clear-night
command -v btrfs-assistant >/dev/null 2>&1 && row "Snapshots" timeshift command -v nomarchy-snapshots >/dev/null 2>&1 && row "Snapshots" timeshift
if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then if [ -e "''${bats[0]}" ] && command -v powerprofilesctl >/dev/null 2>&1; then
row "Power profile" preferences-system-power row "Power profile" preferences-system-power
fi fi
row "Back" go-previous back
} | rofi -dmenu -show-icons -p System) || exit 0 } | rofi -dmenu -show-icons -p System) || exit 0
case "$choice" in case "$choice" in
"$BACK") exec "$0" ;;
*Network*) exec "$0" network ;; *Network*) exec "$0" network ;;
*Bluetooth*) exec "$0" bluetooth ;; *Bluetooth*) exec "$0" bluetooth ;;
*"Do Not Disturb"*) exec "$0" dnd ;; *"Do Not Disturb"*) exec "$0" dnd ;;
*"Night light"*) exec "$0" nightlight ;; *"Night light"*) exec "$0" nightlight ;;
*Snapshots*) exec "$0" snapshot ;; *Snapshots*) exec "$0" snapshot ;;
*"Power profile"*) exec "$0" power-profile ;; *"Power profile"*) exec "$0" power-profile ;;
*Back*) exec "$0" ;;
esac ;; esac ;;
"") "")

View File

@@ -325,8 +325,69 @@ in
nixos-rebuild switch --flake /etc/nixos#default "$@" nixos-rebuild switch --flake /etc/nixos#default "$@"
'') '')
# The desktop snapshot manager (browse / diff / restore / rollback over # The desktop snapshot manager (browse / diff / restore / rollback over
# snapper, elevating via polkit) — what `nomarchy-menu snapshot` launches. # snapper, elevating via polkit). Kept for when nixpkgs fixes its crash.
++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant; ++ lib.optional cfg.snapper.enable pkgs.btrfs-assistant
# Keyboard-driven snapper browser/restore — the fallback `nomarchy-menu
# snapshot` actually launches while btrfs-assistant 2.2 segfaults on 26.05
# (libbtrfsutil ABI). Runs as root (snapper is root-only here; the menu
# opens it in a terminal via sudo, one password prompt), fzf to pick, with
# browse/diff (read-only) and typed-`yes` confirmation before any write.
++ lib.optional cfg.snapper.enable (pkgs.writeShellApplication {
name = "nomarchy-snapshots";
runtimeInputs = with pkgs; [ snapper fzf gawk gnugrep less coreutils systemd ];
text = ''
if [ "$(id -u)" -ne 0 ]; then
echo "nomarchy-snapshots must run as root (snapper needs it) use sudo." >&2
exit 1
fi
mapfile -t configs < <(snapper list-configs | awk 'NR>2 {print $1}' | grep .)
if [ "''${#configs[@]}" -eq 0 ]; then
echo "No snapper configs found." >&2; exit 1
elif [ "''${#configs[@]}" -eq 1 ]; then
config="''${configs[0]}"
else
config=$(printf '%s\n' "''${configs[@]}" | fzf --prompt="snapper config> ") || exit 0
fi
while :; do
snap=$(snapper -c "$config" list \
| fzf --header-lines=2 --prompt="[$config] pick a snapshot (Esc quits)> ") || exit 0
num=$(awk '{print $1}' <<<"$snap")
case "$num" in ""|*[!0-9]*) continue ;; esac
action=$(printf '%s\n' \
"Browse changes since #$num (read-only)" \
"Restore changed files to #$num (undochange)" \
"Roll the system back to #$num (reboot)" \
" Back to the snapshot list" \
| fzf --prompt="snapshot #$num> ") || exit 0
case "$action" in
Browse*)
snapper -c "$config" status "$num..0" | less -R || true ;;
Restore*)
read -rp "Revert files in '$config' to snapshot #$num? Type yes to confirm: " ans || continue
if [ "$ans" = yes ]; then
snapper -c "$config" undochange "$num..0"
echo "Files restored. Press enter."; read -r _ || true
fi ;;
Roll*)
if [ "$config" != root ]; then
echo "Rollback applies to the 'root' config only; use Restore for '$config'. Press enter."
read -r _ || true
else
read -rp "Roll the SYSTEM back to #$num and REBOOT now? Type yes to confirm: " ans || continue
if [ "$ans" = yes ]; then
snapper -c root rollback "$num"
echo "Rolled back rebooting"; systemctl reboot
fi
fi ;;
*) continue ;;
esac
done
'';
});
# Don't let boot entries fill the ESP over the years. # Don't let boot entries fill the ESP over the years.
boot.loader.systemd-boot.configurationLimit = lib.mkDefault 10; boot.loader.systemd-boot.configurationLimit = lib.mkDefault 10;