fix(yazi): update plugins

This commit is contained in:
2025-04-28 19:57:56 +03:00
parent 94fabd2f50
commit 473d4a771a
61 changed files with 5624 additions and 6531 deletions

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Xianyi Lin
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.

View File

@@ -0,0 +1,41 @@
# time-travel.yazi
A Yazi plugin for browsing backwards and forwards in time via BTRFS / ZFS
snapshots.
https://github.com/user-attachments/assets/6d2fc9e7-f86e-4444-aab6-4e11e51e8b34
## Installation
```sh
ya pack -a iynaix/time-travel
```
> [!NOTE]
> The minimum required yazi version is 25.2.7.
## Usage
Add keymaps similar to the following to your `~/.config/yazi/keymap.toml`:
```toml
[[manager.prepend_keymap]]
on = ["z", "h"]
run = "plugin time-travel --args=prev"
desc = "Go to previous snapshot"
[[manager.prepend_keymap]]
on = ["z", "l"]
run = "plugin time-travel --args=next"
desc = "Go to next snapshot"
[[manager.prepend_keymap]]
on = ["z", "e"]
run = "plugin time-travel --args=exit"
desc = "Exit browsing snapshots"
```
#### Note for BTRFS
`sudo` is required to run btrfs commands such as `btrfs subvolume list`, the
plugin will drop into a terminal to prompt for the password.

View File

