refactor: revert module namespace from canola back to oil

Problem: the canola rename creates unnecessary friction for users
migrating from stevearc/oil.nvim — every `require('oil')` call and
config reference must change.

Solution: revert all module paths, URL schemes, autocmd groups,
highlight groups, and filetype names back to `oil`. The repo stays
`canola.nvim` for identity; the code is a drop-in replacement.
This commit is contained in:
Barrett Ruth 2026-03-10 22:41:32 -04:00
parent 9298b48c5d
commit 8dd67f91e8
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
68 changed files with 1622 additions and 1625 deletions

678
lua/oil/adapters/files.lua Normal file
View file

@ -0,0 +1,678 @@
local cache = require('oil.cache')
local columns = require('oil.columns')
local config = require('oil.config')
local constants = require('oil.constants')
local fs = require('oil.fs')
local git = require('oil.git')
local log = require('oil.log')
local permissions = require('oil.adapters.files.permissions')
local util = require('oil.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) oil.FilesAdapter: oil.Adapter
---@field to_short_os_path fun(path: string, entry_type: nil|oil.EntryType): string
---@param path string
---@param entry_type nil|oil.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 entry[FIELD_TYPE] == 'directory' 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|oil.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 oil.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 oil.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/oil.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/oil.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?: oil.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?: oil.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 oil.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 oil.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,
vim.schedule_wrap(function(err)
if not err then
vim.api.nvim_exec_autocmds(
'User',
{ pattern = 'OilFileCreated', modeline = false, data = { path = path } }
)
end
cb(err)
end)
)
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('oil.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

View 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

394
lua/oil/adapters/s3.lua Normal file
View file

@ -0,0 +1,394 @@
local config = require('oil.config')
local constants = require('oil.constants')
local files = require('oil.adapters.files')
local fs = require('oil.fs')
local loading = require('oil.loading')
local pathutil = require('oil.pathutil')
local s3fs = require('oil.adapters.s3.s3fs')
local util = require('oil.util')
local M = {}
local FIELD_TYPE = constants.FIELD_TYPE
local FIELD_META = constants.FIELD_META
---@class (exact) oil.s3Url
---@field scheme string
---@field bucket nil|string
---@field path nil|string
---@param oil_url string
---@return oil.s3Url
M.parse_url = function(oil_url)
local scheme, url = util.parse_url(oil_url)
assert(scheme and url, string.format("Malformed input url '%s'", oil_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', oil_url))
end
---@cast ret oil.s3Url
return ret
end
---@param url oil.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 oil.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 oil.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 ''
end
if entry[FIELD_TYPE] == 'directory' then
return ''
end
if 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|oil.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?: oil.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 oil.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 oil.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, 'oil')
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, 'oil')
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

View file

@ -0,0 +1,149 @@
local cache = require('oil.cache')
local config = require('oil.config')
local constants = require('oil.constants')
local shell = require('oil.shell')
local util = require('oil.util')
local M = {}
local FIELD_META = constants.FIELD_META
---@param line string
---@return string Name of entry
---@return oil.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 oil.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?: oil.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

479
lua/oil/adapters/ssh.lua Normal file
View file

@ -0,0 +1,479 @@
local config = require('oil.config')
local constants = require('oil.constants')
local files = require('oil.adapters.files')
local fs = require('oil.fs')
local loading = require('oil.loading')
local pathutil = require('oil.pathutil')
local permissions = require('oil.adapters.files.permissions')
local shell = require('oil.shell')
local sshfs = require('oil.adapters.ssh.sshfs')
local util = require('oil.util')
local M = {}
local FIELD_NAME = constants.FIELD_NAME
local FIELD_TYPE = constants.FIELD_TYPE
local FIELD_META = constants.FIELD_META
---@class (exact) oil.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 oil_url string
---@return oil.sshUrl
M.parse_url = function(oil_url)
local scheme, url = util.parse_url(oil_url)
assert(scheme and url, string.format("Malformed input url '%s'", oil_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', oil_url))
end
---@cast ret oil.sshUrl
return ret
end
---@param url oil.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 oil.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 oil.sshUrl
---@param url2 oil.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 oil.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 ''
end
if entry[FIELD_TYPE] == 'directory' then
return ''
end
if 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|oil.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?: oil.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 oil.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 oil.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, 'oil')
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, 'oil')
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 oil.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

View file

@ -0,0 +1,317 @@
local config = require('oil.config')
local layout = require('oil.layout')
local util = require('oil.util')
---@class (exact) oil.sshCommand
---@field cmd string|string[]
---@field cb fun(err?: string, output?: string[])
---@field running? boolean
---@class (exact) oil.sshConnection
---@field new fun(url: oil.sshUrl): oil.sshConnection
---@field create_ssh_command fun(url: oil.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 oil.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 oil.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 oil.sshUrl
---@return oil.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("oil.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 oil.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

View file

@ -0,0 +1,264 @@
local SSHConnection = require('oil.adapters.ssh.connection')
local cache = require('oil.cache')
local constants = require('oil.constants')
local permissions = require('oil.adapters.files.permissions')
local util = require('oil.util')
---@class (exact) oil.sshFs
---@field new fun(url: oil.sshUrl): oil.sshFs
---@field conn oil.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 oil.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 oil.sshUrl
---@return oil.sshFs
function SSHFS.new(url)
---@type oil.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?: oil.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/oil/adapters/test.lua Normal file
View file

@ -0,0 +1,96 @@
local cache = require('oil.cache')
local util = require('oil.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?: oil.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 oil.EntryType
---@return oil.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 = 'oil-test://' .. parent
return cache.create_and_store_entry(parent_url, entry.name, entry.entry_type)
end
---@param name string
---@return nil|oil.ColumnDefinition
M.get_column = function(name)
return nil
end
---@param bufnr integer
---@return boolean
M.is_modifiable = function(bufnr)
return true
end
---@param action oil.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 oil.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

View file

@ -0,0 +1,9 @@
local fs = require('oil.fs')
if fs.is_mac then
return require('oil.adapters.trash.mac')
elseif fs.is_windows then
return require('oil.adapters.trash.windows')
else
return require('oil.adapters.trash.freedesktop')
end

View file

@ -0,0 +1,634 @@
-- Based on the FreeDesktop.org trash specification
-- https://specifications.freedesktop.org/trash/1.0/
local cache = require('oil.cache')
local config = require('oil.config')
local constants = require('oil.constants')
local files = require('oil.adapters.files')
local fs = require('oil.fs')
local util = require('oil.util')
local uv = vim.uv or vim.loop
local FIELD_META = constants.FIELD_META
local M = {}
local function ensure_trash_dir(path)
local mode = 448 -- 0700
fs.mkdirp(fs.join(path, 'info'), mode)
fs.mkdirp(fs.join(path, 'files'), mode)
end
---Gets the location of the home trash dir, creating it if necessary
---@return string
local function get_home_trash_dir()
local xdg_home = vim.env.XDG_DATA_HOME
if not xdg_home then
xdg_home = fs.join(assert(uv.os_homedir()), '.local', 'share')
end
local trash_dir = fs.join(xdg_home, 'Trash')
ensure_trash_dir(trash_dir)
return trash_dir
end
---@param mode integer
---@return boolean
local function is_sticky(mode)
local extra = bit.rshift(mode, 9)
return bit.band(extra, 4) ~= 0
end
---Get the topdir .Trash/$uid directory if present and valid
---@param path string
---@return string[]
local function get_top_trash_dirs(path)
local dirs = {}
local dev = (uv.fs_lstat(path) or {}).dev
local top_trash_dirs = vim.fs.find('.Trash', { upward = true, path = path, limit = math.huge })
for _, top_trash_dir in ipairs(top_trash_dirs) do
local stat = uv.fs_lstat(top_trash_dir)
if stat and not dev then
dev = stat.dev
end
if stat and stat.dev == dev and stat.type == 'directory' and is_sticky(stat.mode) then
local trash_dir = fs.join(top_trash_dir, tostring(uv.getuid()))
ensure_trash_dir(trash_dir)
table.insert(dirs, trash_dir)
end
end
-- Also search for the .Trash-$uid
top_trash_dirs = vim.fs.find(
string.format('.Trash-%d', uv.getuid()),
{ upward = true, path = path, limit = math.huge }
)
for _, top_trash_dir in ipairs(top_trash_dirs) do
local stat = uv.fs_lstat(top_trash_dir)
if stat and stat.dev == dev then
ensure_trash_dir(top_trash_dir)
table.insert(dirs, top_trash_dir)
end
end
return dirs
end
---@param path string
---@return string
local function get_write_trash_dir(path)
local lstat = uv.fs_lstat(path)
local home_trash = get_home_trash_dir()
if not lstat then
-- If the source file doesn't exist default to home trash dir
return home_trash
end
local dev = lstat.dev
if uv.fs_lstat(home_trash).dev == dev then
return home_trash
end
local top_trash_dirs = get_top_trash_dirs(path)
if not vim.tbl_isempty(top_trash_dirs) then
return top_trash_dirs[1]
end
local parent = vim.fn.fnamemodify(path, ':h')
local next_parent = vim.fn.fnamemodify(parent, ':h')
while parent ~= next_parent and uv.fs_lstat(next_parent).dev == dev do
parent = next_parent
next_parent = vim.fn.fnamemodify(parent, ':h')
end
local top_trash = fs.join(parent, string.format('.Trash-%d', uv.getuid()))
ensure_trash_dir(top_trash)
return top_trash
end
---@param path string
---@return string[]
local function get_read_trash_dirs(path)
local dirs = { get_home_trash_dir() }
vim.list_extend(dirs, get_top_trash_dirs(path))
return dirs
end
---@param url string
---@param callback fun(url: string)
M.normalize_url = function(url, callback)
local scheme, path = util.parse_url(url)
assert(path)
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ':p')
uv.fs_realpath(
os_path,
vim.schedule_wrap(function(err, new_os_path)
local realpath = new_os_path or os_path
callback(scheme .. util.addslash(fs.os_to_posix_path(realpath)))
end)
)
end
---@param url string
---@param entry oil.Entry
---@param cb fun(path: string)
M.get_entry_path = function(url, entry, cb)
local internal_entry = assert(cache.get_entry_by_id(entry.id))
local meta = assert(internal_entry[FIELD_META])
---@type oil.TrashInfo
local trash_info = meta.trash_info
if not trash_info then
-- This is a subpath in the trash
M.normalize_url(url, cb)
return
end
local path = fs.os_to_posix_path(trash_info.trash_file)
if meta.stat.type == 'directory' then
path = util.addslash(path)
end
cb('oil://' .. path)
end
---@class oil.TrashInfo
---@field trash_file string
---@field info_file string
---@field original_path string
---@field deletion_date number
---@field stat uv.aliases.fs_stat_table
---@param info_file string
---@param cb fun(err?: string, info?: oil.TrashInfo)
local function read_trash_info(info_file, cb)
if not vim.endswith(info_file, '.trashinfo') then
return cb('File is not .trashinfo')
end
uv.fs_open(info_file, 'r', 448, function(err, fd)
if err then
return cb(err)
end
assert(fd)
uv.fs_fstat(fd, function(stat_err, stat)
if stat_err then
uv.fs_close(fd)
return cb(stat_err)
end
uv.fs_read(
fd,
assert(stat).size,
nil,
vim.schedule_wrap(function(read_err, content)
uv.fs_close(fd)
if read_err then
return cb(read_err)
end
assert(content)
local trash_info = {
info_file = info_file,
}
local lines = vim.split(content, '\r?\n')
if lines[1] ~= '[Trash Info]' then
return cb('File missing [Trash Info] header')
end
local trash_base = vim.fn.fnamemodify(info_file, ':h:h')
for _, line in ipairs(lines) do
local key, value = unpack(vim.split(line, '=', { plain = true, trimempty = true }))
if key == 'Path' and not trash_info.original_path then
if not vim.startswith(value, '/') then
value = fs.join(trash_base, value)
end
trash_info.original_path = value
elseif key == 'DeletionDate' and not trash_info.deletion_date then
trash_info.deletion_date = vim.fn.strptime('%Y-%m-%dT%H:%M:%S', value)
end
end
if not trash_info.original_path or not trash_info.deletion_date then
return cb('File missing required fields')
end
local basename = vim.fn.fnamemodify(info_file, ':t:r')
trash_info.trash_file = fs.join(trash_base, 'files', basename)
uv.fs_lstat(trash_info.trash_file, function(trash_stat_err, trash_stat)
if trash_stat_err then
cb('.trashinfo file points to non-existant file')
else
trash_info.stat = trash_stat
---@cast trash_info oil.TrashInfo
cb(nil, trash_info)
end
end)
end)
)
end)
end)
end
---@param url string
---@param column_defs string[]
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
M.list = function(url, column_defs, cb)
cb = vim.schedule_wrap(cb)
local _, path = util.parse_url(url)
assert(path)
local trash_dirs = get_read_trash_dirs(path)
local trash_idx = 0
local read_next_trash_dir
read_next_trash_dir = function()
trash_idx = trash_idx + 1
local trash_dir = trash_dirs[trash_idx]
if not trash_dir then
return cb()
end
-- Show all files from the trash directory if we are in the root of the device, which we can
-- tell if the trash dir is a subpath of our current path
local show_all_files = fs.is_subpath(path, trash_dir)
-- The first trash dir is a special case; it is in the home directory and we should only show
-- all entries if we are in the top root path "/"
if trash_idx == 1 then
show_all_files = path == '/'
end
local info_dir = fs.join(trash_dir, 'info')
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
uv.fs_opendir(info_dir, function(open_err, fd)
if open_err then
if open_err:match('^ENOENT: no such file or directory') then
-- If the directory doesn't exist, treat the list as a success. We will be able to traverse
-- and edit a not-yet-existing directory.
return read_next_trash_dir()
else
return cb(open_err)
end
end
local read_next
read_next = function()
uv.fs_readdir(fd, function(err, entries)
if err then
uv.fs_closedir(fd, function()
cb(err)
end)
return
elseif entries then
local internal_entries = {}
local poll = util.cb_collect(#entries, function(inner_err)
if inner_err then
cb(inner_err)
else
cb(nil, internal_entries, read_next)
end
end)
for _, entry in ipairs(entries) do
read_trash_info(
fs.join(info_dir, entry.name),
vim.schedule_wrap(function(read_err, info)
if read_err then
-- Discard the error. We don't care if there's something wrong with one of these
-- files.
poll()
else
local parent = util.addslash(vim.fn.fnamemodify(info.original_path, ':h'))
if path == parent or show_all_files then
local name = vim.fn.fnamemodify(info.trash_file, ':t')
---@diagnostic disable-next-line: undefined-field
local cache_entry = cache.create_entry(url, name, info.stat.type)
local display_name = vim.fn.fnamemodify(info.original_path, ':t')
cache_entry[FIELD_META] = {
stat = info.stat,
trash_info = info,
display_name = display_name,
}
table.insert(internal_entries, cache_entry)
end
if path ~= parent and (show_all_files or fs.is_subpath(path, parent)) then
local name = parent:sub(path:len() + 1)
local next_par = vim.fs.dirname(name)
while next_par ~= '.' do
name = next_par
next_par = vim.fs.dirname(name)
end
---@diagnostic disable-next-line: undefined-field
local cache_entry = cache.create_entry(url, name, 'directory')
cache_entry[FIELD_META] = {
stat = info.stat,
}
table.insert(internal_entries, cache_entry)
end
poll()
end
end)
)
end
else
uv.fs_closedir(fd, function(close_err)
if close_err then
cb(close_err)
else
vim.schedule(read_next_trash_dir)
end
end)
end
end)
end
read_next()
---@diagnostic disable-next-line: param-type-mismatch
end, 10000)
end
read_next_trash_dir()
end
---@param bufnr integer
---@return boolean
M.is_modifiable = function(bufnr)
return true
end
local file_columns = {}
local current_year
-- Make sure we run this import-time effect in the main loop (mostly for tests)
vim.schedule(function()
current_year = vim.fn.strftime('%Y')
end)
file_columns.mtime = {
render = function(entry, conf)
local meta = entry[FIELD_META]
if not meta then
return nil
end
---@type oil.TrashInfo
local trash_info = meta.trash_info
local time = trash_info and trash_info.deletion_date or meta.stat and meta.stat.mtime.sec
if not time then
return nil
end
local fmt = conf and conf.format
local ret
if fmt then
ret = vim.fn.strftime(fmt, time)
else
local year = vim.fn.strftime('%Y', time)
if year ~= current_year then
ret = vim.fn.strftime('%b %d %Y', time)
else
ret = vim.fn.strftime('%b %d %H:%M', time)
end
end
return ret
end,
get_sort_value = function(entry)
local meta = entry[FIELD_META]
---@type nil|oil.TrashInfo
local trash_info = meta and meta.trash_info
if trash_info then
return trash_info.deletion_date
else
return 0
end
end,
parse = function(line, conf)
local fmt = conf and conf.format
local pattern
if fmt then
pattern = fmt:gsub('%%.', '%%S+')
else
pattern = '%S+%s+%d+%s+%d%d:?%d%d'
end
return line:match('^(' .. pattern .. ')%s+(.+)$')
end,
}
---@param name string
---@return nil|oil.ColumnDefinition
M.get_column = function(name)
return file_columns[name]
end
M.supported_cross_adapter_actions = { files = 'move' }
---@param action oil.Action
---@return boolean
M.filter_action = function(action)
if action.type == 'create' then
return false
elseif action.type == 'delete' then
local entry = assert(cache.get_entry_by_url(action.url))
local meta = entry[FIELD_META]
return meta ~= nil and meta.trash_info ~= nil
elseif action.type == 'move' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
return src_adapter.name == 'files' or dest_adapter.name == 'files'
-- selene: allow(if_same_then_else)
elseif action.type == 'copy' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
return src_adapter.name == 'files' or dest_adapter.name == 'files'
else
error(string.format("Bad action type '%s'", action.type))
end
end
---@param err oil.ParseError
---@return boolean
M.filter_error = function(err)
if err.message == 'Duplicate filename' then
return false
end
return true
end
---@param action oil.Action
---@return string
M.render_action = function(action)
if action.type == 'delete' then
local entry = assert(cache.get_entry_by_url(action.url))
local meta = entry[FIELD_META]
---@type oil.TrashInfo
local trash_info = assert(meta).trash_info
local short_path = fs.shorten_path(trash_info.original_path)
return string.format(' PURGE %s', short_path)
elseif action.type == 'move' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == 'files' then
local _, path = util.parse_url(action.src_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format(' TRASH %s', short_path)
elseif dest_adapter.name == 'files' then
local _, path = util.parse_url(action.dest_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format('RESTORE %s', short_path)
else
error('Must be moving files into or out of trash')
end
elseif action.type == 'copy' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == 'files' then
local _, path = util.parse_url(action.src_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format(' COPY %s -> TRASH', short_path)
elseif dest_adapter.name == 'files' then
local _, path = util.parse_url(action.dest_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format('RESTORE %s', short_path)
else
error('Must be copying files into or out of trash')
end
else
error(string.format("Bad action type '%s'", action.type))
end
end
---@param trash_info oil.TrashInfo
---@param cb fun(err?: string)
local function purge(trash_info, cb)
fs.recursive_delete('file', trash_info.info_file, function(err)
if err then
return cb(err)
end
---@diagnostic disable-next-line: undefined-field
fs.recursive_delete(trash_info.stat.type, trash_info.trash_file, cb)
end)
end
---@param path string
---@param info_path string
---@param cb fun(err?: string)
local function write_info_file(path, info_path, cb)
uv.fs_open(
info_path,
'w',
448,
vim.schedule_wrap(function(err, fd)
if err then
return cb(err)
end
assert(fd)
local deletion_date = vim.fn.strftime('%Y-%m-%dT%H:%M:%S')
local contents = string.format('[Trash Info]\nPath=%s\nDeletionDate=%s', path, deletion_date)
uv.fs_write(fd, contents, function(write_err)
uv.fs_close(fd, function(close_err)
cb(write_err or close_err)
end)
end)
end)
)
end
---@param path string
---@param cb fun(err?: string, trash_info?: oil.TrashInfo)
local function create_trash_info(path, cb)
local trash_dir = get_write_trash_dir(path)
local basename = vim.fs.basename(path)
local now = os.time()
local name = string.format('%s-%d.%d', basename, now, math.random(100000, 999999))
local dest_path = fs.join(trash_dir, 'files', name)
local dest_info = fs.join(trash_dir, 'info', name .. '.trashinfo')
uv.fs_lstat(path, function(err, stat)
if err then
return cb(err)
end
assert(stat)
write_info_file(path, dest_info, function(info_err)
if info_err then
return cb(info_err)
end
---@type oil.TrashInfo
local trash_info = {
original_path = path,
trash_file = dest_path,
info_file = dest_info,
deletion_date = now,
stat = stat,
}
cb(nil, trash_info)
end)
end)
end
---@param action oil.Action
---@param cb fun(err: nil|string)
M.perform_action = function(action, cb)
if action.type == 'delete' then
local entry = assert(cache.get_entry_by_url(action.url))
local meta = entry[FIELD_META]
---@type oil.TrashInfo
local trash_info = assert(meta).trash_info
purge(trash_info, cb)
elseif action.type == 'move' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == 'files' then
local _, path = util.parse_url(action.src_url)
M.delete_to_trash(assert(path), cb)
elseif dest_adapter.name == 'files' then
-- Restore
local _, dest_path = util.parse_url(action.dest_url)
assert(dest_path)
local entry = assert(cache.get_entry_by_url(action.src_url))
local meta = entry[FIELD_META]
---@type oil.TrashInfo
local trash_info = assert(meta).trash_info
fs.recursive_move(action.entry_type, trash_info.trash_file, dest_path, function(err)
if err then
return cb(err)
end
uv.fs_unlink(trash_info.info_file, cb)
end)
else
error('Must be moving files into or out of trash')
end
elseif action.type == 'copy' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == 'files' then
local _, path = util.parse_url(action.src_url)
assert(path)
create_trash_info(path, function(err, trash_info)
if err then
cb(err)
else
local stat_type = trash_info.stat.type or 'unknown'
fs.recursive_copy(stat_type, path, trash_info.trash_file, vim.schedule_wrap(cb))
end
end)
elseif dest_adapter.name == 'files' then
-- Restore
local _, dest_path = util.parse_url(action.dest_url)
assert(dest_path)
local entry = assert(cache.get_entry_by_url(action.src_url))
local meta = entry[FIELD_META]
---@type oil.TrashInfo
local trash_info = assert(meta).trash_info
fs.recursive_copy(action.entry_type, trash_info.trash_file, dest_path, cb)
else
error('Must be moving files into or out of trash')
end
else
cb(string.format('Bad action type: %s', action.type))
end
end
---@param path string
---@param cb fun(err?: string)
M.delete_to_trash = function(path, cb)
create_trash_info(path, function(err, trash_info)
if err then
cb(err)
else
local stat_type = trash_info.stat.type or 'unknown'
fs.recursive_move(stat_type, path, trash_info.trash_file, vim.schedule_wrap(cb))
end
end)
end
return M

View file

@ -0,0 +1,232 @@
local cache = require('oil.cache')
local config = require('oil.config')
local files = require('oil.adapters.files')
local fs = require('oil.fs')
local util = require('oil.util')
local uv = vim.uv or vim.loop
local M = {}
local function touch_dir(path)
uv.fs_mkdir(path, 448) -- 0700
end
---Gets the location of the home trash dir, creating it if necessary
---@return string
local function get_trash_dir()
local trash_dir = fs.join(assert(uv.os_homedir()), '.Trash')
touch_dir(trash_dir)
return trash_dir
end
---@param url string
---@param callback fun(url: string)
M.normalize_url = function(url, callback)
local scheme, path = util.parse_url(url)
assert(path)
callback(scheme .. '/')
end
---@param url string
---@param entry oil.Entry
---@param cb fun(path: string)
M.get_entry_path = function(url, entry, cb)
local trash_dir = get_trash_dir()
local path = fs.join(trash_dir, entry.name)
if entry.type == 'directory' then
path = 'oil://' .. path
end
cb(path)
end
---@param url string
---@param column_defs string[]
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
M.list = function(url, column_defs, cb)
cb = vim.schedule_wrap(cb)
local _, path = util.parse_url(url)
assert(path)
local trash_dir = get_trash_dir()
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
uv.fs_opendir(trash_dir, function(open_err, fd)
if open_err then
if open_err:match('^ENOENT: no such file or directory') then
-- If the directory doesn't exist, treat the list as a success. We will be able to traverse
-- and edit a not-yet-existing directory.
return cb()
else
return cb(open_err)
end
end
local read_next
read_next = function()
uv.fs_readdir(fd, function(err, entries)
if err then
uv.fs_closedir(fd, function()
cb(err)
end)
return
elseif entries then
local internal_entries = {}
local poll = util.cb_collect(#entries, function(inner_err)
if inner_err then
cb(inner_err)
else
cb(nil, internal_entries, read_next)
end
end)
for _, entry in ipairs(entries) do
-- TODO: read .DS_Store and filter by original dir
local cache_entry = cache.create_entry(url, entry.name, entry.type)
table.insert(internal_entries, cache_entry)
poll()
end
else
uv.fs_closedir(fd, function(close_err)
if close_err then
cb(close_err)
else
cb()
end
end)
end
end)
end
read_next()
---@diagnostic disable-next-line: param-type-mismatch
end, 10000)
end
---@param bufnr integer
---@return boolean
M.is_modifiable = function(bufnr)
return true
end
---@param name string
---@return nil|oil.ColumnDefinition
M.get_column = function(name)
return nil
end
M.supported_cross_adapter_actions = { files = 'move' }
---@param action oil.Action
---@return string
M.render_action = function(action)
if action.type == 'create' then
return string.format('CREATE %s', action.url)
elseif action.type == 'delete' then
return string.format(' PURGE %s', action.url)
elseif action.type == 'move' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == 'files' then
local _, path = util.parse_url(action.src_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format(' TRASH %s', short_path)
elseif dest_adapter.name == 'files' then
local _, path = util.parse_url(action.dest_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format('RESTORE %s', short_path)
else
return string.format(' %s %s -> %s', action.type:upper(), action.src_url, action.dest_url)
end
elseif action.type == 'copy' then
return string.format(' %s %s -> %s', action.type:upper(), action.src_url, action.dest_url)
else
error('Bad action type')
end
end
---@param action oil.Action
---@param cb fun(err: nil|string)
M.perform_action = function(action, cb)
local trash_dir = get_trash_dir()
if action.type == 'create' then
local _, path = util.parse_url(action.url)
assert(path)
path = trash_dir .. path
if action.entry_type == 'directory' then
uv.fs_mkdir(path, 493, function(err)
-- Ignore if the directory already exists
if not err or err:match('^EEXIST:') then
cb()
else
cb(err)
end
end) -- 0755
elseif action.entry_type == 'link' and action.link then
local flags = nil
local target = fs.posix_to_os_path(action.link)
---@diagnostic disable-next-line: param-type-mismatch
uv.fs_symlink(target, path, flags, cb)
else
fs.touch(path, config.new_file_mode, cb)
end
elseif action.type == 'delete' then
local _, path = util.parse_url(action.url)
assert(path)
local fullpath = trash_dir .. path
fs.recursive_delete(action.entry_type, fullpath, cb)
elseif action.type == 'move' or action.type == 'copy' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
local _, src_path = util.parse_url(action.src_url)
local _, dest_path = util.parse_url(action.dest_url)
assert(src_path and dest_path)
if src_adapter.name == 'files' then
dest_path = trash_dir .. dest_path
elseif dest_adapter.name == 'files' then
src_path = trash_dir .. src_path
else
dest_path = trash_dir .. dest_path
src_path = trash_dir .. src_path
end
if action.type == 'move' then
fs.recursive_move(action.entry_type, src_path, dest_path, cb)
else
fs.recursive_copy(action.entry_type, src_path, dest_path, cb)
end
else
cb(string.format('Bad action type: %s', action.type))
end
end
---@param path string
---@param cb fun(err?: string)
M.delete_to_trash = function(path, cb)
local basename = vim.fs.basename(path)
local trash_dir = get_trash_dir()
local dest = fs.join(trash_dir, basename)
uv.fs_lstat(
path,
vim.schedule_wrap(function(stat_err, src_stat)
if stat_err then
return cb(stat_err)
end
assert(src_stat)
if uv.fs_lstat(dest) then
local date_str = vim.fn.strftime(' %Y-%m-%dT%H:%M:%S')
local name_pieces = vim.split(basename, '.', { plain = true })
if #name_pieces > 1 then
table.insert(name_pieces, #name_pieces - 1, date_str)
basename = table.concat(name_pieces)
else
basename = basename .. date_str
end
dest = fs.join(trash_dir, basename)
end
local stat_type = src_stat.type
fs.recursive_move(stat_type, path, dest, vim.schedule_wrap(cb))
end)
)
end
return M

View file

@ -0,0 +1,411 @@
local util = require('oil.util')
local uv = vim.uv or vim.loop
local cache = require('oil.cache')
local config = require('oil.config')
local constants = require('oil.constants')
local files = require('oil.adapters.files')
local fs = require('oil.fs')
local powershell_trash = require('oil.adapters.trash.windows.powershell-trash')
local FIELD_META = constants.FIELD_META
local FIELD_TYPE = constants.FIELD_TYPE
local M = {}
---@return string
local function get_trash_dir()
local cwd = assert(vim.fn.getcwd())
local trash_dir = cwd:sub(1, 3) .. '$Recycle.Bin'
if vim.fn.isdirectory(trash_dir) == 1 then
return trash_dir
end
trash_dir = 'C:\\$Recycle.Bin'
if vim.fn.isdirectory(trash_dir) == 1 then
return trash_dir
end
error('No trash found')
end
---@param path string
---@return string
local win_addslash = function(path)
if not vim.endswith(path, '\\') then
return path .. '\\'
else
return path
end
end
---@class oil.WindowsTrashInfo
---@field trash_file string
---@field original_path string
---@field deletion_date integer
---@field info_file? string
---@param url string
---@param column_defs string[]
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
M.list = function(url, column_defs, cb)
local _, path = util.parse_url(url)
path = fs.posix_to_os_path(assert(path))
local trash_dir = get_trash_dir()
local show_all_files = fs.is_subpath(path, trash_dir)
powershell_trash.list_raw_entries(function(err, raw_entries)
if err then
cb(err)
return
end
local raw_displayed_entries = vim.tbl_filter(
---@param entry {IsFolder: boolean, DeletionDate: integer, Name: string, Path: string, OriginalPath: string}
function(entry)
local parent = win_addslash(assert(vim.fn.fnamemodify(entry.OriginalPath, ':h')))
local is_in_path = path == parent
local is_subpath = fs.is_subpath(path, parent)
return is_in_path or is_subpath or show_all_files
end,
raw_entries
)
local displayed_entries = vim.tbl_map(
---@param entry {IsFolder: boolean, DeletionDate: integer, Name: string, Path: string, OriginalPath: string}
---@return {[1]:nil, [2]:string, [3]:string, [4]:{stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}}
function(entry)
local parent = win_addslash(assert(vim.fn.fnamemodify(entry.OriginalPath, ':h')))
--- @type oil.InternalEntry
local cache_entry
if path == parent or show_all_files then
local deleted_file_tail = assert(vim.fn.fnamemodify(entry.Path, ':t'))
local deleted_file_head = assert(vim.fn.fnamemodify(entry.Path, ':h'))
local info_file_head = deleted_file_head
--- @type string?
local info_file
cache_entry =
cache.create_entry(url, deleted_file_tail, entry.IsFolder and 'directory' or 'file')
-- info_file on windows has the following format: $I<6 char hash>.<extension>
-- the hash is the same for the deleted file and the info file
-- so, we take the hash (and extension) from the deleted file
--
-- see https://superuser.com/questions/368890/how-does-the-recycle-bin-in-windows-work/1736690#1736690
local info_file_tail = deleted_file_tail:match('^%$R(.*)$') --[[@as string?]]
if info_file_tail then
info_file_tail = '$I' .. info_file_tail
info_file = info_file_head .. '\\' .. info_file_tail
end
cache_entry[FIELD_META] = {
stat = nil,
---@type oil.WindowsTrashInfo
trash_info = {
trash_file = entry.Path,
original_path = entry.OriginalPath,
deletion_date = entry.DeletionDate,
info_file = info_file,
},
display_name = entry.Name,
}
end
if path ~= parent and (show_all_files or fs.is_subpath(path, parent)) then
local name = parent:sub(path:len() + 1)
local next_par = vim.fs.dirname(name)
while next_par ~= '.' do
name = next_par
next_par = vim.fs.dirname(name)
cache_entry = cache.create_entry(url, name, 'directory')
cache_entry[FIELD_META] = {}
end
end
return cache_entry
end,
raw_displayed_entries
)
cb(nil, displayed_entries)
end)
end
M.is_modifiable = function(_bufnr)
return true
end
local current_year
-- Make sure we run this import-time effect in the main loop (mostly for tests)
vim.schedule(function()
current_year = vim.fn.strftime('%Y')
end)
local file_columns = {}
file_columns.mtime = {
render = function(entry, conf)
local meta = entry[FIELD_META]
if not meta then
return nil
end
---@type oil.WindowsTrashInfo
local trash_info = meta.trash_info
local time = trash_info and trash_info.deletion_date
if not time then
return nil
end
local fmt = conf and conf.format
local ret
if fmt then
ret = vim.fn.strftime(fmt, time)
else
local year = vim.fn.strftime('%Y', time)
if year ~= current_year then
ret = vim.fn.strftime('%b %d %Y', time)
else
ret = vim.fn.strftime('%b %d %H:%M', time)
end
end
return ret
end,
get_sort_value = function(entry)
local meta = entry[FIELD_META]
---@type nil|oil.WindowsTrashInfo
local trash_info = meta and meta.trash_info
if trash_info and trash_info.deletion_date then
return trash_info.deletion_date
else
return 0
end
end,
parse = function(line, conf)
local fmt = conf and conf.format
local pattern
if fmt then
pattern = fmt:gsub('%%.', '%%S+')
else
pattern = '%S+%s+%d+%s+%d%d:?%d%d'
end
return line:match('^(' .. pattern .. ')%s+(.+)$')
end,
}
---@param name string
---@return nil|oil.ColumnDefinition
M.get_column = function(name)
return file_columns[name]
end
---@param action oil.Action
---@return boolean
M.filter_action = function(action)
if action.type == 'create' then
return false
elseif action.type == 'delete' then
local entry = assert(cache.get_entry_by_url(action.url))
local meta = entry[FIELD_META]
return meta ~= nil and meta.trash_info ~= nil
elseif action.type == 'move' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
return src_adapter.name == 'files' or dest_adapter.name == 'files'
-- selene: allow(if_same_then_else)
elseif action.type == 'copy' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
return src_adapter.name == 'files' or dest_adapter.name == 'files'
else
error(string.format("Bad action type '%s'", action.type))
end
end
---@param url string
---@param callback fun(url: string)
M.normalize_url = function(url, callback)
local scheme, path = util.parse_url(url)
assert(path)
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ':p')
assert(os_path)
uv.fs_realpath(
os_path,
vim.schedule_wrap(function(_err, new_os_path)
local realpath = new_os_path or os_path
callback(scheme .. util.addslash(fs.os_to_posix_path(realpath)))
end)
)
end
---@param url string
---@param entry oil.Entry
---@param cb fun(path: string)
M.get_entry_path = function(url, entry, cb)
local internal_entry = assert(cache.get_entry_by_id(entry.id))
local meta = internal_entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
local trash_info = meta and meta.trash_info
if not trash_info then
-- This is a subpath in the trash
M.normalize_url(url, cb)
return
end
local path = fs.os_to_posix_path(trash_info.trash_file)
if entry.type == 'directory' then
path = win_addslash(path)
end
cb('oil://' .. path)
end
---@param err oil.ParseError
---@return boolean
M.filter_error = function(err)
if err.message == 'Duplicate filename' then
return false
end
return true
end
---@param action oil.Action
---@return string
M.render_action = function(action)
if action.type == 'delete' then
local entry = assert(cache.get_entry_by_url(action.url))
local meta = entry[FIELD_META]
---@type oil.WindowsTrashInfo
local trash_info = assert(meta).trash_info
local short_path = fs.shorten_path(trash_info.original_path)
return string.format(' PURGE %s', short_path)
elseif action.type == 'move' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == 'files' then
local _, path = util.parse_url(action.src_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format(' TRASH %s', short_path)
elseif dest_adapter.name == 'files' then
local _, path = util.parse_url(action.dest_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format('RESTORE %s', short_path)
else
error('Must be moving files into or out of trash')
end
elseif action.type == 'copy' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == 'files' then
local _, path = util.parse_url(action.src_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format(' COPY %s -> TRASH', short_path)
elseif dest_adapter.name == 'files' then
local _, path = util.parse_url(action.dest_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format('RESTORE %s', short_path)
else
error('Must be copying files into or out of trash')
end
else
error(string.format("Bad action type '%s'", action.type))
end
end
---@param trash_info oil.WindowsTrashInfo
---@param cb fun(err?: string, raw_entries: oil.WindowsRawEntry[]?)
local purge = function(trash_info, cb)
fs.recursive_delete('file', trash_info.info_file, function(err)
if err then
return cb(err)
end
fs.recursive_delete('file', trash_info.trash_file, cb)
end)
end
---@param path string
---@param type string
---@param cb fun(err?: string, trash_info?: oil.TrashInfo)
local function create_trash_info_and_copy(path, type, cb)
local temp_path = path .. 'temp'
-- create a temporary copy on the same location
fs.recursive_copy(
type,
path,
temp_path,
vim.schedule_wrap(function(err)
if err then
return cb(err)
end
-- delete original file
M.delete_to_trash(path, function(err2)
if err2 then
return cb(err2)
end
-- rename temporary copy to the original file name
fs.recursive_move(type, temp_path, path, cb)
end)
end)
)
end
---@param action oil.Action
---@param cb fun(err: nil|string)
M.perform_action = function(action, cb)
if action.type == 'delete' then
local entry = assert(cache.get_entry_by_url(action.url))
local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
local trash_info = meta and meta.trash_info
purge(trash_info, cb)
elseif action.type == 'move' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == 'files' then
local _, path = util.parse_url(action.src_url)
M.delete_to_trash(assert(path), cb)
elseif dest_adapter.name == 'files' then
-- Restore
local _, dest_path = util.parse_url(action.dest_url)
assert(dest_path)
dest_path = fs.posix_to_os_path(dest_path)
local entry = assert(cache.get_entry_by_url(action.src_url))
local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
local trash_info = meta and meta.trash_info
fs.recursive_move(action.entry_type, trash_info.trash_file, dest_path, function(err)
if err then
return cb(err)
end
uv.fs_unlink(trash_info.info_file, cb)
end)
end
elseif action.type == 'copy' then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == 'files' then
local _, path = util.parse_url(action.src_url)
assert(path)
path = fs.posix_to_os_path(path)
local entry = assert(cache.get_entry_by_url(action.src_url))
create_trash_info_and_copy(path, entry[FIELD_TYPE], cb)
elseif dest_adapter.name == 'files' then
-- Restore
local _, dest_path = util.parse_url(action.dest_url)
assert(dest_path)
dest_path = fs.posix_to_os_path(dest_path)
local entry = assert(cache.get_entry_by_url(action.src_url))
local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
local trash_info = meta and meta.trash_info
fs.recursive_copy(action.entry_type, trash_info.trash_file, dest_path, cb)
else
error('Must be moving files into or out of trash')
end
else
cb(string.format('Bad action type: %s', action.type))
end
end
M.supported_cross_adapter_actions = { files = 'move' }
---@param path string
---@param cb fun(err?: string)
M.delete_to_trash = function(path, cb)
powershell_trash.delete_to_trash(path, cb)
end
return M

View file

@ -0,0 +1,123 @@
---@class (exact) oil.PowershellCommand
---@field cmd string
---@field cb fun(err?: string, output?: string)
---@field running? boolean
---@class oil.PowershellConnection
---@field private jid integer
---@field private execution_error? string
---@field private commands oil.PowershellCommand[]
---@field private stdout string[]
---@field private is_reading_data boolean
local PowershellConnection = {}
---@param init_command? string
---@return oil.PowershellConnection
function PowershellConnection.new(init_command)
local self = setmetatable({
commands = {},
stdout = {},
is_reading_data = false,
}, { __index = PowershellConnection })
self:_init(init_command)
---@type oil.PowershellConnection
return self
end
---@param init_command? string
function PowershellConnection:_init(init_command)
-- For some reason beyond my understanding, at least one of the following
-- things requires `noshellslash` to avoid the embeded powershell process to
-- send only "" to the stdout (never calling the callback because
-- "===DONE(True)===" is never sent to stdout)
-- * vim.fn.jobstart
-- * cmd.exe
-- * powershell.exe
local saved_shellslash = vim.o.shellslash
vim.o.shellslash = false
-- 65001 is the UTF-8 codepage
-- powershell needs to be launched with the UTF-8 codepage to use it for both stdin and stdout
local jid = vim.fn.jobstart({
'cmd',
'/c',
'"chcp 65001 && powershell -NoProfile -NoLogo -ExecutionPolicy Bypass -NoExit -Command -"',
}, {
---@param data string[]
on_stdout = function(_, data)
for _, fragment in ipairs(data) do
if fragment:find('===DONE%((%a+)%)===') then
self.is_reading_data = false
local output = table.concat(self.stdout, '')
local cb = self.commands[1].cb
table.remove(self.commands, 1)
local success = fragment:match('===DONE%((%a+)%)===')
if success == 'True' then
cb(nil, output)
elseif success == 'False' then
cb(success .. ': ' .. output, output)
end
self.stdout = {}
self:_consume()
elseif self.is_reading_data then
table.insert(self.stdout, fragment)
end
end
end,
})
vim.o.shellslash = saved_shellslash
if jid == 0 then
self:_set_error("passed invalid arguments to 'powershell'")
elseif jid == -1 then
self:_set_error("'powershell' is not executable")
else
self.jid = jid
end
if init_command then
table.insert(self.commands, { cmd = init_command, cb = function() end })
self:_consume()
end
end
---@param command string
---@param cb fun(err?: string, output?: string[])
function PowershellConnection:run(command, cb)
if self.execution_error then
cb(self.execution_error)
else
table.insert(self.commands, { cmd = command, cb = cb })
self:_consume()
end
end
function PowershellConnection:_consume()
if not vim.tbl_isempty(self.commands) then
local cmd = self.commands[1]
if not cmd.running then
cmd.running = true
self.is_reading_data = true
-- $? contains the execution status of the last command.
-- see https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables?view=powershell-7.4#section-1
vim.api.nvim_chan_send(self.jid, cmd.cmd .. '\nWrite-Host "===DONE($?)==="\n')
end
end
end
---@param err string
function PowershellConnection:_set_error(err)
if self.execution_error then
return
end
self.execution_error = err
local commands = self.commands
self.commands = {}
for _, cmd in ipairs(commands) do
cmd.cb(err)
end
end
return PowershellConnection

View file

@ -0,0 +1,78 @@
-- A wrapper around trash operations using windows powershell
local Powershell = require('oil.adapters.trash.windows.powershell-connection')
---@class oil.WindowsRawEntry
---@field IsFolder boolean
---@field DeletionDate integer
---@field Name string
---@field Path string
---@field OriginalPath string
local M = {}
-- 0xa is the constant for Recycle Bin. See https://learn.microsoft.com/en-us/windows/win32/api/shldisp/ne-shldisp-shellspecialfolderconstants
local list_entries_init = [[
$shell = New-Object -ComObject 'Shell.Application'
$folder = $shell.NameSpace(0xa)
]]
local list_entries_cmd = [[
$data = @(foreach ($i in $folder.items())
{
@{
IsFolder=$i.IsFolder;
DeletionDate=([DateTimeOffset]$i.extendedproperty('datedeleted')).ToUnixTimeSeconds();
Name=$i.Name;
Path=$i.Path;
OriginalPath=-join($i.ExtendedProperty('DeletedFrom'), "\", $i.Name)
}
})
ConvertTo-Json $data -Compress
]]
---@type nil|oil.PowershellConnection
local list_entries_powershell
---@param cb fun(err?: string, raw_entries?: oil.WindowsRawEntry[])
M.list_raw_entries = function(cb)
if not list_entries_powershell then
list_entries_powershell = Powershell.new(list_entries_init)
end
list_entries_powershell:run(list_entries_cmd, function(err, string)
if err then
cb(err)
return
end
local ok, value = pcall(vim.json.decode, string)
if not ok then
cb(value)
return
end
cb(nil, value)
end)
end
-- 0 is the constant for Windows Desktop. See https://learn.microsoft.com/en-us/windows/win32/api/shldisp/ne-shldisp-shellspecialfolderconstants
local delete_init = [[
$shell = New-Object -ComObject 'Shell.Application'
$folder = $shell.NameSpace(0)
]]
local delete_cmd = [[
$path = Get-Item '%s'
$folder.ParseName($path.FullName).InvokeVerb('delete')
]]
---@type nil|oil.PowershellConnection
local delete_to_trash_powershell
---@param path string
---@param cb fun(err?: string)
M.delete_to_trash = function(path, cb)
if not delete_to_trash_powershell then
delete_to_trash_powershell = Powershell.new(delete_init)
end
delete_to_trash_powershell:run((delete_cmd):format(path:gsub("'", "''")), cb)
end
return M