feat: builtin support for editing files over ssh (#27)

This commit is contained in:
Steven Arcangeli 2023-01-13 23:28:24 -08:00
parent 75b710e311
commit ca4da68aae
18 changed files with 593 additions and 291 deletions

View file

@ -37,6 +37,7 @@ jobs:
include:
- nvim_tag: v0.8.0
- nvim_tag: v0.8.1
- nvim_tag: v0.8.2
name: Run tests
runs-on: ubuntu-22.04

View file

@ -184,12 +184,10 @@ Note that file operations work _across adapters_. This means that you can use oi
This adapter allows you to browse files over ssh, much like netrw. To use it, simply open a buffer using the following name template:
```
nvim oil-ssh://[username@]hostname[:port]/[path]
nvim scp://[username@]hostname[:port]/[path]
```
This should look familiar. In fact, if you replace `oil-ssh://` with `sftp://`, this is the exact same url format that netrw uses.
While this adapter effectively replaces netrw for directory browsing, it still relies on netrw for file editing. When you open a file from oil, it will use the `scp://host/path/to/file.txt` format that triggers remote editing via netrw.
This may look familiar. In fact, this is the exact same url format that netrw uses.
Note that at the moment the ssh adapter does not support Windows machines, and it requires the server to have a `/bin/bash` binary as well as standard unix commands (`rm`, `mv`, `mkdir`, `chmod`, `cp`, `touch`, `ln`, `echo`).

View file

@ -175,13 +175,20 @@ end
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
vim.loop.fs_realpath(os_path, function(err, new_os_path)
local realpath = new_os_path or os_path
vim.loop.fs_stat(
realpath,
vim.schedule_wrap(function(stat_err, stat)
if not stat or stat.type == "directory" then
local norm_path = util.addslash(fs.os_to_posix_path(realpath))
callback(scheme .. norm_path)
else
callback(realpath)
end
end)
)
end)
end
---@param url string
@ -303,12 +310,6 @@ M.is_modifiable = function(bufnr)
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)

View file

@ -2,8 +2,9 @@ local cache = require("oil.cache")
local config = require("oil.config")
local fs = require("oil.fs")
local files = require("oil.adapters.files")
local loading = require("oil.loading")
local permissions = require("oil.adapters.files.permissions")
local ssh_connection = require("oil.adapters.ssh.connection")
local sshfs = require("oil.adapters.ssh.sshfs")
local pathutil = require("oil.pathutil")
local shell = require("oil.shell")
local util = require("oil.util")
@ -85,62 +86,13 @@ local function get_connection(url, allow_retry)
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)
if not conn or (allow_retry and conn:get_connection_error()) then
conn = sshfs.new(res)
_connections[key] = conn
end
return conn
end
local typechar_map = {
l = "link",
d = "directory",
p = "fifo",
s = "socket",
["-"] = "file",
c = "file", -- character special file
b = "file", -- block special file
}
---@param line string
---@return string Name of entry
---@return oil.EntryType
---@return nil|table Metadata for entry
local function parse_ls_line(line)
local typechar, perms, refcount, user, group, rem =
line:match("^(.)(%S+)%s+(%d+)%s+(%S+)%s+(%S+)%s+(.*)$")
if not typechar then
error(string.format("Could not parse '%s'", line))
end
local type = typechar_map[typechar] or "file"
local meta = {
user = user,
group = group,
mode = permissions.parse(perms),
refcount = tonumber(refcount),
}
local name, size, date, major, minor
if typechar == "c" or typechar == "b" then
major, minor, date, name = rem:match("^(%d+)%s*,%s*(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)")
meta.major = tonumber(major)
meta.minor = tonumber(minor)
else
size, date, name = rem:match("^(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)")
meta.size = tonumber(size)
end
meta.iso_modified_date = date
if type == "link" then
local link
name, link = unpack(vim.split(name, " -> ", { plain = true }))
if vim.endswith(link, "/") then
link = link:sub(1, #link - 1)
end
meta.link = link
end
return name, type, meta
end
local ssh_columns = {}
ssh_columns.permissions = {
render = function(entry, conf)
@ -171,8 +123,7 @@ ssh_columns.permissions = {
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)
conn:chmod(action.value, res.path, callback)
end,
}
@ -230,24 +181,9 @@ M.normalize_url = function(url, callback)
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)
conn:realpath(path, function(err, abspath)
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
@ -256,81 +192,19 @@ M.normalize_url = function(url, callback)
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)
local conn = get_connection(url)
conn:list_dir(url, res.path, function(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
@ -338,33 +212,27 @@ end
---@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
local conn = get_connection(bufname)
local dir_meta = conn:get_dir_meta(bufname)
if not dir_meta then
-- Directories that don't exist yet are modifiable
return true
end
local conn = get_connection(bufname)
if not conn.meta.user or not conn.meta.groups then
local meta = conn:get_meta()
if not meta.user or not 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)
if dir_meta.user == meta.user then
rwx = bit.rshift(dir_meta.mode, 6)
elseif vim.tbl_contains(meta.groups, dir_meta.group) then
rwx = bit.rshift(dir_meta.mode, 3)
else
rwx = meta.mode
rwx = dir_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)
@ -399,16 +267,16 @@ M.perform_action = function(action, cb)
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)
conn:mkdir(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)
conn:mklink(res.path, action.link, cb)
else
conn:run(string.format("touch '%s'", res.path), cb)
conn:touch(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)
conn:rm(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)
@ -418,14 +286,14 @@ M.perform_action = function(action, cb)
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)
shell.run({ "scp", "-C", "-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)
src_conn:rm(src_res.path, cb)
end)
else
src_conn:run(string.format("mv '%s' '%s'", src_res.path, dest_res.path), cb)
src_conn:mv(src_res.path, dest_res.path, cb)
end
else
cb("We should never attempt to move across adapters")
@ -439,9 +307,9 @@ M.perform_action = function(action, cb)
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)
shell.run({ "scp", "-C", "-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)
src_conn:cp(src_res.path, dest_res.path, cb)
else
local src_arg
local dest_arg
@ -454,7 +322,7 @@ M.perform_action = function(action, cb)
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)
shell.run({ "scp", "-C", "-r", src_arg, dest_arg }, cb)
end
else
cb(string.format("Bad action type: %s", action.type))
@ -463,4 +331,63 @@ end
M.supports_xfer = { files = true }
---@param bufnr integer
M.read_file = function(bufnr)
loading.set_loading(bufnr, true)
local bufname = vim.api.nvim_buf_get_name(bufnr)
local url = parse_url(bufname)
local scp_url = url_to_scp(url)
local basename = pathutil.basename(bufname)
local tmpdir = fs.join(vim.fn.stdpath("cache"), "oil")
fs.mkdirp(tmpdir)
local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, "ssh_XXXXXX"))
vim.loop.fs_close(fd)
local tmp_bufnr = vim.fn.bufadd(tmpfile)
shell.run({ "scp", "-C", scp_url, tmpfile }, function(err)
loading.set_loading(bufnr, false)
vim.bo[bufnr].modifiable = true
vim.cmd.doautocmd({ args = { "BufReadPre", bufname }, mods = { silent = true } })
if err then
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, vim.split(err, "\n"))
else
vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, {})
vim.api.nvim_buf_call(bufnr, function()
vim.cmd.read({ args = { tmpfile }, mods = { silent = true } })
end)
vim.loop.fs_unlink(tmpfile)
vim.api.nvim_buf_set_lines(bufnr, 0, 1, true, {})
end
vim.bo[bufnr].modified = false
vim.bo[bufnr].filetype = vim.filetype.match({ buf = bufnr, filename = basename })
vim.cmd.doautocmd({ args = { "BufReadPost", bufname }, mods = { silent = true } })
vim.api.nvim_buf_delete(tmp_bufnr, { force = true })
end)
end
---@param bufnr integer
M.write_file = function(bufnr)
local bufname = vim.api.nvim_buf_get_name(bufnr)
vim.bo[bufnr].modifiable = false
local url = parse_url(bufname)
local scp_url = url_to_scp(url)
local tmpdir = fs.join(vim.fn.stdpath("cache"), "oil")
local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, "ssh_XXXXXXXX"))
vim.loop.fs_close(fd)
vim.cmd.doautocmd({ args = { "BufWritePre", bufname }, mods = { silent = true } })
vim.cmd.write({ args = { tmpfile }, bang = true, mods = { silent = true } })
local tmp_bufnr = vim.fn.bufadd(tmpfile)
shell.run({ "scp", "-C", tmpfile, scp_url }, function(err)
if err then
vim.notify(string.format("Error writing file: %s", err), vim.log.levels.ERROR)
end
vim.bo[bufnr].modifiable = true
vim.bo[bufnr].modified = false
vim.cmd.doautocmd({ args = { "BufWritePost", bufname }, mods = { silent = true } })
vim.loop.fs_unlink(tmpfile)
vim.api.nvim_buf_delete(tmp_bufnr, { force = true })
end)
end
return M

