#!/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 <