From ca4da68aaebaebf5cd68151c2b5ad56e00c06126 Mon Sep 17 00:00:00 2001 From: Steven Arcangeli Date: Fri, 13 Jan 2023 23:28:24 -0800 Subject: [PATCH] feat: builtin support for editing files over ssh (#27) --- .github/workflows/tests.yml | 1 + README.md | 6 +- lua/oil/adapters/files.lua | 27 ++-- lua/oil/adapters/ssh.lua | 247 ++++++++++++--------------------- lua/oil/adapters/ssh/sshfs.lua | 228 ++++++++++++++++++++++++++++++ lua/oil/adapters/test.lua | 21 ++- lua/oil/config.lua | 9 +- lua/oil/init.lua | 146 ++++++++++++------- lua/oil/mutator/init.lua | 1 + lua/oil/shell.lua | 49 ++++--- lua/oil/util.lua | 18 +-- lua/oil/view.lua | 6 + tests/altbuf_spec.lua | 33 +++-- tests/move_rename_spec.lua | 59 ++++++++ tests/regression_spec.lua | 4 +- tests/test_util.lua | 10 ++ tests/url_spec.lua | 4 +- tests/win_options_spec.lua | 15 +- 18 files changed, 593 insertions(+), 291 deletions(-) create mode 100644 lua/oil/adapters/ssh/sshfs.lua create mode 100644 tests/move_rename_spec.lua diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1c7b3f4..bb2b930 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/README.md b/README.md index edeec19..9a2e739 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/lua/oil/adapters/files.lua b/lua/oil/adapters/files.lua index 91a78fa..1b307c9 100644 --- a/lua/oil/adapters/files.lua +++ b/lua/oil/adapters/files.lua @@ -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) diff --git a/lua/oil/adapters/ssh.lua b/lua/oil/adapters/ssh.lua index 0381d37..8d04c27 100644 --- a/lua/oil/adapters/ssh.lua +++ b/lua/oil/adapters/ssh.lua @@ -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 diff --git a/lua/oil/adapters/ssh/sshfs.lua b/lua/oil/adapters/ssh/sshfs.lua new file mode 100644 index 0000000..36b4945 --- /dev/null +++ b/lua/oil/adapters/ssh/sshfs.lua @@ -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 diff --git a/lua/oil/adapters/test.lua b/lua/oil/adapters/test.lua index 0f4d4a7..b127dfc 100644 --- a/lua/oil/adapters/test.lua +++ b/lua/oil/adapters/test.lua @@ -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 diff --git a/lua/oil/config.lua b/lua/oil/config.lua index fe596c4..f80b993 100644 --- a/lua/oil/config.lua +++ b/lua/oil/config.lua @@ -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 = {} diff --git a/lua/oil/init.lua b/lua/oil/init.lua index f5ff70a..3cff13c 100644 --- a/lua/oil/init.lua +++ b/lua/oil/init.lua @@ -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 ---@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 diff --git a/lua/oil/mutator/init.lua b/lua/oil/mutator/init.lua index e61801e..0ed8b41 100644 --- a/lua/oil/mutator/init.lua +++ b/lua/oil/mutator/init.lua @@ -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() diff --git a/lua/oil/shell.lua b/lua/oil/shell.lua index 63cb823..2c401ef 100644 --- a/lua/oil/shell.lua +++ b/lua/oil/shell.lua @@ -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] diff --git a/lua/oil/util.lua b/lua/oil/util.lua index ad03170..c669771 100644 --- a/lua/oil/util.lua +++ b/lua/oil/util.lua @@ -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 diff --git a/lua/oil/view.lua b/lua/oil/view.lua index 4363d9f..b5978cb 100644 --- a/lua/oil/view.lua +++ b/lua/oil/view.lua @@ -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) diff --git a/tests/altbuf_spec.lua b/tests/altbuf_spec.lua index d68ebbf..b73cb88 100644 --- a/tests/altbuf_spec.lua +++ b/tests/altbuf_spec.lua @@ -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) diff --git a/tests/move_rename_spec.lua b/tests/move_rename_spec.lua new file mode 100644 index 0000000..660ab64 --- /dev/null +++ b/tests/move_rename_spec.lua @@ -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) diff --git a/tests/regression_spec.lua b/tests/regression_spec.lua index 9f63e97..1e65f62 100644 --- a/tests/regression_spec.lua +++ b/tests/regression_spec.lua @@ -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" } }) diff --git a/tests/test_util.lua b/tests/test_util.lua index 3cf24b7..3d0ca10 100644 --- a/tests/test_util.lua +++ b/tests/test_util.lua @@ -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 diff --git a/tests/url_spec.lua b/tests/url_spec.lua index 6a1861a..0045d81 100644 --- a/tests/url_spec.lua +++ b/tests/url_spec.lua @@ -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) diff --git a/tests/win_options_spec.lua b/tests/win_options_spec.lua index e0e42ac..c8fc34e 100644 --- a/tests/win_options_spec.lua +++ b/tests/win_options_spec.lua @@ -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 ", 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({ 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" } })