454 lines
13 KiB
Lua
454 lines
13 KiB
Lua
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
|