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

652
lua/oil/actions.lua Normal file
View file

@ -0,0 +1,652 @@
local oil = require('oil')
local util = require('oil.util')
local M = {}
M.show_help = {
callback = function()
local config = require('oil.config')
require('oil.keymap_util').show_help(config.keymaps)
end,
desc = 'Show default keymaps',
}
M.select = {
desc = 'Open the entry under the cursor',
callback = function(opts)
opts = opts or {}
local callback = opts.callback
opts.callback = nil
oil.select(opts, callback)
end,
parameters = {
vertical = {
type = 'boolean',
desc = 'Open the buffer in a vertical split',
},
horizontal = {
type = 'boolean',
desc = 'Open the buffer in a horizontal split',
},
split = {
type = '"aboveleft"|"belowright"|"topleft"|"botright"',
desc = 'Split modifier',
},
tab = {
type = 'boolean',
desc = 'Open the buffer in a new tab',
},
close = {
type = 'boolean',
desc = 'Close the original oil buffer once selection is made',
},
},
}
M.select_vsplit = {
desc = 'Open the entry under the cursor in a vertical split',
deprecated = true,
callback = function()
oil.select({ vertical = true })
end,
}
M.select_split = {
desc = 'Open the entry under the cursor in a horizontal split',
deprecated = true,
callback = function()
oil.select({ horizontal = true })
end,
}
M.select_tab = {
desc = 'Open the entry under the cursor in a new tab',
deprecated = true,
callback = function()
oil.select({ tab = true })
end,
}
M.preview = {
desc = 'Open the entry under the cursor in a preview window, or close the preview window if already open',
parameters = {
vertical = {
type = 'boolean',
desc = 'Open the buffer in a vertical split',
},
horizontal = {
type = 'boolean',
desc = 'Open the buffer in a horizontal split',
},
split = {
type = '"aboveleft"|"belowright"|"topleft"|"botright"',
desc = 'Split modifier',
},
},
callback = function(opts)
local entry = oil.get_cursor_entry()
if not entry then
vim.notify('Could not find entry under cursor', vim.log.levels.ERROR)
return
end
local winid = util.get_preview_win()
if winid then
local cur_id = vim.w[winid].oil_entry_id
if entry.id == cur_id then
vim.api.nvim_win_close(winid, true)
if util.is_floating_win() then
local layout = require('oil.layout')
local win_opts = layout.get_fullscreen_win_opts()
vim.api.nvim_win_set_config(0, win_opts)
end
return
end
end
oil.open_preview(opts)
end,
}
M.preview_scroll_down = {
desc = 'Scroll down in the preview window',
callback = function()
local winid = util.get_preview_win()
if winid then
vim.api.nvim_win_call(winid, function()
vim.cmd.normal({
args = { vim.api.nvim_replace_termcodes('<C-d>', true, true, true) },
bang = true,
})
end)
end
end,
}
M.preview_scroll_up = {
desc = 'Scroll up in the preview window',
callback = function()
local winid = util.get_preview_win()
if winid then
vim.api.nvim_win_call(winid, function()
vim.cmd.normal({
args = { vim.api.nvim_replace_termcodes('<C-u>', true, true, true) },
bang = true,
})
end)
end
end,
}
M.preview_scroll_left = {
desc = 'Scroll left in the preview window',
callback = function()
local winid = util.get_preview_win()
if winid then
vim.api.nvim_win_call(winid, function()
vim.cmd.normal({ 'zH', bang = true })
end)
end
end,
}
M.preview_scroll_right = {
desc = 'Scroll right in the preview window',
callback = function()
local winid = util.get_preview_win()
if winid then
vim.api.nvim_win_call(winid, function()
vim.cmd.normal({ 'zL', bang = true })
end)
end
end,
}
M.parent = {
desc = 'Navigate to the parent path',
callback = oil.open,
}
M.close = {
desc = 'Close oil and restore original buffer',
callback = function(opts)
opts = opts or {}
oil.close(opts)
end,
parameters = {
exit_if_last_buf = {
type = 'boolean',
desc = 'Exit vim if oil is closed as the last buffer',
},
},
}
M.close_float = {
desc = 'Close oil if the window is floating, otherwise do nothing',
callback = function(opts)
if vim.w.is_oil_win then
opts = opts or {}
oil.close(opts)
end
end,
parameters = {
exit_if_last_buf = {
type = 'boolean',
desc = 'Exit vim if oil is closed as the last buffer',
},
},
}
---@param cmd string
---@param silent? boolean
local function cd(cmd, silent)
local dir = oil.get_current_dir()
if dir then
vim.cmd({ cmd = cmd, args = { dir } })
if not silent then
vim.notify(string.format('CWD: %s', dir), vim.log.levels.INFO)
end
else
vim.notify('Cannot :cd; not in a directory', vim.log.levels.WARN)
end
end
M.cd = {
desc = ':cd to the current oil directory',
callback = function(opts)
opts = opts or {}
local cmd = 'cd'
if opts.scope == 'tab' then
cmd = 'tcd'
elseif opts.scope == 'win' then
cmd = 'lcd'
end
cd(cmd, opts.silent)
end,
parameters = {
scope = {
type = 'nil|"tab"|"win"',
desc = 'Scope of the directory change (e.g. use |:tcd| or |:lcd|)',
},
silent = {
type = 'boolean',
desc = 'Do not show a message when changing directories',
},
},
}
M.tcd = {
desc = ':tcd to the current oil directory',
deprecated = true,
callback = function()
cd('tcd')
end,
}
M.open_cwd = {
desc = "Open oil in Neovim's current working directory",
callback = function()
oil.open(vim.fn.getcwd())
end,
}
M.toggle_hidden = {
desc = 'Toggle hidden files and directories',
callback = function()
require('oil.view').toggle_hidden()
end,
}
M.open_terminal = {
desc = 'Open a terminal in the current directory',
callback = function()
local config = require('oil.config')
local bufname = vim.api.nvim_buf_get_name(0)
local adapter = config.get_adapter_by_scheme(bufname)
if not adapter then
return
end
if adapter.name == 'files' then
local dir = oil.get_current_dir()
assert(dir, 'Oil buffer with files adapter must have current directory')
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_current_buf(bufnr)
if vim.fn.has('nvim-0.11') == 1 then
vim.fn.jobstart(vim.o.shell, { cwd = dir, term = true })
else
---@diagnostic disable-next-line: deprecated
vim.fn.termopen(vim.o.shell, { cwd = dir })
end
elseif adapter.name == 'ssh' then
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_set_current_buf(bufnr)
local url = require('oil.adapters.ssh').parse_url(bufname)
local cmd = require('oil.adapters.ssh.connection').create_ssh_command(url)
local term_id
if vim.fn.has('nvim-0.11') == 1 then
term_id = vim.fn.jobstart(cmd, { term = true })
else
---@diagnostic disable-next-line: deprecated
term_id = vim.fn.termopen(cmd)
end
if term_id then
vim.api.nvim_chan_send(term_id, string.format('cd %s\n', url.path))
end
else
vim.notify(
string.format("Cannot open terminal for unsupported adapter: '%s'", adapter.name),
vim.log.levels.WARN
)
end
end,
}
---Copied from vim.ui.open in Neovim 0.10+
---@param path string
---@return nil|string[] cmd
---@return nil|string error
local function get_open_cmd(path)
if vim.fn.has('mac') == 1 then
return { 'open', path }
elseif vim.fn.has('win32') == 1 then
if vim.fn.executable('rundll32') == 1 then
return { 'rundll32', 'url.dll,FileProtocolHandler', path }
else
return nil, 'rundll32 not found'
end
elseif vim.fn.executable('explorer.exe') == 1 then
return { 'explorer.exe', path }
elseif vim.fn.executable('xdg-open') == 1 then
return { 'xdg-open', path }
else
return nil, 'no handler found'
end
end
M.open_external = {
desc = 'Open the entry under the cursor in an external program',
callback = function()
local entry = oil.get_cursor_entry()
local dir = oil.get_current_dir()
if not entry or not dir then
return
end
local path = dir .. entry.name
if vim.ui.open then
vim.ui.open(path)
return
end
local cmd, err = get_open_cmd(path)
if not cmd then
vim.notify(string.format('Could not open %s: %s', path, err), vim.log.levels.ERROR)
return
end
local jid = vim.fn.jobstart(cmd, { detach = true })
assert(jid > 0, 'Failed to start job')
end,
}
M.refresh = {
desc = 'Refresh current directory list',
callback = function(opts)
opts = opts or {}
if vim.bo.modified and not opts.force then
local ok, choice = pcall(vim.fn.confirm, 'Discard changes?', 'No\nYes')
if not ok or choice ~= 2 then
return
end
end
vim.cmd.edit({ bang = true })
-- :h CTRL-L-default
vim.cmd.nohlsearch()
end,
parameters = {
force = {
desc = 'When true, do not prompt user if they will be discarding changes',
type = 'boolean',
},
},
}
local function open_cmdline_with_path(path)
local escaped =
vim.api.nvim_replace_termcodes(': ' .. vim.fn.fnameescape(path) .. '<Home>', true, false, true)
vim.api.nvim_feedkeys(escaped, 'n', false)
end
M.open_cmdline = {
desc = 'Open vim cmdline with current entry as an argument',
callback = function(opts)
opts = vim.tbl_deep_extend('keep', opts or {}, {
shorten_path = true,
})
local config = require('oil.config')
local fs = require('oil.fs')
local entry = oil.get_cursor_entry()
if not entry then
return
end
local bufname = vim.api.nvim_buf_get_name(0)
local scheme, path = util.parse_url(bufname)
if not scheme then
return
end
local adapter = config.get_adapter_by_scheme(scheme)
if not adapter or not path or adapter.name ~= 'files' then
return
end
local fullpath = fs.posix_to_os_path(path) .. entry.name
if opts.modify then
fullpath = vim.fn.fnamemodify(fullpath, opts.modify)
end
if opts.shorten_path then
fullpath = fs.shorten_path(fullpath)
end
open_cmdline_with_path(fullpath)
end,
parameters = {
modify = {
desc = 'Modify the path with |fnamemodify()| using this as the mods argument',
type = 'string',
},
shorten_path = {
desc = 'Use relative paths when possible',
type = 'boolean',
},
},
}
M.yank_entry = {
desc = 'Yank the filepath of the entry under the cursor to a register',
callback = function(opts)
opts = opts or {}
local entry = oil.get_cursor_entry()
local dir = oil.get_current_dir()
if not entry or not dir then
return
end
local name = entry.name
if entry.type == 'directory' then
name = name .. '/'
end
local path = dir .. name
if opts.modify then
path = vim.fn.fnamemodify(path, opts.modify)
end
vim.fn.setreg(vim.v.register, path)
end,
parameters = {
modify = {
desc = 'Modify the path with |fnamemodify()| using this as the mods argument',
type = 'string',
},
},
}
M.copy_entry_path = {
desc = 'Yank the filepath of the entry under the cursor to a register',
deprecated = true,
callback = function()
local entry = oil.get_cursor_entry()
local dir = oil.get_current_dir()
if not entry or not dir then
return
end
vim.fn.setreg(vim.v.register, dir .. entry.name)
end,
}
M.copy_entry_filename = {
desc = 'Yank the filename of the entry under the cursor to a register',
deprecated = true,
callback = function()
local entry = oil.get_cursor_entry()
if not entry then
return
end
vim.fn.setreg(vim.v.register, entry.name)
end,
}
M.copy_to_system_clipboard = {
desc = 'Copy the entry under the cursor to the system clipboard',
callback = function()
require('oil.clipboard').copy_to_system_clipboard()
end,
}
M.paste_from_system_clipboard = {
desc = 'Paste the system clipboard into the current oil directory',
callback = function(opts)
require('oil.clipboard').paste_from_system_clipboard(opts and opts.delete_original)
end,
parameters = {
delete_original = {
type = 'boolean',
desc = 'Delete the original file after copying',
},
},
}
M.open_cmdline_dir = {
desc = 'Open vim cmdline with current directory as an argument',
deprecated = true,
callback = function()
local fs = require('oil.fs')
local dir = oil.get_current_dir()
if dir then
open_cmdline_with_path(fs.shorten_path(dir))
end
end,
}
M.change_sort = {
desc = 'Change the sort order',
callback = function(opts)
opts = opts or {}
if opts.sort then
oil.set_sort(opts.sort)
return
end
local sort_cols = { 'name', 'size', 'atime', 'mtime', 'ctime', 'birthtime' }
vim.ui.select(sort_cols, { prompt = 'Sort by', kind = 'oil_sort_col' }, function(col)
if not col then
return
end
vim.ui.select(
{ 'ascending', 'descending' },
{ prompt = 'Sort order', kind = 'oil_sort_order' },
function(order)
if not order then
return
end
order = order == 'ascending' and 'asc' or 'desc'
oil.set_sort({
{ 'type', 'asc' },
{ col, order },
})
end
)
end)
end,
parameters = {
sort = {
type = 'oil.SortSpec[]',
desc = 'List of columns plus direction (see |oil.set_sort|) instead of interactive selection',
},
},
}
M.toggle_trash = {
desc = 'Jump to and from the trash for the current directory',
callback = function()
local fs = require('oil.fs')
local bufname = vim.api.nvim_buf_get_name(0)
local scheme, path = util.parse_url(bufname)
local bufnr = vim.api.nvim_get_current_buf()
local url
if scheme == 'oil://' then
url = 'oil-trash://' .. path
elseif scheme == 'oil-trash://' then
url = 'oil://' .. path
-- The non-linux trash implementations don't support per-directory trash,
-- so jump back to the stored source buffer.
if not fs.is_linux then
local src_bufnr = vim.b.oil_trash_toggle_src
if src_bufnr and vim.api.nvim_buf_is_valid(src_bufnr) then
url = vim.api.nvim_buf_get_name(src_bufnr)
end
end
else
vim.notify('No trash found for buffer', vim.log.levels.WARN)
return
end
vim.cmd.edit({ args = { url } })
vim.b.oil_trash_toggle_src = bufnr
end,
}
M.send_to_qflist = {
desc = 'Sends files in the current oil directory to the quickfix list, replacing the previous entries.',
callback = function(opts)
opts = vim.tbl_deep_extend('keep', opts or {}, {
target = 'qflist',
action = 'r',
only_matching_search = false,
})
util.send_to_quickfix({
target = opts.target,
action = opts.action,
only_matching_search = opts.only_matching_search,
})
end,
parameters = {
target = {
type = '"qflist"|"loclist"',
desc = 'The target list to send files to',
},
action = {
type = '"r"|"a"',
desc = 'Replace or add to current quickfix list (see |setqflist-action|)',
},
only_matching_search = {
type = 'boolean',
desc = 'Whether to only add the files that matches the last search. This option only applies when search highlighting is active',
},
},
}
M.add_to_qflist = {
desc = 'Adds files in the current oil directory to the quickfix list, keeping the previous entries.',
deprecated = true,
callback = function()
util.send_to_quickfix({
target = 'qflist',
mode = 'a',
})
end,
}
M.send_to_loclist = {
desc = 'Sends files in the current oil directory to the location list, replacing the previous entries.',
deprecated = true,
callback = function()
util.send_to_quickfix({
target = 'loclist',
mode = 'r',
})
end,
}
M.add_to_loclist = {
desc = 'Adds files in the current oil directory to the location list, keeping the previous entries.',
deprecated = true,
callback = function()
util.send_to_quickfix({
target = 'loclist',
mode = 'a',
})
end,
}
---List actions for documentation generation
---@private
M._get_actions = function()
local ret = {}
for name, action in pairs(M) do
if type(action) == 'table' and action.desc then
table.insert(ret, {
name = name,
desc = action.desc,
deprecated = action.deprecated,
parameters = action.parameters,
})
end
end
return ret
end
return M

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

206
lua/oil/cache.lua Normal file
View file

