Files
dotfiles_arch/yazi/.config/yazi/plugins/whoosh.yazi/main.lua
2026-01-07 15:00:47 +01:00

1743 lines
50 KiB
Lua

-- @since 25.5.28 this is a test
local path_sep = package.config:sub(1, 1)
local DEFAULT_SPECIAL_KEYS = {
create_temp = "<Enter>",
fuzzy_search = "<Space>",
history = "<Tab>",
previous_dir = "<Backspace>",
}
local function get_fzf_delimiter()
if ya.target_family() == "windows" then
return "--delimiter=\\t"
else
return "--delimiter='\t'"
end
end
local get_hovered_path = ya.sync(function(state)
local h = cx.active.current.hovered
if h then
local path = tostring(h.url)
if h.cha.is_dir then
if ya.target_family() == "windows" and path:match("^[A-Za-z]:$") then
return path .. "\\"
end
return path
end
return path
else
return ""
end
end)
local is_hovered_directory = ya.sync(function(state)
local h = cx.active.current.hovered
if h then
return h.cha.is_dir
end
return false
end)
local get_current_dir_path = ya.sync(function()
local path = tostring(cx.active.current.cwd)
if ya.target_family() == "windows" and path:match("^[A-Za-z]:$") then
return path .. "\\"
end
return path
end)
local get_state_attr = ya.sync(function(state, attr)
return state[attr]
end)
local set_state_attr = ya.sync(function(state, attr, value)
state[attr] = value
end)
local set_bookmarks = ya.sync(function(state, path, value)
state.bookmarks[path] = value
end)
local set_temp_bookmarks = ya.sync(function(state, path, value)
state.temp_bookmarks[path] = value
end)
local get_temp_bookmarks = ya.sync(function(state)
return state.temp_bookmarks
end)
local get_current_tab_idx = ya.sync(function(state)
return cx.tabs.idx
end)
local get_directory_history = ya.sync(function(state)
return state.directory_history
end)
local add_to_history = ya.sync(function(state, tab_idx, path)
if not state.directory_history[tab_idx] then
state.directory_history[tab_idx] = {}
end
local history = state.directory_history[tab_idx]
local history_size = state.history_size or 10
for i = #history, 1, -1 do
if history[i] == path then
table.remove(history, i)
end
end
table.insert(history, 1, path)
while #history > history_size do
table.remove(history, #history)
end
end)
local get_tab_history = ya.sync(function(state, tab_idx)
return state.directory_history[tab_idx] or {}
end)
local function ensure_directory(path)
local dir_path = path:match("(.+)[\\/][^\\/]*$")
if not dir_path then
return
end
if ya.target_family() == "windows" then
os.execute('mkdir "' .. dir_path:gsub("/", "\\") .. '" 2>nul')
else
os.execute('mkdir -p "' .. dir_path .. '"')
end
end
local function normalize_path(path)
local normalized_path = tostring(path):gsub("[\\/]+", path_sep)
if ya.target_family() == "windows" then
if normalized_path:match("^[A-Za-z]:[\\/]*$") then
normalized_path = normalized_path:gsub("^([A-Za-z]:)[\\/]*", "%1\\")
else
normalized_path = normalized_path:gsub("^([A-Za-z]:)[\\/]+", "%1\\")
normalized_path = normalized_path:gsub("[\\/]+$", "")
end
else
if normalized_path ~= "/" then
normalized_path = normalized_path:gsub("[\\/]+$", "")
end
end
return normalized_path
end
local function apply_home_alias(path)
if not path or path == "" then
return path
end
local home_alias_enabled = get_state_attr("home_alias_enabled")
if home_alias_enabled == false then
return path
end
if path:sub(1, 1) == "~" then
return path
end
local home = os.getenv("HOME")
if ya.target_family() == "windows" and (not home or home == "") then
home = os.getenv("USERPROFILE")
end
if not home or home == "" then
return path
end
local normalized_home = normalize_path(home)
if not normalized_home or normalized_home == "" then
return path
end
local sep = path_sep
if ya.target_family() == "windows" then
local path_lower = path:lower()
local home_lower = normalized_home:lower()
if path_lower == home_lower then
return "~"
end
local prefix_lower = (normalized_home .. sep):lower()
if path_lower:sub(1, #prefix_lower) == prefix_lower then
return "~" .. path:sub(#normalized_home + 1)
end
else
if path == normalized_home then
return "~"
end
local prefix = normalized_home .. sep
if path:sub(1, #prefix) == prefix then
return "~" .. path:sub(#normalized_home + 1)
end
end
return path
end
local function normalize_special_key(value, default)
if value == nil then
return default
end
if value == false then
return nil
end
if type(value) == "string" then
local trimmed = value:gsub("^%s*(.-)%s*$", "%1")
if trimmed == "" then
return nil
end
return trimmed
end
if type(value) == "table" then
local seq = {}
for _, item in ipairs(value) do
if type(item) == "string" then
local trimmed = item:gsub("^%s*(.-)%s*$", "%1")
if trimmed ~= "" then
table.insert(seq, trimmed)
end
end
end
if #seq == 0 then
return nil
end
return seq
end
return default
end
local function truncate_long_folder_names(path, max_folder_length)
if not max_folder_length or max_folder_length <= 0 then
return path
end
local separator = ya.target_family() == "windows" and "\\" or "/"
local parts = {}
for part in path:gmatch("[^" .. separator .. "]+") do
if #part > max_folder_length then
local keep_length = math.max(3, math.floor(max_folder_length * 0.4))
local truncated = part:sub(1, keep_length) .. "..."
table.insert(parts, truncated)
else
table.insert(parts, part)
end
end
local result = table.concat(parts, separator)
if path:sub(1, 1) == separator then
result = separator .. result
end
return result
end
local function truncate_path(path, max_parts)
max_parts = max_parts or 3
local normalized_path = normalize_path(path)
normalized_path = apply_home_alias(normalized_path)
local parts = {}
local separator = ya.target_family() == "windows" and "\\" or "/"
if ya.target_family() == "windows" then
local drive, rest = normalized_path:match("^([A-Za-z]:\\)(.*)$")
if drive then
table.insert(parts, drive)
if rest and rest ~= "" then
for part in rest:gmatch("[^\\]+") do
table.insert(parts, part)
end
end
else
for part in normalized_path:gmatch("[^\\]+") do
table.insert(parts, part)
end
end
else
if normalized_path:sub(1, 1) == "/" then
table.insert(parts, "/")
local rest = normalized_path:sub(2)
if rest ~= "" then
for part in rest:gmatch("[^/]+") do
table.insert(parts, part)
end
end
elseif normalized_path:sub(1, 1) == "~" then
table.insert(parts, "~")
local rest = normalized_path:sub(2)
if rest:sub(1, 1) == "/" then
rest = rest:sub(2)
end
if rest ~= "" then
for part in rest:gmatch("[^/]+") do
table.insert(parts, part)
end
end
else
for part in normalized_path:gmatch("[^/]+") do
table.insert(parts, part)
end
end
end
if #parts > max_parts then
local result_parts = {}
local first_part = parts[1]
if ya.target_family() == "windows" and first_part:match("^[A-Za-z]:\\$") then
first_part = first_part:sub(1, -2)
end
if ya.target_family() ~= "windows" and first_part == "/" then
table.insert(result_parts, "")
else
table.insert(result_parts, first_part)
end
table.insert(result_parts, "")
for i = #parts - max_parts + 2, #parts do
table.insert(result_parts, parts[i])
end
local out = table.concat(result_parts, separator)
if ya.target_family() ~= "windows" then
out = out:gsub("^//+", "/")
end
return out
else
return normalized_path
end
end
local function path_to_desc(path)
local path_truncate_enabled = get_state_attr("path_truncate_enabled")
local result_path = apply_home_alias(normalize_path(path))
local path_truncate_long_names_enabled = get_state_attr("path_truncate_long_names_enabled")
if path_truncate_long_names_enabled == true then
local max_folder_length = get_state_attr("path_max_folder_name_length") or 20
result_path = truncate_long_folder_names(result_path, max_folder_length)
end
if path_truncate_enabled == true then
local max_depth = get_state_attr("path_max_depth") or 3
result_path = truncate_path(result_path, max_depth)
end
return result_path
end
local function get_display_width(str)
return ui.Line(str):width()
end
local function truncate_long_folder_names(path, max_folder_length)
if not max_folder_length or max_folder_length <= 0 then
return path
end
local separator = ya.target_family() == "windows" and "\\" or "/"
local parts = {}
local is_windows = ya.target_family() == "windows"
if is_windows then
local drive, rest = path:match("^([A-Za-z]:\\)(.*)$")
if drive then
table.insert(parts, drive:sub(1, -2))
if rest and rest ~= "" then
for part in rest:gmatch("[^\\]+") do
if #part > max_folder_length then
local keep_length = math.max(3, math.floor(max_folder_length * 0.4))
local truncated = part:sub(1, keep_length) .. "..."
table.insert(parts, truncated)
else
table.insert(parts, part)
end
end
end
return table.concat(parts, separator)
end
end
for part in path:gmatch("[^" .. (separator == "\\" and "\\\\" or separator) .. "]+") do
if #part > max_folder_length then
local keep_length = math.max(3, math.floor(max_folder_length * 0.4))
local truncated = part:sub(1, keep_length) .. "..."
table.insert(parts, truncated)
else
table.insert(parts, part)
end
end
local result = table.concat(parts, separator)
if path:sub(1, 1) == separator then
result = separator .. result
end
return result
end
local function path_to_desc_for_fzf(path)
local fzf_path_truncate_enabled = get_state_attr("fzf_path_truncate_enabled")
local result_path = apply_home_alias(normalize_path(path))
local fzf_path_truncate_long_names_enabled = get_state_attr("fzf_path_truncate_long_names_enabled")
if fzf_path_truncate_long_names_enabled == true then
local max_folder_length = get_state_attr("fzf_path_max_folder_name_length") or 20
result_path = truncate_long_folder_names(result_path, max_folder_length)
end
if fzf_path_truncate_enabled == true then
local max_depth = get_state_attr("fzf_path_max_depth") or 5
result_path = truncate_path(result_path, max_depth)
end
return result_path
end
local function path_to_desc_for_history(path)
local history_fzf_path_truncate_enabled = get_state_attr("history_fzf_path_truncate_enabled")
local result_path = apply_home_alias(normalize_path(path))
local history_fzf_path_truncate_long_names_enabled = get_state_attr("history_fzf_path_truncate_long_names_enabled")
if history_fzf_path_truncate_long_names_enabled == true then
local max_folder_length = get_state_attr("history_fzf_path_max_folder_name_length") or 30
result_path = truncate_long_folder_names(result_path, max_folder_length)
end
if history_fzf_path_truncate_enabled == true then
local max_depth = get_state_attr("history_fzf_path_max_depth") or 5
result_path = truncate_path(result_path, max_depth)
end
return result_path
end
local function format_bookmark_for_menu(tag, key)
return tag
end
local function format_bookmark_for_fzf(tag, path, key, max_tag_width, max_path_width)
local tag_width = math.max(max_tag_width, 15)
local path_width = math.max(max_path_width or 30, 30)
local formatted_tag = tag
local tag_display_width = get_display_width(tag)
if tag_display_width > tag_width then
formatted_tag = tag:sub(1, tag_width - 3) .. "..."
else
formatted_tag = tag .. string.rep(" ", tag_width - tag_display_width)
end
local display_path = path_to_desc_for_fzf(path)
local formatted_path = display_path
local path_display_width = get_display_width(display_path)
if path_display_width > path_width then
formatted_path = display_path:sub(1, path_width - 3) .. "..."
else
formatted_path = display_path .. string.rep(" ", path_width - path_display_width)
end
local key_display = ""
if key then
if type(key) == "table" then
key_display = table.concat(key, ",")
elseif type(key) == "string" and #key > 0 then
key_display = key
else
key_display = tostring(key)
end
end
return formatted_tag .. " " .. formatted_path .. " " .. key_display
end
local function sort_bookmarks(bookmarks, key1, key2, reverse)
reverse = reverse or false
table.sort(bookmarks, function(x, y)
if not x or not y then
return false
end
local x_key1, y_key1 = x[key1], y[key1]
local x_key2, y_key2 = x[key2], y[key2]
if x_key1 == nil and y_key1 == nil then
if x_key2 == nil and y_key2 == nil then
return false
elseif x_key2 == nil then
return false
elseif y_key2 == nil then
return true
else
return tostring(x_key2) < tostring(y_key2)
end
elseif x_key1 == nil then
return false
elseif y_key1 == nil then
return true
else
return tostring(x_key1) < tostring(y_key1)
end
end)
if reverse then
local n = #bookmarks
for i = 1, math.floor(n / 2) do
bookmarks[i], bookmarks[n - i + 1] = bookmarks[n - i + 1], bookmarks[i]
end
end
return bookmarks
end
local action_save, action_jump, action_delete, which_find, fzf_find, fzf_find_for_rename, fzf_history
local function get_all_bookmarks()
local all_b = {}
local config_b = get_state_attr("config_bookmarks")
local user_b = get_state_attr("bookmarks")
for path, item in pairs(config_b) do
all_b[path] = item
end
for path, item in pairs(user_b) do
all_b[path] = item
end
return all_b
end
local function serialize_key_for_file(key)
if type(key) == "table" then
return table.concat(key, ",")
elseif type(key) == "string" then
return key
else
return tostring(key)
end
end
local function deserialize_key_from_file(key_str)
if not key_str or key_str == "" then
return ""
end
key_str = key_str:gsub("^%s*(.-)%s*$", "%1")
if key_str == "" then
return ""
end
if key_str:find(",") then
local seq = {}
for token in key_str:gmatch("[^,%s]+") do
token = token:gsub("^%s*(.-)%s*$", "%1")
if token ~= "" then
if token:match("^<.->$") then
table.insert(seq, token)
else
for _, cp in utf8.codes(token) do
table.insert(seq, utf8.char(cp))
end
end
end
end
return seq
end
if key_str:match("^<.->$") then
return key_str
end
if utf8.len(key_str) > 1 then
local seq = {}
for _, cp in utf8.codes(key_str) do
table.insert(seq, utf8.char(cp))
end
return seq
else
return key_str
end
end
local save_to_file = function(mb_path, bookmarks)
ensure_directory(mb_path)
local file = io.open(mb_path, "w")
if file == nil then
ya.notify({
title = "Bookmarks Error",
content = "Cannot create bookmark file: " .. mb_path,
timeout = 2,
level = "error",
})
return
end
local array = {}
for _, item in pairs(bookmarks) do
table.insert(array, item)
end
sort_bookmarks(array, "tag", "key", true)
for _, item in ipairs(array) do
local serialized_key = serialize_key_for_file(item.key)
file:write(string.format("%s\t%s\t%s\n", item.tag, item.path, serialized_key))
end
file:close()
end
fzf_find = function()
local mb_path = get_state_attr("path")
local temp_bookmarks = get_temp_bookmarks()
local permit = ui.hide()
local temp_file_path = nil
local cmd
local all_perm_bookmarks = get_all_bookmarks()
temp_file_path = os.tmpname()
local temp_file = io.open(temp_file_path, "w")
if temp_file then
local all_fzf_items = {}
local max_tag_width = 0
local max_path_width = 0
if temp_bookmarks and next(temp_bookmarks) then
local temp_array = {}
for _, item in pairs(temp_bookmarks) do
if item and item.tag and item.path and item.key then
table.insert(temp_array, item)
end
end
sort_bookmarks(temp_array, "tag", "key", true)
for _, item in ipairs(temp_array) do
local tag_with_prefix = "[TEMP] " .. item.tag
local display_path = path_to_desc_for_fzf(item.path)
table.insert(all_fzf_items, { tag = tag_with_prefix, path = item.path, key = item.key or "" })
max_tag_width = math.max(max_tag_width, get_display_width(tag_with_prefix))
max_path_width = math.max(max_path_width, get_display_width(display_path))
end
end
if all_perm_bookmarks and next(all_perm_bookmarks) then
local perm_array = {}
for _, item in pairs(all_perm_bookmarks) do
table.insert(perm_array, item)
end
sort_bookmarks(perm_array, "tag", "key", true)
for _, item in ipairs(perm_array) do
local display_path = path_to_desc_for_fzf(item.path)
table.insert(all_fzf_items, { tag = item.tag, path = item.path, key = item.key or "" })
max_tag_width = math.max(max_tag_width, get_display_width(item.tag))
max_path_width = math.max(max_path_width, get_display_width(display_path))
end
end
if #all_fzf_items > 0 then
for _, item in ipairs(all_fzf_items) do
local formatted_line = format_bookmark_for_fzf(item.tag, item.path, item.key, max_tag_width, max_path_width)
temp_file:write(formatted_line .. "\t" .. item.path .. "\n")
end
temp_file:close()
cmd = string.format('fzf %s --with-nth=1 --prompt="Search > " < "%s"', get_fzf_delimiter(), temp_file_path)
else
temp_file:close()
cmd = 'echo No bookmarks found | fzf --prompt="Search > "'
end
else
cmd = 'echo No bookmarks found | fzf --prompt="Search > "'
end
local handle = io.popen(cmd, "r")
local result = ""
if handle then
result = string.gsub(handle:read("*all") or "", "^%s*(.-)%s*$", "%1")
handle:close()
end
if temp_file_path then
os.remove(temp_file_path)
end
permit:drop()
if result and result ~= "" and result ~= "No bookmarks found" then
local tab_pos = result:find("\t")
if tab_pos then
return result:sub(tab_pos + 1)
end
end
return nil
end
fzf_find_for_rename = function()
local mb_path = get_state_attr("path")
local temp_bookmarks = get_temp_bookmarks()
local permit = ui.hide()
local temp_file_path = nil
local cmd
local all_perm_bookmarks = get_all_bookmarks()
temp_file_path = os.tmpname()
local temp_file = io.open(temp_file_path, "w")
if temp_file then
local all_fzf_items = {}
local max_tag_width = 0
local max_path_width = 0
if temp_bookmarks and next(temp_bookmarks) then
local temp_array = {}
for _, item in pairs(temp_bookmarks) do
if item and item.tag and item.path and item.key then
table.insert(temp_array, item)
end
end
sort_bookmarks(temp_array, "tag", "key", true)
for _, item in ipairs(temp_array) do
local tag_with_prefix = "[TEMP] " .. item.tag
local display_path = path_to_desc_for_fzf(item.path)
table.insert(all_fzf_items, { tag = tag_with_prefix, path = item.path, key = item.key or "" })
max_tag_width = math.max(max_tag_width, get_display_width(tag_with_prefix))
max_path_width = math.max(max_path_width, get_display_width(display_path))
end
end
if all_perm_bookmarks and next(all_perm_bookmarks) then
local perm_array = {}
for _, item in pairs(all_perm_bookmarks) do
table.insert(perm_array, item)
end
sort_bookmarks(perm_array, "tag", "key", true)
for _, item in ipairs(perm_array) do
local display_path = path_to_desc_for_fzf(item.path)
table.insert(all_fzf_items, { tag = item.tag, path = item.path, key = item.key or "" })
max_tag_width = math.max(max_tag_width, get_display_width(item.tag))
max_path_width = math.max(max_path_width, get_display_width(display_path))
end
end
if #all_fzf_items > 0 then
for _, item in ipairs(all_fzf_items) do
local formatted_line = format_bookmark_for_fzf(item.tag, item.path, item.key, max_tag_width, max_path_width)
temp_file:write(formatted_line .. "\t" .. item.path .. "\n")
end
temp_file:close()
cmd = string.format('fzf %s --with-nth=1 --prompt="Rename > " < "%s"', get_fzf_delimiter(), temp_file_path)
else
temp_file:close()
cmd = 'echo No bookmarks found | fzf --prompt="Rename > "'
end
else
cmd = 'echo No bookmarks found | fzf --prompt="Rename > "'
end
local handle = io.popen(cmd, "r")
local result = ""
if handle then
result = string.gsub(handle:read("*all") or "", "^%s*(.-)%s*$", "%1")
handle:close()
end
if temp_file_path then
os.remove(temp_file_path)
end
permit:drop()
if result and result ~= "" and result ~= "No bookmarks found" then
local tab_pos = result:find("\t")
if tab_pos then
return result:sub(tab_pos + 1)
end
end
return nil
end
fzf_find_multi = function()
local temp_bookmarks = get_temp_bookmarks()
local user_bookmarks = get_state_attr("bookmarks")
local permit = ui.hide()
local temp_file_path = nil
local cmd
temp_file_path = os.tmpname()
local temp_file = io.open(temp_file_path, "w")
if temp_file then
local all_fzf_items = {}
local max_tag_width = 0
local max_path_width = 0
if temp_bookmarks and next(temp_bookmarks) then
local temp_array = {}
for _, item in pairs(temp_bookmarks) do
if item and item.tag and item.path and item.key then
table.insert(temp_array, item)
end
end
sort_bookmarks(temp_array, "tag", "key", true)
for _, item in ipairs(temp_array) do
local tag_with_prefix = "[TEMP] " .. item.tag
local display_path = path_to_desc_for_fzf(item.path)
table.insert(all_fzf_items, { tag = tag_with_prefix, path = item.path, key = item.key or "" })
max_tag_width = math.max(max_tag_width, get_display_width(tag_with_prefix))
max_path_width = math.max(max_path_width, get_display_width(display_path))
end
end
if user_bookmarks and next(user_bookmarks) then
local user_array = {}
for _, item in pairs(user_bookmarks) do
table.insert(user_array, item)
end
sort_bookmarks(user_array, "tag", "key", true)
for _, item in ipairs(user_array) do
local display_path = path_to_desc_for_fzf(item.path)
table.insert(all_fzf_items, { tag = item.tag, path = item.path, key = item.key or "" })
max_tag_width = math.max(max_tag_width, get_display_width(item.tag))
max_path_width = math.max(max_path_width, get_display_width(display_path))
end
end
if #all_fzf_items > 0 then
for _, item in ipairs(all_fzf_items) do
local formatted_line = format_bookmark_for_fzf(item.tag, item.path, item.key, max_tag_width, max_path_width)
temp_file:write(formatted_line .. "\t" .. item.path .. "\n")
end
temp_file:close()
cmd =
string.format('fzf --multi %s --with-nth=1 --prompt="Delete > " < "%s"', get_fzf_delimiter(), temp_file_path)
else
temp_file:close()
cmd = 'echo No deletable bookmarks found | fzf --prompt="Delete > "'
end
else
cmd = 'echo No deletable bookmarks found | fzf --prompt="Delete > "'
end
local handle = io.popen(cmd, "r")
local result = ""
if handle then
result = handle:read("*all") or ""
handle:close()
end
if temp_file_path then
os.remove(temp_file_path)
end
permit:drop()
if result and result ~= "" and result ~= "No deletable bookmarks found" then
local paths = {}
for line in result:gmatch("[^\r\n]+") do
line = string.gsub(line, "^%s*(.-)%s*$", "%1")
if line ~= "" then
local tab_pos = line:find("\t")
if tab_pos then
table.insert(paths, line:sub(tab_pos + 1))
end
end
end
return paths
end
return {}
end
fzf_history = function()
local current_tab = get_current_tab_idx()
local history = get_tab_history(current_tab)
local current_path = normalize_path(get_current_dir_path())
local filtered_history = {}
if history then
for _, path in ipairs(history) do
if path ~= current_path then
table.insert(filtered_history, path)
end
end
end
if not filtered_history or #filtered_history == 0 then
return nil
end
local permit = ui.hide()
local temp_file_path = os.tmpname()
local temp_file = io.open(temp_file_path, "w")
if temp_file then
for i, path in ipairs(filtered_history) do
local display_path = path_to_desc_for_history(path)
local formatted_line = string.format("%2d. %s", i, display_path)
temp_file:write(formatted_line .. "\t" .. path .. "\n")
end
temp_file:close()
local cmd = string.format('fzf %s --with-nth=1 --prompt="History > " < "%s"', get_fzf_delimiter(), temp_file_path)
local handle = io.popen(cmd, "r")
local result = ""
if handle then
result = string.gsub(handle:read("*all") or "", "^%s*(.-)%s*$", "%1")
handle:close()
end
os.remove(temp_file_path)
permit:drop()
if result and result ~= "" then
local tab_pos = result:find("\t")
if tab_pos then
return result:sub(tab_pos + 1)
end
end
else
permit:drop()
end
return nil
end
local create_special_menu_items = function()
local special_items = {}
local special_keys = get_state_attr("special_keys") or DEFAULT_SPECIAL_KEYS
local create_temp_key = special_keys.create_temp
if create_temp_key then
table.insert(special_items, { desc = "Create temporary bookmark", on = create_temp_key, path = "__CREATE_TEMP__" })
end
local fuzzy_search_key = special_keys.fuzzy_search
if fuzzy_search_key then
table.insert(special_items, { desc = "Fuzzy search", on = fuzzy_search_key, path = "__FUZZY_SEARCH__" })
end
local current_tab = get_current_tab_idx()
local history = get_tab_history(current_tab)
local current_path = normalize_path(get_current_dir_path())
local filtered_history = {}
if history then
for _, path in ipairs(history) do
if path ~= current_path then
table.insert(filtered_history, path)
end
end
end
local history_key = special_keys.history
if history_key and filtered_history and #filtered_history > 0 then
table.insert(special_items, { desc = "Directory history", on = history_key, path = "__HISTORY__" })
end
local previous_dir_key = special_keys.previous_dir
if previous_dir_key and filtered_history and filtered_history[1] then
local previous_dir = filtered_history[1]
local display_path = path_to_desc(previous_dir)
table.insert(special_items, { desc = "<- " .. display_path, on = previous_dir_key, path = previous_dir })
end
return special_items
end
which_find = function()
local bookmarks = get_all_bookmarks()
local temp_bookmarks = get_temp_bookmarks()
local cands_static = create_special_menu_items()
local cands_bookmarks = {}
local all_bookmark_items = {}
local max_tag_width = 0
local max_path_width = 0
if temp_bookmarks then
for path, item in pairs(temp_bookmarks) do
if item and item.tag and #item.tag ~= 0 then
local tag_with_prefix = "[TEMP] " .. item.tag
local display_path = path_to_desc(item.path or path)
table.insert(
all_bookmark_items,
{ tag = tag_with_prefix, path = item.path or path, key = item.key or "", is_temp = true }
)
max_tag_width = math.max(max_tag_width, get_display_width(tag_with_prefix))
max_path_width = math.max(max_path_width, get_display_width(display_path))
end
end
end
for path, item in pairs(bookmarks) do
if item and item.tag and #item.tag ~= 0 then
local display_path = path_to_desc(item.path or path)
table.insert(
all_bookmark_items,
{ tag = item.tag, path = item.path or path, key = item.key or "", is_temp = false }
)
max_tag_width = math.max(max_tag_width, get_display_width(item.tag))
max_path_width = math.max(max_path_width, get_display_width(display_path))
end
end
for _, item in ipairs(all_bookmark_items) do
if
item.key
and item.key ~= ""
and (type(item.key) == "string" or (type(item.key) == "table" and #item.key > 0))
then
local formatted_desc = format_bookmark_for_menu(item.tag, item.key)
table.insert(cands_bookmarks, { desc = formatted_desc, on = item.key, path = item.path })
end
end
sort_bookmarks(cands_bookmarks, "on", "desc", false)
local cands = {}
for _, item in ipairs(cands_static) do
table.insert(cands, item)
end
for _, item in ipairs(cands_bookmarks) do
table.insert(cands, item)
end
if #cands == #cands_static and #cands_bookmarks == 0 then
ya.notify({ title = "Bookmarks", content = "No bookmarks found", timeout = 1, level = "info" })
end
local idx = ya.which({ cands = cands })
if idx == nil then
return nil
end
return cands[idx].path
end
which_find_deletable = function()
local user_bookmarks = get_state_attr("bookmarks")
local temp_bookmarks = get_temp_bookmarks()
local cands_bookmarks = {}
local all_bookmark_items = {}
if temp_bookmarks then
for path, item in pairs(temp_bookmarks) do
if item and item.tag and #item.tag ~= 0 then
local tag_with_prefix = "[TEMP] " .. item.tag
table.insert(
all_bookmark_items,
{ tag = tag_with_prefix, path = item.path or path, key = item.key or "", is_temp = true }
)
end
end
end
if user_bookmarks then
for path, item in pairs(user_bookmarks) do
if item and item.tag and #item.tag ~= 0 then
table.insert(
all_bookmark_items,
{ tag = item.tag, path = item.path or path, key = item.key or "", is_temp = false }
)
end
end
end
for _, item in ipairs(all_bookmark_items) do
if
item.key
and item.key ~= ""
and (type(item.key) == "string" or (type(item.key) == "table" and #item.key > 0))
then
local formatted_desc = format_bookmark_for_menu(item.tag, item.key)
table.insert(cands_bookmarks, { desc = formatted_desc, on = item.key, path = item.path })
end
end
sort_bookmarks(cands_bookmarks, "on", "desc", false)
if #cands_bookmarks == 0 then
ya.notify({ title = "Bookmarks", content = "No deletable bookmarks found", timeout = 1, level = "info" })
return nil
end
local idx = ya.which({ cands = cands_bookmarks })
if idx == nil then
return nil
end
return cands_bookmarks[idx].path
end
action_jump = function(path)
if path == nil then
return
end
local jump_notify = get_state_attr("jump_notify")
local all_bookmarks = get_all_bookmarks()
local temp_bookmarks = get_temp_bookmarks()
if path == "__CREATE_TEMP__" then
action_save(get_current_dir_path(), true)
return
elseif path == "__FUZZY_SEARCH__" then
local selected_path = fzf_find()
if selected_path then
action_jump(selected_path)
end
return
elseif path == "__HISTORY__" then
local selected_path = fzf_history()
if selected_path then
action_jump(selected_path)
end
return
end
local bookmark = temp_bookmarks[path] or all_bookmarks[path]
if not bookmark then
ya.emit("cd", { path })
if jump_notify then
ya.notify({ title = "Bookmarks", content = 'Jump to "' .. path_to_desc(path) .. '"', timeout = 1, level = "info" })
end
return
end
local tag = bookmark.tag
local is_temp = temp_bookmarks[path] ~= nil
ya.emit("cd", { path })
if jump_notify then
local prefix = is_temp and "[TEMP] " or ""
ya.notify({ title = "Bookmarks", content = 'Jump to "' .. prefix .. tag .. '"', timeout = 1, level = "info" })
end
end
local function parse_keys_input(input)
if not input or input == "" then
return {}
end
local seq = {}
for token in input:gmatch("[^,%s]+") do
token = token:gsub("^%s*(.-)%s*$", "%1")
if token ~= "" then
if token:match("^<.->$") then
table.insert(seq, token)
else
for _, cp in utf8.codes(token) do
table.insert(seq, utf8.char(cp))
end
end
end
end
return seq
end
local function format_keys_for_display(keys)
if type(keys) == "table" then
return table.concat(keys, ",")
elseif type(keys) == "string" then
return keys
else
return ""
end
end
local function _seq_from_key(k)
if type(k) == "table" then
local out = {}
for _, t in ipairs(k) do
if t:match("^<.->$") then
table.insert(out, t)
else
for _, cp in utf8.codes(t) do
table.insert(out, utf8.char(cp))
end
end
end
return out
elseif type(k) == "string" then
return parse_keys_input(k)
else
return {}
end
end
local function _seq_equal(a, b)
if #a ~= #b then
return false
end
for i = 1, #a do
if a[i] ~= b[i] then
return false
end
end
return true
end
local function _seq_is_prefix(short, long)
if #short >= #long then
return false
end
for i = 1, #short do
if short[i] ~= long[i] then
return false
end
end
return true
end
local function _seq_to_string(seq)
return table.concat(seq, ",")
end
local function find_path_by_key_sequence(seq)
if not seq or #seq == 0 then
return nil
end
local function matches(candidate)
if candidate == nil or candidate == "" then
return false
end
local candidate_seq = _seq_from_key(candidate)
if #candidate_seq == 0 then
return false
end
return _seq_equal(seq, candidate_seq)
end
for _, item in ipairs(create_special_menu_items() or {}) do
if matches(item.on) then
return item.path
end
end
local temp = get_temp_bookmarks()
for path, item in pairs(temp or {}) do
if matches(item.key) then
return path
end
end
local bookmarks = get_all_bookmarks()
for path, item in pairs(bookmarks or {}) do
if matches(item.key) then
return path
end
end
return nil
end
local function jump_by_key_spec(spec)
local cleaned = (spec or ""):gsub("^%s*(.-)%s*$", "%1")
if cleaned == "" then
ya.notify({ title = "Bookmarks", content = "Missing key sequence", timeout = 1, level = "warn" })
return false
end
local seq = parse_keys_input(cleaned)
if #seq == 0 then
ya.notify({ title = "Bookmarks", content = "Missing key sequence", timeout = 1, level = "warn" })
return false
end
local path = find_path_by_key_sequence(seq)
if not path then
ya.notify({
title = "Bookmarks",
content = "Bookmark not found for key: " .. _seq_to_string(seq),
timeout = 1,
level = "info",
})
return false
end
action_jump(path)
return true
end
local generate_key = function()
local keys = get_state_attr("keys")
local key2rank = get_state_attr("key2rank")
local bookmarks = get_all_bookmarks()
local temp_bookmarks = get_temp_bookmarks()
local mb = {}
for _, item in pairs(bookmarks) do
if item and item.key then
if type(item.key) == "string" and #item.key == 1 then
table.insert(mb, item.key)
elseif type(item.key) == "table" then
for _, k in ipairs(item.key) do
if type(k) == "string" and #k == 1 then
table.insert(mb, k)
end
end
end
end
end
if temp_bookmarks then
for _, item in pairs(temp_bookmarks) do
if item and item.key then
if type(item.key) == "string" and #item.key == 1 then
table.insert(mb, item.key)
elseif type(item.key) == "table" then
for _, k in ipairs(item.key) do
if type(k) == "string" and #k == 1 then
table.insert(mb, k)
end
end
end
end
end
end
if #mb == 0 then
return keys[1]
end
table.sort(mb, function(a, b)
return (key2rank[a] or 999) < (key2rank[b] or 999)
end)
local idx = 1
for _, key in ipairs(keys) do
if idx > #mb or (key2rank[key] or 999) < (key2rank[mb[idx]] or 999) then
return key
end
idx = idx + 1
end
return nil
end
action_save = function(path, is_temp)
if path == nil or #path == 0 then
return
end
local mb_path = get_state_attr("path")
local all_bookmarks = get_all_bookmarks()
local temp_bookmarks = get_temp_bookmarks()
local path_obj
if is_temp and temp_bookmarks and temp_bookmarks[path] then
path_obj = temp_bookmarks[path]
else
path_obj = all_bookmarks[path] or (temp_bookmarks and temp_bookmarks[path])
end
local tag = path_obj and path_obj.tag or path:match(".*[\\/]([^\\/]+)[\\/]?$")
while true do
local title = is_temp and "Tag ⟨alias name⟩ [TEMPORARY]" or "Tag ⟨alias name⟩"
local value, event = ya.input({ title = title, value = tag, pos = { "top-center", y = 3, w = 40 } })
if event ~= 1 then
return
end
tag = value or ""
if #tag == 0 then
ya.notify({ title = "Bookmarks", content = "Empty tag", timeout = 1, level = "info" })
else
local tag_obj = nil
for _, item in pairs(all_bookmarks) do
if item.tag == tag then
tag_obj = item
break
end
end
if not tag_obj and temp_bookmarks then
for _, item in pairs(temp_bookmarks) do
if item.tag == tag then
tag_obj = item
break
end
end
end
if tag_obj == nil or tag_obj.path == path then
break
end
ya.notify({ title = "Bookmarks", content = "Duplicated tag", timeout = 1, level = "info" })
end
end
local key = path_obj and path_obj.key or generate_key()
local key_display = format_keys_for_display(key)
while true do
local value, event = ya.input({
title = "Keys ⟨space, comma or empty separator⟩",
value = key_display,
pos = { "top-center", y = 3, w = 50 },
})
if event ~= 1 then
return
end
local input_str = value or ""
if input_str == "" then
key = ""
break
end
local parsed_keys = parse_keys_input(input_str)
if #parsed_keys == 0 then
key = ""
break
elseif #parsed_keys == 1 then
key = parsed_keys[1]
else
key = parsed_keys
end
local new_seq = _seq_from_key(key)
local conflict, conflict_seq
local function check(items)
for _, item in pairs(items or {}) do
if item and item.key and item.path ~= path then
local exist = _seq_from_key(item.key)
if #exist > 0 then
if _seq_equal(new_seq, exist) then
conflict, conflict_seq = "duplicate", exist
return true
end
if _seq_is_prefix(new_seq, exist) or _seq_is_prefix(exist, new_seq) then
conflict, conflict_seq = "prefix", exist
return true
end
end
end
end
return false
end
if check(all_bookmarks) or check(temp_bookmarks) then
local msg = (conflict == "duplicate") and ("Duplicated key sequence: " .. _seq_to_string(new_seq))
or ("Ambiguous with existing sequence: " .. _seq_to_string(conflict_seq))
ya.notify({ title = "Bookmarks", content = msg, timeout = 2, level = "info" })
key_display = input_str
else
break
end
end
if is_temp then
set_temp_bookmarks(path, { tag = tag, path = path, key = key })
ya.notify({ title = "Bookmarks", content = '[TEMP] "' .. tag .. '" saved', timeout = 1, level = "info" })
else
set_bookmarks(path, { tag = tag, path = path, key = key })
local user_bookmarks = get_state_attr("bookmarks")
save_to_file(mb_path, user_bookmarks)
ya.notify({ title = "Bookmarks", content = '"' .. tag .. '" saved', timeout = 1, level = "info" })
end
end
action_delete = function(path)
if path == nil then
return
end
local mb_path = get_state_attr("path")
local user_bookmarks = get_state_attr("bookmarks")
local temp_bookmarks = get_temp_bookmarks()
local bookmark = temp_bookmarks[path] or user_bookmarks[path]
if not bookmark then
ya.notify({
title = "Bookmarks",
content = "Cannot delete: Not a user or temp bookmark",
timeout = 2,
level = "warn",
})
return
end
local tag = bookmark.tag
local is_temp = temp_bookmarks[path] ~= nil
if is_temp then
set_temp_bookmarks(path, nil)
ya.notify({ title = "Bookmarks", content = '[TEMP] "' .. tag .. '" deleted', timeout = 1, level = "info" })
else
set_bookmarks(path, nil)
local updated_user_bookmarks = get_state_attr("bookmarks")
save_to_file(mb_path, updated_user_bookmarks)
ya.notify({ title = "Bookmarks", content = '"' .. tag .. '" deleted', timeout = 1, level = "info" })
end
end
action_delete_multi = function(paths)
if not paths or #paths == 0 then
return
end
local mb_path = get_state_attr("path")
local user_bookmarks = get_state_attr("bookmarks")
local temp_bookmarks = get_temp_bookmarks()
local deleted_count = 0
local deleted_temp_count = 0
local deleted_names = {}
local not_found_count = 0
for _, path in ipairs(paths) do
local bookmark = temp_bookmarks[path] or user_bookmarks[path]
if bookmark then
local tag = bookmark.tag
local is_temp = temp_bookmarks[path] ~= nil
if is_temp then
set_temp_bookmarks(path, nil)
deleted_temp_count = deleted_temp_count + 1
table.insert(deleted_names, "[TEMP] " .. tag)
else
set_bookmarks(path, nil)
deleted_count = deleted_count + 1
table.insert(deleted_names, tag)
end
else
not_found_count = not_found_count + 1
end
end
if deleted_count > 0 then
local updated_user_bookmarks = get_state_attr("bookmarks")
save_to_file(mb_path, updated_user_bookmarks)
end
local total_deleted = deleted_count + deleted_temp_count
local message_parts = {}
if total_deleted > 0 then
table.insert(message_parts, string.format("Deleted %d bookmark(s)", total_deleted))
if deleted_count > 0 and deleted_temp_count > 0 then
table.insert(message_parts, string.format("(%d permanent, %d temporary)", deleted_count, deleted_temp_count))
elseif deleted_temp_count > 0 then
table.insert(message_parts, "(temporary)")
end
end
if not_found_count > 0 then
table.insert(message_parts, string.format("%d not found", not_found_count))
end
local final_message = table.concat(message_parts, ", ")
if total_deleted > 0 then
ya.notify({ title = "Bookmarks", content = final_message, timeout = 2, level = "info" })
else
ya.notify({ title = "Bookmarks", content = "No bookmarks were deleted", timeout = 1, level = "warn" })
end
end
local action_delete_all = function(temp_only)
local mb_path = get_state_attr("path")
local title = temp_only and "Delete all temporary bookmarks? ⟨y/n⟩" or "Delete all user bookmarks? ⟨y/n⟩"
local value, event = ya.input({ title = title, pos = { "top-center", y = 3, w = 45 } })
if event ~= 1 or string.lower(value or "") ~= "y" then
ya.notify({ title = "Bookmarks", content = "Cancel delete", timeout = 1, level = "info" })
return
end
if temp_only then
set_state_attr("temp_bookmarks", {})
ya.notify({ title = "Bookmarks", content = "All temporary bookmarks deleted", timeout = 1, level = "info" })
else
set_state_attr("bookmarks", {})
save_to_file(mb_path, {})
ya.notify({ title = "Bookmarks", content = "All user-created bookmarks deleted", timeout = 1, level = "info" })
end
end
return {
setup = function(state, options)
local default_path = (ya.target_family() == "windows" and os.getenv("APPDATA") .. "\\yazi\\config\\bookmarks")
or (os.getenv("HOME") .. "/.config/yazi/bookmarks")
local bookmarks_path = options.bookmarks_path or options.path
if type(bookmarks_path) == "string" and bookmarks_path ~= "" then
state.path = bookmarks_path
else
state.path = default_path
end
state.jump_notify = options.jump_notify == nil and false or options.jump_notify
state.home_alias_enabled = options.home_alias_enabled == nil and true or options.home_alias_enabled
state.path_truncate_enabled = options.path_truncate_enabled == nil and false or options.path_truncate_enabled
state.path_max_depth = options.path_max_depth or 3
state.fzf_path_truncate_enabled = options.fzf_path_truncate_enabled == nil and false
or options.fzf_path_truncate_enabled
state.fzf_path_max_depth = options.fzf_path_max_depth or 5
state.path_truncate_long_names_enabled = options.path_truncate_long_names_enabled == nil and false
or options.path_truncate_long_names_enabled
state.fzf_path_truncate_long_names_enabled = options.fzf_path_truncate_long_names_enabled == nil and false
or options.fzf_path_truncate_long_names_enabled
state.path_max_folder_name_length = options.path_max_folder_name_length or 20
state.fzf_path_max_folder_name_length = options.fzf_path_max_folder_name_length or 20
state.history_size = options.history_size or 10
state.history_fzf_path_truncate_enabled = options.history_fzf_path_truncate_enabled == nil and false
or options.history_fzf_path_truncate_enabled
state.history_fzf_path_max_depth = options.history_fzf_path_max_depth or 5
state.history_fzf_path_truncate_long_names_enabled = options.history_fzf_path_truncate_long_names_enabled == nil
and false
or options.history_fzf_path_truncate_long_names_enabled
state.history_fzf_path_max_folder_name_length = options.history_fzf_path_max_folder_name_length or 30
local special_keys_options = options.special_keys or {}
local special_keys = {}
for name, default_key in pairs(DEFAULT_SPECIAL_KEYS) do
local normalized = normalize_special_key(special_keys_options[name], default_key)
if normalized ~= nil then
special_keys[name] = normalized
end
end
state.special_keys = special_keys
ensure_directory(state.path)
local keys = options.keys or "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
state.keys, state.key2rank = {}, {}
for i = 1, #keys do
local char = keys:sub(i, i)
table.insert(state.keys, char)
state.key2rank[char] = i
end
local function convert_simple_bookmarks(simple_bookmarks)
local converted = {}
local path_sep = package.config:sub(1, 1)
local home_path = ya.target_family() == "windows" and os.getenv("USERPROFILE") or os.getenv("HOME")
for _, bookmark in ipairs(simple_bookmarks or {}) do
local path = bookmark.path
if path:sub(1, 1) == "~" then
path = home_path .. path:sub(2)
end
if ya.target_family() == "windows" then
path = path:gsub("/", "\\")
else
path = path:gsub("\\", "/")
end
if path:sub(-1) ~= path_sep then
path = path .. path_sep
end
converted[path] = {
tag = bookmark.tag,
path = path,
key = bookmark.key,
}
end
return converted
end
state.config_bookmarks = {}
local bookmarks_to_process = options.bookmarks or {}
if #bookmarks_to_process > 0 and bookmarks_to_process[1].tag then
state.config_bookmarks = convert_simple_bookmarks(bookmarks_to_process)
else
for _, item in pairs(bookmarks_to_process) do
state.config_bookmarks[item.path] = { tag = item.tag, path = item.path, key = item.key }
end
end
local user_bookmarks = {}
local file = io.open(state.path, "r")
if file ~= nil then
for line in file:lines() do
local tag, path, key_str = string.match(line, "(.-)\t(.-)\t(.*)")
if tag and path then
local key = deserialize_key_from_file(key_str or "")
user_bookmarks[path] = { tag = tag, path = path, key = key }
end
end
file:close()
end
state.bookmarks = user_bookmarks
save_to_file(state.path, state.bookmarks)
state.temp_bookmarks = {}
state.directory_history = {}
state.last_paths = {}
state.initialized_tabs = {}
ps.sub("cd", function(body)
local tab = body.tab or cx.tabs.idx
local new_path = normalize_path(tostring(cx.active.current.cwd))
if not state.initialized_tabs[tab] then
state.last_paths[tab] = new_path
state.initialized_tabs[tab] = true
return
end
local previous_path = state.last_paths[tab]
if previous_path and previous_path ~= new_path then
add_to_history(tab, previous_path)
end
state.last_paths[tab] = new_path
end)
end,
entry = function(self, jobs)
local args = jobs.args or {}
local action = args[1]
if type(action) == "string" and action:sub(1, 9):lower() == "jump_key_" then
jump_by_key_spec(action:sub(10))
return
end
if not action then
return
end
if action == "save" then
if is_hovered_directory() then
action_save(get_hovered_path(), false)
else
ya.notify({ title = "Bookmarks", content = "Selected item is not a directory", timeout = 2, level = "warn" })
end
elseif action == "save_cwd" then
action_save(get_current_dir_path(), false)
elseif action == "save_temp" then
if is_hovered_directory() then
action_save(get_hovered_path(), true)
else
ya.notify({ title = "Bookmarks", content = "Selected item is not a directory", timeout = 2, level = "warn" })
end
elseif action == "save_cwd_temp" then
action_save(get_current_dir_path(), true)
elseif action == "delete_by_key" then
action_delete(which_find_deletable())
elseif action == "delete_by_fzf" then
action_delete_multi(fzf_find_multi())
elseif action == "delete_multi_by_fzf" then
action_delete_multi(fzf_find_multi())
elseif action == "delete_all" then
action_delete_all(false)
elseif action == "delete_all_temp" then
action_delete_all(true)
elseif action == "jump_by_key" then
action_jump(which_find())
elseif action == "jump_by_fzf" then
action_jump(fzf_find())
elseif action == "rename_by_key" then
local path = which_find()
if path then
local temp_b = get_temp_bookmarks()
action_save(path, temp_b[path] ~= nil)
end
elseif action == "rename_by_fzf" then
local path = fzf_find_for_rename()
if path then
local temp_b = get_temp_bookmarks()
action_save(path, temp_b[path] ~= nil)
end
end
end,
}