feat: first draft

This commit is contained in:
Steven Arcangeli 2022-12-15 02:24:27 -08:00
parent bf2dfb970d
commit fefd6ad5e4
48 changed files with 7201 additions and 1 deletions

406
lua/oil/adapters/files.lua Normal file
View 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

View 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
View 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

View 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
View 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