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.
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`.
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.
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.
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.
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.
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.
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
Problem: `mutation_in_progress` and `buffers_locked` are module-level
locals in `mutator/init.lua` and `view.lua`. When a trash test times
out mid-mutation, these flags stay true and `reset_editor()` never
clears them. Subsequent tests' `save()` calls bail on
`mutation_in_progress`, silently skipping mutations so
`OilMutationComplete` never fires — causing cascading 10s timeouts.
Solution: Add `mutator.reset()` to clear leaked mutation state, and
call it from `reset_editor()` when `is_mutating()` is true. A 50ms
event loop drain lets in-flight libuv callbacks settle before the
reset. Baseline: 4/10 sequential passes. After fix: 49/50 parallel
passes with zero timing overhead on the happy path (~2.4s).
* docs(upstream): mark #675 as duplicate of #117 (#124)
* docs(upstream): mark #617 fixed via cherry-picked #618
Problem: Issue #617 (filetype-based icon detection) was still listed as
`open` in the upstream tracker despite being addressed by PR #618.
Solution: Update status to `fixed — cherry-picked (#618)`. Verified
with manual testing that `use_slow_filetype_detection` correctly detects
shebangs in extensionless files.
* docs(upstream): mark #675 as duplicate of #117
Problem: Issue #675 (move file into folder by renaming) was listed as
`open` despite being a duplicate of #117, the primary tracking issue
for move-by-rename (46 upvotes). Upstream already closed#675 as such.
Solution: Update status to `duplicate of #117`.
* fix(float): support `close = false` for floating oil windows
Problem: `select` with `close = false` was broken for floating oil
windows. The float auto-close autocmd would always close the window
when focus left, and there was no mechanism to preserve it.
Solution: Add `oil_keep_open` window flag set when `close = false` is
used on a float. The auto-close autocmd checks this flag before closing.
On file select, focus returns to the original window behind the float
so the file opens there, then focus restores to the float.
* docs(upstream): mark stevearc/oil.nvim#399 as fixed (#159)
Problem: the permissions column rendered as a monolithic unstyled
string, making it hard to scan `rwx` bits at a glance.
Solution: add per-character highlight groups for permission characters
following the `eza`/`lsd` convention. All groups link to standard
Neovim highlights so every colorscheme works out of the box.
Problem: in insert mode, `<BS>`, `<C-h>`, `<C-w>`, and `<C-u>` could
delete backwards past the name column boundary into the icon/permissions/ID
prefix, corrupting the line and breaking the parser.
Solution: add buffer-local insert-mode expr keymaps that compute the name
column boundary (cached per line) and return no-op when the cursor is
already at or before it. `<C-u>` emits exactly enough `<BS>` keys to
delete back to the boundary, never past it.
Closes#133
* refactor: revert module namespace from canola back to oil
Problem: the canola rename creates unnecessary friction for users
migrating from stevearc/oil.nvim — every `require('oil')` call and
config reference must change.
Solution: revert all module paths, URL schemes, autocmd groups,
highlight groups, and filetype names back to `oil`. The repo stays
`canola.nvim` for identity; the code is a drop-in replacement.
* refactor: remove `vim.g.oil` declarative config
Problem: the `vim.g.oil` configuration path was added prematurely.
It adds a second config entrypoint before the plugin has stabilized
enough to justify it.
Solution: remove `vim.g.oil` support from `plugin/oil.lua`,
`config.setup()`, docs, and tests. Users configure via
`require("oil").setup({})`.
Problem: `get_current_dir()` returns nil for non-local adapters (SSH,
S3, trash), leaving users with no public API to get the current
buffer's location.
Solution: add `get_current_url()` which returns the full canola URL
for any adapter, or nil if not in a canola buffer.
Based on: stevearc/oil.nvim#646
Problem: at the filesystem root (`/`), `actions.parent` triggers a
full `vim.cmd.edit()` and async re-render cycle even though the parent
of `/` is `/`.
Solution: in `canola.open()`, return early when `parent_url` equals
the current buffer name.
Closes#108.
* fix(select): redraw screen after buffer switch
Problem: `select` opens files inside a `vim.schedule_wrap` callback
from `normalize_url`. Scheduled `FileType` autocmds (e.g. treesitter
parsing) queue onto the same batch, blocking the screen update. The
oil buffer stays visible until the heavy work finishes.
Solution: call `vim.cmd.redraw()` after the buffer switch to flush
the screen before any queued scheduled callbacks run. Matches the
behavior of plain `:e`.
* docs(upstream): mark #699 fixed (#106)
Problem: `constrain_cursor` only fired on `CursorMoved` and
`ModeChanged`, so arrow key navigation in insert mode could move
the cursor into the concealed ID prefix area.
Solution: add `CursorMovedI` to the autocmd event list. The
`constrain_cursor()` function is already mode-agnostic.
* feat: add `toggle()` API for regular windows
Problem: `toggle_float()` and `toggle_split()` exist but there is no
`toggle()` for regular windows, forcing users to write their own
filetype-checking wrapper.
Solution: add `M.toggle()` that delegates to `close()` or `open()`
based on whether the current buffer is a canola buffer. Includes
vimdoc entry.
* docs(upstream): mark #621 fixed
* fix(columns): hide misleading directory sizes in size column
Problem: the size column shows the filesystem inode size (typically
4096 = 4.1k) for directories, which is misleading — users expect no
size for directories.
Solution: add an early return for directory entries in the size render
function of the files, SSH, and S3 adapters.
* docs(upstream): mark #486 fixed
* feat(preview): add `max_file_size` config to skip large file previews
Problem: previewing large files (e.g. 500 MB logs, binaries) loads them
into a buffer and can freeze or OOM Neovim. `disable_preview` only
receives the filename, so users cannot gate on file size.
Solution: add `preview_win.max_file_size` (number, MB, default 10). In
`open_preview`, check `entry.meta.stat.size` and fall back to
`vim.uv.fs_stat` when the cached stat is absent. If the file exceeds
the limit and a preview window is already open, render "File too large
to preview" in it; if not, emit a WARN notify and return early. The
cursor-moved auto-update path only fires when a window already exists,
so no flag threading is needed to distinguish explicit from implicit.
Based on: stevearc/oil.nvim#213
* feat(view): add `show_hidden_when_empty` for hidden-only directories
Problem: with `show_hidden = false`, a directory containing only
dotfiles renders as just `..`, giving no indication that entries exist.
Solution: add `view_options.show_hidden_when_empty` (boolean, default
false). After the main filter loop in `render_buffer`, if the option is
set and `#line_table <= 1`, iterate `entry_list` again and render any
entry not matched by `is_always_hidden`, using `is_hidden = true` so
they render with the dimmed hidden style.
Based on: stevearc/oil.nvim#473
* docs(upstream): fix formatting
* docs(upstream): update #213 and #473 with PR and commit links
Problem: users who want hands-off behaviour had no way to skip the
`prompt_save_on_select_new_entry` confirmation dialog — enabling the
prompt meant always being asked, with no silent auto-save path.
Solution: add `auto_save_on_select_new_entry` (default `false`) which,
when true, calls `M.save()` and proceeds immediately instead of showing
the confirm dialog. Includes type annotations, vimdoc, and upstream
tracker update for stevearc/oil.nvim#393.
* docs(upstream): triage batch — #739 cherry-pick, 10 issue updates
* feat: add `open_split` and `toggle_split` API
Problem: canola had no way to open a browser in a normal split window;
only floating windows were supported via `open_float`/`toggle_float`.
`M.close` also crashed with E444 when called from the last window.
Solution: port stevearc/oil.nvim#728 — add `open_split(dir, opts, cb)`
and `toggle_split(dir, opts, cb)` mirroring the float API. Use
`is_canola_win`/`canola_original_win` window vars (not the upstream
`is_oil_win` names). Wrap `nvim_win_close` in `pcall` with `enew()`
fallback to handle the last-window E444 case.
Based on: stevearc/oil.nvim#728
* docs: add vimdoc for `open_split`/`toggle_split` and macOS trash recipe
Cherry-picked from: stevearc/oil.nvim#739
* docs(upstream): fix prettier formatting
* fix: show float title when border is nil
Problem: the float title was only shown via the native `nvim_win_set_config`
path, which requires a border to render. The guard `config.float.border ~=
'none'` did not account for `nil`, which is the default — so users with no
explicit `border` config never saw the path title in the floating window.
Solution: require both `~= nil` and `~= 'none'` before using the native
title. In all other cases (border nil, 'none', or nvim < 0.9), fall back to
`util.add_title_to_win`, which renders a child floating window for the title.
Based on: stevearc/oil.nvim#683
* refactor: drop nvim-0.9 version checks in float title logic
feat: add \`skip_confirm_for_delete\` option
Problem: there was no way to suppress the confirmation popup when the
only pending operations are deletes. \`skip_confirm_for_simple_edits\`
explicitly excludes deletes, so users who delete frequently had no opt-out.
Solution: add \`skip_confirm_for_delete = false\` config option. When true,
\`confirmation.show()\` skips the popup if every pending action is a delete.
Based on: stevearc/oil.nvim#392
Problem: when `prompt_save_on_select_new_entry` is enabled and the user
presses Escape on the "Save changes?" confirm dialog, `vim.fn.confirm`
returns 0, but the select continued as if the user had chosen "No".
Solution: add an explicit `choice == 0` branch that returns immediately,
aborting the select without saving or opening any files.
* feat: emit \`CanolaFileCreated\` autocmd on file creation
Problem: no way to hook into individual file creation to populate
initial contents, without a plugin-specific config callback.
Solution: fire \`User CanolaFileCreated\` with \`data.path\` after each
successful \`fs.touch\` in the files adapter. Users listen with
\`nvim_create_autocmd\` and write to the path however they like.
* build: gitignore `doc/upstream.html`
* docs(upstream): mark #721 fixed, triage #735
* docs(upstream): simplify #735 note
Problem: When files are deleted via canola, any open Neovim buffers
for those files remain alive, polluting the jumplist with stale
entries.
Solution: Add an opt-in `cleanup_buffers_on_delete` config option
(default `false`). When enabled, `finish()` in `mutator/init.lua`
iterates completed delete actions and wipes matching buffers via
`nvim_buf_delete` before `CanolaActionsPost` fires. Only local
filesystem deletes are handled (guarded by the `files` adapter
check).
Problem: pressing `o`/`O` in a canola buffer placed the cursor at
column 0, requiring manual navigation past concealed ID prefixes and
column text (icons, permissions) to reach the name column.
Solution: add `show_insert_guide()` which temporarily sets
`virtualedit=all` on empty lines and positions the cursor at the
name column. Computes the correct virtual column by measuring the
visible column prefix width via `nvim_strwidth`, adjusting for
`conceallevel` (0=full ID width, 1=replacement char, 2/3=hidden).
Restores `virtualedit` on `TextChangedI` or `InsertLeave`.
* fix: restore `buflisted` on jumplist buffer re-entry
Problem: Neovim's jumplist machinery re-enters canola buffers via an
internal `:edit`-equivalent path, which unconditionally sets
`buflisted = true`. The existing workaround in `open()` and
`open_float()` only covers canola-initiated navigation, leaving
`<C-o>` and `<C-i>` unhandled.
Solution: Apply the same `buf_options.buflisted` guard in the
`BufEnter` autocmd, directly after `set_win_options()`. This fires
on every buffer entry — including all jumplist paths — and mirrors
the pattern already used at the two `:edit` callsites.
* docs: mark upstream #302 as fixed in tracker
Problem: the codebase still used the upstream \`oil\` naming everywhere —
URL schemes, the \`:Oil\` command, highlight groups, user events, module
paths, filetypes, buffer/window variables, LuaCATS type annotations,
vimdoc help tags, syntax groups, and internal identifiers.
Solution: mechanical rename of every reference. URL schemes now use
\`canola://\` (plus \`canola-ssh://\`, \`canola-s3://\`, \`canola-sss://\`,
\`canola-trash://\`, \`canola-test://\`). The \`:Canola\` command replaces
\`:Oil\`. All highlight groups, user events, augroups, namespaces,
filetypes, require paths, type annotations, help tags, and identifiers
follow suit. The \`upstream\` remote to \`stevearc/oil.nvim\` has been
removed and the \`vim.g.oil\` deprecation shim dropped.
Problem: when the nonicons direct API was detected, all icons were
returned with the generic 'OilFileIcon' highlight group, losing
per-filetype colors from nvim-web-devicons.
Solution: resolve highlight groups from devicons when available so
nonicons glyphs retain their per-filetype colors.
Problem: nonicons.nvim exposes a public get_icon/get_icon_by_filetype
API, but oil.nvim can only use nonicons glyphs indirectly through the
devicons monkey-patch. This couples oil to devicons even when nonicons
is available standalone.
Solution: add a nonicons provider in get_icon_provider() between
mini.icons and devicons. Feature-gated on nonicons.get_icon existing
so old nonicons versions fall through to devicons. Uses OilDirIcon
and OilFileIcon highlight groups.
* feat(icons): add nonicons.nvim icon provider support
Problem: oil.nvim only recognizes mini.icons and nvim-web-devicons as
icon providers. nonicons.nvim works when paired with devicons (via its
apply() monkey-patch), but has no standalone support.
Solution: add a nonicons.nvim fallback in get_icon_provider(), placed
after devicons so the patched devicons path is preferred when both are
installed. The standalone path handles directories via
nonicons.get('file-directory'), files via filetype/extension lookup with
a generic file icon fallback.
* fix(doc): improve readme phrasing
* build: replace luacheck with selene
Problem: luacheck is unmaintained (last release 2018) and required
suppressing four warning classes to avoid false positives. It also
lacks first-class vim/neovim awareness.
Solution: switch to selene with std='vim' for vim-aware linting.
Replace the luacheck CI job with selene, update the Makefile lint
target, and delete .luacheckrc.
* build: add nix devshell and pre-commit hooks
Problem: oil.nvim had no reproducible dev environment. The .envrc
set up a Python venv for the now-removed docgen pipeline, and there
were no pre-commit hooks for local formatting checks.
Solution: add flake.nix with stylua, selene, and prettier in the
devshell. Replace the stale Python .envrc with 'use flake'. Add
.pre-commit-config.yaml with stylua and prettier hooks matching
other plugins in the repo collection.
* fix: format with stylua
* build(selene): configure lints and add inline suppressions
Problem: selene fails on 5 errors and 3 warnings from upstream code
patterns that are intentional (mixed tables in config API, unused
callback parameters, identical if branches for readability).
Solution: globally allow mixed_table and unused_variable (high volume,
inherent to the codebase design). Add inline selene:allow directives
for the 8 remaining issues: if_same_then_else (4), mismatched_arg_count
(1), empty_if (2), global_usage (1). Remove .envrc from tracking.
* build: switch typecheck action to mrcjkb/lua-typecheck-action
Problem: oil.nvim used stevearc/nvim-typecheck-action, which required
cloning the action repo locally for the Makefile lint target. All
other plugins in the collection use mrcjkb/lua-typecheck-action.
Solution: swap to mrcjkb/lua-typecheck-action@v0 for consistency.
Remove the nvim-typecheck-action git clone from the Makefile and
.gitignore. Drop LuaLS from the local lint target since it requires
a full language server install — CI handles it.
* feat: support vim.g.oil declarative configuration
Problem: oil.nvim requires an imperative require("oil").setup(opts)
call to initialize. The Neovim ecosystem is moving toward vim.g.plugin
as a declarative config source that works without explicit setup calls.
Solution: fall back to vim.g.oil in config.setup() when no opts are
passed, and add plugin/oil.lua to auto-initialize when vim.g.oil is
set. Explicit setup(opts) calls still take precedence. Update docs
and add tests for the new resolution order.
Closes: barrettruth/oil.nvim#1
* build: remove release-please pipeline
Problem: the release-please action creates automated releases that
are not needed for this fork's workflow.
Solution: remove the release job from tests.yml and the
release-please branch exclusion from the review request workflow.
* fix(doc): improve readme phrasing
* doc: minor phrasing "improvements"
* ci: add luarocks release on tag push
Problem: there is no automated way to publish oil.nvim to luarocks
when a new version is tagged.
Solution: add a luarocks workflow that triggers on v* tag pushes,
runs the test suite via workflow_call, then publishes via
luarocks-tag-release. Add workflow_call trigger to tests.yml so it
can be reused.
Problem: when the preview window opens a directory that already has a
loaded oil buffer with unsaved edits, open_preview() unconditionally
calls load_oil_buffer() on it. This re-initializes the buffer via
view.initialize() -> render_buffer_async(), which re-fetches the
directory listing from disk and replaces all buffer lines, destroying
the user's pending edits. The mutation parser then can't see the
deleted entry in the source buffer, so it produces a COPY action
instead of a MOVE.
Solution: guard the load_oil_buffer() call in open_preview() with a
check for vim.b[filebufnr].oil_ready. Buffers that are already
initialized and rendered are not re-loaded, preserving any unsaved
modifications the user has made.
Closes: stevearc/oil.nvim#632
Problem: when neovim is opened with multiple directory arguments
(e.g. nvim dir1/ dir2/), only the first directory gets handled by oil.
The BufAdd autocmd that renames directory buffers to oil:// URLs is
registered inside setup(), but neovim creates all argument buffers
before setup() runs. The initial hijack block only processes
nvim_get_current_buf(), so additional directory buffers are never
renamed and remain as plain empty buffers.
Solution: iterate all existing buffers at setup time instead of only
the current one. Each directory buffer gets renamed to an oil:// URL
so that BufReadCmd fires when the user switches to it.
Closes: stevearc/oil.nvim#670
Problem: oil sets buftype='acwrite' inside view.initialize(), which runs
in an async finish() callback after adapter.normalize_url(). BufEnter
fires before finish() completes, so user autocmds that check buftype on
oil buffers see an empty string instead of 'acwrite'.
Solution: set buftype='acwrite' early in load_oil_buffer() alongside the
existing early filetype='oil' assignment, before the async gap. The
redundant set in view.initialize() is harmless (idempotent).
Closes: stevearc/oil.nvim#710
Problem: the ---@cast opts -nil was placed after the first opts guard
but LuaLS loses narrowing at the second tbl_deep_extend on line 928,
causing a persistent need-check-nil warning at opts.refetch.
Solution: remove the redundant first opts = opts or {} guard (the
tbl_deep_extend already handles nil) and place the cast after the
second tbl_deep_extend where opts is actually used.
Problem: LuaLS still warns at opts.refetch access because it does not
track narrowing through opts = opts or {}.
Solution: add ---@cast opts -nil after the guard, matching the same
pattern used in util.render_text.
Problem: CI typecheck fails with 13 warnings — 11 need-check-nil in
util.render_text (opts param annotated nil|table but guaranteed non-nil
after tbl_deep_extend), 1 undefined-doc-param in view.render_buffer_async
(annotation says "callback" but param is "caller_callback"), and 1
need-check-nil on opts in the same function.
Solution: add ---@cast opts -nil after the tbl_deep_extend call, fix
the param name in the doc annotation, and add opts = opts or {} guard.