Yazi integration#
yazi is a terminal file manager with a Lua
plugin system. olink maps well onto it as a thin wrapper plugin: the Lua
side only discovers the project directory, shows a menu, and shells out to
the olink binary. No URL logic is ported, so the plugin never drifts when
new targets are added. Tested against yazi 26.9.1; the APIs used are
ya.sync, ya.which, ya.notify, ya.clipboard, ya.emit,
ya.quote, Command:output and fs.cha.
How it works#
Yazi gives the plugin the current tab’s directory (
cx.active.current.cwd).The plugin walks up to the project root — olink only works from the root — and runs
olink list -d <root>to learn which targets exist there.The target names become a
ya.whichkey menu (names always fit the menu’s column layout); the pick shows a confirm dialog with the full description and the resolved URL, and confirming runsolink open <target> -d <root>(orolink url+ clipboard with--copy, no dialog).
Requirements#
The
olinkCLI onPATH.A yazi new enough for
ya.which(any recent release; 26.9.1 verified).
Install#
Install the plugin with yazi’s package manager (it lives in this repository’s
olink.yazi/ directory) and bind it in ~/.config/yazi/keymap.toml:
ya pkg add hasansezertasan/olink:olink
[[mgr.prepend_keymap]]
on = ["g", "o"]
run = "plugin olink"
desc = "Open project links with olink"
[[mgr.prepend_keymap]]
on = ["g", "O"]
run = "plugin olink -- --copy"
desc = "Copy project link URL with olink"
The -- before --copy matters: Yazi only forwards arguments after
the end-of-options separator to the plugin, so without it job.args.copy
stays unset and g O would open instead of copying.
Press g o while browsing a project to open a link, g O to copy the
URL instead. Outside a project (no root markers found) the plugin notifies
instead of failing silently.
Plugin source#
The installed main.lua is the tested file olink.yazi/main.lua,
included verbatim below — what you read here is what the tests execute:
--- @since 26.9.1
-- ~/.config/yazi/plugins/olink.yazi/main.lua (installed by `ya pkg add`)
--
-- Open olink targets (PyPI page, issues, CI, download stats, ...) for the
-- project you are browsing, without leaving yazi.
-- Requires the `olink` CLI on PATH (https://github.com/hasansezertasan/olink).
-- Picks with the which-key menu, then confirms with the full description
-- and resolved URL. Everything stays inside yazi's own UI.
--- Files that mark a project root. Kept in sync with what olink reads
--- (git remotes plus the per-ecosystem package manifests). Exact names
--- only: glob-based manifests (`*.gemspec`, `*.csproj`, `*.cabal`) have no
--- existence check here and resolve through the `.git` marker instead.
local MARKERS = {
".git",
"pyproject.toml",
"package.json",
"Cargo.toml",
"go.mod",
"composer.json",
"pubspec.yaml",
"pom.xml",
"mix.exs",
"Makefile.PL",
"dist.ini",
}
--- One keystroke per target in the which-key menu. Built from code ranges
--- rather than one long literal: a 36-character string trips secret
--- scanners, and this pool is just a-z then 0-9.
local KEY_POOL = (function()
local keys = {}
for code = 97, 122 do -- a-z
keys[#keys + 1] = string.char(code)
end
for code = 48, 57 do -- 0-9
keys[#keys + 1] = string.char(code)
end
return table.concat(keys)
end)()
--- Current tab's cwd; `cx` is only readable from a sync context.
local get_cwd = ya.sync(function()
return tostring(cx.active.current.cwd)
end)
local function exists(url)
local cha, _ = fs.cha(url)
return cha ~= nil
end
--- Parent directory of `path`, or nil at the filesystem root. No depth
--- cap: every step shortens the path, so the walk always terminates.
local function parent_dir(path)
local url = Url(path)
local parent = url.parent
if parent == nil or tostring(parent) == tostring(url) then
return nil
end
return tostring(parent)
end
--- olink only works from the project root, so walk up from `start`
--- until a directory carries a known marker. (`parent` is yazi's own
--- Url property, so separators are correct on every platform.)
local function find_root(start)
local dir = start
while dir ~= nil do
local url = Url(dir)
for _, marker in ipairs(MARKERS) do
if exists(url:join(marker)) then
return dir
end
end
dir = parent_dir(dir)
end
return nil
end
--- Run olink and return trimmed stdout, or nil plus an error message.
local function olink(args)
local output, err = Command("olink"):arg(args):output()
if output == nil then
return nil, string.format("failed to start `olink`: %s", tostring(err))
end
if not output.status.success then
local msg = output.stderr
if msg == nil or msg == "" then
msg = "olink exited with code " .. tostring(output.status.code)
end
return nil, msg
end
return (output.stdout:gsub("%s+$", "")), nil
end
--- Parse the human-readable `olink list` output into { name, desc } rows.
--- Each row looks like: ` pypi - Open PyPI page`
local function parse_list(text)
local rows = {}
for line in text:gmatch("[^\r\n]+") do
local name, desc = line:match("^%s+(%S+)%s+%-%s+(.+)$")
if name ~= nil and desc ~= nil then
rows[#rows + 1] = { name = name, desc = desc }
end
end
return rows
end
local function fail(message)
ya.notify({ title = "olink", content = message, level = "error", timeout = 5 })
end
--- Pick a target row with the which-key menu. Labels are names only: the
--- menu lays candidates out in columns, so each cell only fits a couple
--- dozen characters — names always fit, descriptions would chop mid-word.
--- The full description is shown on the confirm dialog instead.
--- Returns the row, or nil when dismissed. Beyond the key pool the menu
--- cannot show everything, so a notice names the shortfall honestly.
local function pick_row(rows)
local cands = {}
for i, row in ipairs(rows) do
if i > #KEY_POOL then
break
end
cands[i] = { on = KEY_POOL:sub(i, i), desc = row.name }
end
if #rows > #KEY_POOL then
ya.notify({
title = "olink",
content = string.format("showing %d of %d targets", #KEY_POOL, #rows),
timeout = 4,
})
end
local idx = ya.which({ cands = cands })
if idx == nil then
return nil
end
return rows[idx]
end
--- Walk up to the nearest directory olink can actually list. A `.git`
--- directory is a repository boundary: if olink rejects it, that error is
--- reported rather than silently opening a parent repository's links.
--- Manifest-only directories that olink rejects are skipped upward; the
--- first error is kept for the final report. Note an empty list still exits
--- successfully, so success alone is not acceptance: zero parsed targets
--- counts as unusable too.
local function find_usable(start)
local dir = start
local first_err = nil
while true do
local root = find_root(dir)
if root == nil then
return nil, first_err or "no project root found above the current directory"
end
local text, lerr = olink({ "list", "-d", root })
if text ~= nil and #parse_list(text) > 0 then
return root, text
end
local uerr = lerr or ("no olink targets available for " .. root)
if first_err == nil then
first_err = uerr
end
if exists(Url(root):join(".git")) then
return nil, uerr
end
dir = parent_dir(root)
end
end
local function entry(self, job)
local root, text = find_usable(get_cwd())
if root == nil then
fail(text)
return
end
local rows = parse_list(text)
local picked = pick_row(rows)
if picked == nil then
return
end
local url, uerr = olink({ "url", picked.name, "-d", root })
if url == nil then
fail(uerr)
return
end
if job.args.copy then
ya.clipboard(url)
ya.notify({ title = "olink", content = "copied: " .. url, timeout = 3 })
return
end
local open_it = ya.confirm({
pos = { "center", w = 70, h = 10 },
title = picked.name,
body = picked.desc .. "\n" .. url,
})
if open_it then
ya.emit("shell", { string.format("olink open %s -d %s", ya.quote(picked.name), ya.quote(root)) })
end
end
return { entry = entry }
Testing#
tests/test_yazi_plugin.py keeps this page honest:
the
literalincludeabove must reference the example file, so docs and code cannot drift;the TOML keymap snippet must parse;
the Lua file is compiled and its entry point is executed inside lupa (an in-process Lua runtime, so this runs on every CI OS with no system dependency) against live
olink list/olink urloutput from a fixture project — covering the open path with its confirm dialog, dialog dismissal,--copy, menu dismissal, the no-project-root case, olink failure, the Windows root walk, retry past a broken manifest, retry past an empty target list, the.gitretry boundary, the menu cap notice, and multi-ecosystem suffix passthrough.
Notes#
Picker: the which-key menu shows target names only, because its column layout chops longer labels mid-word and nothing may be shortened by the plugin. The full description and resolved URL appear on the confirm dialog instead — everything stays inside yazi’s own UI.
Why parse the text output?
olink list --jsonexists, but decoding it in Lua needs eitherjqonPATHor a JSON library; thename - descriptionlines parse with one pattern and keep the plugin dependency-free. If olink ever changes that text layout, switch theparse_liststep to--json.Multi-ecosystem projects: the default
olink listemits suffixed names such asdeps:npmwhen several ecosystems are detected, and the plugin passes listed names through verbatim, so they open as-is.More targets than keys: the menu caps at the key pool (36 entries); beyond that it says how many it omits, so nothing vanishes silently.
Root markers: exact filenames only, including the Perl pair (
Makefile.PL,dist.ini). Glob-based manifests (*.gemspec,*.csproj,*.cabal) have no existence check and resolve through the.gitmarker instead.No TUI here:
olink interactiveis replaced by yazi’s ownya.whichmenu — the right trade, since yazi is the interface.