2025-04-27 16:56:43 +02:00

274 lines
7.8 KiB
Lua

--- @since 25.2.7
local M = {}
local shell = os.getenv("SHELL") or ""
local PackageName = "Restore"
local function success(s, ...)
ya.notify({ title = PackageName, content = string.format(s, ...), timeout = 5, level = "info" })
end
local function fail(s, ...)
ya.notify({ title = PackageName, content = string.format(s, ...), timeout = 5, level = "error" })
end
---@enum STATE
local STATE = {
POSITION = "position",
SHOW_CONFIRM = "show_confirm",
THEME = "theme",
}
local set_state = ya.sync(function(state, key, value)
if state then
state[key] = value
else
state = {}
state[key] = value
end
end)
local get_state = ya.sync(function(state, key)
if state then
return state[key]
else
return nil
end
end)
---@enum File_Type
local File_Type = {
File = "file",
Dir = "dir_all",
None_Exist = "unknown",
}
---@alias TRASHED_ITEM {trash_index: number, trashed_date_time: string, trashed_path: string, type: File_Type} Item in trash list
function get_basename(filepath)
return filepath:match("^.+/(.+)$") or filepath
end
local get_cwd = ya.sync(function()
return tostring(cx.active.current.cwd)
end)
local function path_quote(path)
local result = "'" .. string.gsub(path, "'", "'\\''") .. "'"
return result
end
local function get_file_type(path)
local cha, _ = fs.cha(Url(path))
if cha then
return cha.is_dir and File_Type.Dir or File_Type.File
else
return File_Type.None_Exist
end
end
local function get_trash_volume()
local cwd = get_cwd()
local trash_volumes_stream, cmr_err =
Command("trash-list"):args({ "--volumes" }):stdout(Command.PIPED):stderr(Command.PIPED):output()
local matched_vol_path = nil
if trash_volumes_stream then
local matched_vol_length = 0
for vol in trash_volumes_stream.stdout:gmatch("[^\r\n]+") do
local vol_length = utf8.len(vol) or 0
if cwd:sub(1, vol_length) == vol and vol_length > matched_vol_length then
matched_vol_path = vol
matched_vol_length = vol_length
end
end
if not matched_vol_path then
fail("Can't get trash directory")
end
else
fail("Failed to start `trash-list` with error: `%s`. Do you have `trash-cli` installed?", cmr_err)
end
return matched_vol_path
end
---get list of latest files/folders trashed
---@param curr_working_volume currently working volume
---@return TRASHED_ITEM[]|nil
local function get_latest_trashed_items(curr_working_volume)
---@type TRASHED_ITEM[]
local restorable_items = {}
local fake_enter = Command("printf"):stderr(Command.PIPED):stdout(Command.PIPED):spawn():take_stdout()
local trash_list_stream, err_cmd = Command(shell)
:args({ "-c", "trash-restore " .. path_quote(curr_working_volume) })
:stdin(fake_enter)
:stdout(Command.PIPED)
:stderr(Command.PIPED)
:output()
if trash_list_stream then
---@type TRASHED_ITEM[]
local trash_list = {}
for line in trash_list_stream.stdout:gmatch("[^\r\n]+") do
-- remove leading spaces
line = line:match("^%s*(.+)$")
local trash_index, item_date, item_path = line:match("^(%d+) (%S+ %S+) (.+)$")
if item_date and item_path and trash_index ~= nil then
table.insert(trash_list, {
trash_index = tonumber(trash_index),
trashed_date_time = item_date,
trashed_path = item_path,
type = File_Type.None_Exist,
})
end
end
if #trash_list == 0 then
success("Nothing left to restore")
return
end
local last_item_datetime = trash_list[#trash_list].trashed_date_time
for _, trash_item in ipairs(trash_list) do
if trash_item then
if trash_item.trashed_date_time == last_item_datetime then
trash_item.type = get_file_type(trash_item.trashed_path)
table.insert(restorable_items, trash_item)
end
end
end
else
fail("Failed to start `trash-restore` with error: `%s`. Do you have `trash-cli` installed?", err_cmd)
return
end
return restorable_items
-- return newest_trashed_items
end
---@param trash_list TRASHED_ITEM[]
local function filter_none_exised_paths(trash_list)
---@type TRASHED_ITEM[]
local existed_trash_items = {}
for _, v in ipairs(trash_list) do
if v.type ~= File_Type.None_Exist then
table.insert(existed_trash_items, v)
end
end
return existed_trash_items
end
local function restore_files(curr_working_volume, start_index, end_index)
if type(start_index) ~= "number" or type(end_index) ~= "number" or start_index < 0 or end_index < 0 then
fail("Failed to restore file(s): out of range")
return
end
ya.manager_emit("shell", {
"echo " .. ya.quote(start_index .. "-" .. end_index) .. " | trash-restore --overwrite " .. path_quote(
curr_working_volume
),
confirm = true,
})
local file_to_restore_count = end_index - start_index + 1
success("Restored " .. tostring(file_to_restore_count) .. " file" .. (file_to_restore_count > 1 and "s" or ""))
end
function M:setup(opts)
if opts and opts.position and type(opts.position) == "table" then
set_state(STATE.POSITION, opts.position)
else
set_state(STATE.POSITION, { "center", w = 70, h = 40 })
end
if opts and opts.show_confirm then
set_state(STATE.SHOW_CONFIRM, opts.show_confirm)
else
set_state(STATE.SHOW_CONFIRM, false)
end
if opts and opts.theme and type(opts.theme) == "table" then
set_state(STATE.THEME, opts.theme)
else
set_state(STATE.THEME, {})
end
end
---@param trash_list TRASHED_ITEM[]
local function get_components(trash_list)
local theme = get_state(STATE.THEME) or {}
local item_odd_style = theme.list_item and theme.list_item.odd and ui.Style():fg(theme.list_item.odd)
or (th and th.confirm and th.confirm.list or ui.Style():fg("blue"))
local item_even_style = theme.list_item and theme.list_item.even and ui.Style():fg(theme.list_item.even)
or (th and th.confirm and th.confirm.list or ui.Style():fg("blue"))
local trashed_items_components = {}
for idx, item in pairs(trash_list) do
table.insert(
trashed_items_components,
ui.Line({
ui.Span(" "),
ui.Span(item.trashed_path):style(idx % 2 == 0 and item_even_style or item_odd_style),
}):align(ui.Line.LEFT)
)
end
return trashed_items_components
end
function M:entry()
local curr_working_volume = get_trash_volume()
if not curr_working_volume then
return
end
local trashed_items = get_latest_trashed_items(curr_working_volume)
if trashed_items == nil then
return
end
local collided_items = filter_none_exised_paths(trashed_items)
local overwrite_confirmed = true
local show_confirm = get_state(STATE.SHOW_CONFIRM)
local pos = get_state(STATE.POSITION)
pos = pos or { "center", w = 70, h = 40 }
local theme = get_state(STATE.THEME) or {}
theme.title = theme.title and ui.Style():fg(theme.title):bold() or (th and th.confirm and th.confirm.title)
theme.header = theme.header and ui.Style():fg(theme.header) or (th and th.confirm and th.confirm.content)
theme.header_warning = ui.Style():fg(theme.header_warning or "yellow")
if ya.confirm and show_confirm then
local continue_restore = ya.confirm({
-- title = ui.Line("Restore files/folders"):fg(theme.title):bold(),
title = ui.Line("Restore files/folders"):style(theme.title),
content = ui.Text({
ui.Line(""),
ui.Line("The following files and folders are going to be restored:"):style(theme.header),
ui.Line(""),
table.unpack(get_components(trashed_items)),
})
:align(ui.Text.LEFT)
:wrap(ui.Text.WRAP),
pos = pos,
})
-- stopping
if not continue_restore then
return
end
end
-- show Confirm dialog with list of collided items
if #collided_items > 0 then
overwrite_confirmed = ya.confirm({
title = ui.Line("Restore files/folders"):style(theme.title),
content = ui.Text({
ui.Line(""),
ui.Line("The following files and folders are existed, overwrite?"):style(theme.header_warning),
ui.Line(""),
table.unpack(get_components(collided_items)),
})
:align(ui.Text.LEFT)
:wrap(ui.Text.WRAP),
pos = pos,
})
end
if overwrite_confirmed then
restore_files(curr_working_volume, trashed_items[1].trash_index, trashed_items[#trashed_items].trash_index)
end
end
return M