diff --git a/doc/oil.txt b/doc/oil.txt index 71d8d70..b2ccef7 100644 --- a/doc/oil.txt +++ b/doc/oil.txt @@ -173,6 +173,8 @@ The full list of options with their defaults: extra_scp_args = {}, -- Extra arguments to pass to aws s3 when creating/deleting/moving/copying files using aws s3 extra_s3_args = {}, + -- Extra arguments to pass to curl for FTP operations + extra_curl_args = {}, -- EXPERIMENTAL support for performing file operations with git git = { -- Return true to automatically git add/mv/rm files @@ -993,6 +995,50 @@ S3 *oil-adapter-s Older versions of Neovim (0.11 and earlier) don't support numbers in the URL scheme, so use `oil-sss` instead of `oil-s3`. +FTP *oil-adapter-ftp* + + Browse files over FTP or FTPS (FTP over TLS). Open a buffer with: > + nvim oil-ftp://[username[:password]@]hostname[:port]/[path]/ + nvim oil-ftps://[username[:password]@]hostname[:port]/[path]/ +< + The `oil-ftps://` scheme connects with `--ssl-reqd`, requiring a valid TLS + certificate. Use `oil-ftp://` for plain FTP. + + Authentication ~ + + Credentials can be supplied in the URL (`user:pass@host`) or stored in + `~/.netrc` (recommended — keeps passwords out of shell history): > + machine ftp.example.com login myuser password mypass +< + How it works ~ + + The FTP adapter uses `curl` to perform all operations. Directory listings + come from FTP LIST output (Unix and IIS/Windows formats are both supported). + File reads download to a local tempfile; file writes upload from a tempfile. + Renames on the same server use the FTP RNFR/RNTO commands. File copies + between servers go through a local tempfile. + + Limitations ~ + + Symbolic links cannot be created over FTP. Directory copies are not + supported (copy individual files instead). Moving or copying directories + across different FTP hosts is not supported. + + Configuration ~ + + Pass extra flags to `curl` with `extra_curl_args`: >lua + require("oil").setup({ + extra_curl_args = { "--insecure" }, + }) +< + The adapter supports the `size`, `mtime`, and `permissions` columns. + Permission changes use the FTP `SITE CHMOD` command; not all servers + support it. + + Dependencies ~ + + Requires `curl` (standard on Linux and macOS). + Trash *oil-adapter-trash* See |oil-trash| for details on the built-in trash adapter. diff --git a/doc/upstream.md b/doc/upstream.md index bffee00..925db5f 100644 --- a/doc/upstream.md +++ b/doc/upstream.md @@ -40,7 +40,7 @@ issues against this fork. | [#156](https://github.com/stevearc/oil.nvim/issues/156) | Paste path of files into oil buffer | fixed — added `oil-recipe-paste-file-from-clipboard` | | [#200](https://github.com/stevearc/oil.nvim/issues/200) | Highlights not working when opening a file | not actionable — cannot reproduce, nvim 0.9.4 | | [#207](https://github.com/stevearc/oil.nvim/issues/207) | Suppress "no longer available" message | fixed — `cleanup_buffers_on_delete` option | -| [#210](https://github.com/stevearc/oil.nvim/issues/210) | FTP support | open | +| [#210](https://github.com/stevearc/oil.nvim/issues/210) | FTP support | fixed | | [#213](https://github.com/stevearc/oil.nvim/issues/213) | Disable preview for large files | fixed ([#85](https://github.com/barrettruth/canola.nvim/pull/85)) | | [#226](https://github.com/stevearc/oil.nvim/issues/226) | K8s/Docker adapter | not actionable — no demand | | [#232](https://github.com/stevearc/oil.nvim/issues/232) | Cannot close last window | consolidated into [#149](https://github.com/barrettruth/canola.nvim/issues/149) | diff --git a/lua/oil/adapters/ftp.lua b/lua/oil/adapters/ftp.lua new file mode 100644 index 0000000..3d8580c --- /dev/null +++ b/lua/oil/adapters/ftp.lua @@ -0,0 +1,639 @@ +local config = require('oil.config') +local constants = require('oil.constants') +local files = require('oil.adapters.files') +local fs = require('oil.fs') +local loading = require('oil.loading') +local pathutil = require('oil.pathutil') +local permissions = require('oil.adapters.files.permissions') +local shell = require('oil.shell') +local util = require('oil.util') +local M = {} + +local FIELD_TYPE = constants.FIELD_TYPE +local FIELD_META = constants.FIELD_META + +---@class (exact) oil.ftpUrl +---@field scheme string +---@field host string +---@field user nil|string +---@field password nil|string +---@field port nil|integer +---@field path string + +---@param oil_url string +---@return oil.ftpUrl +M.parse_url = function(oil_url) + local scheme, url = util.parse_url(oil_url) + assert(scheme and url, string.format("Malformed input url '%s'", oil_url)) + local ret = { scheme = scheme } + local userinfo, rem = url:match('^([^@%s]+)@(.*)$') + if userinfo then + local user, pass = userinfo:match('^([^:]+):(.+)$') + if user then + ret.user = user + ret.password = pass + else + ret.user = userinfo + end + url = rem + end + 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 FTP url: %s', oil_url)) + end + ---@cast ret oil.ftpUrl + return ret +end + +---@param url oil.ftpUrl +---@return string +local function url_to_str(url) + local pieces = { url.scheme } + if url.user then + table.insert(pieces, url.user) + if url.password then + table.insert(pieces, ':') + table.insert(pieces, url.password) + end + 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.ftpUrl +---@return string +local function curl_ftp_url(url) + local scheme = url.scheme == 'oil-ftps://' and 'ftps://' or 'ftp://' + local pieces = { scheme } + if url.user then + table.insert(pieces, url.user) + if url.password then + table.insert(pieces, ':') + table.insert(pieces, url.password) + end + 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.ftpUrl +---@return string +local function host_ftp_url(url) + local scheme = url.scheme == 'oil-ftps://' and 'ftps://' or 'ftp://' + local pieces = { scheme } + if url.user then + table.insert(pieces, url.user) + if url.password then + table.insert(pieces, ':') + table.insert(pieces, url.password) + end + 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, '/') + return table.concat(pieces, '') +end + +---@param url oil.ftpUrl +---@return string[] +local function ssl_args(url) + if url.scheme == 'oil-ftps://' then + return { '--ssl-reqd' } + end + return {} +end + +---@param url oil.ftpUrl +---@param extra_args string[] +---@param cb fun(err: nil|string, output: nil|string[]) +local function curl(url, extra_args, cb) + local cmd = { 'curl', '-s', '--netrc-optional' } + vim.list_extend(cmd, ssl_args(url)) + vim.list_extend(cmd, config.extra_curl_args) + vim.list_extend(cmd, extra_args) + shell.run(cmd, cb) +end + +---@param url oil.ftpUrl +---@return string +local function ftp_abs_path(url) + return '/' .. url.path +end + +---@param url1 oil.ftpUrl +---@param url2 oil.ftpUrl +---@return boolean +local function url_hosts_equal(url1, url2) + return url1.host == url2.host and url1.port == url2.port and url1.user == url2.user +end + +local month_map = { + Jan = 1, + Feb = 2, + Mar = 3, + Apr = 4, + May = 5, + Jun = 6, + Jul = 7, + Aug = 8, + Sep = 9, + Oct = 10, + Nov = 11, + Dec = 12, +} + +---@param line string +---@return nil|string, nil|string, nil|table +local function parse_unix_list_line(line) + local perms, size, month, day, timeoryear, name = + line:match('^([dlrwxstST%-]+)%s+%d+%s+%S+%s+%S+%s+(%d+)%s+(%a+)%s+(%d+)%s+(%S+)%s+(.+)$') + if not perms then + return nil + end + local entry_type + local first = perms:sub(1, 1) + if first == 'd' then + entry_type = 'directory' + elseif first == 'l' then + entry_type = 'link' + else + entry_type = 'file' + end + local link_target + if entry_type == 'link' then + local link_name, target = name:match('^(.+) %-> (.+)$') + if link_name then + name = link_name + link_target = target + end + end + local mtime + local mon = month_map[month] + if mon then + local hour, min = timeoryear:match('^(%d+):(%d+)$') + if hour then + local now = os.time() + local t = os.date('*t', now) + ---@cast t osdate + mtime = os.time({ + year = t.year, + month = mon, + day = tonumber(day), + hour = tonumber(hour), + min = tonumber(min), + sec = 0, + }) + if mtime > now + 86400 then + mtime = os.time({ + year = t.year - 1, + month = mon, + day = tonumber(day), + hour = tonumber(hour), + min = tonumber(min), + sec = 0, + }) + end + else + local year = tonumber(timeoryear) + if year then + mtime = + os.time({ year = year, month = mon, day = tonumber(day), hour = 0, min = 0, sec = 0 }) + end + end + end + local mode = permissions.parse(perms:sub(2)) + local meta = { size = tonumber(size), mtime = mtime, mode = mode } + if link_target then + meta.link = link_target + end + return name, entry_type, meta +end + +---@param line string +---@return nil|string, nil|string, nil|table +local function parse_iis_list_line(line) + local size_or_dir, name = line:match('^%d+%-%d+%-%d+%s+%d+:%d+%a+%s+(%S+)%s+(.+)$') + if not size_or_dir then + return nil + end + local entry_type, size + if size_or_dir == '' then + entry_type = 'directory' + else + entry_type = 'file' + size = tonumber(size_or_dir) + end + local meta = { size = size } + return name, entry_type, meta +end + +local ftp_columns = {} +ftp_columns.size = { + render = function(entry, conf) + local meta = entry[FIELD_META] + if not meta or not meta.size then + return '' + end + if entry[FIELD_TYPE] == 'directory' then + return '' + end + 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, + + get_sort_value = function(entry) + local meta = entry[FIELD_META] + if meta and meta.size then + return meta.size + else + return 0 + end + end, +} + +ftp_columns.mtime = { + render = function(entry, conf) + local meta = entry[FIELD_META] + if not meta or not meta.mtime then + return '' + end + return os.date('%Y-%m-%d %H:%M', meta.mtime) + end, + + parse = function(line, conf) + return line:match('^(%d+%-%d+%-%d+%s%d+:%d+)%s+(.*)$') + end, + + get_sort_value = function(entry) + local meta = entry[FIELD_META] + if meta and meta.mtime then + return meta.mtime + else + return 0 + end + end, +} + +ftp_columns.permissions = { + render = function(entry, conf) + local meta = entry[FIELD_META] + if not meta or not meta.mode then + return + end + local str = permissions.mode_to_str(meta.mode) + return { str, permissions.mode_to_highlights(str) } + 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 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 = M.parse_url(action.url) + local octal = permissions.mode_to_octal_str(action.value) + local ftp_path = ftp_abs_path(res) + curl(res, { + '--quote', + string.format('SITE CHMOD %s %s', octal, ftp_path), + host_ftp_url(res), + }, callback) + end, +} + +---@param name string +---@return nil|oil.ColumnDefinition +M.get_column = function(name) + return ftp_columns[name] +end + +---@param bufname string +---@return string +M.get_parent = function(bufname) + local res = M.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 = M.parse_url(url) + callback(url_to_str(res)) +end + +---@param url string +---@param column_defs string[] +---@param callback fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun()) +M.list = function(url, column_defs, callback) + if vim.fn.executable('curl') ~= 1 then + callback('`curl` is not executable. Can you run `curl --version`?') + return + end + local res = M.parse_url(url) + curl(res, { curl_ftp_url(res) }, function(err, output) + if err then + callback(err) + return + end + local entries = {} + for _, line in ipairs(output or {}) do + if line ~= '' then + local name, entry_type, meta = parse_unix_list_line(line) + if not name then + name, entry_type, meta = parse_iis_list_line(line) + end + if name and entry_type and name ~= '.' and name ~= '..' then + table.insert(entries, { 0, name, entry_type, meta }) + end + end + end + callback(nil, entries) + end) +end + +---@param bufnr integer +---@return boolean +M.is_modifiable = function(bufnr) + return true +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(src) + assert(path) + src = files.to_short_os_path(path, action.entry_type) + end + if config.get_adapter_by_scheme(dest) ~= M then + local _, path = util.parse_url(dest) + assert(path) + dest = 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 src_res oil.ftpUrl +---@param dest_res oil.ftpUrl +---@param cb fun(err: nil|string) +local function ftp_copy_file(src_res, dest_res, cb) + local cache_dir = vim.fn.stdpath('cache') + assert(type(cache_dir) == 'string') + local tmpdir = fs.join(cache_dir, 'oil') + fs.mkdirp(tmpdir) + local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, 'ftp_XXXXXX')) + if fd then + vim.loop.fs_close(fd) + end + curl(src_res, { curl_ftp_url(src_res), '-o', tmpfile }, function(err) + if err then + vim.loop.fs_unlink(tmpfile) + return cb(err) + end + curl(dest_res, { '-T', tmpfile, curl_ftp_url(dest_res) }, function(err2) + vim.loop.fs_unlink(tmpfile) + cb(err2) + end) + 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 = M.parse_url(action.url) + local ftp_path = ftp_abs_path(res) + if action.entry_type == 'directory' then + curl(res, { + '--quote', + string.format('MKD %s', ftp_path), + host_ftp_url(res), + }, cb) + elseif action.entry_type == 'link' then + cb('FTP does not support symbolic links') + else + curl(res, { '-T', '/dev/null', curl_ftp_url(res) }, cb) + end + elseif action.type == 'delete' then + local res = M.parse_url(action.url) + local ftp_path = ftp_abs_path(res) + if action.entry_type == 'directory' then + curl(res, { + '--quote', + string.format('RMD %s', ftp_path), + host_ftp_url(res), + }, cb) + else + curl(res, { + '--quote', + string.format('DELE %s', ftp_path), + host_ftp_url(res), + }, cb) + end + elseif action.type == 'move' then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + if src_adapter == M and dest_adapter == M then + local src_res = M.parse_url(action.src_url) + local dest_res = M.parse_url(action.dest_url) + if url_hosts_equal(src_res, dest_res) then + curl(src_res, { + '--quote', + string.format('RNFR %s', ftp_abs_path(src_res)), + '--quote', + string.format('RNTO %s', ftp_abs_path(dest_res)), + host_ftp_url(src_res), + }, cb) + else + if action.entry_type == 'directory' then + cb('Cannot move directories across FTP hosts') + return + end + ftp_copy_file(src_res, dest_res, function(err) + if err then + return cb(err) + end + curl(src_res, { + '--quote', + string.format('DELE %s', ftp_abs_path(src_res)), + host_ftp_url(src_res), + }, cb) + end) + end + else + cb('We should never attempt to move across adapters') + end + elseif action.type == 'copy' then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + if src_adapter == M and dest_adapter == M then + if action.entry_type == 'directory' then + cb('Cannot copy directories over FTP; copy individual files instead') + return + end + local src_res = M.parse_url(action.src_url) + local dest_res = M.parse_url(action.dest_url) + ftp_copy_file(src_res, dest_res, cb) + elseif src_adapter == M then + if action.entry_type == 'directory' then + cb('Cannot copy FTP directories to local filesystem via curl') + return + end + local src_res = M.parse_url(action.src_url) + local _, dest_path = util.parse_url(action.dest_url) + assert(dest_path) + local local_path = fs.posix_to_os_path(dest_path) + curl(src_res, { curl_ftp_url(src_res), '-o', local_path }, cb) + else + if action.entry_type == 'directory' then + cb('Cannot copy local directories to FTP via curl') + return + end + local _, src_path = util.parse_url(action.src_url) + assert(src_path) + local local_path = fs.posix_to_os_path(src_path) + local dest_res = M.parse_url(action.dest_url) + curl(dest_res, { '-T', local_path, curl_ftp_url(dest_res) }, cb) + end + else + cb(string.format('Bad action type: %s', action.type)) + end +end + +M.supported_cross_adapter_actions = { files = 'copy' } + +---@param bufnr integer +M.read_file = function(bufnr) + loading.set_loading(bufnr, true) + local bufname = vim.api.nvim_buf_get_name(bufnr) + local url = M.parse_url(bufname) + local basename = pathutil.basename(bufname) + local cache_dir = vim.fn.stdpath('cache') + assert(type(cache_dir) == 'string') + local tmpdir = fs.join(cache_dir, 'oil') + fs.mkdirp(tmpdir) + local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, 'ftp_XXXXXX')) + if fd then + vim.loop.fs_close(fd) + end + local tmp_bufnr = vim.fn.bufadd(tmpfile) + + curl(url, { curl_ftp_url(url), '-o', 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 + local filetype = vim.filetype.match({ buf = bufnr, filename = basename }) + if filetype then + vim.bo[bufnr].filetype = filetype + end + 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) + local url = M.parse_url(bufname) + local cache_dir = vim.fn.stdpath('cache') + assert(type(cache_dir) == 'string') + local tmpdir = fs.join(cache_dir, 'oil') + local fd, tmpfile = vim.loop.fs_mkstemp(fs.join(tmpdir, 'ftp_XXXXXXXX')) + if fd then + vim.loop.fs_close(fd) + end + vim.cmd.doautocmd({ args = { 'BufWritePre', bufname }, mods = { silent = true } }) + vim.bo[bufnr].modifiable = false + vim.cmd.write({ args = { tmpfile }, bang = true, mods = { silent = true, noautocmd = true } }) + local tmp_bufnr = vim.fn.bufadd(tmpfile) + + curl(url, { '-T', tmpfile, curl_ftp_url(url) }, function(err) + vim.bo[bufnr].modifiable = true + if err then + vim.notify(string.format('Error writing file: %s', err), vim.log.levels.ERROR) + else + vim.bo[bufnr].modified = false + vim.cmd.doautocmd({ args = { 'BufWritePost', bufname }, mods = { silent = true } }) + end + vim.loop.fs_unlink(tmpfile) + vim.api.nvim_buf_delete(tmp_bufnr, { force = true }) + end) +end + +return M diff --git a/lua/oil/config.lua b/lua/oil/config.lua index ec381b4..f96239e 100644 --- a/lua/oil/config.lua +++ b/lua/oil/config.lua @@ -116,6 +116,8 @@ local default_config = { extra_scp_args = {}, -- Extra arguments to pass to aws s3 when creating/deleting/moving/copying files using aws s3 extra_s3_args = {}, + -- Extra arguments to pass to curl for FTP operations + extra_curl_args = {}, -- EXPERIMENTAL support for performing file operations with git git = { -- Return true to automatically git add/mv/rm files @@ -223,6 +225,8 @@ default_config.adapters = { ['oil-ssh://'] = 'ssh', [oil_s3_string] = 's3', ['oil-trash://'] = 'trash', + ['oil-ftp://'] = 'ftp', + ['oil-ftps://'] = 'ftp', } default_config.adapter_aliases = {} -- We want the function in the default config for documentation generation, but if we nil it out @@ -254,6 +258,7 @@ default_config.view_options.highlight_filename = nil ---@field new_dir_mode integer ---@field extra_scp_args string[] ---@field extra_s3_args string[] +---@field extra_curl_args string[] ---@field git oil.GitOptions ---@field float oil.FloatWindowConfig ---@field preview_win oil.PreviewWindowConfig @@ -288,6 +293,7 @@ local M = {} ---@field new_dir_mode? integer Permission mode for new directories in decimal (default 493 = 0755) ---@field extra_scp_args? string[] Extra arguments to pass to SCP when moving/copying files over SSH ---@field extra_s3_args? string[] Extra arguments to pass to aws s3 when moving/copying files using aws s3 +---@field extra_curl_args? string[] Extra arguments to pass to curl for FTP operations ---@field git? oil.SetupGitOptions EXPERIMENTAL support for performing file operations with git ---@field float? oil.SetupFloatWindowConfig Configuration for the floating window in oil.open_float ---@field preview_win? oil.SetupPreviewWindowConfig Configuration for the file preview window