refactor: revert canola namespace to oil and remove vim.g config (#120)
Some checks failed
quality / changes (push) Has been cancelled
test / Test (Neovim nightly) (push) Has been cancelled
test / Test (Neovim stable) (push) Has been cancelled
quality / Lua Format Check (push) Has been cancelled
quality / Lua Lint Check (push) Has been cancelled
quality / Lua Type Check (push) Has been cancelled
quality / Markdown Format Check (push) Has been cancelled
Some checks failed
quality / changes (push) Has been cancelled
test / Test (Neovim nightly) (push) Has been cancelled
test / Test (Neovim stable) (push) Has been cancelled
quality / Lua Format Check (push) Has been cancelled
quality / Lua Lint Check (push) Has been cancelled
quality / Lua Type Check (push) Has been cancelled
quality / Markdown Format Check (push) Has been cancelled
* refactor: revert module namespace from canola back to oil
Problem: the canola rename creates unnecessary friction for users
migrating from stevearc/oil.nvim — every `require('oil')` call and
config reference must change.
Solution: revert all module paths, URL schemes, autocmd groups,
highlight groups, and filetype names back to `oil`. The repo stays
`canola.nvim` for identity; the code is a drop-in replacement.
* refactor: remove `vim.g.oil` declarative config
Problem: the `vim.g.oil` configuration path was added prematurely.
It adds a second config entrypoint before the plugin has stabilized
enough to justify it.
Solution: remove `vim.g.oil` support from `plugin/oil.lua`,
`config.setup()`, docs, and tests. Users configure via
`require("oil").setup({})`.
This commit is contained in:
parent
9298b48c5d
commit
e2cd1e66cf
69 changed files with 1724 additions and 1754 deletions
634
lua/oil/adapters/trash/freedesktop.lua
Normal file
634
lua/oil/adapters/trash/freedesktop.lua
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
-- Based on the FreeDesktop.org trash specification
|
||||
-- https://specifications.freedesktop.org/trash/1.0/
|
||||
local cache = require('oil.cache')
|
||||
local config = require('oil.config')
|
||||
local constants = require('oil.constants')
|
||||
local files = require('oil.adapters.files')
|
||||
local fs = require('oil.fs')
|
||||
local util = require('oil.util')
|
||||
|
||||
local uv = vim.uv or vim.loop
|
||||
local FIELD_META = constants.FIELD_META
|
||||
|
||||
local M = {}
|
||||
|
||||
local function ensure_trash_dir(path)
|
||||
local mode = 448 -- 0700
|
||||
fs.mkdirp(fs.join(path, 'info'), mode)
|
||||
fs.mkdirp(fs.join(path, 'files'), mode)
|
||||
end
|
||||
|
||||
---Gets the location of the home trash dir, creating it if necessary
|
||||
---@return string
|
||||
local function get_home_trash_dir()
|
||||
local xdg_home = vim.env.XDG_DATA_HOME
|
||||
if not xdg_home then
|
||||
xdg_home = fs.join(assert(uv.os_homedir()), '.local', 'share')
|
||||
end
|
||||
local trash_dir = fs.join(xdg_home, 'Trash')
|
||||
ensure_trash_dir(trash_dir)
|
||||
return trash_dir
|
||||
end
|
||||
|
||||
---@param mode integer
|
||||
---@return boolean
|
||||
local function is_sticky(mode)
|
||||
local extra = bit.rshift(mode, 9)
|
||||
return bit.band(extra, 4) ~= 0
|
||||
end
|
||||
|
||||
---Get the topdir .Trash/$uid directory if present and valid
|
||||
---@param path string
|
||||
---@return string[]
|
||||
local function get_top_trash_dirs(path)
|
||||
local dirs = {}
|
||||
local dev = (uv.fs_lstat(path) or {}).dev
|
||||
local top_trash_dirs = vim.fs.find('.Trash', { upward = true, path = path, limit = math.huge })
|
||||
for _, top_trash_dir in ipairs(top_trash_dirs) do
|
||||
local stat = uv.fs_lstat(top_trash_dir)
|
||||
if stat and not dev then
|
||||
dev = stat.dev
|
||||
end
|
||||
if stat and stat.dev == dev and stat.type == 'directory' and is_sticky(stat.mode) then
|
||||
local trash_dir = fs.join(top_trash_dir, tostring(uv.getuid()))
|
||||
ensure_trash_dir(trash_dir)
|
||||
table.insert(dirs, trash_dir)
|
||||
end
|
||||
end
|
||||
|
||||
-- Also search for the .Trash-$uid
|
||||
top_trash_dirs = vim.fs.find(
|
||||
string.format('.Trash-%d', uv.getuid()),
|
||||
{ upward = true, path = path, limit = math.huge }
|
||||
)
|
||||
for _, top_trash_dir in ipairs(top_trash_dirs) do
|
||||
local stat = uv.fs_lstat(top_trash_dir)
|
||||
if stat and stat.dev == dev then
|
||||
ensure_trash_dir(top_trash_dir)
|
||||
table.insert(dirs, top_trash_dir)
|
||||
end
|
||||
end
|
||||
|
||||
return dirs
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return string
|
||||
local function get_write_trash_dir(path)
|
||||
local lstat = uv.fs_lstat(path)
|
||||
local home_trash = get_home_trash_dir()
|
||||
if not lstat then
|
||||
-- If the source file doesn't exist default to home trash dir
|
||||
return home_trash
|
||||
end
|
||||
local dev = lstat.dev
|
||||
if uv.fs_lstat(home_trash).dev == dev then
|
||||
return home_trash
|
||||
end
|
||||
|
||||
local top_trash_dirs = get_top_trash_dirs(path)
|
||||
if not vim.tbl_isempty(top_trash_dirs) then
|
||||
return top_trash_dirs[1]
|
||||
end
|
||||
|
||||
local parent = vim.fn.fnamemodify(path, ':h')
|
||||
local next_parent = vim.fn.fnamemodify(parent, ':h')
|
||||
while parent ~= next_parent and uv.fs_lstat(next_parent).dev == dev do
|
||||
parent = next_parent
|
||||
next_parent = vim.fn.fnamemodify(parent, ':h')
|
||||
end
|
||||
|
||||
local top_trash = fs.join(parent, string.format('.Trash-%d', uv.getuid()))
|
||||
ensure_trash_dir(top_trash)
|
||||
return top_trash
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return string[]
|
||||
local function get_read_trash_dirs(path)
|
||||
local dirs = { get_home_trash_dir() }
|
||||
vim.list_extend(dirs, get_top_trash_dirs(path))
|
||||
return dirs
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param callback fun(url: string)
|
||||
M.normalize_url = function(url, callback)
|
||||
local scheme, path = util.parse_url(url)
|
||||
assert(path)
|
||||
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ':p')
|
||||
uv.fs_realpath(
|
||||
os_path,
|
||||
vim.schedule_wrap(function(err, new_os_path)
|
||||
local realpath = new_os_path or os_path
|
||||
callback(scheme .. util.addslash(fs.os_to_posix_path(realpath)))
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param entry oil.Entry
|
||||
---@param cb fun(path: string)
|
||||
M.get_entry_path = function(url, entry, cb)
|
||||
local internal_entry = assert(cache.get_entry_by_id(entry.id))
|
||||
local meta = assert(internal_entry[FIELD_META])
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = meta.trash_info
|
||||
if not trash_info then
|
||||
-- This is a subpath in the trash
|
||||
M.normalize_url(url, cb)
|
||||
return
|
||||
end
|
||||
local path = fs.os_to_posix_path(trash_info.trash_file)
|
||||
if meta.stat.type == 'directory' then
|
||||
path = util.addslash(path)
|
||||
end
|
||||
cb('oil://' .. path)
|
||||
end
|
||||
|
||||
---@class oil.TrashInfo
|
||||
---@field trash_file string
|
||||
---@field info_file string
|
||||
---@field original_path string
|
||||
---@field deletion_date number
|
||||
---@field stat uv.aliases.fs_stat_table
|
||||
|
||||
---@param info_file string
|
||||
---@param cb fun(err?: string, info?: oil.TrashInfo)
|
||||
local function read_trash_info(info_file, cb)
|
||||
if not vim.endswith(info_file, '.trashinfo') then
|
||||
return cb('File is not .trashinfo')
|
||||
end
|
||||
uv.fs_open(info_file, 'r', 448, function(err, fd)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
assert(fd)
|
||||
uv.fs_fstat(fd, function(stat_err, stat)
|
||||
if stat_err then
|
||||
uv.fs_close(fd)
|
||||
return cb(stat_err)
|
||||
end
|
||||
uv.fs_read(
|
||||
fd,
|
||||
assert(stat).size,
|
||||
nil,
|
||||
vim.schedule_wrap(function(read_err, content)
|
||||
uv.fs_close(fd)
|
||||
if read_err then
|
||||
return cb(read_err)
|
||||
end
|
||||
assert(content)
|
||||
local trash_info = {
|
||||
info_file = info_file,
|
||||
}
|
||||
local lines = vim.split(content, '\r?\n')
|
||||
if lines[1] ~= '[Trash Info]' then
|
||||
return cb('File missing [Trash Info] header')
|
||||
end
|
||||
local trash_base = vim.fn.fnamemodify(info_file, ':h:h')
|
||||
for _, line in ipairs(lines) do
|
||||
local key, value = unpack(vim.split(line, '=', { plain = true, trimempty = true }))
|
||||
if key == 'Path' and not trash_info.original_path then
|
||||
if not vim.startswith(value, '/') then
|
||||
value = fs.join(trash_base, value)
|
||||
end
|
||||
trash_info.original_path = value
|
||||
elseif key == 'DeletionDate' and not trash_info.deletion_date then
|
||||
trash_info.deletion_date = vim.fn.strptime('%Y-%m-%dT%H:%M:%S', value)
|
||||
end
|
||||
end
|
||||
|
||||
if not trash_info.original_path or not trash_info.deletion_date then
|
||||
return cb('File missing required fields')
|
||||
end
|
||||
|
||||
local basename = vim.fn.fnamemodify(info_file, ':t:r')
|
||||
trash_info.trash_file = fs.join(trash_base, 'files', basename)
|
||||
uv.fs_lstat(trash_info.trash_file, function(trash_stat_err, trash_stat)
|
||||
if trash_stat_err then
|
||||
cb('.trashinfo file points to non-existant file')
|
||||
else
|
||||
trash_info.stat = trash_stat
|
||||
---@cast trash_info oil.TrashInfo
|
||||
cb(nil, trash_info)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
|
||||
M.list = function(url, column_defs, cb)
|
||||
cb = vim.schedule_wrap(cb)
|
||||
local _, path = util.parse_url(url)
|
||||
assert(path)
|
||||
local trash_dirs = get_read_trash_dirs(path)
|
||||
local trash_idx = 0
|
||||
|
||||
local read_next_trash_dir
|
||||
read_next_trash_dir = function()
|
||||
trash_idx = trash_idx + 1
|
||||
local trash_dir = trash_dirs[trash_idx]
|
||||
if not trash_dir then
|
||||
return cb()
|
||||
end
|
||||
|
||||
-- Show all files from the trash directory if we are in the root of the device, which we can
|
||||
-- tell if the trash dir is a subpath of our current path
|
||||
local show_all_files = fs.is_subpath(path, trash_dir)
|
||||
-- The first trash dir is a special case; it is in the home directory and we should only show
|
||||
-- all entries if we are in the top root path "/"
|
||||
if trash_idx == 1 then
|
||||
show_all_files = path == '/'
|
||||
end
|
||||
|
||||
local info_dir = fs.join(trash_dir, 'info')
|
||||
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
|
||||
uv.fs_opendir(info_dir, function(open_err, fd)
|
||||
if open_err then
|
||||
if open_err:match('^ENOENT: no such file or directory') then
|
||||
-- If the directory doesn't exist, treat the list as a success. We will be able to traverse
|
||||
-- and edit a not-yet-existing directory.
|
||||
return read_next_trash_dir()
|
||||
else
|
||||
return cb(open_err)
|
||||
end
|
||||
end
|
||||
local read_next
|
||||
read_next = function()
|
||||
uv.fs_readdir(fd, function(err, entries)
|
||||
if err then
|
||||
uv.fs_closedir(fd, function()
|
||||
cb(err)
|
||||
end)
|
||||
return
|
||||
elseif entries then
|
||||
local internal_entries = {}
|
||||
local poll = util.cb_collect(#entries, function(inner_err)
|
||||
if inner_err then
|
||||
cb(inner_err)
|
||||
else
|
||||
cb(nil, internal_entries, read_next)
|
||||
end
|
||||
end)
|
||||
|
||||
for _, entry in ipairs(entries) do
|
||||
read_trash_info(
|
||||
fs.join(info_dir, entry.name),
|
||||
vim.schedule_wrap(function(read_err, info)
|
||||
if read_err then
|
||||
-- Discard the error. We don't care if there's something wrong with one of these
|
||||
-- files.
|
||||
poll()
|
||||
else
|
||||
local parent = util.addslash(vim.fn.fnamemodify(info.original_path, ':h'))
|
||||
if path == parent or show_all_files then
|
||||
local name = vim.fn.fnamemodify(info.trash_file, ':t')
|
||||
---@diagnostic disable-next-line: undefined-field
|
||||
local cache_entry = cache.create_entry(url, name, info.stat.type)
|
||||
local display_name = vim.fn.fnamemodify(info.original_path, ':t')
|
||||
cache_entry[FIELD_META] = {
|
||||
stat = info.stat,
|
||||
trash_info = info,
|
||||
display_name = display_name,
|
||||
}
|
||||
table.insert(internal_entries, cache_entry)
|
||||
end
|
||||
if path ~= parent and (show_all_files or fs.is_subpath(path, parent)) then
|
||||
local name = parent:sub(path:len() + 1)
|
||||
local next_par = vim.fs.dirname(name)
|
||||
while next_par ~= '.' do
|
||||
name = next_par
|
||||
next_par = vim.fs.dirname(name)
|
||||
end
|
||||
---@diagnostic disable-next-line: undefined-field
|
||||
local cache_entry = cache.create_entry(url, name, 'directory')
|
||||
|
||||
cache_entry[FIELD_META] = {
|
||||
stat = info.stat,
|
||||
}
|
||||
table.insert(internal_entries, cache_entry)
|
||||
end
|
||||
poll()
|
||||
end
|
||||
end)
|
||||
)
|
||||
end
|
||||
else
|
||||
uv.fs_closedir(fd, function(close_err)
|
||||
if close_err then
|
||||
cb(close_err)
|
||||
else
|
||||
vim.schedule(read_next_trash_dir)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
read_next()
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
end, 10000)
|
||||
end
|
||||
read_next_trash_dir()
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return boolean
|
||||
M.is_modifiable = function(bufnr)
|
||||
return true
|
||||
end
|
||||
|
||||
local file_columns = {}
|
||||
|
||||
local current_year
|
||||
-- Make sure we run this import-time effect in the main loop (mostly for tests)
|
||||
vim.schedule(function()
|
||||
current_year = vim.fn.strftime('%Y')
|
||||
end)
|
||||
|
||||
file_columns.mtime = {
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
if not meta then
|
||||
return nil
|
||||
end
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = meta.trash_info
|
||||
local time = trash_info and trash_info.deletion_date or meta.stat and meta.stat.mtime.sec
|
||||
if not time then
|
||||
return nil
|
||||
end
|
||||
local fmt = conf and conf.format
|
||||
local ret
|
||||
if fmt then
|
||||
ret = vim.fn.strftime(fmt, time)
|
||||
else
|
||||
local year = vim.fn.strftime('%Y', time)
|
||||
if year ~= current_year then
|
||||
ret = vim.fn.strftime('%b %d %Y', time)
|
||||
else
|
||||
ret = vim.fn.strftime('%b %d %H:%M', time)
|
||||
end
|
||||
end
|
||||
return ret
|
||||
end,
|
||||
|
||||
get_sort_value = function(entry)
|
||||
local meta = entry[FIELD_META]
|
||||
---@type nil|oil.TrashInfo
|
||||
local trash_info = meta and meta.trash_info
|
||||
if trash_info then
|
||||
return trash_info.deletion_date
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
local fmt = conf and conf.format
|
||||
local pattern
|
||||
if fmt then
|
||||
pattern = fmt:gsub('%%.', '%%S+')
|
||||
else
|
||||
pattern = '%S+%s+%d+%s+%d%d:?%d%d'
|
||||
end
|
||||
return line:match('^(' .. pattern .. ')%s+(.+)$')
|
||||
end,
|
||||
}
|
||||
|
||||
---@param name string
|
||||
---@return nil|oil.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return file_columns[name]
|
||||
end
|
||||
|
||||
M.supported_cross_adapter_actions = { files = 'move' }
|
||||
|
||||
---@param action oil.Action
|
||||
---@return boolean
|
||||
M.filter_action = function(action)
|
||||
if action.type == 'create' then
|
||||
return false
|
||||
elseif action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META]
|
||||
return meta ~= nil and meta.trash_info ~= nil
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
return src_adapter.name == 'files' or dest_adapter.name == 'files'
|
||||
-- selene: allow(if_same_then_else)
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
return src_adapter.name == 'files' or dest_adapter.name == 'files'
|
||||
else
|
||||
error(string.format("Bad action type '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param err oil.ParseError
|
||||
---@return boolean
|
||||
M.filter_error = function(err)
|
||||
if err.message == 'Duplicate filename' then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
if action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META]
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = assert(meta).trash_info
|
||||
local short_path = fs.shorten_path(trash_info.original_path)
|
||||
return string.format(' PURGE %s', short_path)
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format(' TRASH %s', short_path)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format('RESTORE %s', short_path)
|
||||
else
|
||||
error('Must be moving files into or out of trash')
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format(' COPY %s -> TRASH', short_path)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format('RESTORE %s', short_path)
|
||||
else
|
||||
error('Must be copying files into or out of trash')
|
||||
end
|
||||
else
|
||||
error(string.format("Bad action type '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param trash_info oil.TrashInfo
|
||||
---@param cb fun(err?: string)
|
||||
local function purge(trash_info, cb)
|
||||
fs.recursive_delete('file', trash_info.info_file, function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
---@diagnostic disable-next-line: undefined-field
|
||||
fs.recursive_delete(trash_info.stat.type, trash_info.trash_file, cb)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param info_path string
|
||||
---@param cb fun(err?: string)
|
||||
local function write_info_file(path, info_path, cb)
|
||||
uv.fs_open(
|
||||
info_path,
|
||||
'w',
|
||||
448,
|
||||
vim.schedule_wrap(function(err, fd)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
assert(fd)
|
||||
local deletion_date = vim.fn.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
local contents = string.format('[Trash Info]\nPath=%s\nDeletionDate=%s', path, deletion_date)
|
||||
uv.fs_write(fd, contents, function(write_err)
|
||||
uv.fs_close(fd, function(close_err)
|
||||
cb(write_err or close_err)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err?: string, trash_info?: oil.TrashInfo)
|
||||
local function create_trash_info(path, cb)
|
||||
local trash_dir = get_write_trash_dir(path)
|
||||
local basename = vim.fs.basename(path)
|
||||
local now = os.time()
|
||||
local name = string.format('%s-%d.%d', basename, now, math.random(100000, 999999))
|
||||
local dest_path = fs.join(trash_dir, 'files', name)
|
||||
local dest_info = fs.join(trash_dir, 'info', name .. '.trashinfo')
|
||||
uv.fs_lstat(path, function(err, stat)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
assert(stat)
|
||||
write_info_file(path, dest_info, function(info_err)
|
||||
if info_err then
|
||||
return cb(info_err)
|
||||
end
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = {
|
||||
original_path = path,
|
||||
trash_file = dest_path,
|
||||
info_file = dest_info,
|
||||
deletion_date = now,
|
||||
stat = stat,
|
||||
}
|
||||
cb(nil, trash_info)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@param cb fun(err: nil|string)
|
||||
M.perform_action = function(action, cb)
|
||||
if action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META]
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = assert(meta).trash_info
|
||||
purge(trash_info, cb)
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
M.delete_to_trash(assert(path), cb)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
-- Restore
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
local entry = assert(cache.get_entry_by_url(action.src_url))
|
||||
local meta = entry[FIELD_META]
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = assert(meta).trash_info
|
||||
fs.recursive_move(action.entry_type, trash_info.trash_file, dest_path, function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
uv.fs_unlink(trash_info.info_file, cb)
|
||||
end)
|
||||
else
|
||||
error('Must be moving files into or out of trash')
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
create_trash_info(path, function(err, trash_info)
|
||||
if err then
|
||||
cb(err)
|
||||
else
|
||||
local stat_type = trash_info.stat.type or 'unknown'
|
||||
fs.recursive_copy(stat_type, path, trash_info.trash_file, vim.schedule_wrap(cb))
|
||||
end
|
||||
end)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
-- Restore
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
local entry = assert(cache.get_entry_by_url(action.src_url))
|
||||
local meta = entry[FIELD_META]
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = assert(meta).trash_info
|
||||
fs.recursive_copy(action.entry_type, trash_info.trash_file, dest_path, cb)
|
||||
else
|
||||
error('Must be moving files into or out of trash')
|
||||
end
|
||||
else
|
||||
cb(string.format('Bad action type: %s', action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err?: string)
|
||||
M.delete_to_trash = function(path, cb)
|
||||
create_trash_info(path, function(err, trash_info)
|
||||
if err then
|
||||
cb(err)
|
||||
else
|
||||
local stat_type = trash_info.stat.type or 'unknown'
|
||||
fs.recursive_move(stat_type, path, trash_info.trash_file, vim.schedule_wrap(cb))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
232
lua/oil/adapters/trash/mac.lua
Normal file
232
lua/oil/adapters/trash/mac.lua
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
local cache = require('oil.cache')
|
||||
local config = require('oil.config')
|
||||
local files = require('oil.adapters.files')
|
||||
local fs = require('oil.fs')
|
||||
local util = require('oil.util')
|
||||
|
||||
local uv = vim.uv or vim.loop
|
||||
|
||||
local M = {}
|
||||
|
||||
local function touch_dir(path)
|
||||
uv.fs_mkdir(path, 448) -- 0700
|
||||
end
|
||||
|
||||
---Gets the location of the home trash dir, creating it if necessary
|
||||
---@return string
|
||||
local function get_trash_dir()
|
||||
local trash_dir = fs.join(assert(uv.os_homedir()), '.Trash')
|
||||
touch_dir(trash_dir)
|
||||
return trash_dir
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param callback fun(url: string)
|
||||
M.normalize_url = function(url, callback)
|
||||
local scheme, path = util.parse_url(url)
|
||||
assert(path)
|
||||
callback(scheme .. '/')
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param entry oil.Entry
|
||||
---@param cb fun(path: string)
|
||||
M.get_entry_path = function(url, entry, cb)
|
||||
local trash_dir = get_trash_dir()
|
||||
local path = fs.join(trash_dir, entry.name)
|
||||
if entry.type == 'directory' then
|
||||
path = 'oil://' .. path
|
||||
end
|
||||
cb(path)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
|
||||
M.list = function(url, column_defs, cb)
|
||||
cb = vim.schedule_wrap(cb)
|
||||
local _, path = util.parse_url(url)
|
||||
assert(path)
|
||||
local trash_dir = get_trash_dir()
|
||||
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
|
||||
uv.fs_opendir(trash_dir, function(open_err, fd)
|
||||
if open_err then
|
||||
if open_err:match('^ENOENT: no such file or directory') then
|
||||
-- If the directory doesn't exist, treat the list as a success. We will be able to traverse
|
||||
-- and edit a not-yet-existing directory.
|
||||
return cb()
|
||||
else
|
||||
return cb(open_err)
|
||||
end
|
||||
end
|
||||
local read_next
|
||||
read_next = function()
|
||||
uv.fs_readdir(fd, function(err, entries)
|
||||
if err then
|
||||
uv.fs_closedir(fd, function()
|
||||
cb(err)
|
||||
end)
|
||||
return
|
||||
elseif entries then
|
||||
local internal_entries = {}
|
||||
local poll = util.cb_collect(#entries, function(inner_err)
|
||||
if inner_err then
|
||||
cb(inner_err)
|
||||
else
|
||||
cb(nil, internal_entries, read_next)
|
||||
end
|
||||
end)
|
||||
|
||||
for _, entry in ipairs(entries) do
|
||||
-- TODO: read .DS_Store and filter by original dir
|
||||
local cache_entry = cache.create_entry(url, entry.name, entry.type)
|
||||
table.insert(internal_entries, cache_entry)
|
||||
poll()
|
||||
end
|
||||
else
|
||||
uv.fs_closedir(fd, function(close_err)
|
||||
if close_err then
|
||||
cb(close_err)
|
||||
else
|
||||
cb()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
read_next()
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
end, 10000)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return boolean
|
||||
M.is_modifiable = function(bufnr)
|
||||
return true
|
||||
end
|
||||
|
||||
---@param name string
|
||||
---@return nil|oil.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return nil
|
||||
end
|
||||
|
||||
M.supported_cross_adapter_actions = { files = 'move' }
|
||||
|
||||
---@param action oil.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
if action.type == 'create' then
|
||||
return string.format('CREATE %s', action.url)
|
||||
elseif action.type == 'delete' then
|
||||
return string.format(' PURGE %s', action.url)
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format(' TRASH %s', short_path)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format('RESTORE %s', short_path)
|
||||
else
|
||||
return string.format(' %s %s -> %s', action.type:upper(), action.src_url, action.dest_url)
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
return string.format(' %s %s -> %s', action.type:upper(), action.src_url, action.dest_url)
|
||||
else
|
||||
error('Bad action type')
|
||||
end
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@param cb fun(err: nil|string)
|
||||
M.perform_action = function(action, cb)
|
||||
local trash_dir = get_trash_dir()
|
||||
if action.type == 'create' then
|
||||
local _, path = util.parse_url(action.url)
|
||||
assert(path)
|
||||
path = trash_dir .. path
|
||||
if action.entry_type == 'directory' then
|
||||
uv.fs_mkdir(path, 493, function(err)
|
||||
-- Ignore if the directory already exists
|
||||
if not err or err:match('^EEXIST:') then
|
||||
cb()
|
||||
else
|
||||
cb(err)
|
||||
end
|
||||
end) -- 0755
|
||||
elseif action.entry_type == 'link' and action.link then
|
||||
local flags = nil
|
||||
local target = fs.posix_to_os_path(action.link)
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
uv.fs_symlink(target, path, flags, cb)
|
||||
else
|
||||
fs.touch(path, config.new_file_mode, cb)
|
||||
end
|
||||
elseif action.type == 'delete' then
|
||||
local _, path = util.parse_url(action.url)
|
||||
assert(path)
|
||||
local fullpath = trash_dir .. path
|
||||
fs.recursive_delete(action.entry_type, fullpath, cb)
|
||||
elseif action.type == 'move' or action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
local _, src_path = util.parse_url(action.src_url)
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(src_path and dest_path)
|
||||
if src_adapter.name == 'files' then
|
||||
dest_path = trash_dir .. dest_path
|
||||
elseif dest_adapter.name == 'files' then
|
||||
src_path = trash_dir .. src_path
|
||||
else
|
||||
dest_path = trash_dir .. dest_path
|
||||
src_path = trash_dir .. src_path
|
||||
end
|
||||
|
||||
if action.type == 'move' then
|
||||
fs.recursive_move(action.entry_type, src_path, dest_path, cb)
|
||||
else
|
||||
fs.recursive_copy(action.entry_type, src_path, dest_path, cb)
|
||||
end
|
||||
else
|
||||
cb(string.format('Bad action type: %s', action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err?: string)
|
||||
M.delete_to_trash = function(path, cb)
|
||||
local basename = vim.fs.basename(path)
|
||||
local trash_dir = get_trash_dir()
|
||||
local dest = fs.join(trash_dir, basename)
|
||||
uv.fs_lstat(
|
||||
path,
|
||||
vim.schedule_wrap(function(stat_err, src_stat)
|
||||
if stat_err then
|
||||
return cb(stat_err)
|
||||
end
|
||||
assert(src_stat)
|
||||
if uv.fs_lstat(dest) then
|
||||
local date_str = vim.fn.strftime(' %Y-%m-%dT%H:%M:%S')
|
||||
local name_pieces = vim.split(basename, '.', { plain = true })
|
||||
if #name_pieces > 1 then
|
||||
table.insert(name_pieces, #name_pieces - 1, date_str)
|
||||
basename = table.concat(name_pieces)
|
||||
else
|
||||
basename = basename .. date_str
|
||||
end
|
||||
dest = fs.join(trash_dir, basename)
|
||||
end
|
||||
|
||||
local stat_type = src_stat.type
|
||||
fs.recursive_move(stat_type, path, dest, vim.schedule_wrap(cb))
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
return M
|
||||
411
lua/oil/adapters/trash/windows.lua
Normal file
411
lua/oil/adapters/trash/windows.lua
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
local util = require('oil.util')
|
||||
local uv = vim.uv or vim.loop
|
||||
local cache = require('oil.cache')
|
||||
local config = require('oil.config')
|
||||
local constants = require('oil.constants')
|
||||
local files = require('oil.adapters.files')
|
||||
local fs = require('oil.fs')
|
||||
local powershell_trash = require('oil.adapters.trash.windows.powershell-trash')
|
||||
|
||||
local FIELD_META = constants.FIELD_META
|
||||
local FIELD_TYPE = constants.FIELD_TYPE
|
||||
|
||||
local M = {}
|
||||
|
||||
---@return string
|
||||
local function get_trash_dir()
|
||||
local cwd = assert(vim.fn.getcwd())
|
||||
local trash_dir = cwd:sub(1, 3) .. '$Recycle.Bin'
|
||||
if vim.fn.isdirectory(trash_dir) == 1 then
|
||||
return trash_dir
|
||||
end
|
||||
trash_dir = 'C:\\$Recycle.Bin'
|
||||
if vim.fn.isdirectory(trash_dir) == 1 then
|
||||
return trash_dir
|
||||
end
|
||||
error('No trash found')
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return string
|
||||
local win_addslash = function(path)
|
||||
if not vim.endswith(path, '\\') then
|
||||
return path .. '\\'
|
||||
else
|
||||
return path
|
||||
end
|
||||
end
|
||||
|
||||
---@class oil.WindowsTrashInfo
|
||||
---@field trash_file string
|
||||
---@field original_path string
|
||||
---@field deletion_date integer
|
||||
---@field info_file? string
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
|
||||
M.list = function(url, column_defs, cb)
|
||||
local _, path = util.parse_url(url)
|
||||
path = fs.posix_to_os_path(assert(path))
|
||||
|
||||
local trash_dir = get_trash_dir()
|
||||
local show_all_files = fs.is_subpath(path, trash_dir)
|
||||
|
||||
powershell_trash.list_raw_entries(function(err, raw_entries)
|
||||
if err then
|
||||
cb(err)
|
||||
return
|
||||
end
|
||||
|
||||
local raw_displayed_entries = vim.tbl_filter(
|
||||
---@param entry {IsFolder: boolean, DeletionDate: integer, Name: string, Path: string, OriginalPath: string}
|
||||
function(entry)
|
||||
local parent = win_addslash(assert(vim.fn.fnamemodify(entry.OriginalPath, ':h')))
|
||||
local is_in_path = path == parent
|
||||
local is_subpath = fs.is_subpath(path, parent)
|
||||
return is_in_path or is_subpath or show_all_files
|
||||
end,
|
||||
raw_entries
|
||||
)
|
||||
local displayed_entries = vim.tbl_map(
|
||||
---@param entry {IsFolder: boolean, DeletionDate: integer, Name: string, Path: string, OriginalPath: string}
|
||||
---@return {[1]:nil, [2]:string, [3]:string, [4]:{stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}}
|
||||
function(entry)
|
||||
local parent = win_addslash(assert(vim.fn.fnamemodify(entry.OriginalPath, ':h')))
|
||||
|
||||
--- @type oil.InternalEntry
|
||||
local cache_entry
|
||||
if path == parent or show_all_files then
|
||||
local deleted_file_tail = assert(vim.fn.fnamemodify(entry.Path, ':t'))
|
||||
local deleted_file_head = assert(vim.fn.fnamemodify(entry.Path, ':h'))
|
||||
local info_file_head = deleted_file_head
|
||||
--- @type string?
|
||||
local info_file
|
||||
cache_entry =
|
||||
cache.create_entry(url, deleted_file_tail, entry.IsFolder and 'directory' or 'file')
|
||||
-- info_file on windows has the following format: $I<6 char hash>.<extension>
|
||||
-- the hash is the same for the deleted file and the info file
|
||||
-- so, we take the hash (and extension) from the deleted file
|
||||
--
|
||||
-- see https://superuser.com/questions/368890/how-does-the-recycle-bin-in-windows-work/1736690#1736690
|
||||
local info_file_tail = deleted_file_tail:match('^%$R(.*)$') --[[@as string?]]
|
||||
if info_file_tail then
|
||||
info_file_tail = '$I' .. info_file_tail
|
||||
info_file = info_file_head .. '\\' .. info_file_tail
|
||||
end
|
||||
cache_entry[FIELD_META] = {
|
||||
stat = nil,
|
||||
---@type oil.WindowsTrashInfo
|
||||
trash_info = {
|
||||
trash_file = entry.Path,
|
||||
original_path = entry.OriginalPath,
|
||||
deletion_date = entry.DeletionDate,
|
||||
info_file = info_file,
|
||||
},
|
||||
display_name = entry.Name,
|
||||
}
|
||||
end
|
||||
if path ~= parent and (show_all_files or fs.is_subpath(path, parent)) then
|
||||
local name = parent:sub(path:len() + 1)
|
||||
local next_par = vim.fs.dirname(name)
|
||||
while next_par ~= '.' do
|
||||
name = next_par
|
||||
next_par = vim.fs.dirname(name)
|
||||
cache_entry = cache.create_entry(url, name, 'directory')
|
||||
|
||||
cache_entry[FIELD_META] = {}
|
||||
end
|
||||
end
|
||||
return cache_entry
|
||||
end,
|
||||
raw_displayed_entries
|
||||
)
|
||||
cb(nil, displayed_entries)
|
||||
end)
|
||||
end
|
||||
|
||||
M.is_modifiable = function(_bufnr)
|
||||
return true
|
||||
end
|
||||
|
||||
local current_year
|
||||
-- Make sure we run this import-time effect in the main loop (mostly for tests)
|
||||
vim.schedule(function()
|
||||
current_year = vim.fn.strftime('%Y')
|
||||
end)
|
||||
|
||||
local file_columns = {}
|
||||
file_columns.mtime = {
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
if not meta then
|
||||
return nil
|
||||
end
|
||||
---@type oil.WindowsTrashInfo
|
||||
local trash_info = meta.trash_info
|
||||
local time = trash_info and trash_info.deletion_date
|
||||
if not time then
|
||||
return nil
|
||||
end
|
||||
local fmt = conf and conf.format
|
||||
local ret
|
||||
if fmt then
|
||||
ret = vim.fn.strftime(fmt, time)
|
||||
else
|
||||
local year = vim.fn.strftime('%Y', time)
|
||||
if year ~= current_year then
|
||||
ret = vim.fn.strftime('%b %d %Y', time)
|
||||
else
|
||||
ret = vim.fn.strftime('%b %d %H:%M', time)
|
||||
end
|
||||
end
|
||||
return ret
|
||||
end,
|
||||
|
||||
get_sort_value = function(entry)
|
||||
local meta = entry[FIELD_META]
|
||||
---@type nil|oil.WindowsTrashInfo
|
||||
local trash_info = meta and meta.trash_info
|
||||
if trash_info and trash_info.deletion_date then
|
||||
return trash_info.deletion_date
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
local fmt = conf and conf.format
|
||||
local pattern
|
||||
if fmt then
|
||||
pattern = fmt:gsub('%%.', '%%S+')
|
||||
else
|
||||
pattern = '%S+%s+%d+%s+%d%d:?%d%d'
|
||||
end
|
||||
return line:match('^(' .. pattern .. ')%s+(.+)$')
|
||||
end,
|
||||
}
|
||||
|
||||
---@param name string
|
||||
---@return nil|oil.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return file_columns[name]
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@return boolean
|
||||
M.filter_action = function(action)
|
||||
if action.type == 'create' then
|
||||
return false
|
||||
elseif action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META]
|
||||
return meta ~= nil and meta.trash_info ~= nil
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
return src_adapter.name == 'files' or dest_adapter.name == 'files'
|
||||
-- selene: allow(if_same_then_else)
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
return src_adapter.name == 'files' or dest_adapter.name == 'files'
|
||||
else
|
||||
error(string.format("Bad action type '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param callback fun(url: string)
|
||||
M.normalize_url = function(url, callback)
|
||||
local scheme, path = util.parse_url(url)
|
||||
assert(path)
|
||||
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ':p')
|
||||
assert(os_path)
|
||||
uv.fs_realpath(
|
||||
os_path,
|
||||
vim.schedule_wrap(function(_err, new_os_path)
|
||||
local realpath = new_os_path or os_path
|
||||
callback(scheme .. util.addslash(fs.os_to_posix_path(realpath)))
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param entry oil.Entry
|
||||
---@param cb fun(path: string)
|
||||
M.get_entry_path = function(url, entry, cb)
|
||||
local internal_entry = assert(cache.get_entry_by_id(entry.id))
|
||||
local meta = internal_entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
|
||||
local trash_info = meta and meta.trash_info
|
||||
if not trash_info then
|
||||
-- This is a subpath in the trash
|
||||
M.normalize_url(url, cb)
|
||||
return
|
||||
end
|
||||
|
||||
local path = fs.os_to_posix_path(trash_info.trash_file)
|
||||
if entry.type == 'directory' then
|
||||
path = win_addslash(path)
|
||||
end
|
||||
cb('oil://' .. path)
|
||||
end
|
||||
|
||||
---@param err oil.ParseError
|
||||
---@return boolean
|
||||
M.filter_error = function(err)
|
||||
if err.message == 'Duplicate filename' then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
if action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META]
|
||||
---@type oil.WindowsTrashInfo
|
||||
local trash_info = assert(meta).trash_info
|
||||
local short_path = fs.shorten_path(trash_info.original_path)
|
||||
return string.format(' PURGE %s', short_path)
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format(' TRASH %s', short_path)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format('RESTORE %s', short_path)
|
||||
else
|
||||
error('Must be moving files into or out of trash')
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format(' COPY %s -> TRASH', short_path)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format('RESTORE %s', short_path)
|
||||
else
|
||||
error('Must be copying files into or out of trash')
|
||||
end
|
||||
else
|
||||
error(string.format("Bad action type '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param trash_info oil.WindowsTrashInfo
|
||||
---@param cb fun(err?: string, raw_entries: oil.WindowsRawEntry[]?)
|
||||
local purge = function(trash_info, cb)
|
||||
fs.recursive_delete('file', trash_info.info_file, function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
fs.recursive_delete('file', trash_info.trash_file, cb)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param type string
|
||||
---@param cb fun(err?: string, trash_info?: oil.TrashInfo)
|
||||
local function create_trash_info_and_copy(path, type, cb)
|
||||
local temp_path = path .. 'temp'
|
||||
-- create a temporary copy on the same location
|
||||
fs.recursive_copy(
|
||||
type,
|
||||
path,
|
||||
temp_path,
|
||||
vim.schedule_wrap(function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
-- delete original file
|
||||
M.delete_to_trash(path, function(err2)
|
||||
if err2 then
|
||||
return cb(err2)
|
||||
end
|
||||
-- rename temporary copy to the original file name
|
||||
fs.recursive_move(type, temp_path, path, cb)
|
||||
end)
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@param cb fun(err: nil|string)
|
||||
M.perform_action = function(action, cb)
|
||||
if action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
|
||||
local trash_info = meta and meta.trash_info
|
||||
|
||||
purge(trash_info, cb)
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
M.delete_to_trash(assert(path), cb)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
-- Restore
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
dest_path = fs.posix_to_os_path(dest_path)
|
||||
local entry = assert(cache.get_entry_by_url(action.src_url))
|
||||
local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
|
||||
local trash_info = meta and meta.trash_info
|
||||
fs.recursive_move(action.entry_type, trash_info.trash_file, dest_path, function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
uv.fs_unlink(trash_info.info_file, cb)
|
||||
end)
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
path = fs.posix_to_os_path(path)
|
||||
local entry = assert(cache.get_entry_by_url(action.src_url))
|
||||
create_trash_info_and_copy(path, entry[FIELD_TYPE], cb)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
-- Restore
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
dest_path = fs.posix_to_os_path(dest_path)
|
||||
local entry = assert(cache.get_entry_by_url(action.src_url))
|
||||
local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
|
||||
local trash_info = meta and meta.trash_info
|
||||
fs.recursive_copy(action.entry_type, trash_info.trash_file, dest_path, cb)
|
||||
else
|
||||
error('Must be moving files into or out of trash')
|
||||
end
|
||||
else
|
||||
cb(string.format('Bad action type: %s', action.type))
|
||||
end
|
||||
end
|
||||
|
||||
M.supported_cross_adapter_actions = { files = 'move' }
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err?: string)
|
||||
M.delete_to_trash = function(path, cb)
|
||||
powershell_trash.delete_to_trash(path, cb)
|
||||
end
|
||||
|
||||
return M
|
||||
123
lua/oil/adapters/trash/windows/powershell-connection.lua
Normal file
123
lua/oil/adapters/trash/windows/powershell-connection.lua
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
---@class (exact) oil.PowershellCommand
|
||||
---@field cmd string
|
||||
---@field cb fun(err?: string, output?: string)
|
||||
---@field running? boolean
|
||||
|
||||
---@class oil.PowershellConnection
|
||||
---@field private jid integer
|
||||
---@field private execution_error? string
|
||||
---@field private commands oil.PowershellCommand[]
|
||||
---@field private stdout string[]
|
||||
---@field private is_reading_data boolean
|
||||
local PowershellConnection = {}
|
||||
|
||||
---@param init_command? string
|
||||
---@return oil.PowershellConnection
|
||||
function PowershellConnection.new(init_command)
|
||||
local self = setmetatable({
|
||||
commands = {},
|
||||
stdout = {},
|
||||
is_reading_data = false,
|
||||
}, { __index = PowershellConnection })
|
||||
|
||||
self:_init(init_command)
|
||||
|
||||
---@type oil.PowershellConnection
|
||||
return self
|
||||
end
|
||||
|
||||
---@param init_command? string
|
||||
function PowershellConnection:_init(init_command)
|
||||
-- For some reason beyond my understanding, at least one of the following
|
||||
-- things requires `noshellslash` to avoid the embeded powershell process to
|
||||
-- send only "" to the stdout (never calling the callback because
|
||||
-- "===DONE(True)===" is never sent to stdout)
|
||||
-- * vim.fn.jobstart
|
||||
-- * cmd.exe
|
||||
-- * powershell.exe
|
||||
local saved_shellslash = vim.o.shellslash
|
||||
vim.o.shellslash = false
|
||||
|
||||
-- 65001 is the UTF-8 codepage
|
||||
-- powershell needs to be launched with the UTF-8 codepage to use it for both stdin and stdout
|
||||
local jid = vim.fn.jobstart({
|
||||
'cmd',
|
||||
'/c',
|
||||
'"chcp 65001 && powershell -NoProfile -NoLogo -ExecutionPolicy Bypass -NoExit -Command -"',
|
||||
}, {
|
||||
---@param data string[]
|
||||
on_stdout = function(_, data)
|
||||
for _, fragment in ipairs(data) do
|
||||
if fragment:find('===DONE%((%a+)%)===') then
|
||||
self.is_reading_data = false
|
||||
local output = table.concat(self.stdout, '')
|
||||
local cb = self.commands[1].cb
|
||||
table.remove(self.commands, 1)
|
||||
local success = fragment:match('===DONE%((%a+)%)===')
|
||||
if success == 'True' then
|
||||
cb(nil, output)
|
||||
elseif success == 'False' then
|
||||
cb(success .. ': ' .. output, output)
|
||||
end
|
||||
self.stdout = {}
|
||||
self:_consume()
|
||||
elseif self.is_reading_data then
|
||||
table.insert(self.stdout, fragment)
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
vim.o.shellslash = saved_shellslash
|
||||
|
||||
if jid == 0 then
|
||||
self:_set_error("passed invalid arguments to 'powershell'")
|
||||
elseif jid == -1 then
|
||||
self:_set_error("'powershell' is not executable")
|
||||
else
|
||||
self.jid = jid
|
||||
end
|
||||
|
||||
if init_command then
|
||||
table.insert(self.commands, { cmd = init_command, cb = function() end })
|
||||
self:_consume()
|
||||
end
|
||||
end
|
||||
|
||||
---@param command string
|
||||
---@param cb fun(err?: string, output?: string[])
|
||||
function PowershellConnection:run(command, cb)
|
||||
if self.execution_error then
|
||||
cb(self.execution_error)
|
||||
else
|
||||
table.insert(self.commands, { cmd = command, cb = cb })
|
||||
self:_consume()
|
||||
end
|
||||
end
|
||||
|
||||
function PowershellConnection:_consume()
|
||||
if not vim.tbl_isempty(self.commands) then
|
||||
local cmd = self.commands[1]
|
||||
if not cmd.running then
|
||||
cmd.running = true
|
||||
self.is_reading_data = true
|
||||
-- $? contains the execution status of the last command.
|
||||
-- see https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables?view=powershell-7.4#section-1
|
||||
vim.api.nvim_chan_send(self.jid, cmd.cmd .. '\nWrite-Host "===DONE($?)==="\n')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param err string
|
||||
function PowershellConnection:_set_error(err)
|
||||
if self.execution_error then
|
||||
return
|
||||
end
|
||||
self.execution_error = err
|
||||
local commands = self.commands
|
||||
self.commands = {}
|
||||
for _, cmd in ipairs(commands) do
|
||||
cmd.cb(err)
|
||||
end
|
||||
end
|
||||
|
||||
return PowershellConnection
|
||||
78
lua/oil/adapters/trash/windows/powershell-trash.lua
Normal file
78
lua/oil/adapters/trash/windows/powershell-trash.lua
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
-- A wrapper around trash operations using windows powershell
|
||||
local Powershell = require('oil.adapters.trash.windows.powershell-connection')
|
||||
|
||||
---@class oil.WindowsRawEntry
|
||||
---@field IsFolder boolean
|
||||
---@field DeletionDate integer
|
||||
---@field Name string
|
||||
---@field Path string
|
||||
---@field OriginalPath string
|
||||
|
||||
local M = {}
|
||||
|
||||
-- 0xa is the constant for Recycle Bin. See https://learn.microsoft.com/en-us/windows/win32/api/shldisp/ne-shldisp-shellspecialfolderconstants
|
||||
local list_entries_init = [[
|
||||
$shell = New-Object -ComObject 'Shell.Application'
|
||||
$folder = $shell.NameSpace(0xa)
|
||||
]]
|
||||
|
||||
local list_entries_cmd = [[
|
||||
$data = @(foreach ($i in $folder.items())
|
||||
{
|
||||
@{
|
||||
IsFolder=$i.IsFolder;
|
||||
DeletionDate=([DateTimeOffset]$i.extendedproperty('datedeleted')).ToUnixTimeSeconds();
|
||||
Name=$i.Name;
|
||||
Path=$i.Path;
|
||||
OriginalPath=-join($i.ExtendedProperty('DeletedFrom'), "\", $i.Name)
|
||||
}
|
||||
})
|
||||
ConvertTo-Json $data -Compress
|
||||
]]
|
||||
|
||||
---@type nil|oil.PowershellConnection
|
||||
local list_entries_powershell
|
||||
|
||||
---@param cb fun(err?: string, raw_entries?: oil.WindowsRawEntry[])
|
||||
M.list_raw_entries = function(cb)
|
||||
if not list_entries_powershell then
|
||||
list_entries_powershell = Powershell.new(list_entries_init)
|
||||
end
|
||||
list_entries_powershell:run(list_entries_cmd, function(err, string)
|
||||
if err then
|
||||
cb(err)
|
||||
return
|
||||
end
|
||||
|
||||
local ok, value = pcall(vim.json.decode, string)
|
||||
if not ok then
|
||||
cb(value)
|
||||
return
|
||||
end
|
||||
cb(nil, value)
|
||||
end)
|
||||
end
|
||||
|
||||
-- 0 is the constant for Windows Desktop. See https://learn.microsoft.com/en-us/windows/win32/api/shldisp/ne-shldisp-shellspecialfolderconstants
|
||||
local delete_init = [[
|
||||
$shell = New-Object -ComObject 'Shell.Application'
|
||||
$folder = $shell.NameSpace(0)
|
||||
]]
|
||||
local delete_cmd = [[
|
||||
$path = Get-Item '%s'
|
||||
$folder.ParseName($path.FullName).InvokeVerb('delete')
|
||||
]]
|
||||
|
||||
---@type nil|oil.PowershellConnection
|
||||
local delete_to_trash_powershell
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err?: string)
|
||||
M.delete_to_trash = function(path, cb)
|
||||
if not delete_to_trash_powershell then
|
||||
delete_to_trash_powershell = Powershell.new(delete_init)
|
||||
end
|
||||
delete_to_trash_powershell:run((delete_cmd):format(path:gsub("'", "''")), cb)
|
||||
end
|
||||
|
||||
return M
|
||||
Loading…
Add table
Add a link
Reference in a new issue