@ -0,0 +1,206 @@
local constants = require('oil.constants')
local util = require('oil.util')
local M = {}
local FIELD_ID = constants.FIELD_ID
local FIELD_NAME = constants.FIELD_NAME
local FIELD_META = constants.FIELD_META
local next_id = 1
-- Map<url, Map<entry name, oil.InternalEntry>>
---@type table<string, table<string, oil.InternalEntry>>
local url_directory = {}
---@type table<integer, oil.InternalEntry>
local entries_by_id = {}
---@type table<integer, string>
local parent_url_by_id = {}
-- Temporary map while a directory is being updated
local tmp_url_directory = {}
local _cached_id_fmt
---@param id integer
---@return string
M.format_id = function(id)
if not _cached_id_fmt then
local id_str_length = math.max(3, 1 + math.floor(math.log10(next_id)))
_cached_id_fmt = '/%0' .. string.format('%d', id_str_length) .. 'd'
end
return _cached_id_fmt:format(id)
end
M.clear_everything = function()
next_id = 1
url_directory = {}
entries_by_id = {}
parent_url_by_id = {}
end
---@param parent_url string
---@param name string
---@param type oil.EntryType
---@return oil.InternalEntry
M.create_entry = function(parent_url, name, type)
parent_url = util.addslash(parent_url)
local parent = tmp_url_directory[parent_url] or url_directory[parent_url]
local entry
if parent then
entry = parent[name]
end
if entry then
return entry
end
return { nil, name, type }
end
---@param parent_url string
---@param entry oil.InternalEntry
M.store_entry = function(parent_url, entry)
parent_url = util.addslash(parent_url)
local parent = url_directory[parent_url]
if not parent then
parent = {}
url_directory[parent_url] = parent
end
local id = entry[FIELD_ID]
if id == nil then
id = next_id
next_id = next_id + 1
entry[FIELD_ID] = id
_cached_id_fmt = nil
end
local name = entry[FIELD_NAME]
parent[name] = entry
local tmp_dir = tmp_url_directory[parent_url]
if tmp_dir and tmp_dir[name] then
tmp_dir[name] = nil
end
entries_by_id[id] = entry
parent_url_by_id[id] = parent_url
end
---@param parent_url string
---@param name string
---@param type oil.EntryType
---@return oil.InternalEntry
M.create_and_store_entry = function(parent_url, name, type)
local entry = M.create_entry(parent_url, name, type)
M.store_entry(parent_url, entry)
return entry
end
---@param parent_url string
M.begin_update_url = function(parent_url)
parent_url = util.addslash(parent_url)
tmp_url_directory[parent_url] = url_directory[parent_url]
url_directory[parent_url] = {}
end
---@param parent_url string
M.end_update_url = function(parent_url)
parent_url = util.addslash(parent_url)
if not tmp_url_directory[parent_url] then
return
end
for _, old_entry in pairs(tmp_url_directory[parent_url]) do
local id = old_entry[FIELD_ID]
parent_url_by_id[id] = nil
entries_by_id[id] = nil
end
tmp_url_directory[parent_url] = nil
end
---@param id integer
---@return nil|oil.InternalEntry
M.get_entry_by_id = function(id)
return entries_by_id[id]
end
---@param url string
---@return nil|oil.InternalEntry
M.get_entry_by_url = function(url)
local scheme, path = util.parse_url(url)
assert(path)
local parent_url = scheme .. vim.fn.fnamemodify(path, ':h')
local basename = vim.fn.fnamemodify(path, ':t')
return M.list_url(parent_url)[basename]
end
---@param id integer
---@return string
M.get_parent_url = function(id)
local url = parent_url_by_id[id]
if not url then
error(string.format('Entry %d missing parent url', id))
end
return url
end
---@param url string
---@return table<string, oil.InternalEntry>
M.list_url = function(url)
url = util.addslash(url)
return url_directory[url] or {}
end
---@param action oil.Action
M.perform_action = function(action)
if action.type == 'create' then
local scheme, path = util.parse_url(action.url)
assert(path)
local parent_url = util.addslash(scheme .. vim.fn.fnamemodify(path, ':h'))
local name = vim.fn.fnamemodify(path, ':t')
M.create_and_store_entry(parent_url, name, action.entry_type)
elseif action.type == 'delete' then
local scheme, path = util.parse_url(action.url)
assert(path)
local parent_url = util.addslash(scheme .. vim.fn.fnamemodify(path, ':h'))
local name = vim.fn.fnamemodify(path, ':t')
local entry = url_directory[parent_url][name]
url_directory[parent_url][name] = nil
entries_by_id[entry[FIELD_ID]] = nil
parent_url_by_id[entry[FIELD_ID]] = nil
elseif action.type == 'move' then
local src_scheme, src_path = util.parse_url(action.src_url)
assert(src_path)
local src_parent_url = util.addslash(src_scheme .. vim.fn.fnamemodify(src_path, ':h'))
local src_name = vim.fn.fnamemodify(src_path, ':t')
local entry = url_directory[src_parent_url][src_name]
local dest_scheme, dest_path = util.parse_url(action.dest_url)
assert(dest_path)
local dest_parent_url = util.addslash(dest_scheme .. vim.fn.fnamemodify(dest_path, ':h'))
local dest_name = vim.fn.fnamemodify(dest_path, ':t')
url_directory[src_parent_url][src_name] = nil
local dest_parent = url_directory[dest_parent_url]
if not dest_parent then
dest_parent = {}
url_directory[dest_parent_url] = dest_parent
end
-- We have to clear the metadata because it can be inaccurate after the move
entry[FIELD_META] = nil
dest_parent[dest_name] = entry
parent_url_by_id[entry[FIELD_ID]] = dest_parent_url
entry[FIELD_NAME] = dest_name
util.update_moved_buffers(action.entry_type, action.src_url, action.dest_url)
elseif action.type == 'copy' then
local scheme, path = util.parse_url(action.dest_url)
assert(path)
local parent_url = util.addslash(scheme .. vim.fn.fnamemodify(path, ':h'))
local name = vim.fn.fnamemodify(path, ':t')
M.create_and_store_entry(parent_url, name, action.entry_type)
-- selene: allow(empty_if)
elseif action.type == 'change' then
-- Cache doesn't need to update
else
---@diagnostic disable-next-line: undefined-field
error(string.format("Bad action type: '%s'", action.type))
end
end
return M

370
lua/oil/clipboard.lua Normal file
View file

