deleted clipcascade, updated yazi

This commit is contained in:
liph committed 2026-08-29 22:55:55 +02:00
1 parent 4ff0615bac
commit 4327b8ce0e
54 files changed
+1726 -6768

No files matched your search

+1 -25
View File
@@ -1,29 +1,5 @@
--- eza-preview ---
require("eza-preview"):setup({
-- Set the tree preview to be default (default: true)
default_tree = true,
-- Directory depth level for tree preview (default: 3)
level = 3,
-- Show file icons
icons = true,
-- Follow symlinks when previewing directories (default: true)
follow_symlinks = true,
-- Show target file info instead of symlink info (default: false)
dereference = false,
-- Show hidden files (default: true)
all = true,
-- Ignore files matching patterns (default: {})
-- ignore_glob = "*.log"
-- ignore_glob = { "*.tmp", "node_modules", ".git", ".DS_Store" }
-- SEE: https://www.linuxjournal.com/content/pattern-matching-bash to learn about glob patterns
ignore_glob = {},
-- Ignore files mentioned in '.gitignore' (default: true)
git_ignore = true,
-- Show git status (default: false)
git_status = false,
})
-- -- Or use default settings
-- require("eza-preview"):setup({})
require("eza-preview"):setup({})
--- omp ---
require("omp"):setup({ config = "/home/liph/.config/yazi/omp/omp.toml" })
+57
View File
@@ -7,6 +7,62 @@
# ~/.config/yazi/keymap.toml
prepend_keymap = [
###############################################################################
### toggle-pane ###
###############################################################################
{ on = [
"m",
"1",
], run = "plugin toggle-pane min-parent", desc = "Show or hide the parent pane" },
{ on = [
"m",
"2",
], run = "plugin toggle-pane min-current", desc = "Show or hide the current pane" },
{ on = [
"m",
"3",
], run = "plugin toggle-pane min-preview", desc = "Show or hide the preview pane" },
{ on = [
"M",
"1",
], run = "plugin toggle-pane max-parent", desc = "Maximize or restore the parent pane" },
{ on = [
"M",
"2",
], run = "plugin toggle-pane max-current", desc = "Maximize or restore the current pane" },
{ on = [
"M",
"3",
], run = "plugin toggle-pane max-preview", desc = "Maximize or restore the preview pane" },
###############################################################################
### fast-piper ###
###############################################################################
{ on = [
"<A-Up>",
], run = "seek -1", desc = "Scroll up" },
{ on = [
"<A-Down>",
], run = "seek +1", desc = "Scroll down" },
{ on = [
"<S-Up>",
], run = "seek -10", desc = "Scroll up" },
{ on = [
"<S-Down>",
], run = "seek +10", desc = "Scroll down" },
###############################################################################
### smart-filter ###
###############################################################################
{ on = [
"D",
], run = "plugin smart-filter", desc = "Smart filter" },
###############################################################################
### wl-clipboard ###
###############################################################################
{ on = [
"<C-y>",
], run = [
"plugin wl-clipboard",
] },
###############################################################################
### omni-trash ###
###############################################################################
@@ -72,6 +128,7 @@ prepend_keymap = [
###############################################################################
{ on = [
"M",
"M",
], run = "plugin lsblk-mount", desc = "Open the drive mount manager" },
###############################################################################
### chmod ###
+20 -5
View File
@@ -73,15 +73,30 @@ use = "boydaihungst/file-extra-metadata"
rev = "8bb639d"
hash = "8c5f14c09c981a496afaab5184e4aa08"
[[plugin.deps]]
use = "Sonico98/exifaudio"
rev = "4506f9d"
hash = "4761fffb60a12d37096c34a356380cdf"
[[plugin.deps]]
use = "goon/omni-trash"
rev = "3c2a992"
hash = "6e5bf967707c8c9b6495cf5863ff497f"
[[plugin.deps]]
use = "grappas/wl-clipboard"
rev = "8cc5524"
hash = "5c587834c274dca9059092714ece1f18"
[[plugin.deps]]
use = "yazi-rs/plugins:smart-filter"
rev = "c591a36"
hash = "d6f7351ea25a43c9b5f4315fa1e23935"
[[plugin.deps]]
use = "alberti42/faster-piper"
rev = "bb90261"
hash = "12b5e342d54db42b5b023faa1a11f28e"
[[plugin.deps]]
use = "yazi-rs/plugins:toggle-pane"
rev = "c591a36"
hash = "5b2fbef7d1513ce38ff72623a071a5e"
[flavor]
deps = []
@@ -1,38 +0,0 @@
# exifaudio.yazi
Preview audio metadata and cover on [Yazi](https://github.com/sxyazi/yazi).
![image](https://github.com/Sonico98/exifaudio.yazi/assets/61394886/53c1492c-9f05-4c80-a4e7-94fb36f35ca9)
## Installation
```sh
# Automatically with yazi 0.3.0
ya pack -a "Sonico98/exifaudio"
# Or manually under:
# Linux/macOS
git clone https://github.com/Sonico98/exifaudio.yazi.git ~/.config/yazi/plugins/exifaudio.yazi
# Windows
git clone https://github.com/Sonico98/exifaudio.yazi.git %AppData%\yazi\config\plugins\exifaudio.yazi
```
## Usage
Add the following to your `yazi.toml`:
```toml
[plugin]
prepend_previewers = [
{ mime = "audio/*", run = "exifaudio"}
]
```
Make sure you have [exiftool](https://exiftool.org/) installed and in your `PATH`.
Optional: if you have [mediainfo](https://mediaarea.net/en/MediaInfo) installed and in your `PATH`, it will be used instead for more accurate metadata. Exiftool is still required to display the cover.
## Thanks
Thanks to [sxyazi](https://github.com/sxyazi) for the PDF previewer code, on which this previewer is based on.
@@ -1,215 +0,0 @@
local M = {}
function GetPath(str)
local sep = '/'
if ya.target_family() == "windows" then
sep = '\\'
end
return str:match("(.*"..sep..")")
end
function Exiftool(...)
local child = Command("exiftool")
:arg{
"-q", "-q", "-S", "-Title", "-SortName",
"-TitleSort", "-TitleSortOrder", "-Artist",
"-SortArtist", "-ArtistSort", "-PerformerSortOrder",
"-Album", "-SortAlbum", "-AlbumSort", "-AlbumSortOrder",
"-AlbumArtist", "-SortAlbumArtist", "-AlbumArtistSort",
"-AlbumArtistSortOrder", "-Genre", "-TrackNumber",
"-Year", "-Duration", "-SampleRate",
"-AudioSampleRate", "-AudioBitrate", "-AvgBitrate",
"-Channels", "-AudioChannels", tostring(...),
}
:stdout(Command.PIPED)
:stderr(Command.NULL)
:spawn()
return child
end
function Mediainfo(...)
local file, cache_dir = ...
local template = cache_dir.."mediainfo.txt"
local child = Command("mediainfo")
:arg{
"--Output=file://"..template, tostring(file)
}
:stdout(Command.PIPED)
:stderr(Command.NULL)
:spawn()
return child
end
function M:peek(job)
local cache = ya.file_cache(job)
if not cache then
return
end
-- Get cache dir to find the mediainfo template file
local cache_dir = GetPath(tostring(cache))
-- Try mediainfo, otherwise use exiftool
local status, child = pcall(Mediainfo, job.file.url, cache_dir)
if not status or child == nil then
status, child = pcall(Exiftool, job.file.url)
if not status or child == nil then
local error = ui.Line { ui.Span("Make sure exiftool is installed and in your PATH") }
local p = ui.Text(error):area(job.area):wrap(ui.Wrap.YES)
ya.preview_widget(job, { p })
return
end
end
local limit = job.area.h
local i, metadata = 0, {}
repeat
local next, event = child:read_line()
if event == 1 then
return self:fallback_to_builtin()
elseif event ~= 0 then
break
end
i = i + 1
if i > job.skip then
local m_title, m_tag = Prettify(next)
if m_title ~= "" and m_tag ~= "" then
local ti = ui.Span(m_title):bold()
local ta = ui.Span(m_tag)
table.insert(metadata, ui.Line{ti, ta})
table.insert(metadata, ui.Line{})
end
end
until i >= job.skip + limit
local p = ui.Text(metadata):area(job.area):wrap(ui.Wrap.YES)
ya.preview_widget(job, { p })
local cover_width = job.area.w / 2 - 5
local cover_height = (job.area.h / 4) + 3
local bottom_right = ui.Rect {
x = job.area.right - cover_width,
y = job.area.bottom - cover_height,
w = cover_width,
h = cover_height,
}
if self:preload(job) == true then
ya.image_show(cache, bottom_right)
end
end
function Prettify(metadata)
local substitutions = {
Sortname = "Sort Title:",
SortName = "Sort Title:",
TitleSort = "Sort Title:",
TitleSortOrder = "Sort Title:",
ArtistSort = "Sort Artist:",
SortArtist = "Sort Artist:",
Artist = "Artist:",
ARTIST = "Artist:",
PerformerSortOrder = "Sort Artist:",
SortAlbumArtist = "Sort Album Artist:",
AlbumArtistSortOrder = "Sort Album Artist:",
AlbumArtistSort = "Sort Album Artist:",
AlbumSortOrder = "Sort Album:",
AlbumSort = "Sort Album:",
SortAlbum = "Sort Album:",
Album = "Album:",
ALBUM = "Album:",
AlbumArtist = "Album Artist:",
Genre = "Genre:",
GENRE = "Genre:",
TrackNumber = "Track Number:",
Year = "Year:",
Duration = "Duration:",
AudioBitrate = "Bitrate:",
AvgBitrate = "Average Bitrate:",
AudioSampleRate = "Sample Rate:",
SampleRate = "Sample Rate:",
AudioChannels = "Channels:"
}
for k, v in pairs(substitutions) do
metadata = metadata:gsub(tostring(k)..":", v, 1)
end
-- Separate the tag title from the tag data
local t={}
for str in string.gmatch(metadata , "([^"..":".."]+)") do
if str ~= "\n" then
table.insert(t, str)
else
table.insert(t, nil)
end
end
-- Add back semicolon to title, rejoin tag data if it happened to contain a semicolon
local title, tag_data = "", ""
if t[1] ~= nil then
title, tag_data = t[1]..":", table.concat(t, ":", 2)
end
return title, tag_data
end
function M:seek(job)
local h = cx.active.current.hovered
if h and h.url == job.file.url then
ya.manager_emit("peek", {
tostring(math.max(0, cx.active.preview.skip + job.units)),
only_if = tostring(job.file.url),
})
end
end
function M:preload(job)
local cache = ya.file_cache(job)
if not cache or fs.cha(cache) then
return true
end
local mediainfo_template = 'General;"\
$if(%Track%,Title: %Track%,)\
$if(%Track/Sort%,Sort Title: %Track/Sort%,)\
$if(%Title/Sort%,Sort Title: %Title/Sort%,)\
$if(%TITLESORT%,Sort Title: %TITLESORT%,)\
$if(%Performer%,Artist: %Performer%,)\
$if(%Performer/Sort%,Sort Artist: %Performer/Sort%,)\
$if(%ARTISTSORT%,Sort Artist: %ARTISTSORT%,)\
$if(%Album%,Album: %Album%,)\
$if(%Album/Sort%,Sort Album: %Album/Sort%)\
$if(%ALBUMSORT%,Sort Album: %ALBUMSORT%)\
$if(%Album/Performer%,Album Artist: %Album/Performer%)\
$if(%Album/Performer/Sort%,Sort Album Artist: %Album/Performer/Sort%)\
$if(%Genre%,Genre: %Genre%)\
$if(%Track/Position%,Track Number: %Track/Position%)\
$if(%Recorded_Date%,Year: %Recorded_Date%)\
$if(%Duration/String%,Duration: %Duration/String%)\
$if(%BitRate/String%,Bitrate: %BitRate/String%)\
"\
Audio;"Sample Rate: %SamplingRate%\
Channels: %Channel(s)%"\
'
-- Write the mediainfo template file into yazi's cache dir
local cache_dir = GetPath(tostring(cache))
fs.write(Url(cache_dir.."mediainfo.txt"), mediainfo_template)
local output = Command("exiftool")
:arg{ "-b", "-CoverArt", "-Picture", tostring(job.file.url) }
:stdout(Command.PIPED)
:stderr(Command.PIPED)
:output()
if not output then
return true, Err("Couldn't extract cover art, error: %s", err)
end
return fs.write(cache, output.stdout) and true or false
end
return M
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Andrea Alberti
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.
@@ -0,0 +1,279 @@
# faster-piper.yazi
A fast, cache-aware reimplementation of `piper.yazi` for Yazi.
`faster-piper` is a general-purpose previewer that pipes the output of an
arbitrary shell command into Yazi’s preview pane, with aggressive caching
and efficient scrolling for large outputs.
## Installation
**Requires Yazi 26.8.15 or newer.** Earlier versions lack the `th.icon:match` and
`Url.spec.is_search` APIs this plugin uses, and Yazi will refuse to load it with a
prompt to upgrade. If you cannot upgrade Yazi, use
[`piper.yazi`](https://github.com/yazi-rs/plugins/tree/main/piper.yazi) instead.
```sh
ya pkg add alberti42/faster-piper
```
### Windows
**Windows is untested.** The plugin is developed on macOS and Linux, and
nobody has yet confirmed a working preview on Windows. Several
Windows-specific defects have been fixed by inspection rather than by
running the plugin there, so treat the platform as unsupported until a user
reports otherwise.
What is expected to be required:
Like `piper.yazi`, this plugin runs your command through a POSIX shell, so a
`sh` must be reachable on `PATH`, for instance from
[MSYS2](https://www.msys2.org), Cygwin, or Git for Windows. Note that a default
Git for Windows installation puts only `C:\Program Files\Git\cmd` on `PATH`,
which contains no `sh.exe`; enable *Use Git and optional Unix tools from the
Command Prompt* at install time, or add `C:\Program Files\Git\usr\bin`
yourself. Preview recipes are POSIX shell, not `cmd`, on every platform.
Please report your experience, working or not, in
[issue #2](https://github.com/alberti42/faster-piper.yazi/issues/2), which
collects everything Windows-related. A confirmation that previews render
correctly is very valuable: once it is confirm correct Windows behavior, a new
release will be made announcing the Windows support.
## Motivation
The original [`piper.yazi`](https://github.com/yazi-rs/plugins/tree/main/piper.yazi)
is a simple and elegant previewer that executes a shell command on each preview.
`faster-piper` started as an experiment to explore whether:
- preview output could be cached safely,
- scrolling could be made O(1) using file-backed paging,
- large outputs could be handled without re-running the generator,
- resizing and jump-to-end behavior could be made deterministic.
The result is a substantially different internal architecture that favors
performance and predictability for expensive preview commands.
## Compatibility with `piper.yazi`
`faster-piper` is **syntax-compatible** with `piper.yazi`.
Existing configurations written for `piper.yazi` continue to work without
modification. The command format, variables, and preview semantics are the same:
- `$1` — path to the file being previewed, always written as `"$1"` with the
double quotes; the quoted form is what gets replaced by the escaped path
- `$w` — preview width
- `$h` — preview height
- `$t` — detected terminal theme, either `dark` or `light`
`$t` comes from Yazi's own terminal detection, so previews match the rest of the
interface. If your terminal does not report a colour scheme, `$t` is `dark`.
You can replace `piper` with `faster-piper` in your Yazi configuration and keep
using the same preview commands.
For more usage examples and ideas, please refer to the original
[`piper.yazi` README](https://github.com/yazi-rs/plugins/tree/main/piper).
## Caching
A preview is generated once and reused. The cache is discarded when the file
changes, when you edit the command in `yazi.toml`, and when one of `$w`, `$h` or
`$t` changes — but each of those only if your command actually reads it.
So resizing the preview pane regenerates a `glow -w=$w` preview, and leaves a
`tar -tf "$1"` preview alone, because the second produces the same output at any
size.
> **Note**
> faster-piper can only see the theme through `$t`. If your command detects the
> theme by itself — for example `bat --theme=auto:always` — faster-piper cannot
> know that its output depends on the theme, and the cache will not refresh when
> you switch. Use `$t` instead.
## Usage
### Configure previewers
Use `faster-piper` exactly like `piper`: pass a shell command after `--`.
The command’s stdout becomes the preview content.
#### Example: Preview Markdown with `glow`
```toml
[[plugin.prepend_previewers]]
url = "*.md"
run = 'faster-piper -- CLICOLOR_FORCE=1 glow -w=$w -s=dracula -- "$1"'
```
#### Example: Match the terminal theme with `bat`
`bat` accepts `dark` and `light` as `--theme` values, which is exactly what `$t`
holds, so the two map onto each other directly:
```toml
[[plugin.prepend_previewers]]
url = "*.rs"
run = 'faster-piper -- bat -p --color=always --theme-dark=Dracula --theme-light=GitHub --theme="$t" "$1"'
```
Do not rely on `bat`'s own `--theme=auto` here. Preview output is redirected to
the cache file rather than to the terminal, so plain `auto` cannot detect
anything and falls back to a dark theme. `--theme=auto:always` does detect, but
it queries the terminal that Yazi is already reading from, and its result is
invisible to the cache. `$t` avoids both problems.
#### Example: Preview tarballs with `tar`
```toml
[[plugin.prepend_previewers]]
url = "*.tar*"
run = 'faster-piper --format=url -- tar tf "$1"'
```
### Preloading for instant previews
`faster-piper` works **without** preloading: the first `peek` will generate the
cache on demand.
If you want previews to appear _instantaneously_ when you move the cursor (i.e.
the cache is already warm by the time `peek` runs), configure a matching rule
under `plugin.prepend_preloaders`.
There are two supported ways to do this:
#### Option A (recommended): define the command only in the preloader with `--rely-on-preloader`
If you don’t want to duplicate the piping command in both places, you can make
the previewer rule “rely on the preloader”:
- The **preloader** is responsible for generating the cache (and therefore must
include the command after `--`).
- The **previewer** becomes a lightweight reader: it only displays the cache and
(in edge cases such as terminal resize) may self-heal using the cached recipe.
Example:
```toml
[plugin]
prepend_previewers = [
{ url = "*.md", run = 'faster-piper --rely-on-preloader' },
{ url = "*.tar*", run = 'faster-piper --rely-on-preloader --format=url' },
{ url = "*.txt.gz", run = 'faster-piper --rely-on-preloader' },
]
prepend_preloaders = [
{ url = "*.md", run = 'faster-piper -- CLICOLOR_FORCE=1 glow -w=$w -s=dracula -- "$1"' },
{ url = "*.tar*", run = 'faster-piper -- tar tf "$1"' },
{ url = "*.txt.gz", run = 'faster-piper -- gzip -dc "$1"' },
]
```
This mode is mainly about **avoiding duplication**: you keep the generator
command in one place (the preloader rule), while the previewer rule stays a
lightweight cache reader.
Note: `faster-piper` is race-safe in both modes — if `preload` and `peek` happen
at the same time, a lock ensures only one process writes the cache, and the
other will reuse it once it becomes available.
##### Notes on `--rely-on-preloader`
- `--rely-on-preloader` is intended for setups where you **do configure**
`prepend_preloaders`. In that mode, `peek` will first assume the cache was
warmed by the preloader, and only falls back to self-healing in edge cases
(e.g. resize-triggered peeks).
- If you **don’t** use preloaders at all, omit the flag and run in normal mode:
`peek` will generate the cache on demand.
- When you _do_ use preloaders, `--rely-on-preloader` is the simplest way to
avoid keeping two command strings in sync.
- If you choose Option B (duplicating commands), keep them **identical**;
mixing different commands for the same matcher is undefined because of races.
#### Option B (classic): duplicate the same command in previewers + preloaders
This mirrors `piper.yazi` usage and is always valid.
```toml
[plugin]
prepend_previewers = [
{ url = "*.md", run = 'faster-piper -- CLICOLOR_FORCE=1 glow -w=$w -s=dracula -- "$1"' },
{ url = "*.tar*", run = 'faster-piper --format=url -- tar tf "$1"' },
{ url = "*.txt.gz", run = 'faster-piper -- gzip -dc "$1"' },
]
prepend_preloaders = [
{ url = "*.md", run = 'faster-piper -- CLICOLOR_FORCE=1 glow -w=$w -s=dracula -- "$1"' },
{ url = "*.tar*", run = 'faster-piper -- tar tf "$1"' },
{ url = "*.txt.gz", run = 'faster-piper -- gzip -dc "$1"' },
]
```
**Important:** the command templates must be identical. Yazi may race `preload`
and `peek`, and whichever runs first might win. If the commands differ, the cache
content becomes ambiguous.
##### Notes on race conditions (preloader vs. peek)
`faster-piper` handles the race between preloading and previewing gracefully:
- If both `preload` and `peek` arrive around the same time, **the generator is
not run twice** (the lock ensures only one writer).
- Which one “wins” (preloader or peek) can fluctuate depending on timing, but
the outcome is the same: you get a valid cache and a correct preview.
### Fast scrolling and “jump to top/bottom”
`faster-piper` supports normal incremental scrolling via `seek +/-N` (in lines).
In addition, it implements a _jump heuristic_ for very large seek steps:
- If a single `seek` step is **less than -999**, it jumps to the **top**.
- If a single `seek` step is **greater than +999**, it jumps to the **bottom**.
This is useful for binding keys like Home/End to instant top/bottom navigation,
without having to know the total line count ahead of time.
### Recommended keymaps
Add these to your `prepend_keymap` to enable smooth scrolling in the preview:
```toml
[plugin]
prepend_keymap = [
{ on = "<A-Up>", run = "seek -1", desc = "Scroll up" },
{ on = "<A-Down>", run = "seek +1", desc = "Scroll down" },
{ on = "<A-PageUp>", run = "seek -15", desc = "Scroll page up" },
{ on = "<A-PageDown>", run = "seek +15", desc = "Scroll page down" },
{ on = "<A-Home>", run = "seek -10000", desc = "Scroll to the top" },
{ on = "<A-End>", run = "seek +10000", desc = "Scroll to the bottom" },
]
```
Tip: if you prefer different thresholds, just keep the “jump” bindings beyond
±999 (e.g. ±5000, ±10000).
## Relationship to `piper.yazi`
This project is a **from-scratch rewrite** inspired by the original idea of
`piper.yazi`.
- The core concept (using shell commands as previewers) comes from
`piper.yazi`.
- The configuration syntax and user-facing behavior are intentionally kept
compatible.
- The internal implementation, caching model, and scrolling logic are new and
optimized for performance.
All credit for the original idea and initial implementation goes to the
`piper.yazi` authors.
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
It is inspired by `piper.yazi`, which is also MIT-licensed.
See the LICENSE file for details.
@@ -0,0 +1,880 @@
--- @since 26.8.15
local M = {}
-- If Yazi asks for a skip larger than this, jump straight to the last page.
local SKIP_JUMP_THRESHOLD = 999
local PEEK_JUMP_THRESHOLD = 99999999
-- Maximum time allowed for preview (in ms)
local TIME_OUT_LOCK = 5000
local TIME_OUT_PREVIEW = 200
-- Maximum time for stale-lock directories
local LOCK_TTL_SEC = 60
----------------------------------------------------------------------
-- Cache header layout (1-based line numbers)
--
-- We keep ALL header-related offsets centralized here.
-- When adding a new header field:
-- 1) insert it into this layout
-- 2) bump HEADER.N
-- 3) bump HEADER.VERSION so existing caches are discarded
-- 4) update any reader functions that fetch header fields
----------------------------------------------------------------------
local HEADER = {
-- Layout marker, always stored on the FIRST line.
--
-- Bump this on ANY change to the layout below. A cache written by an
-- older version then fails validation, is removed, and is regenerated.
-- This is what makes header changes safe; there is no migration path.
--
-- It is NOT the plugin version, and must not follow it. A release that
-- leaves this layout untouched keeps the same marker, so caches stay
-- valid across upgrades. Bumping it needlessly discards every user's
-- cached preview for nothing.
VERSION = "FPCACHE1",
-- Total number of header lines stored at the top of the cache file.
N = 6,
-- Which header line contains what (1-based):
LINE_VER = 1, -- layout marker, must equal HEADER.VERSION
LINE_CMD = 2, -- raw user-provided command template (job.args[1], unchanged)
LINE_NLINE = 3, -- number of *content* lines (excludes headers)
LINE_W = 4, -- preview width used to generate this cache
LINE_H = 5, -- preview height used to generate this cache
LINE_T = 6, -- terminal theme used to generate this cache ("dark"|"light")
}
-- Content starts immediately after the header.
local function content_first_line()
return HEADER.N + 1
end
----------------------------------------------------------------------
-- Utils
----------------------------------------------------------------------
--- Parse a "truthy/falsey" value into a boolean, with an explicit default.
---
--- This is meant for plugin args / config values that might arrive as:
--- - nil -> returns `default`
--- - boolean -> returns that boolean
--- - number -> 0 = false, non-zero = true
--- - string -> common on/off values (case-insensitive):
--- true: "true", "1", "yes", "on"
--- false: "false", "0", "no", "off"
--- anything else -> returns `default`
--- - other types -> returns `default`
---
--- param v any -> Value to parse.
--- param default boolean -> The fallback value used when `v` is nil/unknown.
--- return boolean
local function is_true(v, default)
assert(type(default) == "boolean", "is_true: default must be a boolean")
if v == nil then
return default
end
if v == true then
return true
end
if v == false then
return false
end
if type(v) == "number" then
return v ~= 0
end
if type(v) == "string" then
v = v:lower()
if v == "true" or v == "1" or v == "yes" or v == "on" then
return true
end
if v == "false" or v == "0" or v == "no" or v == "off" then
return false
end
-- Unknown string: fall back to default (or you can choose to return true)
return default
end
-- Unknown type: fall back to default
return default
end
----------------------------------------------------------------------
-- fs_path(url) -> string
--
-- Convert Yazi Url into a real filesystem path string for external tools.
--
-- Why:
-- In some views (notably search results), Yazi uses virtual URLs such as:
-- search://dupli:1:1//Users/andrea/file.txt
-- External commands (bat, glow, tar, etc.) expect a plain filesystem path.
--
-- How:
-- Yazi already exposes the underlying path via `url.path`, and also tells
-- us whether this Url comes from search using `url.spec.is_search`.
--
-- Behavior:
-- - For search URLs: return tostring(url.path)
-- - For regular file URLs: return tostring(url)
-- - Defensive fallback: if url.path is missing/empty, fall back to tostring(url)
----------------------------------------------------------------------
local function fs_path(url)
if url and url.spec.is_search then
local p = url.path
if p then
local s = tostring(p)
if s ~= "" then
return s
end
end
end
return tostring(url)
end
-- Split text into "lines" (like read_line()).
-- Drops empty / whitespace-only lines to avoid blank entries.
local function split_lines(s)
local t = {}
if not s or s == "" then
return t
end
-- Ensure the last line is captured even if s doesn't end with '\n'
s = s .. "\n"
for line in s:gmatch("(.-)\n") do
-- Remove empty and whitespace-only lines:
-- - empty: line == ""
-- - whitespace-only: line:match("^%s*$")
if not line:match("^%s*$") then
t[#t + 1] = line .. "\n"
end
end
return t
end
----------------------------------------------------------------------
-- read_line_range(path, first, last) -> lines, err
--
-- Return lines [first, last] (1-based, inclusive) of a text file as a
-- table of strings, each WITHOUT its trailing newline. A trailing \r is
-- dropped too, so a cache written through a Windows shell parses exactly
-- like one written under Unix.
--
-- This replaces `sed -n '<first>,<last>p'`. Yazi also runs on Windows,
-- where `sed` is absent from the default PATH even when a POSIX shell is
-- installed (Git for Windows keeps it in usr\bin, off PATH). For the few
-- lines we need, spawning a process per peek costs more than reading the
-- file here anyway.
----------------------------------------------------------------------
local function read_line_range(path, first, last)
local fh, err = io.open(path, "r")
if not fh then
return nil, tostring(err)
end
local out, i = {}, 0
for line in fh:lines() do
i = i + 1
if i >= first then
out[#out + 1] = (line:gsub("\r$", ""))
end
if i >= last then
break
end
end
fh:close()
return out, nil
end
function M.format(job, lines)
local format = job.args.format
if format ~= "url" then
local s = table.concat(lines, ""):gsub("\r", ""):gsub("\t", string.rep(" ", rt.preview.tab_size))
return ui.Text.parse(s):area(job.area)
end
for i = 1, #lines do
lines[i] = lines[i]:gsub("[\r\n]+$", "")
local icon = th.icon:match(File {
url = Url(lines[i]),
cha = Cha { mode = tonumber(lines[i]:sub(-1) == "/" and "40700" or "100644", 8) },
})
if icon then
lines[i] = ui.Line { ui.Span(" " .. icon.text .. " "):style(icon.style), lines[i] }
end
end
return ui.Text(lines):area(job.area)
end
local read_cache_header -- forward declaration
----------------------------------------------------------------------
-- Terminal theme, as exposed to preview commands via $t.
--
-- rt.term.light() returns nil when the terminal does not report a
-- colour scheme. We treat that as "dark", which matches Yazi's own
-- default appearance. The fallback is deliberate and documented in the
-- README, because a user on an unreporting light terminal has no other
-- way to explain the result.
----------------------------------------------------------------------
local function current_theme()
return rt.term.light() and "light" or "dark"
end
----------------------------------------------------------------------
-- Does `tpl` read the environment variable `name`?
--
-- Matches "$name" and "${name}". The frontier pattern stops "$t" from
-- matching inside "$theme".
--
-- Over-matching is harmless: "$t" inside single quotes, or escaped as
-- "\$t", costs one extra cache rebuild. Under-matching would leave a
-- stale cache, so the check errs toward rebuilding.
----------------------------------------------------------------------
local function uses_env(tpl, name)
if tpl:find("${" .. name .. "}", 1, true) then
return true
end
return tpl:find("%$" .. name .. "%f[^%w_]") ~= nil
end
-- Header-based freshness check:
-- - cache mtime >= source mtime
-- - header parses and carries the current layout version
-- - header recipe matches the requested one, IF Yazi passed one
-- - header w/h/theme match the current ones, EACH only if the recipe
-- reads the matching variable ($w, $h, $t)
-- Returns:
-- ok, hdr
-- where hdr is the parsed header if available.
local function cache_is_fresh(job, cache_path)
local c = fs.cha(cache_path)
local s = job.file.cha
if not (c and c.mtime and s and s.mtime and c.mtime >= s.mtime) then
return false, nil
end
local hdr = read_cache_header(cache_path)
if not hdr then
return false, nil
end
-- The recipe is part of the cache identity: output produced by a
-- different command is not a cache hit.
--
-- Yazi omits the previewer arguments on some calls, e.g. seek. We
-- cannot compare then, and generate_cache recovers the stored recipe.
local tpl = job.args and job.args[1]
if tpl and tpl ~= "" and tpl ~= hdr.cmd then
return false, nil
end
-- Below, only the variables the recipe actually reads can affect its
-- output, so only those invalidate the cache. A recipe that ignores
-- $w produces identical bytes at any width; discarding its cache on
-- every resize would be pure waste.
--
-- This assumes the command learns the geometry and the theme from us,
-- not by inspecting the terminal itself. That holds here, because
-- stdout is redirected to the cache file and stdin is closed, so a
-- command sees a pipe rather than a terminal.
--
-- Caveat: a command that detects the theme through its own channel
-- (e.g. `bat --theme=auto:always`) defeats the $t test, and its cache
-- will not refresh. See the README.
if uses_env(hdr.cmd, "w") and hdr.w ~= job.area.w then
return false, nil
end
if uses_env(hdr.cmd, "h") and hdr.h ~= job.area.h then
return false, nil
end
if uses_env(hdr.cmd, "t") and hdr.t ~= current_theme() then
return false, nil
end
return true, hdr
end
-- Derive cache path from file_cache base + current w/h
local function get_cache_path(job)
local base = ya.file_cache({ file = job.file, skip = 0 })
if not base then
return nil, "caching-disabled-by-yazi"
end
return Url(tostring(base)), nil
end
-- Identify this Yazi instance. Used to keep the lock and the scratch
-- files of two instances previewing the same file apart.
local function instance_id()
local app_id = ya.id and ya.id("app") or nil
if app_id and app_id.value then
return tostring(app_id.value)
end
return "noid"
end
local function lock_path_for(cache_path)
-- faster-piper lock system
return Url(string.format("%s_FP_%s.lock", tostring(cache_path), instance_id()))
end
----------------------------------------------------------------------
-- Scratch files used while generating one cache entry.
--
-- They are named per instance and per cache entry, so two generators
-- racing for the same preview (different instances, or a lock that was
-- broken as stale) never write the same scratch path. The finished file
-- appears at cache_path through a single rename, so a reader sees either
-- the previous cache or the new one, never a half-written mixture.
----------------------------------------------------------------------
local function scratch_paths_for(cache_path)
local base = string.format("%s_FP_%s", tostring(cache_path), instance_id())
return Url(base .. ".out"), Url(base .. ".hdr")
end
local function lock_is_held(cache_path)
local lock = lock_path_for(cache_path)
return fs.cha(lock) ~= nil
end
-- Wait until cache is safe to read: unlocked + fresh.
-- Returns: ok, hdr
local function wait_for_ready_cache(job, cache_path, timeout_ms)
local deadline = ya.time() + (timeout_ms / 1000)
while ya.time() < deadline do
-- If writer is active, don't even try to read.
if lock_is_held(cache_path) then
ya.sleep(0.02) -- 20ms when locked
else
if not fs.cha(cache_path) then
ya.sleep(0.01)
else
local ok, hdr = cache_is_fresh(job, cache_path)
if ok then return true, hdr end
ya.sleep(0.01)
end
end
end
return false, nil
end
local function lock_age_seconds(lock_path)
local c = fs.cha(lock_path)
if not (c and c.mtime) then
return math.huge
end
return ya.time() - c.mtime
end
local function break_lock_dir(lock_path)
-- best-effort; ignore failures
fs.remove("dir_all", lock_path)
end
-- Try to acquire lock by creating a directory (atomic on POSIX filesystems).
-- Returns true if acquired, false if timed out.
local function acquire_lock(lock_path, timeout_ms)
timeout_ms = timeout_ms or 500
local deadline = ya.time() + (timeout_ms / 1000)
while ya.time() < deadline do
local ok = fs.create("dir", lock_path)
if ok then
return true
end
-- If it exists and is stale, break it and retry immediately
if lock_age_seconds(lock_path) > LOCK_TTL_SEC then
-- ya.dbg({ stale_lock = tostring(lock_path), age = lock_age_seconds(lock_path) })
break_lock_dir(lock_path)
else
ya.sleep(0.01)
end
end
return false
end
----------------------------------------------------------------------
-- Release a held lock, synchronously.
--
-- This deliberately avoids fs.remove(): that is an async Yazi API, and
-- the release must also work from a to-be-closed handler, where the
-- coroutine can no longer yield ("attempt to yield across a C-call
-- boundary"). os.remove() is plain C and removes an empty directory on
-- POSIX. On Windows it refuses directories, so an abandoned lock there
-- still has to wait for LOCK_TTL_SEC; nothing else breaks.
----------------------------------------------------------------------
local function release_lock(lock_path)
os.remove(tostring(lock_path))
end
----------------------------------------------------------------------
-- Read and validate a full header in ONE call.
-- Returns:
-- hdr = { cmd = <string>, nline = <number>, w = <number> } on success
-- nil, err on failure
--
-- Notes:
-- - cmd is returned without trailing newline.
-- - nline and w are parsed as integers.
----------------------------------------------------------------------
read_cache_header = function(cache_path)
-- Read the first HEADER.N lines at once
local lines, err = read_line_range(tostring(cache_path), 1, HEADER.N)
if not lines then
return nil, err
end
if #lines < HEADER.N then
return nil, "incomplete cache header (need " .. HEADER.N .. " lines, got " .. #lines .. ")"
end
-- Check the layout marker BEFORE anything else. A cache written by an
-- older version has a different layout, so every offset below is wrong
-- for it. Rejecting here makes the caller discard and regenerate it.
if lines[HEADER.LINE_VER] ~= HEADER.VERSION then
return nil, "cache header layout is outdated; expected " .. HEADER.VERSION
end
local cmd = lines[HEADER.LINE_CMD]
local nline = tonumber((lines[HEADER.LINE_NLINE] or ""):match("^%s*(%d+)%s*$"))
local w = tonumber((lines[HEADER.LINE_W] or ""):match("^%s*(%d+)%s*$"))
local h = tonumber((lines[HEADER.LINE_H] or ""):match("^%s*(%d+)%s*$"))
local t = (lines[HEADER.LINE_T] or ""):match("^%s*(%a+)%s*$")
if cmd == nil then
return nil, "missing cmd header line"
end
if not nline then
return nil, "invalid line-count header: " .. tostring(lines[HEADER.LINE_NLINE])
end
if not w then
return nil, "invalid width header: " .. tostring(lines[HEADER.LINE_W])
end
if not h then
return nil, "invalid height header: " .. tostring(lines[HEADER.LINE_H])
end
if t ~= "dark" and t ~= "light" then
return nil, "invalid theme header: " .. tostring(lines[HEADER.LINE_T])
end
return { cmd = cmd, nline = nline, w = w, h = h, t = t }, nil
end
----------------------------------------------------------------------
-- Cache generation
--
-- Behavior:
-- - If job.args[1] is present: use it as recipe.
-- - Else: if cache exists and has valid header: reuse cached recipe (LINE_CMD).
-- - Else: fail (no recipe available).
--
-- Always writes a HEADER.N-line header:
-- 1) layout marker (HEADER.VERSION)
-- 2) command template (exact string we use)
-- 3) number of content lines (wc -l, trimmed)
-- 4) width used (w, trimmed)
-- 5) height used (h, trimmed)
-- 6) terminal theme used (t)
----------------------------------------------------------------------
local function generate_cache(job, cache_path)
local source_path = fs_path(job.file.url)
-- 1) Decide template: job.args[1] or cached header
local tpl = job.args and job.args[1]
if tpl == "" then tpl = nil end
if not tpl then
local cha = fs.cha(cache_path)
if cha then
local hdr, herr = read_cache_header(cache_path)
if hdr and hdr.cmd and hdr.cmd ~= "" then
tpl = hdr.cmd
else
-- header invalid -> cannot recover recipe
ya.err("faster-piper: cache header invalid; cannot reuse recipe: " .. tostring(herr))
end
end
end
if not tpl or tpl == "" then
ya.err("faster-piper: missing generator command template (job.args[1]) and no usable cached header")
return false
end
-- Guard: template must be single-line for env passing + header layout
if tpl:find("\n", 1, true) then
ya.err("faster-piper: command template contains newline; unsupported")
return false
end
-- 2) Expand "$1" safely for external tools
--
-- ya.quote() escapes for the *host* shell by default, which on Windows is
-- cmd: it would turn `%` into `%%cd:~,` and quote with `"`. Everything here
-- is fed to `sh`, so we force POSIX escaping with the second argument.
--
-- The `%` in the replacement string is doubled because gsub reads `%1`,
-- `%0`, ... as capture references; a path containing `%` would otherwise
-- raise "invalid use of '%' in replacement string".
local quoted_source = ya.quote(source_path, true):gsub("%%", "%%%%")
local final = tpl:gsub('"$1"', quoted_source)
local out_url, hdr_url = scratch_paths_for(cache_path)
local out_path = ya.quote(tostring(out_url), true)
local hdr_path = ya.quote(tostring(hdr_url), true)
local quoted_path = ya.quote(tostring(cache_path), true)
-- 3) Generate content into a scratch file, prepend the header into a
-- second one, then rename that over cache_path.
--
-- cache_path itself is never written in place: a generator that is
-- killed halfway (Yazi cancels superseded peeks) would otherwise
-- leave a headerless stump where a perfectly good cache used to be.
--
-- - We trim whitespace from wc output to avoid leading spaces.
-- - We also trim $w and $h to be safe.
local cmd = string.format([[
(%s) > %s &&
L=$(wc -l < %s | tr -d '[:space:]') &&
W=$(printf '%%s' "$w" | tr -d '[:space:]') &&
H=$(printf '%%s' "$h" | tr -d '[:space:]') &&
{ printf '%%s\n' "$FP_VER"; printf '%%s\n' "$FP_TPL"; printf '%%s\n' "$L"; printf '%%s\n' "$W"; printf '%%s\n' "$H"; printf '%%s\n' "$t"; cat %s; } > %s &&
mv %s %s
]],
final,
out_path,
out_path,
out_path,
hdr_path,
hdr_path,
quoted_path
)
local function discard_scratch()
fs.remove("file", out_url)
fs.remove("file", hdr_url)
end
local child, err = Command("sh")
:arg({ "-c", cmd })
:env("w", tostring(job.area.w))
:env("h", tostring(job.area.h))
:env("t", current_theme())
:env("FP_TPL", tpl) -- EXACT template string we used
:env("FP_VER", HEADER.VERSION) -- header layout marker
-- Windows: a Cygwin-derived `sh` (Git Bash, MSYS2) upper-cases the
-- environment it inherits from a native process, which would turn our
-- $w/$h/$t into $W/$H/$T and leave the recipe reading empty values.
-- Harmless everywhere else. piper.yazi does the same.
:env("CYGWIN", "noupcaseenv")
:stdin(Command.NULL)
:stdout(Command.NULL)
:stderr(Command.PIPED)
:spawn()
if not child then
ya.err("faster-piper: failed to spawn: " .. tostring(err))
discard_scratch()
return false
end
local output, werr = child:wait_with_output()
if not output then
ya.err("faster-piper: wait failed: " .. tostring(werr))
discard_scratch()
return false
end
if not output.status.success then
ya.err("faster-piper: command failed (code=" .. tostring(output.status.code) .. "): " .. tostring(output.stderr))
discard_scratch()
return false
end
-- The content scratch file has served its purpose; the header one was
-- renamed away by the shell.
fs.remove("file", out_url)
-- 4) Sanity-check the newly written header
local hdr, herr = read_cache_header(cache_path)
if not hdr then
ya.err("faster-piper: wrote cache but header sanity-check failed: " .. tostring(herr))
fs.remove("file", cache_path)
return false
end
return true
end
-- -------------------------------------------------------------------
-- Ensure cache exists & is fresh; regenerate if needed
-- -------------------------------------------------------------------
local function ensure_cache(job)
local cache_path, why = get_cache_path(job)
if not cache_path then
return nil, why
end
-- Fresh -> done
if cache_is_fresh(job, cache_path) then
return cache_path
end
-- Acquire lock. It only deduplicates work: a generator that fails to
-- get it still produces a correct cache, because every generator
-- writes its own scratch files and publishes them with one rename.
local lock_path = lock_path_for(cache_path)
local locked = acquire_lock(lock_path, TIME_OUT_LOCK)
-- Hand the lock back on every exit path, including the one Yazi takes
-- when it abandons this peek.
--
-- Yazi drops the coroutine of a superseded peek at whatever await point
-- it has reached, so anything written after generate_cache() simply
-- never runs. A to-be-closed variable is closed even then, and is the
-- only hook we get. Without it a single abandoned peek wedges every
-- later peek of that file until LOCK_TTL_SEC expires; Yazi peeks twice
-- in quick succession on startup, which is enough to hit this on a
-- generator as cheap as `eza --tree`.
local _lock <close> = setmetatable({}, {
__close = function()
if locked then
release_lock(lock_path)
end
end,
})
-- Re-check: whoever held the lock may have generated it meanwhile.
-- This also covers the timed-out case, where we go on to generate
-- without the lock rather than reporting failure.
if cache_is_fresh(job, cache_path) then
return cache_path
end
if not generate_cache(job, cache_path) then
return nil, "generate-failed"
end
return cache_path
end
----------------------------------------------------------------------
-- Yazi hooks
----------------------------------------------------------------------
function M:preload(job)
-- Preload is explicitly configured -> always warm cache
local cache_path = ensure_cache(job)
return cache_path ~= nil
end
----------------------------------------------------------------------
-- NOTE ABOUT "JUMP TO END" / HUGE SCROLLS (Yazi limitation + workaround)
--
-- Problem:
-- Yazi's preview scrolling model is built around a `skip` integer that
-- is passed into `peek(job)` and represents "how many units to skip"
-- (lines for text previewers). Yazi does NOT provide:
--
-- 1) The total number of lines of the previewed content.
-- 2) Any callback where `seek()` can read user args (command, caching).
-- 3) A reliable shared Lua state between `seek()` and `peek()`.
--
-- In particular:
-- - `seek(job)` is stateless and arg-agnostic. It cannot know whether
-- caching is enabled, which generator is used, or what cache file exists.
-- - We cannot maintain a Lua table indexed by filename to store per-file
-- metadata (like total lines), because Yazi may reload the Lua state
-- between calls. So `seek()` cannot rely on anything computed earlier
-- by `peek()` or `preload()`.
--
-- Consequence:
-- When the user performs a large scroll action (e.g. PageDown held, or
-- "scroll to bottom"), Yazi may ask us to render extremely large skip
-- values. But we cannot clamp skip in `seek()` (we don't know file length),
-- and Yazi itself may sanitize/clamp very large skips in inconsistent ways.
--
-- The only place where we CAN know the "file length" is inside `peek()`:
-- because we embed the total line count in the cache file itself as the
-- first line header.
--
-- But there is a catch:
-- We MUST NOT silently change the rendering range inside `peek()`, because
-- Yazi tracks preview state using the requested skip. If we locally clamp
-- skip without telling Yazi, Yazi believes we are at one skip while we are
-- actually rendering a different one, and scrolling becomes desynchronized.
--
-- Workaround:
-- We implement "jump to end" as a two-step protocol:
--
-- (A) seek() detects a "huge scroll in one action" using ONLY job.units,
-- and emits a special sentinel skip value:
--
-- skip = cur + PEEK_JUMP_THRESHOLD + 1
--
-- The "+1" ensures skip > PEEK_JUMP_THRESHOLD even when cur == 0.
--
-- (B) peek() sees skip > PEEK_JUMP_THRESHOLD, reads `total` from the
-- cache header, and if (total <= PEEK_JUMP_THRESHOLD) then we know
-- this skip is "definitely beyond EOF", so we clamp by EMITTING a
-- NEW peek() call with skip=max_skip (last page), then return:
--
-- ya.emit("peek", { max_skip, only_if = job.file.url })
-- return
--
-- This keeps Yazi's internal state consistent because it re-runs peek
-- with the corrected skip.
--
-- (C) For very large files (total > PEEK_JUMP_THRESHOLD) we cannot jump
-- reliably without knowing the actual length ahead of time. In that
-- case we simply treat the skip as a real skip and do nothing special.
-- This is the best we can do under Yazi's constraints.
--
-- Summary:
-- - seek() cannot know file length -> cannot clamp skip.
-- - peek() CAN know file length via cache header.
-- - peek() MUST NOT locally clamp -> must re-emit peek() with corrected skip.
-- - sentinel skips are used to request "jump-to-end" in a stateless way.
--
-- Do NOT remove this logic unless Yazi gains:
-- - a reliable shared Lua state between calls, OR
-- - total line count passed into preview jobs, OR
-- - a preview API that supports clamping without desync.
----------------------------------------------------------------------
function M:seek(job)
-- SEEK MUST BE STATELESS AND ARG-AGNOSTIC
-- Yazi does not provide information in job whether the cache is present
-- and what command generated the preview content
local cur = cx.active.preview.skip or 0
local units = job.units or 0
-- Candidate skip (absolute)
local new_skip = cur + units
-- Fast path: if user scrolls *way* up in one action, jump to top.
if units < -SKIP_JUMP_THRESHOLD then
new_skip = 0
end
if units > SKIP_JUMP_THRESHOLD then
ya.emit("peek", { cur + PEEK_JUMP_THRESHOLD + 1, only_if = job.file.url })
return
end
new_skip = math.max(0, new_skip)
ya.emit("peek", { new_skip, only_if = job.file.url })
end
function M:peek(job)
local cache_path, why
local hdr, herr
if is_true(job.args.rely_on_preloader,false) then
cache_path, why = get_cache_path(job)
if not cache_path then
ya.preview_widget(job, ui.Text.parse("faster-piper: " .. tostring(why)):area(job.area))
return
end
local ok
ok, hdr = cache_is_fresh(job, cache_path) -- hdr is assured to be nil when ok==false
if not ok then
-- If the cache file exists, we can self-heal (resize case) by reusing cmd from header.
if fs.cha(cache_path) then
local ensured, ewhy = ensure_cache(job)
if ensured then
cache_path = ensured
else
ya.preview_widget(job,
ui.Text.parse("faster-piper: failed to refresh cache: " .. tostring(ewhy)):area(job.area)
)
return
end
else
-- Cache file doesn't exist (save-race): wait briefly for preloader to write it.
local ok2
ok2, hdr = wait_for_ready_cache(job, cache_path, TIME_OUT_PREVIEW)
if not ok2 then
ya.preview_widget(job,
ui.Text.parse("faster-piper: ⏳ preview is taking longer than expected. Try selecting the file again."):area(job.area)
)
return
end
end
end
else
cache_path, why = ensure_cache(job)
if not cache_path then
ya.preview_widget(job, ui.Text.parse("faster-piper: " .. tostring(why)):area(job.area))
return
end
end
-- If hdr not already available/reliable, read it once here
if not hdr then hdr, herr = read_cache_header(cache_path) end
if hdr then
local total = hdr.nline
local limit = job.area.h
local max_skip = math.max(0, total - limit)
local skip = job.skip or 0
if total <= PEEK_JUMP_THRESHOLD and skip > PEEK_JUMP_THRESHOLD and skip ~= max_skip then
ya.emit("peek", { max_skip, only_if = job.file.url })
return
end
if skip > max_skip then
ya.emit("peek", { max_skip, only_if = job.file.url })
return
end
else
ya.err("faster-piper: failed to read cache header: " .. tostring(herr))
end
local limit = job.area.h
local skip = job.skip or 0
-- content starts after HEADER.N lines of header
local start = skip + content_first_line()
local stop = start + limit - 1
local lines, err = read_line_range(tostring(cache_path), start, stop)
if not lines then
ya.preview_widget(job, ui.Text.parse("faster-piper: read(slice): " .. tostring(err)):area(job.area))
return
end
-- The slice starts past the header, so it is pure content.
local slice = #lines > 0 and (table.concat(lines, "\n") .. "\n") or ""
if job.args.format == "url" then
ya.preview_widget(job, M.format(job, split_lines(slice)))
else
ya.preview_widget(job, ui.Text.parse(slice):area(job.area))
end
end
-- -------------------------------------------------------------------
-- entry(): called by `run = 'plugin faster-piper ...'` keybindings
-- -------------------------------------------------------------------
function M:entry(job)
return
end
return M
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 Sonico98
Copyright (c) 2023 yazi-rs
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,28 @@
# smart-filter.yazi
A Yazi plugin that makes filters smarter: continuous filtering, automatically enter unique directory, open file on submitting.
https://github.com/yazi-rs/plugins/assets/17523360/72aaf117-1378-4f7e-93ba-d425a79deac5
## Installation
```sh
ya pkg add yazi-rs/plugins:smart-filter
```
## Usage
Add this to your `~/.config/yazi/keymap.toml`:
```toml
[[mgr.prepend_keymap]]
on = "F"
run = "plugin smart-filter"
desc = "Smart filter"
```
Note that, the keybindings above are just examples, please tune them up as needed to ensure they don't conflict with your other actions/plugins.
## License
This plugin is MIT-licensed. For more information check the [LICENSE](LICENSE) file.
@@ -0,0 +1,50 @@
--- @since 25.12.29
local hovered = ya.sync(function()
local h = cx.active.current.hovered
if not h then
return {}
end
return {
url = h.url,
is_dir = h.cha.is_dir,
unique = #cx.active.current.files == 1,
}
end)
local function prompt()
return ya.input {
title = "Smart filter:",
pos = { "top-center", y = 2, w = 50 },
realtime = true,
debounce = 0.1,
}
end
local function entry()
local input = prompt()
while true do
local value, event = input:recv()
if event ~= 1 and event ~= 3 then
ya.emit("escape", { filter = true })
break
end
ya.emit("filter_do", { value, smart = true })
local h = hovered()
if h.unique and h.is_dir then
ya.emit("escape", { filter = true })
ya.emit("enter", {})
input = prompt()
elseif event == 1 then
ya.emit("escape", { filter = true })
ya.emit(h.is_dir and "enter" or "open", { h.url })
break
end
end
end
return { entry = entry }
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 yazi-rs
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.
@@ -0,0 +1,80 @@
# toggle-pane.yazi
Toggle the show, hide, and maximize states for different panes: parent, current, and preview. It respects the user's [`ratio` settings](https://yazi-rs.github.io/docs/configuration/yazi#mgr.ratio)!
Assume the user's `ratio` is $$[A, B, C]$$, that is, $$\text{parent}=A, \text{current}=B, \text{preview}=C$$:
- `min-parent`: Toggles between $$0$$ and $$A$$ - the parent is either completely hidden or showed with width $$A$$.
- `max-parent`: Toggles between $$A$$ and $$\infty$$ - the parent is either showed with width $$A$$ or fills the entire screen.
- `min-current`: Toggles between $$0$$ and $$B$$ - the current is either completely hidden or showed with width $$B$$.
- `max-current`: Toggles between $$B$$ and $$\infty$$ - the current is either showed with width $$B$$ or fills the entire screen.
- `min-preview`: Toggles between $$0$$ and $$C$$ - the preview is either completely hidden or showed with width $$C$$.
- `max-preview`: Toggles between $$C$$ and $$\infty$$ - the preview is either showed with width $$C$$ or fills the entire screen.
- `reset`: Resets to the user's configured `ratio`.
## Installation
```sh
ya pkg add yazi-rs/plugins:toggle-pane
```
## Usage
Hide/Show preview:
```toml
# keymap.toml
[[mgr.prepend_keymap]]
on = "T"
run = "plugin toggle-pane min-preview"
desc = "Show or hide the preview pane"
```
Maximize/Restore preview:
```toml
# keymap.toml
[[mgr.prepend_keymap]]
on = "T"
run = "plugin toggle-pane max-preview"
desc = "Maximize or restore the preview pane"
```
You can replace `preview` with `current` or `parent` to toggle the other panes.
Note that, the keybindings above are just examples, please tune them up as needed to ensure they don't conflict with your other actions/plugins.
## Advanced
In addition to triggering the plugin with a keypress, you can also trigger it in your `init.lua` file:
```lua
if os.getenv("NVIM") then
require("toggle-pane"):entry("min-preview")
end
```
In the example above, when it detects that you're [using Yazi in nvim](https://yazi-rs.github.io/docs/resources#vim), the preview is hidden by default — you can always press `T` (or any key you've bound) to show it again.
## Tips
This plugin only maximizes the "available preview area", without actually changing the content size.
This means that the appearance of your preview largely depends on the previewer you are using.
However, most previewers tend to make the most of the available space, so this usually isn't an issue.
For image previews, you may want to tune up the [`max_width`][max-width] and [`max_height`][max-height] options in your `yazi.toml`:
```toml
[preview]
# Change them to your desired values
max_width = 1000
max_height = 1000
```
[max-width]: https://yazi-rs.github.io/docs/configuration/yazi/#preview.max_width
[max-height]: https://yazi-rs.github.io/docs/configuration/yazi/#preview.max_height
## License
This plugin is MIT-licensed. For more information, check the [LICENSE](LICENSE) file.
@@ -0,0 +1,48 @@
--- @since 26.8.15
--- @sync entry
local PANE = { parent = 1, current = 2, preview = 3 }
local function eq(other)
local r = rt.mgr.ratio
return other[1] == r[1] and other[2] == r[2] and other[3] == r[3]
end
local function get()
local r = rt.mgr.ratio
return { r[1], r[2], r[3] }
end
local function set(new) rt.mgr.ratio = { new[1], new[2], new[3] } end
local function entry(st, job)
job = type(job) == "string" and { args = { job } } or job
if not eq(st.new or {}) then
st.new, st.old = nil, nil
end
local N, O = st.new or get(), st.old or get()
local act, to = string.match(job.args[1] or "", "(.-)-(.+)")
local i = PANE[to]
if act == "min" then
N[i] = N[i] == O[i] and 0 or O[i]
elseif act == "max" then
local max = N[i] == 9999 and O[i] or 9999
N[1] = N[1] == 9999 and O[1] or N[1]
N[2] = N[2] == 9999 and O[2] or N[2]
N[3] = N[3] == 9999 and O[3] or N[3]
N[i] = max
end
if act then
st.new, st.old = N, O
else
N, st.new, st.old = O, nil, nil
end
set(N)
ya.emit("app:resize", {})
end
return { entry = entry }
@@ -1,5 +1,14 @@
zshrc /home/liph/dotfiles/zshrc z
yazi /home/liph/dotfiles/yazi/.config/yazi d,y
tank /mnt/tank T,T
nvim /home/liph/dotfiles/nvim/.config/nvim d,n
niri /home/liph/dotfiles/niri/.config/niri d,N
mnt /mnt M
home /home/liph f
herdr /home/liph/dotfiles/herdr/.config/herdr d,h
flash1 /mnt/flash1 T,f
Downloads /home/liph/Downloads D,d
dotfiles /home/liph/dotfiles d,d
Documents /home/liph/Documents D,D
.config /home/liph/.config c
aerc /home/liph/dotfiles/aerc/aerc d,a
@@ -0,0 +1,7 @@
Copyright 2024 grappas
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.
@@ -0,0 +1,34 @@
# wl-clipboard.yazi
Forked from: [orhnk/system-clipboard.yazi](https://github.com/orhnk/system-clipboard.yazi) to work with wayland compositors.
## Demo
<https://github.com/user-attachments/assets/74d59f49-266a-497e-b67e-d77e64209026>
## Config
> [!NOTE]
> You need yazi 3.x for this plugin to work.
> [!Important]
> This plugin utilizes ["wl-clipboard" project](https://github.com/bugaevc/wl-clipboard).
> You need to have it installed on your system. (Make sure that It's on your $PATH)
## Installation
```bash
ya pkg add grappas/wl-clipboard
```
## Configuration
Copy or install this plugin and add the following keymap to your `manager.prepend_keymap`:
```toml
on = "<C-y>"
run = ["plugin wl-clipboard"]
```
> [!Tip]
> If you want to use this plugin with yazi's default yanking behaviour you should use `cx.yanked` instead of `tab.selected` in `init.lua` (See [#1487](https://github.com/sxyazi/yazi/issues/1487))
@@ -0,0 +1,60 @@
-- Meant to run at async context. (yazi system-clipboard)
local selected_or_hovered = ya.sync(function()
local tab, paths = cx.active, {}
for _, u in pairs(tab.selected) do
paths[#paths + 1] = tostring(u)
end
if #paths == 0 and tab.current.hovered then
paths[1] = tostring(tab.current.hovered.url)
end
return paths
end)
return {
entry = function()
ya.emit("escape", { visual = true })
local urls = selected_or_hovered()
if #urls == 0 then
return ya.notify({ title = "System Clipboard", content = "No file selected", level = "warn", timeout = 5 })
end
-- ya.notify({ title = #urls, content = table.concat(urls, " "), level = "info", timeout = 5 })
-- Format the URLs for `text/uri-list` specification
local function encode_uri(uri)
return uri:gsub("([^%w%-%._~:/])", function(c)
return string.format("%%%02X", string.byte(c))
end)
end
local file_list_formatted = ""
for _, path in ipairs(urls) do
-- Each file path must be URI-encoded and prefixed with "file://"
file_list_formatted = file_list_formatted .. "file://" .. encode_uri(path) .. "\r\n"
end
local status, err =
Command("wl-copy"):arg("--type"):arg("text/uri-list"):arg(file_list_formatted):spawn():wait()
if status or status.succes then
ya.notify({
title = "System Clipboard",
content = "Succesfully copied the file(s) to system clipboard",
level = "info",
timeout = 5,
})
end
if not status or not status.success then
ya.notify({
title = "System Clipboard",
content = string.format("Could not copy selected file(s) %s", status and status.code or err),
level = "error",
timeout = 5,
})
end
end,
}
+31 -7
View File
@@ -12,7 +12,7 @@ sort_dir_first = true
sort_translit = false
sort_fallback = "alphabetical"
linemode = "none"
show_hidden = false
show_hidden = true
show_symlink = true
scrolloff = 5
mouse_events = ["click", "scroll", "drag"]
@@ -20,8 +20,8 @@ mouse_events = ["click", "scroll", "drag"]
[preview]
wrap = "no"
tab_size = 2
max_width = 600
max_height = 900
max_width = 800
max_height = 1200
cache_dir = ""
image_delay = 30
image_filter = "triangle"
@@ -143,10 +143,14 @@ fetchers = [
]
prepend_fetchers = [
#####################################################################
### git ###
#####################################################################
{ url = "*", run = "git", group = "git" },
{ url = "*/", run = "git", group = "git" },
#####################################################################
### mime-ext ###
#####################################################################
{ url = "local://*", run = "mime-ext.local", prio = "high", group = "mime" },
{ url = "remote://*", run = "mime-ext.remote", prio = "high", group = "mime" },
]
@@ -195,7 +199,15 @@ preloaders = [
]
prepend_preloaders = [
#####################################################################
### piper ###
#####################################################################
{ url = "*.md", run = 'faster-piper -- CLICOLOR_FORCE=1 glow -w=$w -s=dracula -- "$1"' },
{ url = "*.tar*", run = 'faster-piper -- tar tf "$1"' },
{ url = "*.txt.gz", run = 'faster-piper -- gzip -dc "$1"' },
#####################################################################
### mediainfo ###
#####################################################################
# Replace magick, image, video with mediainfo
{ mime = "{audio,video,image}/*", run = "mediainfo" },
# .srt subtitle, adobe illustrator, adobe photoshop file
@@ -247,29 +259,41 @@ previewers = [
{ mime = "trash/**", run = "trash" },
# Error
{ mime = "null/*", run = "null" },
# # Fallback
# { url = "*", run = "file" },
# Fallback
{ url = "*", run = "file" },
]
append_previewers = [
#####################################################################
### extra-metadata ###
#####################################################################
{ url = "*", run = "file-extra-metadata" },
# Setup keybind for spotter: https://github.com/sxyazi/yazi/pull/1802
]
prepend_previewers = [
####################################################################
### piper ###
#####################################################################
{ url = "*.md", run = 'faster-piper -- CLICOLOR_FORCE=1 glow -w=$w -s=dracula -- "$1"' },
{ url = "*.tar*", run = 'faster-piper -- tar tf "$1"' },
{ url = "*.txt.gz", run = 'faster-piper -- gzip -dc "$1"' },
####################################################################
### rich-preview ###
#####################################################################
{ url = "*.csv", run = "rich-preview" }, # for csv files
{ url = "*.md", run = "rich-preview" }, # for markdown (.md) files
{ url = "*.rst", run = "rich-preview" }, # for restructured text (.rst) files
{ url = "*.ipynb", run = "rich-preview" }, # for jupyter notebooks (.ipynb)
{ url = "*.json", run = "rich-preview" }, # for json (.json) files
# { url = "*.lang_type", run = "rich-preview"} # for particular language files eg. .py, .go., .lua, etc.
# ### exifaudio ###
# { mime = "audio/*", run = "exifaudio" },
#####################################################################
### eza-preview ###
#####################################################################
{ url = "*/", run = "eza-preview" },
#####################################################################
### mediainfo ###
#####################################################################
# Replace magick, image, video with mediainfo
{ mime = "{audio,video,image}/*", run = "mediainfo" },
# .srt subtitle, adobe illustrator, adobe photoshop file