View file

@ -0,0 +1,228 @@
local cache = require("oil.cache")
local permissions = require("oil.adapters.files.permissions")
local SSHConnection = require("oil.adapters.ssh.connection")
local util = require("oil.util")
local FIELD = require("oil.constants").FIELD
local SSHFS = {}
local typechar_map = {
l = "link",
d = "directory",
p = "fifo",
s = "socket",
["-"] = "file",
c = "file", -- character special file
b = "file", -- block special file
}
---@param line string
---@return string Name of entry
---@return oil.EntryType
---@return nil|table Metadata for entry
local function parse_ls_line(line)
local typechar, perms, refcount, user, group, rem =
line:match("^(.)(%S+)%s+(%d+)%s+(%S+)%s+(%S+)%s+(.*)$")
if not typechar then
error(string.format("Could not parse '%s'", line))
end
local type = typechar_map[typechar] or "file"
local meta = {
user = user,
group = group,
mode = permissions.parse(perms),
refcount = tonumber(refcount),
}
local name, size, date, major, minor
if typechar == "c" or typechar == "b" then
major, minor, date, name = rem:match("^(%d+)%s*,%s*(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)")
meta.major = tonumber(major)
meta.minor = tonumber(minor)
else
size, date, name = rem:match("^(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)")
meta.size = tonumber(size)
end
meta.iso_modified_date = date
if type == "link" then
local link
name, link = unpack(vim.split(name, " -> ", { plain = true }))
if vim.endswith(link, "/") then
link = link:sub(1, #link - 1)
end
meta.link = link
end
return name, type, meta
end
---@param url oil.sshUrl
function SSHFS.new(url)
return setmetatable({
conn = SSHConnection.new(url),
}, {
__index = SSHFS,
})
end
function SSHFS:get_connection_error()
return self.conn.connection_error
end
---@param value integer
---@param path string
---@param callback fun(err: nil|string)
function SSHFS:chmod(value, path, callback)
local octal = permissions.mode_to_octal_str(value)
self.conn:run(string.format("chmod %s '%s'", octal, path), callback)
end
function SSHFS:open_terminal()
self.conn:open_terminal()
end
function SSHFS:realpath(path, callback)
local cmd = string.format(
'if ! readlink -f "%s" 2>/dev/null; then [[ "%s" == /* ]] && echo "%s" || echo "$PWD/%s"; fi',
path,
path,
path,
path
)
self.conn:run(cmd, function(err, lines)
if err then
return callback(err)
end
local abspath = table.concat(lines, "")
-- If the path was "." then the abspath might be /path/to/., so we need to trim that final '.'
if vim.endswith(abspath, ".") then
abspath = abspath:sub(1, #abspath - 1)
end
self.conn:run(string.format("ls -fld '%s'", abspath), function(ls_err, ls_lines)
local type
if ls_err then
-- If the file doesn't exist, treat it like a not-yet-existing directory
type = "directory"
else
local _
_, type = parse_ls_line(ls_lines[1])
end
if type == "directory" then
abspath = util.addslash(abspath)
end
callback(nil, abspath)
end)
end)
end
local dir_meta = {}
function SSHFS:list_dir(url, path, callback)
local path_postfix = ""
if path ~= "" then
path_postfix = string.format(" '%s'", path)
end
self.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 callback()
else
return callback(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
self.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 callback(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
callback()
end)
else
callback()
end
end)
end
---@param path string
---@param callback fun(err: nil|string)
function SSHFS:mkdir(path, callback)
self.conn:run(string.format("mkdir -p '%s'", path), callback)
end
---@param path string
---@param callback fun(err: nil|string)
function SSHFS:touch(path, callback)
self.conn:run(string.format("touch '%s'", path), callback)
end
---@param path string
---@param link string
---@param callback fun(err: nil|string)
function SSHFS:mklink(path, link, callback)
self.conn:run(string.format("ln -s '%s' '%s'", link, path), callback)
end
---@param path string
---@param callback fun(err: nil|string)
function SSHFS:rm(path, callback)
self.conn:run(string.format("rm -rf '%s'", path), callback)
end
---@param src string
---@param dest string
---@param callback fun(err: nil|string)
function SSHFS:mv(src, dest, callback)
self.conn:run(string.format("mv '%s' '%s'", src, dest), callback)
end
---@param src string
---@param dest string
---@param callback fun(err: nil|string)
function SSHFS:cp(src, dest, callback)
self.conn:run(string.format("cp -r '%s' '%s'", src, dest), callback)
end
function SSHFS:get_dir_meta(url)
return dir_meta[url]
end
function SSHFS:get_meta()
return self.conn.meta
end
return SSHFS

View file

@ -1,6 +1,12 @@
local cache = require("oil.cache")
local M = {}
---@param url string
---@param callback fun(url: string)
M.normalize_url = function(url, callback)
callback(url)
end
---@param path string
---@param column_defs string[]
---@param cb fun(err: nil|string, entries: nil|oil.InternalEntry[])
@ -36,11 +42,6 @@ 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)
@ -59,4 +60,14 @@ M.perform_action = function(action, cb)
cb()
end
---@param bufnr integer
M.read_file = function(bufnr)
-- pass
end
---@param bufnr integer
M.write_file = function(bufnr)
-- pass
end
return M

View file

@ -67,12 +67,11 @@ local default_config = {
-- reason, I'm taking them out of the section above so they won't show up in the autogen docs.
default_config.adapters = {
["oil://"] = "files",
["oil-ssh://"] = "ssh",
["scp://"] = "ssh",
}
-- When opening the parent of a file, substitute these url schemes
default_config.remap_schemes = {
["scp://"] = "oil-ssh://",
["sftp://"] = "oil-ssh://",
-- For backwards compatibility
default_config.adapter_aliases = {
["oil-ssh://"] = "scp://",
}
local M = {}

View file

@ -14,13 +14,14 @@ local M = {}
---@field name string
---@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 normalize_url 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))
---@field read_file fun(bufnr: integer)
---@field write_file fun(bufnr: integer)
---Get the entry on a specific line (1-indexed)
---@param bufnr integer
@ -189,7 +190,6 @@ M.get_buffer_parent_url = function(bufname)
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 and adapter.get_parent then
@ -370,7 +370,6 @@ M.select = function(opts)
local scheme, dir = util.parse_url(bufname)
local child = dir .. entry.name
local url = scheme .. child
local buffer_name
if
entry.type == "directory"
or (
@ -380,7 +379,6 @@ M.select = function(opts)
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.
@ -392,7 +390,6 @@ M.select = function(opts)
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,
@ -405,7 +402,7 @@ M.select = function(opts)
local cmd = opts.split and "split" or "edit"
vim.cmd({
cmd = cmd,
args = { buffer_name },
args = { url },
mods = mods,
})
if opts.preview then
@ -514,19 +511,69 @@ M.save = function(opts)
mutator.try_write_changes(opts.confirm)
end
local function restore_alt_buf()
local config = require("oil.config")
local view = require("oil.view")
if vim.bo.filetype == "oil" then
view.set_win_options()
vim.api.nvim_win_set_var(0, "oil_did_enter", true)
elseif vim.w.oil_did_enter then
vim.api.nvim_win_del_var(0, "oil_did_enter")
-- We are entering a non-oil buffer *after* having been in an oil buffer
local has_orig, orig_buffer = pcall(vim.api.nvim_win_get_var, 0, "oil_original_buffer")
if has_orig and vim.api.nvim_buf_is_valid(orig_buffer) then
if vim.api.nvim_get_current_buf() ~= orig_buffer then
-- If we are editing a new file after navigating around oil, set the alternate buffer
-- to be the last buffer we were in before opening oil
vim.fn.setreg("#", orig_buffer)
else
-- If we are editing the same buffer that we started oil from, set the alternate to be
-- what it was before we opened oil
local has_orig_alt, alt_buffer =
pcall(vim.api.nvim_win_get_var, 0, "oil_original_alternate")
if has_orig_alt and vim.api.nvim_buf_is_valid(alt_buffer) then
vim.fn.setreg("#", alt_buffer)
end
end
end
if config.restore_win_options then
view.restore_win_options()
end
end
end
---@param bufnr integer
local function load_oil_buffer(bufnr)
local config = require("oil.config")
local keymap_util = require("oil.keymap_util")
local loading = require("oil.loading")
local util = require("oil.util")
local view = require("oil.view")
local bufname = vim.api.nvim_buf_get_name(bufnr)
local adapter = config.get_adapter_by_scheme(bufname)
vim.bo[bufnr].buftype = "acwrite"
vim.bo[bufnr].filetype = "oil"
vim.bo[bufnr].bufhidden = "hide"
vim.bo[bufnr].syntax = "oil"
local scheme, path = util.parse_url(bufname)
if config.adapter_aliases[scheme] then
if scheme == "oil-ssh://" then
vim.notify_once(
'The "oil-ssh://" url scheme is deprecated, use "scp://" instead.\nSupport will be removed on 2023-06-01.',
vim.log.levels.WARN
)
end
scheme = config.adapter_aliases[scheme]
bufname = scheme .. path
util.rename_buffer(bufnr, bufname)
end
local adapter = config.get_adapter_by_scheme(scheme)
if vim.endswith(bufname, "/") then
-- This is a small quality-of-life thing. If the buffer name ends with a `/`, we know it's a
-- directory, and can set the filetype early. This is helpful for adapters with a lot of latency
-- (e.g. ssh) because it will set up the filetype keybinds at the *beginning* of the loading
-- process.
vim.bo[bufnr].filetype = "oil"
keymap_util.set_keymaps("", config.keymaps, bufnr)
end
loading.set_loading(bufnr, true)
local function finish(new_url)
if new_url ~= bufname then
@ -535,23 +582,31 @@ local function load_oil_buffer(bufnr)
-- have BufReadCmd called for it
return
end
bufname = new_url
end
vim.cmd.doautocmd({ args = { "BufReadPre", bufname }, mods = { emsg_silent = true } })
view.initialize(bufnr)
vim.cmd.doautocmd({ args = { "BufReadPost", bufname }, mods = { emsg_silent = true } })
if vim.endswith(bufname, "/") then
vim.cmd.doautocmd({ args = { "BufReadPre", bufname }, mods = { emsg_silent = true } })
view.initialize(bufnr)
vim.cmd.doautocmd({ args = { "BufReadPost", bufname }, mods = { emsg_silent = true } })
else
vim.bo[bufnr].buftype = "acwrite"
adapter.read_file(bufnr)
end
restore_alt_buf()
end
if adapter.normalize_url then
adapter.normalize_url(bufname, finish)
else
finish(util.addslash(bufname))
end
adapter.normalize_url(bufname, finish)
end
---Initialize oil
---@param opts nil|table
M.setup = function(opts)
local config = require("oil.config")
-- Disable netrw
vim.g.loaded_netrw = 1
vim.g.loaded_netrwPlugin = 1
config.setup(opts)
set_colors()
vim.api.nvim_create_user_command("Oil", function(args)
@ -574,6 +629,9 @@ M.setup = function(opts)
for scheme in pairs(config.adapters) do
table.insert(patterns, scheme .. "*")
end
for scheme in pairs(config.adapter_aliases) do
table.insert(patterns, scheme .. "*")
end
local scheme_pattern = table.concat(patterns, ",")
vim.api.nvim_create_autocmd("ColorScheme", {
@ -595,10 +653,16 @@ M.setup = function(opts)
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 } })
local bufname = vim.api.nvim_buf_get_name(params.buf)
if vim.endswith(bufname, "/") then
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 } })
else
local adapter = config.get_adapter_by_scheme(bufname)
adapter.write_file(params.buf)
end
end,
})
vim.api.nvim_create_autocmd("BufWinLeave", {
@ -617,34 +681,15 @@ M.setup = function(opts)
group = aug,
pattern = "*",
callback = function()
local util = require("oil.util")
local view = require("oil.view")
if vim.bo.filetype == "oil" then
view.set_win_options()
vim.api.nvim_win_set_var(0, "oil_did_enter", true)
local scheme = util.parse_url(vim.api.nvim_buf_get_name(0))
if scheme and config.adapters[scheme] then
view.maybe_set_cursor()
elseif vim.w.oil_did_enter then
vim.api.nvim_win_del_var(0, "oil_did_enter")
-- We are entering a non-oil buffer *after* having been in an oil buffer
local has_orig, orig_buffer = pcall(vim.api.nvim_win_get_var, 0, "oil_original_buffer")
if has_orig and vim.api.nvim_buf_is_valid(orig_buffer) then
if vim.api.nvim_get_current_buf() ~= orig_buffer then
-- If we are editing a new file after navigating around oil, set the alternate buffer
-- to be the last buffer we were in before opening oil
vim.fn.setreg("#", orig_buffer)
else
-- If we are editing the same buffer that we started oil from, set the alternate to be
-- what it was before we opened oil
local has_orig_alt, alt_buffer =
pcall(vim.api.nvim_win_get_var, 0, "oil_original_alternate")
if has_orig_alt and vim.api.nvim_buf_is_valid(alt_buffer) then
vim.fn.setreg("#", alt_buffer)
end
end
end
if config.restore_win_options then
view.restore_win_options()
end
else
-- Only run this logic if we are *not* in an oil buffer.
-- Oil buffers have to run it in BufReadCmd after confirming they are a directory or a file
restore_alt_buf()
end
end,
})
@ -722,6 +767,7 @@ M.setup = function(opts)
end
end,
})
maybe_hijack_directory_buffer(0)
end

