406 lines
13 KiB
Lua
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,
|
|
}
|