From b2391795af9c9f42f3b4c3d43c011eca35783795 Mon Sep 17 00:00:00 2001 From: cyteen Date: Wed, 4 Mar 2026 15:09:42 +0000 Subject: [PATCH] httm plugins to capture http and wrapper behaviour. Attempting to add the functionality of httm and port the wrapper scripts to lua plugins for yazi where it makes sense. --- 020_yazi-plugin_httm-bowie_lua.sh | 327 ++++++++++ 020_yazi-plugin_httm-nicotine.sh | 377 +++++++++++ 020_yazi-plugin_httm-nicotine_lua.sh | 238 +++++++ 020_yazi-plugin_httm-zdbstat_lua.sh | 181 ++++++ 020_yazi-plugin_httm.sh | 922 +++++++++++++++++++++++++++ 5 files changed, 2045 insertions(+) create mode 100644 020_yazi-plugin_httm-bowie_lua.sh create mode 100644 020_yazi-plugin_httm-nicotine.sh create mode 100644 020_yazi-plugin_httm-nicotine_lua.sh create mode 100644 020_yazi-plugin_httm-zdbstat_lua.sh create mode 100644 020_yazi-plugin_httm.sh diff --git a/020_yazi-plugin_httm-bowie_lua.sh b/020_yazi-plugin_httm-bowie_lua.sh new file mode 100644 index 0000000..c5e0daf --- /dev/null +++ b/020_yazi-plugin_httm-bowie_lua.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +: <&2 + exit 0 +} + +print_usage() { + local nicotine="\e[31mnicotine\e[0m" + local httm="\e[31mhttm\e[0m" + local git="\e[31mgit\e[0m" + local tar="\e[31mtar\e[0m" + + printf "\ +$nicotine is a wrapper script for $httm which converts unique snapshot file versions to a $git archive. + +USAGE: + nicotine [OPTIONS]... [file1 file2...] + +OPTIONS: + --output-dir: + Select the output directory. Default is current working directory. + --no-archive + Disable archive creation. Create a new $git repository. + --single-repo: + Add all provided files/dirs into a single git repository instead of one per argument. + --debug: + Show $git and $tar command output. + --help: + Display this dialog. + --version: + Display script version. + +" 1>&2 + exit 1 +} + +prep_exec() { + for cmd in find readlink git tar mktemp mkdir httm; do + command -v "$cmd" >/dev/null 2>&1 || { printf "Error: '$cmd' is required.\n" 1>&2; exit 1; } + done +} + +function copy_add_commit { + local debug=$1; shift + local path="$1"; shift + local dest_dir="$1"; shift + + if [[ -d "$path" ]]; then + cp -a "$path" "$dest_dir/" + return 0 + else + cp -a "$path" "$dest_dir" + fi + + local commit_date + commit_date=$(date -d "$(stat -c %y "$path")") + + if [[ "$debug" = true ]]; then + git add --all "$dest_dir" + git commit -m "httm commit from ZFS snapshot: $(basename "$path")" --date "$commit_date" || true + else + git add --all "$dest_dir" > /dev/null + git commit -q -m "httm commit from ZFS snapshot: $(basename "$path")" --date "$commit_date" > /dev/null || true + fi +} + +function get_unique_versions { + local debug=$1; shift + local path="$1"; shift + local dest_dir="$1"; shift + + local -a version_list=() + if [[ ! -d "$path" ]]; then + while read -r line; do + [[ -n "$line" ]] && version_list+=("$line") + done <<<"$(httm -n --omit-ditto "$path")" + fi + + if [[ -d "$path" ]] || [[ ${#version_list[@]} -le 1 ]]; then + copy_add_commit "$debug" "$path" "$dest_dir" + else + for version in "${version_list[@]}"; do + copy_add_commit "$debug" "$version" "$dest_dir" + done + fi +} + +function traverse { + local debug=$1; shift + local path="$1"; shift + local dest_dir="$1"; shift + + get_unique_versions "$debug" "$path" "$dest_dir" + [[ -d "$path" ]] || return 0 + + local basename + basename=$(basename "$path") + + while read -r entry; do + [[ -z "$entry" ]] && continue + if [[ -d "$entry" ]]; then + traverse "$debug" "$entry" "$dest_dir/$basename" + else + get_unique_versions "$debug" "$entry" "$dest_dir/$basename" + fi + done <<<"$(find "$path" -mindepth 1 -maxdepth 1)" +} + +function nicotine { + ( + prep_exec + + local debug=false + local no_archive=false + local single_repo=false + local output_dir="$(pwd)" + local -a input_files=() + + while [[ $# -ge 1 ]]; do + case "$1" in + --output-dir) shift; output_dir="$(realpath "$1")"; shift ;; + --debug) debug=true; shift ;; + --no-archive) no_archive=true; shift ;; + --single-repo) single_repo=true; shift ;; + --help|-h) print_usage ;; + --version|-V) print_version ;; + *) input_files+=("$1"); shift ;; + esac + done + + [[ ${#input_files[@]} -gt 0 ]] || { printf "Error: No input files.\n" 1>&2; exit 1; } + + local tmp_dir + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + if [[ "$single_repo" = true ]]; then + local repo_name="nicotine-combined" + local archive_dir="$tmp_dir/$repo_name" + mkdir -p "$archive_dir" + cd "$archive_dir" + git init -q + + for file in "${input_files[@]}"; do + local can_path + can_path=$(realpath "$file" 2>/dev/null) || continue + [[ -e "$can_path" ]] || continue + + # if [[ -d "$can_path" ]]; then + # traverse "$debug" "$can_path" "$tmp_dir" + # else + # traverse "$debug" "$can_path" "$archive_dir" + # fi + + traverse "$debug" "$can_path" "$archive_dir" + done + + finalize_output "$debug" "$no_archive" "$tmp_dir" "$output_dir" "$repo_name" + else + for file in "${input_files[@]}"; do + local can_path + can_path=$(realpath "$file" 2>/dev/null) || continue + [[ -e "$can_path" ]] || continue + + local base + base=$(basename "$can_path") + base="${base#.}" + + local archive_dir="$tmp_dir/$base" + mkdir -p "$archive_dir" + ( + cd "$archive_dir" + git init -q + # + # if [[ -d "$can_path" ]]; then + # traverse "$debug" "$can_path" "$tmp_dir" + # else + # traverse "$debug" "$can_path" "$archive_dir" + # fi + + traverse "$debug" "$can_path" "$archive_dir" + ) + finalize_output "$debug" "$no_archive" "$tmp_dir" "$output_dir" "$base" + rm -rf "${archive_dir:?}" + done + fi + ) +} + +function finalize_output { + local debug=$1 no_archive=$2 tmp=$3 out=$4 base=$5 + if [[ "$no_archive" = true ]]; then + cp -ra "$tmp/$base" "$out/$base-git" + printf "Repository created: $out/$base-git\n" + else + local out_file="$out/$base-git.tar.gz" + tar -C "$tmp" -zcf "$out_file" "$base" + printf "Archive created: $out_file\n" + fi +} + +nicotine "${@}" +EOF +} +# So as to not loose the original nicotine bash script. +if [[ -f /usr/bin/nicotine ]]; then + if [[ -f /usr/bin/nicotine.bak ]]; then + sudo mv /usr/bin/nicotine.bak /usr/bin/nicotine + fi + sudo mv /usr/bin/nicotine /usr/bin/nicotine.bak + conf_print_nicotine_script | sudo tee /usr/bin/nicotine + sudo chmod +x /usr/bin/nicotine +fi diff --git a/020_yazi-plugin_httm-nicotine_lua.sh b/020_yazi-plugin_httm-nicotine_lua.sh new file mode 100644 index 0000000..9833a39 --- /dev/null +++ b/020_yazi-plugin_httm-nicotine_lua.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash + +: <"${ZDBSTAT_PLUGIN_HOME}/main.lua" <<'EOF' +-- zdbstat.yazi/init.lua +-- Custom previewer: dump zdb -dddddddddd metadata when mode enabled + +local M = {} + +-- Global toggle state (persists across calls via _G) +local mode_get = ya.sync(function() return _G.ZDBSTAT_MODE or "none" end) + +function M:peek(job) + local mode = mode_get() + if mode == "none" then + return -- Let default previewer handle it + end + + local path = tostring(job.url) + + -- Resolve realpath + local rp = "" + local rp_child = Command("realpath"):arg(path):stdout(Command.PIPED):spawn() + if rp_child then + rp_child:wait() + rp = rp_child:read_line() or path + rp = rp:gsub("\n$", "") + end + if rp == "" then rp = path end + + -- Get dataset via zfs list + local dataset = "" + local ds_child = Command("zfs"):arg("list"):arg("-H"):arg("-o"):arg("name"):arg(rp) + :stdout(Command.PIPED):stderr(Command.PIPED):spawn() + if ds_child then + ds_child:wait() + dataset = ds_child:read_line() or "" + dataset = dataset:gsub("\n$", "") + end + + if dataset == "" then + ya.preview_widget(job, ui.Text("Not a ZFS path or dataset not found"):area(job.area)) + return + end + + -- Get inode + local inode = "" + local st_child = Command("stat"):arg("-c"):arg("%i"):arg(rp) + :stdout(Command.PIPED):spawn() + if st_child then + st_child:wait() + inode = st_child:read_line() or "" + inode = inode:gsub("\n$", "") + end + + if inode == "" then + ya.preview_widget(job, ui.Text("Could not get inode"):area(job.area)) + return + end + + -- Find sudo-like + local elevator = nil + for _, e in ipairs({"sudo", "doas", "pkexec"}) do + local test = Command(e):arg("--version"):stdout(Command.PIPED):stderr(Command.PIPED):spawn() + if test then + test:wait() + if test.status and test.status.success then + elevator = e + break + end + end + end + + -- Build zdb command + local zdb_cmd = Command("zdb"):arg("-dddddddddd"):arg(dataset):arg(inode) + if elevator then + zdb_cmd = Command(elevator):arg(zdb_cmd._program):args(zdb_cmd._args or {}) + end + + -- Capture output (PIPED so we can show in preview) + zdb_cmd:stdout(Command.PIPED):stderr(Command.PIPED) + local child, err = zdb_cmd:spawn() + if not child then + ya.preview_widget(job, ui.Text("Failed to run zdb: " .. tostring(err)):area(job.area)) + return + end + + child:wait() + local output = "" + for line in child:read_lines() do + output = output .. line .. "\n" + end + + if output == "" then + output = "zdb returned no output (check permissions?)" + end + + -- Show in preview with log-like highlighting + ya.preview_code({ + area = job.area, + content = output, + filetype = "log", -- or "text" if no bat-like highlighting desired + }) +end + +function M:seek(job) + self:peek(job) -- No advanced seeking needed +end + +return M +EOF + +cat >"${ZDBSTAT_PLUGIN_HOME}/toggle.lua" <<'EOF' +-- zdbstat.yazi/toggle.lua (separate entry plugin to cycle mode) + +return { + entry = function() + local modes = { "none", "zdb" } + local current = _G.ZDBSTAT_MODE or "none" + local next_mode = "none" + for i, m in ipairs(modes) do + if m == current then + next_mode = modes[(i % #modes) + 1] + break + end + end + _G.ZDBSTAT_MODE = next_mode + + ya.notify({ + title = "zdbstat", + content = "Preview mode: " .. next_mode, + timeout = 2, + }) + + -- Refresh current preview + if cx.active.current.hovered then + ya.manager_emit("peek", { cx.active.preview.skip or 0, force = true }) + end + end, +} +EOF + +cat <<'EOF' + +Add to your yazi.toml (or yazi.toml.d/): + +[plugin.preview] +prepend_previewers = [ + { name = "*", run = "zdbstat", sync = true }, +] + +Add to keymap.toml: + +[[manager.prepend_keymap]] +on = [ "g", "z" ] +run = "plugin zdbstat --args='toggle'" +desc = "Toggle zdbstat preview (zdb metadata / default)" + +EOF + +echo "zdbstat previewer installed at: ${PLUGIN_HOME}/init.lua" +echo "Toggle entry at: ${PLUGIN_HOME}/toggle.lua" +echo "Restart yazi or :plugin --reload zdbstat" +echo "Press 'gz' to cycle: default preview ↔ zdb metadata preview" +echo "Note: First zdb run may prompt for sudo/doas password (terminal popup)." diff --git a/020_yazi-plugin_httm.sh b/020_yazi-plugin_httm.sh new file mode 100644 index 0000000..f2aafc8 --- /dev/null +++ b/020_yazi-plugin_httm.sh @@ -0,0 +1,922 @@ +#!/usr/bin/env bash + +: <M + +# Core / frequently used +[[manager.prepend_keymap]] +on = [ "M", "s" ] +run = "plugin httm snapshot" +desc = "httm: Snapshot current or selected items" + +[[manager.prepend_keymap]] +on = [ "M", "b" ] +run = "plugin httm browse" +desc = "httm: Interactive browse + restore (full TUI)" + +[[manager.prepend_keymap]] +on = [ "M", "y" ] +run = "plugin httm select" +desc = "httm: Select snapshot versions → yank paths" + +# Time-travel navigation (non-interactive cd into snapshots) +[[manager.prepend_keymap]] +on = [ "M", "p" ] +run = "plugin httm prev" +desc = "httm: Jump to previous (older) snapshot" + +[[manager.prepend_keymap]] +on = [ "M", "n" ] +run = "plugin httm next" +desc = "httm: Jump to next (newer) snapshot" + +[[manager.prepend_keymap]] +on = [ "M", "l" ] # l = live +run = "plugin httm exit" +desc = "httm: Exit back to live filesystem" + +# Diffs (bowie) +[[manager.prepend_keymap]] +on = [ "M", "d" ] +run = "plugin httm bowie" +desc = "httm/bowie: Show diff with last snapshot" + +[[manager.prepend_keymap]] +on = [ "M", "D" ] +run = "plugin httm bowie-all" +desc = "httm/bowie: Show diffs across all versions" + +# Other helpers +[[manager.prepend_keymap]] +on = [ "M", "o" ] +run = "plugin httm ounce" +desc = "httm/ounce: Pre-emptive dynamic snapshot check" + +[[manager.prepend_keymap]] +on = [ "M", "g" ] +run = "plugin httm nicotine" +desc = "httm/nicotine: Export versions as git archive" + +# Time Machine (equine) – optional sub-prefix `ht` +[[manager.prepend_keymap]] +on = [ "M", "t", "m" ] +run = "plugin httm equine-mount-local" +desc = "httm/equine: Mount local Time Machine snapshots" + +[[manager.prepend_keymap]] +on = [ "M", "t", "u" ] +run = "plugin httm equine-umount-local" +desc = "httm/equine: Unmount local Time Machine" + +[[manager.prepend_keymap]] +on = [ "M", "t", "r" ] +run = "plugin httm equine-mount-remote" +desc = "httm/equine: Mount remote Time Machine" + +[[manager.prepend_keymap]] +on = [ "M", "t", "R" ] +run = "plugin httm equine-umount-remote" +desc = "httm/equine: Unmount remote Time Machine" +EOF +} +# conf_print_httm_keymap | tee -a "${YAZI_HOME}/keymap.toml" + +#---------------------------------------------------------------- + +conf_print_httm_help_keymap() { + cat <<'EOF' + +[[manager.prepend_keymap]] +on = [ "M", "?" ] +run = '''shell --confirm ' +cat << "FOE" +httm commands (leader: ` h) + s snapshot current/selected + b browse + restore (TUI) + y select versions → yank + p previous snapshot + n next snapshot + l back to live filesystem + d diff last version (bowie) + D diff all versions + o ounce dynamic snapshot + g nicotine git archive + t m mount local Time Machine + t u unmount local + t r mount remote + t R unmount remote +FOE + ' ''' + desc = "httm: Show command help" +EOF +} +# conf_print_httm_help_keymap | tee -a "${YAZI_HOME}/keymap.toml" + +#---------------------------------------------------------------- + +# Create the plugin main.lua +conf_print_httm_main() { + cat <<'EOF' +-- Helper to get targets (selected or hovered files) +-- Uses ya.sync because cx (context) access must be synchronous +local get_targets = ya.sync(function() + local tab = cx.active + local selected = tab.selected + if #selected == 0 then + local hovered = tab.current.hovered + if hovered then + return { tostring(hovered.url) } + else + return { tostring(tab.current.cwd) } + end + end + + local targets = {} + for _, u in ipairs(selected) do + table.insert(targets, tostring(u)) + end + return targets +end) + +local function notify(msg, level) + ya.notify({ + title = "httm.yazi", + content = msg, + timeout = level == "error" and 6 or level == "warn" and 4 or 3, + level = level or "info", + }) +end + +local function script_exists(name) + local cmd = os.Command("which"):arg(name):stdout(os.Command.PIPED):output() + return cmd and cmd.status.success and cmd.stdout:match("%S") ~= nil +end + +local function run_interactive(cmd_name, args) + local permit = ui.hide() -- ← this is the key change + + local child, err = + Command(cmd_name):arg(args or {}):stdin(Command.INHERIT):stdout(Command.INHERIT):stderr(Command.INHERIT):spawn() + + if not child then + ya.notify({ + title = "Spawn Error", + content = "Failed to start " .. cmd_name .. ": " .. tostring(err), + level = "error", + timeout = 5, + }) + permit:drop() -- important: always release the permit on early exit + return + end + + local status = child:wait() + + -- Clean up + permit:drop() + + if not status or not status.success then + ya.notify({ + title = cmd_name, + content = "Process exited with non-zero status", + level = "warn", + timeout = 3, + }) + end +end + +-- Improved sudo handling +local function sudo_already() + local status = os.Command("sudo"):args({ "--validate", "--non-interactive" }):status() + return status and status.success +end + +local function run_with_sudo(program, args) + local cmd = os.Command("sudo"):arg(program):args(args):stdout(os.Command.PIPED):stderr(os.Command.PIPED) + if sudo_already() then + return cmd:output() + end + + local _permit = ya.hide() + ya.notify({ title = "httm.yazi", content = "Sudo password required...", level = "info" }) + local output = cmd:output() + return (output and (output.status.success or sudo_already())) and output or nil +end + +-- FS detection utilities +local get_cwd = ya.sync(function() + return tostring(cx.active.current.cwd) +end) + +local function trim(s) + return s:match("^%s*(.-)%s*$") +end + +local function get_filesystem_type(cwd) + local out = os.Command("stat"):args({ "-f", "-c", "%T", cwd }):output() + return out and out.status.success and trim(out.stdout) or nil +end + +-- [ZFS/BTRFS logic remains logically the same, updated to os.Command] +local function zfs_dataset(cwd) + local out = os.Command("df"):args({ "--output=source", cwd }):output() + if not out or not out.status.success then + return nil + end + local dataset + for line in out.stdout:gmatch("[^\r\n]+") do + dataset = line + end + return dataset +end + +-- ... [Other ZFS/BTRFS helpers omitted for brevity, ensure they use os.Command] ... + +local function snapshot() + local targets = get_targets() + if #targets == 0 then + return notify("No target", "error") + end + + local cmd = os.Command("httm"):arg("--snap") + for _, t in ipairs(targets) do + cmd:arg(t) + end + + local output = cmd:output() + if output and output.status.success then + notify("Snapshot created") + ya.mgr_emit("refresh", {}) + else + notify("httm --snap failed; trying sudo...", "warn") + run_with_sudo("httm", { "--snap", unpack(targets) }) + ya.mgr_emit("refresh", {}) + end +end + +local function select_files() + local _permit = ya.hide() + local cmd = os.Command("httm"):args({ "-s", "-R" }):stdout(os.Command.PIPED):stderr(os.Command.PIPED) + local output = cmd:output() + + if not output or not output.status.success then + notify("httm selection failed", "error") + return + end + + local paths = {} + for line in output.stdout:gmatch("[^\n]+") do + local trimmed = trim(line) + if trimmed ~= "" then + table.insert(paths, trimmed) + end + end + + if #paths > 0 then + ya.mgr_emit("yank", { paths = paths, silent = true }) + notify("Yanked " .. #paths .. " paths") + end +end + +-- Time travel implementation using updated ya.mgr_emit +local function time_travel(action) + -- ... (Logic for finding next/prev snapshot path) ... + -- Use: ya.mgr_emit("cd", { path }) +end + +return { + entry = function(_, job) + local arg = job.args[1] + + if arg == "snapshot" then + snapshot() + elseif arg == "browse" then + run_interactive("httm", { "-r", "-R" }) + elseif arg == "select" then + select_files() + elseif arg == "bowie" then + local targets = get_targets() + run_interactive("bowie", targets) + -- ... add other cases matching your original logic ... + elseif arg == "prev" or arg == "next" or arg == "exit" then + time_travel(arg) + else + notify("Unknown command: " .. tostring(arg), "error") + end + end, +} +EOF +} +conf_print_httm_main | tee "${HTTM_PLUGIN_HOME}/main.lua" + +#---------------------------------------------------------------- + +conf_print_httm_main_old() { + cat <<'EOF' +-- ~/.config/yazi/plugins/httm.yazi/main.lua + +local function get_targets(cx) + local tab = cx.active + local selected = tab.selected + + if #selected == 0 then + local hovered = tab.current.hovered + if hovered then + return { tostring(hovered.url) } + else + return { tostring(tab.current.cwd) } + end + end + + local targets = {} + for _, u in ipairs(selected) do + table.insert(targets, tostring(u)) + end + return targets +end + +local function notify(msg, level) + ya.notify { + title = "httm.yazi", + content = msg, + timeout = level == "error" and 6 or level == "warn" and 4 or 3, + level = level or "info", + } +end + +local function script_exists(name) + local cmd = Command("which"):arg(name):stdout(Command.PIPED):output() + return cmd and cmd.status.success and cmd.stdout:match("%S") ~= nil +end + +local function run_interactive(cx, cmd_name, args) + local _permit = ya.hide() + + local cmd = Command(cmd_name):args(args or {}) + + local child, err = cmd:spawn() + if not child then + notify("Failed to spawn " .. cmd_name .. ": " .. tostring(err), "error") + return + end + + local status = child:wait() + if not status or not status.success then + notify(cmd_name .. " exited abnormally", "warn") + end + + ya.manager_emit("refresh", {}) +end + +local function run_capture_output(cmd_name, args) + local cmd = Command(cmd_name) + :args(args or {}) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + + local output = cmd:output() + if not output or not output.status.success then + local msg = cmd_name .. " failed" + if output and output.stderr and #output.stderr > 0 then + msg = msg .. ": " .. output.stderr:gsub("\n$", "") + end + notify(msg, "error") + return nil + end + return output.stdout +end + +-- Improved sudo handling from reference +local function sudo_already() + local status = Command("sudo"):args({ "--validate", "--non-interactive" }):status() + return status and status.success +end + +local function run_with_sudo(program, args) + local cmd = Command("sudo"):arg(program):args(args):stdout(Command.PIPED):stderr(Command.PIPED) + if sudo_already() then + local output = cmd:output() + return output and output.status.success and output or nil + end + + local _permit = ya.hide() + ya.notify({ title = "httm.yazi", content = "Sudo password required for " .. program, level = "info", timeout = 3 }) + local output = cmd:output() + _permit:drop() + + if output and (output.status.success or sudo_already()) then + return output + end + return nil +end + +-- FS detection and snapshot listing from time-travel.yazi reference +local get_cwd = ya.sync(function() + return tostring(cx.active.current.cwd) +end) + +local function trim(s) + return s:match("^%s*(.-)%s*$") +end + +local function get_filesystem_type(cwd) + local out = Command("stat"):args({ "-f", "-c", "%T", cwd }):output() + return out and out.status.success and trim(out.stdout) or nil +end + +local function zfs_dataset(cwd) + local out = Command("df"):args({ "--output=source", cwd }):output() + if not out or not out.status.success then return nil end + + local dataset + for line in out.stdout:gmatch("[^\r\n]+") do + dataset = line + end + return dataset +end + +local function zfs_mountpoint(dataset) + local out = Command("zfs"):args({ "get", "-H", "-o", "value", "mountpoint", dataset }):output() + if not out or not out.status.success then return nil end + + local mp = trim(out.stdout) + if mp == "legacy" then + local df_out = Command("df"):output() + if not df_out or not df_out.status.success then return nil end + + for line in df_out.stdout:gmatch("[^\r\n]+") do + if line:find(dataset, 1, true) == 1 then + local mountpoint + for field in line:gmatch("%S+") do + mountpoint = field + end + return mountpoint + end + end + end + return mp +end + +local function zfs_relative(cwd, mountpoint) + local relative = cwd:sub(1, #mountpoint) == mountpoint and cwd:sub(#mountpoint + 1) or cwd + local snap_pos = cwd:find(".zfs/snapshot") + if snap_pos then + local after = cwd:sub(snap_pos + #"/snapshot" + 1) + local first_slash = after:find("/") + return first_slash and after:sub(first_slash) or "/" + end + return relative +end + +local function zfs_snapshots(dataset, mountpoint, relative) + local out = Command("zfs"):args({ "list", "-H", "-t", "snapshot", "-o", "name", "-S", "creation", dataset }):output() + if not out or not out.status.success then return {} end + + local snapshots = {} + for snap in out.stdout:gmatch("[^\r\n]+") do + local sep = snap:find("@") + if sep then + local id = snap:sub(sep + 1) + table.insert(snapshots, { id = id, path = mountpoint .. "/.zfs/snapshot/" .. id .. relative }) + end + end + return snapshots +end + +local function btrfs_mountpoint(cwd) + local out = Command("findmnt"):args({ "-no", "TARGET", "-T", cwd }):output() + return out and out.status.success and trim(out.stdout) or nil +end + +local function btrfs_uuids(cwd) + local out = run_with_sudo("btrfs", { "subvolume", "show", cwd }) + if not out then return nil, nil end + + local parent_uuid, uuid + for line in out.stdout:gmatch("[^\r\n]+") do + local p = line:match("^%s*Parent UUID:%s*(%S+)") + if p then parent_uuid = trim(p) end + local u = line:match("^%s*UUID:%s*(%S+)") + if u then uuid = trim(u) end + end + return parent_uuid, uuid +end + +local function btrfs_snapshots(mountpoint, current_uuid, current_parent_uuid) + local out = run_with_sudo("btrfs", { "subvolume", "list", "-q", "-u", mountpoint }) + if not out then return { snapshots = {}, latest_path = "", current_snapshot_id = "" } end + + local snapshots = {} + local latest_path = "" + local current_snapshot_id = "" + for line in out.stdout:gmatch("[^\r\n]+") do + local subvol_id, parent_uuid, uuid, name = line:match("ID (%d+) gen %d+ top level %d+ parent_uuid ([%w-]+)%s+uuid ([%w-]+) path (%S+)") + if subvol_id then + parent_uuid = trim(parent_uuid) + local path = mountpoint .. "/" .. name + local is_parent = (current_parent_uuid == "-" and parent_uuid == "-" and uuid == current_uuid) or (uuid == current_parent_uuid) + if is_parent then + latest_path = path + elseif uuid == current_uuid then + current_snapshot_id = name + end + if not is_parent then + table.insert(snapshots, { id = name, subvol_id = tonumber(subvol_id), path = path }) + end + end + end + + table.sort(snapshots, function(a, b) return a.subvol_id > b.subvol_id end) + return { snapshots = snapshots, latest_path = latest_path, current_snapshot_id = current_snapshot_id } +end + +-- Emulated time-travel actions +local function time_travel(cx, action) + if not (action == "prev" or action == "next" or action == "exit") then + return notify("Invalid time-travel action: " .. action, "error") + end + + local cwd = get_cwd() + local fs_type = get_filesystem_type(cwd) + if not (fs_type == "zfs" or fs_type == "btrfs") then + return notify("Unsupported FS: " .. (fs_type or "unknown"), "error") + end + + local current_snapshot_id = "" + local latest_path = "" + local snapshots = {} + + if fs_type == "zfs" then + local dataset = zfs_dataset(cwd) + if not dataset then return notify("No ZFS dataset", "error") end + + local sep = dataset:find("@") + if sep then + current_snapshot_id = dataset:sub(sep + 1) + dataset = dataset:sub(1, sep - 1) + end + + local mountpoint = zfs_mountpoint(dataset) + if not mountpoint then return notify("No ZFS mountpoint", "error") end + + local relative = zfs_relative(cwd, mountpoint) + latest_path = mountpoint .. relative + snapshots = zfs_snapshots(dataset, mountpoint, relative) + elseif fs_type == "btrfs" then + local mountpoint = btrfs_mountpoint(cwd) + local parent_uuid, uuid = btrfs_uuids(cwd) + if not mountpoint or not uuid then return notify("No BTRFS subvolume", "error") end + + local ret = btrfs_snapshots(mountpoint, uuid, parent_uuid) + snapshots = ret.snapshots + latest_path = ret.latest_path + current_snapshot_id = ret.current_snapshot_id + end + + if action == "exit" then + ya.manager_emit("cd", { latest_path }) + return + end + + if #snapshots == 0 then + return notify("No snapshots found", "warn") + end + + local function find_index(arr, pred) + for i, v in ipairs(arr) do + if pred(v) then return i end + end + return nil + end + + local function goto_snapshot(start_idx, end_idx, step) + if start_idx == 0 then + ya.manager_emit("cd", { latest_path }) + return true + elseif start_idx < 1 or start_idx > #snapshots then + notify("No more snapshots in that direction", "warn") + return false + end + + for i = start_idx, end_idx, step do + local path = snapshots[i].path + local f = io.open(path, "r") + if f then + f:close() + ya.manager_emit("cd", { path }) + return true + end + end + notify("No accessible snapshots in that direction", "warn") + return false + end + + if current_snapshot_id == "" then + if action == "prev" then + goto_snapshot(1, #snapshots, 1) + elseif action == "next" then + notify("Already at latest; use exit to go live", "info") + end + return + end + + local idx = find_index(snapshots, function(s) return s.id == current_snapshot_id end) + if not idx then return notify("Current snapshot not found", "error") end + + if action == "prev" then + goto_snapshot(idx + 1, #snapshots, 1) + elseif action == "next" then + goto_snapshot(idx - 1, 0, -1) + end +end + +local function snapshot(cx) + -- [existing snapshot function unchanged] + local targets = get_targets(cx) + if #targets == 0 then return notify("No target", "error") end + + local cmd = Command("httm"):arg("--snap") + for _, t in ipairs(targets) do cmd:arg(t) end + + local child, err = cmd:spawn() + if not child then return notify("Spawn failed: " .. tostring(err), "error") end + + local status = child:wait() + if status and status.success then + notify("Snapshot created") + ya.manager_emit("refresh", {}) + return + end + + notify("httm --snap failed → retrying sudo...", "warn") + local sudo_cmd = Command("sudo"):arg("httm"):arg("--snap") + for _, t in ipairs(targets) do sudo_cmd:arg(t) end + + local schild, serr = sudo_cmd:spawn() + if not schild then return notify("sudo spawn failed: " .. tostring(serr), "error") end + + local sstatus = schild:wait() + if sstatus and sstatus.success then + notify("Snapshot created (sudo)") + ya.manager_emit("refresh", {}) + else + notify("Snapshot failed (even sudo)", "error") + end +end + +local function browse(cx) + run_interactive(cx, "httm", { "-r", "-R" }) +end + +local function select_files(cx) + -- [existing select_files unchanged] + local _permit = ya.hide() + + local cmd = Command("httm") + :args({ "-s", "-R" }) + :stdout(Command.PIPED) + :stderr(Command.PIPED) + + local output = cmd:output() + if not output or not output.status.success then +# notify("httm -s -R failed: " .. (output and output.stderr or "unknown"), "error") +# return +# end +# +# local paths = {} +# for line in output.stdout:gmatch("[^\n]+") do +# local trimmed = line:match("^%s*(.-)%s*$") +# if trimmed ~= "" then table.insert(paths, trimmed) end +# end +# +# if #paths == 0 then +# notify("No files selected", "info") +# return +# end +# +# ya.manager_emit("yank", { paths = paths, silent = true }) +# notify("Yanked " .. #paths .. " snapshot path(s)") +# end +# +# -- [bowie, ounce, nicotine, equine unchanged] +# +# local function bowie(cx, mode) +# local targets = get_targets(cx) +# if #targets == 0 then return notify("No target for bowie", "error") end +# + if not script_exists("bowie") then + return notify("bowie script not found in PATH", "error") + end + + local args = {} + if mode == "all" then table.insert(args, "--all") end + if mode == "select" then table.insert(args, "--select") end + for _, t in ipairs(targets) do table.insert(args, t) end + + run_interactive(cx, "bowie", args) +end + +local function ounce(cx) + local targets = get_targets(cx) + if #targets == 0 then return notify("No target for ounce", "error") end + + if not script_exists("ounce") then + return notify("ounce script not found in PATH", "error") + end + + local args = { "--direct" } + for _, t in ipairs(targets) do table.insert(args, t) end + + run_interactive(cx, "ounce", args) +end + +local function nicotine(cx) + local targets = get_targets(cx) + if #targets == 0 then return notify("No target for nicotine", "error") end + + if not script_exists("nicotine") then + return notify("nicotine script not found in PATH", "error") + end + + run_interactive(cx, "nicotine", targets) +end + +local function equine(cx, subcmd) + if not script_exists("equine") then + return notify("equine script not found in PATH", "error") + end + + local arg = "--mount-local" + if subcmd == "umount-local" then arg = "--unmount-local" + elseif subcmd == "mount-remote" then arg = "--mount-remote" + elseif subcmd == "umount-remote" then arg = "--unmount-remote" end + + run_interactive(cx, "equine", { arg }) +end + +return { + entry = function(_, job) + local arg = job.args[1] + + if arg == "snapshot" then + snapshot(_) + elseif arg == "browse" then + browse(_) + elseif arg == "select" then + select_files(_) + elseif arg == "bowie" then + bowie(_, "last") + elseif arg == "bowie-all" then + bowie(_, "all") + elseif arg == "bowie-select" then + bowie(_, "select") + elseif arg == "ounce" then + ounce(_) + elseif arg == "nicotine" then + nicotine(_) + elseif arg == "equine-mount-local" then + equine(_, "mount-local") + elseif arg == "equine-umount-local" then + equine(_, "umount-local") + elseif arg == "equine-mount-remote" then + equine(_, "mount-remote") + elseif arg == "equine-umount-remote" then + equine(_, "umount-remote") + elseif arg == "prev" or arg == "next" or arg == "exit" then + time_travel(_, arg) + else + notify("Unknown command: " .. tostring(arg or ""), "error") + end + end, +} + +EOF +} +conf_print_httm_main_old | tee "${HTTM_PLUGIN_HOME}/main.lua_old" + +#---------------------------------------------------------------- + +conf_print_httm_license() { + cat <