Files
yazi/plugins/bunny.yazi/main.lua
T

406 lines
13 KiB
Lua

--- @since 25.12.29
--- @diagnostic disable-next-line:unused-function,unused-local
local function debug(...)
--- @diagnostic disable-next-line:unused-function
local function toReadableString(val)
if type(val) == 'table' then
local str = 'table:{ '
for k, v in pairs(val) do
str = str .. '[' .. tostring(k) .. '] = ' .. toReadableString(v) .. ', '
end
return str .. '}'
else
return type(val) .. ':' .. tostring(val)
end
end
local args = { ... }
local processed_args = {}
for _, arg in pairs(args) do
table.insert(processed_args, toReadableString(arg))
end
if #processed_args == 0 then
processed_args = { 'nil' }
end
ya.dbg('BUNNY.YAZI', table.unpack(processed_args))
end
local function fail(s, ...) ya.notify { title = 'bunny.yazi', content = string.format(s, ...), timeout = 4, level = 'error' } end
local function info(s, ...) ya.notify { title = 'bunny.yazi', content = string.format(s, ...), timeout = 2, level = 'info' } end
local get_state = ya.sync(function(state, attr)
return state[attr]
end)
local set_state = ya.sync(function(state, attr, value)
state[attr] = value
end)
local get_cwd = ya.sync(function(_state)
return tostring(cx.active.current.cwd) -- Url objects are evil >.<"
end)
local get_current_tab_idx = ya.sync(function(_state)
return cx.tabs.idx
end)
local get_tabs_as_paths = ya.sync(function(_state)
local tabs = cx.tabs
local active_tab_idx = tabs.idx
local result = {}
for idx = 1, #tabs, 1 do
if idx ~= active_tab_idx and tabs[idx] then
result[idx] = tostring(tabs[idx].current.cwd)
end
end
return result
end)
local some = function(...)
for _, v in ipairs({ ... }) do
if v ~= nil then
return v
end
end
end
local function filename(pathstr)
if pathstr == '/' then return pathstr end
local url_name = Url(pathstr):name()
if url_name then
return tostring(url_name)
else
return pathstr
end
end
local function path_to_desc(path, strategy)
if strategy == 'filename' then
return filename(path)
end
local home = os.getenv('HOME')
if home and home ~= '' then
local startPos, endPos = string.find(path, home)
-- Only substitute if the match is from the start of path, very important
if startPos == 1 then
return '~' .. path:sub(endPos + 1)
end
end
return tostring(path)
end
local function sort_hops(hops)
local function convert_key(key)
local t = type(key)
if t == 'table' then
-- If table, sort by first key string
return key[1]
elseif t == 'string' and string.lower(key) ~= key then
-- If uppercase letter (signifying an ephemeral hop), prepend "z"
-- so it gets sorted after lowercase persistent hops
return 'z' .. key
end
return key
end
table.sort(hops, function(x, y)
return convert_key(x.key) < convert_key(y.key)
end)
return hops
end
local create_special_hops = function(config)
local hops = {}
local desc_strategy, tabs, ephemeral = config.desc_strategy, config.tabs, config.ephemeral
if ephemeral then
table.insert(hops, { key = '<Enter>', desc = 'Create hop', path = '__MARK__' })
end
table.insert(hops, { key = '<Space>', desc = 'Fuzzy search', path = '__FUZZY__' })
local tabhist = get_state('tabhist')
local tab = get_current_tab_idx()
if tabhist[tab] and tabhist[tab][2] then
local previous_dir = tabhist[tab][2]
table.insert(hops, {
key = '<Backspace>',
path = previous_dir,
desc = path_to_desc(previous_dir, desc_strategy)
})
end
if tabs then
for idx, tab_path in pairs(get_tabs_as_paths()) do
table.insert(hops, {
key = tostring(idx),
path = tab_path,
desc = path_to_desc(tab_path, desc_strategy)
})
end
end
return hops
end
local function validate_options(options)
local function validate_key(key)
local kt = type(key)
if not key then
return false, 'key is missing'
elseif kt == 'string' then
if utf8.len(key) ~= 1 then
return false, 'key must be single character'
end
elseif kt == 'table' then
if #key == 0 then
return false, 'key cannot be empty table'
end
for _, char in pairs(key) do
if utf8.len(char) ~= 1 then
return false, 'key list must contain single characters'
end
end
else
return false, 'key must be string or table'
end
return true, ''
end
if type(options) ~= 'table' then
return 'Invalid config'
end
local hops, desc_strategy, tabs, ephemeral, fuzzy_cmd, notify =
options.hops, options.desc_strategy, options.tabs,
options.ephemeral, options.fuzzy_cmd, options.notify
-- Validate hops
if type(hops) == 'table' then
local used_keys = {}
for idx, hop in pairs(hops) do
hop.desc = hop.desc or hop.tag or nil -- used to be tag, allow for backwards compat
local key_is_validated, key_err = validate_key(hop.key)
local err = 'Invalid "hops" config value: #' .. idx .. ' '
if not key_is_validated then
return err .. key_err
elseif not hop.path then
return err .. 'path is missing'
elseif type(hop.path) ~= 'string' or string.len(hop.path) == 0 then
return err .. 'path must be non-empty string'
elseif hop.desc and (type(hop.desc) ~= 'string' or string.len(hop.path) == 0) then
return err .. 'desc must be non-empty string'
end
-- Check for duplicate keys
if used_keys[hop.key] then
return err .. 'needs unique key'
end
-- Insert hop keys as table keys to easily check for set membership
used_keys[hop.key] = true
end
else
return 'Invalid "hops" config value'
end
-- Validate other options
if desc_strategy ~= nil and desc_strategy ~= 'path' and desc_strategy ~= 'filename' then
return 'Invalid "desc_strategy" config value'
elseif tabs ~= nil and type(notify) ~= 'boolean' then
return 'Invalid "tabs" config value'
elseif ephemeral ~= nil and type(notify) ~= 'boolean' then
return 'Invalid "ephemeral" config value'
elseif fuzzy_cmd ~= nil and type(fuzzy_cmd) ~= 'string' then
return 'Invalid "fuzzy_cmd" config value'
elseif notify ~= nil and type(notify) ~= 'boolean' then
return 'Invalid "notify" config value'
end
end
-- https://github.com/sxyazi/yazi/blob/main/yazi-plugin/preset/plugins/fzf.lua
-- https://github.com/sxyazi/yazi/blob/main/yazi-plugin/src/process/child.rs
-- Returns nil if fzf command failed or user exited with escape key
local select_fuzzy = function(hops, config)
---@diagnostic disable-next-line:undefined-field
local permit = ui.hide()
-- child_err is basically a shell error in this case which is useful
local child, child_err =
Command(config.fuzzy_cmd):stdin(Command.PIPED):stdout(Command.PIPED):stderr(Command.INHERIT):spawn()
if child_err then
fail('Command `%s` failed with code %s. Do you have it installed?', config.fuzzy_cmd, child_err.code)
return
elseif not child then
-- Idk how this could ever be reached but I must nil check to make luals happy
fail('Command failed')
return
end
local fuzzy_entries = {}
for _, hop in pairs(hops) do
local existing_desc = fuzzy_entries[hop.path]
if not existing_desc or existing_desc == '' then
local fuzzy_desc = hop.desc
-- Avoid repeating path in description
if fuzzy_desc == hop.path then
fuzzy_desc = ''
end
fuzzy_entries[hop.path] = fuzzy_desc
end
end
-- Build fzf input string
local input_lines = {}
for entry_path, entry_desc in pairs(fuzzy_entries) do
local line = entry_desc .. string.rep(' ', 23 - #entry_desc) .. '\t' .. entry_path
table.insert(input_lines, line)
end
child:write_all(table.concat(input_lines, '\n'))
---@diagnostic disable-next-line:undefined-field
child:flush()
-- Ignore the second return value (Error) because we can just check if
-- output is nil.
local output = child:wait_with_output()
permit:drop()
if not output then
-- See comment above, not common and impossible to give helpful message
fail('Failed to parse fuzzy searcher result')
return
elseif output and not output.status.success then
if output.status.code ~= 130 then -- user pressed escape to quit
fail('Command `%s` failed with code %s', config.fuzzy_cmd, output.status.code)
end
return
end
-- Parse fzf output, remove right padded spaces from desc
local desc, path = string.match(output.stdout, '^(.-) *\t(.-)\n$')
if not desc or not path or path == '' then
fail('Failed to parse fuzzy searcher result')
return
end
if desc == '' then
desc = path_to_desc(path, config.desc_strategy)
end
return { desc = desc, path = path }
end
local cd = function(selected_hop, config)
local _, dir_list_err = fs.read_dir(Url(selected_hop.path), { limit = 1, resolve = true })
if dir_list_err then
fail('Invalid directory ' .. path_to_desc(selected_hop.path))
return
end
-- Assuming that if I can fs.read_dir, then this will also succeed
ya.emit('cd', { selected_hop.path })
if config.notify then
info('Hopped to ' .. selected_hop.desc)
end
end
local attempt_hop = function(hops, config)
local cands = {}
for _, hop in pairs(create_special_hops(config)) do
table.insert(cands, { desc = hop.desc, on = hop.key, path = hop.path })
end
for _, hop in pairs(hops) do
table.insert(cands, { desc = hop.desc, on = hop.key, path = hop.path })
end
local hops_idx = ya.which { cands = cands }
if not hops_idx then return end
local selected_hop = cands[hops_idx]
-- Handle special hops
if selected_hop.path == '__MARK__' then
local mark_cands = {};
-- Ideally any unicode character would be supported, but yazi.which
-- requies a concrete list of candidates
local candidate_chars = {
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p',
'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F',
'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V',
'W', 'X', 'Y', 'Z', '`', '~', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')',
'-', '_', '=', '+', '[', '{', ']', '}', '\\', '|', ';', ':', "'", '"', ',', '<',
'.', '>', '/', '?', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'
}
for _, char in pairs(candidate_chars) do
table.insert(mark_cands, { on = char })
end
info('Press a key to create new hop')
local char_idx = ya.which { cands = mark_cands, silent = true }
if char_idx ~= nil then
local selected_char = mark_cands[char_idx].on
local cwd = get_cwd()
table.insert(hops, { key = selected_char, path = cwd, desc = path_to_desc(cwd, config.desc_strategy) })
set_state('hops', sort_hops(hops))
end
return
elseif selected_hop.path == '__FUZZY__' then
local fuzzy_hop = select_fuzzy(hops, config)
if not fuzzy_hop then return end
selected_hop = fuzzy_hop
end
cd(selected_hop, config)
end
local function init()
local options = get_state('options')
local err = validate_options(options)
if err then
set_state('init_error', err)
fail(err)
return
end
-- Set default config values
local desc_strategy = options.desc_strategy or 'path'
set_state('config', {
desc_strategy = desc_strategy,
fuzzy_cmd = some(options.fuzzy_cmd, 'fzf'),
notify = some(options.notify, false),
ephemeral = some(options.ephemeral, true),
tabs = some(options.tabs, true),
})
-- Set hops after ensuring they all have a description
local hops = {}
for _, hop in pairs(options.hops) do
hop.desc = hop.desc or path_to_desc(hop.path, desc_strategy)
-- Manually replace ~ from users so we can make a valid Url later to check if dir exists
if string.sub(hop.path, 1, 1) == '~' then
hop.path = os.getenv('HOME') .. hop.path:sub(2)
end
table.insert(hops, hop)
end
set_state('hops', sort_hops(hops))
set_state('init', true)
end
return {
setup = function(state, options)
state.options = options
ps.sub('cd', function(body)
if not body then return end
-- Note: This callback is sync and triggered at startup!
local tab = body.tab -- type number
if not tab then return end
-- Very important to turn this into a string because Url has ownership issues
-- when passed to standard utility functions >.<'
-- https://github.com/sxyazi/yazi/issues/2159
local cwd = tostring(cx.active.current.cwd)
-- Upon startup this will be nil so initialize if necessary
local tabhist = state.tabhist or {}
-- tabhist structure:{ <tab_index> = { <current_dir>, <previous_dir?> }, ... }
if not tabhist[tab] then
-- If fresh tab, initialize tab history table
tabhist[tab] = { cwd }
else
-- Otherwise, shift history table to the right and add cwd to the front
tabhist[tab] = { cwd, tabhist[tab][1] }
end
state.tabhist = tabhist
end)
end,
entry = function(_self, job)
if not get_state('init') then
init()
end
local init_error = get_state('init_error')
if init_error then
fail(init_error)
return
end
local hops, config = get_state('hops'), get_state('config')
if job.args[1] == 'fuzzy' then
local fuzzy_hop = select_fuzzy(hops, config)
if fuzzy_hop then
cd(fuzzy_hop, config)
end
else
attempt_hop(hops, config)
end
end,
}