feat(ftp): add FTP/FTPS adapter via curl (#167)
* feat(ftp): add FTP/FTPS adapter via curl 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 * docs(upstream): mark #210 fixed in #167 * fix(ftp): use python3 ftplib for control-channel FTP operations Problem: DELE, RMD, MKD, and RNFR/RNTO were implemented using curl --quote, which requires a subsequent LIST or STOR to trigger the FTP connection. That data-channel operation hangs on slow or busy servers, making every mutation appear stuck. Solution: replace the curl --quote approach with a python3 ftplib one-liner for all control-channel operations. ftplib executes DELE, RMD, MKD, RNFR/RNTO, and SITE CHMOD without opening a data channel, so they complete instantly. The curl wrapper is retained for LIST, read_file, and write_file, which genuinely need a data channel. * fix(ftp): use nil entry ID so cache assigns unique IDs Problem: `M.list` returned entries as `{0, name, type, meta}`. `cache.store_entry` only assigns a fresh ID when `entry[FIELD_ID] == nil`; passing 0 caused every entry to be stored as ID 0, all overwriting each other. `get_entry_by_id(0)` then always returned the last-stored entry, breaking navigation (always opened the same file), rename (wrong entry matched), and create (wrong diff). Solution: change the placeholder from 0 to nil, matching how `cache.create_entry` itself builds entries. * fix(ftp): use ftp.rename() for RNFR/RNTO and raw Python lines in ftpcmd Problem: `ftpcmd` wrapped every command in `ftp.voidcmd()`, which expects a final 2xx response. `RNFR` returns 350 (intermediate), so `voidcmd` raised an exception before `RNTO` was ever sent, causing every rename to fail with '350 Ready for destination name'. Solution: change `ftpcmd` to accept raw Python lines instead of FTP command strings, then use `ftp.rename(src, dst)` for the rename case. `ftplib.rename` handles the 350 intermediate response correctly internally. All other callers now wrap their FTP commands in `ftp.voidcmd()` explicitly. * fix(ftp): recursively delete directory contents before RMD Problem: FTP's RMD command fails with '550 Directory not empty' if the directory has any contents. Unlike the S3 adapter which uses `aws s3 rm --recursive`, FTP has no protocol-level recursive delete. Solution: emit a Python rmtree helper inside the ftpcmd script that walks the directory via MLSD, recursively deletes children (DELE for files, rmtree for subdirs), then sends RMD on the now-empty directory. * fix(ftp): give oil-ftps:// its own adapter name to prevent scheme clobbering Problem: both oil-ftp:// and oil-ftps:// mapped to the adapter name 'ftp', so config.adapter_to_scheme['ftp'] was set to whichever scheme pairs() iterated last — non-deterministic. init.lua uses adapter_to_scheme[adapter.name] to reconstruct the parent URL, so roughly half the time it injected 'oil-ftps://' into ftp:// buffer navigation, causing the ssl-reqd error on '-' press. Solution: register oil-ftps:// under adapter name 'ftps' via a one-line shim that returns require('oil.adapters.ftp'). Now adapter_to_scheme['ftp'] = 'oil-ftp://' and adapter_to_scheme['ftps'] = 'oil-ftps://' are both stable. * fix(ftp): percent-encode path in curl FTP URLs Problem: filenames containing spaces (or other URL-unsafe characters) caused curl to fail with "Unknown error" because the raw path was concatenated directly into the FTP URL. Solution: add `url_encode_path` to encode non-safe characters (excluding `/`) before building the curl URL in `curl_ftp_url`. * fix(ftp): fix STARTTLS, error visibility, and robustness Problem: `curl_ftp_url` emitted `ftps://` (implicit TLS) for `oil-ftps://` URLs, causing listing to fail against STARTTLS servers while Python mutations worked — the two paths spoke different TLS modes. curl's `-s` flag silenced all error output, producing "Unknown error" on any curl failure. File creation used `/dev/null` (breaks on Windows, diverges from S3). The Python TLS context didn't honour `--insecure`/`-k` from `extra_curl_args`. Deleting non-empty dirs on servers without MLSD gave a cryptic `500 Unknown command`. Solution: Always emit `ftp://` in `curl_ftp_url`; TLS is enforced solely via `--ssl-reqd`, making STARTTLS consistent between curl and Python. Add `-S` to expose curl errors. Replace `/dev/null` with `curl -T -` + `stdin='null'` (matches `s3fs` pattern). Mirror `--insecure`/`-k` into the Python SSL context. Wrap `mlsd()` in try/except with a clear actionable message. Add `spec/ftp_spec.lua` with 28 unit tests covering URL parsing, list parsing, and curl URL building. Update `doc/oil.txt` to document STARTTLS and MLSD. * ci: format * fix(ftp): resolve LuaLS type warnings in `curl` wrapper and `parse_unix_list_line`
This commit is contained in:
parent
d67195b637
commit
a410507846
6 changed files with 931 additions and 24 deletions
49
doc/oil.txt
49
doc/oil.txt
|
|
@ -173,6 +173,8 @@ The full list of options with their defaults:
|
||||||
extra_scp_args = {},
|
extra_scp_args = {},
|
||||||
-- Extra arguments to pass to aws s3 when creating/deleting/moving/copying files using aws s3
|
-- Extra arguments to pass to aws s3 when creating/deleting/moving/copying files using aws s3
|
||||||
extra_s3_args = {},
|
extra_s3_args = {},
|
||||||
|
-- Extra arguments to pass to curl for FTP operations
|
||||||
|
extra_curl_args = {},
|
||||||
-- 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
|
||||||
|
|
@ -993,6 +995,53 @@ S3 *oil-adapter-s
|
||||||
Older versions of Neovim (0.11 and earlier) don't support numbers in the
|
Older versions of Neovim (0.11 and earlier) don't support numbers in the
|
||||||
URL scheme, so use `oil-sss` instead of `oil-s3`.
|
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 uses explicit TLS (STARTTLS / AUTH TLS, RFC 4217).
|
||||||
|
The server must support the `AUTH TLS` command on port 21. Servers using
|
||||||
|
implicit TLS (port 990) are not supported. 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. Deleting non-empty directories
|
||||||
|
requires MLSD support (RFC 3659); supported by vsftpd, ProFTPD, FileZilla
|
||||||
|
Server, and most servers from 2007 onwards.
|
||||||
|
|
||||||
|
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*
|
Trash *oil-adapter-trash*
|
||||||
|
|
||||||
See |oil-trash| for details on the built-in trash adapter.
|
See |oil-trash| for details on the built-in trash adapter.
|
||||||
|
|
|
||||||
|
|
@ -5,30 +5,30 @@ issues against this fork.
|
||||||
|
|
||||||
## Upstream PRs
|
## Upstream PRs
|
||||||
|
|
||||||
| PR | Description | Status |
|
| PR | Description | Status |
|
||||||
| ----------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
| ----------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| [#495](https://github.com/stevearc/oil.nvim/pull/495) | Cancel visual/operator-pending mode on close | cherry-picked |
|
| [#495](https://github.com/stevearc/oil.nvim/pull/495) | Cancel visual/operator-pending mode on close | cherry-picked |
|
||||||
| [#537](https://github.com/stevearc/oil.nvim/pull/537) | Configurable file/directory creation permissions | cherry-picked |
|
| [#537](https://github.com/stevearc/oil.nvim/pull/537) | Configurable file/directory creation permissions | cherry-picked |
|
||||||
| [#618](https://github.com/stevearc/oil.nvim/pull/618) | Opt-in filetype detection for icons | cherry-picked |
|
| [#618](https://github.com/stevearc/oil.nvim/pull/618) | Opt-in filetype detection for icons | cherry-picked |
|
||||||
| [#644](https://github.com/stevearc/oil.nvim/pull/644) | Pass entry to `is_hidden_file`/`is_always_hidden` | cherry-picked |
|
| [#644](https://github.com/stevearc/oil.nvim/pull/644) | Pass entry to `is_hidden_file`/`is_always_hidden` | cherry-picked |
|
||||||
| [#697](https://github.com/stevearc/oil.nvim/pull/697) | Recipe for file extension column | cherry-picked |
|
| [#697](https://github.com/stevearc/oil.nvim/pull/697) | Recipe for file extension column | cherry-picked |
|
||||||
| [#698](https://github.com/stevearc/oil.nvim/pull/698) | Executable file highlighting | cherry-picked |
|
| [#698](https://github.com/stevearc/oil.nvim/pull/698) | Executable file highlighting | cherry-picked |
|
||||||
| [#717](https://github.com/stevearc/oil.nvim/pull/717) | Add oil-git.nvim to extensions | cherry-picked |
|
| [#717](https://github.com/stevearc/oil.nvim/pull/717) | Add oil-git.nvim to extensions | cherry-picked |
|
||||||
| [#720](https://github.com/stevearc/oil.nvim/pull/720) | Gate `BufAdd` autocmd behind config check | cherry-picked |
|
| [#720](https://github.com/stevearc/oil.nvim/pull/720) | Gate `BufAdd` autocmd behind config check | cherry-picked |
|
||||||
| [#722](https://github.com/stevearc/oil.nvim/pull/722) | Fix freedesktop trash URL | cherry-picked |
|
| [#722](https://github.com/stevearc/oil.nvim/pull/722) | Fix freedesktop trash URL | cherry-picked |
|
||||||
| [#723](https://github.com/stevearc/oil.nvim/pull/723) | Emit `OilReadPost` event after render | cherry-picked |
|
| [#723](https://github.com/stevearc/oil.nvim/pull/723) | Emit `OilReadPost` event after render | cherry-picked |
|
||||||
| [#725](https://github.com/stevearc/oil.nvim/pull/725) | Normalize keymap keys before config merge | cherry-picked |
|
| [#725](https://github.com/stevearc/oil.nvim/pull/725) | Normalize keymap keys before config merge | cherry-picked |
|
||||||
| [#727](https://github.com/stevearc/oil.nvim/pull/727) | Clarify `get_current_dir` nil + Telescope recipe | cherry-picked |
|
| [#727](https://github.com/stevearc/oil.nvim/pull/727) | Clarify `get_current_dir` nil + Telescope recipe | cherry-picked |
|
||||||
| [#739](https://github.com/stevearc/oil.nvim/pull/739) | macOS FreeDesktop trash recipe | cherry-picked |
|
| [#739](https://github.com/stevearc/oil.nvim/pull/739) | macOS FreeDesktop trash recipe | cherry-picked |
|
||||||
| [#488](https://github.com/stevearc/oil.nvim/pull/488) | Parent directory in a split | not actionable — empty PR |
|
| [#488](https://github.com/stevearc/oil.nvim/pull/488) | Parent directory in a split | not actionable — empty PR |
|
||||||
| [#493](https://github.com/stevearc/oil.nvim/pull/493) | UNC paths on Windows | not actionable — superseded by [#686](https://github.com/stevearc/oil.nvim/pull/686) |
|
| [#493](https://github.com/stevearc/oil.nvim/pull/493) | UNC paths on Windows | not actionable — superseded by [#686](https://github.com/stevearc/oil.nvim/pull/686) |
|
||||||
| [#686](https://github.com/stevearc/oil.nvim/pull/686) | Windows path conversion fix | not actionable — Windows-only |
|
| [#686](https://github.com/stevearc/oil.nvim/pull/686) | Windows path conversion fix | not actionable — Windows-only |
|
||||||
| [#735](https://github.com/stevearc/oil.nvim/pull/735) | gX opens external program with selection | not actionable — hardcoded Linux-only, incomplete |
|
| [#735](https://github.com/stevearc/oil.nvim/pull/735) | gX opens external program with selection | not actionable — hardcoded Linux-only, incomplete |
|
||||||
| [#591](https://github.com/stevearc/oil.nvim/pull/591) | release-please changelog | not applicable |
|
| [#591](https://github.com/stevearc/oil.nvim/pull/591) | release-please changelog | not applicable |
|
||||||
| [#667](https://github.com/stevearc/oil.nvim/pull/667) | Virtual text columns + headers | consolidated into [#142](https://github.com/barrettruth/canola.nvim/issues/142) |
|
| [#667](https://github.com/stevearc/oil.nvim/pull/667) | Virtual text columns + headers | consolidated into [#142](https://github.com/barrettruth/canola.nvim/issues/142) |
|
||||||
| [#708](https://github.com/stevearc/oil.nvim/pull/708) | Move file into new dir by renaming | consolidated into [#32](https://github.com/barrettruth/canola.nvim/issues/32) |
|
| [#708](https://github.com/stevearc/oil.nvim/pull/708) | Move file into new dir by renaming | consolidated into [#32](https://github.com/barrettruth/canola.nvim/issues/32) |
|
||||||
| [#721](https://github.com/stevearc/oil.nvim/pull/721) | `create_hook` to populate file contents | not actionable — `OilFileCreated` event already covers the use case (see [#280](https://github.com/stevearc/oil.nvim/issues/280)) |
|
| [#721](https://github.com/stevearc/oil.nvim/pull/721) | `create_hook` to populate file contents | not actionable — `OilFileCreated` event already covers the use case (see [#280](https://github.com/stevearc/oil.nvim/issues/280)) |
|
||||||
| [#728](https://github.com/stevearc/oil.nvim/pull/728) | `open_split` for opening oil in a split | deferred — tracked as [#2](https://github.com/barrettruth/canola.nvim/issues/2) |
|
| [#728](https://github.com/stevearc/oil.nvim/pull/728) | `open_split` for opening oil in a split | deferred — tracked as [#2](https://github.com/barrettruth/canola.nvim/issues/2) |
|
||||||
|
|
||||||
## Issues
|
## Issues
|
||||||
|
|
||||||
|
|
@ -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` |
|
| [#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 |
|
| [#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 |
|
| [#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 ([#167](https://github.com/barrettruth/canola.nvim/pull/167)) |
|
||||||
| [#213](https://github.com/stevearc/oil.nvim/issues/213) | Disable preview for large files | fixed ([#85](https://github.com/barrettruth/canola.nvim/pull/85)) |
|
| [#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 |
|
| [#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) |
|
| [#232](https://github.com/stevearc/oil.nvim/issues/232) | Cannot close last window | consolidated into [#149](https://github.com/barrettruth/canola.nvim/issues/149) |
|
||||||
|
|
|
||||||
685
lua/oil/adapters/ftp.lua
Normal file
685
lua/oil/adapters/ftp.lua
Normal file
|
|
@ -0,0 +1,685 @@
|
||||||
|
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 s string
|
||||||
|
---@return string
|
||||||
|
local function url_encode_path(s)
|
||||||
|
return (
|
||||||
|
s:gsub('[^A-Za-z0-9%-._~:/]', function(c)
|
||||||
|
return string.format('%%%02X', c:byte())
|
||||||
|
end)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param url oil.ftpUrl
|
||||||
|
---@return string
|
||||||
|
local function curl_ftp_url(url)
|
||||||
|
local pieces = { 'ftp://' }
|
||||||
|
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_encode_path(url.path))
|
||||||
|
return table.concat(pieces, '')
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param url oil.ftpUrl
|
||||||
|
---@param py_lines string[]
|
||||||
|
---@param cb fun(err: nil|string)
|
||||||
|
local function ftpcmd(url, py_lines, cb)
|
||||||
|
local lines = {}
|
||||||
|
local use_tls = url.scheme == 'oil-ftps://'
|
||||||
|
if use_tls then
|
||||||
|
local insecure = vim.tbl_contains(config.extra_curl_args, '--insecure')
|
||||||
|
or vim.tbl_contains(config.extra_curl_args, '-k')
|
||||||
|
table.insert(lines, 'import ftplib, ssl')
|
||||||
|
table.insert(lines, 'ctx = ssl.create_default_context()')
|
||||||
|
if insecure then
|
||||||
|
table.insert(lines, 'ctx.check_hostname = False')
|
||||||
|
table.insert(lines, 'ctx.verify_mode = ssl.CERT_NONE')
|
||||||
|
end
|
||||||
|
table.insert(lines, 'ftp = ftplib.FTP_TLS(context=ctx)')
|
||||||
|
else
|
||||||
|
table.insert(lines, 'import ftplib')
|
||||||
|
table.insert(lines, 'ftp = ftplib.FTP()')
|
||||||
|
end
|
||||||
|
table.insert(lines, string.format('ftp.connect(%q, %d)', url.host, url.port or 21))
|
||||||
|
if use_tls then
|
||||||
|
table.insert(lines, 'ftp.auth()')
|
||||||
|
end
|
||||||
|
local user = url.user or 'anonymous'
|
||||||
|
local password = url.password or ''
|
||||||
|
table.insert(lines, string.format('ftp.login(%q, %q)', user, password))
|
||||||
|
if use_tls then
|
||||||
|
table.insert(lines, 'ftp.prot_p()')
|
||||||
|
end
|
||||||
|
for _, line in ipairs(py_lines) do
|
||||||
|
table.insert(lines, line)
|
||||||
|
end
|
||||||
|
table.insert(lines, 'ftp.quit()')
|
||||||
|
local script = table.concat(lines, '\n')
|
||||||
|
shell.run({ 'python3', '-c', script }, function(err)
|
||||||
|
if err then
|
||||||
|
cb(err:match('ftplib%.[^:]+: (.+)$') or err:match('[^\n]+$') or err)
|
||||||
|
else
|
||||||
|
cb(nil)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
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 opts table|fun(err: nil|string, output: nil|string[])
|
||||||
|
---@param cb? fun(err: nil|string, output: nil|string[])
|
||||||
|
local function curl(url, extra_args, opts, cb)
|
||||||
|
if not cb then
|
||||||
|
cb = opts --[[@as fun(err: nil|string, output: nil|string[])]]
|
||||||
|
opts = {}
|
||||||
|
end
|
||||||
|
local cmd = { 'curl', '-sS', '--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, opts, 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) or 0,
|
||||||
|
hour = tonumber(hour) or 0,
|
||||||
|
min = tonumber(min) or 0,
|
||||||
|
sec = 0,
|
||||||
|
})
|
||||||
|
if mtime > now + 86400 then
|
||||||
|
mtime = os.time({
|
||||||
|
year = t.year - 1,
|
||||||
|
month = mon,
|
||||||
|
day = tonumber(day) or 0,
|
||||||
|
hour = tonumber(hour) or 0,
|
||||||
|
min = tonumber(min) or 0,
|
||||||
|
sec = 0,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local year = tonumber(timeoryear)
|
||||||
|
if year then
|
||||||
|
mtime = os.time({
|
||||||
|
year = year,
|
||||||
|
month = mon,
|
||||||
|
day = tonumber(day) or 0,
|
||||||
|
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 == '<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)
|
||||||
|
ftpcmd(
|
||||||
|
res,
|
||||||
|
{ string.format('ftp.voidcmd(%q)', 'SITE CHMOD ' .. octal .. ' ' .. ftp_path) },
|
||||||
|
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, { nil, 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
|
||||||
|
ftpcmd(res, { string.format('ftp.voidcmd(%q)', 'MKD ' .. ftp_path) }, cb)
|
||||||
|
elseif action.entry_type == 'link' then
|
||||||
|
cb('FTP does not support symbolic links')
|
||||||
|
else
|
||||||
|
curl(res, { '-T', '-', curl_ftp_url(res) }, { stdin = 'null' }, 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
|
||||||
|
ftpcmd(res, {
|
||||||
|
'def rmtree(f, p):',
|
||||||
|
' try:',
|
||||||
|
' entries = list(f.mlsd(p))',
|
||||||
|
' except ftplib.error_perm as e:',
|
||||||
|
' if "500" in str(e) or "502" in str(e): import sys; sys.exit("Server does not support MLSD; cannot recursively delete non-empty directories")',
|
||||||
|
' raise',
|
||||||
|
' for name, facts in entries:',
|
||||||
|
' if name in (".", ".."): continue',
|
||||||
|
' child = p.rstrip("/") + "/" + name',
|
||||||
|
' if facts["type"] == "dir": rmtree(f, child)',
|
||||||
|
' else: f.voidcmd("DELE " + child)',
|
||||||
|
' f.voidcmd("RMD " + p)',
|
||||||
|
string.format('rmtree(ftp, %q)', ftp_path),
|
||||||
|
}, cb)
|
||||||
|
else
|
||||||
|
ftpcmd(res, { string.format('ftp.voidcmd(%q)', 'DELE ' .. ftp_path) }, 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
|
||||||
|
ftpcmd(src_res, {
|
||||||
|
string.format('ftp.rename(%q, %q)', ftp_abs_path(src_res), ftp_abs_path(dest_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
|
||||||
|
ftpcmd(
|
||||||
|
src_res,
|
||||||
|
{ string.format('ftp.voidcmd(%q)', 'DELE ' .. ftp_abs_path(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
|
||||||
|
|
||||||
|
M._parse_unix_list_line = parse_unix_list_line
|
||||||
|
M._parse_iis_list_line = parse_iis_list_line
|
||||||
|
M._url_encode_path = url_encode_path
|
||||||
|
M._curl_ftp_url = curl_ftp_url
|
||||||
|
|
||||||
|
return M
|
||||||
1
lua/oil/adapters/ftps.lua
Normal file
1
lua/oil/adapters/ftps.lua
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
return require('oil.adapters.ftp')
|
||||||
|
|
@ -116,6 +116,8 @@ local default_config = {
|
||||||
extra_scp_args = {},
|
extra_scp_args = {},
|
||||||
-- Extra arguments to pass to aws s3 when creating/deleting/moving/copying files using aws s3
|
-- Extra arguments to pass to aws s3 when creating/deleting/moving/copying files using aws s3
|
||||||
extra_s3_args = {},
|
extra_s3_args = {},
|
||||||
|
-- Extra arguments to pass to curl for FTP operations
|
||||||
|
extra_curl_args = {},
|
||||||
-- 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
|
||||||
|
|
@ -223,6 +225,8 @@ default_config.adapters = {
|
||||||
['oil-ssh://'] = 'ssh',
|
['oil-ssh://'] = 'ssh',
|
||||||
[oil_s3_string] = 's3',
|
[oil_s3_string] = 's3',
|
||||||
['oil-trash://'] = 'trash',
|
['oil-trash://'] = 'trash',
|
||||||
|
['oil-ftp://'] = 'ftp',
|
||||||
|
['oil-ftps://'] = 'ftps',
|
||||||
}
|
}
|
||||||
default_config.adapter_aliases = {}
|
default_config.adapter_aliases = {}
|
||||||
-- We want the function in the default config for documentation generation, but if we nil it out
|
-- 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 new_dir_mode integer
|
||||||
---@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 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
|
||||||
|
|
@ -288,6 +293,7 @@ local M = {}
|
||||||
---@field new_dir_mode? integer Permission mode for new directories in decimal (default 493 = 0755)
|
---@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_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 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
|
||||||
|
|
|
||||||
166
spec/ftp_spec.lua
Normal file
166
spec/ftp_spec.lua
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
local ftp = require('oil.adapters.ftp')
|
||||||
|
|
||||||
|
describe('ftp adapter', function()
|
||||||
|
describe('parse_url', function()
|
||||||
|
it('parses minimal url', function()
|
||||||
|
local res = ftp.parse_url('oil-ftp://host/')
|
||||||
|
assert.equals('oil-ftp://', res.scheme)
|
||||||
|
assert.equals('host', res.host)
|
||||||
|
assert.equals('', res.path)
|
||||||
|
assert.equals(nil, res.user)
|
||||||
|
assert.equals(nil, res.password)
|
||||||
|
assert.equals(nil, res.port)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses url with user, password, port, and path', function()
|
||||||
|
local res = ftp.parse_url('oil-ftp://user:pass@host:2121/some/path/')
|
||||||
|
assert.equals('oil-ftp://', res.scheme)
|
||||||
|
assert.equals('host', res.host)
|
||||||
|
assert.equals('user', res.user)
|
||||||
|
assert.equals('pass', res.password)
|
||||||
|
assert.equals(2121, res.port)
|
||||||
|
assert.equals('some/path/', res.path)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses url with user only', function()
|
||||||
|
local res = ftp.parse_url('oil-ftp://user@host/path/')
|
||||||
|
assert.equals('user', res.user)
|
||||||
|
assert.equals(nil, res.password)
|
||||||
|
assert.equals(nil, res.port)
|
||||||
|
assert.equals('path/', res.path)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses oil-ftps:// scheme', function()
|
||||||
|
local res = ftp.parse_url('oil-ftps://host/path/')
|
||||||
|
assert.equals('oil-ftps://', res.scheme)
|
||||||
|
assert.equals('host', res.host)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses port without credentials', function()
|
||||||
|
local res = ftp.parse_url('oil-ftp://host:990/')
|
||||||
|
assert.equals('host', res.host)
|
||||||
|
assert.equals(990, res.port)
|
||||||
|
assert.equals(nil, res.user)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('get_parent', function()
|
||||||
|
it('goes up one directory', function()
|
||||||
|
assert.equals('oil-ftp://host/', ftp.get_parent('oil-ftp://host/subdir/'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('goes up nested directories', function()
|
||||||
|
assert.equals(
|
||||||
|
'oil-ftp://user:pass@host:2121/a/b/',
|
||||||
|
ftp.get_parent('oil-ftp://user:pass@host:2121/a/b/c/')
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('stays at root', function()
|
||||||
|
assert.equals('oil-ftp://host/', ftp.get_parent('oil-ftp://host/'))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('unix list line parsing', function()
|
||||||
|
it('parses a regular file', function()
|
||||||
|
local name, entry_type, meta =
|
||||||
|
ftp._parse_unix_list_line('-rw-r--r-- 1 user group 42 Jan 15 2024 hello.txt')
|
||||||
|
assert.equals('hello.txt', name)
|
||||||
|
assert.equals('file', entry_type)
|
||||||
|
assert.equals(42, meta.size)
|
||||||
|
assert.equals('number', type(meta.mtime))
|
||||||
|
assert.equals(420, meta.mode)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses a directory', function()
|
||||||
|
local name, entry_type, meta =
|
||||||
|
ftp._parse_unix_list_line('drwxr-xr-x 2 user group 4096 Mar 18 10:30 subdir')
|
||||||
|
assert.equals('subdir', name)
|
||||||
|
assert.equals('directory', entry_type)
|
||||||
|
assert.equals(4096, meta.size)
|
||||||
|
assert.equals('number', type(meta.mtime))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses a symlink', function()
|
||||||
|
local name, entry_type, meta =
|
||||||
|
ftp._parse_unix_list_line('lrwxrwxrwx 1 user group 8 Mar 18 10:30 link -> target')
|
||||||
|
assert.equals('link', name)
|
||||||
|
assert.equals('link', entry_type)
|
||||||
|
assert.equals('target', meta.link)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses a filename with spaces', function()
|
||||||
|
local name, entry_type, _ =
|
||||||
|
ftp._parse_unix_list_line('-rw-r--r-- 1 user group 100 Mar 15 09:00 my file.txt')
|
||||||
|
assert.equals('my file.txt', name)
|
||||||
|
assert.equals('file', entry_type)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns nil for unrecognised lines', function()
|
||||||
|
local name = ftp._parse_unix_list_line('total 42')
|
||||||
|
assert.equals(nil, name)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('iis list line parsing', function()
|
||||||
|
it('parses a directory', function()
|
||||||
|
local name, entry_type, _ =
|
||||||
|
ftp._parse_iis_list_line('01-14-24 09:27AM <DIR> dirname')
|
||||||
|
assert.equals('dirname', name)
|
||||||
|
assert.equals('directory', entry_type)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('parses a file', function()
|
||||||
|
local name, entry_type, meta =
|
||||||
|
ftp._parse_iis_list_line('01-14-24 09:27AM 12345 file.txt')
|
||||||
|
assert.equals('file.txt', name)
|
||||||
|
assert.equals('file', entry_type)
|
||||||
|
assert.equals(12345, meta.size)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns nil for unrecognised lines', function()
|
||||||
|
local name = ftp._parse_iis_list_line('drwxr-xr-x 2 user group 4096 Mar 18 10:30 subdir')
|
||||||
|
assert.equals(nil, name)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('curl_ftp_url', function()
|
||||||
|
it('always uses ftp:// scheme for oil-ftp://', function()
|
||||||
|
local url = ftp.parse_url('oil-ftp://host/path/')
|
||||||
|
assert(vim.startswith(ftp._curl_ftp_url(url), 'ftp://'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('always uses ftp:// scheme for oil-ftps://', function()
|
||||||
|
local url = ftp.parse_url('oil-ftps://host/path/')
|
||||||
|
assert(vim.startswith(ftp._curl_ftp_url(url), 'ftp://'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('encodes spaces in path', function()
|
||||||
|
local url = ftp.parse_url('oil-ftp://host/my path/')
|
||||||
|
assert.equals('ftp://host/my%20path/', ftp._curl_ftp_url(url))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('includes credentials and port', function()
|
||||||
|
local url = ftp.parse_url('oil-ftp://user:pass@host:2121/dir/')
|
||||||
|
assert.equals('ftp://user:pass@host:2121/dir/', ftp._curl_ftp_url(url))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('url_encode_path', function()
|
||||||
|
it('leaves safe characters unchanged', function()
|
||||||
|
assert.equals('hello.txt', ftp._url_encode_path('hello.txt'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('encodes spaces', function()
|
||||||
|
assert.equals('a%20file.txt', ftp._url_encode_path('a file.txt'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('preserves path separators', function()
|
||||||
|
assert.equals('dir/subdir/file.txt', ftp._url_encode_path('dir/subdir/file.txt'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('encodes special characters', function()
|
||||||
|
assert.equals('hello%23world', ftp._url_encode_path('hello#world'))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue