deleted clipcascade, updated yazi
This commit is contained in:
1 parent
4ff0615bac
commit
4327b8ce0e
54 files changed
+1726
-6768
No files matched your search
@@ -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" })
|
||||
|
||||
@@ -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 ###
|
||||
|
||||
@@ -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).
|
||||
|
||||

|
||||
|
||||
## 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
-1
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user