@ -0,0 +1,370 @@
local cache = require('oil.cache')
local columns = require('oil.columns')
local config = require('oil.config')
local fs = require('oil.fs')
local oil = require('oil')
local parser = require('oil.mutator.parser')
local util = require('oil.util')
local view = require('oil.view')
local M = {}
---@return "wayland"|"x11"|nil
local function get_linux_session_type()
local xdg_session_type = vim.env.XDG_SESSION_TYPE
if not xdg_session_type then
return
end
xdg_session_type = xdg_session_type:lower()
if xdg_session_type:find('x11') then
return 'x11'
elseif xdg_session_type:find('wayland') then
return 'wayland'
else
return nil
end
end
---@return boolean
local function is_linux_desktop_gnome()
local cur_desktop = vim.env.XDG_CURRENT_DESKTOP
local session_desktop = vim.env.XDG_SESSION_DESKTOP
local idx = session_desktop and session_desktop:lower():find('gnome')
or cur_desktop and cur_desktop:lower():find('gnome')
return idx ~= nil or cur_desktop == 'X-Cinnamon' or cur_desktop == 'XFCE'
end
---@param winid integer
---@param entry oil.InternalEntry
---@param column_defs oil.ColumnSpec[]
---@param adapter oil.Adapter
---@param bufnr integer
local function write_pasted(winid, entry, column_defs, adapter, bufnr)
local col_width = {}
for i in ipairs(column_defs) do
col_width[i + 1] = 1
end
local line_table =
{ view.format_entry_cols(entry, column_defs, col_width, adapter, false, bufnr) }
local lines, _ = util.render_table(line_table, col_width)
local pos = vim.api.nvim_win_get_cursor(winid)
vim.api.nvim_buf_set_lines(bufnr, pos[1], pos[1], true, lines)
end
---@param parent_url string
---@param entry oil.InternalEntry
local function remove_entry_from_parent_buffer(parent_url, entry)
local bufnr = vim.fn.bufadd(parent_url)
assert(vim.api.nvim_buf_is_loaded(bufnr), 'Expected parent buffer to be loaded during paste')
local adapter = assert(util.get_adapter(bufnr))
local column_defs = columns.get_supported_columns(adapter)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
for i, line in ipairs(lines) do
local result = parser.parse_line(adapter, line, column_defs)
if result and result.entry == entry then
vim.api.nvim_buf_set_lines(bufnr, i - 1, i, false, {})
return
end
end
local exported = util.export_entry(entry)
vim.notify(
string.format("Error: could not delete original file '%s'", exported.name),
vim.log.levels.ERROR
)
end
---@param paths string[]
---@param delete_original? boolean
local function paste_paths(paths, delete_original)
local bufnr = vim.api.nvim_get_current_buf()
local scheme = 'oil://'
local adapter = assert(config.get_adapter_by_scheme(scheme))
local column_defs = columns.get_supported_columns(scheme)
local winid = vim.api.nvim_get_current_win()
local parent_urls = {}
local pending_paths = {}
-- Handle as many paths synchronously as possible
for _, path in ipairs(paths) do
-- Trim the trailing slash off directories
if vim.endswith(path, '/') then
path = path:sub(1, -2)
end
local ori_entry = cache.get_entry_by_url(scheme .. path)
local parent_url = util.addslash(scheme .. vim.fs.dirname(path))
if ori_entry then
write_pasted(winid, ori_entry, column_defs, adapter, bufnr)
if delete_original then
remove_entry_from_parent_buffer(parent_url, ori_entry)
end
else
parent_urls[parent_url] = true
table.insert(pending_paths, path)
end
end
-- If all paths could be handled synchronously, we're done
if #pending_paths == 0 then
return
end
-- Process the remaining paths by asynchronously loading them
local cursor = vim.api.nvim_win_get_cursor(winid)
local complete_loading = util.cb_collect(#vim.tbl_keys(parent_urls), function(err)
if err then
vim.notify(string.format('Error loading parent directory: %s', err), vim.log.levels.ERROR)
else
-- Something in this process moves the cursor to the top of the window, so have to restore it
vim.api.nvim_win_set_cursor(winid, cursor)
for _, path in ipairs(pending_paths) do
local ori_entry = cache.get_entry_by_url(scheme .. path)
if ori_entry then
write_pasted(winid, ori_entry, column_defs, adapter, bufnr)
if delete_original then
local parent_url = util.addslash(scheme .. vim.fs.dirname(path))
remove_entry_from_parent_buffer(parent_url, ori_entry)
end
else
vim.notify(
string.format("The pasted file '%s' could not be found", path),
vim.log.levels.ERROR
)
end
end
end
end)
for parent_url, _ in pairs(parent_urls) do
local new_bufnr = vim.api.nvim_create_buf(false, false)
vim.api.nvim_buf_set_name(new_bufnr, parent_url)
oil.load_oil_buffer(new_bufnr)
util.run_after_load(new_bufnr, complete_loading)
end
end
---@return integer start
---@return integer end
local function range_from_selection()
-- [bufnum, lnum, col, off]; both row and column 1-indexed
local start = vim.fn.getpos('v')
local end_ = vim.fn.getpos('.')
local start_row = start[2]
local end_row = end_[2]
if start_row > end_row then
start_row, end_row = end_row, start_row
end
return start_row, end_row
end
M.copy_to_system_clipboard = function()
local dir = oil.get_current_dir()
if not dir then
vim.notify('System clipboard only works for local files', vim.log.levels.ERROR)
return
end
local entries = {}
local mode = vim.api.nvim_get_mode().mode
if mode == 'v' or mode == 'V' then
if fs.is_mac then
vim.notify(
'Copying multiple paths to clipboard is not supported on mac',
vim.log.levels.ERROR
)
return
end
local start_row, end_row = range_from_selection()
for i = start_row, end_row do
table.insert(entries, oil.get_entry_on_line(0, i))
end
-- leave visual mode
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'n', true)
else
table.insert(entries, oil.get_cursor_entry())
end
-- This removes holes in the list-like table
entries = vim.tbl_values(entries)
if #entries == 0 then
vim.notify('Could not find local file under cursor', vim.log.levels.WARN)
return
end
local paths = {}
for _, entry in ipairs(entries) do
table.insert(paths, dir .. entry.name)
end
local cmd = {}
local stdin
if fs.is_mac then
cmd = {
'osascript',
'-e',
'on run args',
'-e',
'set the clipboard to POSIX file (first item of args)',
'-e',
'end run',
paths[1],
}
elseif fs.is_linux then
local xdg_session_type = get_linux_session_type()
if xdg_session_type == 'x11' then
vim.list_extend(cmd, { 'xclip', '-i', '-selection', 'clipboard' })
elseif xdg_session_type == 'wayland' then
table.insert(cmd, 'wl-copy')
else
vim.notify('System clipboard not supported, check $XDG_SESSION_TYPE', vim.log.levels.ERROR)
return
end
local urls = {}
for _, path in ipairs(paths) do
table.insert(urls, 'file://' .. path)
end
if is_linux_desktop_gnome() then
stdin = string.format('copy\n%s\0', table.concat(urls, '\n'))
vim.list_extend(cmd, { '-t', 'x-special/gnome-copied-files' })
else
stdin = table.concat(urls, '\n') .. '\n'
vim.list_extend(cmd, { '-t', 'text/uri-list' })
end
else
vim.notify('System clipboard not supported on Windows', vim.log.levels.ERROR)
return
end
if vim.fn.executable(cmd[1]) == 0 then
vim.notify(string.format("Could not find executable '%s'", cmd[1]), vim.log.levels.ERROR)
return
end
local stderr = ''
local jid = vim.fn.jobstart(cmd, {
stderr_buffered = true,
on_stderr = function(_, data)
stderr = table.concat(data, '\n')
end,
on_exit = function(j, exit_code)
if exit_code ~= 0 then
vim.notify(
string.format("Error copying '%s' to system clipboard\n%s", vim.inspect(paths), stderr),
vim.log.levels.ERROR
)
else
if #paths == 1 then
vim.notify(string.format("Copied '%s' to system clipboard", paths[1]))
else
vim.notify(string.format('Copied %d files to system clipboard', #paths))
end
end
end,
})
assert(jid > 0, 'Failed to start job')
if stdin then
vim.api.nvim_chan_send(jid, stdin)
vim.fn.chanclose(jid, 'stdin')
end
end
---@param lines string[]
---@return string[]
local function handle_paste_output_mac(lines)
local ret = {}
for _, line in ipairs(lines) do
if not line:match('^%s*$') then
table.insert(ret, line)
end
end
return ret
end
---@param lines string[]
---@return string[]
local function handle_paste_output_linux(lines)
local ret = {}
for _, line in ipairs(lines) do
local path = line:match('^file://(.+)$')
if path then
table.insert(ret, util.url_unescape(path))
end
end
return ret
end
---@param delete_original? boolean Delete the source file after pasting
M.paste_from_system_clipboard = function(delete_original)
local dir = oil.get_current_dir()
if not dir then
return
end
local cmd = {}
local handle_paste_output
if fs.is_mac then
cmd = {
'osascript',
'-e',
'on run',
'-e',
'POSIX path of (the clipboard as «class furl»)',
'-e',
'end run',
}
handle_paste_output = handle_paste_output_mac
elseif fs.is_linux then
local xdg_session_type = get_linux_session_type()
if xdg_session_type == 'x11' then
vim.list_extend(cmd, { 'xclip', '-o', '-selection', 'clipboard' })
elseif xdg_session_type == 'wayland' then
table.insert(cmd, 'wl-paste')
else
vim.notify('System clipboard not supported, check $XDG_SESSION_TYPE', vim.log.levels.ERROR)
return
end
if is_linux_desktop_gnome() then
vim.list_extend(cmd, { '-t', 'x-special/gnome-copied-files' })
else
vim.list_extend(cmd, { '-t', 'text/uri-list' })
end
handle_paste_output = handle_paste_output_linux
else
vim.notify('System clipboard not supported on Windows', vim.log.levels.ERROR)
return
end
local paths
local stderr = ''
if vim.fn.executable(cmd[1]) == 0 then
vim.notify(string.format("Could not find executable '%s'", cmd[1]), vim.log.levels.ERROR)
return
end
local jid = vim.fn.jobstart(cmd, {
stdout_buffered = true,
stderr_buffered = true,
on_stdout = function(j, data)
local lines = vim.split(table.concat(data, '\n'), '\r?\n')
paths = handle_paste_output(lines)
end,
on_stderr = function(_, data)
stderr = table.concat(data, '\n')
end,
on_exit = function(j, exit_code)
if exit_code ~= 0 or not paths then
vim.notify(
string.format('Error pasting from system clipboard: %s', stderr),
vim.log.levels.ERROR
)
elseif #paths == 0 then
vim.notify('No valid files found in system clipboard', vim.log.levels.WARN)
else
paste_paths(paths, delete_original)
end
end,
})
assert(jid > 0, 'Failed to start job')
end
return M

287
lua/oil/columns.lua Normal file
View file

@ -0,0 +1,287 @@
local config = require('oil.config')
local constants = require('oil.constants')
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
local all_columns = {}
---@alias oil.ColumnSpec string|{[1]: string, [string]: any}
---@class (exact) oil.ColumnDefinition
---@field render fun(entry: oil.InternalEntry, conf: nil|table, bufnr: integer): nil|oil.TextChunk
---@field parse fun(line: string, conf: nil|table): nil|string, nil|string
---@field compare? fun(entry: oil.InternalEntry, parsed_value: any): boolean
---@field render_action? fun(action: oil.ChangeAction): string
---@field perform_action? fun(action: oil.ChangeAction, callback: fun(err: nil|string))
---@field get_sort_value? fun(entry: oil.InternalEntry): number|string
---@field create_sort_value_factory? fun(num_entries: integer): fun(entry: oil.InternalEntry): number|string
---@param name string
---@param column oil.ColumnDefinition
M.register = function(name, column)
all_columns[name] = column
end
---@param adapter oil.Adapter
---@param defn oil.ColumnSpec
---@return nil|oil.ColumnDefinition
M.get_column = function(adapter, defn)
local name = util.split_config(defn)
return all_columns[name] or adapter.get_column(name)
end
---@param adapter_or_scheme string|oil.Adapter
---@return oil.ColumnSpec[]
M.get_supported_columns = function(adapter_or_scheme)
local adapter
if type(adapter_or_scheme) == 'string' then
adapter = config.get_adapter_by_scheme(adapter_or_scheme)
else
adapter = adapter_or_scheme
end
assert(adapter)
local ret = {}
for _, def in ipairs(config.columns) do
if M.get_column(adapter, def) then
table.insert(ret, def)
end
end
return ret
end
local EMPTY = { '-', 'OilEmpty' }
M.EMPTY = EMPTY
---@param adapter oil.Adapter
---@param col_def oil.ColumnSpec
---@param entry oil.InternalEntry
---@param bufnr integer
---@return oil.TextChunk
M.render_col = function(adapter, col_def, entry, bufnr)
local name, conf = util.split_config(col_def)
local column = M.get_column(adapter, name)
if not column then
-- This shouldn't be possible because supports_col should return false
return EMPTY
end
local chunk = column.render(entry, conf, bufnr)
if type(chunk) == 'table' then
if chunk[1]:match('^%s*$') then
return EMPTY
end
else
if not chunk or chunk:match('^%s*$') then
return EMPTY
end
if conf and conf.highlight then
local highlight = conf.highlight
if type(highlight) == 'function' then
highlight = conf.highlight(chunk)
end
return { chunk, highlight }
end
end
return chunk
end
---@param adapter oil.Adapter
---@param line string
---@param col_def oil.ColumnSpec
---@return nil|string
---@return nil|string
M.parse_col = function(adapter, line, col_def)
local name, conf = util.split_config(col_def)
-- If rendering failed, there will just be a "-"
local empty_col, rem = line:match('^%s*(-%s+)(.*)$')
if empty_col then
return nil, rem
end
local column = M.get_column(adapter, name)
if column then
return column.parse(line:gsub('^%s+', ''), conf)
end
end
---@param adapter oil.Adapter
---@param col_name string
---@param entry oil.InternalEntry
---@param parsed_value any
---@return boolean
M.compare = function(adapter, col_name, entry, parsed_value)
local column = M.get_column(adapter, col_name)
if column and column.compare then
return column.compare(entry, parsed_value)
else
return false
end
end
---@param adapter oil.Adapter
---@param action oil.ChangeAction
---@return string
M.render_change_action = function(adapter, action)
local column = M.get_column(adapter, action.column)
if not column then
error(string.format('Received change action for nonexistant column %s', action.column))
end
if column.render_action then
return column.render_action(action)
else
return string.format('CHANGE %s %s = %s', action.url, action.column, action.value)
end
end
---@param adapter oil.Adapter
---@param action oil.ChangeAction
---@param callback fun(err: nil|string)
M.perform_change_action = function(adapter, action, callback)
local column = M.get_column(adapter, action.column)
if not column then
return callback(
string.format('Received change action for nonexistant column %s', action.column)
)
end
column.perform_action(action, callback)
end
local icon_provider = util.get_icon_provider()
if icon_provider then
M.register('icon', {
render = function(entry, conf, bufnr)
local field_type = entry[FIELD_TYPE]
local name = entry[FIELD_NAME]
local meta = entry[FIELD_META]
if field_type == 'link' and meta then
if meta.link then
name = meta.link
end
if meta.link_stat then
field_type = meta.link_stat.type
end
end
if meta and meta.display_name then
name = meta.display_name
end
local ft = nil
if conf and conf.use_slow_filetype_detection and field_type == 'file' then
local bufname = vim.api.nvim_buf_get_name(bufnr)
local _, path = util.parse_url(bufname)
if path then
local lines = vim.fn.readfile(path .. name, '', 16)
if lines and #lines > 0 then
ft = vim.filetype.match({ filename = name, contents = lines })
end
end
end
local icon, hl = icon_provider(field_type, name, conf, ft)
if not conf or conf.add_padding ~= false then
icon = icon .. ' '
end
if conf and conf.highlight then
if type(conf.highlight) == 'function' then
hl = conf.highlight(icon)
else
hl = conf.highlight
end
end
return { icon, hl }
end,
parse = function(line, conf)
return line:match('^(%S+)%s+(.*)$')
end,
})
end
local default_type_icons = {
directory = 'dir',
socket = 'sock',
}
---@param entry oil.InternalEntry
---@return boolean
local function is_entry_directory(entry)
local type = entry[FIELD_TYPE]
if type == 'directory' then
return true
elseif type == 'link' then
local meta = entry[FIELD_META]
return (meta and meta.link_stat and meta.link_stat.type == 'directory') == true
else
return false
end
end
M.register('type', {
render = function(entry, conf)
local entry_type = entry[FIELD_TYPE]
if conf and conf.icons then
return conf.icons[entry_type] or entry_type
else
return default_type_icons[entry_type] or entry_type
end
end,
parse = function(line, conf)
return line:match('^(%S+)%s+(.*)$')
end,
get_sort_value = function(entry)
if is_entry_directory(entry) then
return 1
else
return 2
end
end,
})
local function adjust_number(int)
return string.format('%03d%s', #int, int)
end
M.register('name', {
render = function(entry, conf)
error('Do not use the name column. It is for sorting only')
end,
parse = function(line, conf)
error('Do not use the name column. It is for sorting only')
end,
create_sort_value_factory = function(num_entries)
if
config.view_options.natural_order == false
or (config.view_options.natural_order == 'fast' and num_entries > 5000)
then
if config.view_options.case_insensitive then
return function(entry)
return entry[FIELD_NAME]:lower()
end
else
return function(entry)
return entry[FIELD_NAME]
end
end
else
local memo = {}
return function(entry)
if memo[entry] == nil then
local name = entry[FIELD_NAME]:gsub('0*(%d+)', adjust_number)
if config.view_options.case_insensitive then
name = name:lower()
end
memo[entry] = name
end
return memo[entry]
end
end
end,
})
return M

529
lua/oil/config.lua Normal file
View file

@ -0,0 +1,529 @@
local default_config = {
-- Oil will take over directory buffers (e.g. `vim .` or `:e src/`)
-- Set to false if you want some other plugin (e.g. netrw) to open when you edit directories.
default_file_explorer = true,
-- Id is automatically added at the beginning, and name at the end
-- See :help oil-columns
columns = {
'icon',
-- "permissions",
-- "size",
-- "mtime",
},
-- Buffer-local options to use for oil buffers
buf_options = {
buflisted = false,
bufhidden = 'hide',
},
-- Window-local options to use for oil buffers
win_options = {
wrap = false,
signcolumn = 'no',
cursorcolumn = false,
foldcolumn = '0',
spell = false,
list = false,
conceallevel = 3,
concealcursor = 'nvic',
},
-- Send deleted files to the trash instead of permanently deleting them (:help oil-trash)
delete_to_trash = false,
-- Wipe open buffers for files deleted via oil (:help oil.cleanup_buffers_on_delete)
cleanup_buffers_on_delete = false,
-- Skip the confirmation popup for simple operations (:help oil.skip_confirm_for_simple_edits)
skip_confirm_for_simple_edits = false,
skip_confirm_for_delete = false,
-- Selecting a new/moved/renamed file or directory will prompt you to save changes first
-- (:help prompt_save_on_select_new_entry)
prompt_save_on_select_new_entry = true,
auto_save_on_select_new_entry = false,
-- Oil will automatically delete hidden buffers after this delay
-- You can set the delay to false to disable cleanup entirely
-- Note that the cleanup process only starts when none of the oil buffers are currently displayed
cleanup_delay_ms = 2000,
lsp_file_methods = {
-- Enable or disable LSP file operations
enabled = true,
-- Time to wait for LSP file operations to complete before skipping
timeout_ms = 1000,
-- Set to true to autosave buffers that are updated with LSP willRenameFiles
-- Set to "unmodified" to only save unmodified buffers
autosave_changes = false,
},
-- Constrain the cursor to the editable parts of the oil buffer
-- Set to `false` to disable, or "name" to keep it on the file names
constrain_cursor = 'editable',
-- Set to true to watch the filesystem for changes and reload oil
watch_for_changes = false,
-- Keymaps in oil buffer. Can be any value that `vim.keymap.set` accepts OR a table of keymap
-- options with a `callback` (e.g. { callback = function() ... end, desc = "", mode = "n" })
-- Additionally, if it is a string that matches "actions.<name>",
-- it will use the mapping at require("oil.actions").<name>
-- Set to `false` to remove a keymap
-- See :help oil-actions for a list of all available actions
keymaps = {
['g?'] = { 'actions.show_help', mode = 'n' },
['<CR>'] = 'actions.select',
['<C-s>'] = { 'actions.select', opts = { vertical = true } },
['<C-h>'] = { 'actions.select', opts = { horizontal = true } },
['<C-t>'] = { 'actions.select', opts = { tab = true } },
['<C-p>'] = 'actions.preview',
['<C-c>'] = { 'actions.close', mode = 'n' },
['<C-l>'] = 'actions.refresh',
['-'] = { 'actions.parent', mode = 'n' },
['_'] = { 'actions.open_cwd', mode = 'n' },
['`'] = { 'actions.cd', mode = 'n' },
['g~'] = { 'actions.cd', opts = { scope = 'tab' }, mode = 'n' },
['gs'] = { 'actions.change_sort', mode = 'n' },
['gx'] = 'actions.open_external',
['g.'] = { 'actions.toggle_hidden', mode = 'n' },
['g\\'] = { 'actions.toggle_trash', mode = 'n' },
},
-- Set to false to disable all of the above keymaps
use_default_keymaps = true,
view_options = {
-- Show files and directories that start with "."
show_hidden = false,
show_hidden_when_empty = false,
-- This function defines what is considered a "hidden" file
is_hidden_file = function(name, bufnr)
local m = name:match('^%.')
return m ~= nil
end,
-- This function defines what will never be shown, even when `show_hidden` is set
is_always_hidden = function(name, bufnr)
return false
end,
-- Sort file names with numbers in a more intuitive order for humans.
-- Can be "fast", true, or false. "fast" will turn it off for large directories.
natural_order = 'fast',
-- Sort file and directory names case insensitive
case_insensitive = false,
sort = {
-- sort order can be "asc" or "desc"
-- see :help oil-columns to see which columns are sortable
{ 'type', 'asc' },
{ 'name', 'asc' },
},
-- Customize the highlight group for the file name
highlight_filename = function(entry, is_hidden, is_link_target, is_link_orphan)
return nil
end,
},
new_file_mode = 420,
new_dir_mode = 493,
-- Extra arguments to pass to SCP when moving/copying files over SSH
extra_scp_args = {},
-- Extra arguments to pass to aws s3 when creating/deleting/moving/copying files using aws s3
extra_s3_args = {},
-- EXPERIMENTAL support for performing file operations with git
git = {
-- Return true to automatically git add/mv/rm files
add = function(path)
return false
end,
mv = function(src_path, dest_path)
return false
end,
rm = function(path)
return false
end,
},
-- Configuration for the floating window in oil.open_float
float = {
-- Padding around the floating window
padding = 2,
-- max_width and max_height can be integers or a float between 0 and 1 (e.g. 0.4 for 40%)
max_width = 0,
max_height = 0,
border = nil,
win_options = {
winblend = 0,
},
-- optionally override the oil buffers window title with custom function: fun(winid: integer): string
get_win_title = nil,
-- preview_split: Split direction: "auto", "left", "right", "above", "below".
preview_split = 'auto',
-- This is the config that will be passed to nvim_open_win.
-- Change values here to customize the layout
override = function(conf)
return conf
end,
},
-- Configuration for the file preview window
preview_win = {
-- Whether the preview window is automatically updated when the cursor is moved
update_on_cursor_moved = true,
-- How to open the preview window "load"|"scratch"|"fast_scratch"
preview_method = 'fast_scratch',
-- A function that returns true to disable preview on a file e.g. to avoid lag
disable_preview = function(filename)
return false
end,
max_file_size = 10,
-- Window-local options to use for preview window buffers
win_options = {},
},
-- Configuration for the floating action confirmation window
confirmation = {
-- Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%)
-- min_width and max_width can be a single value or a list of mixed integer/float types.
-- max_width = {100, 0.8} means "the lesser of 100 columns or 80% of total"
max_width = 0.9,
-- min_width = {40, 0.4} means "the greater of 40 columns or 40% of total"
min_width = { 40, 0.4 },
-- optionally define an integer/float for the exact width of the preview window
width = nil,
-- Height dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%)
-- min_height and max_height can be a single value or a list of mixed integer/float types.
-- max_height = {80, 0.9} means "the lesser of 80 columns or 90% of total"
max_height = 0.9,
-- min_height = {5, 0.1} means "the greater of 5 columns or 10% of total"
min_height = { 5, 0.1 },
-- optionally define an integer/float for the exact height of the preview window
height = nil,
border = nil,
win_options = {
winblend = 0,
},
},
-- Configuration for the floating progress window
progress = {
max_width = 0.9,
min_width = { 40, 0.4 },
width = nil,
max_height = { 10, 0.9 },
min_height = { 5, 0.1 },
height = nil,
border = nil,
minimized_border = 'none',
win_options = {
winblend = 0,
},
},
-- Configuration for the floating SSH window
ssh = {
border = nil,
},
-- Configuration for the floating keymaps help window
keymaps_help = {
border = nil,
},
}
-- The adapter API hasn't really stabilized yet. We're not ready to advertise or encourage people to
-- write their own adapters, and so there's no real reason to edit these config options. For that
-- reason, I'm taking them out of the section above so they won't show up in the autogen docs.
-- not "oil-s3://" on older neovim versions, since it doesn't open buffers correctly with a number
-- in the name
local oil_s3_string = vim.fn.has('nvim-0.12') == 1 and 'oil-s3://' or 'oil-sss://'
default_config.adapters = {
['oil://'] = 'files',
['oil-ssh://'] = 'ssh',
[oil_s3_string] = 's3',
['oil-trash://'] = 'trash',
}
default_config.adapter_aliases = {}
-- We want the function in the default config for documentation generation, but if we nil it out
-- here we can get some performance wins
default_config.view_options.highlight_filename = nil
---@class oil.Config
---@field adapters table<string, string> Hidden from SetupOpts
---@field adapter_aliases table<string, string> Hidden from SetupOpts
---@field silence_scp_warning? boolean Undocumented option
---@field default_file_explorer boolean
---@field columns oil.ColumnSpec[]
---@field buf_options table<string, any>
---@field win_options table<string, any>
---@field delete_to_trash boolean
---@field cleanup_buffers_on_delete boolean
---@field skip_confirm_for_simple_edits boolean
---@field skip_confirm_for_delete boolean
---@field prompt_save_on_select_new_entry boolean
---@field auto_save_on_select_new_entry boolean
---@field cleanup_delay_ms integer
---@field lsp_file_methods oil.LspFileMethods
---@field constrain_cursor false|"name"|"editable"
---@field watch_for_changes boolean
---@field keymaps table<string, any>
---@field use_default_keymaps boolean
---@field view_options oil.ViewOptions
---@field new_file_mode integer
---@field new_dir_mode integer
---@field extra_scp_args string[]
---@field extra_s3_args string[]
---@field git oil.GitOptions
---@field float oil.FloatWindowConfig
---@field preview_win oil.PreviewWindowConfig
---@field confirmation oil.ConfirmationWindowConfig
---@field progress oil.ProgressWindowConfig
---@field ssh oil.SimpleWindowConfig
---@field keymaps_help oil.SimpleWindowConfig
local M = {}
-- For backwards compatibility
---@alias oil.setupOpts oil.SetupOpts
---@class (exact) oil.SetupOpts
---@field default_file_explorer? boolean Oil will take over directory buffers (e.g. `vim .` or `:e src/`). Set to false if you still want to use netrw.
---@field columns? oil.ColumnSpec[] The columns to display. See :help oil-columns.
---@field buf_options? table<string, any> Buffer-local options to use for oil buffers
---@field win_options? table<string, any> Window-local options to use for oil buffers
---@field delete_to_trash? boolean Send deleted files to the trash instead of permanently deleting them (:help oil-trash).
---@field cleanup_buffers_on_delete? boolean Wipe open buffers for files deleted via oil (:help oil.cleanup_buffers_on_delete).
---@field skip_confirm_for_simple_edits? boolean Skip the confirmation popup for simple operations (:help oil.skip_confirm_for_simple_edits).
---@field skip_confirm_for_delete? boolean Skip the confirmation popup when all pending actions are deletes (:help oil.skip_confirm_for_delete).
---@field prompt_save_on_select_new_entry? boolean Selecting a new/moved/renamed file or directory will prompt you to save changes first (:help prompt_save_on_select_new_entry).
---@field auto_save_on_select_new_entry? boolean Automatically save changes when selecting a new/moved/renamed entry, instead of prompting (:help oil.auto_save_on_select_new_entry).
---@field cleanup_delay_ms? integer Oil will automatically delete hidden buffers after this delay. You can set the delay to false to disable cleanup entirely. Note that the cleanup process only starts when none of the oil buffers are currently displayed.
---@field lsp_file_methods? oil.SetupLspFileMethods Configure LSP file operation integration.
---@field constrain_cursor? false|"name"|"editable" Constrain the cursor to the editable parts of the oil buffer. Set to `false` to disable, or "name" to keep it on the file names.
---@field watch_for_changes? boolean Set to true to watch the filesystem for changes and reload oil.
---@field keymaps? table<string, any>
---@field use_default_keymaps? boolean Set to false to disable all of the above keymaps
---@field view_options? oil.SetupViewOptions Configure which files are shown and how they are shown.
---@field new_file_mode? integer Permission mode for new files in decimal (default 420 = 0644)
---@field new_dir_mode? integer Permission mode for new directories in decimal (default 493 = 0755)
---@field extra_scp_args? string[] Extra arguments to pass to SCP when moving/copying files over SSH
---@field extra_s3_args? string[] Extra arguments to pass to aws s3 when moving/copying files using aws s3
---@field git? oil.SetupGitOptions EXPERIMENTAL support for performing file operations with git
---@field float? oil.SetupFloatWindowConfig Configuration for the floating window in oil.open_float
---@field preview_win? oil.SetupPreviewWindowConfig Configuration for the file preview window
---@field confirmation? oil.SetupConfirmationWindowConfig Configuration for the floating action confirmation window
---@field progress? oil.SetupProgressWindowConfig Configuration for the floating progress window
---@field ssh? oil.SetupSimpleWindowConfig Configuration for the floating SSH window
---@field keymaps_help? oil.SetupSimpleWindowConfig Configuration for the floating keymaps help window
---@class (exact) oil.LspFileMethods
---@field enabled boolean
---@field timeout_ms integer
---@field autosave_changes boolean|"unmodified" Set to true to autosave buffers that are updated with LSP willRenameFiles. Set to "unmodified" to only save unmodified buffers.
---@class (exact) oil.SetupLspFileMethods
---@field enabled? boolean Enable or disable LSP file operations
---@field timeout_ms? integer Time to wait for LSP file operations to complete before skipping.
---@field autosave_changes? boolean|"unmodified" Set to true to autosave buffers that are updated with LSP willRenameFiles. Set to "unmodified" to only save unmodified buffers.
---@class (exact) oil.ViewOptions
---@field show_hidden boolean
---@field show_hidden_when_empty boolean
---@field is_hidden_file fun(name: string, bufnr: integer, entry: oil.Entry): boolean
---@field is_always_hidden fun(name: string, bufnr: integer, entry: oil.Entry): boolean
---@field natural_order boolean|"fast"
---@field case_insensitive boolean
---@field sort oil.SortSpec[]
---@field highlight_filename? fun(entry: oil.Entry, is_hidden: boolean, is_link_target: boolean, is_link_orphan: boolean, bufnr: integer): string|nil
---@class (exact) oil.SetupViewOptions
---@field show_hidden? boolean Show files and directories that start with "."
---@field show_hidden_when_empty? boolean When true and the directory has no visible entries, show hidden entries instead of an empty listing (:help oil.show_hidden_when_empty).
---@field is_hidden_file? fun(name: string, bufnr: integer): boolean This function defines what is considered a "hidden" file
---@field is_always_hidden? fun(name: string, bufnr: integer): boolean This function defines what will never be shown, even when `show_hidden` is set
---@field natural_order? boolean|"fast" Sort file names with numbers in a more intuitive order for humans. Can be slow for large directories.
---@field case_insensitive? boolean Sort file and directory names case insensitive
---@field sort? oil.SortSpec[] Sort order for the file list
---@field highlight_filename? fun(entry: oil.Entry, is_hidden: boolean, is_link_target: boolean, is_link_orphan: boolean): string|nil Customize the highlight group for the file name
---@class (exact) oil.SortSpec
---@field [1] string
---@field [2] "asc"|"desc"
---@class (exact) oil.GitOptions
---@field add fun(path: string): boolean
---@field mv fun(src_path: string, dest_path: string): boolean
---@field rm fun(path: string): boolean
---@class (exact) oil.SetupGitOptions
---@field add? fun(path: string): boolean Return true to automatically git add a new file
---@field mv? fun(src_path: string, dest_path: string): boolean Return true to automatically git mv a moved file
---@field rm? fun(path: string): boolean Return true to automatically git rm a deleted file
---@class (exact) oil.WindowDimensionDualConstraint
---@field [1] number
---@field [2] number
---@alias oil.WindowDimension number|oil.WindowDimensionDualConstraint
---@class (exact) oil.WindowConfig
---@field max_width oil.WindowDimension
---@field min_width oil.WindowDimension
---@field width? number
---@field max_height oil.WindowDimension
---@field min_height oil.WindowDimension
---@field height? number
---@field border string|string[]
---@field win_options table<string, any>
---@class (exact) oil.SetupWindowConfig
---@field max_width? oil.WindowDimension Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%). Can be a single value or a list of mixed integer/float types. max_width = {100, 0.8} means "the lesser of 100 columns or 80% of total"
---@field min_width? oil.WindowDimension Width dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%). Can be a single value or a list of mixed integer/float types. min_width = {40, 0.4} means "the greater of 40 columns or 40% of total"
---@field width? number Define an integer/float for the exact width of the preview window
---@field max_height? oil.WindowDimension Height dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%). Can be a single value or a list of mixed integer/float types. max_height = {80, 0.9} means "the lesser of 80 columns or 90% of total"
---@field min_height? oil.WindowDimension Height dimensions can be integers or a float between 0 and 1 (e.g. 0.4 for 40%). Can be a single value or a list of mixed integer/float types. min_height = {5, 0.1} means "the greater of 5 columns or 10% of total"
---@field height? number Define an integer/float for the exact height of the preview window
---@field border? string|string[] Window border
---@field win_options? table<string, any>
---@alias oil.PreviewMethod
---| '"load"' # Load the previewed file into a buffer
---| '"scratch"' # Put the text into a scratch buffer to avoid LSP attaching
---| '"fast_scratch"' # Put only the visible text into a scratch buffer
---@class (exact) oil.PreviewWindowConfig
---@field update_on_cursor_moved boolean
---@field preview_method oil.PreviewMethod
---@field disable_preview fun(filename: string): boolean
---@field max_file_size number Maximum file size (in MB) to preview. Files larger than this will show a placeholder.
---@field win_options table<string, any>
---@class (exact) oil.ConfirmationWindowConfig : oil.WindowConfig
---@class (exact) oil.SetupPreviewWindowConfig
---@field update_on_cursor_moved? boolean Whether the preview window is automatically updated when the cursor is moved
---@field disable_preview? fun(filename: string): boolean A function that returns true to disable preview on a file e.g. to avoid lag
---@field max_file_size? number Maximum file size in MB to show in preview. Files exceeding this will not be loaded (:help oil.preview_win). Set to nil to disable the limit.
---@field preview_method? oil.PreviewMethod How to open the preview window
---@field win_options? table<string, any> Window-local options to use for preview window buffers
---@class (exact) oil.SetupConfirmationWindowConfig : oil.SetupWindowConfig
---@class (exact) oil.ProgressWindowConfig : oil.WindowConfig
---@field minimized_border string|string[]
---@class (exact) oil.SetupProgressWindowConfig : oil.SetupWindowConfig
---@field minimized_border? string|string[] The border for the minimized progress window
---@class (exact) oil.FloatWindowConfig
---@field padding integer
---@field max_width integer
---@field max_height integer
---@field border string|string[]
---@field win_options table<string, any>
---@field get_win_title fun(winid: integer): string
---@field preview_split "auto"|"left"|"right"|"above"|"below"
---@field override fun(conf: table): table
---@class (exact) oil.SetupFloatWindowConfig
---@field padding? integer
---@field max_width? integer
---@field max_height? integer
---@field border? string|string[] Window border
---@field win_options? table<string, any>
---@field get_win_title? fun(winid: integer): string
---@field preview_split? "auto"|"left"|"right"|"above"|"below" Direction that the preview command will split the window
---@field override? fun(conf: table): table
---@class (exact) oil.SimpleWindowConfig
---@field border string|string[]
---@class (exact) oil.SetupSimpleWindowConfig
---@field border? string|string[] Window border
M.setup = function(opts)
opts = opts or vim.g.oil or {}
local new_conf = vim.tbl_deep_extend('keep', opts, default_config)
if not new_conf.use_default_keymaps then
new_conf.keymaps = opts.keymaps or {}
elseif opts.keymaps then
-- We don't want to deep merge the keymaps, we want any keymap defined by the user to override
-- everything about the default.
for k, v in pairs(opts.keymaps) do
local normalized = vim.api.nvim_replace_termcodes(k, true, true, true)
for existing_k, _ in pairs(new_conf.keymaps) do
if
existing_k ~= k
and vim.api.nvim_replace_termcodes(existing_k, true, true, true) == normalized
then
new_conf.keymaps[existing_k] = nil
end
end
new_conf.keymaps[k] = v
end
end
-- Backwards compatibility for old versions that don't support winborder
if vim.fn.has('nvim-0.11') == 0 then
new_conf = vim.tbl_deep_extend('keep', new_conf, {
float = { border = 'rounded' },
confirmation = { border = 'rounded' },
progress = { border = 'rounded' },
ssh = { border = 'rounded' },
keymaps_help = { border = 'rounded' },
})
end
-- Backwards compatibility. We renamed the 'preview' window config to be called 'confirmation'.
if opts.preview and not opts.confirmation then
new_conf.confirmation = vim.tbl_deep_extend('keep', opts.preview, default_config.confirmation)
end
-- Backwards compatibility. We renamed the 'preview' config to 'preview_win'
if opts.preview and opts.preview.update_on_cursor_moved ~= nil then
new_conf.preview_win.update_on_cursor_moved = opts.preview.update_on_cursor_moved
end
if new_conf.lsp_rename_autosave ~= nil then
new_conf.lsp_file_methods.autosave_changes = new_conf.lsp_rename_autosave
new_conf.lsp_rename_autosave = nil
vim.notify_once(
'oil config value lsp_rename_autosave has moved to lsp_file_methods.autosave_changes.\nCompatibility will be removed on 2024-09-01.',
vim.log.levels.WARN
)
end
-- This option was renamed because it is no longer experimental
if new_conf.experimental_watch_for_changes then
new_conf.watch_for_changes = true
end
for k, v in pairs(new_conf) do
M[k] = v
end
M.adapter_to_scheme = {}
for k, v in pairs(M.adapters) do
M.adapter_to_scheme[v] = k
end
M._adapter_by_scheme = {}
end
---@param scheme nil|string
---@return nil|oil.Adapter
M.get_adapter_by_scheme = function(scheme)
if not scheme then
return nil
end
if not vim.endswith(scheme, '://') then
local pieces = vim.split(scheme, '://', { plain = true })
if #pieces <= 2 then
scheme = pieces[1] .. '://'
else
error(string.format("Malformed url: '%s'", scheme))
end
end
local adapter = M._adapter_by_scheme[scheme]
if adapter == nil then
local name = M.adapters[scheme]
if not name then
return nil
end
local ok
ok, adapter = pcall(require, string.format('oil.adapters.%s', name))
if ok then
adapter.name = name
M._adapter_by_scheme[scheme] = adapter
else
M._adapter_by_scheme[scheme] = false
adapter = false
end
end
if adapter then
return adapter
else
return nil
end
end
return M

