refactor: rename oil to canola across entire codebase (#70)
Problem: the codebase still used the upstream \`oil\` naming everywhere — URL schemes, the \`:Oil\` command, highlight groups, user events, module paths, filetypes, buffer/window variables, LuaCATS type annotations, vimdoc help tags, syntax groups, and internal identifiers. Solution: mechanical rename of every reference. URL schemes now use \`canola://\` (plus \`canola-ssh://\`, \`canola-s3://\`, \`canola-sss://\`, \`canola-trash://\`, \`canola-test://\`). The \`:Canola\` command replaces \`:Oil\`. All highlight groups, user events, augroups, namespaces, filetypes, require paths, type annotations, help tags, and identifiers follow suit. The \`upstream\` remote to \`stevearc/oil.nvim\` has been removed and the \`vim.g.oil\` deprecation shim dropped.
This commit is contained in:
parent
67ad0632a6
commit
0d3088f57e
70 changed files with 1571 additions and 1555 deletions
663
lua/canola/adapters/files.lua
Normal file
663
lua/canola/adapters/files.lua
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
local cache = require('canola.cache')
|
||||
local columns = require('canola.columns')
|
||||
local config = require('canola.config')
|
||||
local constants = require('canola.constants')
|
||||
local fs = require('canola.fs')
|
||||
local git = require('canola.git')
|
||||
local log = require('canola.log')
|
||||
local permissions = require('canola.adapters.files.permissions')
|
||||
local util = require('canola.util')
|
||||
local uv = vim.uv or vim.loop
|
||||
|
||||
local M = {}
|
||||
|
||||
local FIELD_NAME = constants.FIELD_NAME
|
||||
local FIELD_TYPE = constants.FIELD_TYPE
|
||||
local FIELD_META = constants.FIELD_META
|
||||
|
||||
local function read_link_data(path, cb)
|
||||
uv.fs_readlink(
|
||||
path,
|
||||
vim.schedule_wrap(function(link_err, link)
|
||||
if link_err then
|
||||
cb(link_err)
|
||||
else
|
||||
assert(link)
|
||||
local stat_path = link
|
||||
if not fs.is_absolute(link) then
|
||||
stat_path = fs.join(vim.fn.fnamemodify(path, ':h'), link)
|
||||
end
|
||||
uv.fs_stat(stat_path, function(stat_err, stat)
|
||||
cb(nil, link, stat)
|
||||
end)
|
||||
end
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@class (exact) canola.FilesAdapter: canola.Adapter
|
||||
---@field to_short_os_path fun(path: string, entry_type: nil|canola.EntryType): string
|
||||
|
||||
---@param path string
|
||||
---@param entry_type nil|canola.EntryType
|
||||
---@return string
|
||||
M.to_short_os_path = function(path, entry_type)
|
||||
local shortpath = fs.shorten_path(fs.posix_to_os_path(path))
|
||||
if entry_type == 'directory' then
|
||||
shortpath = util.addslash(shortpath, true)
|
||||
end
|
||||
return shortpath
|
||||
end
|
||||
|
||||
local file_columns = {}
|
||||
|
||||
file_columns.size = {
|
||||
require_stat = true,
|
||||
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
local stat = meta and meta.stat
|
||||
if not stat then
|
||||
return columns.EMPTY
|
||||
end
|
||||
if stat.size >= 1e9 then
|
||||
return string.format('%.1fG', stat.size / 1e9)
|
||||
elseif stat.size >= 1e6 then
|
||||
return string.format('%.1fM', stat.size / 1e6)
|
||||
elseif stat.size >= 1e3 then
|
||||
return string.format('%.1fk', stat.size / 1e3)
|
||||
else
|
||||
return string.format('%d', stat.size)
|
||||
end
|
||||
end,
|
||||
|
||||
get_sort_value = function(entry)
|
||||
local meta = entry[FIELD_META]
|
||||
local stat = meta and meta.stat
|
||||
if stat then
|
||||
return stat.size
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
return line:match('^(%d+%S*)%s+(.*)$')
|
||||
end,
|
||||
}
|
||||
|
||||
-- TODO support file permissions on windows
|
||||
if not fs.is_windows then
|
||||
file_columns.permissions = {
|
||||
require_stat = true,
|
||||
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
local stat = meta and meta.stat
|
||||
if not stat then
|
||||
return columns.EMPTY
|
||||
end
|
||||
return permissions.mode_to_str(stat.mode)
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
return permissions.parse(line)
|
||||
end,
|
||||
|
||||
compare = function(entry, parsed_value)
|
||||
local meta = entry[FIELD_META]
|
||||
if parsed_value and meta and meta.stat and meta.stat.mode then
|
||||
local mask = bit.lshift(1, 12) - 1
|
||||
local old_mode = bit.band(meta.stat.mode, mask)
|
||||
if parsed_value ~= old_mode then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end,
|
||||
|
||||
render_action = function(action)
|
||||
local _, path = util.parse_url(action.url)
|
||||
assert(path)
|
||||
return string.format(
|
||||
'CHMOD %s %s',
|
||||
permissions.mode_to_octal_str(action.value),
|
||||
M.to_short_os_path(path, action.entry_type)
|
||||
)
|
||||
end,
|
||||
|
||||
perform_action = function(action, callback)
|
||||
local _, path = util.parse_url(action.url)
|
||||
assert(path)
|
||||
path = fs.posix_to_os_path(path)
|
||||
uv.fs_stat(path, function(err, stat)
|
||||
if err then
|
||||
return callback(err)
|
||||
end
|
||||
assert(stat)
|
||||
-- We are only changing the lower 12 bits of the mode
|
||||
local mask = bit.bnot(bit.lshift(1, 12) - 1)
|
||||
local old_mode = bit.band(stat.mode, mask)
|
||||
uv.fs_chmod(path, bit.bor(old_mode, action.value), callback)
|
||||
end)
|
||||
end,
|
||||
}
|
||||
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)
|
||||
|
||||
for _, time_key in ipairs({ 'ctime', 'mtime', 'atime', 'birthtime' }) do
|
||||
file_columns[time_key] = {
|
||||
require_stat = true,
|
||||
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
local stat = meta and meta.stat
|
||||
if not stat then
|
||||
return columns.EMPTY
|
||||
end
|
||||
local fmt = conf and conf.format
|
||||
local ret
|
||||
if fmt then
|
||||
ret = vim.fn.strftime(fmt, stat[time_key].sec)
|
||||
else
|
||||
local year = vim.fn.strftime('%Y', stat[time_key].sec)
|
||||
if year ~= current_year then
|
||||
ret = vim.fn.strftime('%b %d %Y', stat[time_key].sec)
|
||||
else
|
||||
ret = vim.fn.strftime('%b %d %H:%M', stat[time_key].sec)
|
||||
end
|
||||
end
|
||||
return ret
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
local fmt = conf and conf.format
|
||||
local pattern
|
||||
if fmt then
|
||||
-- Replace placeholders with a pattern that matches non-space characters (e.g. %H -> %S+)
|
||||
-- and whitespace with a pattern that matches any amount of whitespace
|
||||
-- e.g. "%b %d %Y" -> "%S+%s+%S+%s+%S+"
|
||||
pattern = fmt
|
||||
:gsub('%%.', '%%S+')
|
||||
:gsub('%s+', '%%s+')
|
||||
-- escape `()[]` because those are special characters in Lua patterns
|
||||
:gsub(
|
||||
'%(',
|
||||
'%%('
|
||||
)
|
||||
:gsub('%)', '%%)')
|
||||
:gsub('%[', '%%[')
|
||||
:gsub('%]', '%%]')
|
||||
else
|
||||
pattern = '%S+%s+%d+%s+%d%d:?%d%d'
|
||||
end
|
||||
return line:match('^(' .. pattern .. ')%s+(.+)$')
|
||||
end,
|
||||
|
||||
get_sort_value = function(entry)
|
||||
local meta = entry[FIELD_META]
|
||||
local stat = meta and meta.stat
|
||||
if stat then
|
||||
return stat[time_key].sec
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
---@param column_defs table[]
|
||||
---@return boolean
|
||||
local function columns_require_stat(column_defs)
|
||||
for _, def in ipairs(column_defs) do
|
||||
local name = util.split_config(def)
|
||||
local column = M.get_column(name)
|
||||
---@diagnostic disable-next-line: undefined-field We only put this on the files adapter columns
|
||||
if column and column.require_stat then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
---@param name string
|
||||
---@return nil|canola.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return file_columns[name]
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param callback fun(url: string)
|
||||
M.normalize_url = function(url, callback)
|
||||
local scheme, path = util.parse_url(url)
|
||||
assert(path)
|
||||
|
||||
if fs.is_windows then
|
||||
if path == '/' then
|
||||
return callback(url)
|
||||
else
|
||||
local is_root_drive = path:match('^/%u$')
|
||||
if is_root_drive then
|
||||
return callback(url .. '/')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ':p')
|
||||
uv.fs_realpath(os_path, function(err, new_os_path)
|
||||
local realpath
|
||||
if fs.is_windows then
|
||||
-- Ignore the fs_realpath on windows because it will resolve mapped network drives to the IP
|
||||
-- address instead of using the drive letter
|
||||
realpath = os_path
|
||||
else
|
||||
realpath = new_os_path or os_path
|
||||
end
|
||||
|
||||
uv.fs_stat(
|
||||
realpath,
|
||||
vim.schedule_wrap(function(stat_err, stat)
|
||||
local is_directory
|
||||
if stat then
|
||||
is_directory = stat.type == 'directory'
|
||||
elseif vim.endswith(realpath, '/') or (fs.is_windows and vim.endswith(realpath, '\\')) then
|
||||
is_directory = true
|
||||
else
|
||||
local filetype = vim.filetype.match({ filename = vim.fs.basename(realpath) })
|
||||
is_directory = filetype == nil
|
||||
end
|
||||
|
||||
if is_directory then
|
||||
local norm_path = util.addslash(fs.os_to_posix_path(realpath))
|
||||
callback(scheme .. norm_path)
|
||||
else
|
||||
callback(vim.fn.fnamemodify(realpath, ':.'))
|
||||
end
|
||||
end)
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param entry canola.Entry
|
||||
---@param cb fun(path: nil|string)
|
||||
M.get_entry_path = function(url, entry, cb)
|
||||
if entry.id then
|
||||
local parent_url = cache.get_parent_url(entry.id)
|
||||
local scheme, path = util.parse_url(parent_url)
|
||||
M.normalize_url(scheme .. path .. entry.name, cb)
|
||||
else
|
||||
if entry.type == 'directory' then
|
||||
cb(url)
|
||||
else
|
||||
local _, path = util.parse_url(url)
|
||||
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(assert(path)), ':p')
|
||||
cb(os_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param parent_dir string
|
||||
---@param entry canola.InternalEntry
|
||||
---@param require_stat boolean
|
||||
---@param cb fun(err?: string)
|
||||
local function fetch_entry_metadata(parent_dir, entry, require_stat, cb)
|
||||
local entry_path = fs.posix_to_os_path(parent_dir .. entry[FIELD_NAME])
|
||||
local meta = entry[FIELD_META]
|
||||
if not meta then
|
||||
meta = {}
|
||||
entry[FIELD_META] = meta
|
||||
end
|
||||
|
||||
-- Sometimes fs_readdir entries don't have a type, so we need to stat them.
|
||||
-- See https://github.com/stevearc/canola.nvim/issues/543
|
||||
if not require_stat and not entry[FIELD_TYPE] then
|
||||
require_stat = true
|
||||
end
|
||||
|
||||
-- Make sure we always get fs_stat info for links
|
||||
if entry[FIELD_TYPE] == 'link' then
|
||||
read_link_data(entry_path, function(link_err, link, link_stat)
|
||||
if link_err then
|
||||
log.warn('Error reading link data %s: %s', entry_path, link_err)
|
||||
return cb()
|
||||
end
|
||||
meta.link = link
|
||||
if link_stat then
|
||||
-- Use the fstat of the linked file as the stat for the link
|
||||
meta.link_stat = link_stat
|
||||
meta.stat = link_stat
|
||||
elseif require_stat then
|
||||
-- The link is broken, so let's use the stat of the link itself
|
||||
uv.fs_lstat(entry_path, function(stat_err, stat)
|
||||
if stat_err then
|
||||
log.warn('Error lstat link file %s: %s', entry_path, stat_err)
|
||||
return cb()
|
||||
end
|
||||
meta.stat = stat
|
||||
cb()
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
cb()
|
||||
end)
|
||||
elseif require_stat then
|
||||
uv.fs_stat(entry_path, function(stat_err, stat)
|
||||
if stat_err then
|
||||
log.warn('Error stat file %s: %s', entry_path, stat_err)
|
||||
return cb()
|
||||
end
|
||||
assert(stat)
|
||||
entry[FIELD_TYPE] = stat.type
|
||||
meta.stat = stat
|
||||
cb()
|
||||
end)
|
||||
else
|
||||
cb()
|
||||
end
|
||||
end
|
||||
|
||||
-- On windows, sometimes the entry type from fs_readdir is "link" but the actual type is not.
|
||||
-- See https://github.com/stevearc/canola.nvim/issues/535
|
||||
if fs.is_windows then
|
||||
local old_fetch_metadata = fetch_entry_metadata
|
||||
fetch_entry_metadata = function(parent_dir, entry, require_stat, cb)
|
||||
if entry[FIELD_TYPE] == 'link' then
|
||||
local entry_path = fs.posix_to_os_path(parent_dir .. entry[FIELD_NAME])
|
||||
uv.fs_lstat(entry_path, function(stat_err, stat)
|
||||
if stat_err then
|
||||
log.warn('Error lstat link file %s: %s', entry_path, stat_err)
|
||||
return old_fetch_metadata(parent_dir, entry, require_stat, cb)
|
||||
end
|
||||
assert(stat)
|
||||
entry[FIELD_TYPE] = stat.type
|
||||
local meta = entry[FIELD_META]
|
||||
if not meta then
|
||||
meta = {}
|
||||
entry[FIELD_META] = meta
|
||||
end
|
||||
meta.stat = stat
|
||||
old_fetch_metadata(parent_dir, entry, require_stat, cb)
|
||||
end)
|
||||
else
|
||||
return old_fetch_metadata(parent_dir, entry, require_stat, cb)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: canola.InternalEntry[], fetch_more?: fun())
|
||||
local function list_windows_drives(url, column_defs, cb)
|
||||
local _, path = util.parse_url(url)
|
||||
assert(path)
|
||||
local require_stat = columns_require_stat(column_defs)
|
||||
local stdout = ''
|
||||
local jid = vim.fn.jobstart({ 'wmic', 'logicaldisk', 'get', 'name' }, {
|
||||
stdout_buffered = true,
|
||||
on_stdout = function(_, data)
|
||||
stdout = table.concat(data, '\n')
|
||||
end,
|
||||
on_exit = function(_, code)
|
||||
if code ~= 0 then
|
||||
return cb('Error listing windows devices')
|
||||
end
|
||||
local lines = vim.split(stdout, '\n', { plain = true, trimempty = true })
|
||||
-- Remove the "Name" header
|
||||
table.remove(lines, 1)
|
||||
local internal_entries = {}
|
||||
local complete_disk_cb = util.cb_collect(#lines, function(err)
|
||||
if err then
|
||||
cb(err)
|
||||
else
|
||||
cb(nil, internal_entries)
|
||||
end
|
||||
end)
|
||||
|
||||
for _, disk in ipairs(lines) do
|
||||
if disk:match('^%s*$') then
|
||||
-- Skip empty line
|
||||
complete_disk_cb()
|
||||
else
|
||||
disk = disk:gsub(':%s*$', '')
|
||||
local cache_entry = cache.create_entry(url, disk, 'directory')
|
||||
table.insert(internal_entries, cache_entry)
|
||||
fetch_entry_metadata(path, cache_entry, require_stat, complete_disk_cb)
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
if jid <= 0 then
|
||||
cb('Could not list windows devices')
|
||||
end
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: canola.InternalEntry[], fetch_more?: fun())
|
||||
M.list = function(url, column_defs, cb)
|
||||
local _, path = util.parse_url(url)
|
||||
assert(path)
|
||||
if fs.is_windows and path == '/' then
|
||||
return list_windows_drives(url, column_defs, cb)
|
||||
end
|
||||
local dir = fs.posix_to_os_path(path)
|
||||
local require_stat = columns_require_stat(column_defs)
|
||||
|
||||
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
|
||||
uv.fs_opendir(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)
|
||||
local internal_entries = {}
|
||||
if err then
|
||||
uv.fs_closedir(fd, function()
|
||||
cb(err)
|
||||
end)
|
||||
return
|
||||
elseif entries then
|
||||
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
|
||||
local cache_entry = cache.create_entry(url, entry.name, entry.type)
|
||||
table.insert(internal_entries, cache_entry)
|
||||
fetch_entry_metadata(path, cache_entry, require_stat, 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)
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local _, path = util.parse_url(bufname)
|
||||
assert(path)
|
||||
if fs.is_windows and path == '/' then
|
||||
return false
|
||||
end
|
||||
local dir = fs.posix_to_os_path(path)
|
||||
local stat = uv.fs_stat(dir)
|
||||
if not stat then
|
||||
return true
|
||||
end
|
||||
|
||||
-- fs_access can return nil, force boolean return
|
||||
return uv.fs_access(dir, 'W') == true
|
||||
end
|
||||
|
||||
---@param action canola.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
if action.type == 'create' then
|
||||
local _, path = util.parse_url(action.url)
|
||||
assert(path)
|
||||
local ret = string.format('CREATE %s', M.to_short_os_path(path, action.entry_type))
|
||||
if action.link then
|
||||
ret = ret .. ' -> ' .. fs.posix_to_os_path(action.link)
|
||||
end
|
||||
return ret
|
||||
elseif action.type == 'delete' then
|
||||
local _, path = util.parse_url(action.url)
|
||||
assert(path)
|
||||
local short_path = M.to_short_os_path(path, action.entry_type)
|
||||
if config.delete_to_trash then
|
||||
return string.format(' TRASH %s', short_path)
|
||||
else
|
||||
return string.format('DELETE %s', short_path)
|
||||
end
|
||||
elseif action.type == 'move' or action.type == 'copy' then
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if dest_adapter == M then
|
||||
local _, src_path = util.parse_url(action.src_url)
|
||||
assert(src_path)
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
return string.format(
|
||||
' %s %s -> %s',
|
||||
action.type:upper(),
|
||||
M.to_short_os_path(src_path, action.entry_type),
|
||||
M.to_short_os_path(dest_path, action.entry_type)
|
||||
)
|
||||
else
|
||||
-- We should never hit this because we don't implement supported_cross_adapter_actions
|
||||
error("files adapter doesn't support cross-adapter move/copy")
|
||||
end
|
||||
else
|
||||
error(string.format("Bad action type: '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param action canola.Action
|
||||
---@param cb fun(err: nil|string)
|
||||
M.perform_action = function(action, cb)
|
||||
if action.type == 'create' then
|
||||
local _, path = util.parse_url(action.url)
|
||||
assert(path)
|
||||
path = fs.posix_to_os_path(path)
|
||||
|
||||
if config.git.add(path) then
|
||||
local old_cb = cb
|
||||
cb = vim.schedule_wrap(function(err)
|
||||
if not err then
|
||||
git.add(path, old_cb)
|
||||
else
|
||||
old_cb(err)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
if action.entry_type == 'directory' then
|
||||
uv.fs_mkdir(path, config.new_dir_mode, function(err)
|
||||
-- Ignore if the directory already exists
|
||||
if not err or err:match('^EEXIST:') then
|
||||
cb()
|
||||
else
|
||||
cb(err)
|
||||
end
|
||||
end)
|
||||
elseif action.entry_type == 'link' and action.link then
|
||||
local flags = nil
|
||||
local target = fs.posix_to_os_path(action.link)
|
||||
if fs.is_windows then
|
||||
flags = {
|
||||
dir = vim.fn.isdirectory(target) == 1,
|
||||
junction = false,
|
||||
}
|
||||
end
|
||||
---@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)
|
||||
path = fs.posix_to_os_path(path)
|
||||
|
||||
if config.git.rm(path) then
|
||||
local old_cb = cb
|
||||
cb = vim.schedule_wrap(function(err)
|
||||
if not err then
|
||||
git.rm(path, old_cb)
|
||||
else
|
||||
old_cb(err)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
if config.delete_to_trash then
|
||||
require('canola.adapters.trash').delete_to_trash(path, cb)
|
||||
else
|
||||
fs.recursive_delete(action.entry_type, path, cb)
|
||||
end
|
||||
elseif action.type == 'move' then
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if dest_adapter == M then
|
||||
local _, src_path = util.parse_url(action.src_url)
|
||||
assert(src_path)
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
src_path = fs.posix_to_os_path(src_path)
|
||||
dest_path = fs.posix_to_os_path(dest_path)
|
||||
if config.git.mv(src_path, dest_path) then
|
||||
git.mv(action.entry_type, src_path, dest_path, cb)
|
||||
else
|
||||
fs.recursive_move(action.entry_type, src_path, dest_path, cb)
|
||||
end
|
||||
else
|
||||
-- We should never hit this because we don't implement supported_cross_adapter_actions
|
||||
cb("files adapter doesn't support cross-adapter move")
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if dest_adapter == M then
|
||||
local _, src_path = util.parse_url(action.src_url)
|
||||
assert(src_path)
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
src_path = fs.posix_to_os_path(src_path)
|
||||
dest_path = fs.posix_to_os_path(dest_path)
|
||||
fs.recursive_copy(action.entry_type, src_path, dest_path, cb)
|
||||
else
|
||||
-- We should never hit this because we don't implement supported_cross_adapter_actions
|
||||
cb("files adapter doesn't support cross-adapter copy")
|
||||
end
|
||||
else
|
||||
cb(string.format('Bad action type: %s', action.type))
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
103
lua/canola/adapters/files/permissions.lua
Normal file
103
lua/canola/adapters/files/permissions.lua
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
local M = {}
|
||||
|
||||
---@param exe_modifier false|string
|
||||
---@param num integer
|
||||
---@return string
|
||||
local function perm_to_str(exe_modifier, num)
|
||||
local str = (bit.band(num, 4) ~= 0 and 'r' or '-') .. (bit.band(num, 2) ~= 0 and 'w' or '-')
|
||||
if exe_modifier then
|
||||
if bit.band(num, 1) ~= 0 then
|
||||
return str .. exe_modifier
|
||||
else
|
||||
return str .. exe_modifier:upper()
|
||||
end
|
||||
else
|
||||
return str .. (bit.band(num, 1) ~= 0 and 'x' or '-')
|
||||
end
|
||||
end
|
||||
|
||||
---@param mode integer
|
||||
---@return string
|
||||
M.mode_to_str = function(mode)
|
||||
local extra = bit.rshift(mode, 9)
|
||||
return perm_to_str(bit.band(extra, 4) ~= 0 and 's', bit.rshift(mode, 6))
|
||||
.. perm_to_str(bit.band(extra, 2) ~= 0 and 's', bit.rshift(mode, 3))
|
||||
.. perm_to_str(bit.band(extra, 1) ~= 0 and 't', mode)
|
||||
end
|
||||
|
||||
---@param mode integer
|
||||
---@return string
|
||||
M.mode_to_octal_str = function(mode)
|
||||
local mask = 7
|
||||
return tostring(bit.band(mask, bit.rshift(mode, 9)))
|
||||
.. tostring(bit.band(mask, bit.rshift(mode, 6)))
|
||||
.. tostring(bit.band(mask, bit.rshift(mode, 3)))
|
||||
.. tostring(bit.band(mask, mode))
|
||||
end
|
||||
|
||||
---@param str string String of 3 characters
|
||||
---@return nil|integer
|
||||
local function str_to_mode(str)
|
||||
local r, w, x = unpack(vim.split(str, '', {}))
|
||||
local mode = 0
|
||||
if r == 'r' then
|
||||
mode = bit.bor(mode, 4)
|
||||
elseif r ~= '-' then
|
||||
return nil
|
||||
end
|
||||
if w == 'w' then
|
||||
mode = bit.bor(mode, 2)
|
||||
elseif w ~= '-' then
|
||||
return nil
|
||||
end
|
||||
-- t means sticky and executable
|
||||
-- T means sticky, not executable
|
||||
-- s means setuid/setgid and executable
|
||||
-- S means setuid/setgid and not executable
|
||||
if x == 'x' or x == 't' or x == 's' then
|
||||
mode = bit.bor(mode, 1)
|
||||
elseif x ~= '-' and x ~= 'T' and x ~= 'S' then
|
||||
return nil
|
||||
end
|
||||
return mode
|
||||
end
|
||||
|
||||
---@param perm string
|
||||
---@return integer
|
||||
local function parse_extra_bits(perm)
|
||||
perm = perm:lower()
|
||||
local mode = 0
|
||||
if perm:sub(3, 3) == 's' then
|
||||
mode = bit.bor(mode, 4)
|
||||
end
|
||||
if perm:sub(6, 6) == 's' then
|
||||
mode = bit.bor(mode, 2)
|
||||
end
|
||||
if perm:sub(9, 9) == 't' then
|
||||
mode = bit.bor(mode, 1)
|
||||
end
|
||||
return mode
|
||||
end
|
||||
|
||||
---@param line string
|
||||
---@return nil|integer
|
||||
---@return nil|string
|
||||
M.parse = function(line)
|
||||
local strval, rem = line:match('^([r%-][w%-][xsS%-][r%-][w%-][xsS%-][r%-][w%-][xtT%-])%s*(.*)$')
|
||||
if not strval then
|
||||
return
|
||||
end
|
||||
local user_mode = str_to_mode(strval:sub(1, 3))
|
||||
local group_mode = str_to_mode(strval:sub(4, 6))
|
||||
local any_mode = str_to_mode(strval:sub(7, 9))
|
||||
local extra = parse_extra_bits(strval)
|
||||
if not user_mode or not group_mode or not any_mode then
|
||||
return
|
||||
end
|
||||
local mode = bit.bor(bit.lshift(user_mode, 6), bit.lshift(group_mode, 3))
|
||||
mode = bit.bor(mode, any_mode)
|
||||
mode = bit.bor(mode, bit.lshift(extra, 9))
|
||||
return mode, rem
|
||||
end
|
||||
|
||||
return M
|
||||
389
lua/canola/adapters/s3.lua
Normal file
389
lua/canola/adapters/s3.lua
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
local config = require('canola.config')
|
||||
local constants = require('canola.constants')
|
||||
local files = require('canola.adapters.files')
|
||||
local fs = require('canola.fs')
|
||||
local loading = require('canola.loading')
|
||||
local pathutil = require('canola.pathutil')
|
||||
local s3fs = require('canola.adapters.s3.s3fs')
|
||||
local util = require('canola.util')
|
||||
local M = {}
|
||||
|
||||
local FIELD_META = constants.FIELD_META
|
||||
|
||||
---@class (exact) canola.s3Url
|
||||
---@field scheme string
|
||||
---@field bucket nil|string
|
||||
---@field path nil|string
|
||||
|
||||
---@param canola_url string
|
||||
---@return canola.s3Url
|
||||
M.parse_url = function(canola_url)
|
||||
local scheme, url = util.parse_url(canola_url)
|
||||
assert(scheme and url, string.format("Malformed input url '%s'", canola_url))
|
||||
local ret = { scheme = scheme }
|
||||
local bucket, path = url:match('^([^/]+)/?(.*)$')
|
||||
ret.bucket = bucket
|
||||
ret.path = path ~= '' and path or nil
|
||||
if not ret.bucket and ret.path then
|
||||
error(string.format('Parsing error for s3 url: %s', canola_url))
|
||||
end
|
||||
---@cast ret canola.s3Url
|
||||
return ret
|
||||
end
|
||||
|
||||
---@param url canola.s3Url
|
||||
---@return string
|
||||
local function url_to_str(url)
|
||||
local pieces = { url.scheme }
|
||||
if url.bucket then
|
||||
assert(url.bucket ~= '')
|
||||
table.insert(pieces, url.bucket)
|
||||
table.insert(pieces, '/')
|
||||
end
|
||||
if url.path then
|
||||
assert(url.path ~= '')
|
||||
table.insert(pieces, url.path)
|
||||
end
|
||||
return table.concat(pieces, '')
|
||||
end
|
||||
|
||||
---@param url canola.s3Url
|
||||
---@param is_folder boolean
|
||||
---@return string
|
||||
local function url_to_s3(url, is_folder)
|
||||
local pieces = { 's3://' }
|
||||
if url.bucket then
|
||||
assert(url.bucket ~= '')
|
||||
table.insert(pieces, url.bucket)
|
||||
table.insert(pieces, '/')
|
||||
end
|
||||
if url.path then
|
||||
assert(url.path ~= '')
|
||||
table.insert(pieces, url.path)
|
||||
if is_folder and not vim.endswith(url.path, '/') then
|
||||
table.insert(pieces, '/')
|
||||
end
|
||||
end
|
||||
return table.concat(pieces, '')
|
||||
end
|
||||
|
||||
---@param url canola.s3Url
|
||||
---@return boolean
|
||||
local function is_bucket(url)
|
||||
assert(url.bucket and url.bucket ~= '')
|
||||
if url.path then
|
||||
assert(url.path ~= '')
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local s3_columns = {}
|
||||
s3_columns.size = {
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
if not meta or not meta.size then
|
||||
return ''
|
||||
elseif meta.size >= 1e9 then
|
||||
return string.format('%.1fG', meta.size / 1e9)
|
||||
elseif meta.size >= 1e6 then
|
||||
return string.format('%.1fM', meta.size / 1e6)
|
||||
elseif meta.size >= 1e3 then
|
||||
return string.format('%.1fk', meta.size / 1e3)
|
||||
else
|
||||
return string.format('%d', meta.size)
|
||||
end
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
return line:match('^(%d+%S*)%s+(.*)$')
|
||||
end,
|
||||
|
||||
get_sort_value = function(entry)
|
||||
local meta = entry[FIELD_META]
|
||||
if meta and meta.size then
|
||||
return meta.size
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
s3_columns.birthtime = {
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
if not meta or not meta.date then
|
||||
return ''
|
||||
else
|
||||
return meta.date
|
||||
end
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
return line:match('^(%d+%-%d+%-%d+%s%d+:%d+:%d+)%s+(.*)$')
|
||||
end,
|
||||
|
||||
get_sort_value = function(entry)
|
||||
local meta = entry[FIELD_META]
|
||||
if meta and meta.date then
|
||||
local year, month, day, hour, min, sec =
|
||||
meta.date:match('^(%d+)%-(%d+)%-(%d+)%s(%d+):(%d+):(%d+)$')
|
||||
local time =
|
||||
os.time({ year = year, month = month, day = day, hour = hour, min = min, sec = sec })
|
||||
return time
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
---@param name string
|
||||
---@return nil|canola.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return s3_columns[name]
|
||||
end
|
||||
|
||||
---@param bufname string
|
||||
---@return string
|
||||
M.get_parent = function(bufname)
|
||||
local res = M.parse_url(bufname)
|
||||
if res.path then
|
||||
assert(res.path ~= '')
|
||||
local path = pathutil.parent(res.path)
|
||||
res.path = path ~= '' and path or nil
|
||||
else
|
||||
res.bucket = nil
|
||||
end
|
||||
return url_to_str(res)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param callback fun(url: string)
|
||||
M.normalize_url = function(url, callback)
|
||||
local res = M.parse_url(url)
|
||||
callback(url_to_str(res))
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param callback fun(err?: string, entries?: canola.InternalEntry[], fetch_more?: fun())
|
||||
M.list = function(url, column_defs, callback)
|
||||
if vim.fn.executable('aws') ~= 1 then
|
||||
callback('`aws` is not executable. Can you run `aws s3 ls`?')
|
||||
return
|
||||
end
|
||||
|
||||
local res = M.parse_url(url)
|
||||
s3fs.list_dir(url, url_to_s3(res, true), callback)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return boolean
|
||||
M.is_modifiable = function(bufnr)
|
||||
-- default assumption is that everything is modifiable
|
||||
return true
|
||||
end
|
||||
|
||||
---@param action canola.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
local is_folder = action.entry_type == 'directory'
|
||||
if action.type == 'create' then
|
||||
local res = M.parse_url(action.url)
|
||||
local extra = is_bucket(res) and 'BUCKET ' or ''
|
||||
return string.format('CREATE %s%s', extra, url_to_s3(res, is_folder))
|
||||
elseif action.type == 'delete' then
|
||||
local res = M.parse_url(action.url)
|
||||
local extra = is_bucket(res) and 'BUCKET ' or ''
|
||||
return string.format('DELETE %s%s', extra, url_to_s3(res, is_folder))
|
||||
elseif action.type == 'move' or action.type == 'copy' then
|
||||
local src = action.src_url
|
||||
local dest = action.dest_url
|
||||
if config.get_adapter_by_scheme(src) ~= M then
|
||||
local _, path = util.parse_url(src)
|
||||
assert(path)
|
||||
src = files.to_short_os_path(path, action.entry_type)
|
||||
dest = url_to_s3(M.parse_url(dest), is_folder)
|
||||
elseif config.get_adapter_by_scheme(dest) ~= M then
|
||||
local _, path = util.parse_url(dest)
|
||||
assert(path)
|
||||
dest = files.to_short_os_path(path, action.entry_type)
|
||||
src = url_to_s3(M.parse_url(src), is_folder)
|
||||
end
|
||||
return string.format(' %s %s -> %s', action.type:upper(), src, dest)
|
||||
else
|
||||
error(string.format("Bad action type: '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param action canola.Action
|
||||
---@param cb fun(err: nil|string)
|
||||
M.perform_action = function(action, cb)
|
||||
local is_folder = action.entry_type == 'directory'
|
||||
if action.type == 'create' then
|
||||
local res = M.parse_url(action.url)
|
||||
local bucket = is_bucket(res)
|
||||
|
||||
if action.entry_type == 'directory' and bucket then
|
||||
s3fs.mb(url_to_s3(res, true), cb)
|
||||
elseif action.entry_type == 'directory' or action.entry_type == 'file' then
|
||||
s3fs.touch(url_to_s3(res, is_folder), cb)
|
||||
else
|
||||
cb(string.format('Bad entry type on s3 create action: %s', action.entry_type))
|
||||
end
|
||||
elseif action.type == 'delete' then
|
||||
local res = M.parse_url(action.url)
|
||||
local bucket = is_bucket(res)
|
||||
|
||||
if action.entry_type == 'directory' and bucket then
|
||||
s3fs.rb(url_to_s3(res, true), cb)
|
||||
elseif action.entry_type == 'directory' or action.entry_type == 'file' then
|
||||
s3fs.rm(url_to_s3(res, is_folder), is_folder, cb)
|
||||
else
|
||||
cb(string.format('Bad entry type on s3 delete action: %s', action.entry_type))
|
||||
end
|
||||
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 ~= M and src_adapter ~= files) or (dest_adapter ~= M and dest_adapter ~= files)
|
||||
then
|
||||
cb(
|
||||
string.format(
|
||||
'We should never attempt to move from the %s adapter to the %s adapter.',
|
||||
src_adapter.name,
|
||||
dest_adapter.name
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
local src, _
|
||||
if src_adapter == M then
|
||||
local src_res = M.parse_url(action.src_url)
|
||||
src = url_to_s3(src_res, is_folder)
|
||||
else
|
||||
_, src = util.parse_url(action.src_url)
|
||||
end
|
||||
assert(src)
|
||||
|
||||
local dest
|
||||
if dest_adapter == M then
|
||||
local dest_res = M.parse_url(action.dest_url)
|
||||
dest = url_to_s3(dest_res, is_folder)
|
||||
else
|
||||
_, dest = util.parse_url(action.dest_url)
|
||||
end
|
||||
assert(dest)
|
||||
|
||||
s3fs.mv(src, dest, is_folder, cb)
|
||||
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 ~= M and src_adapter ~= files) or (dest_adapter ~= M and dest_adapter ~= files)
|
||||
then
|
||||
cb(
|
||||
string.format(
|
||||
'We should never attempt to copy from the %s adapter to the %s adapter.',
|
||||
src_adapter.name,
|
||||
dest_adapter.name
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
local src, _
|
||||
if src_adapter == M then
|
||||
local src_res = M.parse_url(action.src_url)
|
||||
src = url_to_s3(src_res, is_folder)
|
||||
else
|
||||
_, src = util.parse_url(action.src_url)
|
||||
end
|
||||
assert(src)
|
||||
|
||||
local dest
|
||||
if dest_adapter == M then
|
||||
local dest_res = M.parse_url(action.dest_url)
|
||||
dest = url_to_s3(dest_res, is_folder)
|
||||
else
|
||||
_, dest = util.parse_url(action.dest_url)
|
||||
end
|
||||
assert(dest)
|
||||
|
||||
s3fs.cp(src, dest, is_folder, cb)
|
||||
else
|
||||
cb(string.format('Bad action type: %s', action.type))
|
||||
end
|
||||
end
|
||||
|
||||
M.supported_cross_adapter_actions = { files = 'move' }
|
||||
|
||||
---@param bufnr integer
|
||||
M.read_file = function(bufnr)
|
||||
loading.set_loading(bufnr, true)
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local url = M.parse_url(bufname)
|
||||
local basename = pathutil.basename(bufname)
|
||||
local cache_dir = vim.fn.stdpath('cache')
|
||||
assert(type(cache_dir) == 'string')
|
||||
local tmpdir = fs.join(cache_dir, 'canola')
|
||||
fs.mkdirp(tmpdir)
|
||||
local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, 's3_XXXXXX'))
|
||||
if fd then
|
||||
vim.loop.fs_close(fd)
|
||||
end
|
||||
local tmp_bufnr = vim.fn.bufadd(tmpfile)
|
||||
|
||||
s3fs.cp(url_to_s3(url, false), tmpfile, false, function(err)
|
||||
loading.set_loading(bufnr, false)
|
||||
vim.bo[bufnr].modifiable = true
|
||||
vim.cmd.doautocmd({ args = { 'BufReadPre', bufname }, mods = { silent = true } })
|
||||
if err then
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, vim.split(err, '\n'))
|
||||
else
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, {})
|
||||
vim.api.nvim_buf_call(bufnr, function()
|
||||
vim.cmd.read({ args = { tmpfile }, mods = { silent = true } })
|
||||
end)
|
||||
vim.loop.fs_unlink(tmpfile)
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, 1, true, {})
|
||||
end
|
||||
vim.bo[bufnr].modified = false
|
||||
local filetype = vim.filetype.match({ buf = bufnr, filename = basename })
|
||||
if filetype then
|
||||
vim.bo[bufnr].filetype = filetype
|
||||
end
|
||||
vim.cmd.doautocmd({ args = { 'BufReadPost', bufname }, mods = { silent = true } })
|
||||
vim.api.nvim_buf_delete(tmp_bufnr, { force = true })
|
||||
end)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
M.write_file = function(bufnr)
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local url = M.parse_url(bufname)
|
||||
local cache_dir = vim.fn.stdpath('cache')
|
||||
assert(type(cache_dir) == 'string')
|
||||
local tmpdir = fs.join(cache_dir, 'canola')
|
||||
local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, 's3_XXXXXXXX'))
|
||||
if fd then
|
||||
vim.loop.fs_close(fd)
|
||||
end
|
||||
vim.cmd.doautocmd({ args = { 'BufWritePre', bufname }, mods = { silent = true } })
|
||||
vim.bo[bufnr].modifiable = false
|
||||
vim.cmd.write({ args = { tmpfile }, bang = true, mods = { silent = true, noautocmd = true } })
|
||||
local tmp_bufnr = vim.fn.bufadd(tmpfile)
|
||||
|
||||
s3fs.cp(tmpfile, url_to_s3(url, false), false, function(err)
|
||||
vim.bo[bufnr].modifiable = true
|
||||
if err then
|
||||
vim.notify(string.format('Error writing file: %s', err), vim.log.levels.ERROR)
|
||||
else
|
||||
vim.bo[bufnr].modified = false
|
||||
vim.cmd.doautocmd({ args = { 'BufWritePost', bufname }, mods = { silent = true } })
|
||||
end
|
||||
vim.loop.fs_unlink(tmpfile)
|
||||
vim.api.nvim_buf_delete(tmp_bufnr, { force = true })
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
149
lua/canola/adapters/s3/s3fs.lua
Normal file
149
lua/canola/adapters/s3/s3fs.lua
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
local cache = require('canola.cache')
|
||||
local config = require('canola.config')
|
||||
local constants = require('canola.constants')
|
||||
local shell = require('canola.shell')
|
||||
local util = require('canola.util')
|
||||
|
||||
local M = {}
|
||||
|
||||
local FIELD_META = constants.FIELD_META
|
||||
|
||||
---@param line string
|
||||
---@return string Name of entry
|
||||
---@return canola.EntryType
|
||||
---@return table Metadata for entry
|
||||
local function parse_ls_line_bucket(line)
|
||||
local date, name = line:match('^(%d+%-%d+%-%d+%s%d+:%d+:%d+)%s+(.*)$')
|
||||
if not date or not name then
|
||||
error(string.format("Could not parse '%s'", line))
|
||||
end
|
||||
local type = 'directory'
|
||||
local meta = { date = date }
|
||||
return name, type, meta
|
||||
end
|
||||
|
||||
---@param line string
|
||||
---@return string Name of entry
|
||||
---@return canola.EntryType
|
||||
---@return table Metadata for entry
|
||||
local function parse_ls_line_file(line)
|
||||
local name = line:match('^%s+PRE%s+(.*)/$')
|
||||
local type = 'directory'
|
||||
local meta = {}
|
||||
if name then
|
||||
return name, type, meta
|
||||
end
|
||||
local date, size
|
||||
date, size, name = line:match('^(%d+%-%d+%-%d+%s%d+:%d+:%d+)%s+(%d+)%s+(.*)$')
|
||||
if not name then
|
||||
error(string.format("Could not parse '%s'", line))
|
||||
end
|
||||
type = 'file'
|
||||
meta = { date = date, size = tonumber(size) }
|
||||
return name, type, meta
|
||||
end
|
||||
|
||||
---@param cmd string[] cmd and flags
|
||||
---@return string[] Shell command to run
|
||||
local function create_s3_command(cmd)
|
||||
local full_cmd = vim.list_extend({ 'aws', 's3' }, cmd)
|
||||
return vim.list_extend(full_cmd, config.extra_s3_args)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param path string
|
||||
---@param callback fun(err?: string, entries?: canola.InternalEntry[], fetch_more?: fun())
|
||||
function M.list_dir(url, path, callback)
|
||||
local cmd = create_s3_command({ 'ls', path, '--color=off', '--no-cli-pager' })
|
||||
shell.run(cmd, function(err, lines)
|
||||
if err then
|
||||
return callback(err)
|
||||
end
|
||||
assert(lines)
|
||||
local cache_entries = {}
|
||||
local url_path, _
|
||||
_, url_path = util.parse_url(url)
|
||||
local is_top_level = url_path == nil or url_path:match('/') == nil
|
||||
local parse_ls_line = is_top_level and parse_ls_line_bucket or parse_ls_line_file
|
||||
for _, line in ipairs(lines) do
|
||||
if line ~= '' then
|
||||
local name, type, meta = parse_ls_line(line)
|
||||
-- in s3 '-' can be used to create an "empty folder"
|
||||
if name ~= '-' then
|
||||
local cache_entry = cache.create_entry(url, name, type)
|
||||
table.insert(cache_entries, cache_entry)
|
||||
cache_entry[FIELD_META] = meta
|
||||
end
|
||||
end
|
||||
end
|
||||
callback(nil, cache_entries)
|
||||
end)
|
||||
end
|
||||
|
||||
--- Create files
|
||||
---@param path string
|
||||
---@param callback fun(err: nil|string)
|
||||
function M.touch(path, callback)
|
||||
-- here "-" means that we copy from stdin
|
||||
local cmd = create_s3_command({ 'cp', '-', path })
|
||||
shell.run(cmd, { stdin = 'null' }, callback)
|
||||
end
|
||||
|
||||
--- Remove files
|
||||
---@param path string
|
||||
---@param is_folder boolean
|
||||
---@param callback fun(err: nil|string)
|
||||
function M.rm(path, is_folder, callback)
|
||||
local main_cmd = { 'rm', path }
|
||||
if is_folder then
|
||||
table.insert(main_cmd, '--recursive')
|
||||
end
|
||||
local cmd = create_s3_command(main_cmd)
|
||||
shell.run(cmd, callback)
|
||||
end
|
||||
|
||||
--- Remove bucket
|
||||
---@param bucket string
|
||||
---@param callback fun(err: nil|string)
|
||||
function M.rb(bucket, callback)
|
||||
local cmd = create_s3_command({ 'rb', bucket })
|
||||
shell.run(cmd, callback)
|
||||
end
|
||||
|
||||
--- Make bucket
|
||||
---@param bucket string
|
||||
---@param callback fun(err: nil|string)
|
||||
function M.mb(bucket, callback)
|
||||
local cmd = create_s3_command({ 'mb', bucket })
|
||||
shell.run(cmd, callback)
|
||||
end
|
||||
|
||||
--- Move files
|
||||
---@param src string
|
||||
---@param dest string
|
||||
---@param is_folder boolean
|
||||
---@param callback fun(err: nil|string)
|
||||
function M.mv(src, dest, is_folder, callback)
|
||||
local main_cmd = { 'mv', src, dest }
|
||||
if is_folder then
|
||||
table.insert(main_cmd, '--recursive')
|
||||
end
|
||||
local cmd = create_s3_command(main_cmd)
|
||||
shell.run(cmd, callback)
|
||||
end
|
||||
|
||||
--- Copy files
|
||||
---@param src string
|
||||
---@param dest string
|
||||
---@param is_folder boolean
|
||||
---@param callback fun(err: nil|string)
|
||||
function M.cp(src, dest, is_folder, callback)
|
||||
local main_cmd = { 'cp', src, dest }
|
||||
if is_folder then
|
||||
table.insert(main_cmd, '--recursive')
|
||||
end
|
||||
local cmd = create_s3_command(main_cmd)
|
||||
shell.run(cmd, callback)
|
||||
end
|
||||
|
||||
return M
|
||||
474
lua/canola/adapters/ssh.lua
Normal file
474
lua/canola/adapters/ssh.lua
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
local config = require('canola.config')
|
||||
local constants = require('canola.constants')
|
||||
local files = require('canola.adapters.files')
|
||||
local fs = require('canola.fs')
|
||||
local loading = require('canola.loading')
|
||||
local pathutil = require('canola.pathutil')
|
||||
local permissions = require('canola.adapters.files.permissions')
|
||||
local shell = require('canola.shell')
|
||||
local sshfs = require('canola.adapters.ssh.sshfs')
|
||||
local util = require('canola.util')
|
||||
local M = {}
|
||||
|
||||
local FIELD_NAME = constants.FIELD_NAME
|
||||
local FIELD_META = constants.FIELD_META
|
||||
|
||||
---@class (exact) canola.sshUrl
|
||||
---@field scheme string
|
||||
---@field host string
|
||||
---@field user nil|string
|
||||
---@field port nil|integer
|
||||
---@field path string
|
||||
|
||||
---@param args string[]
|
||||
local function scp(args, ...)
|
||||
local cmd = vim.list_extend({ 'scp', '-C' }, config.extra_scp_args)
|
||||
vim.list_extend(cmd, args)
|
||||
shell.run(cmd, ...)
|
||||
end
|
||||
|
||||
---@param canola_url string
|
||||
---@return canola.sshUrl
|
||||
M.parse_url = function(canola_url)
|
||||
local scheme, url = util.parse_url(canola_url)
|
||||
assert(scheme and url, string.format("Malformed input url '%s'", canola_url))
|
||||
local ret = { scheme = scheme }
|
||||
local username, rem = url:match('^([^@%s]+)@(.*)$')
|
||||
ret.user = username
|
||||
url = rem or url
|
||||
local host, port, path = url:match('^([^:]+):(%d+)/(.*)$')
|
||||
if host then
|
||||
ret.host = host
|
||||
ret.port = tonumber(port)
|
||||
ret.path = path
|
||||
else
|
||||
host, path = url:match('^([^/]+)/(.*)$')
|
||||
ret.host = host
|
||||
ret.path = path
|
||||
end
|
||||
if not ret.host or not ret.path then
|
||||
error(string.format('Malformed SSH url: %s', canola_url))
|
||||
end
|
||||
|
||||
---@cast ret canola.sshUrl
|
||||
return ret
|
||||
end
|
||||
|
||||
---@param url canola.sshUrl
|
||||
---@return string
|
||||
local function url_to_str(url)
|
||||
local pieces = { url.scheme }
|
||||
if url.user then
|
||||
table.insert(pieces, url.user)
|
||||
table.insert(pieces, '@')
|
||||
end
|
||||
table.insert(pieces, url.host)
|
||||
if url.port then
|
||||
table.insert(pieces, string.format(':%d', url.port))
|
||||
end
|
||||
table.insert(pieces, '/')
|
||||
table.insert(pieces, url.path)
|
||||
return table.concat(pieces, '')
|
||||
end
|
||||
|
||||
---@param url canola.sshUrl
|
||||
---@return string
|
||||
local function url_to_scp(url)
|
||||
local pieces = { 'scp://' }
|
||||
if url.user then
|
||||
table.insert(pieces, url.user)
|
||||
table.insert(pieces, '@')
|
||||
end
|
||||
table.insert(pieces, url.host)
|
||||
if url.port then
|
||||
table.insert(pieces, string.format(':%d', url.port))
|
||||
end
|
||||
table.insert(pieces, '/')
|
||||
local escaped_path = util.url_escape(url.path)
|
||||
table.insert(pieces, escaped_path)
|
||||
return table.concat(pieces, '')
|
||||
end
|
||||
|
||||
---@param url1 canola.sshUrl
|
||||
---@param url2 canola.sshUrl
|
||||
---@return boolean
|
||||
local function url_hosts_equal(url1, url2)
|
||||
return url1.host == url2.host and url1.port == url2.port and url1.user == url2.user
|
||||
end
|
||||
|
||||
local _connections = {}
|
||||
---@param url string
|
||||
---@param allow_retry nil|boolean
|
||||
---@return canola.sshFs
|
||||
local function get_connection(url, allow_retry)
|
||||
local res = M.parse_url(url)
|
||||
res.scheme = config.adapter_to_scheme.ssh
|
||||
res.path = ''
|
||||
local key = url_to_str(res)
|
||||
local conn = _connections[key]
|
||||
if not conn or (allow_retry and conn:get_connection_error()) then
|
||||
conn = sshfs.new(res)
|
||||
_connections[key] = conn
|
||||
end
|
||||
return conn
|
||||
end
|
||||
|
||||
local ssh_columns = {}
|
||||
ssh_columns.permissions = {
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
return meta and permissions.mode_to_str(meta.mode)
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
return permissions.parse(line)
|
||||
end,
|
||||
|
||||
compare = function(entry, parsed_value)
|
||||
local meta = entry[FIELD_META]
|
||||
if parsed_value and meta and meta.mode then
|
||||
local mask = bit.lshift(1, 12) - 1
|
||||
local old_mode = bit.band(meta.mode, mask)
|
||||
if parsed_value ~= old_mode then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end,
|
||||
|
||||
render_action = function(action)
|
||||
return string.format('CHMOD %s %s', permissions.mode_to_octal_str(action.value), action.url)
|
||||
end,
|
||||
|
||||
perform_action = function(action, callback)
|
||||
local res = M.parse_url(action.url)
|
||||
local conn = get_connection(action.url)
|
||||
conn:chmod(action.value, res.path, callback)
|
||||
end,
|
||||
}
|
||||
|
||||
ssh_columns.size = {
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
if not meta or not meta.size then
|
||||
return ''
|
||||
elseif meta.size >= 1e9 then
|
||||
return string.format('%.1fG', meta.size / 1e9)
|
||||
elseif meta.size >= 1e6 then
|
||||
return string.format('%.1fM', meta.size / 1e6)
|
||||
elseif meta.size >= 1e3 then
|
||||
return string.format('%.1fk', meta.size / 1e3)
|
||||
else
|
||||
return string.format('%d', meta.size)
|
||||
end
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
return line:match('^(%d+%S*)%s+(.*)$')
|
||||
end,
|
||||
|
||||
get_sort_value = function(entry)
|
||||
local meta = entry[FIELD_META]
|
||||
if meta and meta.size then
|
||||
return meta.size
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
---@param name string
|
||||
---@return nil|canola.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return ssh_columns[name]
|
||||
end
|
||||
|
||||
---For debugging
|
||||
M.open_terminal = function()
|
||||
local conn = get_connection(vim.api.nvim_buf_get_name(0))
|
||||
if conn then
|
||||
conn:open_terminal()
|
||||
end
|
||||
end
|
||||
|
||||
---@param bufname string
|
||||
---@return string
|
||||
M.get_parent = function(bufname)
|
||||
local res = M.parse_url(bufname)
|
||||
res.path = pathutil.parent(res.path)
|
||||
return url_to_str(res)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param callback fun(url: string)
|
||||
M.normalize_url = function(url, callback)
|
||||
local res = M.parse_url(url)
|
||||
local conn = get_connection(url, true)
|
||||
|
||||
local path = res.path
|
||||
if path == '' then
|
||||
path = '.'
|
||||
end
|
||||
|
||||
conn:realpath(path, function(err, abspath)
|
||||
if err then
|
||||
vim.notify(string.format('Error normalizing url %s: %s', url, err), vim.log.levels.WARN)
|
||||
callback(url)
|
||||
else
|
||||
res.path = abspath
|
||||
callback(url_to_str(res))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param callback fun(err?: string, entries?: canola.InternalEntry[], fetch_more?: fun())
|
||||
M.list = function(url, column_defs, callback)
|
||||
local res = M.parse_url(url)
|
||||
|
||||
local conn = get_connection(url)
|
||||
conn:list_dir(url, res.path, callback)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return boolean
|
||||
M.is_modifiable = function(bufnr)
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local conn = get_connection(bufname)
|
||||
local dir_meta = conn:get_dir_meta(bufname)
|
||||
if not dir_meta then
|
||||
-- Directories that don't exist yet are modifiable
|
||||
return true
|
||||
end
|
||||
local meta = conn:get_meta()
|
||||
if not meta.user or not meta.groups then
|
||||
return false
|
||||
end
|
||||
local rwx
|
||||
if dir_meta.user == meta.user then
|
||||
rwx = bit.rshift(dir_meta.mode, 6)
|
||||
elseif vim.tbl_contains(meta.groups, dir_meta.group) then
|
||||
rwx = bit.rshift(dir_meta.mode, 3)
|
||||
else
|
||||
rwx = dir_meta.mode
|
||||
end
|
||||
return bit.band(rwx, 2) ~= 0
|
||||
end
|
||||
|
||||
---@param action canola.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
if action.type == 'create' then
|
||||
local ret = string.format('CREATE %s', action.url)
|
||||
if action.link then
|
||||
ret = ret .. ' -> ' .. action.link
|
||||
end
|
||||
return ret
|
||||
elseif action.type == 'delete' then
|
||||
return string.format('DELETE %s', action.url)
|
||||
elseif action.type == 'move' or action.type == 'copy' then
|
||||
local src = action.src_url
|
||||
local dest = action.dest_url
|
||||
if config.get_adapter_by_scheme(src) == M then
|
||||
local _, path = util.parse_url(dest)
|
||||
assert(path)
|
||||
dest = files.to_short_os_path(path, action.entry_type)
|
||||
else
|
||||
local _, path = util.parse_url(src)
|
||||
assert(path)
|
||||
src = files.to_short_os_path(path, action.entry_type)
|
||||
end
|
||||
return string.format(' %s %s -> %s', action.type:upper(), src, dest)
|
||||
else
|
||||
error(string.format("Bad action type: '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param action canola.Action
|
||||
---@param cb fun(err: nil|string)
|
||||
M.perform_action = function(action, cb)
|
||||
if action.type == 'create' then
|
||||
local res = M.parse_url(action.url)
|
||||
local conn = get_connection(action.url)
|
||||
if action.entry_type == 'directory' then
|
||||
conn:mkdir(res.path, cb)
|
||||
elseif action.entry_type == 'link' and action.link then
|
||||
conn:mklink(res.path, action.link, cb)
|
||||
else
|
||||
conn:touch(res.path, cb)
|
||||
end
|
||||
elseif action.type == 'delete' then
|
||||
local res = M.parse_url(action.url)
|
||||
local conn = get_connection(action.url)
|
||||
conn:rm(res.path, 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 == M and dest_adapter == M then
|
||||
local src_res = M.parse_url(action.src_url)
|
||||
local dest_res = M.parse_url(action.dest_url)
|
||||
local src_conn = get_connection(action.src_url)
|
||||
local dest_conn = get_connection(action.dest_url)
|
||||
if src_conn ~= dest_conn then
|
||||
scp({ '-r', url_to_scp(src_res), url_to_scp(dest_res) }, function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
src_conn:rm(src_res.path, cb)
|
||||
end)
|
||||
else
|
||||
src_conn:mv(src_res.path, dest_res.path, cb)
|
||||
end
|
||||
else
|
||||
cb('We should never attempt to move across adapters')
|
||||
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 == M and dest_adapter == M then
|
||||
local src_res = M.parse_url(action.src_url)
|
||||
local dest_res = M.parse_url(action.dest_url)
|
||||
if not url_hosts_equal(src_res, dest_res) then
|
||||
scp({ '-r', url_to_scp(src_res), url_to_scp(dest_res) }, cb)
|
||||
else
|
||||
local src_conn = get_connection(action.src_url)
|
||||
src_conn:cp(src_res.path, dest_res.path, cb)
|
||||
end
|
||||
else
|
||||
local src_arg
|
||||
local dest_arg
|
||||
if src_adapter == M then
|
||||
src_arg = url_to_scp(M.parse_url(action.src_url))
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
dest_arg = fs.posix_to_os_path(path)
|
||||
else
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
src_arg = fs.posix_to_os_path(path)
|
||||
dest_arg = url_to_scp(M.parse_url(action.dest_url))
|
||||
end
|
||||
scp({ '-r', src_arg, dest_arg }, cb)
|
||||
end
|
||||
else
|
||||
cb(string.format('Bad action type: %s', action.type))
|
||||
end
|
||||
end
|
||||
|
||||
M.supported_cross_adapter_actions = { files = 'copy' }
|
||||
|
||||
---@param bufnr integer
|
||||
M.read_file = function(bufnr)
|
||||
loading.set_loading(bufnr, true)
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local url = M.parse_url(bufname)
|
||||
local scp_url = url_to_scp(url)
|
||||
local basename = pathutil.basename(bufname)
|
||||
local cache_dir = vim.fn.stdpath('cache')
|
||||
assert(type(cache_dir) == 'string')
|
||||
local tmpdir = fs.join(cache_dir, 'canola')
|
||||
fs.mkdirp(tmpdir)
|
||||
local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, 'ssh_XXXXXX'))
|
||||
if fd then
|
||||
vim.loop.fs_close(fd)
|
||||
end
|
||||
local tmp_bufnr = vim.fn.bufadd(tmpfile)
|
||||
|
||||
scp({ scp_url, tmpfile }, function(err)
|
||||
loading.set_loading(bufnr, false)
|
||||
vim.bo[bufnr].modifiable = true
|
||||
vim.cmd.doautocmd({ args = { 'BufReadPre', bufname }, mods = { silent = true } })
|
||||
if err then
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, vim.split(err, '\n'))
|
||||
else
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, {})
|
||||
vim.api.nvim_buf_call(bufnr, function()
|
||||
vim.cmd.read({ args = { tmpfile }, mods = { silent = true } })
|
||||
end)
|
||||
vim.loop.fs_unlink(tmpfile)
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, 1, true, {})
|
||||
end
|
||||
vim.bo[bufnr].modified = false
|
||||
local filetype = vim.filetype.match({ buf = bufnr, filename = basename })
|
||||
if filetype then
|
||||
vim.bo[bufnr].filetype = filetype
|
||||
end
|
||||
vim.cmd.doautocmd({ args = { 'BufReadPost', bufname }, mods = { silent = true } })
|
||||
vim.api.nvim_buf_delete(tmp_bufnr, { force = true })
|
||||
vim.keymap.set('n', 'gf', M.goto_file, { buffer = bufnr })
|
||||
end)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
M.write_file = function(bufnr)
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local url = M.parse_url(bufname)
|
||||
local scp_url = url_to_scp(url)
|
||||
local cache_dir = vim.fn.stdpath('cache')
|
||||
assert(type(cache_dir) == 'string')
|
||||
local tmpdir = fs.join(cache_dir, 'canola')
|
||||
local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, 'ssh_XXXXXXXX'))
|
||||
if fd then
|
||||
vim.loop.fs_close(fd)
|
||||
end
|
||||
vim.cmd.doautocmd({ args = { 'BufWritePre', bufname }, mods = { silent = true } })
|
||||
vim.bo[bufnr].modifiable = false
|
||||
vim.cmd.write({ args = { tmpfile }, bang = true, mods = { silent = true, noautocmd = true } })
|
||||
local tmp_bufnr = vim.fn.bufadd(tmpfile)
|
||||
|
||||
scp({ tmpfile, scp_url }, function(err)
|
||||
vim.bo[bufnr].modifiable = true
|
||||
if err then
|
||||
vim.notify(string.format('Error writing file: %s', err), vim.log.levels.ERROR)
|
||||
else
|
||||
vim.bo[bufnr].modified = false
|
||||
vim.cmd.doautocmd({ args = { 'BufWritePost', bufname }, mods = { silent = true } })
|
||||
end
|
||||
vim.loop.fs_unlink(tmpfile)
|
||||
vim.api.nvim_buf_delete(tmp_bufnr, { force = true })
|
||||
end)
|
||||
end
|
||||
|
||||
M.goto_file = function()
|
||||
local url = M.parse_url(vim.api.nvim_buf_get_name(0))
|
||||
local fname = vim.fn.expand('<cfile>')
|
||||
local fullpath = fname
|
||||
if not fs.is_absolute(fname) then
|
||||
local pardir = vim.fs.dirname(url.path)
|
||||
fullpath = fs.join(pardir, fname)
|
||||
end
|
||||
url.path = vim.fs.dirname(fullpath)
|
||||
local parurl = url_to_str(url)
|
||||
|
||||
---@cast M canola.Adapter
|
||||
util.adapter_list_all(M, parurl, {}, function(err, entries)
|
||||
if err then
|
||||
vim.notify(string.format("Error finding file '%s': %s", fname, err), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
assert(entries)
|
||||
local name_map = {}
|
||||
for _, entry in ipairs(entries) do
|
||||
name_map[entry[FIELD_NAME]] = entry
|
||||
end
|
||||
|
||||
local basename = vim.fs.basename(fullpath)
|
||||
if name_map[basename] then
|
||||
url.path = fullpath
|
||||
vim.cmd.edit({ args = { url_to_str(url) } })
|
||||
return
|
||||
end
|
||||
for suffix in vim.gsplit(vim.o.suffixesadd, ',', { plain = true, trimempty = true }) do
|
||||
local suffixname = basename .. suffix
|
||||
if name_map[suffixname] then
|
||||
url.path = fullpath .. suffix
|
||||
vim.cmd.edit({ args = { url_to_str(url) } })
|
||||
return
|
||||
end
|
||||
end
|
||||
vim.notify(string.format("Can't find file '%s'", fname), vim.log.levels.ERROR)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
317
lua/canola/adapters/ssh/connection.lua
Normal file
317
lua/canola/adapters/ssh/connection.lua
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
local config = require('canola.config')
|
||||
local layout = require('canola.layout')
|
||||
local util = require('canola.util')
|
||||
|
||||
---@class (exact) canola.sshCommand
|
||||
---@field cmd string|string[]
|
||||
---@field cb fun(err?: string, output?: string[])
|
||||
---@field running? boolean
|
||||
|
||||
---@class (exact) canola.sshConnection
|
||||
---@field new fun(url: canola.sshUrl): canola.sshConnection
|
||||
---@field create_ssh_command fun(url: canola.sshUrl): string[]
|
||||
---@field meta {user?: string, groups?: string[]}
|
||||
---@field connection_error nil|string
|
||||
---@field connected boolean
|
||||
---@field private term_bufnr integer
|
||||
---@field private jid integer
|
||||
---@field private term_winid nil|integer
|
||||
---@field private commands canola.sshCommand[]
|
||||
---@field private _stdout string[]
|
||||
local SSHConnection = {}
|
||||
|
||||
local function output_extend(agg, output)
|
||||
local start = #agg
|
||||
if vim.tbl_isempty(agg) then
|
||||
for _, line in ipairs(output) do
|
||||
line = line:gsub('\r', '')
|
||||
table.insert(agg, line)
|
||||
end
|
||||
else
|
||||
for i, v in ipairs(output) do
|
||||
v = v:gsub('\r', '')
|
||||
if i == 1 then
|
||||
agg[#agg] = agg[#agg] .. v
|
||||
else
|
||||
table.insert(agg, v)
|
||||
end
|
||||
end
|
||||
end
|
||||
return start
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param num_lines integer
|
||||
---@return string[]
|
||||
local function get_last_lines(bufnr, num_lines)
|
||||
local end_line = vim.api.nvim_buf_line_count(bufnr)
|
||||
num_lines = math.min(num_lines, end_line)
|
||||
local lines = {}
|
||||
while end_line > 0 and #lines < num_lines do
|
||||
local need_lines = num_lines - #lines
|
||||
lines = vim.list_extend(
|
||||
vim.api.nvim_buf_get_lines(bufnr, end_line - need_lines, end_line, false),
|
||||
lines
|
||||
)
|
||||
while not vim.tbl_isempty(lines) and lines[#lines]:match('^%s*$') do
|
||||
table.remove(lines)
|
||||
end
|
||||
end_line = end_line - need_lines
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
---@param url canola.sshUrl
|
||||
---@return string[]
|
||||
function SSHConnection.create_ssh_command(url)
|
||||
local host = url.host
|
||||
if url.user then
|
||||
host = url.user .. '@' .. host
|
||||
end
|
||||
local command = {
|
||||
'ssh',
|
||||
host,
|
||||
}
|
||||
if url.port then
|
||||
table.insert(command, '-p')
|
||||
table.insert(command, url.port)
|
||||
end
|
||||
return command
|
||||
end
|
||||
|
||||
---@param url canola.sshUrl
|
||||
---@return canola.sshConnection
|
||||
function SSHConnection.new(url)
|
||||
local command = SSHConnection.create_ssh_command(url)
|
||||
vim.list_extend(command, {
|
||||
'/bin/sh',
|
||||
'-c',
|
||||
-- HACK: For some reason in my testing if I just have "echo READY" it doesn't appear, but if I echo
|
||||
-- anything prior to that, it *will* appear. The first line gets swallowed.
|
||||
"echo '_make_newline_'; echo '===READY==='; exec /bin/sh",
|
||||
})
|
||||
local term_bufnr = vim.api.nvim_create_buf(false, true)
|
||||
local self = setmetatable({
|
||||
meta = {},
|
||||
commands = {},
|
||||
connected = false,
|
||||
connection_error = nil,
|
||||
term_bufnr = term_bufnr,
|
||||
}, {
|
||||
__index = SSHConnection,
|
||||
})
|
||||
|
||||
local term_id
|
||||
local mode = vim.api.nvim_get_mode().mode
|
||||
util.run_in_fullscreen_win(term_bufnr, function()
|
||||
term_id = vim.api.nvim_open_term(term_bufnr, {
|
||||
on_input = function(_, _, _, data)
|
||||
---@diagnostic disable-next-line: invisible
|
||||
pcall(vim.api.nvim_chan_send, self.jid, data)
|
||||
end,
|
||||
})
|
||||
end)
|
||||
self.term_id = term_id
|
||||
vim.api.nvim_chan_send(term_id, string.format('ssh %s\r\n', url.host))
|
||||
util.hack_around_termopen_autocmd(mode)
|
||||
|
||||
-- If it takes more than 2 seconds to connect, pop open the terminal
|
||||
vim.defer_fn(function()
|
||||
if not self.connected and not self.connection_error then
|
||||
self:open_terminal()
|
||||
end
|
||||
end, 2000)
|
||||
self._stdout = {}
|
||||
local jid = vim.fn.jobstart(command, {
|
||||
pty = true, -- This is require for interactivity
|
||||
on_stdout = function(j, output)
|
||||
pcall(vim.api.nvim_chan_send, self.term_id, table.concat(output, '\r\n'))
|
||||
---@diagnostic disable-next-line: invisible
|
||||
local new_i_start = output_extend(self._stdout, output)
|
||||
self:_handle_output(new_i_start)
|
||||
end,
|
||||
on_exit = function(j, code)
|
||||
pcall(
|
||||
vim.api.nvim_chan_send,
|
||||
self.term_id,
|
||||
string.format('\r\n[Process exited %d]\r\n', code)
|
||||
)
|
||||
-- Defer to allow the deferred terminal output handling to kick in first
|
||||
vim.defer_fn(function()
|
||||
if code == 0 then
|
||||
self:_set_connection_error('SSH connection terminated gracefully')
|
||||
else
|
||||
self:_set_connection_error(
|
||||
'Unknown SSH error\nTo see more, run :lua require("canola.adapters.ssh").open_terminal()'
|
||||
)
|
||||
end
|
||||
end, 20)
|
||||
end,
|
||||
})
|
||||
local exe = command[1]
|
||||
if jid == 0 then
|
||||
self:_set_connection_error(string.format("Passed invalid arguments to '%s'", exe))
|
||||
elseif jid == -1 then
|
||||
self:_set_connection_error(string.format("'%s' is not executable", exe))
|
||||
else
|
||||
self.jid = jid
|
||||
end
|
||||
self:run('id -u', function(err, lines)
|
||||
if err then
|
||||
vim.notify(string.format('Error fetching ssh connection user: %s', err), vim.log.levels.WARN)
|
||||
else
|
||||
assert(lines)
|
||||
self.meta.user = vim.trim(table.concat(lines, ''))
|
||||
end
|
||||
end)
|
||||
self:run('id -G', function(err, lines)
|
||||
if err then
|
||||
vim.notify(
|
||||
string.format('Error fetching ssh connection user groups: %s', err),
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
else
|
||||
assert(lines)
|
||||
self.meta.groups = vim.split(table.concat(lines, ''), '%s+', { trimempty = true })
|
||||
end
|
||||
end)
|
||||
|
||||
---@cast self canola.sshConnection
|
||||
return self
|
||||
end
|
||||
|
||||
---@param err string
|
||||
function SSHConnection:_set_connection_error(err)
|
||||
if self.connection_error then
|
||||
return
|
||||
end
|
||||
self.connection_error = err
|
||||
local commands = self.commands
|
||||
self.commands = {}
|
||||
for _, cmd in ipairs(commands) do
|
||||
cmd.cb(err)
|
||||
end
|
||||
end
|
||||
|
||||
function SSHConnection:_handle_output(start_i)
|
||||
if not self.connected then
|
||||
for i = start_i, #self._stdout - 1 do
|
||||
local line = self._stdout[i]
|
||||
if line == '===READY===' then
|
||||
if self.term_winid then
|
||||
if vim.api.nvim_win_is_valid(self.term_winid) then
|
||||
vim.api.nvim_win_close(self.term_winid, true)
|
||||
end
|
||||
self.term_winid = nil
|
||||
end
|
||||
self.connected = true
|
||||
self._stdout = util.tbl_slice(self._stdout, i + 1)
|
||||
self:_handle_output(1)
|
||||
self:_consume()
|
||||
return
|
||||
end
|
||||
end
|
||||
else
|
||||
for i = start_i, #self._stdout - 1 do
|
||||
---@type string
|
||||
local line = self._stdout[i]
|
||||
if line:match('^===BEGIN===%s*$') then
|
||||
self._stdout = util.tbl_slice(self._stdout, i + 1)
|
||||
self:_handle_output(1)
|
||||
return
|
||||
end
|
||||
-- We can't be as strict with the matching (^$) because since we're using a pty the stdout and
|
||||
-- stderr can be interleaved. If the command had an error, the stderr may interfere with a
|
||||
-- clean print of the done line.
|
||||
local exit_code = line:match('===DONE%((%d+)%)===')
|
||||
if exit_code then
|
||||
local output = util.tbl_slice(self._stdout, 1, i - 1)
|
||||
local cb = self.commands[1].cb
|
||||
self._stdout = util.tbl_slice(self._stdout, i + 1)
|
||||
if exit_code == '0' then
|
||||
cb(nil, output)
|
||||
else
|
||||
cb(exit_code .. ': ' .. table.concat(output, '\n'), output)
|
||||
end
|
||||
table.remove(self.commands, 1)
|
||||
self:_handle_output(1)
|
||||
self:_consume()
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function check_last_line()
|
||||
local last_lines = get_last_lines(self.term_bufnr, 1)
|
||||
local last_line = last_lines[1]
|
||||
if last_line:match('^Are you sure you want to continue connecting') then
|
||||
self:open_terminal()
|
||||
-- selene: allow(if_same_then_else)
|
||||
elseif last_line:match('Password:%s*$') then
|
||||
self:open_terminal()
|
||||
elseif last_line:match(': Permission denied %(.+%)%.') then
|
||||
self:_set_connection_error(last_line:match(': (Permission denied %(.+%).)'))
|
||||
elseif last_line:match('^ssh: .*Connection refused%s*$') then
|
||||
self:_set_connection_error('Connection refused')
|
||||
elseif last_line:match('^Connection to .+ closed by remote host.%s*$') then
|
||||
self:_set_connection_error('Connection closed by remote host')
|
||||
end
|
||||
end
|
||||
-- We have to defer this so the terminal buffer has time to update
|
||||
vim.defer_fn(check_last_line, 10)
|
||||
end
|
||||
|
||||
function SSHConnection:open_terminal()
|
||||
if self.term_winid and vim.api.nvim_win_is_valid(self.term_winid) then
|
||||
vim.api.nvim_set_current_win(self.term_winid)
|
||||
return
|
||||
end
|
||||
local min_width = 120
|
||||
local min_height = 20
|
||||
local total_height = layout.get_editor_height()
|
||||
local width = math.min(min_width, vim.o.columns - 2)
|
||||
local height = math.min(min_height, total_height - 3)
|
||||
local row = math.floor((total_height - height) / 2)
|
||||
local col = math.floor((vim.o.columns - width) / 2)
|
||||
self.term_winid = vim.api.nvim_open_win(self.term_bufnr, true, {
|
||||
relative = 'editor',
|
||||
width = width,
|
||||
height = height,
|
||||
row = row,
|
||||
col = col,
|
||||
style = 'minimal',
|
||||
border = config.ssh.border,
|
||||
})
|
||||
vim.cmd.startinsert()
|
||||
end
|
||||
|
||||
---@param command string
|
||||
---@param callback fun(err: nil|string, lines: nil|string[])
|
||||
function SSHConnection:run(command, callback)
|
||||
if self.connection_error then
|
||||
callback(self.connection_error)
|
||||
else
|
||||
table.insert(self.commands, { cmd = command, cb = callback })
|
||||
self:_consume()
|
||||
end
|
||||
end
|
||||
|
||||
function SSHConnection:_consume()
|
||||
if self.connected and not vim.tbl_isempty(self.commands) then
|
||||
local cmd = self.commands[1]
|
||||
if not cmd.running then
|
||||
cmd.running = true
|
||||
vim.api.nvim_chan_send(
|
||||
self.jid,
|
||||
-- HACK: Sleep briefly to help reduce stderr/stdout interleaving.
|
||||
-- I want to find a way to flush the stderr before the echo DONE, but haven't yet.
|
||||
-- This was causing issues when ls directory that doesn't exist (b/c ls prints error)
|
||||
'echo "===BEGIN==="; '
|
||||
.. cmd.cmd
|
||||
.. '; CODE=$?; sleep .01; echo "===DONE($CODE)==="\r'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return SSHConnection
|
||||
264
lua/canola/adapters/ssh/sshfs.lua
Normal file
264
lua/canola/adapters/ssh/sshfs.lua
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
local SSHConnection = require('canola.adapters.ssh.connection')
|
||||
local cache = require('canola.cache')
|
||||
local constants = require('canola.constants')
|
||||
local permissions = require('canola.adapters.files.permissions')
|
||||
local util = require('canola.util')
|
||||
|
||||
---@class (exact) canola.sshFs
|
||||
---@field new fun(url: canola.sshUrl): canola.sshFs
|
||||
---@field conn canola.sshConnection
|
||||
local SSHFS = {}
|
||||
|
||||
local FIELD_TYPE = constants.FIELD_TYPE
|
||||
local FIELD_META = constants.FIELD_META
|
||||
|
||||
local typechar_map = {
|
||||
l = 'link',
|
||||
d = 'directory',
|
||||
p = 'fifo',
|
||||
s = 'socket',
|
||||
['-'] = 'file',
|
||||
c = 'file', -- character special file
|
||||
b = 'file', -- block special file
|
||||
}
|
||||
---@param line string
|
||||
---@return string Name of entry
|
||||
---@return canola.EntryType
|
||||
---@return table Metadata for entry
|
||||
local function parse_ls_line(line)
|
||||
local typechar, perms, refcount, user, group, rem =
|
||||
line:match('^(.)(%S+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(.*)$')
|
||||
if not typechar then
|
||||
error(string.format("Could not parse '%s'", line))
|
||||
end
|
||||
local type = typechar_map[typechar] or 'file'
|
||||
|
||||
local meta = {
|
||||
user = user,
|
||||
group = group,
|
||||
mode = permissions.parse(perms),
|
||||
refcount = tonumber(refcount),
|
||||
}
|
||||
local name, size, date, major, minor
|
||||
if typechar == 'c' or typechar == 'b' then
|
||||
major, minor, date, name = rem:match('^(%d+)%s*,%s*(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)')
|
||||
if name == nil then
|
||||
major, minor, date, name =
|
||||
rem:match('^(%d+)%s*,%s*(%d+)%s+(%d+%-%d+%-%d+%s+%d%d:?%d%d)%s+(.*)')
|
||||
end
|
||||
meta.major = tonumber(major)
|
||||
meta.minor = tonumber(minor)
|
||||
else
|
||||
size, date, name = rem:match('^(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)')
|
||||
if name == nil then
|
||||
size, date, name = rem:match('^(%d+)%s+(%d+%-%d+%-%d+%s+%d%d:?%d%d)%s+(.*)')
|
||||
end
|
||||
meta.size = tonumber(size)
|
||||
end
|
||||
meta.iso_modified_date = date
|
||||
if type == 'link' then
|
||||
local link
|
||||
name, link = unpack(vim.split(name, ' -> ', { plain = true }))
|
||||
if vim.endswith(link, '/') then
|
||||
link = link:sub(1, #link - 1)
|
||||
end
|
||||
meta.link = link
|
||||
end
|
||||
|
||||
return name, type, meta
|
||||
end
|
||||
|
||||
---@param str string String to escape
|
||||
---@return string Escaped string
|
||||
local function shellescape(str)
|
||||
return "'" .. str:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
---@param url canola.sshUrl
|
||||
---@return canola.sshFs
|
||||
function SSHFS.new(url)
|
||||
---@type canola.sshFs
|
||||
return setmetatable({
|
||||
conn = SSHConnection.new(url),
|
||||
}, {
|
||||
__index = SSHFS,
|
||||
})
|
||||
end
|
||||
|
||||
function SSHFS:get_connection_error()
|
||||
return self.conn.connection_error
|
||||
end
|
||||
|
||||
---@param value integer
|
||||
---@param path string
|
||||
---@param callback fun(err: nil|string)
|
||||
function SSHFS:chmod(value, path, callback)
|
||||
local octal = permissions.mode_to_octal_str(value)
|
||||
self.conn:run(string.format('chmod %s %s', octal, shellescape(path)), callback)
|
||||
end
|
||||
|
||||
function SSHFS:open_terminal()
|
||||
self.conn:open_terminal()
|
||||
end
|
||||
|
||||
function SSHFS:realpath(path, callback)
|
||||
local cmd = string.format(
|
||||
'if ! readlink -f "%s" 2>/dev/null; then [[ "%s" == /* ]] && echo "%s" || echo "$PWD/%s"; fi',
|
||||
path,
|
||||
path,
|
||||
path,
|
||||
path
|
||||
)
|
||||
self.conn:run(cmd, function(err, lines)
|
||||
if err then
|
||||
return callback(err)
|
||||
end
|
||||
assert(lines)
|
||||
local abspath = table.concat(lines, '')
|
||||
-- If the path was "." then the abspath might be /path/to/., so we need to trim that final '.'
|
||||
if vim.endswith(abspath, '.') then
|
||||
abspath = abspath:sub(1, #abspath - 1)
|
||||
end
|
||||
self.conn:run(
|
||||
string.format('LC_ALL=C ls -land --color=never %s', shellescape(abspath)),
|
||||
function(ls_err, ls_lines)
|
||||
local type
|
||||
if ls_err then
|
||||
-- If the file doesn't exist, treat it like a not-yet-existing directory
|
||||
type = 'directory'
|
||||
else
|
||||
assert(ls_lines)
|
||||
local _
|
||||
_, type = parse_ls_line(ls_lines[1])
|
||||
end
|
||||
if type == 'directory' then
|
||||
abspath = util.addslash(abspath)
|
||||
end
|
||||
callback(nil, abspath)
|
||||
end
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
local dir_meta = {}
|
||||
|
||||
---@param url string
|
||||
---@param path string
|
||||
---@param callback fun(err?: string, entries?: canola.InternalEntry[], fetch_more?: fun())
|
||||
function SSHFS:list_dir(url, path, callback)
|
||||
local path_postfix = ''
|
||||
if path ~= '' then
|
||||
path_postfix = string.format(' %s', shellescape(path))
|
||||
end
|
||||
self.conn:run('LC_ALL=C ls -lan --color=never' .. path_postfix, function(err, lines)
|
||||
if err then
|
||||
if err:match('No such file or directory%s*$') 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 callback()
|
||||
else
|
||||
return callback(err)
|
||||
end
|
||||
end
|
||||
assert(lines)
|
||||
local any_links = false
|
||||
local entries = {}
|
||||
local cache_entries = {}
|
||||
for _, line in ipairs(lines) do
|
||||
if line ~= '' and not line:match('^total') then
|
||||
local name, type, meta = parse_ls_line(line)
|
||||
if name == '.' then
|
||||
dir_meta[url] = meta
|
||||
elseif name ~= '..' then
|
||||
if type == 'link' then
|
||||
any_links = true
|
||||
end
|
||||
local cache_entry = cache.create_entry(url, name, type)
|
||||
table.insert(cache_entries, cache_entry)
|
||||
entries[name] = cache_entry
|
||||
cache_entry[FIELD_META] = meta
|
||||
end
|
||||
end
|
||||
end
|
||||
if any_links then
|
||||
-- If there were any soft links, then we need to run another ls command with -L so that we can
|
||||
-- resolve the type of the link target
|
||||
self.conn:run(
|
||||
'LC_ALL=C ls -naLl --color=never' .. path_postfix .. ' 2> /dev/null',
|
||||
function(link_err, link_lines)
|
||||
-- Ignore exit code 1. That just means one of the links could not be resolved.
|
||||
if link_err and not link_err:match('^1:') then
|
||||
return callback(link_err)
|
||||
end
|
||||
assert(link_lines)
|
||||
for _, line in ipairs(link_lines) do
|
||||
if line ~= '' and not line:match('^total') then
|
||||
local ok, name, type, meta = pcall(parse_ls_line, line)
|
||||
if ok and name ~= '.' and name ~= '..' then
|
||||
local cache_entry = entries[name]
|
||||
if cache_entry[FIELD_TYPE] == 'link' then
|
||||
cache_entry[FIELD_META].link_stat = {
|
||||
type = type,
|
||||
size = meta.size,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
callback(nil, cache_entries)
|
||||
end
|
||||
)
|
||||
else
|
||||
callback(nil, cache_entries)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param callback fun(err: nil|string)
|
||||
function SSHFS:mkdir(path, callback)
|
||||
self.conn:run(string.format('mkdir -p %s', shellescape(path)), callback)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param callback fun(err: nil|string)
|
||||
function SSHFS:touch(path, callback)
|
||||
self.conn:run(string.format('touch %s', shellescape(path)), callback)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param link string
|
||||
---@param callback fun(err: nil|string)
|
||||
function SSHFS:mklink(path, link, callback)
|
||||
self.conn:run(string.format('ln -s %s %s', shellescape(link), shellescape(path)), callback)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param callback fun(err: nil|string)
|
||||
function SSHFS:rm(path, callback)
|
||||
self.conn:run(string.format('rm -rf %s', shellescape(path)), callback)
|
||||
end
|
||||
|
||||
---@param src string
|
||||
---@param dest string
|
||||
---@param callback fun(err: nil|string)
|
||||
function SSHFS:mv(src, dest, callback)
|
||||
self.conn:run(string.format('mv %s %s', shellescape(src), shellescape(dest)), callback)
|
||||
end
|
||||
|
||||
---@param src string
|
||||
---@param dest string
|
||||
---@param callback fun(err: nil|string)
|
||||
function SSHFS:cp(src, dest, callback)
|
||||
self.conn:run(string.format('cp -r %s %s', shellescape(src), shellescape(dest)), callback)
|
||||
end
|
||||
|
||||
function SSHFS:get_dir_meta(url)
|
||||
return dir_meta[url]
|
||||
end
|
||||
|
||||
function SSHFS:get_meta()
|
||||
return self.conn.meta
|
||||
end
|
||||
|
||||
return SSHFS
|
||||
96
lua/canola/adapters/test.lua
Normal file
96
lua/canola/adapters/test.lua
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
local cache = require('canola.cache')
|
||||
local util = require('canola.util')
|
||||
local M = {}
|
||||
|
||||
---@param url string
|
||||
---@param callback fun(url: string)
|
||||
M.normalize_url = function(url, callback)
|
||||
callback(url)
|
||||
end
|
||||
|
||||
local dir_listing = {}
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: canola.InternalEntry[], fetch_more?: fun())
|
||||
M.list = function(url, column_defs, cb)
|
||||
local _, path = util.parse_url(url)
|
||||
local entries = dir_listing[path] or {}
|
||||
local cache_entries = {}
|
||||
for _, entry in ipairs(entries) do
|
||||
local cache_entry = cache.create_entry(url, entry.name, entry.entry_type)
|
||||
table.insert(cache_entries, cache_entry)
|
||||
end
|
||||
cb(nil, cache_entries)
|
||||
end
|
||||
|
||||
M.test_clear = function()
|
||||
dir_listing = {}
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param entry_type canola.EntryType
|
||||
---@return canola.InternalEntry
|
||||
M.test_set = function(path, entry_type)
|
||||
if path == '/' then
|
||||
return {}
|
||||
end
|
||||
local parent = vim.fn.fnamemodify(path, ':h')
|
||||
if parent ~= path then
|
||||
M.test_set(parent, 'directory')
|
||||
end
|
||||
parent = util.addslash(parent)
|
||||
if not dir_listing[parent] then
|
||||
dir_listing[parent] = {}
|
||||
end
|
||||
local name = vim.fn.fnamemodify(path, ':t')
|
||||
local entry = {
|
||||
name = name,
|
||||
entry_type = entry_type,
|
||||
}
|
||||
table.insert(dir_listing[parent], entry)
|
||||
local parent_url = 'canola-test://' .. parent
|
||||
return cache.create_and_store_entry(parent_url, entry.name, entry.entry_type)
|
||||
end
|
||||
|
||||
---@param name string
|
||||
---@return nil|canola.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return boolean
|
||||
M.is_modifiable = function(bufnr)
|
||||
return true
|
||||
end
|
||||
|
||||
---@param action canola.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
if action.type == 'create' or action.type == 'delete' then
|
||||
return string.format('%s %s', action.type:upper(), action.url)
|
||||
elseif action.type == 'move' or 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 canola.Action
|
||||
---@param cb fun(err: nil|string)
|
||||
M.perform_action = function(action, cb)
|
||||
cb()
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
M.read_file = function(bufnr)
|
||||
-- pass
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
M.write_file = function(bufnr)
|
||||
-- pass
|
||||
end
|
||||
|
||||
return M
|
||||
9
lua/canola/adapters/trash.lua
Normal file
9
lua/canola/adapters/trash.lua
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
local fs = require('canola.fs')
|
||||
|
||||
if fs.is_mac then
|
||||
return require('canola.adapters.trash.mac')
|
||||
elseif fs.is_windows then
|
||||
return require('canola.adapters.trash.windows')
|
||||
else
|
||||
return require('canola.adapters.trash.freedesktop')
|
||||
end
|
||||
634
lua/canola/adapters/trash/freedesktop.lua
Normal file
634
lua/canola/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('canola.cache')
|
||||
local config = require('canola.config')
|
||||
local constants = require('canola.constants')
|
||||
local files = require('canola.adapters.files')
|
||||
local fs = require('canola.fs')
|
||||
local util = require('canola.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 canola.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 canola.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('canola://' .. path)
|
||||
end
|
||||
|
||||
---@class canola.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?: canola.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 canola.TrashInfo
|
||||
cb(nil, trash_info)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: canola.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 canola.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|canola.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|canola.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return file_columns[name]
|
||||
end
|
||||
|
||||
M.supported_cross_adapter_actions = { files = 'move' }
|
||||
|
||||
---@param action canola.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 canola.ParseError
|
||||
---@return boolean
|
||||
M.filter_error = function(err)
|
||||
if err.message == 'Duplicate filename' then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
---@param action canola.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 canola.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 canola.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?: canola.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 canola.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 canola.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 canola.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 canola.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 canola.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/canola/adapters/trash/mac.lua
Normal file
232
lua/canola/adapters/trash/mac.lua
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
local cache = require('canola.cache')
|
||||
local config = require('canola.config')
|
||||
local files = require('canola.adapters.files')
|
||||
local fs = require('canola.fs')
|
||||
local util = require('canola.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 canola.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 = 'canola://' .. path
|
||||
end
|
||||
cb(path)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: canola.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|canola.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return nil
|
||||
end
|
||||
|
||||
M.supported_cross_adapter_actions = { files = 'move' }
|
||||
|
||||
---@param action canola.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 canola.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/canola/adapters/trash/windows.lua
Normal file
411
lua/canola/adapters/trash/windows.lua
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
local util = require('canola.util')
|
||||
local uv = vim.uv or vim.loop
|
||||
local cache = require('canola.cache')
|
||||
local config = require('canola.config')
|
||||
local constants = require('canola.constants')
|
||||
local files = require('canola.adapters.files')
|
||||
local fs = require('canola.fs')
|
||||
local powershell_trash = require('canola.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 canola.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?: canola.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: canola.WindowsTrashInfo, display_name: string}}
|
||||
function(entry)
|
||||
local parent = win_addslash(assert(vim.fn.fnamemodify(entry.OriginalPath, ':h')))
|
||||
|
||||
--- @type canola.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 canola.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 canola.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|canola.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|canola.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return file_columns[name]
|
||||
end
|
||||
|
||||
---@param action canola.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 canola.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: canola.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('canola://' .. path)
|
||||
end
|
||||
|
||||
---@param err canola.ParseError
|
||||
---@return boolean
|
||||
M.filter_error = function(err)
|
||||
if err.message == 'Duplicate filename' then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
---@param action canola.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 canola.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 canola.WindowsTrashInfo
|
||||
---@param cb fun(err?: string, raw_entries: canola.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?: canola.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 canola.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: canola.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: canola.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: canola.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/canola/adapters/trash/windows/powershell-connection.lua
Normal file
123
lua/canola/adapters/trash/windows/powershell-connection.lua
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
---@class (exact) canola.PowershellCommand
|
||||
---@field cmd string
|
||||
---@field cb fun(err?: string, output?: string)
|
||||
---@field running? boolean
|
||||
|
||||
---@class canola.PowershellConnection
|
||||
---@field private jid integer
|
||||
---@field private execution_error? string
|
||||
---@field private commands canola.PowershellCommand[]
|
||||
---@field private stdout string[]
|
||||
---@field private is_reading_data boolean
|
||||
local PowershellConnection = {}
|
||||
|
||||
---@param init_command? string
|
||||
---@return canola.PowershellConnection
|
||||
function PowershellConnection.new(init_command)
|
||||
local self = setmetatable({
|
||||
commands = {},
|
||||
stdout = {},
|
||||
is_reading_data = false,
|
||||
}, { __index = PowershellConnection })
|
||||
|
||||
self:_init(init_command)
|
||||
|
||||
---@type canola.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/canola/adapters/trash/windows/powershell-trash.lua
Normal file
78
lua/canola/adapters/trash/windows/powershell-trash.lua
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
-- A wrapper around trash operations using windows powershell
|
||||
local Powershell = require('canola.adapters.trash.windows.powershell-connection')
|
||||
|
||||
---@class canola.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|canola.PowershellConnection
|
||||
local list_entries_powershell
|
||||
|
||||
---@param cb fun(err?: string, raw_entries?: canola.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|canola.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