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.
This commit is contained in:
cyteen 2026-03-04 15:09:42 +00:00
parent c859679c97
commit b2391795af
5 changed files with 2045 additions and 0 deletions

View File

@ -0,0 +1,327 @@
#!/usr/bin/env bash
: <<!
-------------------------------------------------------------------------------
Yazi httm-bowie previewer + toggle — pure Lua (preview pane, toggleable modes)
Cycles through: none (default preview) → last diff → all diffs
Requires: httm, diff, tree (for dirs), realpath
-------------------------------------------------------------------------------
!
set -euo pipefail
DEST=${1:-/etc/skel/}
YAZI_HOME="${DEST}/.config/yazi"
YAZI_PLUGIN_HOME="${YAZI_HOME}/plugins"
BOWIE_PLUGIN_HOME="${YAZI_PLUGIN_HOME}/httm-bowie.yazi"
mkdir -p "${BOWIE_PLUGIN_HOME}"
#----------------------------------------------------------------
conf_print_bowie_previewer() {
cat <<'EOF'
-- httm-bowie.yazi/init.lua
-- Custom previewer: shows bowie diff based on global mode (none/last/all)
local M = {}
-- Shared state via ya.sync (initial: "none")
local mode_get = ya.sync(function() return _G.BOWIE_MODE or "none" end)
function M:peek(job)
local mode = mode_get()
if mode == "none" then
return -- skip to default previewer
end
local path = tostring(job.url)
local is_file = os.execute("test -f " .. ya.quote(path)) == 0 or os.execute("test -h " .. ya.quote(path)) == 0
local real_cmd = Command("realpath"):arg(path):stdout(Command.PIPED):spawn()
if real_cmd then
real_cmd:wait()
path = real_cmd:read_line() or path
path = path:gsub("\n$", "")
end
local content = ""
local function add_header(txt) content = content .. txt .. "\n__\n\n" end
local function add_diff(prev, curr, is_dir)
if is_dir then
local tree_prev = Command("tree"):arg("-RDsa"):arg(prev):stdout(Command.PIPED):spawn()
local tree_curr = Command("tree"):arg("-RDsa"):arg(curr):stdout(Command.PIPED):spawn()
if not tree_prev or not tree_curr then return end
tree_prev:wait()
tree_curr:wait()
local p_out = tree_prev:read_all() or ""
local c_out = tree_curr:read_all() or ""
p_out = p_out:gsub("^.*\n", "", 1):gsub("\n$", "") -- skip first line
c_out = c_out:gsub("^.*\n", "", 1):gsub("\n$", "")
local diff_cmd = Command("diff"):arg("--color=always"):arg("-T"):arg("--label=" .. prev):arg("--label=" .. curr):stdout(Command.PIPED)
diff_cmd:stdin(Command.PIPED):write(p_out .. "\n" .. c_out):spawn() -- hacky, but diff from strings
diff_cmd:wait()
content = content .. (diff_cmd:read_all() or "") .. "\n"
else
local diff = Command("diff"):arg("--color=always"):arg("-T"):arg(prev):arg(curr):stdout(Command.PIPED):spawn()
if diff then
diff:wait()
content = content .. (diff:read_all() or "No diff") .. "\n"
end
end
end
local function get_last_snap(p)
local cmd = Command("httm"):arg("-n"):arg("--dedup-by=contents"):arg("--omit-ditto"):arg("--last-snap"):arg(p):stdout(Command.PIPED):spawn()
if not cmd then return nil end
cmd:wait()
return cmd:read_line():gsub("\n$", "")
end
local function get_all_snaps(p)
local dedup = is_file and "contents" or "disable"
local cmd = Command("httm"):arg("-n"):arg("--dedup-by=" .. dedup):arg("--omit-ditto"):arg(p):stdout(Command.PIPED):spawn()
if not cmd then return {} end
cmd:wait()
local snaps = {}
for line in cmd:read_lines() do
line = line:gsub("\n$", "")
if line ~= "" then table.insert(snaps, line) end
end
return snaps
end
if mode == "last" then
local snap = get_last_snap(path)
if not snap or snap == "" or snap == path then
content = "No previous snapshot or identical to live."
else
add_header(path .. " (last snapshot diff)")
add_diff(snap, path, not is_file)
end
elseif mode == "all" then
local snaps = get_all_snaps(path)
if #snaps == 0 then
content = "No snapshots found."
else
add_header(path .. " (all changes)")
local prev = snaps[1]
for i = 2, #snaps do
local curr = snaps[i]
content = content .. "-- " .. prev .. " → " .. curr .. " --\n"
add_diff(prev, curr, not is_file)
prev = curr
end
content = content .. "-- " .. prev .. " → LIVE (" .. path .. ") --\n"
add_diff(prev, path, not is_file)
end
end
if content == "" then content = "No content generated." end
ya.preview_code({
area = job.area,
hup = false, -- don't update on hover change
content = content,
filetype = "diff", -- for syntax highlight
})
end
function M:seek(job)
self:peek(job) -- simple, no complex seeking
end
return M
EOF
}
conf_print_bowie_previewer | tee "${BOWIE_PLUGIN_HOME}/init.lua"
#----------------------------------------------------------------
# FIXME: Seems to be a separate plugin just to handle toggle/cycle behaiour:
conf_print_bowie_toggle() {
cat <<'EOF'
-- httm-bowie-toggle.yazi/init.lua
-- Toggle mode: none → last → all → none
return {
entry = function()
local modes = { "none", "last", "all" }
local current = _G.BOWIE_MODE or "none"
local next_idx = 1
for i, m in ipairs(modes) do
if m == current then
next_idx = (i % #modes) + 1
break
end
end
_G.BOWIE_MODE = modes[next_idx]
ya.notify({ title = "Bowie", content = "Mode: " .. _G.BOWIE_MODE, timeout = 2 })
-- Force preview refresh
ya.manager_emit("peek", { cx.active.preview.skip or 0, only_if = tostring(cx.active.current.hovered.url), force = true })
end,
}
EOF
}
# conf_print_bowie_toggle | tee "${HTTM_TOGGLE_HOME}/init.lua"
#----------------------------------------------------------------
conf_print_bowie_config() {
cat <<'EOF'
# Add to ~/.config/yazi/yazi.toml
[plugin]
prepend_previewers = [
{ name = "*", run = "httm-bowie", sync = true },
]
EOF
}
#----------------------------------------------------------------
conf_print_bowie_keymap() {
cat <<'EOF'
# Add to ~/.config/yazi/keymap.toml
[[manager.prepend_keymap]]
on = [ "g", "d" ]
run = "plugin httm-bowie-toggle"
desc = "Toggle Bowie preview mode (none/last/all)"
EOF
}
#----------------------------------------------------------------
conf_print_bowie_license() {
cat <<EOF
MIT License
Copyright (c) 2026 ${AUTHOR_NAME}
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
EOF
}
conf_print_bowie_license | tee "${BOWIE_PLUGIN_HOME}/LICENSE"
#----------------------------------------------------------------
conf_print_bowie_README() {
cat <<'EOF'
This is a specialized plugin for Yazi that integrates `httm` (the Interactive ZFS Snapshot Explorer) directly into your preview pane. It allows you to cycle through different "time-travel" views of your files and directories without leaving the file manager.
Below is a formatted `README.md` you can use for this project.
---
# httm-bowie.yazi
A high-performance **Yazi** plugin that brings `httm` snapshot diffs directly into your preview pane. Cycle through historical versions of files and directories with a single keybind.
## 🌟 Features
* **Three Preview Modes**:
* `none`: Standard Yazi preview (syntax highlighting, images, etc.).
* `last`: Shows a `diff` between the live file and its most recent snapshot.
* `all`: Shows a sequential `diff` chain of all historical changes found by `httm`.
* **Directory Support**: Uses `tree` to diff directory structures if a folder is hovered.
* **Smart Dedup**: Automatically handles `httm`'s deduplication to show only meaningful changes.
* **Native Feel**: Integrated with Yazi's `ya.preview_code` for smooth scrolling and `diff` syntax highlighting.
## 📋 Requirements
Ensure the following are installed and available in your `$PATH`:
* [httm](https://github.com/kimono-koans/httm)
* `diff` (usually pre-installed on Unix)
* `tree` (required for directory previews)
* `realpath`
## 🚀 Installation
### 1. Create the Plugin Folders
Yazi expects plugins in specific directories. Create them and place the `init.lua` files accordingly:
```bash
# Create directories
mkdir -p ~/.config/yazi/plugins/httm-bowie.yazi
mkdir -p ~/.config/yazi/plugins/httm-bowie-toggle.yazi
```
1. Save the **Previewer** code to `~/.config/yazi/plugins/httm-bowie.yazi/init.lua`.
2. Save the **Toggle** code to `~/.config/yazi/plugins/httm-bowie-toggle.yazi/init.lua`.
### 2. Configure `yazi.toml`
Add the previewer to the top of your `prepend_previewers` list so it can intercept the preview before the default handlers:
```toml
[plugin]
prepend_previewers = [
{ name = "*", run = "httm-bowie", sync = true },
]
```
### 3. Configure `keymap.toml`
Bind the toggle function to a key of your choice (e.g., `gd` for "Go Diff"):
```toml
[[manager.prepend_keymap]]
on = [ "g", "d" ]
run = "plugin httm-bowie-toggle"
desc = "Toggle Bowie preview mode (none/last/all)"
```
## 🛠️ Usage
1. **Navigate** to any file or directory inside a ZFS dataset (or any location monitored by `httm`).
2. **Press `gd**` to cycle through modes:
* **Notification: "Mode: last"** → The preview pane now shows the changes made since the last snapshot.
* **Notification: "Mode: all"** → The preview pane shows a scrollable history of all changes across all snapshots.
* **Notification: "Mode: none"** → Returns to standard Yazi behavior.
3. **Scroll** through the diffs using your standard Yazi preview scroll keys.
EOF
}
conf_print_bowie_README | tee "${BOWIE_PLUGIN_HOME}/README.md"
#----------------------------------------------------------------
cat <<EOF
httm-bowie previewer + toggle installed.
Previewer: ${BOWIE_PLUGIN_HOME}/init.lua
Toggle: ${BOWIE_TOGGLE_HOME}/init.lua
conf_print_bowie_config
conf_print_bowie_keymap
Add the above to yazi.toml and keymap.toml.
Restart yazi. Use 'gd' to cycle modes. When 'none', falls back to default preview.
EOF

View File

@ -0,0 +1,377 @@
#!/usr/bin/env bash
: <<!
------------------------------------------------------------------------------
Yazi nicotine plugin — using external nicotine bash script patched to create
a single repo from multiple selected files.
Requires: httm, git, tar, find, stat, realpath (or readlink -f), mktemp
------------------------------------------------------------------------------
!
DEST=${1:-/etc/skel/}
YAZI_HOME="${DEST}/.config/yazi"
YAZI_PLUGIN_HOME="${YAZI_HOME}/plugins"
NICOTINE_PLUGIN_HOME="${YAZI_PLUGIN_HOME}/nicotine.yazi"
mkdir -p "${NICOTINE_PLUGIN_HOME}"
conf_print_nicotine_main() {
cat <<'EOF'
local DEBUG_MODE = true
local function log(message)
if DEBUG_MODE then
ya.err(string.format("[Nicotine Debug] %s", message))
end
end
-- 1. Grab current state (Sync to access cx)
local setup = ya.sync(function()
local targets = {}
local selected = cx.active.selected
if #selected == 0 then
local hovered = cx.active.current.hovered
if hovered then
table.insert(targets, tostring(hovered.url))
end
else
for _, item in pairs(selected) do
table.insert(targets, tostring(item.url))
end
end
return {
targets = targets,
cwd = tostring(cx.active.current.cwd)
}
end)
return {
entry = function(self, args)
log("Plugin started")
local state = setup()
local targets = state.targets
local cwd = state.cwd
if #targets == 0 then
return ya.notify({ title = "Nicotine", content = "No files selected", level = "warn" })
end
local is_archive = args[1] == "--archive"
-- Create a unique temp container
local tmp_container = string.format("%s/yazi-nicotine-%d", os.getenv("TMPDIR") or "/tmp", math.random(100000, 999999))
local status = Command("mkdir"):arg("-p"):arg(tmp_container):spawn():wait()
if not status or not status.success then
return ya.notify({ title = "Nicotine", content = "Failed to create temp dir", level = "error" })
end
ya.notify({
title = "Nicotine",
content = is_archive and "Creating archive..." or "Generating Git history...",
timeout = 2
})
-- Build Nicotine Command
-- Note: using --single-repo as seen in your test script to ensure one combined git
local cmd = Command("nicotine")
cmd:arg("--output-dir"):arg(tmp_container)
cmd:arg("--single-repo")
if not is_archive then
cmd:arg("--no-archive")
end
for _, path in ipairs(targets) do
cmd:arg(path)
end
local nicotine_status = cmd:spawn():wait()
if not nicotine_status or not nicotine_status.success then
Command("rm"):arg("-rf"):arg(tmp_container):spawn():wait()
return ya.notify({ title = "Nicotine Error", content = "Nicotine execution failed.", level = "error" })
end
-- Path fix: nicotine creates a sub-directory
local repo_path = tmp_container .. "/nicotine-combined-git"
if is_archive then
local archive_src = tmp_container .. "/nicotine-combined-git.tar.gz"
local status = Command("cp")
:arg(archive_src)
:arg(cwd)
:spawn()
:wait()
if not status or not status.success then
ya.notify({ title = "Nicotine", content = "Failed to copy archive", level = "error" })
return
end
ya.notify({ title = "Nicotine", content = "Archive copied to current directory", timeout = 3 })
else
-- Git/Lazygit Section
local permit = ui.hide()
local child_status = Command("lazygit")
:cwd(repo_path) -- Enter the actual git directory
:stdin(Command.INHERIT)
:stdout(Command.INHERIT)
:stderr(Command.INHERIT)
:spawn()
:wait()
permit:drop()
end
-- Cleanup container
Command("rm"):arg("-rf"):arg(tmp_container):spawn():wait()
if not is_archive then
ya.notify({ title = "Nicotine", content = "Session complete.", timeout = 2 })
end
end
}
EOF
}
conf_print_nicotine_main | tee "${NICOTINE_PLUGIN_HOME}/main.lua"
#----------------------------------------------------------------
conf_print_nicotine_keymap() {
cat <<'EOF'
[[mgr.prepend_keymap]]
on = [ "g", "n" ]
run = "plugin nicotine"
desc = "Run Nicotine Git snapshot history (lazygit)"
[[mgr.prepend_keymap]]
on = [ "g", "z" ]
run = "plugin nicotine --args='--archive'"
desc = "Create compressed Nicotine archive of selection"
EOF
}
# conf_print_nicotine_keymap | tee -a "${YAZI_HOME}/keymap.toml"
#----------------------------------------------------------------
# Create a version of nicotine that has the --single-repo option.
conf_print_nicotine_script() {
cat <<'EOF'
#!/usr/bin/env bash
set -euf -o pipefail
#set -x
print_version() {
printf "\
nicotine $(httm --version | cut -f2 -d' ')
" 1>&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

View File

@ -0,0 +1,238 @@
#!/usr/bin/env bash
: <<!
# ------------------------------------------------------------------------------
# Yazi nicotine plugin — pure Lua reimplementation (no external nicotine script)
# Requires: httm, git, tar, find, stat, realpath (or readlink -f), mktemp
# ------------------------------------------------------------------------------
!
set -euo pipefail
DEST=${1:-/etc/skel/}
YAZI_HOME="${DEST}/.config/yazi"
YAZI_PLUGIN_HOME="${YAZI_HOME}/plugins"
NICOTINE_PLUGIN_HOME="${YAZI_PLUGIN_HOME}/httm-nicotine.yazi"
mkdir -p "${NICOTINE_PLUGIN_HOME}"
conf_print_nicotine_main() {
cat <<'EOF'
local function log(msg)
-- ya.err("[nicotine] " .. msg) -- uncomment for debug logging to yazi log
end
-- Safe git identity (prevents commit failures when git config is missing)
local GIT_ENV = {
GIT_AUTHOR_NAME = "yazi-nicotine",
GIT_AUTHOR_EMAIL = "yazi-nicotine@localhost",
GIT_COMMITTER_NAME = "yazi-nicotine",
GIT_COMMITTER_EMAIL = "yazi-nicotine@localhost",
}
-- Grab selected files / hovered file + cwd (sync context only)
local get_state = ya.sync(function()
local targets = {}
local selected = cx.active.selected
if #selected == 0 then
local hovered = cx.active.current.hovered
if hovered then
table.insert(targets, tostring(hovered.url))
end
else
for _, item in pairs(selected) do
table.insert(targets, tostring(item.url))
end
end
return {
targets = targets,
cwd = tostring(cx.active.current.cwd),
}
end)
local function run(cmd_builder)
local child, err = cmd_builder:spawn()
if not child then
ya.notify({ title = "Nicotine", content = "Spawn failed: " .. tostring(err), level = "error" })
return nil
end
local status = child:wait()
if not status.success then
ya.notify({ title = "Nicotine", content = cmd_builder._program .. " failed (code " .. tostring(status.code) .. ")", level = "error" })
return nil
end
return status
end
local function get_modification_time(path)
local st = run(Command("stat"):arg("-c"):arg("%Y"):arg(path))
if not st then return os.time() end -- fallback
local code = st.output and tonumber(st.output:match("^%d+")) or os.time()
return code
end
local function copy_add_commit(debug, src_path, repo_dir, commit_msg_suffix)
local cp = Command("cp"):arg("-a"):arg(src_path):arg(repo_dir .. "/")
run(cp)
local date_unix = get_modification_time(src_path)
local date_str = os.date("!%Y-%m-%dT%H:%M:%S", date_unix)
local git = Command("git"):cwd(repo_dir)
:env(GIT_ENV.GIT_AUTHOR_NAME, GIT_ENV.GIT_AUTHOR_NAME)
:env(GIT_ENV.GIT_AUTHOR_EMAIL, GIT_ENV.GIT_AUTHOR_EMAIL)
:env(GIT_ENV.GIT_COMMITTER_NAME, GIT_ENV.GIT_COMMITTER_NAME)
:env(GIT_ENV.GIT_COMMITTER_EMAIL,GIT_ENV.GIT_COMMITTER_EMAIL)
run(git:arg("add"):arg("--all"))
local commit = git:arg("commit")
:arg("--quiet")
:arg("--allow-empty")
:arg("--message"):arg("httm snapshot: " .. commit_msg_suffix)
:arg("--date"):arg(date_str)
if debug then commit = commit:arg("--verbose") end
run(commit)
end
-- Recursive collection of unique versions (mimics original traverse + get_unique_versions)
local function process_path(debug, path, repo_dir)
local httm_out = ""
local httm_cmd = Command("httm"):arg("-n"):arg("--omit-ditto"):arg(path)
:stdout(Command.PIPED):stderr(Command.PIPED)
local child, _ = httm_cmd:spawn()
if child then
local status = child:wait()
if status.success then
httm_out = child:read_lines() or ""
end
end
local versions = {}
for line in httm_out:gmatch("[^\n]+") do
if line ~= "" then table.insert(versions, line) end
end
if #versions <= 1 then
-- no snapshots or single version → just current file/dir
copy_add_commit(debug, path, repo_dir, "current version of " .. path)
else
for _, ver in ipairs(versions) do
copy_add_commit(debug, ver, repo_dir, "snapshot: " .. ver)
end
end
-- If directory, recurse into children
local is_dir = run(Command("test"):arg("-d"):arg(path))
if is_dir then
local find_cmd = Command("find"):arg(path):arg("-mindepth"):arg("1"):arg("-maxdepth"):arg("1")
:stdout(Command.PIPED)
local child = find_cmd:spawn()
if child then
local status = child:wait()
if status.success then
for line in child:read_lines() do
if line ~= "" then
local sub = line
local base = sub:match("^.*/([^/]+)$") or "unknown"
local sub_repo = repo_dir .. "/" .. base
run(Command("mkdir"):arg("-p"):arg(sub_repo))
process_path(debug, sub, sub_repo)
end
end
end
end
end
end
return {
entry = function(_, args)
local state = get_state()
local targets = state.targets
local cwd = state.cwd
if #targets == 0 then
return ya.notify({ title = "Nicotine", content = "No files selected or hovered", level = "warn" })
end
local is_archive = args[1] == "--archive"
local debug = false -- set to true for verbose git output
local tmp_base = os.getenv("TMPDIR") or "/tmp"
local rand = math.random(10000000, 99999999)
local tmp_dir = string.format("%s/yazi-nicotine-%d", tmp_base, rand)
local repo_dir = tmp_dir .. "/nicotine-combined-git"
run(Command("mkdir"):arg("-p"):arg(repo_dir))
ya.notify({
title = "Nicotine",
content = is_archive and "Creating archive..." or "Building git history...",
timeout = 3,
})
run(Command("git"):cwd(repo_dir):arg("init"):arg("--quiet"))
for _, p in ipairs(targets) do
local real = run(Command("realpath"):arg(p))
if real then
process_path(debug, real.output:match("^%S+"), repo_dir)
end
end
if is_archive then
local archive_path = cwd .. "/nicotine-combined-git.tar.gz"
run(Command("tar"):cwd(tmp_dir):arg("-zcf"):arg(archive_path):arg("nicotine-combined-git"))
run(Command("rm"):arg("-rf"):arg(tmp_dir))
ya.notify({ title = "Nicotine", content = "Archive created:\n" .. archive_path, timeout = 5 })
else
-- Open lazygit
local permit = ui.hide()
local lg = Command("lazygit")
:cwd(repo_dir)
:stdin(Command.INHERIT)
:stdout(Command.INHERIT)
:stderr(Command.INHERIT)
local child, err = lg:spawn()
if not child then
permit:drop()
run(Command("rm"):arg("-rf"):arg(tmp_dir))
return ya.notify({ title = "Nicotine", content = "lazygit failed: " .. tostring(err), level = "error" })
end
child:wait()
permit:drop()
run(Command("rm"):arg("-rf"):arg(tmp_dir))
ya.notify({ title = "Nicotine", content = "Session ended.", timeout = 2 })
end
end,
}
EOF
}
conf_print_nicotine_main | tee "${NICOTINE_PLUGIN_HOME}/main.lua"
conf_print_nicotine_keymap() {
cat <<'EOF'
[[manager.prepend_keymap]]
on = [ "g", "n" ]
run = "plugin nicotine"
desc = "Nicotine: view git history of selection in lazygit"
[[manager.prepend_keymap]]
on = [ "g", "z" ]
run = "plugin nicotine --args='--archive'"
desc = "Nicotine: create .tar.gz snapshot archive of selection"
EOF
}
# conf_print_nicotine_keymap | tee -a "${NICOTINE_PLUGIN_HOME}/keymap.toml"
echo "Plugin location: ${NICOTINE_PLUGIN_HOME}/main.lua"
echo "Remember to restart yazi or run :plugin --reload nicotine"

View File

@ -0,0 +1,181 @@
#!/usr/bin/env bash
: <<!
# ------------------------------------------------------------------------------
# Yazi zdbstat previewer — pure Lua (preview pane, toggleable)
# Shows zdb -dddddddddd output in preview when toggled on
# Requires: zfs, zdb, stat, realpath, sudo/doas/pkexec
# ------------------------------------------------------------------------------
!
set -euo pipefail
DEST=${1:-/etc/skel/}
YAZI_HOME="${DEST}/.config/yazi"
YAZI_PLUGIN_HOME="${YAZI_HOME}/plugins"
ZDBSTAT_PLUGIN_HOME="${YAZI_PLUGIN_HOME}/httm-zdbstat.yazi"
mkdir -p "$PLUGIN_HOME"
cat >"${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)."

922
020_yazi-plugin_httm.sh Normal file
View File

@ -0,0 +1,922 @@
#!/usr/bin/env bash
: <<TODO
The main.lua now passes to commandline httm which fails because not snapshots
found (autosnapshot not installed yet), but not other tests done.
main.lua_old is broken (old syntax) but outlines more functionalty.
TODO
DEST=${1:-/etc/skel/}
YAZI_HOME="${DEST}/.config/yazi"
YAZI_PLUGIN_HOME="${YAZI_HOME}/plugins"
HTTM_PLUGIN_HOME="${YAZI_PLUGIN_HOME}/httm.yazi"
mkdir -p "${HTTM_PLUGIN_HOME}"
: <<TODO
Add support for current selection as input to httm (pipe paths via
:stdin(Command.PIPED) + write targets to child's stdin)
Extra modes: --args='last' → httm -l -r (restore latest), --args='diff'
wrap bowie if installed
--no-live, --omit-ditto, --json parsing for richer integration
Custom preview command via --preview="bat --color=always {}" (needs
interactive mode tweak)
TODO
#----------------------------------------------------------------
conf_print_httm_require() {
cat <<'EOF'
require("httm"):setup({
-- Example: showing the number of snapshots in the status bar
show_count = true,
-- Example: custom color for the httm indicator
color = "#ff0000"
})
EOF
}
# conf_print_httm_require | tee -a "${YAZI_HOME}/init.lua"
#----------------------------------------------------------------
conf_print_httm_keymap() {
cat <<'EOF'
# ~/.config/yazi/keymap.toml
# httm plugin leader: <SHIFT>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 "<none>"), "error")
end
end,
}
EOF
}
conf_print_httm_main_old | tee "${HTTM_PLUGIN_HOME}/main.lua_old"
#----------------------------------------------------------------
conf_print_httm_license() {
cat <<EOF
MIT License
Copyright (c) 2026 ${AUTHOR_NAME}
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
EOF
}
conf_print_httm_license | tee "${HTTM_PLUGIN_HOME}/LICENSE"
#----------------------------------------------------------------
conf_print_httm_README() {
cat <<'EOF'
# **kimono-koans/httm** plugin for **Yazi**
This plugin enables **time-travel navigation** using filesystem snapshots (e.g., BTRFS/ZFS),
allowing you to browse, restore, diff, and manage file versions efficiently.
## Requirements
- [Yazi][yazi-link] v26.2.2
- [httm][httm-link] v0.49.9
## Installation
```bash
sh
# Add the plugin
ya pkg add cyteen/httm
# Install plugin
ya pkg install
# Update plugin
ya pkg upgrade
```
### Key Features & Usage
- **Snapshot Management**:
- $(\s): Create a snapshot of current/selected items.
- $(\b): Browse and restore files via an interactive TUI.
- $(\y): Select snapshot versions and yank file paths for reuse.
- **Time Navigation**:
- $(\p) / $(\n): Jump to previous/next snapshot (older/newer).
- $(\l): Exit snapshot view and return to live filesystem.
- **Diffs with Bowie**:
- $(\d): Show differences with the last snapshot.
- $(\D): Show diffs across all versions.
- **Git Integration**:
- $(\g): Export file versions as a Git archive using **nicotine**.
- **Time Machine (Equine)**:
- $(\tm) / $(\tu): Mount/unmount local Time Machine snapshots.
- $(\tr) / $(\tR): Mount/unmount remote snapshots.
- **Helpers**:
- $(\o): Run **ounce** for dynamic snapshot checks.
- $(\?): Display help menu with all httm commands.
EOF
}
conf_print_httm_README | tee "${HTTM_PLUGIN_HOME}/README.md"