--- @since 25.5.28 local function debug(...) local function toReadableString(val) if type(val) == "table" then local str = "{ " for k, v in pairs(val) do str = str .. "[" .. tostring(k) .. "] = " .. toReadableString(v) .. ", " end return str .. "}" elseif type(val) == "Url" then return "Url:" .. tostring(val) else return tostring(val) end end local args = { ... } local processed_args = {} for _, arg in pairs(args) do table.insert(processed_args, toReadableString(arg)) 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) local permit = ui.hide() local child, spawn_err = Command(config.fuzzy_cmd):stdin(Command.PIPED):stdout(Command.PIPED):stderr(Command.INHERIT):spawn() if not child then fail("Command `%s` failed with code %s. Do you have it installed?", config.fuzzy_cmd, spawn_err.code) 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")) child:flush() local output, output_err = child:wait_with_output() permit:drop() if 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_err.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) -- Note: This callback is sync and triggered at startup! local tab = body.tab -- type number -- 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, }