canola-collection: optional adapter backends as a separate plugin #31

Closed
opened 2026-09-21 18:54:20 +00:00 by barrettruth · 0 comments
Owner

Original issue: barrettruth/canola.nvim#181
Original author: barrettruth
Original date: 2026-03-18T20:21:51Z

Introduce barrettruth/canola-collection: a monorepo of optional adapters and extensions that live outside canola core.

Motivation

Canola core currently bundles adapters for local files, SSH, S3, FTP/FTPS, and trash — plus a resession extension and git operation hooks. Most users only ever need the local filesystem adapter. Bundling everything bloats the install, loads code paths that never run, and makes core harder to reason about. The goal is a lean core with a rich, opt-in ecosystem.

What stays in core

  • files adapter (oil://) — local filesystem via libuv. This is canola's raison d'être.
  • test adapter (oil-test://) — in-memory mock filesystem used exclusively by the spec suite. Never shipped as a user-facing feature.
  • git.lua — git operation hooks (git add/mv/rm on mutation). These fire synchronously inside the files adapter's perform_action and are tightly coupled to local filesystem mutations. Removing them would require a new hook API. The config.git.{add,mv,rm} predicate functions remain as-is; they are not the same thing as git status display.

Git status display (decorating buffer entries with their git status) is explicitly not in core. That belongs in canola-git in the collection.

Adapter registration API

Core must expose a way for external adapters to register themselves. The current dispatch path is:

  1. config.adapters maps URL scheme strings to adapter module name strings (e.g. ['oil-ssh://'] = 'ssh').
  2. config.get_adapter_by_scheme(scheme) lazily requires oil.adapters.<name> on first access.

That path only works for adapters bundled inside core. External adapters need a public registration call:

require('canola').register_adapter(scheme, adapter)

Where scheme is the full URL prefix (e.g. 'canola-ssh://') and adapter is a table satisfying the oil.Adapter interface. Calling this writes directly into config._adapter_by_scheme so the scheme resolves without going through the oil.adapters.<name> require path.

The oil.Adapter interface

Every adapter must implement:

Field Type Required Description
name string set by core Unique adapter name; core sets this automatically after registration
list fun(path, column_defs, cb) yes Async. List a directory. Calls cb(err?, entries?, fetch_more?)
is_modifiable fun(bufnr): boolean yes Return true if the directory buffer may be edited
get_column fun(name): oil.ColumnDefinition? yes Return adapter-specific column definitions by name, or nil
normalize_url fun(url, cb) yes Normalize/resolve a URL before opening. Used for symlink following and path canonicalization
get_parent fun(bufname): string no Return the parent URL
get_entry_path fun(url, entry, cb) no Resolve the OS path for a selected entry (used for opening files)
render_action fun(action): string no Render a mutation action for the confirmation UI
perform_action fun(action, cb) no Execute a mutation action. Required for modifiable adapters
read_file fun(bufnr) no Read remote/virtual file contents into a buffer
write_file fun(bufnr) no Write buffer contents back to the remote/virtual destination
supported_cross_adapter_actions table<string, oil.CrossAdapterAction> no Declares which other adapters this one can move/copy to or from. Values are "copy" or "move"
filter_action fun(action): boolean no Filter out actions before execution
filter_error fun(action): boolean no Filter out parse errors before surfacing them

Cross-adapter moves and copies require both adapters to declare the relationship via supported_cross_adapter_actions. For example, the current ssh adapter declares { files = 'copy' }, meaning it can copy to/from the files adapter.

canola-collection repo structure

barrettruth/canola-collection/
├── canola-ssh/
│   ├── lua/canola/adapters/ssh/
│   ├── README.md
│   └── tests/
├── canola-s3/
│   ├── lua/canola/adapters/s3/
│   ├── README.md
│   └── tests/
├── canola-ftp/
│   ├── lua/canola/adapters/ftp.lua
│   ├── lua/canola/adapters/ftps.lua
│   ├── README.md
│   └── tests/
├── canola-trash/
│   ├── lua/canola/adapters/trash/
│   ├── README.md
│   └── tests/
├── canola-git/
│   ├── lua/canola/git/
│   ├── README.md
│   └── tests/
└── canola-resession/
    ├── lua/resession/extensions/canola.lua
    ├── README.md
    └── tests/

Each subdirectory is an independently installable plugin with its own lazy.nvim spec, README, and test suite. A single CI workflow runs all test suites in the monorepo.

Plugins in the collection

canola-ssh

Extracted from lua/oil/adapters/ssh.lua and lua/oil/adapters/ssh/. Registers canola-ssh:// (current scheme oil-ssh://). Depends on scp being in $PATH. On load:

require('canola').register_adapter('canola-ssh://', require('canola.adapters.ssh'))

lazy.nvim spec:

{ 'barrettruth/canola-collection', name = 'canola-ssh', main = 'canola-ssh' }

canola-s3

Extracted from lua/oil/adapters/s3.lua and lua/oil/adapters/s3/. Registers canola-s3://. Depends on aws CLI. Note: the current codebase uses oil-sss:// as the scheme on Neovim < 0.12 because Neovim could not open buffers whose name contained a number adjacent to ://; this workaround and its version guard should carry over.

canola-ftp

Extracted from lua/oil/adapters/ftp.lua and lua/oil/adapters/ftps.lua. Registers canola-ftp:// and canola-ftps://. Depends on curl.

canola-trash

Extracted from lua/oil/adapters/trash.lua and lua/oil/adapters/trash/ (platform backends: freedesktop.lua, mac.lua, windows.lua, windows/). Registers canola-trash://.

Trash is common enough that keeping it in core was considered, but the platform-specific branching (three separate backends, PowerShell connection pool on Windows) adds meaningful complexity and maintenance surface to core. It belongs in the collection.

canola-git

Git status display: decorating oil buffer lines with their git status (modified, staged, untracked, etc.). This is distinct from the git.lua operation hooks that stay in core.

The existing upstream plugin oil-git-status.nvim already does this well. The preferred approach is to coordinate with its author about adopting or co-maintaining it under the canola-collection umbrella rather than building from scratch. If that coordination fails, build fresh.

Either way, the collection plugin exposes decorations via virtual text or extmarks, not by modifying buffer content. Core exposes OilMutationComplete and OilReadPost user autocmds that canola-git listens to in order to refresh status after mutations.

canola-resession

The existing lua/resession/extensions/oil.lua moves to canola-resession/lua/resession/extensions/canola.lua. It is a resession.nvim window extension that saves and restores oil buffer URLs across sessions. No behavior changes needed during extraction — it is already self-contained.

Migration path

Users of the current adapters add the relevant collection plugin alongside canola:

-- lazy.nvim
{
  'barrettruth/canola-collection',
  name = 'canola-ssh',
  main = 'canola-ssh',
  dependencies = { 'barrettruth/canola.nvim' },
}

URL schemes change from oil-ssh:// to canola-ssh://, etc. A compatibility shim in each collection plugin can register the old scheme as an alias for one release cycle to give users time to update bookmarks and config.

Work breakdown

  • Design and implement require('canola').register_adapter(scheme, adapter) in core
  • Remove ssh, s3, ftp/ftps, trash adapter source from core; keep only files + test
  • Create barrettruth/canola-collection repo with monorepo skeleton and shared CI
  • Extract and migrate each adapter as its own subdirectory plugin
  • Coordinate with oil-git-status.nvim author; land canola-git as new build or adoption
  • Move resession extension to canola-resession
  • Write migration guide in canola core docs
> Original issue: barrettruth/canola.nvim#181 > Original author: `barrettruth` > Original date: 2026-03-18T20:21:51Z Introduce `barrettruth/canola-collection`: a monorepo of optional adapters and extensions that live outside canola core. ## Motivation Canola core currently bundles adapters for local files, SSH, S3, FTP/FTPS, and trash — plus a resession extension and git operation hooks. Most users only ever need the local filesystem adapter. Bundling everything bloats the install, loads code paths that never run, and makes core harder to reason about. The goal is a lean core with a rich, opt-in ecosystem. ## What stays in core - `files` adapter (`oil://`) — local filesystem via libuv. This is canola's raison d'être. - `test` adapter (`oil-test://`) — in-memory mock filesystem used exclusively by the spec suite. Never shipped as a user-facing feature. - `git.lua` — git operation hooks (`git add`/`mv`/`rm` on mutation). These fire synchronously inside the files adapter's `perform_action` and are tightly coupled to local filesystem mutations. Removing them would require a new hook API. The `config.git.{add,mv,rm}` predicate functions remain as-is; they are not the same thing as git status display. Git *status display* (decorating buffer entries with their git status) is explicitly **not** in core. That belongs in `canola-git` in the collection. ## Adapter registration API Core must expose a way for external adapters to register themselves. The current dispatch path is: 1. `config.adapters` maps URL scheme strings to adapter module name strings (e.g. `['oil-ssh://'] = 'ssh'`). 2. `config.get_adapter_by_scheme(scheme)` lazily `require`s `oil.adapters.<name>` on first access. That path only works for adapters bundled inside core. External adapters need a public registration call: ```lua require('canola').register_adapter(scheme, adapter) ``` Where `scheme` is the full URL prefix (e.g. `'canola-ssh://'`) and `adapter` is a table satisfying the `oil.Adapter` interface. Calling this writes directly into `config._adapter_by_scheme` so the scheme resolves without going through the `oil.adapters.<name>` require path. ### The `oil.Adapter` interface Every adapter must implement: | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | set by core | Unique adapter name; core sets this automatically after registration | | `list` | `fun(path, column_defs, cb)` | yes | Async. List a directory. Calls `cb(err?, entries?, fetch_more?)` | | `is_modifiable` | `fun(bufnr): boolean` | yes | Return true if the directory buffer may be edited | | `get_column` | `fun(name): oil.ColumnDefinition?` | yes | Return adapter-specific column definitions by name, or nil | | `normalize_url` | `fun(url, cb)` | yes | Normalize/resolve a URL before opening. Used for symlink following and path canonicalization | | `get_parent` | `fun(bufname): string` | no | Return the parent URL | | `get_entry_path` | `fun(url, entry, cb)` | no | Resolve the OS path for a selected entry (used for opening files) | | `render_action` | `fun(action): string` | no | Render a mutation action for the confirmation UI | | `perform_action` | `fun(action, cb)` | no | Execute a mutation action. Required for modifiable adapters | | `read_file` | `fun(bufnr)` | no | Read remote/virtual file contents into a buffer | | `write_file` | `fun(bufnr)` | no | Write buffer contents back to the remote/virtual destination | | `supported_cross_adapter_actions` | `table<string, oil.CrossAdapterAction>` | no | Declares which other adapters this one can move/copy to or from. Values are `"copy"` or `"move"` | | `filter_action` | `fun(action): boolean` | no | Filter out actions before execution | | `filter_error` | `fun(action): boolean` | no | Filter out parse errors before surfacing them | Cross-adapter moves and copies require both adapters to declare the relationship via `supported_cross_adapter_actions`. For example, the current ssh adapter declares `{ files = 'copy' }`, meaning it can copy to/from the files adapter. ## `canola-collection` repo structure ``` barrettruth/canola-collection/ ├── canola-ssh/ │ ├── lua/canola/adapters/ssh/ │ ├── README.md │ └── tests/ ├── canola-s3/ │ ├── lua/canola/adapters/s3/ │ ├── README.md │ └── tests/ ├── canola-ftp/ │ ├── lua/canola/adapters/ftp.lua │ ├── lua/canola/adapters/ftps.lua │ ├── README.md │ └── tests/ ├── canola-trash/ │ ├── lua/canola/adapters/trash/ │ ├── README.md │ └── tests/ ├── canola-git/ │ ├── lua/canola/git/ │ ├── README.md │ └── tests/ └── canola-resession/ ├── lua/resession/extensions/canola.lua ├── README.md └── tests/ ``` Each subdirectory is an independently installable plugin with its own lazy.nvim spec, README, and test suite. A single CI workflow runs all test suites in the monorepo. ## Plugins in the collection ### `canola-ssh` Extracted from `lua/oil/adapters/ssh.lua` and `lua/oil/adapters/ssh/`. Registers `canola-ssh://` (current scheme `oil-ssh://`). Depends on `scp` being in `$PATH`. On load: ```lua require('canola').register_adapter('canola-ssh://', require('canola.adapters.ssh')) ``` lazy.nvim spec: ```lua { 'barrettruth/canola-collection', name = 'canola-ssh', main = 'canola-ssh' } ``` ### `canola-s3` Extracted from `lua/oil/adapters/s3.lua` and `lua/oil/adapters/s3/`. Registers `canola-s3://`. Depends on `aws` CLI. Note: the current codebase uses `oil-sss://` as the scheme on Neovim < 0.12 because Neovim could not open buffers whose name contained a number adjacent to `://`; this workaround and its version guard should carry over. ### `canola-ftp` Extracted from `lua/oil/adapters/ftp.lua` and `lua/oil/adapters/ftps.lua`. Registers `canola-ftp://` and `canola-ftps://`. Depends on `curl`. ### `canola-trash` Extracted from `lua/oil/adapters/trash.lua` and `lua/oil/adapters/trash/` (platform backends: `freedesktop.lua`, `mac.lua`, `windows.lua`, `windows/`). Registers `canola-trash://`. Trash is common enough that keeping it in core was considered, but the platform-specific branching (three separate backends, PowerShell connection pool on Windows) adds meaningful complexity and maintenance surface to core. It belongs in the collection. ### `canola-git` Git *status display*: decorating oil buffer lines with their git status (modified, staged, untracked, etc.). This is distinct from the `git.lua` operation hooks that stay in core. The existing upstream plugin [oil-git-status.nvim](https://github.com/refractalize/oil-git-status.nvim) already does this well. The preferred approach is to coordinate with its author about adopting or co-maintaining it under the canola-collection umbrella rather than building from scratch. If that coordination fails, build fresh. Either way, the collection plugin exposes decorations via virtual text or extmarks, not by modifying buffer content. Core exposes `OilMutationComplete` and `OilReadPost` user autocmds that `canola-git` listens to in order to refresh status after mutations. ### `canola-resession` The existing `lua/resession/extensions/oil.lua` moves to `canola-resession/lua/resession/extensions/canola.lua`. It is a resession.nvim window extension that saves and restores oil buffer URLs across sessions. No behavior changes needed during extraction — it is already self-contained. ## Migration path Users of the current adapters add the relevant collection plugin alongside canola: ```lua -- lazy.nvim { 'barrettruth/canola-collection', name = 'canola-ssh', main = 'canola-ssh', dependencies = { 'barrettruth/canola.nvim' }, } ``` URL schemes change from `oil-ssh://` to `canola-ssh://`, etc. A compatibility shim in each collection plugin can register the old scheme as an alias for one release cycle to give users time to update bookmarks and config. ## Work breakdown - [ ] Design and implement `require('canola').register_adapter(scheme, adapter)` in core - [ ] Remove ssh, s3, ftp/ftps, trash adapter source from core; keep only files + test - [ ] Create `barrettruth/canola-collection` repo with monorepo skeleton and shared CI - [ ] Extract and migrate each adapter as its own subdirectory plugin - [ ] Coordinate with oil-git-status.nvim author; land `canola-git` as new build or adoption - [ ] Move resession extension to `canola-resession` - [ ] Write migration guide in canola core docs
barrettruth added this to the v1.1 milestone 2026-09-21 18:54:20 +00:00
Sign in to join this conversation.
No description provided.