--- @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 = '', desc = 'Create hop', path = '__MARK__' }) end table.insert(hops, { key = '', 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 = '', 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:{ = { , }, ... } 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, }