View file

@ -487,6 +487,7 @@ M.try_write_changes = function(confirm)
end
local actions = M.create_actions_from_diffs(all_diffs)
-- TODO(2023-06-01) If no one has reported data loss by this time, we can remove the disclaimer
disclaimer.show(function(disclaimed)
if not disclaimed then
return unlock()

View file

@ -1,29 +1,36 @@
local M = {}
M.run = function(cmd, callback)
M.run = function(cmd, opts, callback)
if not callback then
callback = opts
opts = {}
end
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"
local jid = vim.fn.jobstart(
cmd,
vim.tbl_deep_extend("keep", opts, {
stdout_buffered = true,
stderr_buffered = true,
on_stdout = function(j, output)
stdout = output
end,
on_stderr = function(j, output)
stderr = output
end,
on_exit = vim.schedule_wrap(function(j, code)
if code == 0 then
callback(nil, stdout)
else
local err = table.concat(stderr, "\n")
if err == "" then
err = "Unknown error"
end
callback(err)
end
callback(err)
end
end),
})
end),
})
)
local exe
if type(cmd) == "string" then
exe = vim.split(cmd, "%s+")[1]

View file

@ -98,11 +98,10 @@ M.rename_buffer = function(src_bufnr, dest_buf_name)
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
-- If this buffer 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
if not vim.loop.fs_stat(dest_buf_name) 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
@ -159,19 +158,10 @@ end
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
if config.adapters[scheme] == "files" then
return { fs.posix_to_os_path(path) }
else
return ret
end
return { url }
end
---@param entry_type oil.EntryType

