feat: first draft
This commit is contained in:
parent
bf2dfb970d
commit
fefd6ad5e4
48 changed files with 7201 additions and 1 deletions
87
lua/oil/actions.lua
Normal file
87
lua/oil/actions.lua
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
local oil = require("oil")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.show_help = {
|
||||
desc = "Show default keymaps",
|
||||
callback = function()
|
||||
local config = require("oil.config")
|
||||
require("oil.keymap_util").show_help(config.keymaps)
|
||||
end,
|
||||
}
|
||||
|
||||
M.select = {
|
||||
desc = "Open the entry under the cursor",
|
||||
callback = oil.select,
|
||||
}
|
||||
|
||||
M.select_vsplit = {
|
||||
desc = "Open the entry under the cursor in a vertical split",
|
||||
callback = function()
|
||||
oil.select({ vertical = true })
|
||||
end,
|
||||
}
|
||||
|
||||
M.select_split = {
|
||||
desc = "Open the entry under the cursor in a horizontal split",
|
||||
callback = function()
|
||||
oil.select({ horizontal = true })
|
||||
end,
|
||||
}
|
||||
|
||||
M.preview = {
|
||||
desc = "Open the entry under the cursor in a preview window",
|
||||
callback = function()
|
||||
oil.select({ preview = true })
|
||||
end,
|
||||
}
|
||||
|
||||
M.parent = {
|
||||
desc = "Navigate to the parent path",
|
||||
callback = oil.open,
|
||||
}
|
||||
|
||||
M.close = {
|
||||
desc = "Close oil and restore original buffer",
|
||||
callback = oil.close,
|
||||
}
|
||||
|
||||
---@param cmd string
|
||||
local function cd(cmd)
|
||||
local dir = oil.get_current_dir()
|
||||
if dir then
|
||||
vim.cmd({ cmd = cmd, args = { dir } })
|
||||
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()
|
||||
cd("cd")
|
||||
end,
|
||||
}
|
||||
|
||||
M.tcd = {
|
||||
desc = ":tcd to the current oil directory",
|
||||
callback = function()
|
||||
cd("tcd")
|
||||
end,
|
||||
}
|
||||
|
||||
M.open_cwd = {
|
||||
desc = "Open oil in Neovim's cwd",
|
||||
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,
|
||||
}
|
||||
|
||||
return M
|
||||
406
lua/oil/adapters/files.lua
Normal file
406
lua/oil/adapters/files.lua
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
local cache = require("oil.cache")
|
||||
local columns = require("oil.columns")
|
||||
local config = require("oil.config")
|
||||
local fs = require("oil.fs")
|
||||
local permissions = require("oil.adapters.files.permissions")
|
||||
local util = require("oil.util")
|
||||
local FIELD = require("oil.constants").FIELD
|
||||
local M = {}
|
||||
|
||||
local function read_link_data(path, cb)
|
||||
vim.loop.fs_readlink(
|
||||
path,
|
||||
vim.schedule_wrap(function(link_err, link)
|
||||
if link_err then
|
||||
cb(link_err)
|
||||
else
|
||||
local stat_path = link
|
||||
if not fs.is_absolute(link) then
|
||||
stat_path = fs.join(vim.fn.fnamemodify(path, ":h"), link)
|
||||
end
|
||||
vim.loop.fs_stat(stat_path, function(stat_err, stat)
|
||||
cb(nil, link, stat)
|
||||
end)
|
||||
end
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@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)
|
||||
end
|
||||
return shortpath
|
||||
end
|
||||
|
||||
local file_columns = {}
|
||||
|
||||
local fs_stat_meta_fields = {
|
||||
stat = function(parent_url, entry, cb)
|
||||
local _, path = util.parse_url(parent_url)
|
||||
local dir = fs.posix_to_os_path(path)
|
||||
vim.loop.fs_stat(fs.join(dir, entry[FIELD.name]), cb)
|
||||
end,
|
||||
}
|
||||
|
||||
file_columns.size = {
|
||||
meta_fields = fs_stat_meta_fields,
|
||||
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD.meta]
|
||||
local stat = meta.stat
|
||||
if not stat then
|
||||
return ""
|
||||
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,
|
||||
|
||||
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 = {
|
||||
meta_fields = fs_stat_meta_fields,
|
||||
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD.meta]
|
||||
local stat = meta.stat
|
||||
if not stat then
|
||||
return ""
|
||||
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.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)
|
||||
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)
|
||||
path = fs.posix_to_os_path(path)
|
||||
vim.loop.fs_stat(path, function(err, stat)
|
||||
if err then
|
||||
return callback(err)
|
||||
end
|
||||
-- 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)
|
||||
vim.loop.fs_chmod(path, bit.bor(old_mode, action.value), callback)
|
||||
end)
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local current_year = vim.fn.strftime("%Y")
|
||||
|
||||
for _, time_key in ipairs({ "ctime", "mtime", "atime", "birthtime" }) do
|
||||
file_columns[time_key] = {
|
||||
meta_fields = fs_stat_meta_fields,
|
||||
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD.meta]
|
||||
local stat = meta.stat
|
||||
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
|
||||
pattern = fmt:gsub("%%.", "%%S+")
|
||||
else
|
||||
pattern = "%S+%s+%d+%s+%d%d:?%d%d"
|
||||
end
|
||||
return line:match("^(" .. pattern .. ")%s+(.+)$")
|
||||
end,
|
||||
}
|
||||
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)
|
||||
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ":p")
|
||||
local realpath = vim.loop.fs_realpath(os_path) or os_path
|
||||
local norm_path = util.addslash(fs.os_to_posix_path(realpath))
|
||||
if norm_path ~= os_path then
|
||||
callback(scheme .. fs.os_to_posix_path(norm_path))
|
||||
else
|
||||
callback(util.addslash(url))
|
||||
end
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param callback fun(err: nil|string, entries: nil|oil.InternalEntry[])
|
||||
M.list = function(url, column_defs, callback)
|
||||
local _, path = util.parse_url(url)
|
||||
local dir = fs.posix_to_os_path(path)
|
||||
local fetch_meta = columns.get_metadata_fetcher(M, column_defs)
|
||||
cache.begin_update_url(url)
|
||||
local function cb(err, data)
|
||||
if err or not data then
|
||||
cache.end_update_url(url)
|
||||
end
|
||||
callback(err, data)
|
||||
end
|
||||
vim.loop.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(read_err)
|
||||
if read_err then
|
||||
cb(read_err)
|
||||
return
|
||||
end
|
||||
vim.loop.fs_readdir(fd, function(err, entries)
|
||||
if err then
|
||||
vim.loop.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, true)
|
||||
read_next()
|
||||
end
|
||||
end)
|
||||
for _, entry in ipairs(entries) do
|
||||
local cache_entry = cache.create_entry(url, entry.name, entry.type)
|
||||
fetch_meta(url, cache_entry, function(meta_err)
|
||||
if err then
|
||||
poll(meta_err)
|
||||
else
|
||||
local meta = cache_entry[FIELD.meta]
|
||||
-- Make sure we always get fs_stat info for links
|
||||
if entry.type == "link" then
|
||||
read_link_data(fs.join(dir, entry.name), function(link_err, link, link_stat)
|
||||
if link_err then
|
||||
poll(link_err)
|
||||
else
|
||||
if not meta then
|
||||
meta = {}
|
||||
cache_entry[FIELD.meta] = meta
|
||||
end
|
||||
meta.link = link
|
||||
meta.link_stat = link_stat
|
||||
cache.store_entry(url, cache_entry)
|
||||
poll()
|
||||
end
|
||||
end)
|
||||
else
|
||||
cache.store_entry(url, cache_entry)
|
||||
poll()
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
else
|
||||
vim.loop.fs_closedir(fd, function(close_err)
|
||||
if close_err then
|
||||
cb(close_err)
|
||||
else
|
||||
cb()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
read_next()
|
||||
end, 100) -- TODO do some testing for this
|
||||
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)
|
||||
local dir = fs.posix_to_os_path(path)
|
||||
local stat = vim.loop.fs_stat(dir)
|
||||
if not stat then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Can't do permissions checks on windows
|
||||
if fs.is_windows then
|
||||
return true
|
||||
end
|
||||
|
||||
local uid = vim.loop.getuid()
|
||||
local gid = vim.loop.getgid()
|
||||
local rwx
|
||||
if uid == stat.uid then
|
||||
rwx = bit.rshift(stat.mode, 6)
|
||||
elseif gid == stat.gid then
|
||||
rwx = bit.rshift(stat.mode, 3)
|
||||
else
|
||||
rwx = stat.mode
|
||||
end
|
||||
return bit.band(rwx, 2) ~= 0
|
||||
end
|
||||
|
||||
---@param url string
|
||||
M.url_to_buffer_name = function(url)
|
||||
local _, path = util.parse_url(url)
|
||||
return fs.posix_to_os_path(path)
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@return string
|
||||
M.render_action = function(action)
|
||||
if action.type == "create" then
|
||||
local _, path = util.parse_url(action.url)
|
||||
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)
|
||||
return string.format("DELETE %s", M.to_short_os_path(path, action.entry_type))
|
||||
elseif action.type == "move" or action.type == "copy" then
|
||||
local dest_adapter = config.get_adapter_by_scheme(action.dest_url)
|
||||
if dest_adapter == M then
|
||||
local _, src_path = util.parse_url(action.src_url)
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
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 supports_xfer
|
||||
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)
|
||||
path = fs.posix_to_os_path(path)
|
||||
if action.entry_type == "directory" then
|
||||
vim.loop.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)
|
||||
if fs.is_windows then
|
||||
flags = {
|
||||
dir = vim.fn.isdirectory(target) == 1,
|
||||
junction = false,
|
||||
}
|
||||
end
|
||||
vim.loop.fs_symlink(target, path, flags, cb)
|
||||
else
|
||||
fs.touch(path, cb)
|
||||
end
|
||||
elseif action.type == "delete" then
|
||||
local _, path = util.parse_url(action.url)
|
||||
path = fs.posix_to_os_path(path)
|
||||
fs.recursive_delete(action.entry_type, path, cb)
|
||||
elseif action.type == "move" then
|
||||
local dest_adapter = config.get_adapter_by_scheme(action.dest_url)
|
||||
if dest_adapter == M then
|
||||
local _, src_path = util.parse_url(action.src_url)
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
src_path = fs.posix_to_os_path(src_path)
|
||||
dest_path = fs.posix_to_os_path(dest_path)
|
||||
fs.recursive_move(action.entry_type, src_path, dest_path, vim.schedule_wrap(cb))
|
||||
else
|
||||
-- We should never hit this because we don't implement supports_xfer
|
||||
cb("files adapter doesn't support cross-adapter move")
|
||||
end
|
||||
elseif action.type == "copy" then
|
||||
local dest_adapter = config.get_adapter_by_scheme(action.dest_url)
|
||||
if dest_adapter == M then
|
||||
local _, src_path = util.parse_url(action.src_url)
|
||||
local _, dest_path = util.parse_url(action.dest_url)
|
||||
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 supports_xfer
|
||||
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 nil|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 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
|
||||
454
lua/oil/adapters/ssh.lua
Normal file
454
lua/oil/adapters/ssh.lua
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
local cache = require("oil.cache")
|
||||
local config = require("oil.config")
|
||||
local fs = require("oil.fs")
|
||||
local files = require("oil.adapters.files")
|
||||
local permissions = require("oil.adapters.files.permissions")
|
||||
local ssh_connection = require("oil.adapters.ssh.connection")
|
||||
local pathutil = require("oil.pathutil")
|
||||
local shell = require("oil.shell")
|
||||
local util = require("oil.util")
|
||||
local FIELD = require("oil.constants").FIELD
|
||||
local M = {}
|
||||
|
||||
---@class oil.sshUrl
|
||||
---@field scheme string
|
||||
---@field host string
|
||||
---@field user nil|string
|
||||
---@field port nil|integer
|
||||
---@field path string
|
||||
|
||||
---@param oil_url string
|
||||
---@return oil.sshUrl
|
||||
local function parse_url(oil_url)
|
||||
local scheme, url = util.parse_url(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
|
||||
|
||||
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 = {}
|
||||
if url.user then
|
||||
table.insert(pieces, url.user)
|
||||
table.insert(pieces, "@")
|
||||
end
|
||||
table.insert(pieces, url.host)
|
||||
table.insert(pieces, ":")
|
||||
if url.port then
|
||||
table.insert(pieces, string.format(":%d", url.port))
|
||||
end
|
||||
table.insert(pieces, url.path)
|
||||
return table.concat(pieces, "")
|
||||
end
|
||||
|
||||
local _connections = {}
|
||||
---@param url string
|
||||
---@param allow_retry nil|boolean
|
||||
local function get_connection(url, allow_retry)
|
||||
local res = parse_url(url)
|
||||
res.scheme = config.adapters.ssh
|
||||
res.path = ""
|
||||
local key = url_to_str(res)
|
||||
local conn = _connections[key]
|
||||
if not conn or (allow_retry and conn.connection_error) then
|
||||
conn = ssh_connection.new(res)
|
||||
_connections[key] = conn
|
||||
end
|
||||
return conn
|
||||
end
|
||||
|
||||
local typechar_map = {
|
||||
l = "link",
|
||||
d = "directory",
|
||||
p = "fifo",
|
||||
s = "socket",
|
||||
["-"] = "file",
|
||||
}
|
||||
---@param line string
|
||||
---@return string Name of entry
|
||||
---@return oil.EntryType
|
||||
---@return nil|table Metadata for entry
|
||||
local function parse_ls_line(line)
|
||||
local typechar, perms, refcount, user, group, size, date, name =
|
||||
line:match("^(.)(%S+)%s+(%d+)%s+(%S+)%s+(%S+)%s+(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%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),
|
||||
size = tonumber(size),
|
||||
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
|
||||
|
||||
local ssh_columns = {}
|
||||
ssh_columns.permissions = {
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD.meta]
|
||||
return 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.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 = parse_url(action.url)
|
||||
local conn = get_connection(action.url)
|
||||
local octal = permissions.mode_to_octal_str(action.value)
|
||||
conn:run(string.format("chmod %s '%s'", octal, res.path), callback)
|
||||
end,
|
||||
}
|
||||
|
||||
ssh_columns.size = {
|
||||
render = function(entry, conf)
|
||||
local meta = entry[FIELD.meta]
|
||||
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,
|
||||
}
|
||||
|
||||
---@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 = 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 = parse_url(url)
|
||||
local conn = get_connection(url, true)
|
||||
|
||||
local path = res.path
|
||||
if path == "" then
|
||||
path = "."
|
||||
end
|
||||
|
||||
local cmd = string.format(
|
||||
'if ! readlink -f "%s" 2>/dev/null; then [[ "%s" == /* ]] && echo "%s" || echo "$PWD/%s"; fi',
|
||||
path,
|
||||
path,
|
||||
path,
|
||||
path
|
||||
)
|
||||
conn:run(cmd, function(err, lines)
|
||||
if err then
|
||||
vim.notify(string.format("Error normalizing url %s: %s", url, err), vim.log.levels.WARN)
|
||||
return callback(url)
|
||||
end
|
||||
local abspath = table.concat(lines, "")
|
||||
if vim.endswith(abspath, ".") then
|
||||
abspath = abspath:sub(1, #abspath - 1)
|
||||
end
|
||||
abspath = util.addslash(abspath)
|
||||
if abspath == res.path then
|
||||
callback(url)
|
||||
else
|
||||
res.path = abspath
|
||||
callback(url_to_str(res))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local dir_meta = {}
|
||||
|
||||
---@param url string
|
||||
---@param column_defs string[]
|
||||
---@param callback fun(err: nil|string, entries: nil|oil.InternalEntry[])
|
||||
M.list = function(url, column_defs, callback)
|
||||
local res = parse_url(url)
|
||||
|
||||
local path_postfix = ""
|
||||
if res.path ~= "" then
|
||||
path_postfix = string.format(" '%s'", res.path)
|
||||
end
|
||||
local conn = get_connection(url)
|
||||
cache.begin_update_url(url)
|
||||
local function cb(err, data)
|
||||
if err or not data then
|
||||
cache.end_update_url(url)
|
||||
end
|
||||
callback(err, data)
|
||||
end
|
||||
conn:run("ls -fl" .. 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 cb()
|
||||
else
|
||||
return cb(err)
|
||||
end
|
||||
end
|
||||
local any_links = false
|
||||
local 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)
|
||||
entries[name] = cache_entry
|
||||
cache_entry[FIELD.meta] = meta
|
||||
cache.store_entry(url, cache_entry)
|
||||
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
|
||||
conn:run("ls -fLl" .. path_postfix, 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 cb(link_err)
|
||||
end
|
||||
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
|
||||
cb()
|
||||
end)
|
||||
else
|
||||
cb()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return boolean
|
||||
M.is_modifiable = function(bufnr)
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local meta = dir_meta[bufname]
|
||||
if not meta then
|
||||
-- Directories that don't exist yet are modifiable
|
||||
return true
|
||||
end
|
||||
local conn = get_connection(bufname)
|
||||
if not conn.meta.user or not conn.meta.groups then
|
||||
return false
|
||||
end
|
||||
local rwx
|
||||
if meta.user == conn.meta.user then
|
||||
rwx = bit.rshift(meta.mode, 6)
|
||||
elseif vim.tbl_contains(conn.meta.groups, meta.group) then
|
||||
rwx = bit.rshift(meta.mode, 3)
|
||||
else
|
||||
rwx = meta.mode
|
||||
end
|
||||
return bit.band(rwx, 2) ~= 0
|
||||
end
|
||||
|
||||
---@param url string
|
||||
M.url_to_buffer_name = function(url)
|
||||
local _, rem = util.parse_url(url)
|
||||
-- Let netrw handle editing files
|
||||
return "scp://" .. rem
|
||||
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)
|
||||
dest = files.to_short_os_path(path, action.entry_type)
|
||||
else
|
||||
local _, path = util.parse_url(src)
|
||||
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 = parse_url(action.url)
|
||||
local conn = get_connection(action.url)
|
||||
if action.entry_type == "directory" then
|
||||
conn:run(string.format("mkdir -p '%s'", res.path), cb)
|
||||
elseif action.entry_type == "link" and action.link then
|
||||
conn:run(string.format("ln -s '%s' '%s'", action.link, res.path), cb)
|
||||
else
|
||||
conn:run(string.format("touch '%s'", res.path), cb)
|
||||
end
|
||||
elseif action.type == "delete" then
|
||||
local res = parse_url(action.url)
|
||||
local conn = get_connection(action.url)
|
||||
conn:run(string.format("rm -rf '%s'", res.path), cb)
|
||||
elseif action.type == "move" then
|
||||
local src_adapter = config.get_adapter_by_scheme(action.src_url)
|
||||
local dest_adapter = config.get_adapter_by_scheme(action.dest_url)
|
||||
if src_adapter == M and dest_adapter == M then
|
||||
local src_res = parse_url(action.src_url)
|
||||
local dest_res = 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
|
||||
shell.run({ "scp", "-r", url_to_scp(src_res), url_to_scp(dest_res) }, function(err)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
src_conn:run(string.format("rm -rf '%s'", src_res.path), cb)
|
||||
end)
|
||||
else
|
||||
src_conn:run(string.format("mv '%s' '%s'", 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 = config.get_adapter_by_scheme(action.src_url)
|
||||
local dest_adapter = config.get_adapter_by_scheme(action.dest_url)
|
||||
if src_adapter == M and dest_adapter == M then
|
||||
local src_res = parse_url(action.src_url)
|
||||
local dest_res = parse_url(action.dest_url)
|
||||
local src_conn = get_connection(action.src_url)
|
||||
local dest_conn = get_connection(action.dest_url)
|
||||
if src_conn.host ~= dest_conn.host then
|
||||
shell.run({ "scp", "-r", url_to_scp(src_res), url_to_scp(dest_res) }, cb)
|
||||
end
|
||||
src_conn:run(string.format("cp -r '%s' '%s'", src_res.path, dest_res.path), cb)
|
||||
else
|
||||
local src_arg
|
||||
local dest_arg
|
||||
if src_adapter == M then
|
||||
src_arg = url_to_scp(parse_url(action.src_url))
|
||||
local _, path = util.parse_url(action.dest_url)
|
||||
dest_arg = fs.posix_to_os_path(path)
|
||||
else
|
||||
local _, path = util.parse_url(action.src_url)
|
||||
src_arg = fs.posix_to_os_path(path)
|
||||
dest_arg = url_to_scp(parse_url(action.dest_url))
|
||||
end
|
||||
shell.run({ "scp", "-r", src_arg, dest_arg }, cb)
|
||||
end
|
||||
else
|
||||
cb(string.format("Bad action type: %s", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
M.supports_xfer = { files = true }
|
||||
|
||||
return M
|
||||
270
lua/oil/adapters/ssh/connection.lua
Normal file
270
lua/oil/adapters/ssh/connection.lua
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
local util = require("oil.util")
|
||||
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
|
||||
function SSHConnection.new(url)
|
||||
local host = url.host
|
||||
if url.user then
|
||||
host = url.user .. "@" .. host
|
||||
end
|
||||
if url.port then
|
||||
host = string.format("%s:%d", host, url.port)
|
||||
end
|
||||
local command = {
|
||||
"ssh",
|
||||
host,
|
||||
"/bin/bash",
|
||||
"--norc",
|
||||
"-c",
|
||||
"echo '_make_newline_'; echo '===READY==='; exec /bin/bash --norc",
|
||||
}
|
||||
local self = setmetatable({
|
||||
host = host,
|
||||
meta = {},
|
||||
commands = {},
|
||||
connected = false,
|
||||
connection_error = nil,
|
||||
}, {
|
||||
__index = SSHConnection,
|
||||
})
|
||||
|
||||
self.term_bufnr = vim.api.nvim_create_buf(false, true)
|
||||
local term_id
|
||||
local mode = vim.api.nvim_get_mode().mode
|
||||
util.run_in_fullscreen_win(self.term_bufnr, function()
|
||||
term_id = vim.api.nvim_open_term(self.term_bufnr, {
|
||||
on_input = function(_, _, _, data)
|
||||
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", 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"))
|
||||
local new_i_start = output_extend(self._stdout, output)
|
||||
self:_handle_output(new_i_start)
|
||||
end,
|
||||
on_exit = function(j, 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")
|
||||
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("whoami", function(err, lines)
|
||||
if err then
|
||||
vim.notify(string.format("Error fetching ssh connection user: %s", err), vim.log.levels.WARN)
|
||||
else
|
||||
self.meta.user = vim.trim(table.concat(lines, ""))
|
||||
end
|
||||
end)
|
||||
self:run("groups", function(err, lines)
|
||||
if err then
|
||||
vim.notify(
|
||||
string.format("Error fetching ssh connection user groups: %s", err),
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
else
|
||||
self.meta.groups = vim.split(table.concat(lines, ""), "%s+", { trimempty = true })
|
||||
end
|
||||
end)
|
||||
|
||||
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
|
||||
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()
|
||||
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 = util.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((util.get_editor_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 = "rounded",
|
||||
})
|
||||
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
|
||||
62
lua/oil/adapters/test.lua
Normal file
62
lua/oil/adapters/test.lua
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
local cache = require("oil.cache")
|
||||
local M = {}
|
||||
|
||||
---@param path string
|
||||
---@param column_defs string[]
|
||||
---@param cb fun(err: nil|string, entries: nil|oil.InternalEntry[])
|
||||
M.list = function(url, column_defs, cb)
|
||||
cb(nil, cache.list_url(url))
|
||||
end
|
||||
|
||||
---@param name string
|
||||
---@return nil|oil.ColumnDefinition
|
||||
M.get_column = function(name)
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param entry_type oil.EntryType
|
||||
M.test_set = function(path, entry_type)
|
||||
local parent = vim.fn.fnamemodify(path, ":h")
|
||||
if parent ~= path then
|
||||
M.test_set(parent, "directory")
|
||||
end
|
||||
local url = "oil-test://" .. path
|
||||
if cache.get_entry_by_url(url) then
|
||||
-- Already exists
|
||||
return
|
||||
end
|
||||
local name = vim.fn.fnamemodify(path, ":t")
|
||||
cache.create_and_store_entry("oil-test://" .. parent, name, entry_type)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return boolean
|
||||
M.is_modifiable = function(bufnr)
|
||||
return true
|
||||
end
|
||||
|
||||
---@param url string
|
||||
M.url_to_buffer_name = function(url)
|
||||
error("Test adapter cannot open files")
|
||||
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
|
||||
|
||||
return M
|
||||
183
lua/oil/cache.lua
Normal file
183
lua/oil/cache.lua
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
local util = require("oil.util")
|
||||
local FIELD = require("oil.constants").FIELD
|
||||
local M = {}
|
||||
|
||||
local next_id = 1
|
||||
|
||||
-- Map<url, Map<entry name, 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
|
||||
local id = next_id
|
||||
next_id = next_id + 1
|
||||
_cached_id_fmt = nil
|
||||
return { id, 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]
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 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 oil.InternalEntry[]
|
||||
M.list_url = function(url)
|
||||
url = util.addslash(url)
|
||||
return url_directory[url] or {}
|
||||
end
|
||||
|
||||
M.get_entry_by_url = function(url)
|
||||
local parent, name = url:match("^(.+)/([^/]+)$")
|
||||
local cache = url_directory[parent]
|
||||
return cache and cache[name]
|
||||
end
|
||||
|
||||
---@param oil.Action
|
||||
M.perform_action = function(action)
|
||||
if action.type == "create" then
|
||||
local scheme, path = util.parse_url(action.url)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
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)
|
||||
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 == "change" then
|
||||
-- Cache doesn't need to update
|
||||
else
|
||||
error(string.format("Bad action type: '%s'", action.type))
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
238
lua/oil/columns.lua
Normal file
238
lua/oil/columns.lua
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
local config = require("oil.config")
|
||||
local util = require("oil.util")
|
||||
local has_devicons, devicons = pcall(require, "nvim-web-devicons")
|
||||
local FIELD = require("oil.constants").FIELD
|
||||
local M = {}
|
||||
|
||||
local all_columns = {}
|
||||
|
||||
---@alias oil.ColumnSpec string|table
|
||||
|
||||
---@class oil.ColumnDefinition
|
||||
---@field render fun(entry: oil.InternalEntry, conf: nil|table): nil|oil.TextChunk
|
||||
---@field parse fun(line: string, conf: nil|table): nil|string, nil|string
|
||||
---@field meta_fields nil|table<string, fun(parent_url:L string, entry: oil.InternalEntry, cb: fn(err: nil|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
|
||||
local function get_column(adapter, defn)
|
||||
local name = util.split_config(defn)
|
||||
return all_columns[name] or adapter.get_column(name)
|
||||
end
|
||||
|
||||
---@param scheme string
|
||||
---@return oil.ColumnSpec[]
|
||||
M.get_supported_columns = function(scheme)
|
||||
local ret = {}
|
||||
local adapter = config.get_adapter_by_scheme(scheme)
|
||||
for _, def in ipairs(config.columns) do
|
||||
if get_column(adapter, def) then
|
||||
table.insert(ret, def)
|
||||
end
|
||||
end
|
||||
return ret
|
||||
end
|
||||
|
||||
---@param adapter oil.Adapter
|
||||
---@param column_defs table[]
|
||||
---@return fun(parent_url: string, entry: oil.InternalEntry, cb: fun(err: nil|string))
|
||||
M.get_metadata_fetcher = function(adapter, column_defs)
|
||||
local keyfetches = {}
|
||||
local num_keys = 0
|
||||
for _, def in ipairs(column_defs) do
|
||||
local name = util.split_config(def)
|
||||
local column = get_column(adapter, name)
|
||||
if column and column.meta_fields then
|
||||
for k, v in pairs(column.meta_fields) do
|
||||
if not keyfetches[k] then
|
||||
keyfetches[k] = v
|
||||
num_keys = num_keys + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if num_keys == 0 then
|
||||
return function(_, _, cb)
|
||||
cb()
|
||||
end
|
||||
end
|
||||
return function(parent_url, entry, cb)
|
||||
cb = util.cb_collect(num_keys, cb)
|
||||
local meta = {}
|
||||
entry[FIELD.meta] = meta
|
||||
for k, v in pairs(keyfetches) do
|
||||
v(parent_url, entry, function(err, value)
|
||||
if err then
|
||||
cb(err)
|
||||
else
|
||||
meta[k] = value
|
||||
cb()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local EMPTY = { "-", "Comment" }
|
||||
|
||||
---@param adapter oil.Adapter
|
||||
---@param col_def oil.ColumnSpec
|
||||
---@param entry oil.InternalEntry
|
||||
---@return oil.TextChunk
|
||||
M.render_col = function(adapter, col_def, entry)
|
||||
local name, conf = util.split_config(col_def)
|
||||
local column = get_column(adapter, name)
|
||||
if not column then
|
||||
-- This shouldn't be possible because supports_col should return false
|
||||
return EMPTY
|
||||
end
|
||||
|
||||
-- Make sure all the required metadata exists before attempting to render
|
||||
if column.meta_fields then
|
||||
local meta = entry[FIELD.meta]
|
||||
if not meta then
|
||||
return EMPTY
|
||||
end
|
||||
for k in pairs(column.meta_fields) do
|
||||
if not meta[k] then
|
||||
return EMPTY
|
||||
end
|
||||
end
|
||||
end
|
||||
local chunk = column.render(entry, conf)
|
||||
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 "-"
|
||||
if vim.startswith(line, "- ") then
|
||||
return nil, line:sub(3)
|
||||
end
|
||||
local column = get_column(adapter, name)
|
||||
if column then
|
||||
return column.parse(line, 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 = 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 = 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 = 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
|
||||
|
||||
if has_devicons then
|
||||
M.register("icon", {
|
||||
render = function(entry, conf)
|
||||
local type = entry[FIELD.type]
|
||||
local name = entry[FIELD.name]
|
||||
local meta = entry[FIELD.meta]
|
||||
if type == "link" and meta then
|
||||
if meta.link then
|
||||
name = meta.link
|
||||
end
|
||||
if meta.link_stat then
|
||||
type = meta.link_stat.type
|
||||
end
|
||||
end
|
||||
if type == "directory" then
|
||||
return { " ", "OilDir" }
|
||||
else
|
||||
local icon
|
||||
local hl
|
||||
icon, hl = devicons.get_icon(name)
|
||||
icon = icon or "?"
|
||||
return { icon .. " ", hl }
|
||||
end
|
||||
end,
|
||||
|
||||
parse = function(line, conf)
|
||||
return line:match("^(%S+)%s+(.*)$")
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local default_type_icons = {
|
||||
directory = "dir",
|
||||
socket = "sock",
|
||||
}
|
||||
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,
|
||||
})
|
||||
|
||||
return M
|
||||
154
lua/oil/config.lua
Normal file
154
lua/oil/config.lua
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
local default_config = {
|
||||
-- Id is automatically added at the beginning, and name at the end
|
||||
-- See :help oil-columns
|
||||
columns = {
|
||||
"icon",
|
||||
-- "permissions",
|
||||
-- "size",
|
||||
-- "mtime",
|
||||
},
|
||||
-- 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 = "n",
|
||||
},
|
||||
-- Restore window options to previous values when leaving an oil buffer
|
||||
restore_win_options = true,
|
||||
-- Skip the confirmation popup for simple operations
|
||||
skip_confirm_for_simple_edits = 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 = "", nowait = true })
|
||||
-- Additionally, if it is a string that matches "action.<name>",
|
||||
-- it will use the mapping at require("oil.action").<name>
|
||||
-- Set to `false` to remove a keymap
|
||||
keymaps = {
|
||||
["g?"] = "actions.show_help",
|
||||
["<CR>"] = "actions.select",
|
||||
["<C-s>"] = "actions.select_vsplit",
|
||||
["<C-h>"] = "actions.select_split",
|
||||
["<C-p>"] = "actions.preview",
|
||||
["<C-c>"] = "actions.close",
|
||||
["-"] = "actions.parent",
|
||||
["_"] = "actions.open_cwd",
|
||||
["`"] = "actions.cd",
|
||||
["~"] = "actions.tcd",
|
||||
["g."] = "actions.toggle_hidden",
|
||||
},
|
||||
view_options = {
|
||||
-- Show files and directories that start with "."
|
||||
show_hidden = false,
|
||||
},
|
||||
-- Configuration for the floating window in oil.open_float
|
||||
float = {
|
||||
-- Padding around the floating window
|
||||
padding = 2,
|
||||
max_width = 0,
|
||||
max_height = 0,
|
||||
border = "rounded",
|
||||
win_options = {
|
||||
winblend = 10,
|
||||
},
|
||||
},
|
||||
adapters = {
|
||||
["oil://"] = "files",
|
||||
["oil-ssh://"] = "ssh",
|
||||
},
|
||||
-- When opening the parent of a file, substitute these url schemes
|
||||
remap_schemes = {
|
||||
["scp://"] = "oil-ssh://",
|
||||
["sftp://"] = "oil-ssh://",
|
||||
},
|
||||
}
|
||||
|
||||
local M = {}
|
||||
|
||||
M.setup = function(opts)
|
||||
local new_conf = vim.tbl_deep_extend("keep", opts or {}, default_config)
|
||||
|
||||
for k, v in pairs(new_conf) do
|
||||
M[k] = v
|
||||
end
|
||||
|
||||
vim.tbl_add_reverse_lookup(M.adapters)
|
||||
M._adapter_by_scheme = {}
|
||||
if type(M.trash) == "string" then
|
||||
M.trash = vim.fn.fnamemodify(vim.fn.expand(M.trash), ":p")
|
||||
end
|
||||
end
|
||||
|
||||
---@return nil|string
|
||||
M.get_trash_url = function()
|
||||
if not M.trash then
|
||||
return nil
|
||||
end
|
||||
local fs = require("oil.fs")
|
||||
if M.trash == true then
|
||||
local data_home = os.getenv("XDG_DATA_HOME") or vim.fn.expand("~/.local/share")
|
||||
local preferred = fs.join(data_home, "trash")
|
||||
local candidates = {
|
||||
preferred,
|
||||
}
|
||||
if fs.is_windows then
|
||||
-- TODO permission issues when using the recycle bin. The folder gets created without
|
||||
-- read/write perms, so all operations fail
|
||||
-- local cwd = vim.fn.getcwd()
|
||||
-- table.insert(candidates, 1, cwd:sub(1, 3) .. "$Recycle.Bin")
|
||||
-- table.insert(candidates, 1, "C:\\$Recycle.Bin")
|
||||
else
|
||||
table.insert(candidates, fs.join(data_home, "Trash", "files"))
|
||||
table.insert(candidates, fs.join(os.getenv("HOME"), ".Trash"))
|
||||
end
|
||||
local trash_dir = preferred
|
||||
for _, candidate in ipairs(candidates) do
|
||||
if vim.fn.isdirectory(candidate) == 1 then
|
||||
trash_dir = candidate
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local oil_trash_dir = vim.fn.fnamemodify(fs.join(trash_dir, "nvim", "oil"), ":p")
|
||||
fs.mkdirp(oil_trash_dir)
|
||||
M.trash = oil_trash_dir
|
||||
end
|
||||
return M.adapters.files .. fs.os_to_posix_path(M.trash)
|
||||
end
|
||||
|
||||
---@param scheme string
|
||||
---@return nil|oil.Adapter
|
||||
M.get_adapter_by_scheme = function(scheme)
|
||||
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]
|
||||
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
|
||||
vim.notify(string.format("Could not find oil adapter '%s'", name), vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
if adapter then
|
||||
return adapter
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
10
lua/oil/constants.lua
Normal file
10
lua/oil/constants.lua
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
local M = {}
|
||||
|
||||
M.FIELD = {
|
||||
id = 1,
|
||||
name = 2,
|
||||
type = 3,
|
||||
meta = 4,
|
||||
}
|
||||
|
||||
return M
|
||||
256
lua/oil/fs.lua
Normal file
256
lua/oil/fs.lua
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
local M = {}
|
||||
|
||||
---@type boolean
|
||||
M.is_windows = vim.loop.os_uname().version:match("Windows")
|
||||
|
||||
---@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
|
||||
|
||||
---@param path string
|
||||
---@param cb fun(err: nil|string)
|
||||
M.touch = function(path, cb)
|
||||
vim.loop.fs_open(path, "a", 420, function(err, fd) -- 0644
|
||||
if err then
|
||||
cb(err)
|
||||
else
|
||||
vim.loop.fs_close(fd, cb)
|
||||
end
|
||||
end)
|
||||
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, rem = path:match("^/([^/]+)/(.*)$")
|
||||
return string.format("%s:\\%s", drive, rem:gsub("/", "\\"))
|
||||
else
|
||||
return path:gsub("/", "\\")
|
||||
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, rem:gsub("\\", "/"))
|
||||
else
|
||||
return path:gsub("\\", "/")
|
||||
end
|
||||
else
|
||||
return path
|
||||
end
|
||||
end
|
||||
|
||||
local home_dir = vim.loop.os_homedir()
|
||||
|
||||
---@param path string
|
||||
---@return string
|
||||
M.shorten_path = function(path)
|
||||
local cwd = vim.fn.getcwd()
|
||||
if vim.startswith(path, cwd) then
|
||||
return path:sub(cwd:len() + 2)
|
||||
end
|
||||
if vim.startswith(path, home_dir) then
|
||||
return "~" .. path:sub(home_dir:len() + 1)
|
||||
end
|
||||
return path
|
||||
end
|
||||
|
||||
M.mkdirp = function(dir)
|
||||
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)
|
||||
vim.loop.fs_mkdir(path, 493)
|
||||
end
|
||||
end
|
||||
|
||||
---@param dir string
|
||||
---@param cb fun(err: nil|string, entries: nil|{type: oil.EntryType, name: string})
|
||||
M.listdir = function(dir, cb)
|
||||
vim.loop.fs_opendir(dir, function(open_err, fd)
|
||||
if open_err then
|
||||
return cb(open_err)
|
||||
end
|
||||
local read_next
|
||||
read_next = function()
|
||||
vim.loop.fs_readdir(fd, function(err, entries)
|
||||
if err then
|
||||
vim.loop.fs_closedir(fd, function()
|
||||
cb(err)
|
||||
end)
|
||||
return
|
||||
elseif entries then
|
||||
cb(nil, entries)
|
||||
read_next()
|
||||
else
|
||||
vim.loop.fs_closedir(fd, function(close_err)
|
||||
if close_err then
|
||||
cb(close_err)
|
||||
else
|
||||
cb()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
read_next()
|
||||
end, 100) -- TODO do some testing for this
|
||||
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 vim.loop.fs_unlink(path, cb)
|
||||
end
|
||||
vim.loop.fs_opendir(path, function(open_err, fd)
|
||||
if open_err then
|
||||
return cb(open_err)
|
||||
end
|
||||
local poll
|
||||
poll = function(inner_cb)
|
||||
vim.loop.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 err 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)
|
||||
vim.loop.fs_closedir(fd)
|
||||
if err then
|
||||
return cb(err)
|
||||
end
|
||||
vim.loop.fs_rmdir(path, cb)
|
||||
end)
|
||||
end, 100) -- TODO do some testing for this
|
||||
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 ~= "directory" then
|
||||
vim.loop.fs_copyfile(src_path, dest_path, { excl = true }, cb)
|
||||
return
|
||||
end
|
||||
vim.loop.fs_stat(src_path, function(stat_err, src_stat)
|
||||
if stat_err then
|
||||
return cb(stat_err)
|
||||
end
|
||||
vim.loop.fs_mkdir(dest_path, src_stat.mode, function(mkdir_err)
|
||||
if mkdir_err then
|
||||
return cb(mkdir_err)
|
||||
end
|
||||
vim.loop.fs_opendir(src_path, function(open_err, fd)
|
||||
if open_err then
|
||||
return cb(open_err)
|
||||
end
|
||||
local poll
|
||||
poll = function(inner_cb)
|
||||
vim.loop.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 err 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)
|
||||
end, 100) -- TODO do some testing for this
|
||||
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)
|
||||
vim.loop.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
|
||||
cb()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
582
lua/oil/init.lua
Normal file
582
lua/oil/init.lua
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
local M = {}
|
||||
|
||||
---@alias oil.InternalEntry string[]
|
||||
|
||||
---@class oil.Entry
|
||||
---@field name string
|
||||
---@field type oil.EntryType
|
||||
---@field id nil|string Will be nil if it hasn't been persisted to disk yet
|
||||
|
||||
---@alias oil.EntryType "file"|"directory"|"socket"|"link"
|
||||
---@alias oil.TextChunk string|string[]
|
||||
|
||||
---@class oil.Adapter
|
||||
---@field list fun(path: string, cb: fun(err: nil|string, entries: nil|oil.InternalEntry[]))
|
||||
---@field is_modifiable fun(bufnr: integer): boolean
|
||||
---@field url_to_buffer_name fun(url: string): string
|
||||
---@field get_column fun(name: string): nil|oil.ColumnDefinition
|
||||
---@field normalize_url nil|fun(url: string, callback: fun(url: string))
|
||||
---@field get_parent nil|fun(bufname: string): string
|
||||
---@field supports_xfer nil|table<string, boolean>
|
||||
---@field render_action nil|fun(action: oil.Action): string
|
||||
---@field perform_action nil|fun(action: oil.Action, cb: fun(err: nil|string))
|
||||
|
||||
---Get the entry on a specific line (1-indexed)
|
||||
---@param bufnr integer
|
||||
---@param lnum integer
|
||||
---@return nil|oil.Entry
|
||||
M.get_entry_on_line = function(bufnr, lnum)
|
||||
local columns = require("oil.columns")
|
||||
local config = require("oil.config")
|
||||
local parser = require("oil.mutator.parser")
|
||||
local util = require("oil.util")
|
||||
if vim.bo[bufnr].filetype ~= "oil" then
|
||||
return nil
|
||||
end
|
||||
local bufname = vim.api.nvim_buf_get_name(0)
|
||||
local scheme = util.parse_url(bufname)
|
||||
local adapter = config.get_adapter_by_scheme(scheme)
|
||||
if not adapter then
|
||||
return nil
|
||||
end
|
||||
|
||||
local line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1]
|
||||
local column_defs = columns.get_supported_columns(scheme)
|
||||
local parsed_entry, entry = parser.parse_line(adapter, line, column_defs)
|
||||
if parsed_entry then
|
||||
if entry then
|
||||
return util.export_entry(entry)
|
||||
else
|
||||
return {
|
||||
name = parsed_entry.name,
|
||||
type = parsed_entry._type,
|
||||
}
|
||||
end
|
||||
end
|
||||
-- This is a NEW entry that hasn't been saved yet
|
||||
local name = vim.trim(line)
|
||||
local entry_type
|
||||
if vim.endswith(name, "/") then
|
||||
name = name:sub(1, name:len() - 1)
|
||||
entry_type = "directory"
|
||||
else
|
||||
entry_type = "file"
|
||||
end
|
||||
if name == "" then
|
||||
return nil
|
||||
else
|
||||
return {
|
||||
name = name,
|
||||
type = entry_type,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
---Get the entry currently under the cursor
|
||||
---@return nil|oil.Entry
|
||||
M.get_cursor_entry = function()
|
||||
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
return M.get_entry_on_line(0, lnum)
|
||||
end
|
||||
|
||||
---Discard all changes made to oil buffers
|
||||
M.discard_all_changes = function()
|
||||
local view = require("oil.view")
|
||||
for _, bufnr in ipairs(view.get_all_buffers()) do
|
||||
if vim.bo[bufnr].modified then
|
||||
view.render_buffer_async(bufnr, {}, function(err)
|
||||
if err then
|
||||
vim.notify(
|
||||
string.format(
|
||||
"Error rendering oil buffer %s: %s",
|
||||
vim.api.nvim_buf_get_name(bufnr),
|
||||
err
|
||||
),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Delete all files in the trash directory
|
||||
---@private
|
||||
---@note
|
||||
--- Trash functionality is incomplete and experimental.
|
||||
M.empty_trash = function()
|
||||
local config = require("oil.config")
|
||||
local fs = require("oil.fs")
|
||||
local util = require("oil.util")
|
||||
local trash_url = config.get_trash_url()
|
||||
if not trash_url then
|
||||
vim.notify("No trash directory configured", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local _, path = util.parse_url(trash_url)
|
||||
local dir = fs.posix_to_os_path(path)
|
||||
if vim.fn.isdirectory(dir) == 1 then
|
||||
fs.recursive_delete("directory", dir, function(err)
|
||||
if err then
|
||||
vim.notify(string.format("Error emptying trash: %s", err), vim.log.levels.ERROR)
|
||||
else
|
||||
vim.notify("Trash emptied")
|
||||
fs.mkdirp(dir)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
---Change the display columns for oil
|
||||
---@param cols oil.ColumnSpec[]
|
||||
M.set_columns = function(cols)
|
||||
require("oil.view").set_columns(cols)
|
||||
end
|
||||
|
||||
---Get the current directory
|
||||
---@return nil|string
|
||||
M.get_current_dir = function()
|
||||
local config = require("oil.config")
|
||||
local fs = require("oil.fs")
|
||||
local util = require("oil.util")
|
||||
local scheme, path = util.parse_url(vim.api.nvim_buf_get_name(0))
|
||||
if config.adapters[scheme] == "files" then
|
||||
return fs.posix_to_os_path(path)
|
||||
end
|
||||
end
|
||||
|
||||
---Get the oil url for a given directory
|
||||
---@private
|
||||
---@param dir nil|string When nil, use the cwd
|
||||
---@return nil|string The parent url
|
||||
---@return nil|string The basename (if present) of the file/dir we were just in
|
||||
M.get_url_for_path = function(dir)
|
||||
local config = require("oil.config")
|
||||
local fs = require("oil.fs")
|
||||
if dir then
|
||||
local abspath = vim.fn.fnamemodify(dir, ":p")
|
||||
local path = fs.os_to_posix_path(abspath)
|
||||
return config.adapters.files .. path
|
||||
else
|
||||
local bufname = vim.api.nvim_buf_get_name(0)
|
||||
return M.get_buffer_parent_url(bufname)
|
||||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param bufname string
|
||||
---@return string
|
||||
---@return nil|string
|
||||
M.get_buffer_parent_url = function(bufname)
|
||||
local config = require("oil.config")
|
||||
local fs = require("oil.fs")
|
||||
local pathutil = require("oil.pathutil")
|
||||
local util = require("oil.util")
|
||||
local scheme, path = util.parse_url(bufname)
|
||||
if not scheme then
|
||||
local parent, basename
|
||||
scheme = config.adapters.files
|
||||
if bufname == "" then
|
||||
parent = fs.os_to_posix_path(vim.fn.getcwd())
|
||||
else
|
||||
parent = fs.os_to_posix_path(vim.fn.fnamemodify(bufname, ":p:h"))
|
||||
basename = vim.fn.fnamemodify(bufname, ":t")
|
||||
end
|
||||
local parent_url = util.addslash(scheme .. parent)
|
||||
return parent_url, basename
|
||||
else
|
||||
scheme = config.remap_schemes[scheme] or scheme
|
||||
local adapter = config.get_adapter_by_scheme(scheme)
|
||||
local parent_url
|
||||
if adapter.get_parent then
|
||||
local adapter_scheme = config.adapters[adapter.name]
|
||||
parent_url = adapter.get_parent(adapter_scheme .. path)
|
||||
else
|
||||
local parent = pathutil.parent(path)
|
||||
parent_url = scheme .. util.addslash(parent)
|
||||
end
|
||||
if parent_url == bufname then
|
||||
return parent_url
|
||||
else
|
||||
return util.addslash(parent_url), pathutil.basename(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Open oil browser in a floating window
|
||||
---@param dir nil|string When nil, open the parent of the current buffer, or the cwd
|
||||
M.open_float = function(dir)
|
||||
local config = require("oil.config")
|
||||
local util = require("oil.util")
|
||||
local view = require("oil.view")
|
||||
local parent_url, basename = M.get_url_for_path(dir)
|
||||
if basename then
|
||||
view.set_last_cursor(parent_url, basename)
|
||||
end
|
||||
|
||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[bufnr].bufhidden = "wipe"
|
||||
local total_width = vim.o.columns
|
||||
local total_height = util.get_editor_height()
|
||||
local width = total_width - 2 * config.float.padding
|
||||
if config.float.max_width > 0 then
|
||||
width = math.min(width, config.float.max_width)
|
||||
end
|
||||
local height = total_height - 2 * config.float.padding
|
||||
if config.float.max_height > 0 then
|
||||
height = math.min(height, config.float.max_height)
|
||||
end
|
||||
local row = math.floor((total_width - width) / 2)
|
||||
local col = math.floor((total_height - height) / 2)
|
||||
local winid = vim.api.nvim_open_win(bufnr, true, {
|
||||
relative = "editor",
|
||||
width = width,
|
||||
height = height,
|
||||
row = row,
|
||||
col = col,
|
||||
style = "minimal",
|
||||
border = config.float.border,
|
||||
zindex = 45,
|
||||
})
|
||||
for k, v in pairs(config.float.win_options) do
|
||||
vim.api.nvim_win_set_option(winid, k, v)
|
||||
end
|
||||
util.add_title_to_win(winid, parent_url)
|
||||
vim.cmd.edit({ args = { parent_url }, mods = { keepalt = true } })
|
||||
end
|
||||
|
||||
---Open oil browser for a directory
|
||||
---@param dir nil|string When nil, open the parent of the current buffer, or the cwd
|
||||
M.open = function(dir)
|
||||
local view = require("oil.view")
|
||||
local parent_url, basename = M.get_url_for_path(dir)
|
||||
if not parent_url then
|
||||
return
|
||||
end
|
||||
if basename then
|
||||
view.set_last_cursor(parent_url, basename)
|
||||
end
|
||||
if not pcall(vim.api.nvim_win_get_var, 0, "oil_original_buffer") then
|
||||
vim.api.nvim_win_set_var(0, "oil_original_buffer", vim.api.nvim_get_current_buf())
|
||||
end
|
||||
vim.cmd.edit({ args = { parent_url }, mods = { keepalt = true } })
|
||||
end
|
||||
|
||||
---Restore the buffer that was present when oil was opened
|
||||
M.close = function()
|
||||
local util = require("oil.util")
|
||||
if util.is_floating_win(0) then
|
||||
vim.api.nvim_win_close(0, true)
|
||||
return
|
||||
end
|
||||
local ok, bufnr = pcall(vim.api.nvim_win_get_var, 0, "oil_original_buffer")
|
||||
if ok then
|
||||
vim.api.nvim_win_del_var(0, "oil_original_buffer")
|
||||
if vim.api.nvim_buf_is_valid(bufnr) then
|
||||
vim.api.nvim_win_set_buf(0, bufnr)
|
||||
return
|
||||
end
|
||||
end
|
||||
vim.api.nvim_buf_delete(0, { force = true })
|
||||
end
|
||||
|
||||
---Select the entry under the cursor
|
||||
---@param opts table
|
||||
--- vertical boolean Open the buffer in a vertical split
|
||||
--- horizontal boolean Open the buffer in a horizontal split
|
||||
--- split "aboveleft"|"belowright"|"topleft"|"botright" Split modifier
|
||||
--- preview boolean Open the buffer in a preview window
|
||||
M.select = function(opts)
|
||||
local cache = require("oil.cache")
|
||||
opts = vim.tbl_extend("keep", opts or {}, {})
|
||||
if opts.horizontal or opts.vertical or opts.preview then
|
||||
opts.split = opts.split or "belowright"
|
||||
end
|
||||
if opts.preview and not opts.horizontal and opts.vertical == nil then
|
||||
opts.vertical = true
|
||||
end
|
||||
local util = require("oil.util")
|
||||
if util.is_floating_win() and opts.preview then
|
||||
vim.notify("oil preview doesn't work in a floating window", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local adapter = util.get_adapter(0)
|
||||
if not adapter then
|
||||
return
|
||||
end
|
||||
|
||||
local mode = vim.api.nvim_get_mode().mode
|
||||
local is_visual = mode:match("^[vV]")
|
||||
|
||||
local entries = {}
|
||||
if is_visual then
|
||||
-- This is the best way to get the visual selection at the moment
|
||||
-- https://github.com/neovim/neovim/pull/13896
|
||||
local _, start_lnum, _, _ = unpack(vim.fn.getpos("v"))
|
||||
local _, end_lnum, _, _, _ = unpack(vim.fn.getcurpos())
|
||||
if start_lnum > end_lnum then
|
||||
start_lnum, end_lnum = end_lnum, start_lnum
|
||||
end
|
||||
for i = start_lnum, end_lnum do
|
||||
local entry = M.get_entry_on_line(0, i)
|
||||
if entry then
|
||||
table.insert(entries, entry)
|
||||
end
|
||||
end
|
||||
else
|
||||
local entry = M.get_cursor_entry()
|
||||
if entry then
|
||||
table.insert(entries, entry)
|
||||
end
|
||||
end
|
||||
if vim.tbl_isempty(entries) then
|
||||
vim.notify("Could not find entry under cursor", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
if #entries > 1 and opts.preview then
|
||||
vim.notify("Cannot preview multiple entries", vim.log.levels.WARN)
|
||||
entries = { entries[1] }
|
||||
end
|
||||
-- Close the preview window
|
||||
for _, winid in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||
if vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_option(winid, "previewwindow") then
|
||||
vim.api.nvim_win_close(winid, true)
|
||||
end
|
||||
end
|
||||
local bufname = vim.api.nvim_buf_get_name(0)
|
||||
local prev_win = vim.api.nvim_get_current_win()
|
||||
for _, entry in ipairs(entries) do
|
||||
local scheme, dir = util.parse_url(bufname)
|
||||
local child = dir .. entry.name
|
||||
local url = scheme .. child
|
||||
local buffer_name
|
||||
if
|
||||
entry.type == "directory"
|
||||
or (
|
||||
entry.type == "link"
|
||||
and entry.meta
|
||||
and entry.meta.link_stat
|
||||
and entry.meta.link_stat.type == "directory"
|
||||
)
|
||||
then
|
||||
buffer_name = util.addslash(url)
|
||||
-- If this is a new directory BUT we think we already have an entry with this name, disallow
|
||||
-- entry. This prevents the case of MOVE /foo -> /bar + CREATE /foo.
|
||||
-- If you enter the new /foo, it will show the contents of the old /foo.
|
||||
if not entry.id and cache.list_url(bufname)[entry.name] then
|
||||
vim.notify("Please save changes before entering new directory", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
else
|
||||
if util.is_floating_win() then
|
||||
vim.api.nvim_win_close(0, false)
|
||||
end
|
||||
buffer_name = adapter.url_to_buffer_name(url)
|
||||
end
|
||||
local mods = {
|
||||
vertical = opts.vertical,
|
||||
horizontal = opts.horizontal,
|
||||
split = opts.split,
|
||||
keepalt = true,
|
||||
}
|
||||
local cmd = opts.split and "split" or "edit"
|
||||
vim.cmd({
|
||||
cmd = cmd,
|
||||
args = { buffer_name },
|
||||
mods = mods,
|
||||
})
|
||||
if opts.preview then
|
||||
vim.api.nvim_win_set_option(0, "previewwindow", true)
|
||||
vim.api.nvim_set_current_win(prev_win)
|
||||
end
|
||||
-- Set opts.split so that for every entry after the first, we do a split
|
||||
opts.split = opts.split or "belowright"
|
||||
if not opts.horizontal and opts.vertical == nil then
|
||||
opts.vertical = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
local function maybe_hijack_directory_buffer(bufnr)
|
||||
local config = require("oil.config")
|
||||
local util = require("oil.util")
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
if bufname == "" then
|
||||
return
|
||||
end
|
||||
if util.parse_url(bufname) or vim.fn.isdirectory(bufname) == 0 then
|
||||
return
|
||||
end
|
||||
util.rename_buffer(
|
||||
bufnr,
|
||||
util.addslash(config.adapters.files .. vim.fn.fnamemodify(bufname, ":p"))
|
||||
)
|
||||
end
|
||||
|
||||
---@private
|
||||
M._get_highlights = function()
|
||||
return {
|
||||
{
|
||||
name = "OilDir",
|
||||
link = "Special",
|
||||
desc = "Directories in an oil buffer",
|
||||
},
|
||||
{
|
||||
name = "OilSocket",
|
||||
link = "Keyword",
|
||||
desc = "Socket files in an oil buffer",
|
||||
},
|
||||
{
|
||||
name = "OilLink",
|
||||
link = nil,
|
||||
desc = "Soft links in an oil buffer",
|
||||
},
|
||||
{
|
||||
name = "OilFile",
|
||||
link = nil,
|
||||
desc = "Normal files in an oil buffer",
|
||||
},
|
||||
{
|
||||
name = "OilCreate",
|
||||
link = "DiagnosticInfo",
|
||||
desc = "Create action in the oil preview window",
|
||||
},
|
||||
{
|
||||
name = "OilDelete",
|
||||
link = "DiagnosticError",
|
||||
desc = "Delete action in the oil preview window",
|
||||
},
|
||||
{
|
||||
name = "OilMove",
|
||||
link = "DiagnosticWarn",
|
||||
desc = "Move action in the oil preview window",
|
||||
},
|
||||
{
|
||||
name = "OilCopy",
|
||||
link = "DiagnosticHint",
|
||||
desc = "Copy action in the oil preview window",
|
||||
},
|
||||
{
|
||||
name = "OilChange",
|
||||
link = "Special",
|
||||
desc = "Change action in the oil preview window",
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function set_colors()
|
||||
for _, conf in ipairs(M._get_highlights()) do
|
||||
if conf.link then
|
||||
vim.api.nvim_set_hl(0, conf.name, { default = true, link = conf.link })
|
||||
end
|
||||
end
|
||||
if not pcall(vim.api.nvim_get_hl_by_name, "FloatTitle") then
|
||||
local border = vim.api.nvim_get_hl_by_name("FloatBorder", true)
|
||||
local normal = vim.api.nvim_get_hl_by_name("Normal", true)
|
||||
vim.api.nvim_set_hl(
|
||||
0,
|
||||
"FloatTitle",
|
||||
{ fg = normal.foreground, bg = border.background or normal.background }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
---Save all changes
|
||||
---@param opts nil|table
|
||||
--- confirm nil|boolean Show confirmation when true, never when false, respect skip_confirm_for_simple_edits if nil
|
||||
M.save = function(opts)
|
||||
opts = opts or {}
|
||||
local mutator = require("oil.mutator")
|
||||
mutator.try_write_changes(opts.confirm)
|
||||
end
|
||||
|
||||
---Initialize oil
|
||||
---@param opts nil|table
|
||||
M.setup = function(opts)
|
||||
local config = require("oil.config")
|
||||
config.setup(opts)
|
||||
set_colors()
|
||||
local aug = vim.api.nvim_create_augroup("Oil", {})
|
||||
if vim.fn.exists("#FileExplorer") then
|
||||
vim.api.nvim_create_augroup("FileExplorer", { clear = true })
|
||||
end
|
||||
|
||||
local patterns = {}
|
||||
for scheme in pairs(config.adapters) do
|
||||
-- We added a reverse lookup to config.adapters, so filter the keys
|
||||
if vim.endswith(scheme, "://") then
|
||||
table.insert(patterns, scheme .. "*")
|
||||
end
|
||||
end
|
||||
local scheme_pattern = table.concat(patterns, ",")
|
||||
|
||||
vim.api.nvim_create_autocmd("ColorScheme", {
|
||||
desc = "Set default oil highlights",
|
||||
group = aug,
|
||||
pattern = "*",
|
||||
callback = set_colors,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("BufReadCmd", {
|
||||
group = aug,
|
||||
pattern = scheme_pattern,
|
||||
nested = true,
|
||||
callback = function(params)
|
||||
local loading = require("oil.loading")
|
||||
local util = require("oil.util")
|
||||
local view = require("oil.view")
|
||||
local adapter = config.get_adapter_by_scheme(params.file)
|
||||
local bufnr = params.buf
|
||||
|
||||
loading.set_loading(bufnr, true)
|
||||
local function finish(new_url)
|
||||
if new_url ~= params.file then
|
||||
util.rename_buffer(bufnr, new_url)
|
||||
end
|
||||
vim.cmd.doautocmd({ args = { "BufReadPre", params.file }, mods = { silent = true } })
|
||||
view.initialize(bufnr)
|
||||
vim.cmd.doautocmd({ args = { "BufReadPost", params.file }, mods = { silent = true } })
|
||||
end
|
||||
|
||||
if adapter.normalize_url then
|
||||
adapter.normalize_url(params.file, finish)
|
||||
else
|
||||
finish(util.addslash(params.file))
|
||||
end
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("BufWriteCmd", {
|
||||
group = aug,
|
||||
pattern = scheme_pattern,
|
||||
nested = true,
|
||||
callback = function(params)
|
||||
vim.cmd.doautocmd({ args = { "BufWritePre", params.file }, mods = { silent = true } })
|
||||
M.save()
|
||||
vim.bo[params.buf].modified = false
|
||||
vim.cmd.doautocmd({ args = { "BufWritePost", params.file }, mods = { silent = true } })
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("BufWinEnter", {
|
||||
desc = "Set/unset oil window options",
|
||||
pattern = "*",
|
||||
callback = function()
|
||||
local view = require("oil.view")
|
||||
if vim.bo.filetype == "oil" then
|
||||
view.set_win_options()
|
||||
elseif config.restore_win_options then
|
||||
view.restore_win_options()
|
||||
end
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("BufAdd", {
|
||||
group = aug,
|
||||
pattern = "*",
|
||||
nested = true,
|
||||
callback = function(params)
|
||||
maybe_hijack_directory_buffer(params.buf)
|
||||
end,
|
||||
})
|
||||
maybe_hijack_directory_buffer(0)
|
||||
end
|
||||
|
||||
return M
|
||||
100
lua/oil/keymap_util.lua
Normal file
100
lua/oil/keymap_util.lua
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
local actions = require("oil.actions")
|
||||
local util = require("oil.util")
|
||||
local M = {}
|
||||
|
||||
local function resolve(rhs)
|
||||
if type(rhs) == "string" and vim.startswith(rhs, "actions.") then
|
||||
return resolve(actions[vim.split(rhs, ".", true)[2]])
|
||||
elseif type(rhs) == "table" then
|
||||
local opts = vim.deepcopy(rhs)
|
||||
opts.callback = nil
|
||||
return rhs.callback, opts
|
||||
end
|
||||
return rhs, {}
|
||||
end
|
||||
|
||||
M.set_keymaps = function(mode, keymaps, bufnr)
|
||||
for k, v in pairs(keymaps) do
|
||||
local rhs, opts = resolve(v)
|
||||
if rhs then
|
||||
vim.keymap.set(mode, k, rhs, vim.tbl_extend("keep", { buffer = bufnr }, opts))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
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 col_left = {}
|
||||
local col_desc = {}
|
||||
local max_lhs = 1
|
||||
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(col_left, { str = keystr, all_lhs = all_lhs })
|
||||
table.insert(col_desc, opts.desc or "")
|
||||
end
|
||||
end
|
||||
|
||||
local lines = {}
|
||||
local highlights = {}
|
||||
local max_line = 1
|
||||
for i = 1, #col_left do
|
||||
local left = col_left[i]
|
||||
local desc = col_desc[i]
|
||||
local line = string.format(" %s %s", util.rpad(left.str, max_lhs), desc)
|
||||
max_line = math.max(max_line, vim.api.nvim_strwidth(line))
|
||||
table.insert(lines, line)
|
||||
local start = 1
|
||||
for _, key in ipairs(left.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.api.nvim_buf_set_option(bufnr, "modifiable", false)
|
||||
vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe")
|
||||
|
||||
local editor_width = vim.o.columns
|
||||
local editor_height = util.get_editor_height()
|
||||
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 = "rounded",
|
||||
})
|
||||
end
|
||||
|
||||
return M
|
||||
61
lua/oil/loading.lua
Normal file
61
lua/oil/loading.lua
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
local util = require("oil.util")
|
||||
local M = {}
|
||||
|
||||
local timers = {}
|
||||
|
||||
local FPS = 20
|
||||
|
||||
M.is_loading = function(bufnr)
|
||||
return timers[bufnr] ~= nil
|
||||
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
|
||||
|
||||
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(
|
||||
100, -- 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.lpad("Loading", math.floor(width / 2) - 3), bar_iter() }
|
||||
util.render_centered_text(bufnr, lines)
|
||||
end)
|
||||
)
|
||||
end
|
||||
elseif timers[bufnr] then
|
||||
timers[bufnr]:close()
|
||||
timers[bufnr] = nil
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
71
lua/oil/mutator/disclaimer.lua
Normal file
71
lua/oil/mutator/disclaimer.lua
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
local fs = require("oil.fs")
|
||||
local ReplLayout = require("oil.repl_layout")
|
||||
local M = {}
|
||||
|
||||
M.show = function(callback)
|
||||
local marker_file = fs.join(vim.fn.stdpath("cache"), ".oil_accepted_disclaimer")
|
||||
vim.loop.fs_stat(
|
||||
marker_file,
|
||||
vim.schedule_wrap(function(err, stat)
|
||||
if stat and stat.type and not err then
|
||||
callback(true)
|
||||
return
|
||||
end
|
||||
|
||||
local confirmation = "I understand this may destroy my files"
|
||||
local lines = {
|
||||
"WARNING",
|
||||
"This plugin has been tested thoroughly, but it is still new.",
|
||||
"There is a chance that there may be bugs that could lead to data loss.",
|
||||
"I recommend that you ONLY use it for files that are checked in to version control.",
|
||||
"",
|
||||
string.format('Please type: "%s" below', confirmation),
|
||||
"",
|
||||
}
|
||||
local hints = {
|
||||
"Try again",
|
||||
"Not quite!",
|
||||
"It's right there ^^^^^^^^^^^",
|
||||
"...seriously?",
|
||||
"Just type this ^^^^",
|
||||
}
|
||||
local attempt = 0
|
||||
local repl
|
||||
repl = ReplLayout.new({
|
||||
lines = lines,
|
||||
on_submit = function(line)
|
||||
if line:upper() ~= confirmation:upper() then
|
||||
attempt = attempt % #hints + 1
|
||||
vim.api.nvim_buf_set_lines(repl.input_bufnr, 0, -1, true, {})
|
||||
vim.bo[repl.view_bufnr].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(repl.view_bufnr, 6, 7, true, { hints[attempt] })
|
||||
vim.bo[repl.view_bufnr].modifiable = false
|
||||
vim.bo[repl.view_bufnr].modified = false
|
||||
else
|
||||
fs.mkdirp(vim.fn.fnamemodify(marker_file, ":h"))
|
||||
fs.touch(
|
||||
marker_file,
|
||||
vim.schedule_wrap(function(err2)
|
||||
if err2 then
|
||||
vim.notify(
|
||||
string.format("Error recording response: %s", err2),
|
||||
vim.log.levels.WARN
|
||||
)
|
||||
end
|
||||
callback(true)
|
||||
repl:close()
|
||||
end)
|
||||
)
|
||||
end
|
||||
end,
|
||||
on_cancel = function()
|
||||
callback(false)
|
||||
end,
|
||||
})
|
||||
local ns = vim.api.nvim_create_namespace("Oil")
|
||||
vim.api.nvim_buf_add_highlight(repl.view_bufnr, ns, "DiagnosticError", 0, 0, -1)
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
return M
|
||||
507
lua/oil/mutator/init.lua
Normal file
507
lua/oil/mutator/init.lua
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
local cache = require("oil.cache")
|
||||
local columns = require("oil.columns")
|
||||
local config = require("oil.config")
|
||||
local disclaimer = require("oil.mutator.disclaimer")
|
||||
local oil = require("oil")
|
||||
local parser = require("oil.mutator.parser")
|
||||
local pathutil = require("oil.pathutil")
|
||||
local preview = require("oil.mutator.preview")
|
||||
local Progress = require("oil.mutator.progress")
|
||||
local Trie = require("oil.mutator.trie")
|
||||
local util = require("oil.util")
|
||||
local view = require("oil.view")
|
||||
local FIELD = require("oil.constants").FIELD
|
||||
local M = {}
|
||||
|
||||
---@alias oil.Action oil.CreateAction|oil.DeleteAction|oil.MoveAction|oil.CopyAction|oil.ChangeAction
|
||||
|
||||
---@class oil.CreateAction
|
||||
---@field type "create"
|
||||
---@field url string
|
||||
---@field entry_type oil.EntryType
|
||||
---@field link nil|string
|
||||
|
||||
---@class oil.DeleteAction
|
||||
---@field type "delete"
|
||||
---@field url string
|
||||
---@field entry_type oil.EntryType
|
||||
|
||||
---@class oil.MoveAction
|
||||
---@field type "move"
|
||||
---@field entry_type oil.EntryType
|
||||
---@field src_url string
|
||||
---@field dest_url string
|
||||
|
||||
---@class oil.CopyAction
|
||||
---@field type "copy"
|
||||
---@field entry_type oil.EntryType
|
||||
---@field src_url string
|
||||
---@field dest_url string
|
||||
|
||||
---@class 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 = {}
|
||||
|
||||
local diff_by_id = setmetatable({}, {
|
||||
__index = function(t, key)
|
||||
local list = {}
|
||||
rawset(t, key, list)
|
||||
return list
|
||||
end,
|
||||
})
|
||||
for bufnr, diffs in pairs(all_diffs) do
|
||||
local adapter = util.get_adapter(bufnr)
|
||||
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]
|
||||
-- FIXME this is kind of a hack. We shouldn't be setting undocumented fields on the diff
|
||||
diff.dest = parent_url .. diff.name
|
||||
table.insert(by_id, diff)
|
||||
else
|
||||
-- Parse nested files like foo/bar/baz
|
||||
local pieces = vim.split(diff.name, "/")
|
||||
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)
|
||||
table.insert(actions, {
|
||||
type = "create",
|
||||
url = alt_url,
|
||||
entry_type = entry_type,
|
||||
link = diff.link,
|
||||
})
|
||||
end
|
||||
else
|
||||
url = url .. "/" .. v
|
||||
table.insert(actions, {
|
||||
type = "create",
|
||||
url = url,
|
||||
entry_type = entry_type,
|
||||
link = diff.link,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif diff.type == "change" then
|
||||
table.insert(actions, {
|
||||
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]
|
||||
by_id.has_delete = true
|
||||
-- Don't insert the delete. We already know that there is a delete because of the presense
|
||||
-- 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
|
||||
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
|
||||
table.insert(actions, {
|
||||
type = i == #diffs and "move" or "copy",
|
||||
entry_type = entry[FIELD.type],
|
||||
dest_url = diff.dest,
|
||||
src_url = cache.get_parent_url(id) .. entry[FIELD.name],
|
||||
})
|
||||
end
|
||||
else
|
||||
-- DELETE when no create
|
||||
table.insert(actions, {
|
||||
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
|
||||
table.insert(actions, {
|
||||
type = "copy",
|
||||
entry_type = entry[FIELD.type],
|
||||
src_url = cache.get_parent_url(id) .. entry[FIELD.name],
|
||||
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.
|
||||
local function get_deps(action)
|
||||
local ret = {}
|
||||
if action.type == "delete" then
|
||||
return 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)
|
||||
-- Copy children before moving parent dir
|
||||
-- e.g. COPY /a/b -> /b BEFORE MOVE /a -> /d
|
||||
src_trie:accum_children_of(action.src_url, ret, function(a)
|
||||
return a.type == "copy"
|
||||
end)
|
||||
-- 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
|
||||
|
||||
---@param actions oil.Action[]
|
||||
---@param cb fun(err: nil|string)
|
||||
M.process_actions = function(actions, cb)
|
||||
-- convert delete actions to move-to-trash
|
||||
local trash_url = config.get_trash_url()
|
||||
if trash_url then
|
||||
for i, v in ipairs(actions) do
|
||||
if v.type == "delete" then
|
||||
local scheme, path = util.parse_url(v.url)
|
||||
if config.adapters[scheme] == "files" then
|
||||
actions[i] = {
|
||||
type = "move",
|
||||
src_url = v.url,
|
||||
entry_type = v.entry_type,
|
||||
dest_url = trash_url .. "/" .. pathutil.basename(path) .. string.format(
|
||||
"_%06d",
|
||||
math.random(999999)
|
||||
),
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Convert cross-adapter moves to a copy + delete
|
||||
for _, action in ipairs(actions) do
|
||||
if action.type == "move" then
|
||||
local src_scheme = util.parse_url(action.src_url)
|
||||
local dest_scheme = util.parse_url(action.dest_url)
|
||||
if src_scheme ~= dest_scheme then
|
||||
action.type = "copy"
|
||||
table.insert(actions, {
|
||||
type = "delete",
|
||||
url = action.src_url,
|
||||
entry_type = action.entry_type,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local finished = false
|
||||
local progress = Progress.new()
|
||||
-- Defer showing the progress to avoid flicker for fast operations
|
||||
vim.defer_fn(function()
|
||||
if not finished then
|
||||
progress:show()
|
||||
end
|
||||
end, 100)
|
||||
|
||||
local function finish(...)
|
||||
finished = true
|
||||
progress:close()
|
||||
cb(...)
|
||||
end
|
||||
|
||||
local idx = 1
|
||||
local next_action
|
||||
next_action = function()
|
||||
if idx > #actions then
|
||||
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 err then
|
||||
finish(err)
|
||||
else
|
||||
cache.perform_action(action)
|
||||
next_action()
|
||||
end
|
||||
end)
|
||||
if action.type == "change" then
|
||||
columns.perform_change_action(adapter, action, callback)
|
||||
else
|
||||
adapter.perform_action(action, callback)
|
||||
end
|
||||
end
|
||||
next_action()
|
||||
end
|
||||
|
||||
---@param confirm nil|boolean
|
||||
M.try_write_changes = function(confirm)
|
||||
local buffers = view.get_all_buffers()
|
||||
local all_diffs = {}
|
||||
local all_errors = {}
|
||||
for _, bufnr in ipairs(buffers) do
|
||||
-- Lock the buffer to prevent race conditions
|
||||
vim.bo[bufnr].modifiable = false
|
||||
if vim.bo[bufnr].modified then
|
||||
local diffs, errors = parser.parse(bufnr)
|
||||
all_diffs[bufnr] = diffs
|
||||
if not vim.tbl_isempty(errors) then
|
||||
all_errors[bufnr] = errors
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local ns = vim.api.nvim_create_namespace("Oil")
|
||||
vim.diagnostic.reset(ns)
|
||||
if not vim.tbl_isempty(all_errors) then
|
||||
vim.notify("Error parsing oil buffers", vim.log.levels.ERROR)
|
||||
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(pairs(all_errors))
|
||||
vim.api.nvim_win_set_buf(0, bufnr)
|
||||
pcall(vim.api.nvim_win_set_cursor, 0, { errs[1].lnum + 1, errs[1].col })
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local actions = M.create_actions_from_diffs(all_diffs)
|
||||
disclaimer.show(function(disclaimed)
|
||||
if not disclaimed then
|
||||
return
|
||||
end
|
||||
preview.show(actions, confirm, function(proceed)
|
||||
if not proceed then
|
||||
return
|
||||
end
|
||||
|
||||
M.process_actions(
|
||||
actions,
|
||||
vim.schedule_wrap(function(err)
|
||||
if err then
|
||||
vim.notify(string.format("[oil] Error applying actions: %s", err), vim.log.levels.ERROR)
|
||||
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.name, "/")[1]
|
||||
)
|
||||
end
|
||||
view.rerender_visible_and_cleanup({ preserve_undo = M.trash })
|
||||
end
|
||||
end)
|
||||
)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
225
lua/oil/mutator/parser.lua
Normal file
225
lua/oil/mutator/parser.lua
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
local cache = require("oil.cache")
|
||||
local columns = require("oil.columns")
|
||||
local util = require("oil.util")
|
||||
local view = require("oil.view")
|
||||
local FIELD = require("oil.constants").FIELD
|
||||
local M = {}
|
||||
|
||||
---@alias oil.Diff oil.DiffNew|oil.DiffDelete|oil.DiffChange
|
||||
|
||||
---@class oil.DiffNew
|
||||
---@field type "new"
|
||||
---@field name string
|
||||
---@field entry_type oil.EntryType
|
||||
---@field id nil|integer
|
||||
---@field link nil|string
|
||||
|
||||
---@class oil.DiffDelete
|
||||
---@field type "delete"
|
||||
---@field name string
|
||||
---@field id integer
|
||||
---
|
||||
---@class 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, "/")
|
||||
if isdir then
|
||||
name = name:sub(1, name:len() - 1)
|
||||
end
|
||||
return name, isdir
|
||||
end
|
||||
|
||||
---Parse a single line in a buffer
|
||||
---@param adapter oil.Adapter
|
||||
---@param line string
|
||||
---@param column_defs oil.ColumnSpec[]
|
||||
---@return table
|
||||
---@return nil|oil.InternalEntry
|
||||
M.parse_line = function(adapter, line, column_defs)
|
||||
local ret = {}
|
||||
local value, rem = line:match("^/(%d+) (.+)$")
|
||||
if not value then
|
||||
return nil, nil, "Malformed ID at start of line"
|
||||
end
|
||||
ret.id = tonumber(value)
|
||||
for _, def in ipairs(column_defs) do
|
||||
local name = util.split_config(def)
|
||||
value, rem = columns.parse_col(adapter, rem, def)
|
||||
if not value then
|
||||
return nil, nil, string.format("Parsing %s failed", name)
|
||||
end
|
||||
ret[name] = value
|
||||
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)
|
||||
if not entry then
|
||||
return ret
|
||||
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 ret
|
||||
end
|
||||
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 ret, entry
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return oil.Diff[]
|
||||
---@return table[] Parsing errors
|
||||
M.parse = function(bufnr)
|
||||
local diffs = {}
|
||||
local errors = {}
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local adapter = util.get_adapter(bufnr)
|
||||
if not adapter then
|
||||
table.insert(errors, {
|
||||
lnum = 1,
|
||||
col = 0,
|
||||
message = string.format("Cannot parse buffer '%s': No adapter", bufname),
|
||||
})
|
||||
return diffs, errors
|
||||
end
|
||||
local scheme, path = util.parse_url(bufname)
|
||||
local column_defs = columns.get_supported_columns(scheme)
|
||||
local parent_url = scheme .. path
|
||||
local children = cache.list_url(parent_url)
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)
|
||||
local original_entries = {}
|
||||
for _, child in pairs(children) do
|
||||
if view.should_display(child) then
|
||||
original_entries[child[FIELD.name]] = child[FIELD.id]
|
||||
end
|
||||
end
|
||||
for i, line in ipairs(lines) do
|
||||
if line:match("^/%d+") then
|
||||
local parsed_entry, entry, err = M.parse_line(adapter, line, column_defs)
|
||||
if err then
|
||||
table.insert(errors, {
|
||||
message = err,
|
||||
lnum = i - 1,
|
||||
col = 0,
|
||||
})
|
||||
goto continue
|
||||
end
|
||||
if not parsed_entry.name or parsed_entry.name:match("/") or not entry then
|
||||
local message
|
||||
if not parsed_entry.name then
|
||||
message = "No filename found"
|
||||
elseif not entry then
|
||||
message = "Could not find existing entry (was the ID changed?)"
|
||||
else
|
||||
message = "Filename cannot contain '/'"
|
||||
end
|
||||
table.insert(errors, {
|
||||
message = message,
|
||||
lnum = i,
|
||||
col = 0,
|
||||
})
|
||||
goto continue
|
||||
end
|
||||
local meta = entry[FIELD.meta]
|
||||
if original_entries[parsed_entry.name] == parsed_entry.id then
|
||||
if entry[FIELD.type] == "link" and (not meta or meta.link ~= parsed_entry.link_target) then
|
||||
table.insert(diffs, {
|
||||
type = "new",
|
||||
name = parsed_entry.name,
|
||||
entry_type = "link",
|
||||
link = parsed_entry.link_target,
|
||||
})
|
||||
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
|
||||
local name, isdir = parsedir(vim.trim(line))
|
||||
if vim.startswith(name, "/") then
|
||||
table.insert(errors, {
|
||||
message = "Paths cannot start with '/'",
|
||||
lnum = i,
|
||||
col = 0,
|
||||
})
|
||||
goto continue
|
||||
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
|
||||
table.insert(diffs, {
|
||||
type = "new",
|
||||
name = name,
|
||||
entry_type = entry_type,
|
||||
link = link,
|
||||
})
|
||||
end
|
||||
end
|
||||
::continue::
|
||||
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
|
||||
130
lua/oil/mutator/preview.lua
Normal file
130
lua/oil/mutator/preview.lua
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
local columns = require("oil.columns")
|
||||
local config = require("oil.config")
|
||||
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 actions oil.Action[]
|
||||
---@param should_confirm nil|boolean
|
||||
---@param cb fun(proceed: boolean)
|
||||
M.show = vim.schedule_wrap(function(actions, should_confirm, cb)
|
||||
if should_confirm == false 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
|
||||
-- The schedule wrap ensures that we actually enter the floating window.
|
||||
-- Not sure why it doesn't work without that
|
||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[bufnr].bufhidden = "wipe"
|
||||
local width = 120
|
||||
local height = 40
|
||||
local winid = vim.api.nvim_open_win(bufnr, true, {
|
||||
relative = "editor",
|
||||
width = width,
|
||||
height = height,
|
||||
row = math.floor((vim.o.lines - vim.o.cmdheight - height) / 2),
|
||||
col = math.floor((vim.o.columns - width) / 2),
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
})
|
||||
vim.bo[bufnr].syntax = "oil_preview"
|
||||
|
||||
local lines = {}
|
||||
for _, action in ipairs(actions) do
|
||||
local adapter = util.get_adapter_for_action(action)
|
||||
if action.type == "change" then
|
||||
table.insert(lines, columns.render_change_action(adapter, action))
|
||||
else
|
||||
table.insert(lines, adapter.render_action(action))
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
width = vim.api.nvim_win_get_width(0)
|
||||
local last_line = "[O]k [C]ancel"
|
||||
local highlights = {}
|
||||
local padding = string.rep(" ", math.floor((width - last_line:len()) / 2))
|
||||
last_line = padding .. last_line
|
||||
table.insert(highlights, { "Special", #lines, padding:len(), padding:len() + 3 })
|
||||
table.insert(highlights, { "Special", #lines, padding:len() + 8, padding:len() + 11 })
|
||||
table.insert(lines, last_line)
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
|
||||
vim.bo[bufnr].modified = false
|
||||
vim.bo[bufnr].modifiable = false
|
||||
local ns = vim.api.nvim_create_namespace("Oil")
|
||||
for _, hl in ipairs(highlights) do
|
||||
vim.api.nvim_buf_add_highlight(bufnr, ns, unpack(hl))
|
||||
end
|
||||
|
||||
local cancel
|
||||
local confirm
|
||||
local function make_callback(value)
|
||||
return function()
|
||||
confirm = function() end
|
||||
cancel = function() end
|
||||
vim.api.nvim_win_close(winid, true)
|
||||
cb(value)
|
||||
end
|
||||
end
|
||||
cancel = make_callback(false)
|
||||
confirm = make_callback(true)
|
||||
vim.api.nvim_create_autocmd("BufLeave", {
|
||||
callback = cancel,
|
||||
once = true,
|
||||
nested = true,
|
||||
buffer = bufnr,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("WinLeave", {
|
||||
callback = cancel,
|
||||
once = true,
|
||||
nested = true,
|
||||
})
|
||||
vim.keymap.set("n", "q", cancel, { buffer = bufnr })
|
||||
vim.keymap.set("n", "C", cancel, { buffer = bufnr })
|
||||
vim.keymap.set("n", "c", cancel, { buffer = bufnr })
|
||||
vim.keymap.set("n", "<Esc>", cancel, { buffer = bufnr })
|
||||
vim.keymap.set("n", "<C-c>", cancel, { buffer = bufnr })
|
||||
vim.keymap.set("n", "O", confirm, { buffer = bufnr })
|
||||
vim.keymap.set("n", "o", confirm, { buffer = bufnr })
|
||||
end)
|
||||
|
||||
return M
|
||||
77
lua/oil/mutator/progress.lua
Normal file
77
lua/oil/mutator/progress.lua
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
local columns = require("oil.columns")
|
||||
local loading = require("oil.loading")
|
||||
local util = require("oil.util")
|
||||
local Progress = {}
|
||||
|
||||
local FPS = 20
|
||||
|
||||
function Progress.new()
|
||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[bufnr].bufhidden = "wipe"
|
||||
return setmetatable({
|
||||
lines = { "", "", "" },
|
||||
bufnr = bufnr,
|
||||
}, {
|
||||
__index = Progress,
|
||||
})
|
||||
end
|
||||
|
||||
function Progress:show()
|
||||
if self.winid and vim.api.nvim_win_is_valid(self.winid) then
|
||||
return
|
||||
end
|
||||
local loading_iter = loading.get_bar_iter()
|
||||
self.timer = vim.loop.new_timer()
|
||||
self.timer:start(
|
||||
0,
|
||||
math.floor(1000 / FPS),
|
||||
vim.schedule_wrap(function()
|
||||
self.lines[2] = loading_iter()
|
||||
self:_render()
|
||||
end)
|
||||
)
|
||||
local width = 120
|
||||
local height = 10
|
||||
self.winid = vim.api.nvim_open_win(self.bufnr, true, {
|
||||
relative = "editor",
|
||||
width = width,
|
||||
height = height,
|
||||
row = math.floor((vim.o.lines - vim.o.cmdheight - height) / 2),
|
||||
col = math.floor((vim.o.columns - width) / 2),
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
})
|
||||
end
|
||||
|
||||
function Progress:_render()
|
||||
util.render_centered_text(self.bufnr, self.lines)
|
||||
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
|
||||
change_line = columns.render_change_action(adapter, action)
|
||||
else
|
||||
change_line = adapter.render_action(action)
|
||||
end
|
||||
self.lines[1] = change_line
|
||||
self.lines[3] = string.format("[%d/%d]", idx, total)
|
||||
self:_render()
|
||||
end
|
||||
|
||||
function Progress:close()
|
||||
if self.timer then
|
||||
self.timer:close()
|
||||
self.timer = nil
|
||||
end
|
||||
if self.winid then
|
||||
vim.api.nvim_win_close(self.winid, true)
|
||||
self.winid = nil
|
||||
end
|
||||
end
|
||||
|
||||
return Progress
|
||||
153
lua/oil/mutator/trie.lua
Normal file
153
lua/oil/mutator/trie.lua
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
local util = require("oil.util")
|
||||
local Trie = {}
|
||||
|
||||
Trie.new = function()
|
||||
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)
|
||||
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.InternalEntry): 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 nil|fun(entry: oil.InternalEntry): 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
|
||||
37
lua/oil/pathutil.lua
Normal file
37
lua/oil/pathutil.lua
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
local fs = require("oil.fs")
|
||||
local M = {}
|
||||
|
||||
---@param path string
|
||||
---@return string
|
||||
M.parent = function(path)
|
||||
-- Do I love this hack? No I do not.
|
||||
-- Does it work? Yes. Mostly. For now.
|
||||
if fs.is_windows then
|
||||
if path:match("^/%a+/?$") then
|
||||
return path
|
||||
end
|
||||
end
|
||||
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
|
||||
140
lua/oil/repl_layout.lua
Normal file
140
lua/oil/repl_layout.lua
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
local util = require("oil.util")
|
||||
local ReplLayout = {}
|
||||
|
||||
---@param opts table
|
||||
--- min_height integer
|
||||
--- min_width integer
|
||||
--- lines string[]
|
||||
--- on_submit fun(text: string): boolean
|
||||
--- on_cancel nil|fun()
|
||||
ReplLayout.new = function(opts)
|
||||
opts = vim.tbl_deep_extend("keep", opts or {}, {
|
||||
min_height = 10,
|
||||
min_width = 120,
|
||||
})
|
||||
vim.validate({
|
||||
lines = { opts.lines, "t" },
|
||||
min_height = { opts.min_height, "n" },
|
||||
min_width = { opts.min_width, "n" },
|
||||
on_submit = { opts.on_submit, "f" },
|
||||
on_cancel = { opts.on_cancel, "f", true },
|
||||
})
|
||||
local total_height = util.get_editor_height()
|
||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[bufnr].bufhidden = "wipe"
|
||||
local width = math.min(opts.min_width, vim.o.columns - 2)
|
||||
local height = math.min(opts.min_height, total_height - 3)
|
||||
local row = math.floor((util.get_editor_height() - height) / 2)
|
||||
local col = math.floor((vim.o.columns - width) / 2)
|
||||
local view_winid = vim.api.nvim_open_win(bufnr, false, {
|
||||
relative = "editor",
|
||||
width = width,
|
||||
height = height,
|
||||
row = row,
|
||||
col = col,
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
focusable = false,
|
||||
})
|
||||
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, opts.lines)
|
||||
vim.bo[bufnr].modified = false
|
||||
vim.bo[bufnr].modifiable = false
|
||||
vim.api.nvim_win_set_cursor(view_winid, { #opts.lines, 0 })
|
||||
|
||||
local input_bufnr = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[input_bufnr].bufhidden = "wipe"
|
||||
local input_winid = vim.api.nvim_open_win(input_bufnr, true, {
|
||||
relative = "editor",
|
||||
width = width,
|
||||
height = 1,
|
||||
row = row + height + 2,
|
||||
col = col,
|
||||
style = "minimal",
|
||||
border = "rounded",
|
||||
})
|
||||
|
||||
vim.api.nvim_create_autocmd("WinClosed", {
|
||||
desc = "Close oil repl window when text input closes",
|
||||
pattern = tostring(input_winid),
|
||||
callback = function()
|
||||
if view_winid and vim.api.nvim_win_is_valid(view_winid) then
|
||||
vim.api.nvim_win_close(view_winid, true)
|
||||
end
|
||||
end,
|
||||
once = true,
|
||||
nested = true,
|
||||
})
|
||||
|
||||
local self = setmetatable({
|
||||
input_bufnr = input_bufnr,
|
||||
view_bufnr = bufnr,
|
||||
input_winid = input_winid,
|
||||
view_winid = view_winid,
|
||||
_cancel = nil,
|
||||
_submit = nil,
|
||||
}, {
|
||||
__index = ReplLayout,
|
||||
})
|
||||
self._cancel = function()
|
||||
self:close()
|
||||
if opts.on_cancel then
|
||||
opts.on_cancel()
|
||||
end
|
||||
end
|
||||
self._submit = function()
|
||||
local line = vim.trim(vim.api.nvim_buf_get_lines(input_bufnr, 0, 1, true)[1])
|
||||
if line == "" then
|
||||
return
|
||||
end
|
||||
if not opts.on_submit(line) then
|
||||
vim.api.nvim_buf_set_lines(input_bufnr, 0, -1, true, {})
|
||||
vim.bo[input_bufnr].modified = false
|
||||
end
|
||||
end
|
||||
local cancel = function()
|
||||
self._cancel()
|
||||
end
|
||||
vim.api.nvim_create_autocmd("BufLeave", {
|
||||
callback = cancel,
|
||||
once = true,
|
||||
nested = true,
|
||||
buffer = input_bufnr,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("WinLeave", {
|
||||
callback = cancel,
|
||||
once = true,
|
||||
nested = true,
|
||||
})
|
||||
vim.keymap.set("n", "<Esc>", cancel, { buffer = input_bufnr })
|
||||
vim.keymap.set({ "n", "i" }, "<C-c>", cancel, { buffer = input_bufnr })
|
||||
vim.keymap.set({ "n", "i" }, "<CR>", function()
|
||||
self._submit()
|
||||
end, { buffer = input_bufnr })
|
||||
vim.cmd.startinsert()
|
||||
return self
|
||||
end
|
||||
|
||||
function ReplLayout:append_view_lines(lines)
|
||||
local bufnr = self.view_bufnr
|
||||
local num_lines = vim.api.nvim_buf_line_count(bufnr)
|
||||
local last_line = vim.api.nvim_buf_get_lines(bufnr, num_lines - 1, num_lines, true)[1]
|
||||
lines[1] = last_line .. lines[1]
|
||||
for i, v in ipairs(lines) do
|
||||
lines[i] = v:gsub("\r$", "")
|
||||
end
|
||||
vim.bo[bufnr].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(bufnr, num_lines - 1, -1, true, lines)
|
||||
vim.bo[bufnr].modifiable = false
|
||||
vim.bo[bufnr].modified = false
|
||||
vim.api.nvim_win_set_cursor(self.view_winid, { num_lines + #lines - 1, 0 })
|
||||
end
|
||||
|
||||
function ReplLayout:close()
|
||||
self._submit = function() end
|
||||
self._cancel = function() end
|
||||
vim.cmd.stopinsert()
|
||||
vim.api.nvim_win_close(self.input_winid, true)
|
||||
end
|
||||
|
||||
return ReplLayout
|
||||
40
lua/oil/shell.lua
Normal file
40
lua/oil/shell.lua
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
local M = {}
|
||||
|
||||
M.run = function(cmd, callback)
|
||||
local stdout
|
||||
local stderr = {}
|
||||
local jid = vim.fn.jobstart(cmd, {
|
||||
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
|
||||
callback(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
|
||||
500
lua/oil/util.lua
Normal file
500
lua/oil/util.lua
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
local config = require("oil.config")
|
||||
local M = {}
|
||||
|
||||
---@param url string
|
||||
---@return nil|string
|
||||
---@return nil|string
|
||||
M.parse_url = function(url)
|
||||
return url:match("^(.*://)(.*)$")
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return nil|oil.Adapter
|
||||
M.get_adapter = function(bufnr)
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local adapter = config.get_adapter_by_scheme(bufname)
|
||||
if not adapter then
|
||||
vim.notify_once(
|
||||
string.format("[oil] could not find adapter for buffer '%s://'", bufname),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
end
|
||||
return adapter
|
||||
end
|
||||
|
||||
---@param text string
|
||||
---@param length nil|integer
|
||||
---@return string
|
||||
M.rpad = function(text, length)
|
||||
if not length then
|
||||
return text
|
||||
end
|
||||
local textlen = vim.api.nvim_strwidth(text)
|
||||
local delta = length - textlen
|
||||
if delta > 0 then
|
||||
return text .. string.rep(" ", delta)
|
||||
else
|
||||
return text
|
||||
end
|
||||
end
|
||||
|
||||
---@param text string
|
||||
---@param length nil|integer
|
||||
---@return string
|
||||
M.lpad = function(text, length)
|
||||
if not length then
|
||||
return text
|
||||
end
|
||||
local textlen = vim.api.nvim_strwidth(text)
|
||||
local delta = length - textlen
|
||||
if delta > 0 then
|
||||
return string.rep(" ", delta) .. text
|
||||
else
|
||||
return text
|
||||
end
|
||||
end
|
||||
|
||||
---@generic T : any
|
||||
---@param tbl T[]
|
||||
---@param start_idx? number
|
||||
---@param end_idx? number
|
||||
---@return T[]
|
||||
M.tbl_slice = function(tbl, start_idx, end_idx)
|
||||
local ret = {}
|
||||
if not start_idx then
|
||||
start_idx = 1
|
||||
end
|
||||
if not end_idx then
|
||||
end_idx = #tbl
|
||||
end
|
||||
for i = start_idx, end_idx do
|
||||
table.insert(ret, tbl[i])
|
||||
end
|
||||
return ret
|
||||
end
|
||||
|
||||
---@param entry oil.InternalEntry
|
||||
---@return oil.Entry
|
||||
M.export_entry = function(entry)
|
||||
local FIELD = require("oil.constants").FIELD
|
||||
return {
|
||||
name = entry[FIELD.name],
|
||||
type = entry[FIELD.type],
|
||||
id = entry[FIELD.id],
|
||||
meta = entry[FIELD.meta],
|
||||
}
|
||||
end
|
||||
|
||||
---@param src_bufnr integer|string Buffer number or name
|
||||
---@param dest_buf_name string
|
||||
M.rename_buffer = function(src_bufnr, dest_buf_name)
|
||||
if type(src_bufnr) == "string" then
|
||||
src_bufnr = vim.fn.bufadd(src_bufnr)
|
||||
if not vim.api.nvim_buf_is_loaded(src_bufnr) then
|
||||
vim.api.nvim_buf_delete(src_bufnr, {})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local bufname = vim.api.nvim_buf_get_name(src_bufnr)
|
||||
local scheme = M.parse_url(bufname)
|
||||
-- If this buffer has a scheme (is not literally a file on disk), then we can use the simple
|
||||
-- rename logic. The only reason we can't use nvim_buf_set_name on files is because vim will
|
||||
-- think that the new buffer conflicts with the file next time it tries to save.
|
||||
if scheme or vim.fn.isdirectory(bufname) == 1 then
|
||||
-- This will fail if the dest buf name already exists
|
||||
local ok = pcall(vim.api.nvim_buf_set_name, src_bufnr, dest_buf_name)
|
||||
if ok then
|
||||
-- Renaming the buffer creates a new buffer with the old name. Find it and delete it.
|
||||
vim.api.nvim_buf_delete(vim.fn.bufadd(bufname), {})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local dest_bufnr = vim.fn.bufadd(dest_buf_name)
|
||||
vim.fn.bufload(dest_bufnr)
|
||||
if vim.bo[src_bufnr].buflisted then
|
||||
vim.bo[dest_bufnr].buflisted = true
|
||||
end
|
||||
-- Find any windows with the old buffer and replace them
|
||||
for _, winid in ipairs(vim.api.nvim_list_wins()) do
|
||||
if vim.api.nvim_win_is_valid(winid) then
|
||||
if vim.api.nvim_win_get_buf(winid) == src_bufnr then
|
||||
vim.api.nvim_win_set_buf(winid, dest_bufnr)
|
||||
end
|
||||
end
|
||||
end
|
||||
if vim.bo[src_bufnr].modified then
|
||||
local src_lines = vim.api.nvim_buf_get_lines(src_bufnr, 0, -1, true)
|
||||
vim.api.nvim_buf_set_lines(dest_bufnr, 0, -1, true, src_lines)
|
||||
end
|
||||
-- Try to delete, but don't if the buffer has changes
|
||||
pcall(vim.api.nvim_buf_delete, src_bufnr, {})
|
||||
end
|
||||
|
||||
---@param count integer
|
||||
---@param cb fun(err: nil|string)
|
||||
M.cb_collect = function(count, cb)
|
||||
return function(err)
|
||||
if err then
|
||||
cb(err)
|
||||
cb = function() end
|
||||
else
|
||||
count = count - 1
|
||||
if count == 0 then
|
||||
cb()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@return string[]
|
||||
local function get_possible_buffer_names_from_url(url)
|
||||
local fs = require("oil.fs")
|
||||
local scheme, path = M.parse_url(url)
|
||||
local ret = {}
|
||||
for k, v in pairs(config.remap_schemes) do
|
||||
if v == scheme then
|
||||
if k ~= "default" then
|
||||
table.insert(ret, k .. path)
|
||||
end
|
||||
end
|
||||
end
|
||||
if vim.tbl_isempty(ret) then
|
||||
return { fs.posix_to_os_path(path) }
|
||||
else
|
||||
return ret
|
||||
end
|
||||
end
|
||||
|
||||
---@param entry_type oil.EntryType
|
||||
---@param src_url string
|
||||
---@param dest_url string
|
||||
M.update_moved_buffers = function(entry_type, src_url, dest_url)
|
||||
local src_buf_names = get_possible_buffer_names_from_url(src_url)
|
||||
local dest_buf_name = get_possible_buffer_names_from_url(dest_url)[1]
|
||||
if entry_type ~= "directory" then
|
||||
for _, src_buf_name in ipairs(src_buf_names) do
|
||||
M.rename_buffer(src_buf_name, dest_buf_name)
|
||||
end
|
||||
else
|
||||
M.rename_buffer(M.addslash(src_url), M.addslash(dest_url))
|
||||
-- If entry type is directory, we need to rename this buffer, and then update buffers that are
|
||||
-- inside of this directory
|
||||
|
||||
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
if vim.startswith(bufname, src_url) then
|
||||
-- Handle oil directory buffers
|
||||
vim.api.nvim_buf_set_name(bufnr, dest_url .. bufname:sub(src_url:len() + 1))
|
||||
elseif bufname ~= "" and vim.bo[bufnr].buftype == "" then
|
||||
-- Handle regular buffers
|
||||
local scheme = M.parse_url(bufname)
|
||||
|
||||
-- If the buffer is a local file, make sure we're using the absolute path
|
||||
if not scheme then
|
||||
bufname = vim.fn.fnamemodify(bufname, ":p")
|
||||
end
|
||||
|
||||
for _, src_buf_name in ipairs(src_buf_names) do
|
||||
if vim.startswith(bufname, src_buf_name) then
|
||||
M.rename_buffer(bufnr, dest_buf_name .. bufname:sub(src_buf_name:len() + 1))
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param name_or_config string|table
|
||||
---@return string
|
||||
---@return table|nil
|
||||
M.split_config = function(name_or_config)
|
||||
if type(name_or_config) == "string" then
|
||||
return name_or_config, nil
|
||||
else
|
||||
if not name_or_config[1] and name_or_config["1"] then
|
||||
-- This was likely loaded from json, so the first element got coerced to a string key
|
||||
name_or_config[1] = name_or_config["1"]
|
||||
name_or_config["1"] = nil
|
||||
end
|
||||
return name_or_config[1], name_or_config
|
||||
end
|
||||
end
|
||||
|
||||
---@param lines oil.TextChunk[][]
|
||||
---@param col_width integer[]
|
||||
---@return string[]
|
||||
---@return any[][] List of highlights {group, lnum, col_start, col_end}
|
||||
M.render_table = function(lines, col_width)
|
||||
local str_lines = {}
|
||||
local highlights = {}
|
||||
for _, cols in ipairs(lines) do
|
||||
local col = 0
|
||||
local pieces = {}
|
||||
for i, chunk in ipairs(cols) do
|
||||
local text, hl
|
||||
if type(chunk) == "table" then
|
||||
text, hl = unpack(chunk)
|
||||
else
|
||||
text = chunk
|
||||
end
|
||||
text = M.rpad(text, col_width[i])
|
||||
table.insert(pieces, text)
|
||||
local col_end = col + text:len() + 1
|
||||
if hl then
|
||||
table.insert(highlights, { hl, #str_lines, col, col_end })
|
||||
end
|
||||
col = col_end
|
||||
end
|
||||
table.insert(str_lines, table.concat(pieces, " "))
|
||||
end
|
||||
return str_lines, highlights
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param highlights any[][] List of highlights {group, lnum, col_start, col_end}
|
||||
M.set_highlights = function(bufnr, highlights)
|
||||
local ns = vim.api.nvim_create_namespace("Oil")
|
||||
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
|
||||
for _, hl in ipairs(highlights) do
|
||||
vim.api.nvim_buf_add_highlight(bufnr, ns, unpack(hl))
|
||||
end
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@return string
|
||||
M.addslash = function(path)
|
||||
if not vim.endswith(path, "/") then
|
||||
return path .. "/"
|
||||
else
|
||||
return path
|
||||
end
|
||||
end
|
||||
|
||||
---@param winid nil|integer
|
||||
---@return boolean
|
||||
M.is_floating_win = function(winid)
|
||||
return vim.api.nvim_win_get_config(winid or 0).relative ~= ""
|
||||
end
|
||||
|
||||
---@return integer
|
||||
M.get_editor_height = function()
|
||||
local total_height = vim.o.lines - vim.o.cmdheight
|
||||
if vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1) then
|
||||
total_height = total_height - 1
|
||||
end
|
||||
if
|
||||
vim.o.laststatus >= 2 or (vim.o.laststatus == 1 and #vim.api.nvim_tabpage_list_wins(0) > 1)
|
||||
then
|
||||
total_height = total_height - 1
|
||||
end
|
||||
return total_height
|
||||
end
|
||||
|
||||
local winid_map = {}
|
||||
M.add_title_to_win = function(winid, title, opts)
|
||||
opts = opts or {}
|
||||
opts.align = opts.align or "left"
|
||||
if not vim.api.nvim_win_is_valid(winid) then
|
||||
return
|
||||
end
|
||||
-- HACK to force the parent window to position itself
|
||||
-- See https://github.com/neovim/neovim/issues/13403
|
||||
vim.cmd.redraw()
|
||||
local width = math.min(vim.api.nvim_win_get_width(winid) - 4, 2 + vim.api.nvim_strwidth(title))
|
||||
local title_winid = winid_map[winid]
|
||||
local bufnr
|
||||
if title_winid and vim.api.nvim_win_is_valid(title_winid) then
|
||||
vim.api.nvim_win_set_width(title_winid, width)
|
||||
bufnr = vim.api.nvim_win_get_buf(title_winid)
|
||||
else
|
||||
bufnr = vim.api.nvim_create_buf(false, true)
|
||||
local col = 1
|
||||
if opts.align == "center" then
|
||||
col = math.floor((vim.api.nvim_win_get_width(winid) - width) / 2)
|
||||
elseif opts.align == "right" then
|
||||
col = vim.api.nvim_win_get_width(winid) - 1 - width
|
||||
elseif opts.align ~= "left" then
|
||||
vim.notify(
|
||||
string.format("Unknown oil window title alignment: '%s'", opts.align),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
end
|
||||
title_winid = vim.api.nvim_open_win(bufnr, false, {
|
||||
relative = "win",
|
||||
win = winid,
|
||||
width = width,
|
||||
height = 1,
|
||||
row = -1,
|
||||
col = col,
|
||||
focusable = false,
|
||||
zindex = 151,
|
||||
style = "minimal",
|
||||
noautocmd = true,
|
||||
})
|
||||
winid_map[winid] = title_winid
|
||||
vim.api.nvim_win_set_option(
|
||||
title_winid,
|
||||
"winblend",
|
||||
vim.api.nvim_win_get_option(winid, "winblend")
|
||||
)
|
||||
vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe")
|
||||
|
||||
local update_autocmd = vim.api.nvim_create_autocmd("BufWinEnter", {
|
||||
desc = "Update oil floating window title when buffer changes",
|
||||
pattern = "*",
|
||||
callback = function(params)
|
||||
local winbuf = params.buf
|
||||
if vim.api.nvim_win_get_buf(winid) ~= winbuf then
|
||||
return
|
||||
end
|
||||
local bufname = vim.api.nvim_buf_get_name(winbuf)
|
||||
local new_width =
|
||||
math.min(vim.api.nvim_win_get_width(winid) - 4, 2 + vim.api.nvim_strwidth(bufname))
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, { " " .. bufname .. " " })
|
||||
vim.bo[bufnr].modified = false
|
||||
vim.api.nvim_win_set_width(title_winid, new_width)
|
||||
local new_col = 1
|
||||
if opts.align == "center" then
|
||||
new_col = math.floor((vim.api.nvim_win_get_width(winid) - new_width) / 2)
|
||||
elseif opts.align == "right" then
|
||||
new_col = vim.api.nvim_win_get_width(winid) - 1 - new_width
|
||||
end
|
||||
vim.api.nvim_win_set_config(title_winid, {
|
||||
relative = "win",
|
||||
win = winid,
|
||||
row = -1,
|
||||
col = new_col,
|
||||
width = new_width,
|
||||
height = 1,
|
||||
})
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("WinClosed", {
|
||||
desc = "Close oil floating window title when floating window closes",
|
||||
pattern = tostring(winid),
|
||||
callback = function()
|
||||
if title_winid and vim.api.nvim_win_is_valid(title_winid) then
|
||||
vim.api.nvim_win_close(title_winid, true)
|
||||
end
|
||||
winid_map[winid] = nil
|
||||
vim.api.nvim_del_autocmd(update_autocmd)
|
||||
end,
|
||||
once = true,
|
||||
nested = true,
|
||||
})
|
||||
end
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, { " " .. title .. " " })
|
||||
vim.bo[bufnr].modified = false
|
||||
vim.api.nvim_win_set_option(
|
||||
title_winid,
|
||||
"winhighlight",
|
||||
"Normal:FloatTitle,NormalFloat:FloatTitle"
|
||||
)
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
---@return oil.Adapter
|
||||
M.get_adapter_for_action = function(action)
|
||||
local adapter = config.get_adapter_by_scheme(action.url or action.src_url)
|
||||
if not adapter then
|
||||
error("no adapter found")
|
||||
end
|
||||
if action.dest_url then
|
||||
local dest_adapter = config.get_adapter_by_scheme(action.dest_url)
|
||||
if adapter ~= dest_adapter then
|
||||
if adapter.supports_xfer and adapter.supports_xfer[dest_adapter.name] then
|
||||
return adapter
|
||||
elseif dest_adapter.supports_xfer and dest_adapter.supports_xfer[adapter.name] then
|
||||
return dest_adapter
|
||||
else
|
||||
error(
|
||||
string.format(
|
||||
"Cannot copy files from %s -> %s; no cross-adapter transfer method found",
|
||||
action.src_url,
|
||||
action.dest_url
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
return adapter
|
||||
end
|
||||
|
||||
M.render_centered_text = function(bufnr, text)
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
return
|
||||
end
|
||||
if type(text) == "string" then
|
||||
text = { text }
|
||||
end
|
||||
local winid
|
||||
for _, win in ipairs(vim.api.nvim_list_wins()) do
|
||||
if vim.api.nvim_win_get_buf(win) == bufnr then
|
||||
winid = win
|
||||
break
|
||||
end
|
||||
end
|
||||
local height = 40
|
||||
local width = 30
|
||||
if winid then
|
||||
height = vim.api.nvim_win_get_height(winid)
|
||||
width = vim.api.nvim_win_get_width(winid)
|
||||
end
|
||||
local lines = {}
|
||||
for _ = 1, (height / 2) - (#text / 2) do
|
||||
table.insert(lines, "")
|
||||
end
|
||||
for _, line in ipairs(text) do
|
||||
line = string.rep(" ", (width - vim.api.nvim_strwidth(line)) / 2) .. line
|
||||
table.insert(lines, line)
|
||||
end
|
||||
vim.api.nvim_buf_set_option(bufnr, "modifiable", true)
|
||||
pcall(vim.api.nvim_buf_set_lines, bufnr, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(bufnr, "modifiable", false)
|
||||
vim.bo[bufnr].modified = false
|
||||
end
|
||||
|
||||
---Run a function in the context of a full-editor window
|
||||
---@param bufnr nil|integer
|
||||
---@param callback fun()
|
||||
M.run_in_fullscreen_win = function(bufnr, callback)
|
||||
if not bufnr then
|
||||
bufnr = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe")
|
||||
end
|
||||
local winid = vim.api.nvim_open_win(bufnr, false, {
|
||||
relative = "editor",
|
||||
width = vim.o.columns,
|
||||
height = vim.o.lines,
|
||||
row = 0,
|
||||
col = 0,
|
||||
noautocmd = true,
|
||||
})
|
||||
local winnr = vim.api.nvim_win_get_number(winid)
|
||||
vim.cmd.wincmd({ count = winnr, args = { "w" }, mods = { noautocmd = true } })
|
||||
callback()
|
||||
vim.cmd.close({ count = winnr, mods = { noautocmd = true, emsg_silent = true } })
|
||||
end
|
||||
|
||||
---This is a hack so we don't end up in insert mode after starting a task
|
||||
---@param prev_mode string The vim mode we were in before opening a terminal
|
||||
M.hack_around_termopen_autocmd = function(prev_mode)
|
||||
-- It's common to have autocmds that enter insert mode when opening a terminal
|
||||
vim.defer_fn(function()
|
||||
local new_mode = vim.api.nvim_get_mode().mode
|
||||
if new_mode ~= prev_mode then
|
||||
if string.find(new_mode, "i") == 1 then
|
||||
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<ESC>", true, true, true), "n", false)
|
||||
if string.find(prev_mode, "v") == 1 or string.find(prev_mode, "V") == 1 then
|
||||
vim.cmd.normal({ bang = true, args = { "gv" } })
|
||||
end
|
||||
end
|
||||
end
|
||||
end, 10)
|
||||
end
|
||||
|
||||
return M
|
||||
430
lua/oil/view.lua
Normal file
430
lua/oil/view.lua
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
local cache = require("oil.cache")
|
||||
local columns = require("oil.columns")
|
||||
local config = require("oil.config")
|
||||
local keymap_util = require("oil.keymap_util")
|
||||
local loading = require("oil.loading")
|
||||
local util = require("oil.util")
|
||||
local FIELD = require("oil.constants").FIELD
|
||||
local M = {}
|
||||
|
||||
-- map of path->last entry under cursor
|
||||
local last_cursor_entry = {}
|
||||
|
||||
---@param entry oil.InternalEntry
|
||||
---@return boolean
|
||||
M.should_display = function(entry)
|
||||
local name = entry[FIELD.name]
|
||||
if not config.view_options.show_hidden and vim.startswith(name, ".") then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
---@param bufname string
|
||||
---@param name string
|
||||
M.set_last_cursor = function(bufname, name)
|
||||
last_cursor_entry[bufname] = name
|
||||
end
|
||||
|
||||
---@param bufname string
|
||||
---@return nil|string
|
||||
M.get_last_cursor = function(bufname)
|
||||
return last_cursor_entry[bufname]
|
||||
end
|
||||
|
||||
local function are_any_modified()
|
||||
local view = require("oil.view")
|
||||
local buffers = view.get_all_buffers()
|
||||
for _, bufnr in ipairs(buffers) do
|
||||
if vim.bo[bufnr].modified then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
M.toggle_hidden = function()
|
||||
local view = require("oil.view")
|
||||
local any_modified = are_any_modified()
|
||||
if any_modified then
|
||||
vim.notify("Cannot toggle hidden files when you have unsaved changes", vim.log.levels.WARN)
|
||||
else
|
||||
config.view_options.show_hidden = not config.view_options.show_hidden
|
||||
view.rerender_visible_and_cleanup({ refetch = false })
|
||||
end
|
||||
end
|
||||
|
||||
M.set_columns = function(cols)
|
||||
local view = require("oil.view")
|
||||
local any_modified = are_any_modified()
|
||||
if any_modified then
|
||||
vim.notify("Cannot change columns when you have unsaved changes", vim.log.levels.WARN)
|
||||
else
|
||||
config.columns = cols
|
||||
-- TODO only refetch if we don't have all the necessary data for the columns
|
||||
view.rerender_visible_and_cleanup({ refetch = true })
|
||||
end
|
||||
end
|
||||
|
||||
-- List of bufnrs
|
||||
local session = {}
|
||||
|
||||
---@return integer[]
|
||||
M.get_all_buffers = function()
|
||||
return vim.tbl_filter(vim.api.nvim_buf_is_loaded, vim.tbl_keys(session))
|
||||
end
|
||||
|
||||
---@param opts table
|
||||
---@note
|
||||
--- This DISCARDS ALL MODIFICATIONS a user has made to oil buffers
|
||||
M.rerender_visible_and_cleanup = function(opts)
|
||||
local buffers = M.get_all_buffers()
|
||||
local hidden_buffers = {}
|
||||
for _, bufnr in ipairs(buffers) do
|
||||
hidden_buffers[bufnr] = true
|
||||
end
|
||||
for _, winid in ipairs(vim.api.nvim_list_wins()) do
|
||||
if vim.api.nvim_win_is_valid(winid) then
|
||||
hidden_buffers[vim.api.nvim_win_get_buf(winid)] = nil
|
||||
end
|
||||
end
|
||||
for _, bufnr in ipairs(buffers) do
|
||||
if hidden_buffers[bufnr] then
|
||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
||||
else
|
||||
M.render_buffer_async(bufnr, opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
M.set_win_options = function()
|
||||
local winid = vim.api.nvim_get_current_win()
|
||||
for k, v in pairs(config.win_options) do
|
||||
if config.restore_win_options then
|
||||
local varname = "_oil_" .. k
|
||||
if not pcall(vim.api.nvim_win_get_var, winid, varname) then
|
||||
local prev_value = vim.wo[k]
|
||||
vim.api.nvim_win_set_var(winid, varname, prev_value)
|
||||
end
|
||||
end
|
||||
vim.api.nvim_win_set_option(winid, k, v)
|
||||
end
|
||||
end
|
||||
|
||||
M.restore_win_options = function()
|
||||
local winid = vim.api.nvim_get_current_win()
|
||||
for k in pairs(config.win_options) do
|
||||
local varname = "_oil_" .. k
|
||||
local has_opt, opt = pcall(vim.api.nvim_win_get_var, winid, varname)
|
||||
if has_opt then
|
||||
vim.api.nvim_win_set_option(winid, k, opt)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Delete hidden oil buffers and if none remain, clear the cache
|
||||
M.cleanup = function()
|
||||
local buffers = M.get_all_buffers()
|
||||
local hidden_buffers = {}
|
||||
for _, bufnr in ipairs(buffers) do
|
||||
if vim.bo[bufnr].modified then
|
||||
return
|
||||
end
|
||||
hidden_buffers[bufnr] = true
|
||||
end
|
||||
for _, winid in ipairs(vim.api.nvim_list_wins()) do
|
||||
if vim.api.nvim_win_is_valid(winid) then
|
||||
hidden_buffers[vim.api.nvim_win_get_buf(winid)] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local any_remaining = false
|
||||
for _, bufnr in ipairs(buffers) do
|
||||
if hidden_buffers[bufnr] then
|
||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
||||
else
|
||||
any_remaining = true
|
||||
end
|
||||
end
|
||||
if not any_remaining then
|
||||
cache.clear_everything()
|
||||
end
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
M.initialize = function(bufnr)
|
||||
if bufnr == 0 then
|
||||
bufnr = vim.api.nvim_get_current_buf()
|
||||
end
|
||||
session[bufnr] = true
|
||||
vim.bo[bufnr].buftype = "acwrite"
|
||||
vim.bo[bufnr].filetype = "oil"
|
||||
vim.bo[bufnr].bufhidden = "hide"
|
||||
vim.bo[bufnr].syntax = "oil"
|
||||
M.set_win_options()
|
||||
vim.api.nvim_create_autocmd("BufHidden", {
|
||||
callback = function()
|
||||
vim.defer_fn(M.cleanup, 2000)
|
||||
end,
|
||||
nested = true,
|
||||
buffer = bufnr,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("BufDelete", {
|
||||
callback = function()
|
||||
session[bufnr] = nil
|
||||
end,
|
||||
nested = true,
|
||||
once = true,
|
||||
buffer = bufnr,
|
||||
})
|
||||
M.render_buffer_async(bufnr, {}, function(err)
|
||||
if err then
|
||||
vim.notify(
|
||||
string.format("Error rendering oil buffer %s: %s", vim.api.nvim_buf_get_name(bufnr), err),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
end
|
||||
end)
|
||||
keymap_util.set_keymaps("", config.keymaps, bufnr)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param opts nil|table
|
||||
--- jump boolean
|
||||
--- jump_first boolean
|
||||
---@return boolean
|
||||
local function render_buffer(bufnr, opts)
|
||||
if bufnr == 0 then
|
||||
bufnr = vim.api.nvim_get_current_buf()
|
||||
end
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
return false
|
||||
end
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
opts = vim.tbl_extend("keep", opts or {}, {
|
||||
jump = false,
|
||||
jump_first = false,
|
||||
})
|
||||
local scheme = util.parse_url(bufname)
|
||||
local adapter = util.get_adapter(bufnr)
|
||||
if not adapter then
|
||||
return false
|
||||
end
|
||||
local entries = cache.list_url(bufname)
|
||||
local entry_list = vim.tbl_values(entries)
|
||||
|
||||
table.sort(entry_list, function(a, b)
|
||||
local a_isdir = a[FIELD.type] == "directory"
|
||||
local b_isdir = b[FIELD.type] == "directory"
|
||||
if a_isdir ~= b_isdir then
|
||||
return a_isdir
|
||||
end
|
||||
return a[FIELD.name] < b[FIELD.name]
|
||||
end)
|
||||
|
||||
local jump_idx
|
||||
if opts.jump_first then
|
||||
jump_idx = 1
|
||||
end
|
||||
local seek_after_render_found = false
|
||||
local seek_after_render = M.get_last_cursor(bufname)
|
||||
local column_defs = columns.get_supported_columns(scheme)
|
||||
local line_table = {}
|
||||
local col_width = {}
|
||||
for i in ipairs(column_defs) do
|
||||
col_width[i + 1] = 1
|
||||
end
|
||||
local virt_text = {}
|
||||
for _, entry in ipairs(entry_list) do
|
||||
if not M.should_display(entry) then
|
||||
goto continue
|
||||
end
|
||||
local cols = M.format_entry_cols(entry, column_defs, col_width, adapter)
|
||||
table.insert(line_table, cols)
|
||||
|
||||
local name = entry[FIELD.name]
|
||||
if seek_after_render == name then
|
||||
seek_after_render_found = true
|
||||
jump_idx = #line_table
|
||||
end
|
||||
::continue::
|
||||
end
|
||||
|
||||
local lines, highlights = util.render_table(line_table, col_width)
|
||||
|
||||
vim.bo[bufnr].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
|
||||
vim.bo[bufnr].modifiable = false
|
||||
vim.bo[bufnr].modified = false
|
||||
util.set_highlights(bufnr, highlights)
|
||||
local ns = vim.api.nvim_create_namespace("Oil")
|
||||
for _, v in ipairs(virt_text) do
|
||||
local lnum, col, ext_opts = unpack(v)
|
||||
vim.api.nvim_buf_set_extmark(bufnr, ns, lnum, col, ext_opts)
|
||||
end
|
||||
if opts.jump then
|
||||
-- TODO why is the schedule necessary?
|
||||
vim.schedule(function()
|
||||
for _, winid in ipairs(vim.api.nvim_list_wins()) do
|
||||
if vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_buf(winid) == bufnr then
|
||||
-- If we're not jumping to a specific lnum, use the current lnum so we can adjust the col
|
||||
local lnum = jump_idx or vim.api.nvim_win_get_cursor(winid)[1]
|
||||
local line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1]
|
||||
local id_str = line:match("^/(%d+)")
|
||||
local id = tonumber(id_str)
|
||||
if id then
|
||||
local entry = cache.get_entry_by_id(id)
|
||||
if entry then
|
||||
local name = entry[FIELD.name]
|
||||
local col = line:find(name, 1, true) or (id_str:len() + 1)
|
||||
vim.api.nvim_win_set_cursor(winid, { lnum, col - 1 })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
return seek_after_render_found
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param adapter oil.Adapter
|
||||
---@param entry oil.InternalEntry
|
||||
---@param column_defs table[]
|
||||
---@param col_width integer[]
|
||||
---@param adapter oil.Adapter
|
||||
---@return oil.TextChunk[]
|
||||
M.format_entry_cols = function(entry, column_defs, col_width, adapter)
|
||||
local name = entry[FIELD.name]
|
||||
-- First put the unique ID
|
||||
local cols = {}
|
||||
local id_key = cache.format_id(entry[FIELD.id])
|
||||
col_width[1] = id_key:len()
|
||||
table.insert(cols, id_key)
|
||||
-- Then add all the configured columns
|
||||
for i, column in ipairs(column_defs) do
|
||||
local chunk = columns.render_col(adapter, column, entry)
|
||||
local text = type(chunk) == "table" and chunk[1] or chunk
|
||||
col_width[i + 1] = math.max(col_width[i + 1], vim.api.nvim_strwidth(text))
|
||||
table.insert(cols, chunk)
|
||||
end
|
||||
-- Always add the entry name at the end
|
||||
local entry_type = entry[FIELD.type]
|
||||
if entry_type == "directory" then
|
||||
table.insert(cols, { name .. "/", "OilDir" })
|
||||
elseif entry_type == "socket" then
|
||||
table.insert(cols, { name, "OilSocket" })
|
||||
elseif entry_type == "link" then
|
||||
local meta = entry[FIELD.meta]
|
||||
local link_text
|
||||
if meta then
|
||||
if meta.link_stat and meta.link_stat.type == "directory" then
|
||||
name = name .. "/"
|
||||
end
|
||||
|
||||
if meta.link then
|
||||
link_text = "->" .. " " .. meta.link
|
||||
if meta.link_stat and meta.link_stat.type == "directory" then
|
||||
link_text = util.addslash(link_text)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(cols, { name, "OilLink" })
|
||||
if link_text then
|
||||
table.insert(cols, { link_text, "Comment" })
|
||||
end
|
||||
else
|
||||
table.insert(cols, { name, "OilFile" })
|
||||
end
|
||||
return cols
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param opts nil|table
|
||||
--- preserve_undo nil|boolean
|
||||
--- refetch nil|boolean Defaults to true
|
||||
---@param callback nil|fun(err: nil|string)
|
||||
M.render_buffer_async = function(bufnr, opts, callback)
|
||||
opts = vim.tbl_deep_extend("keep", opts or {}, {
|
||||
preserve_undo = false,
|
||||
refetch = true,
|
||||
})
|
||||
if bufnr == 0 then
|
||||
bufnr = vim.api.nvim_get_current_buf()
|
||||
end
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local scheme, dir = util.parse_url(bufname)
|
||||
local preserve_undo = opts.preserve_undo and config.adapters[scheme] == "files"
|
||||
if not preserve_undo then
|
||||
-- Undo should not return to a blank buffer
|
||||
-- Method taken from :h clear-undo
|
||||
vim.bo[bufnr].undolevels = -1
|
||||
end
|
||||
local handle_error = vim.schedule_wrap(function(message)
|
||||
if not preserve_undo then
|
||||
vim.bo[bufnr].undolevels = vim.api.nvim_get_option("undolevels")
|
||||
end
|
||||
util.render_centered_text(bufnr, { "Error: " .. message })
|
||||
if callback then
|
||||
callback(message)
|
||||
else
|
||||
error(message)
|
||||
end
|
||||
end)
|
||||
if not dir then
|
||||
handle_error(string.format("Could not parse oil url '%s'", bufname))
|
||||
return
|
||||
end
|
||||
local adapter = util.get_adapter(bufnr)
|
||||
if not adapter then
|
||||
handle_error(string.format("[oil] no adapter for buffer '%s'", bufname))
|
||||
return
|
||||
end
|
||||
local start_ms = vim.loop.hrtime() / 1e6
|
||||
local seek_after_render_found = false
|
||||
local first = true
|
||||
vim.bo[bufnr].modifiable = false
|
||||
loading.set_loading(bufnr, true)
|
||||
|
||||
local finish = vim.schedule_wrap(function()
|
||||
loading.set_loading(bufnr, false)
|
||||
render_buffer(bufnr, { jump = true })
|
||||
if not preserve_undo then
|
||||
vim.bo[bufnr].undolevels = vim.api.nvim_get_option("undolevels")
|
||||
end
|
||||
vim.bo[bufnr].modifiable = adapter.is_modifiable(bufnr)
|
||||
if callback then
|
||||
callback()
|
||||
end
|
||||
end)
|
||||
if not opts.refetch then
|
||||
finish()
|
||||
return
|
||||
end
|
||||
|
||||
adapter.list(bufname, config.columns, function(err, has_more)
|
||||
loading.set_loading(bufnr, false)
|
||||
if err then
|
||||
handle_error(err)
|
||||
return
|
||||
elseif has_more then
|
||||
local now = vim.loop.hrtime() / 1e6
|
||||
local delta = now - start_ms
|
||||
-- If we've been chugging for more than 40ms, go ahead and render what we have
|
||||
if delta > 40 then
|
||||
start_ms = now
|
||||
vim.schedule(function()
|
||||
seek_after_render_found =
|
||||
render_buffer(bufnr, { jump = not seek_after_render_found, jump_first = first })
|
||||
end)
|
||||
end
|
||||
first = false
|
||||
else
|
||||
-- done iterating
|
||||
finish()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
Loading…
Add table
Add a link
Reference in a new issue