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.
This commit is contained in:
Barrett Ruth 2026-03-10 22:41:32 -04:00
parent 9298b48c5d
commit 8dd67f91e8
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
68 changed files with 1622 additions and 1625 deletions

View file

@ -0,0 +1,317 @@
local config = require('oil.config')
local layout = require('oil.layout')
local util = require('oil.util')
---@class (exact) oil.sshCommand
---@field cmd string|string[]
---@field cb fun(err?: string, output?: string[])
---@field running? boolean
---@class (exact) oil.sshConnection
---@field new fun(url: oil.sshUrl): oil.sshConnection
---@field create_ssh_command fun(url: oil.sshUrl): string[]
---@field meta {user?: string, groups?: string[]}
---@field connection_error nil|string
---@field connected boolean
---@field private term_bufnr integer
---@field private jid integer
---@field private term_winid nil|integer
---@field private commands oil.sshCommand[]
---@field private _stdout string[]
local SSHConnection = {}
local function output_extend(agg, output)
local start = #agg
if vim.tbl_isempty(agg) then
for _, line in ipairs(output) do
line = line:gsub('\r', '')
table.insert(agg, line)
end
else
for i, v in ipairs(output) do
v = v:gsub('\r', '')
if i == 1 then
agg[#agg] = agg[#agg] .. v
else
table.insert(agg, v)
end
end
end
return start
end
---@param bufnr integer
---@param num_lines integer
---@return string[]
local function get_last_lines(bufnr, num_lines)
local end_line = vim.api.nvim_buf_line_count(bufnr)
num_lines = math.min(num_lines, end_line)
local lines = {}
while end_line > 0 and #lines < num_lines do
local need_lines = num_lines - #lines
lines = vim.list_extend(
vim.api.nvim_buf_get_lines(bufnr, end_line - need_lines, end_line, false),
lines
)
while not vim.tbl_isempty(lines) and lines[#lines]:match('^%s*$') do
table.remove(lines)
end
end_line = end_line - need_lines
end
return lines
end
---@param url oil.sshUrl
---@return string[]
function SSHConnection.create_ssh_command(url)
local host = url.host
if url.user then
host = url.user .. '@' .. host
end
local command = {
'ssh',
host,
}
if url.port then
table.insert(command, '-p')
table.insert(command, url.port)
end
return command
end
---@param url oil.sshUrl
---@return oil.sshConnection
function SSHConnection.new(url)
local command = SSHConnection.create_ssh_command(url)
vim.list_extend(command, {
'/bin/sh',
'-c',
-- HACK: For some reason in my testing if I just have "echo READY" it doesn't appear, but if I echo
-- anything prior to that, it *will* appear. The first line gets swallowed.
"echo '_make_newline_'; echo '===READY==='; exec /bin/sh",
})
local term_bufnr = vim.api.nvim_create_buf(false, true)
local self = setmetatable({
meta = {},
commands = {},
connected = false,
connection_error = nil,
term_bufnr = term_bufnr,
}, {
__index = SSHConnection,
})
local term_id
local mode = vim.api.nvim_get_mode().mode
util.run_in_fullscreen_win(term_bufnr, function()
term_id = vim.api.nvim_open_term(term_bufnr, {
on_input = function(_, _, _, data)
---@diagnostic disable-next-line: invisible
pcall(vim.api.nvim_chan_send, self.jid, data)
end,
})
end)
self.term_id = term_id
vim.api.nvim_chan_send(term_id, string.format('ssh %s\r\n', url.host))
util.hack_around_termopen_autocmd(mode)
-- If it takes more than 2 seconds to connect, pop open the terminal
vim.defer_fn(function()
if not self.connected and not self.connection_error then
self:open_terminal()
end
end, 2000)
self._stdout = {}
local jid = vim.fn.jobstart(command, {
pty = true, -- This is require for interactivity
on_stdout = function(j, output)
pcall(vim.api.nvim_chan_send, self.term_id, table.concat(output, '\r\n'))
---@diagnostic disable-next-line: invisible
local new_i_start = output_extend(self._stdout, output)
self:_handle_output(new_i_start)
end,
on_exit = function(j, code)
pcall(
vim.api.nvim_chan_send,
self.term_id,
string.format('\r\n[Process exited %d]\r\n', code)
)
-- Defer to allow the deferred terminal output handling to kick in first
vim.defer_fn(function()
if code == 0 then
self:_set_connection_error('SSH connection terminated gracefully')
else
self:_set_connection_error(
'Unknown SSH error\nTo see more, run :lua require("oil.adapters.ssh").open_terminal()'
)
end
end, 20)
end,
})
local exe = command[1]
if jid == 0 then
self:_set_connection_error(string.format("Passed invalid arguments to '%s'", exe))
elseif jid == -1 then
self:_set_connection_error(string.format("'%s' is not executable", exe))
else
self.jid = jid
end
self:run('id -u', function(err, lines)
if err then
vim.notify(string.format('Error fetching ssh connection user: %s', err), vim.log.levels.WARN)
else
assert(lines)
self.meta.user = vim.trim(table.concat(lines, ''))
end
end)
self:run('id -G', function(err, lines)
if err then
vim.notify(
string.format('Error fetching ssh connection user groups: %s', err),
vim.log.levels.WARN
)
else
assert(lines)
self.meta.groups = vim.split(table.concat(lines, ''), '%s+', { trimempty = true })
end
end)
---@cast self oil.sshConnection
return self
end
---@param err string
function SSHConnection:_set_connection_error(err)
if self.connection_error then
return
end
self.connection_error = err
local commands = self.commands
self.commands = {}
for _, cmd in ipairs(commands) do
cmd.cb(err)
end
end
function SSHConnection:_handle_output(start_i)
if not self.connected then
for i = start_i, #self._stdout - 1 do
local line = self._stdout[i]
if line == '===READY===' then
if self.term_winid then
if vim.api.nvim_win_is_valid(self.term_winid) then
vim.api.nvim_win_close(self.term_winid, true)
end
self.term_winid = nil
end
self.connected = true
self._stdout = util.tbl_slice(self._stdout, i + 1)
self:_handle_output(1)
self:_consume()
return
end
end
else
for i = start_i, #self._stdout - 1 do
---@type string
local line = self._stdout[i]
if line:match('^===BEGIN===%s*$') then
self._stdout = util.tbl_slice(self._stdout, i + 1)
self:_handle_output(1)
return
end
-- We can't be as strict with the matching (^$) because since we're using a pty the stdout and
-- stderr can be interleaved. If the command had an error, the stderr may interfere with a
-- clean print of the done line.
local exit_code = line:match('===DONE%((%d+)%)===')
if exit_code then
local output = util.tbl_slice(self._stdout, 1, i - 1)
local cb = self.commands[1].cb
self._stdout = util.tbl_slice(self._stdout, i + 1)
if exit_code == '0' then
cb(nil, output)
else
cb(exit_code .. ': ' .. table.concat(output, '\n'), output)
end
table.remove(self.commands, 1)
self:_handle_output(1)
self:_consume()
return
end
end
end
local function check_last_line()
local last_lines = get_last_lines(self.term_bufnr, 1)
local last_line = last_lines[1]
if last_line:match('^Are you sure you want to continue connecting') then
self:open_terminal()
-- selene: allow(if_same_then_else)
elseif last_line:match('Password:%s*$') then
self:open_terminal()
elseif last_line:match(': Permission denied %(.+%)%.') then
self:_set_connection_error(last_line:match(': (Permission denied %(.+%).)'))
elseif last_line:match('^ssh: .*Connection refused%s*$') then
self:_set_connection_error('Connection refused')
elseif last_line:match('^Connection to .+ closed by remote host.%s*$') then
self:_set_connection_error('Connection closed by remote host')
end
end
-- We have to defer this so the terminal buffer has time to update
vim.defer_fn(check_last_line, 10)
end
function SSHConnection:open_terminal()
if self.term_winid and vim.api.nvim_win_is_valid(self.term_winid) then
vim.api.nvim_set_current_win(self.term_winid)
return
end
local min_width = 120
local min_height = 20
local total_height = layout.get_editor_height()
local width = math.min(min_width, vim.o.columns - 2)
local height = math.min(min_height, total_height - 3)
local row = math.floor((total_height - height) / 2)
local col = math.floor((vim.o.columns - width) / 2)
self.term_winid = vim.api.nvim_open_win(self.term_bufnr, true, {
relative = 'editor',
width = width,
height = height,
row = row,
col = col,
style = 'minimal',
border = config.ssh.border,
})
vim.cmd.startinsert()
end
---@param command string
---@param callback fun(err: nil|string, lines: nil|string[])
function SSHConnection:run(command, callback)
if self.connection_error then
callback(self.connection_error)
else
table.insert(self.commands, { cmd = command, cb = callback })
self:_consume()
end
end
function SSHConnection:_consume()
if self.connected and not vim.tbl_isempty(self.commands) then
local cmd = self.commands[1]
if not cmd.running then
cmd.running = true
vim.api.nvim_chan_send(
self.jid,
-- HACK: Sleep briefly to help reduce stderr/stdout interleaving.
-- I want to find a way to flush the stderr before the echo DONE, but haven't yet.
-- This was causing issues when ls directory that doesn't exist (b/c ls prints error)
'echo "===BEGIN==="; '
.. cmd.cmd
.. '; CODE=$?; sleep .01; echo "===DONE($CODE)==="\r'
)
end
end
end
return SSHConnection

View file

@ -0,0 +1,264 @@
local SSHConnection = require('oil.adapters.ssh.connection')
local cache = require('oil.cache')
local constants = require('oil.constants')
local permissions = require('oil.adapters.files.permissions')
local util = require('oil.util')
---@class (exact) oil.sshFs
---@field new fun(url: oil.sshUrl): oil.sshFs
---@field conn oil.sshConnection
local SSHFS = {}
local FIELD_TYPE = constants.FIELD_TYPE
local FIELD_META = constants.FIELD_META
local typechar_map = {
l = 'link',
d = 'directory',
p = 'fifo',
s = 'socket',
['-'] = 'file',
c = 'file', -- character special file
b = 'file', -- block special file
}
---@param line string
---@return string Name of entry
---@return oil.EntryType
---@return table Metadata for entry
local function parse_ls_line(line)
local typechar, perms, refcount, user, group, rem =
line:match('^(.)(%S+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(.*)$')
if not typechar then
error(string.format("Could not parse '%s'", line))
end
local type = typechar_map[typechar] or 'file'
local meta = {
user = user,
group = group,
mode = permissions.parse(perms),
refcount = tonumber(refcount),
}
local name, size, date, major, minor
if typechar == 'c' or typechar == 'b' then
major, minor, date, name = rem:match('^(%d+)%s*,%s*(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)')
if name == nil then
major, minor, date, name =
rem:match('^(%d+)%s*,%s*(%d+)%s+(%d+%-%d+%-%d+%s+%d%d:?%d%d)%s+(.*)')
end
meta.major = tonumber(major)
meta.minor = tonumber(minor)
else
size, date, name = rem:match('^(%d+)%s+(%S+%s+%d+%s+%d%d:?%d%d)%s+(.*)')
if name == nil then
size, date, name = rem:match('^(%d+)%s+(%d+%-%d+%-%d+%s+%d%d:?%d%d)%s+(.*)')
end
meta.size = tonumber(size)
end
meta.iso_modified_date = date
if type == 'link' then
local link
name, link = unpack(vim.split(name, ' -> ', { plain = true }))
if vim.endswith(link, '/') then
link = link:sub(1, #link - 1)
end
meta.link = link
end
return name, type, meta
end
---@param str string String to escape
---@return string Escaped string
local function shellescape(str)
return "'" .. str:gsub("'", "'\\''") .. "'"
end
---@param url oil.sshUrl
---@return oil.sshFs
function SSHFS.new(url)
---@type oil.sshFs
return setmetatable({
conn = SSHConnection.new(url),
}, {
__index = SSHFS,
})
end
function SSHFS:get_connection_error()
return self.conn.connection_error
end
---@param value integer
---@param path string
---@param callback fun(err: nil|string)
function SSHFS:chmod(value, path, callback)
local octal = permissions.mode_to_octal_str(value)
self.conn:run(string.format('chmod %s %s', octal, shellescape(path)), callback)
end
function SSHFS:open_terminal()
self.conn:open_terminal()
end
function SSHFS:realpath(path, callback)
local cmd = string.format(
'if ! readlink -f "%s" 2>/dev/null; then [[ "%s" == /* ]] && echo "%s" || echo "$PWD/%s"; fi',
path,
path,
path,
path
)
self.conn:run(cmd, function(err, lines)
if err then
return callback(err)
end
assert(lines)
local abspath = table.concat(lines, '')
-- If the path was "." then the abspath might be /path/to/., so we need to trim that final '.'
if vim.endswith(abspath, '.') then
abspath = abspath:sub(1, #abspath - 1)
end
self.conn:run(
string.format('LC_ALL=C ls -land --color=never %s', shellescape(abspath)),
function(ls_err, ls_lines)
local type
if ls_err then
-- If the file doesn't exist, treat it like a not-yet-existing directory
type = 'directory'
else
assert(ls_lines)
local _
_, type = parse_ls_line(ls_lines[1])
end
if type == 'directory' then
abspath = util.addslash(abspath)
end
callback(nil, abspath)
end
)
end)
end
local dir_meta = {}
---@param url string
---@param path string
---@param callback fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
function SSHFS:list_dir(url, path, callback)
local path_postfix = ''
if path ~= '' then
path_postfix = string.format(' %s', shellescape(path))
end
self.conn:run('LC_ALL=C ls -lan --color=never' .. path_postfix, function(err, lines)
if err then
if err:match('No such file or directory%s*$') then
-- If the directory doesn't exist, treat the list as a success. We will be able to traverse
-- and edit a not-yet-existing directory.
return callback()
else
return callback(err)
end
end
assert(lines)
local any_links = false
local entries = {}
local cache_entries = {}
for _, line in ipairs(lines) do
if line ~= '' and not line:match('^total') then
local name, type, meta = parse_ls_line(line)
if name == '.' then
dir_meta[url] = meta
elseif name ~= '..' then
if type == 'link' then
any_links = true
end
local cache_entry = cache.create_entry(url, name, type)
table.insert(cache_entries, cache_entry)
entries[name] = cache_entry
cache_entry[FIELD_META] = meta
end
end
end
if any_links then
-- If there were any soft links, then we need to run another ls command with -L so that we can
-- resolve the type of the link target
self.conn:run(
'LC_ALL=C ls -naLl --color=never' .. path_postfix .. ' 2> /dev/null',
function(link_err, link_lines)
-- Ignore exit code 1. That just means one of the links could not be resolved.
if link_err and not link_err:match('^1:') then
return callback(link_err)
end
assert(link_lines)
for _, line in ipairs(link_lines) do
if line ~= '' and not line:match('^total') then
local ok, name, type, meta = pcall(parse_ls_line, line)
if ok and name ~= '.' and name ~= '..' then
local cache_entry = entries[name]
if cache_entry[FIELD_TYPE] == 'link' then
cache_entry[FIELD_META].link_stat = {
type = type,
size = meta.size,
}
end
end
end
end
callback(nil, cache_entries)
end
)
else
callback(nil, cache_entries)
end
end)
end
---@param path string
---@param callback fun(err: nil|string)
function SSHFS:mkdir(path, callback)
self.conn:run(string.format('mkdir -p %s', shellescape(path)), callback)
end
---@param path string
---@param callback fun(err: nil|string)
function SSHFS:touch(path, callback)
self.conn:run(string.format('touch %s', shellescape(path)), callback)
end
---@param path string
---@param link string
---@param callback fun(err: nil|string)
function SSHFS:mklink(path, link, callback)
self.conn:run(string.format('ln -s %s %s', shellescape(link), shellescape(path)), callback)
end
---@param path string
---@param callback fun(err: nil|string)
function SSHFS:rm(path, callback)
self.conn:run(string.format('rm -rf %s', shellescape(path)), callback)
end
---@param src string
---@param dest string
---@param callback fun(err: nil|string)
function SSHFS:mv(src, dest, callback)
self.conn:run(string.format('mv %s %s', shellescape(src), shellescape(dest)), callback)
end
---@param src string
---@param dest string
---@param callback fun(err: nil|string)
function SSHFS:cp(src, dest, callback)
self.conn:run(string.format('cp -r %s %s', shellescape(src), shellescape(dest)), callback)
end
function SSHFS:get_dir_meta(url)
return dir_meta[url]
end
function SSHFS:get_meta()
return self.conn.meta
end
return SSHFS