From a5cfee05a493b3e69f419a0a14ecae9e98424bda Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 17 Mar 2026 21:40:17 -0400 Subject: [PATCH] feat(ftp): add FTP/FTPS adapter via curl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: canola has no way to browse or edit files on FTP servers, despite the adapter system being designed for exactly this pattern. curl speaks FTP natively, including FTPS (FTP over TLS), and requires no new dependencies. Solution: implement `lua/oil/adapters/ftp.lua` with `oil-ftp://` and `oil-ftps://` schemes. Parses Unix and IIS LIST output, supports `size`, `mtime`, and `permissions` columns, and implements the full adapter API (list, read_file, write_file, render_action, perform_action). Same-host renames use RNFR/RNTO; cross-host and local↔FTP copies use curl download/upload through a tmpfile. Adds `extra_curl_args` config option and documents the adapter in `doc/oil.txt`. Based on: stevearc/oil.nvim#210 --- doc/oil.txt | 46 +++ doc/upstream.md | 2 +- lua/oil/adapters/ftp.lua | 639 +++++++++++++++++++++++++++++++++++++++ lua/oil/config.lua | 6 + 4 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 lua/oil/adapters/ftp.lua 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