@@ -0,0 +1,386 @@
---@param msg string
local notify_warn = function(msg)
ya.notify { title = "ZFS", content = msg, level = "warn", timeout = 5 }
end
---@param msg string
local notify_error = function(msg)
ya.notify { title = "ZFS", content = msg, level = "error", timeout = 5 }
end
---@param arr table
---@param predicate fun(value: any): boolean
---@return number|nil # index if found, nil if not found
local find_index = function(arr, predicate)
for i, value in ipairs(arr) do
if predicate(value) then
return i
end
end
return nil
end
--- Verify if `sudo` is already authenticated
--- @return boolean
local function sudo_already()
local status = Command("sudo"):args({ "--validate", "--non-interactive" }):status()
assert(status, "Failed to run `sudo --validate --non-interactive`")
return status.success
end
--- Run a program with `sudo` privilege
--- @param program string
--- @param args table
--- @return Output|nil output
--- @return integer|nil err
--- nil: no error
--- 1: sudo failed
local function run_with_sudo(program, args)
local cmd = Command("sudo"):args({ program, table.unpack(args) }):stdout(Command.PIPED):stderr(Command.PIPED)
if sudo_already() then
return cmd:output()
end
local permit = ya.hide()
print(string.format("Sudo password required to run: `%s %s`", program, table.concat(args, " ")))
local output = cmd:output()
permit:drop()
if output.status.success or sudo_already() then
return output
end
return nil, 1
end
---@return string
local get_cwd = ya.sync(function()
return tostring(cx.active.current.cwd)
end)
---@param s string
---@return string
local trim = function(s)
return s:match("^%s*(.-)%s*$")
end
---@param cwd string
---@return string|nil
local get_filesystem_type = function(cwd)
local stat, _ = Command("stat"):args({ "-f", "-c", "%T", cwd }):output()
if not stat.status.success then
return nil
end
return trim(stat.stdout)
end
---@param cwd string
---@return string|nil
local zfs_dataset = function(cwd)
local df, _ = Command("df"):args({ "--output=source", cwd }):output()
local dataset = nil
for line in df.stdout:gmatch("[^\r\n]+") do
-- dataset is last line in output
dataset = line
end
return dataset
end
---@param dataset string
---@return string|nil
local zfs_mountpoint = function(dataset)
local zfs, _ = Command("zfs"):args({ "get", "-H", "-o", "value", "mountpoint", dataset }):output()
-- not a dataset!
if not zfs.status.success then
return nil
end
-- legacy mountpoint, search for actual mountpoint using df
if zfs.stdout == "legacy\n" then
local df, _ = Command("df"):output()
if not df.status.success then
return nil
end
for line in df.stdout:gmatch("[^\r\n]+") do
-- match start of line
if string.sub(line, 1, #dataset) == dataset then
local mountpoint = nil
for field in line:gmatch("%S+") do
-- mountpoint is last field in df output
mountpoint = field
end
return mountpoint
end
end
else
return zfs.stdout:gsub("\n$", "")
end
-- shouldn't be here
return nil
end
-- returns the path relative to the mountpoint / snapshot
---@param cwd string
---@param mountpoint string
local zfs_relative = function(cwd, mountpoint)
-- relative path to get mountpoint
local relative = (cwd:sub(0, #mountpoint) == mountpoint) and cwd:sub(#mountpoint + 1) or cwd
-- is a snapshot dir, strip everything after "/snapshot"
if cwd:find(".zfs/snapshot") ~= nil then
local snapshot_pos = cwd:find("/snapshot")
-- everything after the "/snapshot/"
local after = cwd:sub(snapshot_pos + #"/snapshot" + 1)
local first_slash = after:find("/")
-- root of snapshot?
if first_slash == nil then
return "/"
else
return after:sub(first_slash)
end
end
return relative
end
---@class Snapshot
---@field name string
---@field path string
---@param dataset string
---@param mountpoint string
---@param relative string
---@return Snapshot[]
local zfs_snapshots = function(dataset, mountpoint, relative)
-- -S is for reverse order
local zfs_snapshots, _ = Command("zfs"):args({ "list", "-H", "-t", "snapshot", "-o", "name", "-S", "creation",
dataset })
:output()
if not zfs_snapshots.status.success then
return {}
end
---@type Snapshot[]
local snapshots = {}
for snapshot in zfs_snapshots.stdout:gmatch("[^\r\n]+") do
-- in the format dataset@snapshot
local sep = snapshot:find("@")
local id = snapshot:sub(sep + 1)
table.insert(snapshots, {
id = id,
path = mountpoint .. "/.zfs/snapshot/" .. id .. relative,
})
end
return snapshots
end
---@param cwd string
---@return string|nil
local function btrfs_mountpoint(cwd)
local cmd, _ = Command("findmnt"):args({ "-no", "TARGET", "-T", cwd }):output()
if not cmd.status.success then
return nil
end
return trim(cmd.stdout)
end
---Returns the current uuid and the parent uuid
---@param cwd string
---@return string|nil, string|nil
local function btrfs_uuids(cwd)
local cmd, _ = run_with_sudo("btrfs", { "subvolume", "show", cwd })
if not cmd then
return nil
end
local parent_uuid = nil
local uuid = nil
for line in cmd.stdout:gmatch("[^\r\n]+") do
local parent_uuid_re = line:match("^%s*Parent UUID:%s*(%S+)")
if parent_uuid_re then
parent_uuid = trim(parent_uuid_re)
end
local uuid_re = line:match("^%s*UUID:%s*(%S+)")
if uuid_re then
uuid = trim(uuid_re)
end
end
return parent_uuid, uuid
end
---@param mountpoint string
---@param current_uuid string
---@param current_parent_uuid string|nil
---@return { snapshots: Snapshot[], latest_path: string, current_snapshot_id: string }
local function btrfs_snapshots(mountpoint, current_uuid, current_parent_uuid)
local snapshots_cmd, _ = run_with_sudo("btrfs", { "subvolume", "list", "-q", "-u", mountpoint })
if not snapshots_cmd then
return {}
end
local snapshots = {}
local latest_path = ""
local current_snapshot_id = ""
for line in snapshots_cmd.stdout:gmatch("[^\r\n]+") do
local pattern = "ID (%d+) gen %d+ top level %d+ parent_uuid ([%w-]+)%s+uuid ([%w-]+) path (%S+)"
-- Extract the fields
local subvol_id, parent_uuid, uuid, name = line:match(pattern)
parent_uuid = trim(parent_uuid)
local path = mountpoint .. "/" .. name
local is_parent = false
if current_parent_uuid == "-" then
if parent_uuid == "-" and uuid == current_uuid then
is_parent = true
end
else
if uuid == current_parent_uuid then
is_parent = true
end
end
if is_parent then
latest_path = path
end
if uuid == current_uuid and not is_parent then
current_snapshot_id = name
end
if not is_parent then
table.insert(snapshots, {
id = name,
subvol_id = subvol_id, -- used only for sorting
path = path,
})
end
end
-- Sort snapshots by time descending
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
return {
entry = function(_, job)
local action = job.args[1]
local cwd = get_cwd()
if action ~= "exit" and action ~= "prev" and action ~= "next" then
return notify_error("Invalid action: " .. action)
end
local fs_type = get_filesystem_type(cwd)
if fs_type ~= "zfs" and fs_type ~= "btrfs" then
return notify_error("Current directory is not on a BTRFS / ZFS filesystem.")
end
local current_snapshot_id = ""
local latest_path = ""
local snapshots = {}
if fs_type == "zfs" then
local dataset = zfs_dataset(cwd)
if dataset == nil then
return notify_error("Current directory is not within a ZFS dataset.")
end
if cwd:find(".zfs/snapshot") ~= nil then
-- in the format dataset@snapshot
local sep = dataset:find("@")
current_snapshot_id = dataset:sub(sep + 1)
dataset = dataset:sub(1, sep - 1)
end
local mountpoint = zfs_mountpoint(dataset)
if mountpoint == nil then
return notify_error("Current directory is not within a ZFS dataset.")
end
-- NOTE: relative already has leading "/"
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 mountpoint == nil or uuid == nil then
return notify_error("Current directory is not within a BTRFS subvolume.")
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_warn("No snapshots found.")
end
---@param start_idx integer
---@param end_idx integer
---@param step integer
local find_and_goto_snapshot = function(start_idx, end_idx, step)
if start_idx == 0 then
-- going from newest snapshot to current state
return ya.manager_emit("cd", { latest_path })
elseif start_idx < 0 then
return notify_warn("No earlier snapshots found.")
elseif start_idx > #snapshots then
return notify_warn("No earlier snapshots found.")
end
for i = start_idx, end_idx, step do
local snapshot_dir = snapshots[i].path
if io.open(snapshot_dir, "r") then
return ya.manager_emit("cd", { snapshot_dir })
end
end
local direction = action == "prev" and "earlier" or "later"
return notify_warn("No " .. direction .. " snapshots found.")
end
-- NOTE: latest snapshot is first in list
if current_snapshot_id == "" then
if action == "prev" then
-- go to latest snapshot
return find_and_goto_snapshot(1, #snapshots, 1)
elseif action == "next" then
return notify_warn("No later snapshots found.")
end
end
-- has current snapshot
local idx = find_index(snapshots, function(snapshot) return snapshot.id == current_snapshot_id end)
if idx == nil then
return notify_error("Snapshot not found.")
end
if action == "prev" then
find_and_goto_snapshot(idx + 1, #snapshots, 1)
elseif action == "next" then
find_and_goto_snapshot(idx - 1, 1, -1)
end
end,
}