Files
dotfiles_arch/yazi/.config/yazi/plugins/recycle-bin.yazi/main.lua
2026-01-19 21:05:12 +01:00

1470 lines
49 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- main.lua
-- Trash management system for Yazi
--=========== Plugin Settings =================================================
local isDebugEnabled = false
local M = {}
local PLUGIN_NAME = "recycle-bin"
local USER_ID = ya.uid()
local XDG_RUNTIME_DIR = os.getenv("XDG_RUNTIME_DIR") or ("/run/user/" .. USER_ID)
--=========== Compiled Patterns (Performance Optimization) ==================
-- Pre-compiled string patterns for better performance
local PATTERNS = {
filename = "([^/]+)$",
trash_list = "^(%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d) (.+)$",
line_break = "[^\n]+",
line_break_crlf = "[^\r\n]+",
size_info = "^(%S+)",
whitespace_cleanup = "[\r\n]+",
trailing_space = "%s+$",
first_word = "%l",
upper_first = "^%l",
}
--=========== Plugin State ===========================================================
---@enum
local STATE_KEY = {
CONFIG = "CONFIG",
}
--================= Notify / Logger ===========================================
local TIMEOUTS = {
error = 8,
warn = 8,
info = 3,
}
local Notify = {}
---@param level "info"|"warn"|"error"|nil
---@param s string
---@param ... any
function Notify._send(level, s, ...)
debug(s, ...)
local content = Notify._parseContent(s, ...)
local entry = {
title = PLUGIN_NAME,
content = content,
timeout = TIMEOUTS[level] or 3,
level = level,
}
ya.notify(entry)
end
function Notify._parseContent(s, ...)
local ok, content = pcall(string.format, s, ...)
if not ok then content = s end
content = tostring(content):gsub(PATTERNS.whitespace_cleanup, " "):gsub(PATTERNS.trailing_space, "")
return content
end
function Notify.error(...)
ya.err(...)
Notify._send("error", ...)
end
function Notify.warn(...)
Notify._send("warn", ...)
end
function Notify.info(...)
Notify._send("info", ...)
end
function debug(...)
if isDebugEnabled then
local msg = Notify._parseContent(...)
ya.dbg(msg)
end
end
--========= Run terminal commands =======================================================
---@param cmd string
---@param args? string[]
---@param input? string -- optional stdin input (e.g., password)
---@param is_silent? boolean
---@return string|nil, Output|nil
local function run_command(cmd, args, input, is_silent)
debug("Executing command: " .. cmd .. (args and #args > 0 and (" " .. table.concat(args, " ")) or ""))
local msgPrefix = "Command: " .. cmd .. " - "
local cmd_obj = Command(cmd)
-- Add arguments
if type(args) == "table" and #args > 0 then
for _, arg in ipairs(args) do
cmd_obj:arg(arg)
end
end
-- Set stdin mode if input is provided
if input then
cmd_obj:stdin(Command.PIPED)
else
cmd_obj:stdin(Command.INHERIT)
end
-- Set other streams
cmd_obj:stdout(Command.PIPED):stderr(Command.PIPED):env("XDG_RUNTIME_DIR", XDG_RUNTIME_DIR)
local child, cmd_err = cmd_obj:spawn()
if not child then
if not is_silent then Notify.error(msgPrefix .. "Failed to start. Error: %s", tostring(cmd_err)) end
return cmd_err and tostring(cmd_err), nil
end
-- Send stdin input if available
if input then
local ok, err = child:write_all(input)
if not ok then
if not is_silent then Notify.error(msgPrefix .. "Failed to write, stdin: %s", tostring(err)) end
return err and tostring(err), nil
end
local flushed, flush_err = child:flush()
if not flushed then
if not is_silent then Notify.error(msgPrefix .. "Failed to flush, stdin: %s", tostring(flush_err)) end
return flush_err and tostring(flush_err), nil
end
end
-- Read output
local output, out_err = child:wait_with_output()
if not output then
if not is_silent then Notify.error(msgPrefix .. "Failed to get output, error: %s", tostring(out_err)) end
return out_err and tostring(out_err), nil
end
-- Log outputs
if output.stdout ~= "" and not is_silent then debug(msgPrefix .. "stdout: %s", output.stdout) end
if output.status and output.status.code ~= 0 and not is_silent then
debug(msgPrefix .. "Error code `%s`, success: `%s`", output.status.code, tostring(output.status.success))
end
-- Handle child output error
if output.stderr ~= "" then
if not is_silent then debug(msgPrefix .. "stderr: %s", output.stderr) end
-- Only treat stderr as error if command actually failed
if output.status and not output.status.success then return output.stderr, output end
end
return nil, output
end
--========= Sync helpers =======================================================
local set_state = ya.sync(function(state, key, value)
state[key] = value
end)
local get_state = ya.sync(function(state, key)
return state[key]
end)
--- Get and return string of the pwd/cwd
---@return string -- the current working directory
local get_cwd = ya.sync(function()
return tostring(cx.active.current.cwd)
end)
---Get selected files from Yazi
---@return string[]
local get_selected_files = ya.sync(function()
local tab, paths = cx.active, {}
for _, u in pairs(tab.selected) do
paths[#paths + 1] = tostring(u)
end
if #paths == 0 and tab.current.hovered then paths[1] = tostring(tab.current.hovered.url) end
return paths
end)
---Clear the current selection in Yazi
local function clear_selection()
-- Use Yazi's manager_emit to properly clear all selections
ya.manager_emit("select_all", { state = false })
end
--=========== Utils =================================================
--- Deep merge two tables: overrides take precedence
---@param defaults table
---@param overrides table|nil
---@return table
local function deep_merge(defaults, overrides)
if type(overrides) ~= "table" then return defaults end
local result = {}
for k, v in pairs(defaults) do
if type(v) == "table" and type(overrides[k]) == "table" then
result[k] = deep_merge(v, overrides[k])
else
result[k] = overrides[k] ~= nil and overrides[k] or v
end
end
-- Include any keys in overrides not in defaults
for k, v in pairs(overrides) do
if result[k] == nil then result[k] = v end
end
return result
end
---Show an input box.
---@param title string
---@param is_password boolean?
---@param value string?
---@return string|nil
local function prompt(title, is_password, value)
debug("Prompting user for `%s`, is password: `%s`", title, is_password)
local input_value, input_event = ya.input({
title = title,
value = value or "",
obscure = is_password or false,
pos = { "center", y = 3, w = 60 },
})
if input_event ~= 1 then return nil end
return input_value
end
---Create standardized ui.Text with common styling
---@param lines string|string[] Single line or array of lines
---@return table ui.Text object with standard alignment and wrapping
local function create_ui_list(lines)
local line_objects = {}
if type(lines) == "string" then
table.insert(line_objects, ui.Line(lines))
else
for _, line in ipairs(lines) do
table.insert(line_objects, ui.Line(line))
end
end
return ui.Text(line_objects):align(ui.Align.LEFT):wrap(ui.Wrap.YES)
end
---Show a confirmation box.
---@param title string|table Confirmation title (string or structured ui.Line)
---@param body string|string[]|table? Confirmation body (string, string array, or structured ui.Text)
---@param posOpts ui.Pos? A table of position options (e.g. {"center", w = 70, h = 40, x = 0, y = 0})
---@return boolean
local function confirm(title, body, posOpts)
local title_str = type(title) == "string" and title or tostring(title)
debug("Confirming user action for `%s`", title_str)
local pos = {
posOpts and posOpts[1] or "center",
w = posOpts and posOpts.w or 70,
h = posOpts and posOpts.h or 40,
x = posOpts and posOpts.x or 0,
y = posOpts and posOpts.y or 0,
}
local confirmation_data = {
title = type(title) == "string" and ui.Line(title) or title,
pos = pos,
}
if body then
-- Handle different body types
if type(body) == "string" then
confirmation_data.content = create_ui_list(body)
confirmation_data.body = create_ui_list(body)
elseif type(body) == "table" and body[1] and type(body[1]) == "string" then
-- Array of strings
confirmation_data.content = create_ui_list(body)
confirmation_data.body = create_ui_list(body)
else
-- Structured UI component (ui.Text)
confirmation_data.content = body
confirmation_data.body = body
end
end
local answer = ya.confirm(confirmation_data)
return answer
end
---Present a simple whichkey style selector and return the chosen item (Max: 36 options).
---@param title string
---@param items string[]
---@return string|nil
local function choose_which(title, items)
local keys = "1234567890abcdefghijklmnopqrstuvwxyz"
local candidates = {}
for i, item in ipairs(items) do
if i > #keys then break end
candidates[#candidates + 1] = { on = keys:sub(i, i), desc = item }
end
local idx = ya.which({ title = title, cands = candidates })
return idx and items[idx]
end
--============== File helpers ====================================
---Check if a path exists and is a directory
---@param url Url
---@return boolean
local function is_dir(url)
local cha, _ = fs.cha(url)
return cha and cha.is_dir or false
end
---Get file size in bytes using fs.cha()
---@param file_path string Absolute path to the file
---@return integer|nil, string|nil -- size_in_bytes, error_message
local function get_file_size(file_path)
local url = Url(file_path)
local cha, err = fs.cha(url)
if not cha then
local error_msg = string.format("Failed to get file info for %s: %s", file_path, err or "unknown error")
debug(error_msg)
return nil, error_msg
end
if not cha.len then
local error_msg = string.format("File size not available for %s", file_path)
debug(error_msg)
return nil, error_msg
end
return cha.len, nil
end
---Format bytes into human-readable format (B, KB, MB, GB, TB)
---@param bytes integer|nil Number of bytes to format
---@return string Formatted size string
local function format_file_size(bytes)
if not bytes or bytes < 0 then return "0 B" end
local units = { "B", "KB", "MB", "GB", "TB" }
local size = bytes
local unit_index = 1
-- Convert to larger units while size >= 1024 and we have larger units
while size >= 1024 and unit_index < #units do
size = size / 1024
unit_index = unit_index + 1
end
-- Format with appropriate decimal places
if unit_index == 1 then
-- Bytes - no decimal places
return string.format("%d %s", size, units[unit_index])
elseif size >= 100 then
-- >= 100 units - no decimal places (e.g., "156 MB")
return string.format("%.0f %s", size, units[unit_index])
elseif size >= 10 then
-- >= 10 units - one decimal place (e.g., "15.6 MB")
return string.format("%.1f %s", size, units[unit_index])
else
-- < 10 units - two decimal places (e.g., "1.56 MB")
return string.format("%.2f %s", size, units[unit_index])
end
end
---Get file objects with size information for multiple files
---@param file_paths string[] Array of file paths/names to process
---@param base_dir string Base directory where files are located (e.g., "~/.local/share/Trash/files/")
---@return {name: string, size: string}[] Array of file objects with name and size
local function get_files_with_sizes(file_paths, base_dir)
debug("Getting file sizes for %d files from base directory: %s", #file_paths, base_dir)
local file_objects = {}
-- Ensure base_dir ends with a slash for proper path construction
local normalized_base_dir = base_dir
if not normalized_base_dir:match("/$") then normalized_base_dir = normalized_base_dir .. "/" end
for i, file_path in ipairs(file_paths) do
-- Extract filename from the path
local filename = file_path:match(PATTERNS.filename) or file_path
-- Construct full path to the file in the base directory
local full_path = normalized_base_dir .. filename
-- Get file size using existing utility function
local bytes, size_err = get_file_size(full_path)
local formatted_size
if size_err then
-- Log the error but continue processing other files
debug("Could not get size for file %s: %s", filename, size_err)
formatted_size = "unknown size"
else
-- Format the size using existing utility function
formatted_size = format_file_size(bytes)
end
-- Create file object with name and size
file_objects[i] = {
name = filename,
size = formatted_size,
}
end
debug("Successfully processed %d file objects", #file_objects)
return file_objects
end
--=========== Trash helpers =================================================
---Get available trash directories from trash-cli
---@return string[], string|nil -- trash_dirs, error
local function get_trash_directories()
local err, output = run_command("trash-list", { "--trash-dirs" }, nil, true)
if err then return {}, err end
local directories = {}
if output and output.stdout ~= "" then
for line in output.stdout:gmatch(PATTERNS.line_break) do
local trimmed = line:gsub("^%s*(.-)%s*$", "%1") -- trim whitespace
if trimmed ~= "" then
-- Ensure directory path ends with /
if not trimmed:match("/$") then trimmed = trimmed .. "/" end
table.insert(directories, trimmed)
end
end
end
debug("Found %d trash directories: %s", #directories, table.concat(directories, ", "))
return directories, nil
end
---Get the correct trash files directory path based on OS
---@param config table Configuration object containing trash_dir and os
---@return string -- trash_files_directory_path
local function get_trash_files_dir(config)
local trash_files_dir = config.trash_dir
-- On Linux, trash files are in a 'files' subdirectory
if config.os ~= "macos" then
-- Ensure trash_dir ends with / before adding 'files'
if not trash_files_dir:match("/$") then trash_files_dir = trash_files_dir .. "/" end
trash_files_dir = trash_files_dir .. "files"
end
return trash_files_dir
end
---Verify trash dir exists
---@param config table | nil
local function check_has_trash_directory(config)
-- Get Config
if not config then config = get_state(STATE_KEY.CONFIG) end
-- Verify trash dir
local trash_dir = config.trash_dir
local trash_url = Url(trash_dir)
if not is_dir(trash_url) then
Notify.error("Trash directory not found: %s. Please check your configuration.", trash_dir)
return false
end
return true
end
---Select trash directory from available options
---@param directories string[] Array of trash directory paths
---@return string|nil -- selected_directory
local function select_trash_directory(directories)
if #directories == 0 then return nil end
-- If only one directory, use it automatically
if #directories == 1 then
debug("Using single trash directory: %s", directories[1])
return directories[1]
end
-- Multiple directories - present user with selection
debug("Multiple trash directories found, prompting user selection")
local selected_dir = choose_which("Select trash directory:", directories)
if selected_dir then
debug("User selected trash directory: %s", selected_dir)
else
debug("User cancelled trash directory selection")
end
return selected_dir
end
---Ensure trash directory is set, prompting user if needed
---@param config table
---@return boolean -- true if trash directory is available, false if cancelled or error
local function ensure_trash_directory(config)
-- If trash directory is already set and exists, we're good
if config.trash_dir and check_has_trash_directory(config) then return true end
-- Get available trash directories
local directories, dir_err = get_trash_directories()
if dir_err then
Notify.error(
"Failed to discover trash directories: %s. Try 'trash-list --trash-dirs' manually to verify trash directories",
dir_err
)
return false
end
if #directories == 0 then
Notify.error("No trash directories found. Please check trash-cli installation.")
return false
end
-- Let user select which trash directory to use
local selected_dir = select_trash_directory(directories)
if not selected_dir then
Notify.info("Trash directory selection cancelled")
return false
end
-- Save the selected trash directory to config for this session
config.trash_dir = selected_dir
set_state(STATE_KEY.CONFIG, config)
debug("Updated trash_dir for this session: %s", selected_dir)
return true
end
---Get mapping of filenames to original paths from trash-list
---@return table<string, string>, string|nil -- filename_to_path_map, error
local function get_trash_file_mappings()
local err, output = run_command("trash-list", {})
if err then return {}, err end
local mappings = {}
if output and output.stdout ~= "" then
for line in output.stdout:gmatch(PATTERNS.line_break) do
local timestamp, original_path = line:match(PATTERNS.trash_list)
if timestamp and original_path then
local filename = original_path:match(PATTERNS.filename) or original_path
mappings[filename] = original_path
end
end
end
debug("Created %d trash file mappings", #mappings)
return mappings, nil
end
---Get all files in trash with their sizes for display
---@param config table Configuration object
---@return {name: string, size: string}[], string|nil -- file_objects, error
local function get_trash_files_with_sizes(config)
-- Get all files from trash-list
local err, output = run_command("trash-list", {})
if err then return {}, err end
local file_names = {}
if output and output.stdout ~= "" then
for line in output.stdout:gmatch(PATTERNS.line_break) do
local timestamp, original_path = line:match(PATTERNS.trash_list)
if timestamp and original_path then
local filename = original_path:match(PATTERNS.filename) or original_path
table.insert(file_names, filename)
end
end
end
if #file_names == 0 then return {}, nil end
-- Get file objects with sizes using existing function
local trash_files_dir = get_trash_files_dir(config)
local file_objects = get_files_with_sizes(file_names, trash_files_dir)
debug("Retrieved %d trash files with sizes", #file_objects)
return file_objects, nil
end
---Get trash files older than specified days with their sizes for display
---@param config table Configuration object
---@param days integer Number of days - files older than this will be included
---@return {name: string, size: string, deleted_date: string}[], string|nil -- file_objects, error
local function get_trash_files_older_than_days(config, days)
-- Get all files from trash-list
local err, output = run_command("trash-list", {})
if err then return {}, err end
-- Calculate cutoff time (days ago from now)
local current_time = os.time()
local cutoff_time = current_time - (days * 24 * 60 * 60) -- days * hours * minutes * seconds
local old_files = {}
if output and output.stdout ~= "" then
for line in output.stdout:gmatch(PATTERNS.line_break) do
local timestamp, original_path = line:match(PATTERNS.trash_list)
if timestamp and original_path then
-- Parse timestamp: "2025-08-28 20:27:38" format
local year, month, day, hour, min, sec = timestamp:match("(%d+)-(%d+)-(%d+) (%d+):(%d+):(%d+)")
if year and month and day and hour and min and sec then
local file_time = os.time({
year = tonumber(year),
month = tonumber(month),
day = tonumber(day),
hour = tonumber(hour),
min = tonumber(min),
sec = tonumber(sec),
})
-- If file is older than cutoff, include it
if file_time < cutoff_time then
local filename = original_path:match(PATTERNS.filename) or original_path
table.insert(old_files, {
filename = filename,
deleted_date = timestamp,
})
end
end
end
end
end
if #old_files == 0 then return {}, nil end
-- Get file objects with sizes using existing function
local trash_files_dir = get_trash_files_dir(config)
local file_names = {}
for _, file_info in ipairs(old_files) do
table.insert(file_names, file_info.filename)
end
local file_objects = get_files_with_sizes(file_names, trash_files_dir)
-- Add deleted date information to file objects
for i, file_obj in ipairs(file_objects) do
file_obj.deleted_date = old_files[i].deleted_date
end
debug("Retrieved %d trash files older than %d days", #file_objects, days)
return file_objects, nil
end
--- Go to the trash directory
local function open_trash(config)
-- Ensure we have a trash directory selected
if not ensure_trash_directory(config) then return end
local trash_files_dir = get_trash_files_dir(config)
local trash_files_url = Url(trash_files_dir)
-- Go to trash files directory if exists, fallback to trash root if not
if is_dir(trash_files_url) then
ya.emit("cd", { trash_files_url })
else
local trash_root_url = Url(config.trash_dir)
if is_dir(trash_root_url) then
ya.emit("cd", { trash_root_url })
Notify.info("Trash files directory not found, navigated to trash root: %s", config.trash_dir)
else
Notify.error("Trash directory not found: %s", config.trash_dir)
end
end
end
---Offer to open trash directory when user attempts operations outside trash
---@param config table
---@return boolean -- true if user chose to navigate to trash and succeeded, false otherwise
local function offer_to_open_trash(config)
local user_wants_to_navigate = confirm("Not in Trash Directory", {
"This command can only be run from within a Trash directory.",
"Would you like to open the Trash now?",
}, { w = 70, h = 10, x = 0, y = 0 })
if not user_wants_to_navigate then
Notify.info("Operation cancelled")
return false
end
-- Try to open trash directory
open_trash(config)
-- Check if we successfully navigated to a trash directory
local current_dir = get_cwd()
local trash_dirs, dir_err = get_trash_directories()
if dir_err then
Notify.error("Failed to verify navigation: %s", dir_err)
return false
end
for _, trash_dir in ipairs(trash_dirs) do
if current_dir:find(trash_dir, 1, true) == 1 then return true end
end
Notify.error("Failed to navigate to trash directory")
return false
end
---Resolve a path that might be a symlink to its real path using realpath command
---@param path string The path to resolve
---@return string -- The resolved path (or original if resolution fails)
local function resolve_symlink(path)
-- Use realpath command to resolve symlinks
local err, output = run_command("realpath", { path }, nil, true)
if not err and output and output.stdout then
-- Remove trailing newline and return resolved path
local resolved = output.stdout:gsub("[\r\n]+$", "")
if resolved ~= "" then
debug("Resolved symlink: %s -> %s", path, resolved)
return resolved
end
end
-- If resolution failed, return original path
debug("Could not resolve symlink for %s, using original path", path)
return path
end
---Check if current working directory is within a valid trash directory
---If not, offer to navigate to trash directory
---@param config table
---@return boolean -- true if in trash directory or successfully navigated to trash, false otherwise
local is_current_dir_in_trash = function(config)
local current_dir = get_cwd()
-- Resolve symlinks in the current directory path
local resolved_dir = resolve_symlink(current_dir)
local trash_dirs, dir_err = get_trash_directories()
if dir_err then
Notify.error(
"Failed to find trash directories: %s. Check trash-cli installation with 'trash-list --version'",
dir_err
)
return false
end
-- Check if already in trash directory (check both original and resolved paths)
for _, trash_dir in ipairs(trash_dirs) do
if current_dir:find(trash_dir, 1, true) == 1 or resolved_dir:find(trash_dir, 1, true) == 1 then return true end
end
-- Not in trash directory - offer to navigate there
offer_to_open_trash(config)
return false
end
---Get count of items in trash
---@return integer, string|nil -- count, error
local function get_trash_item_count()
local err, output = run_command("trash-list", {})
if err then return 0, err end
local item_count = 0
if output and output.stdout ~= "" then
_, item_count = output.stdout:gsub(PATTERNS.line_break, "")
end
return item_count, nil
end
---Get size of trash files directory
---@param config table
---@return string, string|nil -- size_string, error
local function get_trash_size(config)
local trash_files_dir = get_trash_files_dir(config)
local err, output = run_command("du", { "-sh", trash_files_dir }, nil, true)
if err or not output or output.stdout == "" then return "unknown size", err end
local size_info = output.stdout:match(PATTERNS.size_info)
return size_info or "unknown size", nil
end
---Get size of trash files directory
---@param config table
---@return {count: integer, size: string}|table, {type: string, msg: string}|nil
local function get_trash_data(config)
-- Default values
local count = 0
local size = "0M"
-- Get trash info
local item_count, count_err = get_trash_item_count()
if count_err then
return { count, size }, { type = "error", msg = string.format("Failed to get trash contents: %s", count_err) }
end
if item_count == 0 then return { count, size }, { type = "info", msg = "Trash is already empty" } end
local size_info, size_err = get_trash_size(config)
if size_err then debug("Failed to get trash size: %s", size_err) end
return {
count = item_count,
size = size_info,
}, nil
end
--=========== Conflict Resolution =================================================
---Handle restore conflicts by checking if files exist at original locations
---@param restore_items table[] Array of restore items with original_path, filename, and size
---@return table[] non_conflicted_items, table[] conflicted_items
local function detect_restore_conflicts(restore_items)
local conflicts = {}
local non_conflicted_items = {}
for _, item in ipairs(restore_items) do
local original_url = Url(item.original_path)
local cha, _ = fs.cha(original_url)
if cha then -- File exists at original location
table.insert(conflicts, {
filename = item.filename,
original_path = item.original_path,
size = item.size,
})
else
table.insert(non_conflicted_items, item)
end
end
return non_conflicted_items, conflicts
end
---Create overwrite warning dialog and get user confirmation
---@param conflicts table[] Array of conflicted items
---@return boolean true if user confirms overwrite, false otherwise
local function create_overwrite_warning_dialog(conflicts)
local overwrite_warning = {
"⚠️ DESTRUCTIVE ACTION WARNING ⚠️",
"",
"You are about to PERMANENTLY OVERWRITE existing files:",
}
for _, conflict in ipairs(conflicts) do
table.insert(overwrite_warning, string.format(" • %s", conflict.original_path))
end
table.insert(overwrite_warning, "")
table.insert(overwrite_warning, "The existing files will be LOST FOREVER!")
table.insert(overwrite_warning, "This action CANNOT BE UNDONE!")
-- Create warning dialog with red styling
local warning_components = {}
for i, line in ipairs(overwrite_warning) do
if i == 1 then
-- Main warning title in red
table.insert(warning_components, ui.Line(line):style(th.notify.title_error))
elseif line:match("^ •") then
-- File paths
table.insert(warning_components, ui.Line(line):style(th.notify.content))
elseif line:match("LOST FOREVER") or line:match("CANNOT BE UNDONE") then
-- Critical warnings in red
table.insert(warning_components, ui.Line(line):style(th.notify.title_error))
else
table.insert(warning_components, ui.Line(line))
end
end
local warning_body = ui.Text(warning_components):align(ui.Align.LEFT):wrap(ui.Wrap.YES)
return confirm(" CONFIRM DESTRUCTIVE ACTION ", warning_body, { w = 80, h = 20, x = 0, y = 0 })
end
---Handle overwrite choice confirmation
---@param conflicts table[] Array of conflicted items
---@return string "cancel"|"overwrite" User's final choice for overwrite action
local function handle_overwrite_choice(conflicts)
local confirmed = create_overwrite_warning_dialog(conflicts)
return confirmed and "overwrite" or "cancel"
end
---Present conflict resolution dialog to user and return their choice
---@param conflicts table[] Array of conflicted items
---@param non_conflicted_count integer Number of non-conflicted items
---@return string "cancel"|"skip"|"overwrite" User's choice
local function prompt_conflict_resolution(conflicts, non_conflicted_count)
if non_conflicted_count > 0 then
-- Offer choice between cancel, skip conflicts, or overwrite all
local choices = {
"Cancel restore",
"Skip conflicts and restore others",
"⚠️ Do not skip conflicts, restore ALL and OVERWRITE any conflicts",
}
local choice = choose_which("Resolve File Conflicts", choices)
if choice == "Cancel restore" then
return "cancel"
elseif choice == "Skip conflicts and restore others" then
return "skip"
elseif choice == "⚠️ Do not skip conflicts, restore ALL and OVERWRITE any conflicts" then
return handle_overwrite_choice(conflicts)
else
return "cancel" -- Default to cancel if no choice made
end
else
-- All files have conflicts - offer overwrite option
local choices = { "Cancel restore", "⚠️ Do not skip conflicts, restore ALL and OVERWRITE any conflicts" }
local choice = choose_which("All Files Have Conflicts", choices)
if choice == "⚠️ Do not skip conflicts, restore ALL and OVERWRITE any conflicts" then
return handle_overwrite_choice(conflicts)
else
return "cancel"
end
end
end
---Delete a single conflicting file or directory at its original location
---@param original_path string The path to the conflicting file/directory
---@return boolean success, string|nil error_message
local function delete_conflict_file(original_path)
local original_url = Url(original_path)
-- Check if it's a file or directory to use the correct removal type
local cha, cha_err = fs.cha(original_url)
if not cha then
local error_msg = string.format("Cannot access conflicting item %s: %s", original_path, cha_err or "unknown error")
return false, error_msg
end
local remove_type = cha.is_dir and "dir_all" or "file"
local delete_success, delete_err = fs.remove(remove_type, original_url)
if delete_success then
debug("Successfully deleted conflicting %s: %s", cha.is_dir and "directory" or "file", original_path)
return true, nil
else
local error_msg = string.format(
"Failed to delete existing %s %s: %s",
cha.is_dir and "directory" or "file",
original_path,
delete_err or "unknown error"
)
return false, error_msg
end
end
---Handle restore conflicts and return filtered items based on user choice
---@param restore_items table[] Original restore items
---@return table[]|nil filtered_items (nil if user cancelled)
local function handle_restore_conflicts(restore_items)
local non_conflicted_items, conflicts = detect_restore_conflicts(restore_items)
-- No conflicts found, proceed with all items
if #conflicts == 0 then return restore_items end
-- Present conflict resolution dialog
local user_choice = prompt_conflict_resolution(conflicts, #non_conflicted_items)
if user_choice == "cancel" then
return nil
elseif user_choice == "skip" then
if #non_conflicted_items == 0 then
Notify.info("No files to restore after skipping all conflicts")
return nil
end
Notify.info("Skipping %d conflicted files, proceeding with %d files", #conflicts, #non_conflicted_items)
return non_conflicted_items
elseif user_choice == "overwrite" then
-- Mark items that need overwrite and return all items
-- The actual deletion will happen when user confirms the restore operation
Notify.info("Selected overwrite option for %d conflicting files", #conflicts)
-- Add overwrite metadata to restore items that have conflicts
for _, item in ipairs(restore_items) do
for _, conflict in ipairs(conflicts) do
if item.original_path == conflict.original_path then
item.needs_overwrite = true
break
end
end
end
return restore_items -- Return all items, with overwrite flags set
end
-- Fallback to cancel
return nil
end
--=========== File Selection =================================================
---Validates file selection and extracts filenames
---@param operation_name string The name of the operation (for logging/notifications)
---@return string[]|nil -- selected_paths
local function validate_and_get_selection(operation_name)
-- Get selected files from Yazi
local selected_paths = get_selected_files()
if #selected_paths == 0 then
Notify.warn("No files selected for " .. operation_name)
return nil
end
debug("Selected paths for %s: %s", operation_name, table.concat(selected_paths, ", "))
return selected_paths
end
--=========== Batch Operations =================================================
---Shows standardized confirmation dialog for batch operations
---@param verb string Action verb (e.g., "delete", "restore")
---@param items {name: string, size: string, needs_overwrite: boolean?}[] List of file objects with name, size, and optional overwrite flag
---@param warning string|nil Optional warning message
---@return boolean
local function confirm_batch_operation(verb, items, warning)
local title = string.format("%s the following %d file(s):", verb:gsub(PATTERNS.upper_first, string.upper), #items)
-- Create structured UI components for proper alignment and styling
local body_components = {}
-- Add each item as a formatted line with proper left alignment showing "fileName (size)"
-- Show overwrite warning for files that will overwrite existing files
local overwrite_count = 0
for _, item in ipairs(items) do
local display_text = string.format("%s (%s)", item.name, item.size)
if item.needs_overwrite then
overwrite_count = overwrite_count + 1
-- Mark files that will overwrite with warning styling
table.insert(
body_components,
ui.Line({
ui.Span(" ⚠️ "),
ui.Span(display_text .. " [WILL OVERWRITE]"),
}):style(th.notify.title_warn)
)
else
table.insert(body_components, ui.Line({ ui.Span(" "), ui.Span(display_text) }):align(ui.Align.LEFT))
end
end
-- Add overwrite warning if any files need overwriting
if overwrite_count > 0 then
table.insert(body_components, ui.Line(""))
table.insert(
body_components,
ui.Line(string.format("⚠️ %d existing file(s) will be permanently deleted!", overwrite_count))
:style(th.notify.title_error)
)
end
-- Add warning if provided with styling
if warning then
table.insert(body_components, ui.Line(""))
table.insert(body_components, ui.Line(warning):style(th.notify.title_warn))
end
local structured_body = ui.Text(body_components):align(ui.Align.LEFT):wrap(ui.Wrap.YES)
local confirmation = confirm(title, structured_body)
if not confirmation then
Notify.info(verb:gsub(PATTERNS.upper_first, string.upper) .. " cancelled")
return false
end
return true
end
---Shows confirmation dialog for batch operations with deletion dates
---@param verb string Action verb (e.g., "delete")
---@param items {name: string, size: string, deleted_date: string}[] List of file objects with name, size, and deletion date
---@param days integer Number of days used for filtering
---@param warning string|nil Optional warning message
---@return boolean
local function confirm_batch_operation_with_dates(verb, items, days, warning)
local title = string.format(
"%s the following %d file(s) older than %d days:",
verb:gsub(PATTERNS.upper_first, string.upper),
#items,
days
)
-- Create structured UI components for proper alignment and styling
local body_components = {}
-- Add each item as a formatted line showing "fileName (size) - deleted: date"
for _, item in ipairs(items) do
local display_text = string.format("%s (%s) - deleted: %s", item.name, item.size, item.deleted_date)
table.insert(body_components, ui.Line({ ui.Span(" "), ui.Span(display_text) }):align(ui.Align.LEFT))
end
-- Add warning if provided with styling
if warning then
table.insert(body_components, ui.Line(""))
table.insert(body_components, ui.Line(warning):style(th.notify.title_warn))
end
local structured_body = ui.Text(body_components):align(ui.Align.LEFT):wrap(ui.Wrap.YES)
local confirmation = confirm(title, structured_body)
if not confirmation then
Notify.info(verb:gsub(PATTERNS.upper_first, string.upper) .. " cancelled")
return false
end
return true
end
---Executes batch operation with progress tracking and error handling
---@param items table[] Array of items to process (can be strings, file objects, or restore items)
---@param operation_name string Name of operation for notifications
---@param operation_func function Function that takes an item and returns error_string|nil
---@return integer, integer -- success_count, failed_count
local function execute_batch_operation(items, operation_name, operation_func)
local success_count = 0
local failed_count = 0
for _, item in ipairs(items) do
local err = operation_func(item)
if err then
failed_count = failed_count + 1
else
success_count = success_count + 1
end
end
return success_count, failed_count
end
---Reports standardized operation results
---@param operation_name string Name of the operation
---@param success_count integer Number of successful operations
---@param failed_count integer Number of failed operations
local function report_operation_results(operation_name, success_count, failed_count)
local past_tense = operation_name == "deleting" and "deleted"
or operation_name == "restoring" and "restored"
or operation_name .. "d"
if success_count > 0 and failed_count == 0 then
Notify.info("Successfully %s %d file(s)", past_tense, success_count)
elseif success_count > 0 and failed_count > 0 then
Notify.warn(
"%s %d file(s), failed %d",
past_tense:gsub(PATTERNS.upper_first, string.upper),
success_count,
failed_count
)
else
Notify.error("Failed to %s any files", operation_name:gsub("ing$", ""))
end
end
--=========== api actions =================================================
local function cmd_open_trash(config)
open_trash(config)
end
local function cmd_empty_trash(config)
-- Ensure we have a trash directory selected
if not ensure_trash_directory(config) then return end
-- Get trash data
local data, data_err = get_trash_data(config)
if data_err then
Notify[data_err.type](data_err.msg)
return
end
-- Get all trash files with their sizes for detailed display
local file_objects, file_err = get_trash_files_with_sizes(config)
if file_err then
Notify.error("Failed to get trash file list: %s", file_err)
return
end
-- If no files found, show simple message
if #file_objects == 0 then
Notify.info("Trash is already empty")
return
end
-- Show detailed confirmation dialog with file list and sizes
if not confirm_batch_operation("permanently delete", file_objects, "This action cannot be undone!") then return end
-- Execute trash-empty command
local err, _ = run_command("trash-empty", {}, "y\n")
if err then
Notify.error("Failed to empty trash: %s. Try 'trash-empty' manually to debug", err)
return
end
Notify.info("Trash emptied successfully (%d items, %s freed)", data.count, data.size)
end
local function cmd_empty_trash_by_days(config)
-- Ensure we have a trash directory selected
if not ensure_trash_directory(config) then return end
-- Get trash data prior to the operation to calculate difference
local begin_data, begin_err = get_trash_data(config)
if begin_err then
Notify[begin_err.type](begin_err.msg)
return
end
-- Prompt user for number of days
local days_input = prompt("Delete trash items older than (days)", false, "30")
if not days_input then
Notify.info("Empty trash by days cancelled")
return
end
-- Validate input is a positive integer
local days = tonumber(days_input)
if not days or days <= 0 or math.floor(days) ~= days then
Notify.error("Invalid input: please enter a positive integer for days")
return
end
-- Get files older than specified days with sizes and deletion dates
local file_objects, file_err = get_trash_files_older_than_days(config, days)
if file_err then
Notify.error("Failed to get trash file list: %s", file_err)
return
end
-- If no files found that are older than the specified days
if #file_objects == 0 then
Notify.info("No items found that are older than %d days", days)
return
end
-- Show detailed confirmation dialog with file list, sizes, and deletion dates
if
not confirm_batch_operation_with_dates("permanently delete", file_objects, days, "This action cannot be undone!")
then
return
end
-- Execute trash-empty command with days parameter
local err, _ = run_command("trash-empty", { tostring(days) }, "y\n")
if err then
Notify.error("Failed to empty trash by days: %s", err)
return
end
-- Get trash data after the operation to calculate difference
local end_data, end_err = get_trash_data(config)
if end_err then
Notify[end_err.type](end_err.msg)
return
end
-- Calculate items deleted
local items_deleted = begin_data.count - end_data.count
if items_deleted > 0 then
Notify.info("Successfully removed %d trash items older than %d days", items_deleted, days)
else
Notify.info("No items found that are older than %d days", days)
end
end
local function cmd_delete_selection(config)
-- Ensure we have a trash directory selected
if not ensure_trash_directory(config) then return end
-- Check if current directory is within a valid trash directory
if not is_current_dir_in_trash(config) then return end
-- Validate selection and get filenames
local selected_paths, _ = validate_and_get_selection("deletion")
if not selected_paths then return end
-- Get file objects with sizes for confirmation dialog
local trash_files_dir = get_trash_files_dir(config)
-- Ensure it ends with / for get_files_with_sizes
if not trash_files_dir:match("/$") then trash_files_dir = trash_files_dir .. "/" end
local file_objects = get_files_with_sizes(selected_paths, trash_files_dir)
-- Confirm deletion from trash with warning
if not confirm_batch_operation("permanently delete", file_objects, "This action cannot be undone!") then return end
-- Create operation function for delete
local function delete_operation(path)
local filename = path:match(PATTERNS.filename) or path
-- Use trash-rm with the filename as pattern
-- trash-rm uses fnmatch patterns, so we pass the filename directly
local delete_err, _ = run_command("trash-rm", { filename })
if delete_err then
Notify.error("Failed to delete %s: %s", filename, delete_err)
return delete_err
else
return nil
end
end
-- Execute batch operation
local success_count, failed_count = execute_batch_operation(selected_paths, "permanently deleting", delete_operation)
-- Clear selection after successful delete to prevent stale selections
if success_count > 0 then clear_selection() end
-- Report results
report_operation_results("deleting", success_count, failed_count)
end
local function cmd_restore_selection(config)
-- Ensure we have a trash directory selected
if not ensure_trash_directory(config) then return end
-- Check if current directory is within a valid trash directory
if not is_current_dir_in_trash(config) then return end
-- Validate selection and get filenames
local selected_paths, _ = validate_and_get_selection("restoration")
if not selected_paths then return end
-- Get trash file mappings from trash-list
local trash_mappings, mapping_err = get_trash_file_mappings()
if mapping_err then
Notify.error("Failed to get trash mappings: %s. Try 'trash-list' manually to verify trash contents", mapping_err)
return
end
-- Prepare restore items with original paths and size information
local restore_items = {}
local trash_files_dir = get_trash_files_dir(config)
local normalized_trash_files_dir = trash_files_dir
if not normalized_trash_files_dir:match("/$") then normalized_trash_files_dir = normalized_trash_files_dir .. "/" end
for _, path in ipairs(selected_paths) do
local filename = path:match(PATTERNS.filename) or path
local original_path = trash_mappings[filename]
if original_path then
-- Get file size from trash files directory
local full_path = normalized_trash_files_dir .. filename
local bytes, size_err = get_file_size(full_path)
local formatted_size = size_err and "unknown size" or format_file_size(bytes)
restore_items[#restore_items + 1] = {
filename = filename,
original_path = original_path,
name = filename,
size = formatted_size,
}
else
Notify.warn("Could not find original path for file: %s", filename)
end
end
if #restore_items == 0 then
Notify.info("No files to restore in current trash directory")
return
end
-- Handle potential conflicts at original file locations
local filtered_items = handle_restore_conflicts(restore_items)
if not filtered_items then
-- User cancelled or no valid items after conflict resolution
return
end
-- Update restore_items to use filtered list
restore_items = filtered_items
-- Confirm restoration
if not confirm_batch_operation("restore", restore_items, nil) then return end
-- Create operation function for restore using original paths
local function restore_operation(item)
debug("Restoring %s from original path: %s", item.filename, item.original_path)
-- If this item needs overwrite, delete the existing file/directory first
if item.needs_overwrite then
local delete_success, delete_error = delete_conflict_file(item.original_path)
if not delete_success then
Notify.error(delete_error)
return delete_error
end
end
-- Use trash-restore with the original file path as argument and auto-select first match
local restore_err, _ = run_command("trash-restore", { item.original_path }, "0\n")
if restore_err then
Notify.error("Failed to restore %s: %s", item.name, restore_err)
return restore_err
else
return nil
end
end
-- Execute batch operation
local success_count, failed_count = execute_batch_operation(restore_items, "restoring", restore_operation)
-- Clear selection after successful restore to prevent stale selections
if success_count > 0 then clear_selection() end
-- Report results
report_operation_results("restoring", success_count, failed_count)
end
local function cmd_show_menu(config)
local choice = ya.which({
title = "Recycle Bin Menu",
cands = {
{ on = "o", desc = "Open Trash" },
{ on = "r", desc = "Restore from Trash" },
{ on = "d", desc = "Delete from Trash" },
{ on = "e", desc = "Empty Trash" },
{ on = "D", desc = "Empty by Days" },
},
})
if choice == 1 then
cmd_open_trash(config)
elseif choice == 2 then
cmd_restore_selection(config)
elseif choice == 3 then
cmd_delete_selection(config)
elseif choice == 4 then
cmd_empty_trash(config)
elseif choice == 5 then
cmd_empty_trash_by_days(config)
end
end
--=========== init requirements ================================================
---Verify all dependencies
local function check_dependencies()
-- Check for trash-cli
local trashcli_err, _ = run_command("trash-list", { "--version" }, nil, true)
if trashcli_err then
local path = os.getenv("PATH") or "(unset)"
Notify.error("trashcli not found. Is it installed and in PATH? PATH=" .. path)
return false
end
return true
end
---Initialize the plugin and verify dependencies
local function init()
local initialized = get_state("is_initialized")
if not initialized then
if not check_dependencies() then return false end
initialized = true
set_state("is_initialized", true)
end
return initialized
end
--=========== Plugin start =================================================
-- Default configuration
local default_config = {
trash_dir = nil, -- Will be auto-discovered from trash-list --trash-dirs
os = ya.target_os(),
}
---Merges userprovided configuration options into the defaults.
---@param user_config table|nil
local function set_plugin_config(user_config)
local config = deep_merge(default_config, user_config or {})
set_state(STATE_KEY.CONFIG, config)
end
---Setup
function M:setup(cfg)
set_plugin_config(cfg)
end
---Entry
function M:entry(job)
if not init() then return end
-- Cache config to avoid multiple state access calls
local config = get_state(STATE_KEY.CONFIG)
local action = job.args[1]
-- Pass config to functions that need it to avoid additional state calls
if action == "menu" or not action then
cmd_show_menu(config)
elseif action == "open" then
cmd_open_trash(config)
elseif action == "delete" then
cmd_delete_selection(config)
elseif action == "restore" then
cmd_restore_selection(config)
elseif action == "emptyDays" then
cmd_empty_trash_by_days(config)
elseif action == "empty" then
cmd_empty_trash(config)
else
Notify.error("Unknown action '%s'. Valid actions: menu, open, delete, restore, empty, emptyDays", tostring(action))
end
end
return M