View file

@ -178,6 +178,12 @@ M.initialize = function(bufnr)
if bufnr == 0 then
bufnr = vim.api.nvim_get_current_buf()
end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
vim.bo[bufnr].buftype = "acwrite"
vim.bo[bufnr].syntax = "oil"
vim.bo[bufnr].filetype = "oil"
session[bufnr] = true
for k, v in pairs(config.buf_options) do
vim.api.nvim_buf_set_option(bufnr, k, v)

View file

@ -1,56 +1,67 @@
require("plenary.async").tests.add_to_env()
local oil = require("oil")
local test_util = require("tests.test_util")
local fs = require("oil.fs")
describe("Alternate buffer", function()
a.describe("Alternate buffer", function()
after_each(function()
test_util.reset_editor()
end)
it("sets previous buffer as alternate", function()
a.it("sets previous buffer as alternate", function()
vim.cmd.edit({ args = { "foo" } })
oil.open()
test_util.wait_for_autocmd("BufReadPost")
vim.cmd.edit({ args = { "bar" } })
assert.equals("foo", vim.fn.expand("#"))
end)
it("sets previous buffer as alternate when editing oil://", function()
a.it("sets previous buffer as alternate when editing oil://", function()
vim.cmd.edit({ args = { "foo" } })
vim.cmd.edit({ args = { "oil://" .. fs.os_to_posix_path(vim.fn.getcwd()) } })
test_util.wait_for_autocmd("BufReadPost")
vim.cmd.edit({ args = { "bar" } })
assert.equals("foo", vim.fn.expand("#"))
end)
it("preserves alternate buffer if editing the same file", function()
a.it("preserves alternate buffer if editing the same file", function()
vim.cmd.edit({ args = { "foo" } })
vim.cmd.edit({ args = { "bar" } })
oil.open()
test_util.wait_for_autocmd("BufReadPost")
vim.cmd.edit({ args = { "bar" } })
assert.equals("foo", vim.fn.expand("#"))
end)
it("preserves alternate buffer if discarding changes", function()
a.it("preserves alternate buffer if discarding changes", function()
vim.cmd.edit({ args = { "foo" } })
vim.cmd.edit({ args = { "bar" } })
oil.open()
test_util.wait_for_autocmd("BufReadPost")
oil.close()
assert.equals("bar", vim.fn.expand("%"))
assert.equals("foo", vim.fn.expand("#"))
end)
it("sets previous buffer as alternate after multi-dir hops", function()
a.it("sets previous buffer as alternate after multi-dir hops", function()
vim.cmd.edit({ args = { "foo" } })
oil.open()
test_util.wait_for_autocmd("BufReadPost")
oil.open()
test_util.wait_for_autocmd("BufReadPost")
oil.open()
test_util.wait_for_autocmd("BufReadPost")
oil.open()
test_util.wait_for_autocmd("BufReadPost")
vim.cmd.edit({ args = { "bar" } })
assert.equals("foo", vim.fn.expand("#"))
end)
describe("floating window", function()
it("sets previous buffer as alternate", function()
a.describe("floating window", function()
a.it("sets previous buffer as alternate", function()
vim.cmd.edit({ args = { "foo" } })
oil.open_float()
test_util.wait_for_autocmd("BufReadPost")
-- This is lazy, but testing the actual select logic is more difficult. We can simply
-- replicated it by closing the current window and then doing the edit
vim.api.nvim_win_close(0, true)
@ -58,10 +69,11 @@ describe("Alternate buffer", function()
assert.equals("foo", vim.fn.expand("#"))
end)
it("preserves alternate buffer if editing the same file", function()
a.it("preserves alternate buffer if editing the same file", function()
vim.cmd.edit({ args = { "foo" } })
vim.cmd.edit({ args = { "bar" } })
oil.open_float()
test_util.wait_for_autocmd("BufReadPost")
-- This is lazy, but testing the actual select logic is more difficult. We can simply
-- replicated it by closing the current window and then doing the edit
vim.api.nvim_win_close(0, true)
@ -69,10 +81,11 @@ describe("Alternate buffer", function()
assert.equals("foo", vim.fn.expand("#"))
end)
it("preserves alternate buffer if discarding changes", function()
a.it("preserves alternate buffer if discarding changes", function()
vim.cmd.edit({ args = { "foo" } })
vim.cmd.edit({ args = { "bar" } })
oil.open_float()
test_util.wait_for_autocmd("BufReadPost")
oil.close()
assert.equals("foo", vim.fn.expand("#"))
end)

View file

@ -0,0 +1,59 @@
local fs = require("oil.fs")
local test_util = require("tests.test_util")
local util = require("oil.util")
describe("update_moved_buffers", function()
after_each(function()
test_util.reset_editor()
end)
it("Renames moved buffers", function()
vim.cmd.edit({ args = { "oil-test:///foo/bar.txt" } })
util.update_moved_buffers("file", "oil-test:///foo/bar.txt", "oil-test:///foo/baz.txt")
assert.equals("oil-test:///foo/baz.txt", vim.api.nvim_buf_get_name(0))
end)
it("Renames moved buffers when they are normal files", function()
local tmpdir = fs.join(vim.loop.fs_realpath(vim.fn.stdpath("cache")), "oil", "test")
local testfile = fs.join(tmpdir, "foo.txt")
vim.cmd.edit({ args = { testfile } })
util.update_moved_buffers(
"file",
"oil://" .. fs.os_to_posix_path(testfile),
"oil://" .. fs.os_to_posix_path(fs.join(tmpdir, "bar.txt"))
)
assert.equals(fs.join(tmpdir, "bar.txt"), vim.api.nvim_buf_get_name(0))
end)
it("Renames directories", function()
vim.cmd.edit({ args = { "oil-test:///foo/" } })
util.update_moved_buffers("directory", "oil-test:///foo/", "oil-test:///bar/")
assert.equals("oil-test:///bar/", vim.api.nvim_buf_get_name(0))
end)
it("Renames subdirectories", function()
vim.cmd.edit({ args = { "oil-test:///foo/bar/" } })
util.update_moved_buffers("directory", "oil-test:///foo/", "oil-test:///baz/")
assert.equals("oil-test:///baz/bar/", vim.api.nvim_buf_get_name(0))
end)
it("Renames subfiles", function()
vim.cmd.edit({ args = { "oil-test:///foo/bar.txt" } })
util.update_moved_buffers("directory", "oil-test:///foo/", "oil-test:///baz/")
assert.equals("oil-test:///baz/bar.txt", vim.api.nvim_buf_get_name(0))
end)
it("Renames subfiles when they are normal files", function()
local tmpdir = fs.join(vim.loop.fs_realpath(vim.fn.stdpath("cache")), "oil", "test")
local foo = fs.join(tmpdir, "foo")
local bar = fs.join(tmpdir, "bar")
local testfile = fs.join(foo, "foo.txt")
vim.cmd.edit({ args = { testfile } })
util.update_moved_buffers(
"directory",
"oil://" .. fs.os_to_posix_path(foo),
"oil://" .. fs.os_to_posix_path(bar)
)
assert.equals(fs.join(bar, "foo.txt"), vim.api.nvim_buf_get_name(0))
end)
end)

View file

@ -15,14 +15,14 @@ a.describe("regression tests", function()
vim.cmd.wincmd({ args = { "p" } })
assert.equals("markdown", vim.bo.filetype)
vim.cmd.edit({ args = { "%:p:h" } })
a.util.sleep(10)
test_util.wait_for_autocmd("BufReadPost")
assert.equals("oil", vim.bo.filetype)
end)
-- https://github.com/stevearc/oil.nvim/issues/37
a.it("places the cursor on correct entry when opening on file", function()
vim.cmd.edit({ args = { "." } })
a.util.sleep(10)
test_util.wait_for_autocmd("BufReadPost")
local entry = oil.get_cursor_entry()
assert.not_equals("README.md", entry and entry.name)
vim.cmd.edit({ args = { "README.md" } })

View file

@ -1,3 +1,4 @@
require("plenary.async").tests.add_to_env()
local M = {}
M.reset_editor = function()
@ -16,4 +17,13 @@ M.reset_editor = function()
end
end
M.wait_for_autocmd = a.wrap(function(autocmd, cb)
vim.api.nvim_create_autocmd(autocmd, {
pattern = "*",
nested = true,
once = true,
callback = vim.schedule_wrap(cb),
})
end, 2)
return M

View file

@ -7,8 +7,8 @@ describe("url", function()
{ "/foo/bar.txt", "oil:///foo/", "bar.txt" },
{ "oil:///foo/bar.txt", "oil:///foo/", "bar.txt" },
{ "oil:///", "oil:///" },
{ "scp://user@hostname:8888//bar.txt", "oil-ssh://user@hostname:8888//", "bar.txt" },
{ "oil-ssh://user@hostname:8888//", "oil-ssh://user@hostname:8888//" },
{ "scp://user@hostname:8888//bar.txt", "scp://user@hostname:8888//", "bar.txt" },
{ "scp://user@hostname:8888//", "scp://user@hostname:8888//" },
}
for _, case in ipairs(cases) do
local input, expected, expected_basename = unpack(case)

View file

@ -1,35 +1,40 @@
require("plenary.async").tests.add_to_env()
local oil = require("oil")
local test_util = require("tests.test_util")
describe("window options", function()
a.describe("window options", function()
after_each(function()
test_util.reset_editor()
end)
it("Restores window options on close", function()
a.it("Restores window options on close", function()
vim.cmd.edit({ args = { "README.md" } })
oil.open()
test_util.wait_for_autocmd("BufReadPost")
assert.equals("no", vim.o.signcolumn)
oil.close()
assert.equals("auto", vim.o.signcolumn)
end)
it("Restores window options on edit", function()
a.it("Restores window options on edit", function()
oil.open()
test_util.wait_for_autocmd("BufReadPost")
assert.equals("no", vim.o.signcolumn)
vim.cmd.edit({ args = { "README.md" } })
assert.equals("auto", vim.o.signcolumn)
end)
it("Restores window options on split <filename>", function()
a.it("Restores window options on split <filename>", function()
oil.open()
test_util.wait_for_autocmd("BufReadPost")
assert.equals("no", vim.o.signcolumn)
vim.cmd.split({ args = { "README.md" } })
assert.equals("auto", vim.o.signcolumn)
end)
it("Restores window options on split", function()
a.it("Restores window options on split", function()
oil.open()
test_util.wait_for_autocmd("BufReadPost")
assert.equals("no", vim.o.signcolumn)
vim.cmd.split()
vim.cmd.edit({ args = { "README.md" } })