-- 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 which‑key 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|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 user‑provided 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