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:
parent
9298b48c5d
commit
8dd67f91e8
68 changed files with 1622 additions and 1625 deletions
652
lua/oil/actions.lua
Normal file
652
lua/oil/actions.lua
Normal 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
678
lua/oil/adapters/files.lua
Normal 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
|
||||
103
lua/oil/adapters/files/permissions.lua
Normal file
103
lua/oil/adapters/files/permissions.lua
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
local M = {}
|
||||
|
||||
---@param exe_modifier false|string
|
||||
---@param num integer
|
||||
---@return string
|
||||
local function perm_to_str(exe_modifier, num)
|
||||
local str = (bit.band(num, 4) ~= 0 and 'r' or '-') .. (bit.band(num, 2) ~= 0 and 'w' or '-')
|
||||
if exe_modifier then
|
||||
if bit.band(num, 1) ~= 0 then
|
||||
return str .. exe_modifier
|
||||
else
|
||||
return str .. exe_modifier:upper()
|
||||
end
|
||||
else
|
||||
return str .. (bit.band(num, 1) ~= 0 and 'x' or '-')
|
||||
end
|
||||
end
|
||||
|
||||
---@param mode integer
|
||||
---@return string
|
||||
M.mode_to_str = function(mode)
|
||||
local extra = bit.rshift(mode, 9)
|
||||
return perm_to_str(bit.band(extra, 4) ~= 0 and 's', bit.rshift(mode, 6))
|
||||
.. perm_to_str(bit.band(extra, 2) ~= 0 and 's', bit.rshift(mode, 3))
|
||||
.. perm_to_str(bit.band(extra, 1) ~= 0 and 't', mode)
|
||||
end
|
||||
|
||||
---@param mode integer
|
||||
---@return string
|
||||
M.mode_to_octal_str = function(mode)
|
||||
local mask = 7
|
||||
return tostring(bit.band(mask, bit.rshift(mode, 9)))
|
||||
.. tostring(bit.band(mask, bit.rshift(mode, 6)))
|
||||
.. tostring(bit.band(mask, bit.rshift(mode, 3)))
|
||||
.. tostring(bit.band(mask, mode))
|
||||
end
|
||||
|
||||
---@param str string String of 3 characters
|
||||
---@return nil|integer
|
||||
local function str_to_mode(str)
|
||||
local r, w, x = unpack(vim.split(str, '', {}))
|
||||
local mode = 0
|
||||
if r == 'r' then
|
||||
mode = bit.bor(mode, 4)
|
||||
elseif r ~= '-' then
|
||||
return nil
|
||||
end
|
||||
if w == 'w' then
|
||||
mode = bit.bor(mode, 2)
|
||||
elseif w ~= '-' then
|
||||
return nil
|
||||
end
|
||||
-- t means sticky and executable
|
||||
-- T means sticky, not executable
|
||||
-- s means setuid/setgid and executable
|
||||
-- S means setuid/setgid and not executable
|
||||
if x == 'x' or x == 't' or x == 's' then
|
||||
mode = bit.bor(mode, 1)
|
||||
elseif x ~= '-' and x ~= 'T' and x ~= 'S' then
|
||||
return nil
|
||||
end
|
||||
return mode
|
||||
end
|
||||
|
||||
---@param perm string
|
||||
---@return integer
|
||||
local function parse_extra_bits(perm)
|
||||
perm = perm:lower()
|
||||
local mode = 0
|
||||
if perm:sub(3, 3) == 's' then
|
||||
mode = bit.bor(mode, 4)
|
||||
end
|
||||
if perm:sub(6, 6) == 's' then
|
||||
mode = bit.bor(mode, 2)
|
||||
end
|
||||
if perm:sub(9, 9) == 't' then
|
||||
mode = bit.bor(mode, 1)
|
||||
end
|
||||
return mode
|
||||
end
|
||||
|
||||
---@param line string
|
||||
---@return nil|integer
|
||||
---@return nil|string
|
||||
M.parse = function(line)
|
||||
local strval, rem = line:match('^([r%-][w%-][xsS%-][r%-][w%-][xsS%-][r%-][w%-][xtT%-])%s*(.*)$')
|
||||
if not strval then
|
||||
return
|
||||
end
|
||||
local user_mode = str_to_mode(strval:sub(1, 3))
|
||||
local group_mode = str_to_mode(strval:sub(4, 6))
|
||||
local any_mode = str_to_mode(strval:sub(7, 9))
|
||||
local extra = parse_extra_bits(strval)
|
||||
if not user_mode or not group_mode or not any_mode then
|
||||
return
|
||||
end
|
||||
local mode = bit.bor(bit.lshift(user_mode, 6), bit.lshift(group_mode, 3))
|
||||
mode = bit.bor(mode, any_mode)
|
||||
mode = bit.bor(mode, bit.lshift(extra, 9))
|
||||
return mode, rem
|
||||
end
|
||||
|
||||
return M
|
||||
394
lua/oil/adapters/s3.lua
Normal file
394
lua/oil/adapters/s3.lua
Normal 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
|
||||
149
lua/oil/adapters/s3/s3fs.lua
Normal file
149
lua/oil/adapters/s3/s3fs.lua
Normal 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
479
lua/oil/adapters/ssh.lua
Normal 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
|
||||
317
lua/oil/adapters/ssh/connection.lua
Normal file
317
lua/oil/adapters/ssh/connection.lua
Normal 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
|
||||
264
lua/oil/adapters/ssh/sshfs.lua
Normal file
264
lua/oil/adapters/ssh/sshfs.lua
Normal 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
96
lua/oil/adapters/test.lua
Normal 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
|
||||
9
lua/oil/adapters/trash.lua
Normal file
9
lua/oil/adapters/trash.lua
Normal 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
|
||||
634
lua/oil/adapters/trash/freedesktop.lua
Normal file
634
lua/oil/adapters/trash/freedesktop.lua
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
-- Based on the FreeDesktop.org trash specification
|
||||
-- https://specifications.freedesktop.org/trash/1.0/
|
||||
local cache = require('oil.cache')
|
||||
local config = require('oil.config')
|
||||
local constants = require('oil.constants')
|
||||
local files = require('oil.adapters.files')
|
||||
local fs = require('oil.fs')
|
||||
local util = require('oil.util')
|
||||
|
||||
local uv = vim.uv or vim.loop
|
||||
local FIELD_META = constants.FIELD_META
|
||||
|
||||
local M = {}
|
||||
|
||||
local function ensure_trash_dir(path)
|
||||
local mode = 448 -- 0700
|
||||
fs.mkdirp(fs.join(path, 'info'), mode)
|
||||
fs.mkdirp(fs.join(path, 'files'), mode)
|
||||
end
|
||||
|
||||
---Gets the location of the home trash dir, creating it if necessary
|
||||
---@return string
|
||||
local function get_home_trash_dir()
|
||||
local xdg_home = vim.env.XDG_DATA_HOME
|
||||
if not xdg_home then
|
||||
xdg_home = fs.join(assert(uv.os_homedir()), '.local', 'share')
|
||||
end
|
||||
local trash_dir = fs.join(xdg_home, 'Trash')
|
||||
ensure_trash_dir(trash_dir)
|
||||
return trash_dir
|
||||
end
|
||||
|
||||
---@param mode integer
|
||||
---@return boolean
|
||||
local function is_sticky(mode)
|
||||
local extra = bit.rshift(mode, 9)
|
||||
return bit.band(extra, 4) ~= 0
|
||||
end
|
||||
|
||||
---Get the topdir .Trash/$uid directory if present and valid
|
||||
---@param path string
|
||||
---@return string[]
|
||||
local function get_top_trash_dirs(path)
|
||||
local dirs = {}
|
||||
local dev = (uv.fs_lstat(path) or {}).dev
|
||||
local top_trash_dirs = vim.fs.find('.Trash', { upward = true, path = path, limit = math.huge })
|
||||
for _, top_trash_dir in ipairs(top_trash_dirs) do
|
||||
local stat = uv.fs_lstat(top_trash_dir)
|
||||
if stat and not dev then
|
||||
dev = stat.dev
|
||||
end
|
||||
if stat and stat.dev == dev and stat.type == 'directory' and is_sticky(stat.mode) then
|
||||
local trash_dir = fs.join(top_trash_dir, tostring(uv.getuid()))
|
||||
ensure_trash_dir(trash_dir)
|
||||
table.insert(dirs, trash_dir)
|
||||
end
|
||||
end
|
||||
|
||||
-- Also search for the .Trash-$uid
|
||||
top_trash_dirs = vim.fs.find(
|
||||
string.format('.Trash-%d', uv.getuid()),
|
||||
{ upward = true, path = path, limit = math.huge }
|
||||
)
|
||||
for _, top_trash_dir in ipairs(top_trash_dirs) do
|
||||
local stat = uv.fs_lstat(top_trash_dir)
|
||||
if stat and stat.dev == dev then
|
||||
ensure_trash_dir(top_trash_dir)
|
||||
table.insert(dirs, top_trash_dir)
|
||||
end
|
||||
end
|
||||
|
||||
return dirs
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return string
|
||||
local function get_write_trash_dir(path)
|
||||
local lstat = uv.fs_lstat(path)
|
||||
local home_trash = get_home_trash_dir()
|
||||
if not lstat then
|
||||
-- If the source file doesn't exist default to home trash dir
|
||||
return home_trash
|
||||
end
|
||||
local dev = lstat.dev
|
||||
if uv.fs_lstat(home_trash).dev == dev then
|
||||
return home_trash
|
||||
end
|
||||
|
||||
local top_trash_dirs = get_top_trash_dirs(path)
|
||||
if not vim.tbl_isempty(top_trash_dirs) then
|
||||
return top_trash_dirs[1]
|
||||
end
|
||||
|
||||
local parent = vim.fn.fnamemodify(path, ':h')
|
||||
local next_parent = vim.fn.fnamemodify(parent, ':h')
|
||||
while parent ~= next_parent and uv.fs_lstat(next_parent).dev == dev do
|
||||
parent = next_parent
|
||||
next_parent = vim.fn.fnamemodify(parent, ':h')
|
||||
end
|
||||
|
||||
local top_trash = fs.join(parent, string.format('.Trash-%d', uv.getuid()))
|
||||
ensure_trash_dir(top_trash)
|
||||
return top_trash
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return string[]
|
||||
local function get_read_trash_dirs(path)
|
||||
local dirs = { get_home_trash_dir() }
|
||||
vim.list_extend(dirs, get_top_trash_dirs(path))
|
||||
return dirs
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param callback fun(url: string)
|
||||
M.normalize_url = function(url, callback)
|
||||
local scheme, path = util.parse_url(url)
|
||||
assert(path)
|
||||
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ':p')
|
||||
uv.fs_realpath(
|
||||
os_path,
|
||||
vim.schedule_wrap(function(err, new_os_path)
|
||||
local realpath = new_os_path or os_path
|
||||
callback(scheme .. util.addslash(fs.os_to_posix_path(realpath)))
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param entry oil.Entry
|
||||
---@param cb fun(path: string)
|
||||
M.get_entry_path = function(url, entry, cb)
|
||||
local internal_entry = assert(cache.get_entry_by_id(entry.id))
|
||||
local meta = assert(internal_entry[FIELD_META])
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = meta.trash_info
|
||||
if not trash_info then
|
||||
-- This is a subpath in the trash
|
||||
M.normalize_url(url, cb)
|
||||
return
|
||||
end
|
||||
local path = fs.os_to_posix_path(trash_info.trash_file)
|
||||
if meta.stat.type == 'directory' then
|
||||
path = util.addslash(path)
|
||||
end
|
||||
cb('oil://' .. path)
|
||||
end
|
||||
|
||||
---@class oil.TrashInfo
|
||||
---@field trash_file string
|
||||
---@field info_file string
|
||||
---@field original_path string
|
||||
---@field deletion_date number
|
||||
---@field stat uv.aliases.fs_stat_table
|
||||
|
||||
---@param info_file string
|
||||
---@param cb fun(err?: string, info?: oil.TrashInfo)
|
||||
local function read_trash_info(info_file, cb)
|
||||
if not vim.endswith(info_file, '.trashinfo') then
|
||||
return cb('File is not .trashinfo')
|
||||
end
|
||||
uv.fs_open(info_file, 'r', 448, function(err, fd)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
assert(fd)
|
||||
uv.fs_fstat(fd, function(stat_err, stat)
|
||||
if stat_err then
|
||||
uv.fs_close(fd)
|
||||
return cb(stat_err)
|
||||
end
|
||||
uv.fs_read(
|
||||
fd,
|
||||
assert(stat).size,
|
||||
nil,
|
||||
vim.schedule_wrap(function(read_err, content)
|
||||
uv.fs_close(fd)
|
||||
if read_err then
|
||||
return cb(read_err)
|
||||
end
|
||||
assert(content)
|
||||
local trash_info = {
|
||||
info_file = info_file,
|
||||
}
|
||||
local lines = vim.split(content, '\r?\n')
|
||||
if lines[1] ~= '[Trash Info]' then
|
||||
return cb('File missing [Trash Info] header')
|
||||
end
|
||||
local trash_base = vim.fn.fnamemodify(info_file, ':h:h')
|
||||
for _, line in ipairs(lines) do
|
||||
local key, value = unpack(vim.split(line, '=', { plain = true, trimempty = true }))
|
||||
if key == 'Path' and not trash_info.original_path then
|
||||
if not vim.startswith(value, '/') then
|
||||
value = fs.join(trash_base, value)
|
||||
end
|
||||
trash_info.original_path = value
|
||||
elseif key == 'DeletionDate' and not trash_info.deletion_date then
|
||||
trash_info.deletion_date = vim.fn.strptime('%Y-%m-%dT%H:%M:%S', value)
|
||||
end
|
||||
end
|
||||
|
||||
if not trash_info.original_path or not trash_info.deletion_date then
|
||||
return cb('File missing required fields')
|
||||
end
|
||||
|
||||
local basename = vim.fn.fnamemodify(info_file, ':t:r')
|
||||
trash_info.trash_file = fs.join(trash_base, 'files', basename)
|
||||
uv.fs_lstat(trash_info.trash_file, function(trash_stat_err, trash_stat)
|
||||
if trash_stat_err then
|
||||
cb('.trashinfo file points to non-existant file')
|
||||
else
|
||||
trash_info.stat = trash_stat
|
||||
---@cast trash_info oil.TrashInfo
|
||||
cb(nil, trash_info)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
|
||||
M.list = function(url, column_defs, cb)
|
||||
cb = vim.schedule_wrap(cb)
|
||||
local _, path = util.parse_url(url)
|
||||
assert(path)
|
||||
local trash_dirs = get_read_trash_dirs(path)
|
||||
local trash_idx = 0
|
||||
|
||||
local read_next_trash_dir
|
||||
read_next_trash_dir = function()
|
||||
trash_idx = trash_idx + 1
|
||||
local trash_dir = trash_dirs[trash_idx]
|
||||
if not trash_dir then
|
||||
return cb()
|
||||
end
|
||||
|
||||
-- Show all files from the trash directory if we are in the root of the device, which we can
|
||||
-- tell if the trash dir is a subpath of our current path
|
||||
local show_all_files = fs.is_subpath(path, trash_dir)
|
||||
-- The first trash dir is a special case; it is in the home directory and we should only show
|
||||
-- all entries if we are in the top root path "/"
|
||||
if trash_idx == 1 then
|
||||
show_all_files = path == '/'
|
||||
end
|
||||
|
||||
local info_dir = fs.join(trash_dir, 'info')
|
||||
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
|
||||
uv.fs_opendir(info_dir, function(open_err, fd)
|
||||
if open_err then
|
||||
if open_err:match('^ENOENT: no such file or directory') then
|
||||
-- If the directory doesn't exist, treat the list as a success. We will be able to traverse
|
||||
-- and edit a not-yet-existing directory.
|
||||
return read_next_trash_dir()
|
||||
else
|
||||
return cb(open_err)
|
||||
end
|
||||
end
|
||||
local read_next
|
||||
read_next = function()
|
||||
uv.fs_readdir(fd, function(err, entries)
|
||||
if err then
|
||||
uv.fs_closedir(fd, function()
|
||||
cb(err)
|
||||
end)
|
||||
return
|
||||
elseif entries then
|
||||
local internal_entries = {}
|
||||
local poll = util.cb_collect(#entries, function(inner_err)
|
||||
if inner_err then
|
||||
cb(inner_err)
|
||||
else
|
||||
cb(nil, internal_entries, read_next)
|
||||
end
|
||||
end)
|
||||
|
||||
for _, entry in ipairs(entries) do
|
||||
read_trash_info(
|
||||
fs.join(info_dir, entry.name),
|
||||
vim.schedule_wrap(function(read_err, info)
|
||||
if read_err then
|
||||
-- Discard the error. We don't care if there's something wrong with one of these
|
||||
-- files.
|
||||
poll()
|
||||
else
|
||||
local parent = util.addslash(vim.fn.fnamemodify(info.original_path, ':h'))
|
||||
if path == parent or show_all_files then
|
||||
local name = vim.fn.fnamemodify(info.trash_file, ':t')
|
||||
---@diagnostic disable-next-line: undefined-field
|
||||
local cache_entry = cache.create_entry(url, name, info.stat.type)
|
||||
local display_name = vim.fn.fnamemodify(info.original_path, ':t')
|
||||
cache_entry[FIELD_META] = {
|
||||
stat = info.stat,
|
||||
trash_info = info,
|
||||
display_name = display_name,
|
||||
}
|
||||
table.insert(internal_entries, cache_entry)
|
||||
end
|
||||
if path ~= parent and (show_all_files or fs.is_subpath(path, parent)) then
|
||||
local name = parent:sub(path:len() + 1)
|
||||
local next_par = vim.fs.dirname(name)
|
||||
while next_par ~= '.' do
|
||||
name = next_par
|
||||
next_par = vim.fs.dirname(name)
|
||||
end
|
||||
---@diagnostic disable-next-line: undefined-field
|
||||
local cache_entry = cache.create_entry(url, name, 'directory')
|
||||
|
||||
cache_entry[FIELD_META] = {
|
||||
stat = info.stat,
|
||||
}
|
||||
table.insert(internal_entries, cache_entry)
|
||||
end
|
||||
poll()
|
||||
end
|
||||
end)
|
||||
)
|
||||
end
|
||||
else
|
||||
uv.fs_closedir(fd, function(close_err)
|
||||
if close_err then
|
||||
cb(close_err)
|
||||
else
|
||||
vim.schedule(read_next_trash_dir)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
read_next()
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
end, 10000)
|
||||
end
|
||||
read_next_trash_dir()
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return boolean
|
||||
M.is_modifiable = function(bufnr)
|
||||
return true
|
||||
end
|
||||
|
||||
local file_columns = {}
|
||||
|
||||
local current_year
|
||||
-- Make sure we run this import-time effect in the main loop (mostly for tests)
|
||||
vim.schedule(function()
|
||||
current_year = vim.fn.strftime('%Y')
|
||||
end)
|
||||
|
||||
file_columns.mtime = {
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
if not meta then
|
||||
return nil
|
||||
end
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = meta.trash_info
|
||||
local time = trash_info and trash_info.deletion_date or meta.stat and meta.stat.mtime.sec
|
||||
if not time then
|
||||
return nil
|
||||
end
|
||||
local fmt = conf and conf.format
|
||||
local ret
|
||||
if fmt then
|
||||
ret = vim.fn.strftime(fmt, time)
|
||||
else
|
||||
local year = vim.fn.strftime('%Y', time)
|
||||
if year ~= current_year then
|
||||
ret = vim.fn.strftime('%b %d %Y', time)
|
||||
else
|
||||
ret = vim.fn.strftime('%b %d %H:%M', time)
|
||||
end
|
||||
end
|
||||
return ret
|
||||
end,
|
||||
|
||||
get_sort_value = function(entry)
|
||||
local meta = entry[FIELD_META]
|
||||
---@type nil|oil.TrashInfo
|
||||
local trash_info = meta and meta.trash_info
|
||||
if trash_info then
|
||||
return trash_info.deletion_date
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
local fmt = conf and conf.format
|
||||
local pattern
|
||||
if fmt then
|
||||
pattern = fmt:gsub('%%.', '%%S+')
|
||||
else
|
||||
pattern = '%S+%s+%d+%s+%d%d:?%d%d'
|
||||
end
|
||||
return line:match('^(' .. pattern .. ')%s+(.+)$')
|
||||
end,
|
||||
}
|
||||
|
||||
---@param name string
|
||||
---@return nil|oil.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return file_columns[name]
|
||||
end
|
||||
|
||||
M.supported_cross_adapter_actions = { files = 'move' }
|
||||
|
||||
---@param action oil.Action
|
||||
---@return boolean
|
||||
M.filter_action = function(action)
|
||||
if action.type == 'create' then
|
||||
return false
|
||||
elseif action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META]
|
||||
return meta ~= nil and meta.trash_info ~= nil
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
return src_adapter.name == 'files' or dest_adapter.name == 'files'
|
||||
-- selene: allow(if_same_then_else)
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
return src_adapter.name == 'files' or dest_adapter.name == 'files'
|
||||
else
|
||||
error(string.format("Bad action type '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param err oil.ParseError
|
||||
---@return boolean
|
||||
M.filter_error = function(err)
|
||||
if err.message == 'Duplicate filename' then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
if action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META]
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = assert(meta).trash_info
|
||||
local short_path = fs.shorten_path(trash_info.original_path)
|
||||
return string.format(' PURGE %s', short_path)
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format(' TRASH %s', short_path)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format('RESTORE %s', short_path)
|
||||
else
|
||||
error('Must be moving files into or out of trash')
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format(' COPY %s -> TRASH', short_path)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format('RESTORE %s', short_path)
|
||||
else
|
||||
error('Must be copying files into or out of trash')
|
||||
end
|
||||
else
|
||||
error(string.format("Bad action type '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param trash_info oil.TrashInfo
|
||||
---@param cb fun(err?: string)
|
||||
local function purge(trash_info, cb)
|
||||
fs.recursive_delete('file', trash_info.info_file, function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
---@diagnostic disable-next-line: undefined-field
|
||||
fs.recursive_delete(trash_info.stat.type, trash_info.trash_file, cb)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param info_path string
|
||||
---@param cb fun(err?: string)
|
||||
local function write_info_file(path, info_path, cb)
|
||||
uv.fs_open(
|
||||
info_path,
|
||||
'w',
|
||||
448,
|
||||
vim.schedule_wrap(function(err, fd)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
assert(fd)
|
||||
local deletion_date = vim.fn.strftime('%Y-%m-%dT%H:%M:%S')
|
||||
local contents = string.format('[Trash Info]\nPath=%s\nDeletionDate=%s', path, deletion_date)
|
||||
uv.fs_write(fd, contents, function(write_err)
|
||||
uv.fs_close(fd, function(close_err)
|
||||
cb(write_err or close_err)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err?: string, trash_info?: oil.TrashInfo)
|
||||
local function create_trash_info(path, cb)
|
||||
local trash_dir = get_write_trash_dir(path)
|
||||
local basename = vim.fs.basename(path)
|
||||
local now = os.time()
|
||||
local name = string.format('%s-%d.%d', basename, now, math.random(100000, 999999))
|
||||
local dest_path = fs.join(trash_dir, 'files', name)
|
||||
local dest_info = fs.join(trash_dir, 'info', name .. '.trashinfo')
|
||||
uv.fs_lstat(path, function(err, stat)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
assert(stat)
|
||||
write_info_file(path, dest_info, function(info_err)
|
||||
if info_err then
|
||||
return cb(info_err)
|
||||
end
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = {
|
||||
original_path = path,
|
||||
trash_file = dest_path,
|
||||
info_file = dest_info,
|
||||
deletion_date = now,
|
||||
stat = stat,
|
||||
}
|
||||
cb(nil, trash_info)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@param cb fun(err: nil|string)
|
||||
M.perform_action = function(action, cb)
|
||||
if action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META]
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = assert(meta).trash_info
|
||||
purge(trash_info, cb)
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
M.delete_to_trash(assert(path), cb)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
-- Restore
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
local entry = assert(cache.get_entry_by_url(action.src_url))
|
||||
local meta = entry[FIELD_META]
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = assert(meta).trash_info
|
||||
fs.recursive_move(action.entry_type, trash_info.trash_file, dest_path, function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
uv.fs_unlink(trash_info.info_file, cb)
|
||||
end)
|
||||
else
|
||||
error('Must be moving files into or out of trash')
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
create_trash_info(path, function(err, trash_info)
|
||||
if err then
|
||||
cb(err)
|
||||
else
|
||||
local stat_type = trash_info.stat.type or 'unknown'
|
||||
fs.recursive_copy(stat_type, path, trash_info.trash_file, vim.schedule_wrap(cb))
|
||||
end
|
||||
end)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
-- Restore
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
local entry = assert(cache.get_entry_by_url(action.src_url))
|
||||
local meta = entry[FIELD_META]
|
||||
---@type oil.TrashInfo
|
||||
local trash_info = assert(meta).trash_info
|
||||
fs.recursive_copy(action.entry_type, trash_info.trash_file, dest_path, cb)
|
||||
else
|
||||
error('Must be moving files into or out of trash')
|
||||
end
|
||||
else
|
||||
cb(string.format('Bad action type: %s', action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err?: string)
|
||||
M.delete_to_trash = function(path, cb)
|
||||
create_trash_info(path, function(err, trash_info)
|
||||
if err then
|
||||
cb(err)
|
||||
else
|
||||
local stat_type = trash_info.stat.type or 'unknown'
|
||||
fs.recursive_move(stat_type, path, trash_info.trash_file, vim.schedule_wrap(cb))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
232
lua/oil/adapters/trash/mac.lua
Normal file
232
lua/oil/adapters/trash/mac.lua
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
local cache = require('oil.cache')
|
||||
local config = require('oil.config')
|
||||
local files = require('oil.adapters.files')
|
||||
local fs = require('oil.fs')
|
||||
local util = require('oil.util')
|
||||
|
||||
local uv = vim.uv or vim.loop
|
||||
|
||||
local M = {}
|
||||
|
||||
local function touch_dir(path)
|
||||
uv.fs_mkdir(path, 448) -- 0700
|
||||
end
|
||||
|
||||
---Gets the location of the home trash dir, creating it if necessary
|
||||
---@return string
|
||||
local function get_trash_dir()
|
||||
local trash_dir = fs.join(assert(uv.os_homedir()), '.Trash')
|
||||
touch_dir(trash_dir)
|
||||
return trash_dir
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param callback fun(url: string)
|
||||
M.normalize_url = function(url, callback)
|
||||
local scheme, path = util.parse_url(url)
|
||||
assert(path)
|
||||
callback(scheme .. '/')
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param entry oil.Entry
|
||||
---@param cb fun(path: string)
|
||||
M.get_entry_path = function(url, entry, cb)
|
||||
local trash_dir = get_trash_dir()
|
||||
local path = fs.join(trash_dir, entry.name)
|
||||
if entry.type == 'directory' then
|
||||
path = 'oil://' .. path
|
||||
end
|
||||
cb(path)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
|
||||
M.list = function(url, column_defs, cb)
|
||||
cb = vim.schedule_wrap(cb)
|
||||
local _, path = util.parse_url(url)
|
||||
assert(path)
|
||||
local trash_dir = get_trash_dir()
|
||||
---@diagnostic disable-next-line: param-type-mismatch, discard-returns
|
||||
uv.fs_opendir(trash_dir, function(open_err, fd)
|
||||
if open_err then
|
||||
if open_err:match('^ENOENT: no such file or directory') then
|
||||
-- If the directory doesn't exist, treat the list as a success. We will be able to traverse
|
||||
-- and edit a not-yet-existing directory.
|
||||
return cb()
|
||||
else
|
||||
return cb(open_err)
|
||||
end
|
||||
end
|
||||
local read_next
|
||||
read_next = function()
|
||||
uv.fs_readdir(fd, function(err, entries)
|
||||
if err then
|
||||
uv.fs_closedir(fd, function()
|
||||
cb(err)
|
||||
end)
|
||||
return
|
||||
elseif entries then
|
||||
local internal_entries = {}
|
||||
local poll = util.cb_collect(#entries, function(inner_err)
|
||||
if inner_err then
|
||||
cb(inner_err)
|
||||
else
|
||||
cb(nil, internal_entries, read_next)
|
||||
end
|
||||
end)
|
||||
|
||||
for _, entry in ipairs(entries) do
|
||||
-- TODO: read .DS_Store and filter by original dir
|
||||
local cache_entry = cache.create_entry(url, entry.name, entry.type)
|
||||
table.insert(internal_entries, cache_entry)
|
||||
poll()
|
||||
end
|
||||
else
|
||||
uv.fs_closedir(fd, function(close_err)
|
||||
if close_err then
|
||||
cb(close_err)
|
||||
else
|
||||
cb()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
read_next()
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
end, 10000)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return boolean
|
||||
M.is_modifiable = function(bufnr)
|
||||
return true
|
||||
end
|
||||
|
||||
---@param name string
|
||||
---@return nil|oil.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return nil
|
||||
end
|
||||
|
||||
M.supported_cross_adapter_actions = { files = 'move' }
|
||||
|
||||
---@param action oil.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
if action.type == 'create' then
|
||||
return string.format('CREATE %s', action.url)
|
||||
elseif action.type == 'delete' then
|
||||
return string.format(' PURGE %s', action.url)
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format(' TRASH %s', short_path)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format('RESTORE %s', short_path)
|
||||
else
|
||||
return string.format(' %s %s -> %s', action.type:upper(), action.src_url, action.dest_url)
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
return string.format(' %s %s -> %s', action.type:upper(), action.src_url, action.dest_url)
|
||||
else
|
||||
error('Bad action type')
|
||||
end
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@param cb fun(err: nil|string)
|
||||
M.perform_action = function(action, cb)
|
||||
local trash_dir = get_trash_dir()
|
||||
if action.type == 'create' then
|
||||
local _, path = util.parse_url(action.url)
|
||||
assert(path)
|
||||
path = trash_dir .. path
|
||||
if action.entry_type == 'directory' then
|
||||
uv.fs_mkdir(path, 493, function(err)
|
||||
-- Ignore if the directory already exists
|
||||
if not err or err:match('^EEXIST:') then
|
||||
cb()
|
||||
else
|
||||
cb(err)
|
||||
end
|
||||
end) -- 0755
|
||||
elseif action.entry_type == 'link' and action.link then
|
||||
local flags = nil
|
||||
local target = fs.posix_to_os_path(action.link)
|
||||
---@diagnostic disable-next-line: param-type-mismatch
|
||||
uv.fs_symlink(target, path, flags, cb)
|
||||
else
|
||||
fs.touch(path, config.new_file_mode, cb)
|
||||
end
|
||||
elseif action.type == 'delete' then
|
||||
local _, path = util.parse_url(action.url)
|
||||
assert(path)
|
||||
local fullpath = trash_dir .. path
|
||||
fs.recursive_delete(action.entry_type, fullpath, cb)
|
||||
elseif action.type == 'move' or action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
local _, src_path = util.parse_url(action.src_url)
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(src_path and dest_path)
|
||||
if src_adapter.name == 'files' then
|
||||
dest_path = trash_dir .. dest_path
|
||||
elseif dest_adapter.name == 'files' then
|
||||
src_path = trash_dir .. src_path
|
||||
else
|
||||
dest_path = trash_dir .. dest_path
|
||||
src_path = trash_dir .. src_path
|
||||
end
|
||||
|
||||
if action.type == 'move' then
|
||||
fs.recursive_move(action.entry_type, src_path, dest_path, cb)
|
||||
else
|
||||
fs.recursive_copy(action.entry_type, src_path, dest_path, cb)
|
||||
end
|
||||
else
|
||||
cb(string.format('Bad action type: %s', action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err?: string)
|
||||
M.delete_to_trash = function(path, cb)
|
||||
local basename = vim.fs.basename(path)
|
||||
local trash_dir = get_trash_dir()
|
||||
local dest = fs.join(trash_dir, basename)
|
||||
uv.fs_lstat(
|
||||
path,
|
||||
vim.schedule_wrap(function(stat_err, src_stat)
|
||||
if stat_err then
|
||||
return cb(stat_err)
|
||||
end
|
||||
assert(src_stat)
|
||||
if uv.fs_lstat(dest) then
|
||||
local date_str = vim.fn.strftime(' %Y-%m-%dT%H:%M:%S')
|
||||
local name_pieces = vim.split(basename, '.', { plain = true })
|
||||
if #name_pieces > 1 then
|
||||
table.insert(name_pieces, #name_pieces - 1, date_str)
|
||||
basename = table.concat(name_pieces)
|
||||
else
|
||||
basename = basename .. date_str
|
||||
end
|
||||
dest = fs.join(trash_dir, basename)
|
||||
end
|
||||
|
||||
local stat_type = src_stat.type
|
||||
fs.recursive_move(stat_type, path, dest, vim.schedule_wrap(cb))
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
return M
|
||||
411
lua/oil/adapters/trash/windows.lua
Normal file
411
lua/oil/adapters/trash/windows.lua
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
local util = require('oil.util')
|
||||
local uv = vim.uv or vim.loop
|
||||
local cache = require('oil.cache')
|
||||
local config = require('oil.config')
|
||||
local constants = require('oil.constants')
|
||||
local files = require('oil.adapters.files')
|
||||
local fs = require('oil.fs')
|
||||
local powershell_trash = require('oil.adapters.trash.windows.powershell-trash')
|
||||
|
||||
local FIELD_META = constants.FIELD_META
|
||||
local FIELD_TYPE = constants.FIELD_TYPE
|
||||
|
||||
local M = {}
|
||||
|
||||
---@return string
|
||||
local function get_trash_dir()
|
||||
local cwd = assert(vim.fn.getcwd())
|
||||
local trash_dir = cwd:sub(1, 3) .. '$Recycle.Bin'
|
||||
if vim.fn.isdirectory(trash_dir) == 1 then
|
||||
return trash_dir
|
||||
end
|
||||
trash_dir = 'C:\\$Recycle.Bin'
|
||||
if vim.fn.isdirectory(trash_dir) == 1 then
|
||||
return trash_dir
|
||||
end
|
||||
error('No trash found')
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return string
|
||||
local win_addslash = function(path)
|
||||
if not vim.endswith(path, '\\') then
|
||||
return path .. '\\'
|
||||
else
|
||||
return path
|
||||
end
|
||||
end
|
||||
|
||||
---@class oil.WindowsTrashInfo
|
||||
---@field trash_file string
|
||||
---@field original_path string
|
||||
---@field deletion_date integer
|
||||
---@field info_file? string
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
|
||||
M.list = function(url, column_defs, cb)
|
||||
local _, path = util.parse_url(url)
|
||||
path = fs.posix_to_os_path(assert(path))
|
||||
|
||||
local trash_dir = get_trash_dir()
|
||||
local show_all_files = fs.is_subpath(path, trash_dir)
|
||||
|
||||
powershell_trash.list_raw_entries(function(err, raw_entries)
|
||||
if err then
|
||||
cb(err)
|
||||
return
|
||||
end
|
||||
|
||||
local raw_displayed_entries = vim.tbl_filter(
|
||||
---@param entry {IsFolder: boolean, DeletionDate: integer, Name: string, Path: string, OriginalPath: string}
|
||||
function(entry)
|
||||
local parent = win_addslash(assert(vim.fn.fnamemodify(entry.OriginalPath, ':h')))
|
||||
local is_in_path = path == parent
|
||||
local is_subpath = fs.is_subpath(path, parent)
|
||||
return is_in_path or is_subpath or show_all_files
|
||||
end,
|
||||
raw_entries
|
||||
)
|
||||
local displayed_entries = vim.tbl_map(
|
||||
---@param entry {IsFolder: boolean, DeletionDate: integer, Name: string, Path: string, OriginalPath: string}
|
||||
---@return {[1]:nil, [2]:string, [3]:string, [4]:{stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}}
|
||||
function(entry)
|
||||
local parent = win_addslash(assert(vim.fn.fnamemodify(entry.OriginalPath, ':h')))
|
||||
|
||||
--- @type oil.InternalEntry
|
||||
local cache_entry
|
||||
if path == parent or show_all_files then
|
||||
local deleted_file_tail = assert(vim.fn.fnamemodify(entry.Path, ':t'))
|
||||
local deleted_file_head = assert(vim.fn.fnamemodify(entry.Path, ':h'))
|
||||
local info_file_head = deleted_file_head
|
||||
--- @type string?
|
||||
local info_file
|
||||
cache_entry =
|
||||
cache.create_entry(url, deleted_file_tail, entry.IsFolder and 'directory' or 'file')
|
||||
-- info_file on windows has the following format: $I<6 char hash>.<extension>
|
||||
-- the hash is the same for the deleted file and the info file
|
||||
-- so, we take the hash (and extension) from the deleted file
|
||||
--
|
||||
-- see https://superuser.com/questions/368890/how-does-the-recycle-bin-in-windows-work/1736690#1736690
|
||||
local info_file_tail = deleted_file_tail:match('^%$R(.*)$') --[[@as string?]]
|
||||
if info_file_tail then
|
||||
info_file_tail = '$I' .. info_file_tail
|
||||
info_file = info_file_head .. '\\' .. info_file_tail
|
||||
end
|
||||
cache_entry[FIELD_META] = {
|
||||
stat = nil,
|
||||
---@type oil.WindowsTrashInfo
|
||||
trash_info = {
|
||||
trash_file = entry.Path,
|
||||
original_path = entry.OriginalPath,
|
||||
deletion_date = entry.DeletionDate,
|
||||
info_file = info_file,
|
||||
},
|
||||
display_name = entry.Name,
|
||||
}
|
||||
end
|
||||
if path ~= parent and (show_all_files or fs.is_subpath(path, parent)) then
|
||||
local name = parent:sub(path:len() + 1)
|
||||
local next_par = vim.fs.dirname(name)
|
||||
while next_par ~= '.' do
|
||||
name = next_par
|
||||
next_par = vim.fs.dirname(name)
|
||||
cache_entry = cache.create_entry(url, name, 'directory')
|
||||
|
||||
cache_entry[FIELD_META] = {}
|
||||
end
|
||||
end
|
||||
return cache_entry
|
||||
end,
|
||||
raw_displayed_entries
|
||||
)
|
||||
cb(nil, displayed_entries)
|
||||
end)
|
||||
end
|
||||
|
||||
M.is_modifiable = function(_bufnr)
|
||||
return true
|
||||
end
|
||||
|
||||
local current_year
|
||||
-- Make sure we run this import-time effect in the main loop (mostly for tests)
|
||||
vim.schedule(function()
|
||||
current_year = vim.fn.strftime('%Y')
|
||||
end)
|
||||
|
||||
local file_columns = {}
|
||||
file_columns.mtime = {
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD_META]
|
||||
if not meta then
|
||||
return nil
|
||||
end
|
||||
---@type oil.WindowsTrashInfo
|
||||
local trash_info = meta.trash_info
|
||||
local time = trash_info and trash_info.deletion_date
|
||||
if not time then
|
||||
return nil
|
||||
end
|
||||
local fmt = conf and conf.format
|
||||
local ret
|
||||
if fmt then
|
||||
ret = vim.fn.strftime(fmt, time)
|
||||
else
|
||||
local year = vim.fn.strftime('%Y', time)
|
||||
if year ~= current_year then
|
||||
ret = vim.fn.strftime('%b %d %Y', time)
|
||||
else
|
||||
ret = vim.fn.strftime('%b %d %H:%M', time)
|
||||
end
|
||||
end
|
||||
return ret
|
||||
end,
|
||||
|
||||
get_sort_value = function(entry)
|
||||
local meta = entry[FIELD_META]
|
||||
---@type nil|oil.WindowsTrashInfo
|
||||
local trash_info = meta and meta.trash_info
|
||||
if trash_info and trash_info.deletion_date then
|
||||
return trash_info.deletion_date
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
local fmt = conf and conf.format
|
||||
local pattern
|
||||
if fmt then
|
||||
pattern = fmt:gsub('%%.', '%%S+')
|
||||
else
|
||||
pattern = '%S+%s+%d+%s+%d%d:?%d%d'
|
||||
end
|
||||
return line:match('^(' .. pattern .. ')%s+(.+)$')
|
||||
end,
|
||||
}
|
||||
|
||||
---@param name string
|
||||
---@return nil|oil.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return file_columns[name]
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@return boolean
|
||||
M.filter_action = function(action)
|
||||
if action.type == 'create' then
|
||||
return false
|
||||
elseif action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META]
|
||||
return meta ~= nil and meta.trash_info ~= nil
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
return src_adapter.name == 'files' or dest_adapter.name == 'files'
|
||||
-- selene: allow(if_same_then_else)
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
return src_adapter.name == 'files' or dest_adapter.name == 'files'
|
||||
else
|
||||
error(string.format("Bad action type '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param callback fun(url: string)
|
||||
M.normalize_url = function(url, callback)
|
||||
local scheme, path = util.parse_url(url)
|
||||
assert(path)
|
||||
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ':p')
|
||||
assert(os_path)
|
||||
uv.fs_realpath(
|
||||
os_path,
|
||||
vim.schedule_wrap(function(_err, new_os_path)
|
||||
local realpath = new_os_path or os_path
|
||||
callback(scheme .. util.addslash(fs.os_to_posix_path(realpath)))
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param entry oil.Entry
|
||||
---@param cb fun(path: string)
|
||||
M.get_entry_path = function(url, entry, cb)
|
||||
local internal_entry = assert(cache.get_entry_by_id(entry.id))
|
||||
local meta = internal_entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
|
||||
local trash_info = meta and meta.trash_info
|
||||
if not trash_info then
|
||||
-- This is a subpath in the trash
|
||||
M.normalize_url(url, cb)
|
||||
return
|
||||
end
|
||||
|
||||
local path = fs.os_to_posix_path(trash_info.trash_file)
|
||||
if entry.type == 'directory' then
|
||||
path = win_addslash(path)
|
||||
end
|
||||
cb('oil://' .. path)
|
||||
end
|
||||
|
||||
---@param err oil.ParseError
|
||||
---@return boolean
|
||||
M.filter_error = function(err)
|
||||
if err.message == 'Duplicate filename' then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
if action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META]
|
||||
---@type oil.WindowsTrashInfo
|
||||
local trash_info = assert(meta).trash_info
|
||||
local short_path = fs.shorten_path(trash_info.original_path)
|
||||
return string.format(' PURGE %s', short_path)
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format(' TRASH %s', short_path)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format('RESTORE %s', short_path)
|
||||
else
|
||||
error('Must be moving files into or out of trash')
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format(' COPY %s -> TRASH', short_path)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
assert(path)
|
||||
local short_path = files.to_short_os_path(path, action.entry_type)
|
||||
return string.format('RESTORE %s', short_path)
|
||||
else
|
||||
error('Must be copying files into or out of trash')
|
||||
end
|
||||
else
|
||||
error(string.format("Bad action type '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
---@param trash_info oil.WindowsTrashInfo
|
||||
---@param cb fun(err?: string, raw_entries: oil.WindowsRawEntry[]?)
|
||||
local purge = function(trash_info, cb)
|
||||
fs.recursive_delete('file', trash_info.info_file, function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
fs.recursive_delete('file', trash_info.trash_file, cb)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param type string
|
||||
---@param cb fun(err?: string, trash_info?: oil.TrashInfo)
|
||||
local function create_trash_info_and_copy(path, type, cb)
|
||||
local temp_path = path .. 'temp'
|
||||
-- create a temporary copy on the same location
|
||||
fs.recursive_copy(
|
||||
type,
|
||||
path,
|
||||
temp_path,
|
||||
vim.schedule_wrap(function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
-- delete original file
|
||||
M.delete_to_trash(path, function(err2)
|
||||
if err2 then
|
||||
return cb(err2)
|
||||
end
|
||||
-- rename temporary copy to the original file name
|
||||
fs.recursive_move(type, temp_path, path, cb)
|
||||
end)
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@param cb fun(err: nil|string)
|
||||
M.perform_action = function(action, cb)
|
||||
if action.type == 'delete' then
|
||||
local entry = assert(cache.get_entry_by_url(action.url))
|
||||
local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
|
||||
local trash_info = meta and meta.trash_info
|
||||
|
||||
purge(trash_info, cb)
|
||||
elseif action.type == 'move' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
M.delete_to_trash(assert(path), cb)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
-- Restore
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
dest_path = fs.posix_to_os_path(dest_path)
|
||||
local entry = assert(cache.get_entry_by_url(action.src_url))
|
||||
local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
|
||||
local trash_info = meta and meta.trash_info
|
||||
fs.recursive_move(action.entry_type, trash_info.trash_file, dest_path, function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
uv.fs_unlink(trash_info.info_file, cb)
|
||||
end)
|
||||
end
|
||||
elseif action.type == 'copy' then
|
||||
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
|
||||
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
|
||||
if src_adapter.name == 'files' then
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
assert(path)
|
||||
path = fs.posix_to_os_path(path)
|
||||
local entry = assert(cache.get_entry_by_url(action.src_url))
|
||||
create_trash_info_and_copy(path, entry[FIELD_TYPE], cb)
|
||||
elseif dest_adapter.name == 'files' then
|
||||
-- Restore
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
assert(dest_path)
|
||||
dest_path = fs.posix_to_os_path(dest_path)
|
||||
local entry = assert(cache.get_entry_by_url(action.src_url))
|
||||
local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]]
|
||||
local trash_info = meta and meta.trash_info
|
||||
fs.recursive_copy(action.entry_type, trash_info.trash_file, dest_path, cb)
|
||||
else
|
||||
error('Must be moving files into or out of trash')
|
||||
end
|
||||
else
|
||||
cb(string.format('Bad action type: %s', action.type))
|
||||
end
|
||||
end
|
||||
|
||||
M.supported_cross_adapter_actions = { files = 'move' }
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err?: string)
|
||||
M.delete_to_trash = function(path, cb)
|
||||
powershell_trash.delete_to_trash(path, cb)
|
||||
end
|
||||
|
||||
return M
|
||||
123
lua/oil/adapters/trash/windows/powershell-connection.lua
Normal file
123
lua/oil/adapters/trash/windows/powershell-connection.lua
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
---@class (exact) oil.PowershellCommand
|
||||
---@field cmd string
|
||||
---@field cb fun(err?: string, output?: string)
|
||||
---@field running? boolean
|
||||
|
||||
---@class oil.PowershellConnection
|
||||
---@field private jid integer
|
||||
---@field private execution_error? string
|
||||
---@field private commands oil.PowershellCommand[]
|
||||
---@field private stdout string[]
|
||||
---@field private is_reading_data boolean
|
||||
local PowershellConnection = {}
|
||||
|
||||
---@param init_command? string
|
||||
---@return oil.PowershellConnection
|
||||
function PowershellConnection.new(init_command)
|
||||
local self = setmetatable({
|
||||
commands = {},
|
||||
stdout = {},
|
||||
is_reading_data = false,
|
||||
}, { __index = PowershellConnection })
|
||||
|
||||
self:_init(init_command)
|
||||
|
||||
---@type oil.PowershellConnection
|
||||
return self
|
||||
end
|
||||
|
||||
---@param init_command? string
|
||||
function PowershellConnection:_init(init_command)
|
||||
-- For some reason beyond my understanding, at least one of the following
|
||||
-- things requires `noshellslash` to avoid the embeded powershell process to
|
||||
-- send only "" to the stdout (never calling the callback because
|
||||
-- "===DONE(True)===" is never sent to stdout)
|
||||
-- * vim.fn.jobstart
|
||||
-- * cmd.exe
|
||||
-- * powershell.exe
|
||||
local saved_shellslash = vim.o.shellslash
|
||||
vim.o.shellslash = false
|
||||
|
||||
-- 65001 is the UTF-8 codepage
|
||||
-- powershell needs to be launched with the UTF-8 codepage to use it for both stdin and stdout
|
||||
local jid = vim.fn.jobstart({
|
||||
'cmd',
|
||||
'/c',
|
||||
'"chcp 65001 && powershell -NoProfile -NoLogo -ExecutionPolicy Bypass -NoExit -Command -"',
|
||||
}, {
|
||||
---@param data string[]
|
||||
on_stdout = function(_, data)
|
||||
for _, fragment in ipairs(data) do
|
||||
if fragment:find('===DONE%((%a+)%)===') then
|
||||
self.is_reading_data = false
|
||||
local output = table.concat(self.stdout, '')
|
||||
local cb = self.commands[1].cb
|
||||
table.remove(self.commands, 1)
|
||||
local success = fragment:match('===DONE%((%a+)%)===')
|
||||
if success == 'True' then
|
||||
cb(nil, output)
|
||||
elseif success == 'False' then
|
||||
cb(success .. ': ' .. output, output)
|
||||
end
|
||||
self.stdout = {}
|
||||
self:_consume()
|
||||
elseif self.is_reading_data then
|
||||
table.insert(self.stdout, fragment)
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
vim.o.shellslash = saved_shellslash
|
||||
|
||||
if jid == 0 then
|
||||
self:_set_error("passed invalid arguments to 'powershell'")
|
||||
elseif jid == -1 then
|
||||
self:_set_error("'powershell' is not executable")
|
||||
else
|
||||
self.jid = jid
|
||||
end
|
||||
|
||||
if init_command then
|
||||
table.insert(self.commands, { cmd = init_command, cb = function() end })
|
||||
self:_consume()
|
||||
end
|
||||
end
|
||||
|
||||
---@param command string
|
||||
---@param cb fun(err?: string, output?: string[])
|
||||
function PowershellConnection:run(command, cb)
|
||||
if self.execution_error then
|
||||
cb(self.execution_error)
|
||||
else
|
||||
table.insert(self.commands, { cmd = command, cb = cb })
|
||||
self:_consume()
|
||||
end
|
||||
end
|
||||
|
||||
function PowershellConnection:_consume()
|
||||
if not vim.tbl_isempty(self.commands) then
|
||||
local cmd = self.commands[1]
|
||||
if not cmd.running then
|
||||
cmd.running = true
|
||||
self.is_reading_data = true
|
||||
-- $? contains the execution status of the last command.
|
||||
-- see https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables?view=powershell-7.4#section-1
|
||||
vim.api.nvim_chan_send(self.jid, cmd.cmd .. '\nWrite-Host "===DONE($?)==="\n')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param err string
|
||||
function PowershellConnection:_set_error(err)
|
||||
if self.execution_error then
|
||||
return
|
||||
end
|
||||
self.execution_error = err
|
||||
local commands = self.commands
|
||||
self.commands = {}
|
||||
for _, cmd in ipairs(commands) do
|
||||
cmd.cb(err)
|
||||
end
|
||||
end
|
||||
|
||||
return PowershellConnection
|
||||
78
lua/oil/adapters/trash/windows/powershell-trash.lua
Normal file
78
lua/oil/adapters/trash/windows/powershell-trash.lua
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
-- A wrapper around trash operations using windows powershell
|
||||
local Powershell = require('oil.adapters.trash.windows.powershell-connection')
|
||||
|
||||
---@class oil.WindowsRawEntry
|
||||
---@field IsFolder boolean
|
||||
---@field DeletionDate integer
|
||||
---@field Name string
|
||||
---@field Path string
|
||||
---@field OriginalPath string
|
||||
|
||||
local M = {}
|
||||
|
||||
-- 0xa is the constant for Recycle Bin. See https://learn.microsoft.com/en-us/windows/win32/api/shldisp/ne-shldisp-shellspecialfolderconstants
|
||||
local list_entries_init = [[
|
||||
$shell = New-Object -ComObject 'Shell.Application'
|
||||
$folder = $shell.NameSpace(0xa)
|
||||
]]
|
||||
|
||||
local list_entries_cmd = [[
|
||||
$data = @(foreach ($i in $folder.items())
|
||||
{
|
||||
@{
|
||||
IsFolder=$i.IsFolder;
|
||||
DeletionDate=([DateTimeOffset]$i.extendedproperty('datedeleted')).ToUnixTimeSeconds();
|
||||
Name=$i.Name;
|
||||
Path=$i.Path;
|
||||
OriginalPath=-join($i.ExtendedProperty('DeletedFrom'), "\", $i.Name)
|
||||
}
|
||||
})
|
||||
ConvertTo-Json $data -Compress
|
||||
]]
|
||||
|
||||
---@type nil|oil.PowershellConnection
|
||||
local list_entries_powershell
|
||||
|
||||
---@param cb fun(err?: string, raw_entries?: oil.WindowsRawEntry[])
|
||||
M.list_raw_entries = function(cb)
|
||||
if not list_entries_powershell then
|
||||
list_entries_powershell = Powershell.new(list_entries_init)
|
||||
end
|
||||
list_entries_powershell:run(list_entries_cmd, function(err, string)
|
||||
if err then
|
||||
cb(err)
|
||||
return
|
||||
end
|
||||
|
||||
local ok, value = pcall(vim.json.decode, string)
|
||||
if not ok then
|
||||
cb(value)
|
||||
return
|
||||
end
|
||||
cb(nil, value)
|
||||
end)
|
||||
end
|
||||
|
||||
-- 0 is the constant for Windows Desktop. See https://learn.microsoft.com/en-us/windows/win32/api/shldisp/ne-shldisp-shellspecialfolderconstants
|
||||
local delete_init = [[
|
||||
$shell = New-Object -ComObject 'Shell.Application'
|
||||
$folder = $shell.NameSpace(0)
|
||||
]]
|
||||
local delete_cmd = [[
|
||||
$path = Get-Item '%s'
|
||||
$folder.ParseName($path.FullName).InvokeVerb('delete')
|
||||
]]
|
||||
|
||||
---@type nil|oil.PowershellConnection
|
||||
local delete_to_trash_powershell
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err?: string)
|
||||
M.delete_to_trash = function(path, cb)
|
||||
if not delete_to_trash_powershell then
|
||||
delete_to_trash_powershell = Powershell.new(delete_init)
|
||||
end
|
||||
delete_to_trash_powershell:run((delete_cmd):format(path:gsub("'", "''")), cb)
|
||||
end
|
||||
|
||||
return M
|
||||
206
lua/oil/cache.lua
Normal file
206
lua/oil/cache.lua
Normal 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
370
lua/oil/clipboard.lua
Normal 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
287
lua/oil/columns.lua
Normal 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
529
lua/oil/config.lua
Normal 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
13
lua/oil/constants.lua
Normal 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
385
lua/oil/fs.lua
Normal 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
118
lua/oil/git.lua
Normal 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
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
167
lua/oil/keymap_util.lua
Normal 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
198
lua/oil/layout.lua
Normal 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
89
lua/oil/loading.lua
Normal 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
126
lua/oil/log.lua
Normal 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
121
lua/oil/lsp/helpers.lua
Normal 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
351
lua/oil/lsp/workspace.lua
Normal 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
|
||||
205
lua/oil/mutator/confirmation.lua
Normal file
205
lua/oil/mutator/confirmation.lua
Normal 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
626
lua/oil/mutator/init.lua
Normal 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
321
lua/oil/mutator/parser.lua
Normal 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
|
||||
223
lua/oil/mutator/progress.lua
Normal file
223
lua/oil/mutator/progress.lua
Normal 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
160
lua/oil/mutator/trie.lua
Normal 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
29
lua/oil/pathutil.lua
Normal 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
37
lua/oil/ringbuf.lua
Normal 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
48
lua/oil/shell.lua
Normal 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
1080
lua/oil/util.lua
Normal file
File diff suppressed because it is too large
Load diff
1157
lua/oil/view.lua
Normal file
1157
lua/oil/view.lua
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue