feat(config): add per-host/bucket extra args for SSH, S3, and FTP

Problem: `extra_scp_args`, `extra_s3_args`, and `extra_curl_args` are
global — there's no way to pass adapter-specific args only to a single
host or bucket (e.g. `-O` for a Synology NAS that requires legacy SCP
protocol, or `--endpoint-url` for an R2 bucket).

Solution: add `ssh_hosts`, `s3_buckets`, and `ftp_hosts` config tables
that map exact hostnames/bucket names to per-target arg lists. Per-target
args are appended after the global args at call time. The `scp()` helper
in `ssh.lua` accepts a `hosts` list so cross-host copies deduplicate
host lookups. `create_s3_command` in `s3fs.lua` extracts the bucket from
the command args with no call-site changes needed. `resolved_curl_args`
in `ftp.lua` is called by both `curl()` and `ftpcmd()`.

Based on: stevearc/oil.nvim#607
This commit is contained in:
Barrett Ruth 2026-03-18 12:37:14 -04:00
parent 6845cfe64a
commit 1e5c96ca2c
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
7 changed files with 227 additions and 22 deletions

View file

@ -175,6 +175,21 @@ The full list of options with their defaults:
extra_s3_args = {}, extra_s3_args = {},
-- Extra arguments to pass to curl for FTP operations -- Extra arguments to pass to curl for FTP operations
extra_curl_args = {}, extra_curl_args = {},
-- Per-host SCP arg overrides. Args are appended after global extra_scp_args.
-- Keys are exact hostnames as they appear in the SSH URL.
ssh_hosts = {
-- ["myserver.com"] = { extra_scp_args = { "-O" } },
},
-- Per-bucket S3 arg overrides. Args are appended after global extra_s3_args.
-- Keys are exact S3 bucket names.
s3_buckets = {
-- ["my-r2-bucket"] = { extra_s3_args = { "--endpoint-url", "https://..." } },
},
-- Per-host curl arg overrides for FTP. Args are appended after global extra_curl_args.
-- Keys are exact hostnames as they appear in the FTP URL.
ftp_hosts = {
-- ["ftp.internal.com"] = { extra_curl_args = { "--insecure" } },
},
-- EXPERIMENTAL support for performing file operations with git -- EXPERIMENTAL support for performing file operations with git
git = { git = {
-- Return true to automatically git add/mv/rm files -- Return true to automatically git add/mv/rm files
@ -337,6 +352,35 @@ preview_win.max_file_size *oil.preview_win*
than this limit will show a placeholder message instead of being loaded. than this limit will show a placeholder message instead of being loaded.
Set to `nil` to disable the limit. Set to `nil` to disable the limit.
ssh_hosts *oil.ssh_hosts*
type: `table<string, {extra_scp_args?: string[]}>` default: `{}`
Per-host SCP argument overrides. Keys are exact hostnames as they appear
in the SSH URL. Per-host args are appended after the global
`extra_scp_args`. Example: >lua
ssh_hosts = {
["nas.local"] = { extra_scp_args = { "-O" } },
}
<
s3_buckets *oil.s3_buckets*
type: `table<string, {extra_s3_args?: string[]}>` default: `{}`
Per-bucket S3 argument overrides. Keys are exact S3 bucket names.
Per-bucket args are appended after the global `extra_s3_args`. Example: >lua
s3_buckets = {
["my-r2-bucket"] = { extra_s3_args = { "--endpoint-url", "https://..." } },
}
<
ftp_hosts *oil.ftp_hosts*
type: `table<string, {extra_curl_args?: string[]}>` default: `{}`
Per-host curl argument overrides for FTP operations. Keys are exact
hostnames as they appear in the FTP URL. Per-host args are appended after
the global `extra_curl_args`. Example: >lua
ftp_hosts = {
["ftp.internal.com"] = { extra_curl_args = { "--insecure" } },
}
<
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
API *oil-api* API *oil-api*

View file

@ -99,7 +99,7 @@ issues against this fork.
| [#578](https://github.com/stevearc/oil.nvim/issues/578) | Hidden file dimming recipe | fixed | | [#578](https://github.com/stevearc/oil.nvim/issues/578) | Hidden file dimming recipe | fixed |
| [#587](https://github.com/stevearc/oil.nvim/issues/587) | Alt+h keymap | not actionable — user config issue | | [#587](https://github.com/stevearc/oil.nvim/issues/587) | Alt+h keymap | not actionable — user config issue |
| [#599](https://github.com/stevearc/oil.nvim/issues/599) | user:group display and manipulation | consolidated into [#126](https://github.com/barrettruth/canola.nvim/issues/126) | | [#599](https://github.com/stevearc/oil.nvim/issues/599) | user:group display and manipulation | consolidated into [#126](https://github.com/barrettruth/canola.nvim/issues/126) |
| [#607](https://github.com/stevearc/oil.nvim/issues/607) | Per-host SCP args | open | | [#607](https://github.com/stevearc/oil.nvim/issues/607) | Per-host SCP args | fixed |
| [#609](https://github.com/stevearc/oil.nvim/issues/609) | Cursor placement via Snacks picker | not actionable — Windows-only | | [#609](https://github.com/stevearc/oil.nvim/issues/609) | Cursor placement via Snacks picker | not actionable — Windows-only |
| [#612](https://github.com/stevearc/oil.nvim/issues/612) | Delete buffers on file delete | fixed | | [#612](https://github.com/stevearc/oil.nvim/issues/612) | Delete buffers on file delete | fixed |
| [#615](https://github.com/stevearc/oil.nvim/issues/615) | Cursor at name column on o/O | fixed ([#72](https://github.com/barrettruth/canola.nvim/pull/72)) | | [#615](https://github.com/stevearc/oil.nvim/issues/615) | Cursor at name column on o/O | fixed ([#72](https://github.com/barrettruth/canola.nvim/pull/72)) |

View file

@ -106,6 +106,17 @@ local function curl_ftp_url(url)
return table.concat(pieces, '') return table.concat(pieces, '')
end end
---@param host string
---@return string[]
local function resolved_curl_args(host)
local extra = vim.deepcopy(config.extra_curl_args)
local host_cfg = config.ftp_hosts[host]
if host_cfg and host_cfg.extra_curl_args then
vim.list_extend(extra, host_cfg.extra_curl_args)
end
return extra
end
---@param url oil.ftpUrl ---@param url oil.ftpUrl
---@param py_lines string[] ---@param py_lines string[]
---@param cb fun(err: nil|string) ---@param cb fun(err: nil|string)
@ -113,8 +124,8 @@ local function ftpcmd(url, py_lines, cb)
local lines = {} local lines = {}
local use_tls = url.scheme == 'oil-ftps://' local use_tls = url.scheme == 'oil-ftps://'
if use_tls then if use_tls then
local insecure = vim.tbl_contains(config.extra_curl_args, '--insecure') local curl_args = resolved_curl_args(url.host)
or vim.tbl_contains(config.extra_curl_args, '-k') local insecure = vim.tbl_contains(curl_args, '--insecure') or vim.tbl_contains(curl_args, '-k')
table.insert(lines, 'import ftplib, ssl') table.insert(lines, 'import ftplib, ssl')
table.insert(lines, 'ctx = ssl.create_default_context()') table.insert(lines, 'ctx = ssl.create_default_context()')
if insecure then if insecure then
@ -170,7 +181,7 @@ local function curl(url, extra_args, opts, cb)
end end
local cmd = { 'curl', '-sS', '--netrc-optional' } local cmd = { 'curl', '-sS', '--netrc-optional' }
vim.list_extend(cmd, ssl_args(url)) vim.list_extend(cmd, ssl_args(url))
vim.list_extend(cmd, config.extra_curl_args) vim.list_extend(cmd, resolved_curl_args(url.host))
vim.list_extend(cmd, extra_args) vim.list_extend(cmd, extra_args)
shell.run(cmd, opts, cb) shell.run(cmd, opts, cb)
end end

View file

@ -46,8 +46,22 @@ end
---@param cmd string[] cmd and flags ---@param cmd string[] cmd and flags
---@return string[] Shell command to run ---@return string[] Shell command to run
local function create_s3_command(cmd) local function create_s3_command(cmd)
local bucket
for _, arg in ipairs(cmd) do
bucket = arg:match('^s3://([^/]+)')
if bucket then
break
end
end
local extra = vim.deepcopy(config.extra_s3_args)
if bucket then
local bucket_cfg = config.s3_buckets[bucket]
if bucket_cfg and bucket_cfg.extra_s3_args then
vim.list_extend(extra, bucket_cfg.extra_s3_args)
end
end
local full_cmd = vim.list_extend({ 'aws', 's3' }, cmd) local full_cmd = vim.list_extend({ 'aws', 's3' }, cmd)
return vim.list_extend(full_cmd, config.extra_s3_args) return vim.list_extend(full_cmd, extra)
end end
---@param url string ---@param url string

View file

@ -21,9 +21,21 @@ local FIELD_META = constants.FIELD_META
---@field port nil|integer ---@field port nil|integer
---@field path string ---@field path string
---@param hosts string[]
---@param args string[] ---@param args string[]
local function scp(args, ...) local function scp(hosts, args, ...)
local cmd = vim.list_extend({ 'scp', '-C' }, config.extra_scp_args) local extra = vim.deepcopy(config.extra_scp_args)
local seen = {}
for _, host in ipairs(hosts) do
if not seen[host] then
seen[host] = true
local host_cfg = config.ssh_hosts[host]
if host_cfg and host_cfg.extra_scp_args then
vim.list_extend(extra, host_cfg.extra_scp_args)
end
end
end
local cmd = vim.list_extend({ 'scp', '-C' }, extra)
vim.list_extend(cmd, args) vim.list_extend(cmd, args)
shell.run(cmd, ...) shell.run(cmd, ...)
end end
@ -320,12 +332,16 @@ M.perform_action = function(action, cb)
local src_conn = get_connection(action.src_url) local src_conn = get_connection(action.src_url)
local dest_conn = get_connection(action.dest_url) local dest_conn = get_connection(action.dest_url)
if src_conn ~= dest_conn then if src_conn ~= dest_conn then
scp({ '-r', url_to_scp(src_res), url_to_scp(dest_res) }, function(err) scp(
if err then { src_res.host, dest_res.host },
return cb(err) { '-r', url_to_scp(src_res), url_to_scp(dest_res) },
function(err)
if err then
return cb(err)
end
src_conn:rm(src_res.path, cb)
end end
src_conn:rm(src_res.path, cb) )
end)
else else
src_conn:mv(src_res.path, dest_res.path, cb) src_conn:mv(src_res.path, dest_res.path, cb)
end end
@ -339,26 +355,31 @@ M.perform_action = function(action, cb)
local src_res = M.parse_url(action.src_url) local src_res = M.parse_url(action.src_url)
local dest_res = M.parse_url(action.dest_url) local dest_res = M.parse_url(action.dest_url)
if not url_hosts_equal(src_res, dest_res) then if not url_hosts_equal(src_res, dest_res) then
scp({ '-r', url_to_scp(src_res), url_to_scp(dest_res) }, cb) scp(
{ src_res.host, dest_res.host },
{ '-r', url_to_scp(src_res), url_to_scp(dest_res) },
cb
)
else else
local src_conn = get_connection(action.src_url) local src_conn = get_connection(action.src_url)
src_conn:cp(src_res.path, dest_res.path, cb) src_conn:cp(src_res.path, dest_res.path, cb)
end end
else else
local src_arg
local dest_arg
if src_adapter == M then if src_adapter == M then
src_arg = url_to_scp(M.parse_url(action.src_url)) local src_res = M.parse_url(action.src_url)
local src_arg = url_to_scp(src_res)
local _, path = util.parse_url(action.dest_url) local _, path = util.parse_url(action.dest_url)
assert(path) assert(path)
dest_arg = fs.posix_to_os_path(path) local dest_arg = fs.posix_to_os_path(path)
scp({ src_res.host }, { '-r', src_arg, dest_arg }, cb)
else else
local _, path = util.parse_url(action.src_url) local _, path = util.parse_url(action.src_url)
assert(path) assert(path)
src_arg = fs.posix_to_os_path(path) local src_arg = fs.posix_to_os_path(path)
dest_arg = url_to_scp(M.parse_url(action.dest_url)) local dest_res = M.parse_url(action.dest_url)
local dest_arg = url_to_scp(dest_res)
scp({ dest_res.host }, { '-r', src_arg, dest_arg }, cb)
end end
scp({ '-r', src_arg, dest_arg }, cb)
end end
else else
cb(string.format('Bad action type: %s', action.type)) cb(string.format('Bad action type: %s', action.type))
@ -384,7 +405,7 @@ M.read_file = function(bufnr)
end end
local tmp_bufnr = vim.fn.bufadd(tmpfile) local tmp_bufnr = vim.fn.bufadd(tmpfile)
scp({ scp_url, tmpfile }, function(err) scp({ url.host }, { scp_url, tmpfile }, function(err)
loading.set_loading(bufnr, false) loading.set_loading(bufnr, false)
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
vim.cmd.doautocmd({ args = { 'BufReadPre', bufname }, mods = { silent = true } }) vim.cmd.doautocmd({ args = { 'BufReadPre', bufname }, mods = { silent = true } })
@ -426,7 +447,7 @@ M.write_file = function(bufnr)
vim.cmd.write({ args = { tmpfile }, bang = true, mods = { silent = true, noautocmd = true } }) vim.cmd.write({ args = { tmpfile }, bang = true, mods = { silent = true, noautocmd = true } })
local tmp_bufnr = vim.fn.bufadd(tmpfile) local tmp_bufnr = vim.fn.bufadd(tmpfile)
scp({ tmpfile, scp_url }, function(err) scp({ url.host }, { tmpfile, scp_url }, function(err)
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
if err then if err then
vim.notify(string.format('Error writing file: %s', err), vim.log.levels.ERROR) vim.notify(string.format('Error writing file: %s', err), vim.log.levels.ERROR)

View file

@ -118,6 +118,9 @@ local default_config = {
extra_s3_args = {}, extra_s3_args = {},
-- Extra arguments to pass to curl for FTP operations -- Extra arguments to pass to curl for FTP operations
extra_curl_args = {}, extra_curl_args = {},
ssh_hosts = {},
s3_buckets = {},
ftp_hosts = {},
-- EXPERIMENTAL support for performing file operations with git -- EXPERIMENTAL support for performing file operations with git
git = { git = {
-- Return true to automatically git add/mv/rm files -- Return true to automatically git add/mv/rm files
@ -233,6 +236,15 @@ default_config.adapter_aliases = {}
-- here we can get some performance wins -- here we can get some performance wins
default_config.view_options.highlight_filename = nil default_config.view_options.highlight_filename = nil
---@class (exact) oil.SshHostConfig
---@field extra_scp_args? string[]
---@class (exact) oil.S3BucketConfig
---@field extra_s3_args? string[]
---@class (exact) oil.FtpHostConfig
---@field extra_curl_args? string[]
---@class oil.Config ---@class oil.Config
---@field adapters table<string, string> Hidden from SetupOpts ---@field adapters table<string, string> Hidden from SetupOpts
---@field adapter_aliases table<string, string> Hidden from SetupOpts ---@field adapter_aliases table<string, string> Hidden from SetupOpts
@ -259,6 +271,9 @@ default_config.view_options.highlight_filename = nil
---@field extra_scp_args string[] ---@field extra_scp_args string[]
---@field extra_s3_args string[] ---@field extra_s3_args string[]
---@field extra_curl_args string[] ---@field extra_curl_args string[]
---@field ssh_hosts table<string, oil.SshHostConfig>
---@field s3_buckets table<string, oil.S3BucketConfig>
---@field ftp_hosts table<string, oil.FtpHostConfig>
---@field git oil.GitOptions ---@field git oil.GitOptions
---@field float oil.FloatWindowConfig ---@field float oil.FloatWindowConfig
---@field preview_win oil.PreviewWindowConfig ---@field preview_win oil.PreviewWindowConfig
@ -294,6 +309,9 @@ local M = {}
---@field extra_scp_args? string[] Extra arguments to pass to SCP when moving/copying files over SSH ---@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_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 extra_curl_args? string[] Extra arguments to pass to curl for FTP operations
---@field ssh_hosts? table<string, oil.SshHostConfig> Per-host SCP arg overrides
---@field s3_buckets? table<string, oil.S3BucketConfig> Per-bucket S3 arg overrides
---@field ftp_hosts? table<string, oil.FtpHostConfig> Per-host curl arg overrides
---@field git? oil.SetupGitOptions EXPERIMENTAL support for performing file operations with git ---@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 float? oil.SetupFloatWindowConfig Configuration for the floating window in oil.open_float
---@field preview_win? oil.SetupPreviewWindowConfig Configuration for the file preview window ---@field preview_win? oil.SetupPreviewWindowConfig Configuration for the file preview window

View file

@ -0,0 +1,97 @@
local config = require('oil.config')
local test_util = require('spec.test_util')
describe('per-host/bucket arg overrides', function()
after_each(function()
test_util.reset_editor()
end)
describe('ssh_hosts', function()
it('stores ssh_hosts from setup', function()
config.setup({ ssh_hosts = { ['myserver.com'] = { extra_scp_args = { '-O' } } } })
assert.are.equal('-O', config.ssh_hosts['myserver.com'].extra_scp_args[1])
end)
it('defaults to empty table when not set', function()
config.setup({})
assert.are.same({}, config.ssh_hosts)
end)
it('returns nil for unknown host', function()
config.setup({ ssh_hosts = { ['known.host'] = { extra_scp_args = { '-O' } } } })
assert.is_nil(config.ssh_hosts['unknown.host'])
end)
end)
describe('s3_buckets', function()
it('stores s3_buckets from setup', function()
config.setup({
s3_buckets = {
['my-r2-bucket'] = { extra_s3_args = { '--endpoint-url', 'https://r2.example.com' } },
},
})
assert.are.equal('--endpoint-url', config.s3_buckets['my-r2-bucket'].extra_s3_args[1])
assert.are.equal('https://r2.example.com', config.s3_buckets['my-r2-bucket'].extra_s3_args[2])
end)
it('defaults to empty table when not set', function()
config.setup({})
assert.are.same({}, config.s3_buckets)
end)
it('returns nil for unknown bucket', function()
config.setup({
s3_buckets = { ['known-bucket'] = { extra_s3_args = { '--no-sign-request' } } },
})
assert.is_nil(config.s3_buckets['unknown-bucket'])
end)
end)
describe('ftp_hosts', function()
it('stores ftp_hosts from setup', function()
config.setup({ ftp_hosts = { ['ftp.internal.com'] = { extra_curl_args = { '--insecure' } } } })
assert.are.equal('--insecure', config.ftp_hosts['ftp.internal.com'].extra_curl_args[1])
end)
it('defaults to empty table when not set', function()
config.setup({})
assert.are.same({}, config.ftp_hosts)
end)
it('returns nil for unknown host', function()
config.setup({ ftp_hosts = { ['known.host'] = { extra_curl_args = { '--insecure' } } } })
assert.is_nil(config.ftp_hosts['unknown.host'])
end)
it('reflects --insecure in per-host curl args', function()
config.setup({ ftp_hosts = { ['ftp.internal.com'] = { extra_curl_args = { '--insecure' } } } })
local host_cfg = config.ftp_hosts['ftp.internal.com']
assert.is_truthy(vim.tbl_contains(host_cfg.extra_curl_args, '--insecure'))
end)
end)
describe('merge semantics', function()
it('per-host ssh args supplement global args', function()
config.setup({
extra_scp_args = { '-C' },
ssh_hosts = { ['myserver.com'] = { extra_scp_args = { '-O' } } },
})
assert.are.same({ '-C' }, config.extra_scp_args)
assert.are.same({ '-O' }, config.ssh_hosts['myserver.com'].extra_scp_args)
end)
it('per-bucket s3 args supplement global args', function()
config.setup({
extra_s3_args = { '--sse', 'aws:kms' },
s3_buckets = {
['special-bucket'] = { extra_s3_args = { '--endpoint-url', 'https://...' } },
},
})
assert.are.same({ '--sse', 'aws:kms' }, config.extra_s3_args)
assert.are.same(
{ '--endpoint-url', 'https://...' },
config.s3_buckets['special-bucket'].extra_s3_args
)
end)
end)
end)