13
lua/oil/constants.lua Normal file
View file

@ -0,0 +1,13 @@
local M = {}
---Store entries as a list-like table for maximum space efficiency and retrieval speed.
---We use the constants below to index into the table.
---@alias oil.InternalEntry {[1]: integer, [2]: string, [3]: oil.EntryType, [4]: nil|table}
-- Indexes into oil.InternalEntry
M.FIELD_ID = 1
M.FIELD_NAME = 2
M.FIELD_TYPE = 3
M.FIELD_META = 4
return M

385
lua/oil/fs.lua Normal file
View file

@ -0,0 +1,385 @@
local log = require('oil.log')
local M = {}
local uv = vim.uv or vim.loop
---@type boolean
M.is_windows = uv.os_uname().version:match('Windows')
M.is_mac = uv.os_uname().sysname == 'Darwin'
M.is_linux = not M.is_windows and not M.is_mac
---@type string
M.sep = M.is_windows and '\\' or '/'
---@param ... string
M.join = function(...)
return table.concat({ ... }, M.sep)
end
---Check if OS path is absolute
---@param dir string
---@return boolean
M.is_absolute = function(dir)
if M.is_windows then
return dir:match('^%a:\\')
else
return vim.startswith(dir, '/')
end
end
M.abspath = function(path)
if not M.is_absolute(path) then
path = vim.fn.fnamemodify(path, ':p')
end
return path
end
---@param path string
---@param mode? integer File mode in decimal (default 420 = 0644)
---@param cb fun(err: nil|string)
M.touch = function(path, mode, cb)
if type(mode) == 'function' then
cb = mode
mode = 420
end
uv.fs_open(path, 'a', mode or 420, function(err, fd)
if err then
cb(err)
else
assert(fd)
uv.fs_close(fd, cb)
end
end)
end
--- Returns true if candidate is a subpath of root, or if they are the same path.
---@param root string
---@param candidate string
---@return boolean
M.is_subpath = function(root, candidate)
if candidate == '' then
return false
end
root = vim.fs.normalize(M.abspath(root))
-- Trim trailing "/" from the root
if root:find('/', -1) then
root = root:sub(1, -2)
end
candidate = vim.fs.normalize(M.abspath(candidate))
if M.is_windows then
root = root:lower()
candidate = candidate:lower()
end
if root == candidate then
return true
end
local prefix = candidate:sub(1, root:len())
if prefix ~= root then
return false
end
local candidate_starts_with_sep = candidate:find('/', root:len() + 1, true) == root:len() + 1
local root_ends_with_sep = root:find('/', root:len(), true) == root:len()
return candidate_starts_with_sep or root_ends_with_sep
end
---@param path string
---@return string
M.posix_to_os_path = function(path)
if M.is_windows then
if vim.startswith(path, '/') then
local drive = path:match('^/(%a+)')
if not drive then
return path
end
local rem = path:sub(drive:len() + 2)
return string.format('%s:%s', drive, rem:gsub('/', '\\'))
else
local newpath = path:gsub('/', '\\')
return newpath
end
else
return path
end
end
---@param path string
---@return string
M.os_to_posix_path = function(path)
if M.is_windows then
if M.is_absolute(path) then
local drive, rem = path:match('^([^:]+):\\(.*)$')
return string.format('/%s/%s', drive:upper(), rem:gsub('\\', '/'))
else
local newpath = path:gsub('\\', '/')
return newpath
end
else
return path
end
end
local home_dir = assert(uv.os_homedir())
---@param path string
---@param relative_to? string Shorten relative to this path (default cwd)
---@return string
M.shorten_path = function(path, relative_to)
if not relative_to then
relative_to = vim.fn.getcwd()
end
local relpath
if M.is_subpath(relative_to, path) then
local idx = relative_to:len() + 1
-- Trim the dividing slash if it's not included in relative_to
if not vim.endswith(relative_to, '/') and not vim.endswith(relative_to, '\\') then
idx = idx + 1
end
relpath = path:sub(idx)
if relpath == '' then
relpath = '.'
end
end
if M.is_subpath(home_dir, path) then
local homepath = '~' .. path:sub(home_dir:len() + 1)
if not relpath or homepath:len() < relpath:len() then
return homepath
end
end
return relpath or path
end
---@param dir string
---@param mode? integer
M.mkdirp = function(dir, mode)
mode = mode or 493
local mod = ''
local path = dir
while vim.fn.isdirectory(path) == 0 do
mod = mod .. ':h'
path = vim.fn.fnamemodify(dir, mod)
end
while mod ~= '' do
mod = mod:sub(3)
path = vim.fn.fnamemodify(dir, mod)
uv.fs_mkdir(path, mode)
end
end
---@param dir string
---@param cb fun(err: nil|string, entries: nil|{type: oil.EntryType, name: string})
M.listdir = function(dir, cb)
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
uv.fs_opendir(dir, function(open_err, fd)
if open_err then
return cb(open_err)
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
---@diagnostic disable-next-line: param-type-mismatch
cb(nil, entries)
read_next()
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 entry_type oil.EntryType
---@param path string
---@param cb fun(err: nil|string)
M.recursive_delete = function(entry_type, path, cb)
if entry_type ~= 'directory' then
return uv.fs_unlink(path, cb)
end
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
uv.fs_opendir(path, function(open_err, fd)
if open_err then
return cb(open_err)
end
local poll
poll = function(inner_cb)
uv.fs_readdir(fd, function(err, entries)
if err then
return inner_cb(err)
elseif entries then
local waiting = #entries
local complete
complete = function(err2)
if err2 then
complete = function() end
return inner_cb(err2)
end
waiting = waiting - 1
if waiting == 0 then
poll(inner_cb)
end
end
for _, entry in ipairs(entries) do
M.recursive_delete(entry.type, path .. M.sep .. entry.name, complete)
end
else
inner_cb()
end
end)
end
poll(function(err)
uv.fs_closedir(fd)
if err then
return cb(err)
end
uv.fs_rmdir(path, cb)
end)
---@diagnostic disable-next-line: param-type-mismatch
end, 10000)
end
---Move the undofile for the file at src_path to dest_path
---@param src_path string
---@param dest_path string
---@param copy boolean
local move_undofile = vim.schedule_wrap(function(src_path, dest_path, copy)
local undofile = vim.fn.undofile(src_path)
uv.fs_stat(
undofile,
vim.schedule_wrap(function(stat_err)
if stat_err then
-- undofile doesn't exist
return
end
local dest_undofile = vim.fn.undofile(dest_path)
if copy then
uv.fs_copyfile(src_path, dest_path, function(err)
if err then
log.warn('Error copying undofile %s: %s', undofile, err)
end
end)
else
uv.fs_rename(undofile, dest_undofile, function(err)
if err then
log.warn('Error moving undofile %s: %s', undofile, err)
end
end)
end
end)
)
end)
---@param entry_type oil.EntryType
---@param src_path string
---@param dest_path string
---@param cb fun(err: nil|string)
M.recursive_copy = function(entry_type, src_path, dest_path, cb)
if entry_type == 'link' then
uv.fs_readlink(src_path, function(link_err, link)
if link_err then
return cb(link_err)
end
assert(link)
uv.fs_symlink(link, dest_path, 0, cb)
end)
return
end
if entry_type ~= 'directory' then
uv.fs_copyfile(src_path, dest_path, { excl = true }, cb)
move_undofile(src_path, dest_path, true)
return
end
uv.fs_stat(src_path, function(stat_err, src_stat)
if stat_err then
return cb(stat_err)
end
assert(src_stat)
uv.fs_mkdir(dest_path, src_stat.mode, function(mkdir_err)
if mkdir_err then
return cb(mkdir_err)
end
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
uv.fs_opendir(src_path, function(open_err, fd)
if open_err then
return cb(open_err)
end
local poll
poll = function(inner_cb)
uv.fs_readdir(fd, function(err, entries)
if err then
return inner_cb(err)
elseif entries then
local waiting = #entries
local complete
complete = function(err2)
if err2 then
complete = function() end
return inner_cb(err2)
end
waiting = waiting - 1
if waiting == 0 then
poll(inner_cb)
end
end
for _, entry in ipairs(entries) do
M.recursive_copy(
entry.type,
src_path .. M.sep .. entry.name,
dest_path .. M.sep .. entry.name,
complete
)
end
else
inner_cb()
end
end)
end
poll(cb)
---@diagnostic disable-next-line: param-type-mismatch
end, 10000)
end)
end)
end
---@param entry_type oil.EntryType
---@param src_path string
---@param dest_path string
---@param cb fun(err: nil|string)
M.recursive_move = function(entry_type, src_path, dest_path, cb)
uv.fs_rename(src_path, dest_path, function(err)
if err then
-- fs_rename fails for cross-partition or cross-device operations.
-- We then fall back to a copy + delete
M.recursive_copy(entry_type, src_path, dest_path, function(err2)
if err2 then
cb(err2)
else
M.recursive_delete(entry_type, src_path, cb)
end
end)
else
if entry_type ~= 'directory' then
move_undofile(src_path, dest_path, false)
end
cb()
end
end)
end
return M

118
lua/oil/git.lua Normal file
View file

@ -0,0 +1,118 @@
-- integration with git operations
local fs = require('oil.fs')
local M = {}
---@param path string
---@return string|nil
M.get_root = function(path)
local git_dir = vim.fs.find('.git', { upward = true, path = path })[1]
if git_dir then
return vim.fs.dirname(git_dir)
else
return nil
end
end
---@param path string
---@param cb fun(err: nil|string)
M.add = function(path, cb)
local root = M.get_root(path)
if not root then
return cb()
end
local stderr = ''
local jid = vim.fn.jobstart({ 'git', 'add', path }, {
cwd = root,
stderr_buffered = true,
on_stderr = function(_, data)
stderr = table.concat(data, '\n')
end,
on_exit = function(_, code)
if code ~= 0 then
cb('Error in git add: ' .. stderr)
else
cb()
end
end,
})
if jid <= 0 then
cb()
end
end
---@param path string
---@param cb fun(err: nil|string)
M.rm = function(path, cb)
local root = M.get_root(path)
if not root then
return cb()
end
local stderr = ''
local jid = vim.fn.jobstart({ 'git', 'rm', '-r', path }, {
cwd = root,
stderr_buffered = true,
on_stderr = function(_, data)
stderr = table.concat(data, '\n')
end,
on_exit = function(_, code)
if code ~= 0 then
stderr = vim.trim(stderr)
if stderr:match("^fatal: pathspec '.*' did not match any files$") then
cb()
else
cb('Error in git rm: ' .. stderr)
end
else
cb()
end
end,
})
if jid <= 0 then
cb()
end
end
---@param entry_type oil.EntryType
---@param src_path string
---@param dest_path string
---@param cb fun(err: nil|string)
M.mv = function(entry_type, src_path, dest_path, cb)
local src_git = M.get_root(src_path)
if not src_git or src_git ~= M.get_root(dest_path) then
fs.recursive_move(entry_type, src_path, dest_path, cb)
return
end
local stderr = ''
local jid = vim.fn.jobstart({ 'git', 'mv', src_path, dest_path }, {
cwd = src_git,
stderr_buffered = true,
on_stderr = function(_, data)
stderr = table.concat(data, '\n')
end,
on_exit = function(_, code)
if code ~= 0 then
stderr = vim.trim(stderr)
if
stderr:match('^fatal: not under version control')
or stderr:match('^fatal: source directory is empty')
then
fs.recursive_move(entry_type, src_path, dest_path, cb)
else
cb('Error in git mv: ' .. stderr)
end
else
cb()
end
end,
})
if jid <= 0 then
-- Failed to run git, fall back to normal filesystem operations
fs.recursive_move(entry_type, src_path, dest_path, cb)
end
end
return M

1576
lua/oil/init.lua Normal file

File diff suppressed because it is too large Load diff

167
lua/oil/keymap_util.lua Normal file
View file

@ -0,0 +1,167 @@
local actions = require('oil.actions')
local config = require('oil.config')
local layout = require('oil.layout')
local util = require('oil.util')
local M = {}
---@param rhs string|table|fun()
---@return string|fun() rhs
---@return table opts
---@return string|nil mode
local function resolve(rhs)
if type(rhs) == 'string' and vim.startswith(rhs, 'actions.') then
local action_name = vim.split(rhs, '.', { plain = true })[2]
local action = actions[action_name]
if not action then
vim.notify('[oil.nvim] Unknown action name: ' .. action_name, vim.log.levels.ERROR)
end
return resolve(action)
elseif type(rhs) == 'table' then
local opts = vim.deepcopy(rhs)
-- We support passing in a `callback` key, or using the 1 index as the rhs of the keymap
local callback, parent_opts = resolve(opts.callback or opts[1])
-- Fall back to the parent desc, adding the opts as a string if it exists
if parent_opts.desc and not opts.desc then
if opts.opts then
opts.desc =
string.format('%s %s', parent_opts.desc, vim.inspect(opts.opts):gsub('%s+', ' '))
else
opts.desc = parent_opts.desc
end
end
local mode = opts.mode
if type(rhs.callback) == 'string' then
local action_opts, action_mode
callback, action_opts, action_mode = resolve(rhs.callback)
opts = vim.tbl_extend('keep', opts, action_opts)
mode = mode or action_mode
end
-- remove all the keys that we can't pass as options to `vim.keymap.set`
opts.callback = nil
opts.mode = nil
opts[1] = nil
opts.deprecated = nil
opts.parameters = nil
if opts.opts and type(callback) == 'function' then
local callback_args = opts.opts
opts.opts = nil
local orig_callback = callback
callback = function()
---@diagnostic disable-next-line: redundant-parameter
orig_callback(callback_args)
end
end
return callback, opts, mode
else
return rhs, {}
end
end
---@param keymaps table<string, string|table|fun()>
---@param bufnr integer
M.set_keymaps = function(keymaps, bufnr)
for k, v in pairs(keymaps) do
local rhs, opts, mode = resolve(v)
if rhs then
vim.keymap.set(mode or '', k, rhs, vim.tbl_extend('keep', { buffer = bufnr }, opts))
end
end
end
---@param keymaps table<string, string|table|fun()>
M.show_help = function(keymaps)
local rhs_to_lhs = {}
local lhs_to_all_lhs = {}
for k, rhs in pairs(keymaps) do
if rhs then
if rhs_to_lhs[rhs] then
local first_lhs = rhs_to_lhs[rhs]
table.insert(lhs_to_all_lhs[first_lhs], k)
else
rhs_to_lhs[rhs] = k
lhs_to_all_lhs[k] = { k }
end
end
end
local max_lhs = 1
local keymap_entries = {}
for k, rhs in pairs(keymaps) do
local all_lhs = lhs_to_all_lhs[k]
if all_lhs then
local _, opts = resolve(rhs)
local keystr = table.concat(all_lhs, '/')
max_lhs = math.max(max_lhs, vim.api.nvim_strwidth(keystr))
table.insert(keymap_entries, { str = keystr, all_lhs = all_lhs, desc = opts.desc or '' })
end
end
table.sort(keymap_entries, function(a, b)
return a.desc < b.desc
end)
local lines = {}
local highlights = {}
local max_line = 1
for _, entry in ipairs(keymap_entries) do
local line = string.format(' %s %s', util.pad_align(entry.str, max_lhs, 'left'), entry.desc)
max_line = math.max(max_line, vim.api.nvim_strwidth(line))
table.insert(lines, line)
local start = 1
for _, key in ipairs(entry.all_lhs) do
local keywidth = vim.api.nvim_strwidth(key)
table.insert(highlights, { 'Special', #lines, start, start + keywidth })
start = start + keywidth + 1
end
end
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
local ns = vim.api.nvim_create_namespace('Oil')
for _, hl in ipairs(highlights) do
local hl_group, lnum, start_col, end_col = unpack(hl)
vim.api.nvim_buf_set_extmark(bufnr, ns, lnum - 1, start_col, {
end_col = end_col,
hl_group = hl_group,
})
end
vim.keymap.set('n', 'q', '<cmd>close<CR>', { buffer = bufnr })
vim.keymap.set('n', '<c-c>', '<cmd>close<CR>', { buffer = bufnr })
vim.bo[bufnr].modifiable = false
vim.bo[bufnr].bufhidden = 'wipe'
local editor_width = vim.o.columns
local editor_height = layout.get_editor_height()
local winid = vim.api.nvim_open_win(bufnr, true, {
relative = 'editor',
row = math.max(0, (editor_height - #lines) / 2),
col = math.max(0, (editor_width - max_line - 1) / 2),
width = math.min(editor_width, max_line + 1),
height = math.min(editor_height, #lines),
zindex = 150,
style = 'minimal',
border = config.keymaps_help.border,
})
local function close()
if vim.api.nvim_win_is_valid(winid) then
vim.api.nvim_win_close(winid, true)
end
end
vim.api.nvim_create_autocmd('BufLeave', {
callback = close,
once = true,
nested = true,
buffer = bufnr,
})
vim.api.nvim_create_autocmd('WinLeave', {
callback = close,
once = true,
nested = true,
})
end
return M

198
lua/oil/layout.lua Normal file
View file

@ -0,0 +1,198 @@
local M = {}
---@param value number
---@return boolean
local function is_float(value)
local _, p = math.modf(value)
return p ~= 0
end
---@param value number
---@param max_value number
---@return number
local function calc_float(value, max_value)
if value and is_float(value) then
return math.min(max_value, value * max_value)
else
return value
end
end
---@return integer
M.get_editor_width = function()
return vim.o.columns
end
---@return integer
M.get_editor_height = function()
local editor_height = vim.o.lines - vim.o.cmdheight
-- Subtract 1 if tabline is visible
if vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1) then
editor_height = editor_height - 1
end
-- Subtract 1 if statusline is visible
if
vim.o.laststatus >= 2 or (vim.o.laststatus == 1 and #vim.api.nvim_tabpage_list_wins(0) > 1)
then
editor_height = editor_height - 1
end
return editor_height
end
local function calc_list(values, max_value, aggregator, limit)
local ret = limit
if not max_value or not values then
return nil
elseif type(values) == 'table' then
for _, v in ipairs(values) do
ret = aggregator(ret, calc_float(v, max_value))
end
return ret
else
ret = aggregator(ret, calc_float(values, max_value))
end
return ret
end
local function calculate_dim(desired_size, exact_size, min_size, max_size, total_size)
local ret = calc_float(exact_size, total_size)
local min_val = calc_list(min_size, total_size, math.max, 1)
local max_val = calc_list(max_size, total_size, math.min, total_size)
if not ret then
if not desired_size then
if min_val and max_val then
ret = (min_val + max_val) / 2
else
ret = 80
end
else
ret = calc_float(desired_size, total_size)
end
end
if max_val then
ret = math.min(ret, max_val)
end
if min_val then
ret = math.max(ret, min_val)
end
return math.floor(ret)
end
M.calculate_width = function(desired_width, opts)
return calculate_dim(
desired_width,
opts.width,
opts.min_width,
opts.max_width,
M.get_editor_width()
)
end
M.calculate_height = function(desired_height, opts)
return calculate_dim(
desired_height,
opts.height,
opts.min_height,
opts.max_height,
M.get_editor_height()
)
end
---@class (exact) oil.WinLayout
---@field width integer
---@field height integer
---@field row integer
---@field col integer
---@return vim.api.keyset.win_config
M.get_fullscreen_win_opts = function()
local config = require('oil.config')
local total_width = M.get_editor_width()
local total_height = M.get_editor_height()
local width = total_width - 2 * config.float.padding
if config.float.border ~= 'none' then
width = width - 2 -- The border consumes 1 col on each side
end
if config.float.max_width > 0 then
local max_width = math.floor(calc_float(config.float.max_width, total_width))
width = math.min(width, max_width)
end
local height = total_height - 2 * config.float.padding
if config.float.max_height > 0 then
local max_height = math.floor(calc_float(config.float.max_height, total_height))
height = math.min(height, max_height)
end
local row = math.floor((total_height - height) / 2)
local col = math.floor((total_width - width) / 2) - 1 -- adjust for border width
local win_opts = {
relative = 'editor',
width = width,
height = height,
row = row,
col = col,
border = config.float.border,
zindex = 45,
}
return config.float.override(win_opts) or win_opts
end
---@param winid integer
---@param direction "above"|"below"|"left"|"right"|"auto"
---@param gap integer
---@return oil.WinLayout root_dim New dimensions of the original window
---@return oil.WinLayout new_dim New dimensions of the new window
M.split_window = function(winid, direction, gap)
if direction == 'auto' then
direction = vim.o.splitright and 'right' or 'left'
end
local float_config = vim.api.nvim_win_get_config(winid)
---@type oil.WinLayout
local dim_root = {
width = float_config.width,
height = float_config.height,
col = float_config.col,
row = float_config.row,
}
if vim.fn.has('nvim-0.10') == 0 then
-- read https://github.com/neovim/neovim/issues/24430 for more infos.
dim_root.col = float_config.col[vim.val_idx]
dim_root.row = float_config.row[vim.val_idx]
end
local dim_new = vim.deepcopy(dim_root)
if direction == 'left' or direction == 'right' then
dim_new.width = math.floor(float_config.width / 2) - math.ceil(gap / 2)
dim_root.width = dim_new.width
else
dim_new.height = math.floor(float_config.height / 2) - math.ceil(gap / 2)
dim_root.height = dim_new.height
end
if direction == 'left' then
dim_root.col = dim_root.col + dim_root.width + gap
elseif direction == 'right' then
dim_new.col = dim_new.col + dim_new.width + gap
elseif direction == 'above' then
dim_root.row = dim_root.row + dim_root.height + gap
elseif direction == 'below' then
dim_new.row = dim_new.row + dim_new.height + gap
end
return dim_root, dim_new
end
---@param desired_width integer
---@param desired_height integer
---@param opts table
---@return integer width
---@return integer height
M.calculate_dims = function(desired_width, desired_height, opts)
local width = M.calculate_width(desired_width, opts)
local height = M.calculate_height(desired_height, opts)
return width, height
end
return M

89
lua/oil/loading.lua Normal file
View file

@ -0,0 +1,89 @@
local util = require('oil.util')
local M = {}
local timers = {}
local FPS = 20
---@param bufnr integer
---@return boolean
M.is_loading = function(bufnr)
return timers[bufnr] ~= nil
end
local spinners = {
dots = { '', '', '', '', '', '', '', '', '', '' },
}
---@param name_or_frames string|string[]
---@return fun(): string
M.get_iter = function(name_or_frames)
local frames
if type(name_or_frames) == 'string' then
frames = spinners[name_or_frames]
if not frames then
error(string.format("Unrecognized spinner: '%s'", name_or_frames))
end
else
frames = name_or_frames
end
local i = 0
return function()
i = (i % #frames) + 1
return frames[i]
end
end
M.get_bar_iter = function(opts)
opts = vim.tbl_deep_extend('keep', opts or {}, {
bar_size = 3,
width = 20,
})
local i = 0
return function()
local chars = { '[' }
for _ = 1, opts.width - 2 do
table.insert(chars, ' ')
end
table.insert(chars, ']')
for j = i - opts.bar_size, i do
if j > 1 and j < opts.width then
chars[j] = '='
end
end
i = (i + 1) % (opts.width + opts.bar_size)
return table.concat(chars, '')
end
end
---@param bufnr integer
---@param is_loading boolean
M.set_loading = function(bufnr, is_loading)
if is_loading then
if timers[bufnr] == nil then
local width = 20
timers[bufnr] = vim.loop.new_timer()
local bar_iter = M.get_bar_iter({ width = width })
timers[bufnr]:start(
200, -- Delay the loading screen just a bit to avoid flicker
math.floor(1000 / FPS),
vim.schedule_wrap(function()
if not vim.api.nvim_buf_is_valid(bufnr) or not timers[bufnr] then
M.set_loading(bufnr, false)
return
end
local lines =
{ util.pad_align('Loading', math.floor(width / 2) - 3, 'right'), bar_iter() }
util.render_text(bufnr, lines)
end)
)
end
elseif timers[bufnr] then
timers[bufnr]:close()
timers[bufnr] = nil
end
end
return M

126
lua/oil/log.lua Normal file
View file

@ -0,0 +1,126 @@
local uv = vim.uv or vim.loop
local levels_reverse = {}
for k, v in pairs(vim.log.levels) do
levels_reverse[v] = k
end
local Log = {}
---@type integer
Log.level = vim.log.levels.WARN
---@return string
Log.get_logfile = function()
local fs = require('oil.fs')
local ok, stdpath = pcall(vim.fn.stdpath, 'log')
if not ok then
stdpath = vim.fn.stdpath('cache')
end
assert(type(stdpath) == 'string')
return fs.join(stdpath, 'oil.log')
end
---@param level integer
---@param msg string
---@param ... any[]
---@return string
local function format(level, msg, ...)
local args = vim.F.pack_len(...)
for i = 1, args.n do
local v = args[i]
if type(v) == 'table' then
args[i] = vim.inspect(v)
elseif v == nil then
args[i] = 'nil'
end
end
local ok, text = pcall(string.format, msg, vim.F.unpack_len(args))
-- TODO figure out how to get formatted time inside luv callback
-- local timestr = vim.fn.strftime("%Y-%m-%d %H:%M:%S")
local timestr = ''
if ok then
local str_level = levels_reverse[level]
return string.format('%s[%s] %s', timestr, str_level, text)
else
return string.format(
"%s[ERROR] error formatting log line: '%s' args %s",
timestr,
vim.inspect(msg),
vim.inspect(args)
)
end
end
---@param line string
local function write(line)
-- This will be replaced during initialization
end
local initialized = false
local function initialize()
if initialized then
return
end
initialized = true
local filepath = Log.get_logfile()
local stat = uv.fs_stat(filepath)
if stat and stat.size > 10 * 1024 * 1024 then
local backup = filepath .. '.1'
uv.fs_unlink(backup)
uv.fs_rename(filepath, backup)
end
local parent = vim.fs.dirname(filepath)
require('oil.fs').mkdirp(parent)
local logfile, openerr = io.open(filepath, 'a+')
if not logfile then
local err_msg = string.format('Failed to open oil.nvim log file: %s', openerr)
vim.notify(err_msg, vim.log.levels.ERROR)
else
write = function(line)
logfile:write(line)
logfile:write('\n')
logfile:flush()
end
end
end
---Override the file handler e.g. for tests
---@param handler fun(line: string)
function Log.set_handler(handler)
write = handler
initialized = true
end
function Log.log(level, msg, ...)
if Log.level <= level then
initialize()
local text = format(level, msg, ...)
write(text)
end
end
function Log.trace(...)
Log.log(vim.log.levels.TRACE, ...)
end
function Log.debug(...)
Log.log(vim.log.levels.DEBUG, ...)
end
function Log.info(...)
Log.log(vim.log.levels.INFO, ...)
end
function Log.warn(...)
Log.log(vim.log.levels.WARN, ...)
end
function Log.error(...)
Log.log(vim.log.levels.ERROR, ...)
end
return Log

121
lua/oil/lsp/helpers.lua Normal file
View file

@ -0,0 +1,121 @@
local config = require('oil.config')
local fs = require('oil.fs')
local util = require('oil.util')
local workspace = require('oil.lsp.workspace')
local M = {}
---@param actions oil.Action[]
---@return fun() did_perform Call this function when the file operations have been completed
M.will_perform_file_operations = function(actions)
local moves = {}
local creates = {}
local deletes = {}
for _, action in ipairs(actions) do
if action.type == 'move' then
local src_scheme, src_path = util.parse_url(action.src_url)
assert(src_path)
local src_adapter = assert(config.get_adapter_by_scheme(src_scheme))
local dest_scheme, dest_path = util.parse_url(action.dest_url)
local dest_adapter = assert(config.get_adapter_by_scheme(dest_scheme))
src_path = fs.posix_to_os_path(src_path)
dest_path = fs.posix_to_os_path(assert(dest_path))
if src_adapter.name == 'files' and dest_adapter.name == 'files' then
moves[src_path] = dest_path
elseif src_adapter.name == 'files' then
table.insert(deletes, src_path)
elseif dest_adapter.name == 'files' then
table.insert(creates, src_path)
end
elseif action.type == 'create' then
local scheme, path = util.parse_url(action.url)
path = fs.posix_to_os_path(assert(path))
local adapter = assert(config.get_adapter_by_scheme(scheme))
if adapter.name == 'files' then
table.insert(creates, path)
end
elseif action.type == 'delete' then
local scheme, path = util.parse_url(action.url)
path = fs.posix_to_os_path(assert(path))
local adapter = assert(config.get_adapter_by_scheme(scheme))
if adapter.name == 'files' then
table.insert(deletes, path)
end
elseif action.type == 'copy' then
local scheme, path = util.parse_url(action.dest_url)
path = fs.posix_to_os_path(assert(path))
local adapter = assert(config.get_adapter_by_scheme(scheme))
if adapter.name == 'files' then
table.insert(creates, path)
end
end
end
local buf_was_modified = {}
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
buf_was_modified[bufnr] = vim.bo[bufnr].modified
end
local edited_uris = {}
local final_err = nil
---@param edits nil|{edit: lsp.WorkspaceEdit, client_offset: string}[]
local function accum(edits, err)
final_err = final_err or err
if edits then
for _, edit in ipairs(edits) do
if edit.edit.changes then
for uri in pairs(edit.edit.changes) do
edited_uris[uri] = true
end
end
if edit.edit.documentChanges then
for _, change in ipairs(edit.edit.documentChanges) do
if change.textDocument then
edited_uris[change.textDocument.uri] = true
end
end
end
end
end
end
local timeout_ms = config.lsp_file_methods.timeout_ms
accum(workspace.will_create_files(creates, { timeout_ms = timeout_ms }))
accum(workspace.will_delete_files(deletes, { timeout_ms = timeout_ms }))
accum(workspace.will_rename_files(moves, { timeout_ms = timeout_ms }))
if final_err then
vim.notify(
string.format('[lsp] file operation error: %s', vim.inspect(final_err)),
vim.log.levels.WARN
)
end
return function()
workspace.did_create_files(creates)
workspace.did_delete_files(deletes)
workspace.did_rename_files(moves)
local autosave = config.lsp_file_methods.autosave_changes
if autosave == false then
return
end
for uri, _ in pairs(edited_uris) do
local bufnr = vim.uri_to_bufnr(uri)
local was_open = buf_was_modified[bufnr] ~= nil
local was_modified = buf_was_modified[bufnr]
local should_save = autosave == true or (autosave == 'unmodified' and not was_modified)
-- Autosave changed buffers if they were not modified before
if should_save then
vim.api.nvim_buf_call(bufnr, function()
vim.cmd.update({ mods = { emsg_silent = true, noautocmd = true } })
end)
-- Delete buffers that weren't open before
if not was_open then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
end
end
end
return M

351
lua/oil/lsp/workspace.lua Normal file
View file

@ -0,0 +1,351 @@
local fs = require('oil.fs')
local ms = require('vim.lsp.protocol').Methods
if vim.fn.has('nvim-0.10') == 0 then
ms = {
workspace_willCreateFiles = 'workspace/willCreateFiles',
workspace_didCreateFiles = 'workspace/didCreateFiles',
workspace_willDeleteFiles = 'workspace/willDeleteFiles',
workspace_didDeleteFiles = 'workspace/didDeleteFiles',
workspace_willRenameFiles = 'workspace/willRenameFiles',
workspace_didRenameFiles = 'workspace/didRenameFiles',
}
end
local M = {}
---@param method string
---@return vim.lsp.Client[]
local function get_clients(method)
if vim.fn.has('nvim-0.10') == 1 then
return vim.lsp.get_clients({ method = method })
else
---@diagnostic disable-next-line: deprecated
local clients = vim.lsp.get_active_clients()
return vim.tbl_filter(function(client)
return client.supports_method(method)
end, clients)
end
end
---@param glob string|vim.lpeg.Pattern
---@param path string
---@return boolean
local function match_glob(glob, path)
-- nvim-0.10 will have vim.glob.to_lpeg, so this will be a LPeg pattern
if type(glob) ~= 'string' then
return glob:match(path) ~= nil
end
-- older versions fall back to glob2regpat
local pat = vim.fn.glob2regpat(glob)
local ignorecase = vim.o.ignorecase
vim.o.ignorecase = false
local ok, match = pcall(vim.fn.match, path, pat)
vim.o.ignorecase = ignorecase
if not ok then
error(match)
end
return match >= 0
end
---@param client vim.lsp.Client
---@param filters nil|lsp.FileOperationFilter[]
---@param paths string[]
---@return nil|string[]
local function get_matching_paths(client, filters, paths)
if not filters then
return nil
end
local match_fns = {}
for _, filter in ipairs(filters) do
if filter.scheme == nil or filter.scheme == 'file' then
local pattern = filter.pattern
local glob = pattern.glob
local ignore_case = pattern.options and pattern.options.ignoreCase
if ignore_case then
glob = glob:lower()
end
-- Some language servers use forward slashes as path separators on Windows (LuaLS)
-- We no longer need this after 0.12: https://github.com/neovim/neovim/commit/322a6d305d088420b23071c227af07b7c1beb41a
if vim.fn.has('nvim-0.12') == 0 and fs.is_windows then
glob = glob:gsub('/', '\\')
end
---@type string|vim.lpeg.Pattern
local glob_to_match = glob
if vim.glob and vim.glob.to_lpeg then
glob = glob:gsub('{(.-)}', function(s)
local patterns = vim.split(s, ',')
local filtered = {}
for _, pat in ipairs(patterns) do
if pat ~= '' then
table.insert(filtered, pat)
end
end
if #filtered == 0 then
return ''
end
-- HACK around https://github.com/neovim/neovim/issues/28931
-- find alternations and sort them by length to try to match the longest first
if vim.fn.has('nvim-0.11') == 0 then
table.sort(filtered, function(a, b)
return a:len() > b:len()
end)
end
return '{' .. table.concat(filtered, ',') .. '}'
end)
glob_to_match = vim.glob.to_lpeg(glob)
end
local matches = pattern.matches
table.insert(match_fns, function(path)
local is_dir = vim.fn.isdirectory(path) == 1
if matches and ((matches == 'file' and is_dir) or (matches == 'folder' and not is_dir)) then
return false
end
if ignore_case then
path = path:lower()
end
return match_glob(glob_to_match, path)
end)
end
end
local function match_any_pattern(workspace, path)
local relative_path = path:sub(workspace:len() + 2)
for _, match_fn in ipairs(match_fns) do
-- The glob pattern might be relative to workspace OR absolute
if match_fn(relative_path) or match_fn(path) then
return true
end
end
return false
end
local workspace_folders = vim.tbl_map(function(folder)
return vim.uri_to_fname(folder.uri)
end, client.workspace_folders or {})
local function get_matching_workspace(path)
for _, workspace in ipairs(workspace_folders) do
if fs.is_subpath(workspace, path) then
return workspace
end
end
end
local ret = {}
for _, path in ipairs(paths) do
local workspace = get_matching_workspace(path)
if workspace and match_any_pattern(workspace, path) then
table.insert(ret, path)
end
end
if vim.tbl_isempty(ret) then
return nil
else
return ret
end
end
---@param method string The method to call
---@param capability_name string The name of the fileOperations server capability
---@param files string[] The files and folders that will be created
---@param options table|nil
---@return nil|{edit: lsp.WorkspaceEdit, offset_encoding: string}[]
---@return nil|string|lsp.ResponseError err
local function will_file_operation(method, capability_name, files, options)
options = options or {}
local clients = get_clients(method)
local edits = {}
for _, client in ipairs(clients) do
local filters = vim.tbl_get(
client.server_capabilities,
'workspace',
'fileOperations',
capability_name,
'filters'
)
local matching_files = get_matching_paths(client, filters, files)
if matching_files then
local params = {
files = vim.tbl_map(function(file)
return {
uri = vim.uri_from_fname(file),
}
end, matching_files),
}
local result, err
if vim.fn.has('nvim-0.11') == 1 then
result, err = client:request_sync(method, params, options.timeout_ms or 1000, 0)
else
---@diagnostic disable-next-line: param-type-mismatch
result, err = client.request_sync(method, params, options.timeout_ms or 1000, 0)
end
if result and result.result then
if options.apply_edits ~= false then
vim.lsp.util.apply_workspace_edit(result.result, client.offset_encoding)
end
table.insert(edits, { edit = result.result, offset_encoding = client.offset_encoding })
else
return nil, err or result and result.err
end
end
end
return edits
end
---@param method string The method to call
---@param capability_name string The name of the fileOperations server capability
---@param files string[] The files and folders that will be created
local function did_file_operation(method, capability_name, files)
local clients = get_clients(method)
for _, client in ipairs(clients) do
local filters = vim.tbl_get(
client.server_capabilities,
'workspace',
'fileOperations',
capability_name,
'filters'
)
local matching_files = get_matching_paths(client, filters, files)
if matching_files then
local params = {
files = vim.tbl_map(function(file)
return {
uri = vim.uri_from_fname(file),
}
end, matching_files),
}
if vim.fn.has('nvim-0.11') == 1 then
client:notify(method, params)
else
---@diagnostic disable-next-line: param-type-mismatch
client.notify(method, params)
end
end
end
end
--- Notify the server that the client is about to create files.
---@param files string[] The files and folders that will be created
---@param options table|nil Optional table which holds the following optional fields:
--- - timeout_ms (integer|nil, default 1000):
--- Time in milliseconds to block for rename requests.
--- - apply_edits (boolean|nil, default true):
--- Apply any workspace edits from these file operations.
---@return nil|{edit: lsp.WorkspaceEdit, offset_encoding: string}[]
---@return nil|string|lsp.ResponseError err
function M.will_create_files(files, options)
return will_file_operation(ms.workspace_willCreateFiles, 'willCreate', files, options)
end
--- Notify the server that files were created from within the client.
---@param files string[] The files and folders that will be created
function M.did_create_files(files)
did_file_operation(ms.workspace_didCreateFiles, 'didCreate', files)
end
--- Notify the server that the client is about to delete files.
---@param files string[] The files and folders that will be deleted
---@param options table|nil Optional table which holds the following optional fields:
--- - timeout_ms (integer|nil, default 1000):
--- Time in milliseconds to block for rename requests.
--- - apply_edits (boolean|nil, default true):
--- Apply any workspace edits from these file operations.
---@return nil|{edit: lsp.WorkspaceEdit, offset_encoding: string}[]
---@return nil|string|lsp.ResponseError err
function M.will_delete_files(files, options)
return will_file_operation(ms.workspace_willDeleteFiles, 'willDelete', files, options)
end
--- Notify the server that files were deleted from within the client.
---@param files string[] The files and folders that were deleted
function M.did_delete_files(files)
did_file_operation(ms.workspace_didDeleteFiles, 'didDelete', files)
end
--- Notify the server that the client is about to rename files.
---@param files table<string, string> Mapping of old_path -> new_path
---@param options table|nil Optional table which holds the following optional fields:
--- - timeout_ms (integer|nil, default 1000):
--- Time in milliseconds to block for rename requests.
--- - apply_edits (boolean|nil, default true):
--- Apply any workspace edits from these file operations.
---@return nil|{edit: lsp.WorkspaceEdit, offset_encoding: string}[]
---@return nil|string|lsp.ResponseError err
function M.will_rename_files(files, options)
options = options or {}
local clients = get_clients(ms.workspace_willRenameFiles)
local edits = {}
for _, client in ipairs(clients) do
local filters = vim.tbl_get(
client.server_capabilities,
'workspace',
'fileOperations',
'willRename',
'filters'
)
local matching_files = get_matching_paths(client, filters, vim.tbl_keys(files))
if matching_files then
local params = {
files = vim.tbl_map(function(src_file)
return {
oldUri = vim.uri_from_fname(src_file),
newUri = vim.uri_from_fname(files[src_file]),
}
end, matching_files),
}
local result, err
if vim.fn.has('nvim-0.11') == 1 then
result, err =
client:request_sync(ms.workspace_willRenameFiles, params, options.timeout_ms or 1000, 0)
else
result, err =
---@diagnostic disable-next-line: param-type-mismatch
client.request_sync(ms.workspace_willRenameFiles, params, options.timeout_ms or 1000, 0)
end
if result and result.result then
if options.apply_edits ~= false then
vim.lsp.util.apply_workspace_edit(result.result, client.offset_encoding)
end
table.insert(edits, { edit = result.result, offset_encoding = client.offset_encoding })
else
return nil, err or result and result.err
end
end
end
return edits
end
--- Notify the server that files were renamed from within the client.
---@param files table<string, string> Mapping of old_path -> new_path
function M.did_rename_files(files)
local clients = get_clients(ms.workspace_didRenameFiles)
for _, client in ipairs(clients) do
local filters =
vim.tbl_get(client.server_capabilities, 'workspace', 'fileOperations', 'didRename', 'filters')
local matching_files = get_matching_paths(client, filters, vim.tbl_keys(files))
if matching_files then
local params = {
files = vim.tbl_map(function(src_file)
return {
oldUri = vim.uri_from_fname(src_file),
newUri = vim.uri_from_fname(files[src_file]),
}
end, matching_files),
}
if vim.fn.has('nvim-0.11') == 1 then
client:notify(ms.workspace_didRenameFiles, params)
else
---@diagnostic disable-next-line: param-type-mismatch
client.notify(ms.workspace_didRenameFiles, params)
end
end
end
end
return M

View file

@ -0,0 +1,205 @@
local columns = require('oil.columns')
local config = require('oil.config')
local layout = require('oil.layout')
local util = require('oil.util')
local M = {}
---@param actions oil.Action[]
---@return boolean
local function is_simple_edit(actions)
local num_create = 0
local num_copy = 0
local num_move = 0
for _, action in ipairs(actions) do
-- If there are any deletes, it is not a simple edit
if action.type == 'delete' then
return false
elseif action.type == 'create' then
num_create = num_create + 1
elseif action.type == 'copy' then
num_copy = num_copy + 1
-- Cross-adapter copies are not simple
if util.parse_url(action.src_url) ~= util.parse_url(action.dest_url) then
return false
end
elseif action.type == 'move' then
num_move = num_move + 1
-- Cross-adapter moves are not simple
if util.parse_url(action.src_url) ~= util.parse_url(action.dest_url) then
return false
end
end
end
-- More than one move/copy is complex
if num_move > 1 or num_copy > 1 then
return false
end
-- More than 5 creates is complex
if num_create > 5 then
return false
end
return true
end
---@param winid integer
---@param bufnr integer
---@param lines string[]
local function render_lines(winid, bufnr, lines)
util.render_text(bufnr, lines, {
v_align = 'top',
h_align = 'left',
winid = winid,
actions = { '[Y]es', '[N]o' },
})
end
---@param actions oil.Action[]
---@param should_confirm nil|boolean
---@param cb fun(proceed: boolean)
M.show = vim.schedule_wrap(function(actions, should_confirm, cb)
-- The schedule wrap ensures that we actually enter the floating window.
-- Not sure why it doesn't work without that
if should_confirm == false or #actions == 0 then
cb(true)
return
end
if should_confirm == nil and config.skip_confirm_for_simple_edits and is_simple_edit(actions) then
cb(true)
return
end
if should_confirm == nil and config.skip_confirm_for_delete then
local all_deletes = true
for _, action in ipairs(actions) do
if action.type ~= 'delete' then
all_deletes = false
break
end
end
if all_deletes then
cb(true)
return
end
end
-- Create the buffer
local bufnr = vim.api.nvim_create_buf(false, true)
vim.bo[bufnr].bufhidden = 'wipe'
local lines = {}
local max_line_width = 0
for _, action in ipairs(actions) do
local adapter = util.get_adapter_for_action(action)
local line
if action.type == 'change' then
---@cast action oil.ChangeAction
line = columns.render_change_action(adapter, action)
else
line = adapter.render_action(action)
end
-- We can't handle lines with newlines in them
line = line:gsub('\n', '')
table.insert(lines, line)
local line_width = vim.api.nvim_strwidth(line)
if line_width > max_line_width then
max_line_width = line_width
end
end
table.insert(lines, '')
-- Create the floating window
local width, height = layout.calculate_dims(max_line_width, #lines + 1, config.confirmation)
local ok, winid = pcall(vim.api.nvim_open_win, bufnr, true, {
relative = 'editor',
width = width,
height = height,
row = math.floor((layout.get_editor_height() - height) / 2),
col = math.floor((layout.get_editor_width() - width) / 2),
zindex = 152, -- render on top of the floating window title
style = 'minimal',
border = config.confirmation.border,
})
if not ok then
vim.notify(string.format('Error showing oil preview window: %s', winid), vim.log.levels.ERROR)
cb(false)
end
vim.bo[bufnr].filetype = 'oil_preview'
vim.bo[bufnr].syntax = 'oil_preview'
for k, v in pairs(config.confirmation.win_options) do
vim.api.nvim_set_option_value(k, v, { scope = 'local', win = winid })
end
render_lines(winid, bufnr, lines)
local restore_cursor = util.hide_cursor()
-- Attach autocmds and keymaps
local cancel
local confirm
local autocmds = {}
local function make_callback(value)
return function()
confirm = function() end
cancel = function() end
for _, id in ipairs(autocmds) do
vim.api.nvim_del_autocmd(id)
end
autocmds = {}
vim.api.nvim_win_close(winid, true)
restore_cursor()
cb(value)
end
end
cancel = make_callback(false)
confirm = make_callback(true)
vim.api.nvim_create_autocmd('BufLeave', {
callback = function()
cancel()
end,
once = true,
nested = true,
buffer = bufnr,
})
vim.api.nvim_create_autocmd('WinLeave', {
callback = function()
cancel()
end,
once = true,
nested = true,
})
table.insert(
autocmds,
vim.api.nvim_create_autocmd('VimResized', {
callback = function()
if vim.api.nvim_win_is_valid(winid) then
width, height = layout.calculate_dims(max_line_width, #lines, config.confirmation)
vim.api.nvim_win_set_config(winid, {
relative = 'editor',
width = width,
height = height,
row = math.floor((layout.get_editor_height() - height) / 2),
col = math.floor((layout.get_editor_width() - width) / 2),
zindex = 152, -- render on top of the floating window title
})
render_lines(winid, bufnr, lines)
end
end,
})
)
-- We used to use [C]ancel to cancel, so preserve the old keymap
local cancel_keys = { 'n', 'N', 'c', 'C', 'q', '<C-c>', '<Esc>' }
for _, cancel_key in ipairs(cancel_keys) do
vim.keymap.set('n', cancel_key, function()
cancel()
end, { buffer = bufnr, nowait = true })
end
-- We used to use [O]k to confirm, so preserve the old keymap
local confirm_keys = { 'y', 'Y', 'o', 'O' }
for _, confirm_key in ipairs(confirm_keys) do
vim.keymap.set('n', confirm_key, function()
confirm()
end, { buffer = bufnr, nowait = true })
end
end)
return M

626
lua/oil/mutator/init.lua Normal file
View file

@ -0,0 +1,626 @@
local Progress = require('oil.mutator.progress')
local Trie = require('oil.mutator.trie')
local cache = require('oil.cache')
local columns = require('oil.columns')
local config = require('oil.config')
local confirmation = require('oil.mutator.confirmation')
local constants = require('oil.constants')
local fs = require('oil.fs')
local lsp_helpers = require('oil.lsp.helpers')
local oil = require('oil')
local parser = require('oil.mutator.parser')
local util = require('oil.util')
local view = require('oil.view')
local M = {}
local FIELD_NAME = constants.FIELD_NAME
local FIELD_TYPE = constants.FIELD_TYPE
---@alias oil.Action oil.CreateAction|oil.DeleteAction|oil.MoveAction|oil.CopyAction|oil.ChangeAction
---@class (exact) oil.CreateAction
---@field type "create"
---@field url string
---@field entry_type oil.EntryType
---@field link nil|string
---@class (exact) oil.DeleteAction
---@field type "delete"
---@field url string
---@field entry_type oil.EntryType
---@class (exact) oil.MoveAction
---@field type "move"
---@field entry_type oil.EntryType
---@field src_url string
---@field dest_url string
---@class (exact) oil.CopyAction
---@field type "copy"
---@field entry_type oil.EntryType
---@field src_url string
---@field dest_url string
---@class (exact) oil.ChangeAction
---@field type "change"
---@field entry_type oil.EntryType
---@field url string
---@field column string
---@field value any
---@param all_diffs table<integer, oil.Diff[]>
---@return oil.Action[]
M.create_actions_from_diffs = function(all_diffs)
---@type oil.Action[]
local actions = {}
---@type table<integer, oil.Diff[]>
local diff_by_id = setmetatable({}, {
__index = function(t, key)
local list = {}
rawset(t, key, list)
return list
end,
})
-- To deduplicate create actions
-- This can happen when creating deep nested files e.g.
-- > foo/bar/a.txt
-- > foo/bar/b.txt
local seen_creates = {}
---@param action oil.Action
local function add_action(action)
local adapter = assert(config.get_adapter_by_scheme(action.dest_url or action.url))
if not adapter.filter_action or adapter.filter_action(action) then
if action.type == 'create' then
if seen_creates[action.url] then
return
else
seen_creates[action.url] = true
end
end
table.insert(actions, action)
end
end
for bufnr, diffs in pairs(all_diffs) do
local adapter = util.get_adapter(bufnr, true)
if not adapter then
error('Missing adapter')
end
local parent_url = vim.api.nvim_buf_get_name(bufnr)
for _, diff in ipairs(diffs) do
if diff.type == 'new' then
if diff.id then
local by_id = diff_by_id[diff.id]
---HACK: set the destination on this diff for use later
---@diagnostic disable-next-line: inject-field
diff.dest = parent_url .. diff.name
table.insert(by_id, diff)
else
-- Parse nested files like foo/bar/baz
local path_sep = fs.is_windows and '[/\\]' or '/'
local pieces = vim.split(diff.name, path_sep)
local url = parent_url:gsub('/$', '')
for i, v in ipairs(pieces) do
local is_last = i == #pieces
local entry_type = is_last and diff.entry_type or 'directory'
local alternation = v:match('{([^}]+)}')
if is_last and alternation then
-- Parse alternations like foo.{js,test.js}
for _, alt in ipairs(vim.split(alternation, ',')) do
local alt_url = url .. '/' .. v:gsub('{[^}]+}', alt)
add_action({
type = 'create',
url = alt_url,
entry_type = entry_type,
link = diff.link,
})
end
else
url = url .. '/' .. v
add_action({
type = 'create',
url = url,
entry_type = entry_type,
link = diff.link,
})
end
end
end
elseif diff.type == 'change' then
add_action({
type = 'change',
url = parent_url .. diff.name,
entry_type = diff.entry_type,
column = diff.column,
value = diff.value,
})
else
local by_id = diff_by_id[diff.id]
-- HACK: set has_delete field on a list-like table of diffs
---@diagnostic disable-next-line: inject-field
by_id.has_delete = true
-- Don't insert the delete. We already know that there is a delete because of the presence
-- in the diff_by_id map. The list will only include the 'new' diffs.
end
end
end
for id, diffs in pairs(diff_by_id) do
local entry = cache.get_entry_by_id(id)
if not entry then
error(string.format('Could not find entry %d', id))
end
---HACK: access the has_delete field on the list-like table of diffs
---@diagnostic disable-next-line: undefined-field
if diffs.has_delete then
local has_create = #diffs > 0
if has_create then
-- MOVE (+ optional copies) when has both creates and delete
for i, diff in ipairs(diffs) do
add_action({
type = i == #diffs and 'move' or 'copy',
entry_type = entry[FIELD_TYPE],
---HACK: access the dest field we set above
---@diagnostic disable-next-line: undefined-field
dest_url = diff.dest,
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
})
end
else
-- DELETE when no create
add_action({
type = 'delete',
entry_type = entry[FIELD_TYPE],
url = cache.get_parent_url(id) .. entry[FIELD_NAME],
})
end
else
-- COPY when create but no delete
for _, diff in ipairs(diffs) do
add_action({
type = 'copy',
entry_type = entry[FIELD_TYPE],
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
---HACK: access the dest field we set above
---@diagnostic disable-next-line: undefined-field
dest_url = diff.dest,
})
end
end
end
return M.enforce_action_order(actions)
end
---@param actions oil.Action[]
---@return oil.Action[]
M.enforce_action_order = function(actions)
local src_trie = Trie.new()
local dest_trie = Trie.new()
for _, action in ipairs(actions) do
if action.type == 'delete' or action.type == 'change' then
src_trie:insert_action(action.url, action)
elseif action.type == 'create' then
dest_trie:insert_action(action.url, action)
else
dest_trie:insert_action(action.dest_url, action)
src_trie:insert_action(action.src_url, action)
end
end
-- 1. create a graph, each node points to all of its dependencies
-- 2. for each action, if not added, find it in the graph
-- 3. traverse through the graph until you reach a node that has no dependencies (leaf)
-- 4. append that action to the return value, and remove it from the graph
-- a. TODO optimization: check immediate parents to see if they have no dependencies now
-- 5. repeat
---Gets the dependencies of a particular action. Effectively dynamically calculates the dependency
---"edges" of the graph.
---@param action oil.Action
local function get_deps(action)
local ret = {}
if action.type == 'delete' then
src_trie:accum_children_of(action.url, ret)
elseif action.type == 'create' then
-- Finish operating on parents first
-- e.g. NEW /a BEFORE NEW /a/b
dest_trie:accum_first_parents_of(action.url, ret)
-- Process remove path before creating new path
-- e.g. DELETE /a BEFORE NEW /a
src_trie:accum_actions_at(action.url, ret, function(a)
return a.type == 'move' or a.type == 'delete'
end)
elseif action.type == 'change' then
-- Finish operating on parents first
-- e.g. NEW /a BEFORE CHANGE /a/b
dest_trie:accum_first_parents_of(action.url, ret)
-- Finish operations on this path first
-- e.g. NEW /a BEFORE CHANGE /a
dest_trie:accum_actions_at(action.url, ret)
-- Finish copy from operations first
-- e.g. COPY /a -> /b BEFORE CHANGE /a
src_trie:accum_actions_at(action.url, ret, function(entry)
return entry.type == 'copy'
end)
elseif action.type == 'move' then
-- Finish operating on parents first
-- e.g. NEW /a BEFORE MOVE /z -> /a/b
dest_trie:accum_first_parents_of(action.dest_url, ret)
-- Process children before moving
-- e.g. NEW /a/b BEFORE MOVE /a -> /b
dest_trie:accum_children_of(action.src_url, ret)
-- Process children before moving parent dir
-- e.g. COPY /a/b -> /b BEFORE MOVE /a -> /d
-- e.g. CHANGE /a/b BEFORE MOVE /a -> /d
src_trie:accum_children_of(action.src_url, ret)
-- Process remove path before moving to new path
-- e.g. MOVE /a -> /b BEFORE MOVE /c -> /a
src_trie:accum_actions_at(action.dest_url, ret, function(a)
return a.type == 'move' or a.type == 'delete'
end)
elseif action.type == 'copy' then
-- Finish operating on parents first
-- e.g. NEW /a BEFORE COPY /z -> /a/b
dest_trie:accum_first_parents_of(action.dest_url, ret)
-- Process children before copying
-- e.g. NEW /a/b BEFORE COPY /a -> /b
dest_trie:accum_children_of(action.src_url, ret)
-- Process remove path before copying to new path
-- e.g. MOVE /a -> /b BEFORE COPY /c -> /a
src_trie:accum_actions_at(action.dest_url, ret, function(a)
return a.type == 'move' or a.type == 'delete'
end)
end
return ret
end
---@return nil|oil.Action The leaf action
---@return nil|oil.Action When no leaves found, this is the last action in the loop
local function find_leaf(action, seen)
if not seen then
seen = {}
elseif seen[action] then
return nil, action
end
seen[action] = true
local deps = get_deps(action)
if vim.tbl_isempty(deps) then
return action
end
local action_in_loop
for _, dep in ipairs(deps) do
local leaf, loop_action = find_leaf(dep, seen)
if leaf then
return leaf
elseif not action_in_loop and loop_action then
action_in_loop = loop_action
end
end
return nil, action_in_loop
end
local ret = {}
local after = {}
while not vim.tbl_isempty(actions) do
local action = actions[1]
local selected, loop_action = find_leaf(action)
local to_remove
if selected then
to_remove = selected
else
if loop_action and loop_action.type == 'move' then
-- If this is moving a parent into itself, that's an error
if vim.startswith(loop_action.dest_url, loop_action.src_url) then
error('Detected cycle in desired paths')
end
-- We've detected a move cycle (e.g. MOVE /a -> /b + MOVE /b -> /a)
-- Split one of the moves and retry
local intermediate_url =
string.format('%s__oil_tmp_%05d', loop_action.src_url, math.random(999999))
local move_1 = {
type = 'move',
entry_type = loop_action.entry_type,
src_url = loop_action.src_url,
dest_url = intermediate_url,
}
local move_2 = {
type = 'move',
entry_type = loop_action.entry_type,
src_url = intermediate_url,
dest_url = loop_action.dest_url,
}
to_remove = loop_action
table.insert(actions, move_1)
table.insert(after, move_2)
dest_trie:insert_action(move_1.dest_url, move_1)
src_trie:insert_action(move_1.src_url, move_1)
else
error('Detected cycle in desired paths')
end
end
if selected then
if selected.type == 'move' or selected.type == 'copy' then
if vim.startswith(selected.dest_url, selected.src_url .. '/') then
error(
string.format(
'Cannot move or copy parent into itself: %s -> %s',
selected.src_url,
selected.dest_url
)
)
end
end
table.insert(ret, selected)
end
if to_remove then
if to_remove.type == 'delete' or to_remove.type == 'change' then
src_trie:remove_action(to_remove.url, to_remove)
elseif to_remove.type == 'create' then
dest_trie:remove_action(to_remove.url, to_remove)
else
dest_trie:remove_action(to_remove.dest_url, to_remove)
src_trie:remove_action(to_remove.src_url, to_remove)
end
for i, a in ipairs(actions) do
if a == to_remove then
table.remove(actions, i)
break
end
end
end
end
vim.list_extend(ret, after)
return ret
end
local progress
---@param actions oil.Action[]
---@param cb fun(err: nil|string)
M.process_actions = function(actions, cb)
vim.api.nvim_exec_autocmds(
'User',
{ pattern = 'OilActionsPre', modeline = false, data = { actions = actions } }
)
local did_complete = nil
if config.lsp_file_methods.enabled then
did_complete = lsp_helpers.will_perform_file_operations(actions)
end
-- Convert some cross-adapter moves to a copy + delete
for _, action in ipairs(actions) do
if action.type == 'move' then
local _, cross_action = util.get_adapter_for_action(action)
-- Only do the conversion if the cross-adapter support is "copy"
if cross_action == 'copy' then
---@diagnostic disable-next-line: assign-type-mismatch
action.type = 'copy'
table.insert(actions, {
type = 'delete',
url = action.src_url,
entry_type = action.entry_type,
})
end
end
end
local finished = false
progress = Progress.new()
local function finish(err)
if not finished then
finished = true
progress:close()
progress = nil
if config.cleanup_buffers_on_delete and not err then
for _, action in ipairs(actions) do
if action.type == 'delete' then
local scheme, path = util.parse_url(action.url)
if config.adapters[scheme] == 'files' then
assert(path)
local os_path = fs.posix_to_os_path(path)
local bufnr = vim.fn.bufnr(os_path)
if bufnr ~= -1 then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
end
end
end
vim.api.nvim_exec_autocmds(
'User',
{ pattern = 'OilActionsPost', modeline = false, data = { err = err, actions = actions } }
)
cb(err)
end
end
-- Defer showing the progress to avoid flicker for fast operations
vim.defer_fn(function()
if not finished then
progress:show({
-- TODO some actions are actually cancelable.
-- We should stop them instead of stopping after the current action
cancel = function()
finish('Canceled')
end,
})
end
end, 100)
local idx = 1
local next_action
next_action = function()
if finished then
return
end
if idx > #actions then
if did_complete then
did_complete()
end
finish()
return
end
local action = actions[idx]
progress:set_action(action, idx, #actions)
idx = idx + 1
local ok, adapter = pcall(util.get_adapter_for_action, action)
if not ok then
return finish(adapter)
end
local callback = vim.schedule_wrap(function(err)
if finished then
-- This can happen if the user canceled out of the progress window
return
elseif err then
finish(err)
else
cache.perform_action(action)
next_action()
end
end)
if action.type == 'change' then
---@cast action oil.ChangeAction
columns.perform_change_action(adapter, action, callback)
else
adapter.perform_action(action, callback)
end
end
next_action()
end
M.show_progress = function()
if progress then
progress:restore()
end
end
local mutation_in_progress = false
---@return boolean
M.is_mutating = function()
return mutation_in_progress
end
---@param confirm nil|boolean
---@param cb? fun(err: nil|string)
M.try_write_changes = function(confirm, cb)
if not cb then
cb = function(_err) end
end
if mutation_in_progress then
cb('Cannot perform mutation when already in progress')
return
end
local current_buf = vim.api.nvim_get_current_buf()
local was_modified = vim.bo.modified
local buffers = view.get_all_buffers()
local all_diffs = {}
---@type table<integer, oil.ParseError[]>
local all_errors = {}
mutation_in_progress = true
-- Lock the buffer to prevent race conditions from the user modifying them during parsing
view.lock_buffers()
for _, bufnr in ipairs(buffers) do
if vim.bo[bufnr].modified then
local diffs, errors = parser.parse(bufnr)
all_diffs[bufnr] = diffs
local adapter = assert(util.get_adapter(bufnr, true))
if adapter.filter_error then
errors = vim.tbl_filter(adapter.filter_error, errors)
end
if not vim.tbl_isempty(errors) then
all_errors[bufnr] = errors
end
end
end
local function unlock()
view.unlock_buffers()
-- The ":write" will set nomodified even if we cancel here, so we need to restore it
if was_modified then
vim.bo[current_buf].modified = true
end
mutation_in_progress = false
end
local ns = vim.api.nvim_create_namespace('Oil')
vim.diagnostic.reset(ns)
if not vim.tbl_isempty(all_errors) then
for bufnr, errors in pairs(all_errors) do
vim.diagnostic.set(ns, bufnr, errors)
end
-- Jump to an error
local curbuf = vim.api.nvim_get_current_buf()
if all_errors[curbuf] then
pcall(
vim.api.nvim_win_set_cursor,
0,
{ all_errors[curbuf][1].lnum + 1, all_errors[curbuf][1].col }
)
else
local bufnr, errs = next(all_errors)
assert(bufnr)
assert(errs)
-- HACK: This is a workaround for the fact that we can't switch buffers in the middle of a
-- BufWriteCmd.
vim.schedule(function()
vim.api.nvim_win_set_buf(0, bufnr)
pcall(vim.api.nvim_win_set_cursor, 0, { errs[1].lnum + 1, errs[1].col })
end)
end
unlock()
cb('Error parsing oil buffers')
return
end
local actions = M.create_actions_from_diffs(all_diffs)
confirmation.show(actions, confirm, function(proceed)
if not proceed then
unlock()
cb('Canceled')
return
end
M.process_actions(
actions,
vim.schedule_wrap(function(err)
view.unlock_buffers()
if err then
err = string.format('[oil] Error applying actions: %s', err)
view.rerender_all_oil_buffers(nil, function()
cb(err)
end)
else
local current_entry = oil.get_cursor_entry()
if current_entry then
-- get the entry under the cursor and make sure the cursor stays on it
view.set_last_cursor(
vim.api.nvim_buf_get_name(0),
vim.split(current_entry.parsed_name or current_entry.name, '/')[1]
)
end
view.rerender_all_oil_buffers(nil, function(render_err)
vim.api.nvim_exec_autocmds(
'User',
{ pattern = 'OilMutationComplete', modeline = false }
)
cb(render_err)
end)
end
mutation_in_progress = false
end)
)
end)
end
return M

321
lua/oil/mutator/parser.lua Normal file
View file

@ -0,0 +1,321 @@
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 util = require('oil.util')
local view = require('oil.view')
local M = {}
local FIELD_ID = constants.FIELD_ID
local FIELD_NAME = constants.FIELD_NAME
local FIELD_TYPE = constants.FIELD_TYPE
local FIELD_META = constants.FIELD_META
---@alias oil.Diff oil.DiffNew|oil.DiffDelete|oil.DiffChange
---@class (exact) oil.DiffNew
---@field type "new"
---@field name string
---@field entry_type oil.EntryType
---@field id nil|integer
---@field link nil|string
---@class (exact) oil.DiffDelete
---@field type "delete"
---@field name string
---@field id integer
---@class (exact) oil.DiffChange
---@field type "change"
---@field entry_type oil.EntryType
---@field name string
---@field column string
---@field value any
---@param name string
---@return string
---@return boolean
local function parsedir(name)
local isdir = vim.endswith(name, '/') or (fs.is_windows and vim.endswith(name, '\\'))
if isdir then
name = name:sub(1, name:len() - 1)
end
return name, isdir
end
---@param meta nil|table
---@param parsed_entry table
---@return boolean True if metadata and parsed entry have the same link target
local function compare_link_target(meta, parsed_entry)
if not meta or not meta.link then
return false
end
-- Make sure we trim off any trailing path slashes from both sources
local meta_name = meta.link:gsub('[/\\]$', '')
local parsed_name = parsed_entry.link_target:gsub('[/\\]$', '')
return meta_name == parsed_name
end
---@class (exact) oil.ParseResult
---@field data table Parsed entry data
---@field ranges table<string, integer[]> Locations of the various columns
---@field entry nil|oil.InternalEntry If the entry already exists
---Parse a single line in a buffer
---@param adapter oil.Adapter
---@param line string
---@param column_defs oil.ColumnSpec[]
---@return nil|oil.ParseResult
---@return nil|string Error
M.parse_line = function(adapter, line, column_defs)
local ret = {}
local ranges = {}
local start = 1
local value, rem = line:match('^/(%d+) (.+)$')
if not value then
return nil, 'Malformed ID at start of line'
end
ranges.id = { start, value:len() + 1 }
start = ranges.id[2] + 1
ret.id = tonumber(value)
-- Right after a mutation and we reset the cache, the parent url may not be available
local ok, parent_url = pcall(cache.get_parent_url, ret.id)
if ok then
-- If this line was pasted from another adapter, it may have different columns
local line_adapter = assert(config.get_adapter_by_scheme(parent_url))
if adapter ~= line_adapter then
adapter = line_adapter
column_defs = columns.get_supported_columns(adapter)
end
end
for _, def in ipairs(column_defs) do
local name = util.split_config(def)
local range = { start }
local start_len = string.len(rem)
value, rem = columns.parse_col(adapter, assert(rem), def)
if not rem then
return nil, string.format('Parsing %s failed', name)
end
ret[name] = value
range[2] = range[1] + start_len - string.len(rem) - 1
ranges[name] = range
start = range[2] + 1
end
local name = rem
if name then
local isdir
name, isdir = parsedir(vim.trim(name))
if name ~= '' then
ret.name = name
end
ret._type = isdir and 'directory' or 'file'
end
local entry = cache.get_entry_by_id(ret.id)
ranges.name = { start, start + string.len(rem) - 1 }
if not entry then
return { data = ret, ranges = ranges }
end
-- Parse the symlink syntax
local meta = entry[FIELD_META]
local entry_type = entry[FIELD_TYPE]
if entry_type == 'link' and meta and meta.link then
local name_pieces = vim.split(ret.name, ' -> ', { plain = true })
if #name_pieces ~= 2 then
ret.name = ''
return { data = ret, ranges = ranges }
end
ranges.name = { start, start + string.len(name_pieces[1]) - 1 }
ret.name = parsedir(vim.trim(name_pieces[1]))
ret.link_target = name_pieces[2]
ret._type = 'link'
end
-- Try to keep the same file type
if entry_type ~= 'directory' and entry_type ~= 'file' and ret._type ~= 'directory' then
ret._type = entry[FIELD_TYPE]
end
return { data = ret, entry = entry, ranges = ranges }
end
---@class (exact) oil.ParseError
---@field lnum integer
---@field col integer
---@field message string
---@param bufnr integer
---@return oil.Diff[] diffs
---@return oil.ParseError[] errors Parsing errors
M.parse = function(bufnr)
---@type oil.Diff[]
local diffs = {}
---@type oil.ParseError[]
local errors = {}
local bufname = vim.api.nvim_buf_get_name(bufnr)
local adapter = util.get_adapter(bufnr, true)
if not adapter then
table.insert(errors, {
lnum = 0,
col = 0,
message = string.format("Cannot parse buffer '%s': No adapter", bufname),
})
return diffs, errors
end
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)
local scheme, path = util.parse_url(bufname)
local column_defs = columns.get_supported_columns(adapter)
local parent_url = scheme .. path
local children = cache.list_url(parent_url)
-- map from name to entry ID for all entries previously in the buffer
---@type table<string, integer>
local original_entries = {}
for _, child in pairs(children) do
local name = child[FIELD_NAME]
if view.should_display(bufnr, child) then
original_entries[name] = child[FIELD_ID]
end
end
local seen_names = {}
local function check_dupe(name, i)
if fs.is_mac or fs.is_windows then
-- mac and windows use case-insensitive filesystems
name = name:lower()
end
if seen_names[name] then
table.insert(errors, { message = 'Duplicate filename', lnum = i - 1, end_lnum = i, col = 0 })
else
seen_names[name] = true
end
end
for i, line in ipairs(lines) do
-- hack to be compatible with Lua 5.1
-- use return instead of goto
(function()
if line:match('^/%d+') then
-- Parse the line for an existing entry
local result, err = M.parse_line(adapter, line, column_defs)
if not result or err then
table.insert(errors, {
message = err,
lnum = i - 1,
end_lnum = i,
col = 0,
})
return
elseif result.data.id == 0 then
-- Ignore entries with ID 0 (typically the "../" entry)
return
end
local parsed_entry = result.data
local entry = result.entry
local err_message
if not parsed_entry.name then
err_message = 'No filename found'
elseif not entry then
err_message = 'Could not find existing entry (was the ID changed?)'
elseif parsed_entry.name:match('/') or parsed_entry.name:match(fs.sep) then
err_message = 'Filename cannot contain path separator'
end
if err_message then
table.insert(errors, {
message = err_message,
lnum = i - 1,
end_lnum = i,
col = 0,
})
return
end
assert(entry)
check_dupe(parsed_entry.name, i)
local meta = entry[FIELD_META]
if original_entries[parsed_entry.name] == parsed_entry.id then
if entry[FIELD_TYPE] == 'link' and not compare_link_target(meta, parsed_entry) then
table.insert(diffs, {
type = 'new',
name = parsed_entry.name,
entry_type = 'link',
link = parsed_entry.link_target,
})
elseif entry[FIELD_TYPE] ~= parsed_entry._type then
table.insert(diffs, {
type = 'new',
name = parsed_entry.name,
entry_type = parsed_entry._type,
})
else
original_entries[parsed_entry.name] = nil
end
else
table.insert(diffs, {
type = 'new',
name = parsed_entry.name,
entry_type = parsed_entry._type,
id = parsed_entry.id,
link = parsed_entry.link_target,
})
end
for _, col_def in ipairs(column_defs) do
local col_name = util.split_config(col_def)
if columns.compare(adapter, col_name, entry, parsed_entry[col_name]) then
table.insert(diffs, {
type = 'change',
name = parsed_entry.name,
entry_type = entry[FIELD_TYPE],
column = col_name,
value = parsed_entry[col_name],
})
end
end
else
-- Parse a new entry
local name, isdir = parsedir(vim.trim(line))
if vim.startswith(name, '/') then
table.insert(errors, {
message = "Paths cannot start with '/'",
lnum = i - 1,
end_lnum = i,
col = 0,
})
return
end
if name ~= '' then
local link_pieces = vim.split(name, ' -> ', { plain = true })
local entry_type = isdir and 'directory' or 'file'
local link
if #link_pieces == 2 then
entry_type = 'link'
name, link = unpack(link_pieces)
end
check_dupe(name, i)
table.insert(diffs, {
type = 'new',
name = name,
entry_type = entry_type,
link = link,
})
end
end
end)()
end
for name, child_id in pairs(original_entries) do
table.insert(diffs, {
type = 'delete',
name = name,
id = child_id,
})
end
return diffs, errors
end
return M

View file

@ -0,0 +1,223 @@
local columns = require('oil.columns')
local config = require('oil.config')
local layout = require('oil.layout')
local loading = require('oil.loading')
local util = require('oil.util')
local Progress = {}
local FPS = 20
function Progress.new()
return setmetatable({
lines = { '', '' },
count = '',
spinner = '',
bufnr = nil,
winid = nil,
min_bufnr = nil,
min_winid = nil,
autocmds = {},
closing = false,
}, {
__index = Progress,
})
end
---@private
---@return boolean
function Progress:is_minimized()
return not self.closing
and not self.bufnr
and self.min_bufnr
and vim.api.nvim_buf_is_valid(self.min_bufnr)
end
---@param opts nil|table
--- cancel fun()
function Progress:show(opts)
opts = opts or {}
if self.winid and vim.api.nvim_win_is_valid(self.winid) then
return
end
local bufnr = vim.api.nvim_create_buf(false, true)
vim.bo[bufnr].bufhidden = 'wipe'
self.bufnr = bufnr
self.cancel = opts.cancel or self.cancel
local loading_iter = loading.get_bar_iter()
local spinner = loading.get_iter('dots')
if not self.timer then
self.timer = vim.loop.new_timer()
self.timer:start(
0,
math.floor(1000 / FPS),
vim.schedule_wrap(function()
self.lines[2] = string.format('%s %s', self.count, loading_iter())
self.spinner = spinner()
self:_render()
end)
)
end
local width, height = layout.calculate_dims(120, 10, config.progress)
self.winid = vim.api.nvim_open_win(self.bufnr, true, {
relative = 'editor',
width = width,
height = height,
row = math.floor((layout.get_editor_height() - height) / 2),
col = math.floor((layout.get_editor_width() - width) / 2),
zindex = 152, -- render on top of the floating window title
style = 'minimal',
border = config.progress.border,
})
vim.bo[self.bufnr].filetype = 'oil_progress'
for k, v in pairs(config.progress.win_options) do
vim.api.nvim_set_option_value(k, v, { scope = 'local', win = self.winid })
end
table.insert(
self.autocmds,
vim.api.nvim_create_autocmd('VimResized', {
callback = function()
self:_reposition()
end,
})
)
table.insert(
self.autocmds,
vim.api.nvim_create_autocmd('WinLeave', {
callback = function()
self:minimize()
end,
})
)
local cancel = self.cancel or function() end
local minimize = function()
if self.winid and vim.api.nvim_win_is_valid(self.winid) then
vim.api.nvim_win_close(self.winid, true)
end
end
vim.keymap.set('n', 'c', cancel, { buffer = self.bufnr, nowait = true })
vim.keymap.set('n', 'C', cancel, { buffer = self.bufnr, nowait = true })
vim.keymap.set('n', 'm', minimize, { buffer = self.bufnr, nowait = true })
vim.keymap.set('n', 'M', minimize, { buffer = self.bufnr, nowait = true })
end
function Progress:restore()
if self.closing then
return
elseif not self:is_minimized() then
error('Cannot restore progress window: not minimized')
end
self:_cleanup_minimized_win()
self:show()
end
function Progress:_render()
if self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr) then
util.render_text(
self.bufnr,
self.lines,
{ winid = self.winid, actions = { '[M]inimize', '[C]ancel' } }
)
end
if self.min_bufnr and vim.api.nvim_buf_is_valid(self.min_bufnr) then
util.render_text(
self.min_bufnr,
{ string.format('%sOil: %s', self.spinner, self.count) },
{ winid = self.min_winid, h_align = 'left' }
)
end
end
function Progress:_reposition()
if self.winid and vim.api.nvim_win_is_valid(self.winid) then
local min_width = 120
local line_width = vim.api.nvim_strwidth(self.lines[1])
if line_width > min_width then
min_width = line_width
end
local width, height = layout.calculate_dims(min_width, 10, config.progress)
vim.api.nvim_win_set_config(self.winid, {
relative = 'editor',
width = width,
height = height,
row = math.floor((layout.get_editor_height() - height) / 2),
col = math.floor((layout.get_editor_width() - width) / 2),
zindex = 152, -- render on top of the floating window title
})
end
end
function Progress:_cleanup_main_win()
if self.winid then
if vim.api.nvim_win_is_valid(self.winid) then
vim.api.nvim_win_close(self.winid, true)
end
self.winid = nil
end
for _, id in ipairs(self.autocmds) do
vim.api.nvim_del_autocmd(id)
end
self.autocmds = {}
self.bufnr = nil
end
function Progress:_cleanup_minimized_win()
if self.min_winid and vim.api.nvim_win_is_valid(self.min_winid) then
vim.api.nvim_win_close(self.min_winid, true)
end
self.min_winid = nil
self.min_bufnr = nil
end
function Progress:minimize()
if self.closing then
return
end
self:_cleanup_main_win()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.bo[bufnr].bufhidden = 'wipe'
local winid = vim.api.nvim_open_win(bufnr, false, {
relative = 'editor',
width = 16,
height = 1,
anchor = 'SE',
row = layout.get_editor_height(),
col = layout.get_editor_width(),
zindex = 152, -- render on top of the floating window title
style = 'minimal',
border = config.progress.minimized_border,
})
self.min_bufnr = bufnr
self.min_winid = winid
self:_render()
vim.notify_once('Restore progress window with :Oil --progress')
end
---@param action oil.Action
---@param idx integer
---@param total integer
function Progress:set_action(action, idx, total)
local adapter = util.get_adapter_for_action(action)
local change_line
if action.type == 'change' then
---@cast action oil.ChangeAction
change_line = columns.render_change_action(adapter, action)
else
change_line = adapter.render_action(action)
end
self.lines[1] = change_line
self.count = string.format('%d/%d', idx, total)
self:_reposition()
self:_render()
end
function Progress:close()
self.closing = true
if self.timer then
self.timer:close()
self.timer = nil
end
self:_cleanup_main_win()
self:_cleanup_minimized_win()
end
return Progress

160
lua/oil/mutator/trie.lua Normal file
View file

@ -0,0 +1,160 @@
local util = require('oil.util')
---@class (exact) oil.Trie
---@field new fun(): oil.Trie
---@field private root table
local Trie = {}
---@return oil.Trie
Trie.new = function()
---@type oil.Trie
return setmetatable({
root = { values = {}, children = {} },
}, {
__index = Trie,
})
end
---@param url string
---@return string[]
function Trie:_url_to_path_pieces(url)
local scheme, path = util.parse_url(url)
assert(path)
local pieces = vim.split(path, '/')
table.insert(pieces, 1, scheme)
return pieces
end
---@param url string
---@param value any
function Trie:insert_action(url, value)
local pieces = self:_url_to_path_pieces(url)
self:insert(pieces, value)
end
---@param url string
---@param value any
function Trie:remove_action(url, value)
local pieces = self:_url_to_path_pieces(url)
self:remove(pieces, value)
end
---@param path_pieces string[]
---@param value any
function Trie:insert(path_pieces, value)
local current = self.root
for _, piece in ipairs(path_pieces) do
local next_container = current.children[piece]
if not next_container then
next_container = { values = {}, children = {} }
current.children[piece] = next_container
end
current = next_container
end
table.insert(current.values, value)
end
---@param path_pieces string[]
---@param value any
function Trie:remove(path_pieces, value)
local current = self.root
for _, piece in ipairs(path_pieces) do
local next_container = current.children[piece]
if not next_container then
next_container = { values = {}, children = {} }
current.children[piece] = next_container
end
current = next_container
end
for i, v in ipairs(current.values) do
if v == value then
table.remove(current.values, i)
-- if vim.tbl_isempty(current.values) and vim.tbl_isempty(current.children) then
-- TODO remove container from trie
-- end
return
end
end
error('Value not present in trie: ' .. vim.inspect(value))
end
---Add the first action that affects a parent path of the url
---@param url string
---@param ret oil.InternalEntry[]
function Trie:accum_first_parents_of(url, ret)
local pieces = self:_url_to_path_pieces(url)
local containers = { self.root }
for _, piece in ipairs(pieces) do
local next_container = containers[#containers].children[piece]
table.insert(containers, next_container)
end
table.remove(containers)
while not vim.tbl_isempty(containers) do
local container = containers[#containers]
if not vim.tbl_isempty(container.values) then
vim.list_extend(ret, container.values)
break
end
table.remove(containers)
end
end
---Do a depth-first-search and add all children matching the filter
function Trie:_dfs(container, ret, filter)
if filter then
for _, action in ipairs(container.values) do
if filter(action) then
table.insert(ret, action)
end
end
else
vim.list_extend(ret, container.values)
end
for _, child in ipairs(container.children) do
self:_dfs(child, ret)
end
end
---Add all actions affecting children of the url
---@param url string
---@param ret oil.InternalEntry[]
---@param filter nil|fun(entry: oil.Action): boolean
function Trie:accum_children_of(url, ret, filter)
local pieces = self:_url_to_path_pieces(url)
local current = self.root
for _, piece in ipairs(pieces) do
current = current.children[piece]
if not current then
return
end
end
if current then
for _, child in pairs(current.children) do
self:_dfs(child, ret, filter)
end
end
end
---Add all actions at a specific path
---@param url string
---@param ret oil.InternalEntry[]
---@param filter? fun(entry: oil.Action): boolean
function Trie:accum_actions_at(url, ret, filter)
local pieces = self:_url_to_path_pieces(url)
local current = self.root
for _, piece in ipairs(pieces) do
current = current.children[piece]
if not current then
return
end
end
if current then
for _, action in ipairs(current.values) do
if not filter or filter(action) then
table.insert(ret, action)
end
end
end
end
return Trie

29
lua/oil/pathutil.lua Normal file
View file

@ -0,0 +1,29 @@
local M = {}
---@param path string
---@return string
M.parent = function(path)
if path == '/' then
return '/'
elseif path == '' then
return ''
elseif vim.endswith(path, '/') then
return path:match('^(.*/)[^/]*/$') or ''
else
return path:match('^(.*/)[^/]*$') or ''
end
end
---@param path string
---@return nil|string
M.basename = function(path)
if path == '/' or path == '' then
return
elseif vim.endswith(path, '/') then
return path:match('^.*/([^/]*)/$')
else
return path:match('^.*/([^/]*)$')
end
end
return M

37
lua/oil/ringbuf.lua Normal file
View file

@ -0,0 +1,37 @@
---@class oil.Ringbuf
---@field private size integer
---@field private tail integer
---@field private buf string[]
local Ringbuf = {}
function Ringbuf.new(size)
local self = setmetatable({
size = size,
buf = {},
tail = 0,
}, { __index = Ringbuf })
return self
end
---@param val string
function Ringbuf:push(val)
self.tail = self.tail + 1
if self.tail > self.size then
self.tail = 1
end
self.buf[self.tail] = val
end
---@return string
function Ringbuf:as_str()
local postfix = ''
for i = 1, self.tail, 1 do
postfix = postfix .. self.buf[i]
end
local prefix = ''
for i = self.tail + 1, #self.buf, 1 do
prefix = prefix .. self.buf[i]
end
return prefix .. postfix
end
return Ringbuf

48
lua/oil/shell.lua Normal file
View file

@ -0,0 +1,48 @@
local M = {}
M.run = function(cmd, opts, callback)
if not callback then
callback = opts
opts = {}
end
local stdout
local stderr = {}
local jid = vim.fn.jobstart(
cmd,
vim.tbl_deep_extend('keep', opts, {
stdout_buffered = true,
stderr_buffered = true,
on_stdout = function(j, output)
stdout = output
end,
on_stderr = function(j, output)
stderr = output
end,
on_exit = vim.schedule_wrap(function(j, code)
if code == 0 then
callback(nil, stdout)
else
local err = table.concat(stderr, '\n')
if err == '' then
err = 'Unknown error'
end
local cmd_str = type(cmd) == 'string' and cmd or table.concat(cmd, ' ')
callback(string.format("Error running command '%s'\n%s", cmd_str, err))
end
end),
})
)
local exe
if type(cmd) == 'string' then
exe = vim.split(cmd, '%s+')[1]
else
exe = cmd[1]
end
if jid == 0 then
callback(string.format("Passed invalid arguments to '%s'", exe))
elseif jid == -1 then
callback(string.format("'%s' is not executable", exe))
end
end
return M

1080
lua/oil/util.lua Normal file

File diff suppressed because it is too large Load diff

1157
lua/oil/view.lua Normal file

File diff suppressed because it is too large Load diff