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:
parent
c859679c97
commit
b2391795af
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)."
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue