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,205 @@
local columns = require('oil.columns')
local config = require('oil.config')
local layout = require('oil.layout')
local util = require('oil.util')
local M = {}
---@param actions oil.Action[]
---@return boolean
local function is_simple_edit(actions)
local num_create = 0
local num_copy = 0
local num_move = 0
for _, action in ipairs(actions) do
-- If there are any deletes, it is not a simple edit
if action.type == 'delete' then
return false
elseif action.type == 'create' then
num_create = num_create + 1
elseif action.type == 'copy' then
num_copy = num_copy + 1
-- Cross-adapter copies are not simple
if util.parse_url(action.src_url) ~= util.parse_url(action.dest_url) then
return false
end
elseif action.type == 'move' then
num_move = num_move + 1
-- Cross-adapter moves are not simple
if util.parse_url(action.src_url) ~= util.parse_url(action.dest_url) then
return false
end
end
end
-- More than one move/copy is complex
if num_move > 1 or num_copy > 1 then
return false
end
-- More than 5 creates is complex
if num_create > 5 then
return false
end
return true
end
---@param winid integer
---@param bufnr integer
---@param lines string[]
local function render_lines(winid, bufnr, lines)
util.render_text(bufnr, lines, {
v_align = 'top',
h_align = 'left',
winid = winid,
actions = { '[Y]es', '[N]o' },
})
end
---@param actions oil.Action[]
---@param should_confirm nil|boolean
---@param cb fun(proceed: boolean)
M.show = vim.schedule_wrap(function(actions, should_confirm, cb)
-- The schedule wrap ensures that we actually enter the floating window.
-- Not sure why it doesn't work without that
if should_confirm == false or #actions == 0 then
cb(true)
return
end
if should_confirm == nil and config.skip_confirm_for_simple_edits and is_simple_edit(actions) then
cb(true)
return
end
if should_confirm == nil and config.skip_confirm_for_delete then
local all_deletes = true
for _, action in ipairs(actions) do
if action.type ~= 'delete' then
all_deletes = false
break
end
end
if all_deletes then
cb(true)
return
end
end
-- Create the buffer
local bufnr = vim.api.nvim_create_buf(false, true)
vim.bo[bufnr].bufhidden = 'wipe'
local lines = {}
local max_line_width = 0
for _, action in ipairs(actions) do
local adapter = util.get_adapter_for_action(action)
local line
if action.type == 'change' then
---@cast action oil.ChangeAction
line = columns.render_change_action(adapter, action)
else
line = adapter.render_action(action)
end
-- We can't handle lines with newlines in them
line = line:gsub('\n', '')
table.insert(lines, line)
local line_width = vim.api.nvim_strwidth(line)
if line_width > max_line_width then
max_line_width = line_width
end
end
table.insert(lines, '')
-- Create the floating window
local width, height = layout.calculate_dims(max_line_width, #lines + 1, config.confirmation)
local ok, winid = pcall(vim.api.nvim_open_win, bufnr, true, {
relative = 'editor',
width = width,
height = height,
row = math.floor((layout.get_editor_height() - height) / 2),
col = math.floor((layout.get_editor_width() - width) / 2),
zindex = 152, -- render on top of the floating window title
style = 'minimal',
border = config.confirmation.border,
})
if not ok then
vim.notify(string.format('Error showing oil preview window: %s', winid), vim.log.levels.ERROR)
cb(false)
end
vim.bo[bufnr].filetype = 'oil_preview'
vim.bo[bufnr].syntax = 'oil_preview'
for k, v in pairs(config.confirmation.win_options) do
vim.api.nvim_set_option_value(k, v, { scope = 'local', win = winid })
end
render_lines(winid, bufnr, lines)
local restore_cursor = util.hide_cursor()
-- Attach autocmds and keymaps
local cancel
local confirm
local autocmds = {}
local function make_callback(value)
return function()
confirm = function() end
cancel = function() end
for _, id in ipairs(autocmds) do
vim.api.nvim_del_autocmd(id)
end
autocmds = {}
vim.api.nvim_win_close(winid, true)
restore_cursor()
cb(value)
end
end
cancel = make_callback(false)
confirm = make_callback(true)
vim.api.nvim_create_autocmd('BufLeave', {
callback = function()
cancel()
end,
once = true,
nested = true,
buffer = bufnr,
})
vim.api.nvim_create_autocmd('WinLeave', {
callback = function()
cancel()
end,
once = true,
nested = true,
})
table.insert(
autocmds,
vim.api.nvim_create_autocmd('VimResized', {
callback = function()
if vim.api.nvim_win_is_valid(winid) then
width, height = layout.calculate_dims(max_line_width, #lines, config.confirmation)
vim.api.nvim_win_set_config(winid, {
relative = 'editor',
width = width,
height = height,
row = math.floor((layout.get_editor_height() - height) / 2),
col = math.floor((layout.get_editor_width() - width) / 2),
zindex = 152, -- render on top of the floating window title
})
render_lines(winid, bufnr, lines)
end
end,
})
)
-- We used to use [C]ancel to cancel, so preserve the old keymap
local cancel_keys = { 'n', 'N', 'c', 'C', 'q', '<C-c>', '<Esc>' }
for _, cancel_key in ipairs(cancel_keys) do
vim.keymap.set('n', cancel_key, function()
cancel()
end, { buffer = bufnr, nowait = true })
end
-- We used to use [O]k to confirm, so preserve the old keymap
local confirm_keys = { 'y', 'Y', 'o', 'O' }
for _, confirm_key in ipairs(confirm_keys) do
vim.keymap.set('n', confirm_key, function()
confirm()
end, { buffer = bufnr, nowait = true })
end
end)
return M

626
lua/oil/mutator/init.lua Normal file
View file

@ -0,0 +1,626 @@
local Progress = require('oil.mutator.progress')
local Trie = require('oil.mutator.trie')
local cache = require('oil.cache')
local columns = require('oil.columns')
local config = require('oil.config')
local confirmation = require('oil.mutator.confirmation')
local constants = require('oil.constants')
local fs = require('oil.fs')
local lsp_helpers = require('oil.lsp.helpers')
local oil = require('oil')
local parser = require('oil.mutator.parser')
local util = require('oil.util')
local view = require('oil.view')
local M = {}
local FIELD_NAME = constants.FIELD_NAME
local FIELD_TYPE = constants.FIELD_TYPE
---@alias oil.Action oil.CreateAction|oil.DeleteAction|oil.MoveAction|oil.CopyAction|oil.ChangeAction
---@class (exact) oil.CreateAction
---@field type "create"
---@field url string
---@field entry_type oil.EntryType
---@field link nil|string
---@class (exact) oil.DeleteAction
---@field type "delete"
---@field url string
---@field entry_type oil.EntryType
---@class (exact) oil.MoveAction
---@field type "move"
---@field entry_type oil.EntryType
---@field src_url string
---@field dest_url string
---@class (exact) oil.CopyAction
---@field type "copy"
---@field entry_type oil.EntryType
---@field src_url string
---@field dest_url string
---@class (exact) oil.ChangeAction
---@field type "change"
---@field entry_type oil.EntryType
---@field url string
---@field column string
---@field value any
---@param all_diffs table<integer, oil.Diff[]>
---@return oil.Action[]
M.create_actions_from_diffs = function(all_diffs)
---@type oil.Action[]
local actions = {}
---@type table<integer, oil.Diff[]>
local diff_by_id = setmetatable({}, {
__index = function(t, key)
local list = {}
rawset(t, key, list)
return list
end,
})
-- To deduplicate create actions
-- This can happen when creating deep nested files e.g.
-- > foo/bar/a.txt
-- > foo/bar/b.txt
local seen_creates = {}
---@param action oil.Action
local function add_action(action)
local adapter = assert(config.get_adapter_by_scheme(action.dest_url or action.url))
if not adapter.filter_action or adapter.filter_action(action) then
if action.type == 'create' then
if seen_creates[action.url] then
return
else
seen_creates[action.url] = true
end
end
table.insert(actions, action)
end
end
for bufnr, diffs in pairs(all_diffs) do
local adapter = util.get_adapter(bufnr, true)
if not adapter then
error('Missing adapter')
end
local parent_url = vim.api.nvim_buf_get_name(bufnr)
for _, diff in ipairs(diffs) do
if diff.type == 'new' then
if diff.id then
local by_id = diff_by_id[diff.id]
---HACK: set the destination on this diff for use later
---@diagnostic disable-next-line: inject-field
diff.dest = parent_url .. diff.name
table.insert(by_id, diff)
else
-- Parse nested files like foo/bar/baz
local path_sep = fs.is_windows and '[/\\]' or '/'
local pieces = vim.split(diff.name, path_sep)
local url = parent_url:gsub('/$', '')
for i, v in ipairs(pieces) do
local is_last = i == #pieces
local entry_type = is_last and diff.entry_type or 'directory'
local alternation = v:match('{([^}]+)}')
if is_last and alternation then
-- Parse alternations like foo.{js,test.js}
for _, alt in ipairs(vim.split(alternation, ',')) do
local alt_url = url .. '/' .. v:gsub('{[^}]+}', alt)
add_action({
type = 'create',
url = alt_url,
entry_type = entry_type,
link = diff.link,
})
end
else
url = url .. '/' .. v
add_action({
type = 'create',
url = url,
entry_type = entry_type,
link = diff.link,
})
end
end
end
elseif diff.type == 'change' then
add_action({
type = 'change',
url = parent_url .. diff.name,
entry_type = diff.entry_type,
column = diff.column,
value = diff.value,
})
else
local by_id = diff_by_id[diff.id]
-- HACK: set has_delete field on a list-like table of diffs
---@diagnostic disable-next-line: inject-field
by_id.has_delete = true
-- Don't insert the delete. We already know that there is a delete because of the presence
-- in the diff_by_id map. The list will only include the 'new' diffs.
end
end
end
for id, diffs in pairs(diff_by_id) do
local entry = cache.get_entry_by_id(id)
if not entry then
error(string.format('Could not find entry %d', id))
end
---HACK: access the has_delete field on the list-like table of diffs
---@diagnostic disable-next-line: undefined-field
if diffs.has_delete then
local has_create = #diffs > 0
if has_create then
-- MOVE (+ optional copies) when has both creates and delete
for i, diff in ipairs(diffs) do
add_action({
type = i == #diffs and 'move' or 'copy',
entry_type = entry[FIELD_TYPE],
---HACK: access the dest field we set above
---@diagnostic disable-next-line: undefined-field
dest_url = diff.dest,
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
})
end
else
-- DELETE when no create
add_action({
type = 'delete',
entry_type = entry[FIELD_TYPE],
url = cache.get_parent_url(id) .. entry[FIELD_NAME],
})
end
else
-- COPY when create but no delete
for _, diff in ipairs(diffs) do
add_action({
type = 'copy',
entry_type = entry[FIELD_TYPE],
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
---HACK: access the dest field we set above
---@diagnostic disable-next-line: undefined-field
dest_url = diff.dest,
})
end
end
end
return M.enforce_action_order(actions)
end
---@param actions oil.Action[]
---@return oil.Action[]
M.enforce_action_order = function(actions)
local src_trie = Trie.new()
local dest_trie = Trie.new()
for _, action in ipairs(actions) do
if action.type == 'delete' or action.type == 'change' then
src_trie:insert_action(action.url, action)
elseif action.type == 'create' then
dest_trie:insert_action(action.url, action)
else
dest_trie:insert_action(action.dest_url, action)
src_trie:insert_action(action.src_url, action)
end
end
-- 1. create a graph, each node points to all of its dependencies
-- 2. for each action, if not added, find it in the graph
-- 3. traverse through the graph until you reach a node that has no dependencies (leaf)
-- 4. append that action to the return value, and remove it from the graph
-- a. TODO optimization: check immediate parents to see if they have no dependencies now
-- 5. repeat
---Gets the dependencies of a particular action. Effectively dynamically calculates the dependency
---"edges" of the graph.
---@param action oil.Action
local function get_deps(action)
local ret = {}
if action.type == 'delete' then
src_trie:accum_children_of(action.url, ret)
elseif action.type == 'create' then
-- Finish operating on parents first
-- e.g. NEW /a BEFORE NEW /a/b
dest_trie:accum_first_parents_of(action.url, ret)
-- Process remove path before creating new path
-- e.g. DELETE /a BEFORE NEW /a
src_trie:accum_actions_at(action.url, ret, function(a)
return a.type == 'move' or a.type == 'delete'
end)
elseif action.type == 'change' then
-- Finish operating on parents first
-- e.g. NEW /a BEFORE CHANGE /a/b
dest_trie:accum_first_parents_of(action.url, ret)
-- Finish operations on this path first
-- e.g. NEW /a BEFORE CHANGE /a
dest_trie:accum_actions_at(action.url, ret)
-- Finish copy from operations first
-- e.g. COPY /a -> /b BEFORE CHANGE /a
src_trie:accum_actions_at(action.url, ret, function(entry)
return entry.type == 'copy'
end)
elseif action.type == 'move' then
-- Finish operating on parents first
-- e.g. NEW /a BEFORE MOVE /z -> /a/b
dest_trie:accum_first_parents_of(action.dest_url, ret)
-- Process children before moving
-- e.g. NEW /a/b BEFORE MOVE /a -> /b
dest_trie:accum_children_of(action.src_url, ret)
-- Process children before moving parent dir
-- e.g. COPY /a/b -> /b BEFORE MOVE /a -> /d
-- e.g. CHANGE /a/b BEFORE MOVE /a -> /d
src_trie:accum_children_of(action.src_url, ret)
-- Process remove path before moving to new path
-- e.g. MOVE /a -> /b BEFORE MOVE /c -> /a
src_trie:accum_actions_at(action.dest_url, ret, function(a)
return a.type == 'move' or a.type == 'delete'
end)
elseif action.type == 'copy' then
-- Finish operating on parents first
-- e.g. NEW /a BEFORE COPY /z -> /a/b
dest_trie:accum_first_parents_of(action.dest_url, ret)
-- Process children before copying
-- e.g. NEW /a/b BEFORE COPY /a -> /b
dest_trie:accum_children_of(action.src_url, ret)
-- Process remove path before copying to new path
-- e.g. MOVE /a -> /b BEFORE COPY /c -> /a
src_trie:accum_actions_at(action.dest_url, ret, function(a)
return a.type == 'move' or a.type == 'delete'
end)
end
return ret
end
---@return nil|oil.Action The leaf action
---@return nil|oil.Action When no leaves found, this is the last action in the loop
local function find_leaf(action, seen)
if not seen then
seen = {}
elseif seen[action] then
return nil, action
end
seen[action] = true
local deps = get_deps(action)
if vim.tbl_isempty(deps) then
return action
end
local action_in_loop
for _, dep in ipairs(deps) do
local leaf, loop_action = find_leaf(dep, seen)
if leaf then
return leaf
elseif not action_in_loop and loop_action then
action_in_loop = loop_action
end
end
return nil, action_in_loop
end
local ret = {}
local after = {}
while not vim.tbl_isempty(actions) do
local action = actions[1]
local selected, loop_action = find_leaf(action)
local to_remove
if selected then
to_remove = selected
else
if loop_action and loop_action.type == 'move' then
-- If this is moving a parent into itself, that's an error
if vim.startswith(loop_action.dest_url, loop_action.src_url) then
error('Detected cycle in desired paths')
end
-- We've detected a move cycle (e.g. MOVE /a -> /b + MOVE /b -> /a)
-- Split one of the moves and retry
local intermediate_url =
string.format('%s__oil_tmp_%05d', loop_action.src_url, math.random(999999))
local move_1 = {
type = 'move',
entry_type = loop_action.entry_type,
src_url = loop_action.src_url,
dest_url = intermediate_url,
}
local move_2 = {
type = 'move',
entry_type = loop_action.entry_type,
src_url = intermediate_url,
dest_url = loop_action.dest_url,
}
to_remove = loop_action
table.insert(actions, move_1)
table.insert(after, move_2)
dest_trie:insert_action(move_1.dest_url, move_1)
src_trie:insert_action(move_1.src_url, move_1)
else
error('Detected cycle in desired paths')
end
end
if selected then
if selected.type == 'move' or selected.type == 'copy' then
if vim.startswith(selected.dest_url, selected.src_url .. '/') then
error(
string.format(
'Cannot move or copy parent into itself: %s -> %s',
selected.src_url,
selected.dest_url
)
)
end
end
table.insert(ret, selected)
end
if to_remove then
if to_remove.type == 'delete' or to_remove.type == 'change' then
src_trie:remove_action(to_remove.url, to_remove)
elseif to_remove.type == 'create' then
dest_trie:remove_action(to_remove.url, to_remove)
else
dest_trie:remove_action(to_remove.dest_url, to_remove)
src_trie:remove_action(to_remove.src_url, to_remove)
end
for i, a in ipairs(actions) do
if a == to_remove then
table.remove(actions, i)
break
end
end
end
end
vim.list_extend(ret, after)
return ret
end
local progress
---@param actions oil.Action[]
---@param cb fun(err: nil|string)
M.process_actions = function(actions, cb)
vim.api.nvim_exec_autocmds(
'User',
{ pattern = 'OilActionsPre', modeline = false, data = { actions = actions } }
)
local did_complete = nil
if config.lsp_file_methods.enabled then
did_complete = lsp_helpers.will_perform_file_operations(actions)
end
-- Convert some cross-adapter moves to a copy + delete
for _, action in ipairs(actions) do
if action.type == 'move' then
local _, cross_action = util.get_adapter_for_action(action)
-- Only do the conversion if the cross-adapter support is "copy"
if cross_action == 'copy' then
---@diagnostic disable-next-line: assign-type-mismatch
action.type = 'copy'
table.insert(actions, {
type = 'delete',
url = action.src_url,
entry_type = action.entry_type,
})
end
end
end
local finished = false
progress = Progress.new()
local function finish(err)
if not finished then
finished = true
progress:close()
progress = nil
if config.cleanup_buffers_on_delete and not err then
for _, action in ipairs(actions) do
if action.type == 'delete' then
local scheme, path = util.parse_url(action.url)
if config.adapters[scheme] == 'files' then
assert(path)
local os_path = fs.posix_to_os_path(path)
local bufnr = vim.fn.bufnr(os_path)
if bufnr ~= -1 then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
end
end
end
vim.api.nvim_exec_autocmds(
'User',
{ pattern = 'OilActionsPost', modeline = false, data = { err = err, actions = actions } }
)
cb(err)
end
end
-- Defer showing the progress to avoid flicker for fast operations
vim.defer_fn(function()
if not finished then
progress:show({
-- TODO some actions are actually cancelable.
-- We should stop them instead of stopping after the current action
cancel = function()
finish('Canceled')
end,
})
end
end, 100)
local idx = 1
local next_action
next_action = function()
if finished then
return
end
if idx > #actions then
if did_complete then
did_complete()
end
finish()
return
end
local action = actions[idx]
progress:set_action(action, idx, #actions)
idx = idx + 1
local ok, adapter = pcall(util.get_adapter_for_action, action)
if not ok then
return finish(adapter)
end
local callback = vim.schedule_wrap(function(err)
if finished then
-- This can happen if the user canceled out of the progress window
return
elseif err then
finish(err)
else
cache.perform_action(action)
next_action()
end
end)
if action.type == 'change' then
---@cast action oil.ChangeAction
columns.perform_change_action(adapter, action, callback)
else
adapter.perform_action(action, callback)
end
end
next_action()
end
M.show_progress = function()
if progress then
progress:restore()
end
end
local mutation_in_progress = false
---@return boolean
M.is_mutating = function()
return mutation_in_progress
end
---@param confirm nil|boolean
---@param cb? fun(err: nil|string)
M.try_write_changes = function(confirm, cb)
if not cb then
cb = function(_err) end
end
if mutation_in_progress then
cb('Cannot perform mutation when already in progress')
return
end
local current_buf = vim.api.nvim_get_current_buf()
local was_modified = vim.bo.modified
local buffers = view.get_all_buffers()
local all_diffs = {}
---@type table<integer, oil.ParseError[]>
local all_errors = {}
mutation_in_progress = true
-- Lock the buffer to prevent race conditions from the user modifying them during parsing
view.lock_buffers()
for _, bufnr in ipairs(buffers) do
if vim.bo[bufnr].modified then
local diffs, errors = parser.parse(bufnr)
all_diffs[bufnr] = diffs
local adapter = assert(util.get_adapter(bufnr, true))
if adapter.filter_error then
errors = vim.tbl_filter(adapter.filter_error, errors)
end
if not vim.tbl_isempty(errors) then
all_errors[bufnr] = errors
end
end
end
local function unlock()
view.unlock_buffers()
-- The ":write" will set nomodified even if we cancel here, so we need to restore it
if was_modified then
vim.bo[current_buf].modified = true
end
mutation_in_progress = false
end
local ns = vim.api.nvim_create_namespace('Oil')
vim.diagnostic.reset(ns)
if not vim.tbl_isempty(all_errors) then
for bufnr, errors in pairs(all_errors) do
vim.diagnostic.set(ns, bufnr, errors)
end
-- Jump to an error
local curbuf = vim.api.nvim_get_current_buf()
if all_errors[curbuf] then
pcall(
vim.api.nvim_win_set_cursor,
0,
{ all_errors[curbuf][1].lnum + 1, all_errors[curbuf][1].col }
)
else
local bufnr, errs = next(all_errors)
assert(bufnr)
assert(errs)
-- HACK: This is a workaround for the fact that we can't switch buffers in the middle of a
-- BufWriteCmd.
vim.schedule(function()
vim.api.nvim_win_set_buf(0, bufnr)
pcall(vim.api.nvim_win_set_cursor, 0, { errs[1].lnum + 1, errs[1].col })
end)
end
unlock()
cb('Error parsing oil buffers')
return
end
local actions = M.create_actions_from_diffs(all_diffs)
confirmation.show(actions, confirm, function(proceed)
if not proceed then
unlock()
cb('Canceled')
return
end
M.process_actions(
actions,
vim.schedule_wrap(function(err)
view.unlock_buffers()
if err then
err = string.format('[oil] Error applying actions: %s', err)
view.rerender_all_oil_buffers(nil, function()
cb(err)
end)
else
local current_entry = oil.get_cursor_entry()
if current_entry then
-- get the entry under the cursor and make sure the cursor stays on it
view.set_last_cursor(
vim.api.nvim_buf_get_name(0),
vim.split(current_entry.parsed_name or current_entry.name, '/')[1]
)
end
view.rerender_all_oil_buffers(nil, function(render_err)
vim.api.nvim_exec_autocmds(
'User',
{ pattern = 'OilMutationComplete', modeline = false }
)
cb(render_err)
end)
end
mutation_in_progress = false
end)
)
end)
end
return M

321
lua/oil/mutator/parser.lua Normal file
View file

@ -0,0 +1,321 @@
local cache = require('oil.cache')
local columns = require('oil.columns')
local config = require('oil.config')
local constants = require('oil.constants')
local fs = require('oil.fs')
local util = require('oil.util')
local view = require('oil.view')
local M = {}
local FIELD_ID = constants.FIELD_ID
local FIELD_NAME = constants.FIELD_NAME
local FIELD_TYPE = constants.FIELD_TYPE
local FIELD_META = constants.FIELD_META
---@alias oil.Diff oil.DiffNew|oil.DiffDelete|oil.DiffChange
---@class (exact) oil.DiffNew
---@field type "new"
---@field name string
---@field entry_type oil.EntryType
---@field id nil|integer
---@field link nil|string
---@class (exact) oil.DiffDelete
---@field type "delete"
---@field name string
---@field id integer
---@class (exact) oil.DiffChange
---@field type "change"
---@field entry_type oil.EntryType
---@field name string
---@field column string
---@field value any
---@param name string
---@return string
---@return boolean
local function parsedir(name)
local isdir = vim.endswith(name, '/') or (fs.is_windows and vim.endswith(name, '\\'))
if isdir then
name = name:sub(1, name:len() - 1)
end
return name, isdir
end
---@param meta nil|table
---@param parsed_entry table
---@return boolean True if metadata and parsed entry have the same link target
local function compare_link_target(meta, parsed_entry)
if not meta or not meta.link then
return false
end
-- Make sure we trim off any trailing path slashes from both sources
local meta_name = meta.link:gsub('[/\\]$', '')
local parsed_name = parsed_entry.link_target:gsub('[/\\]$', '')
return meta_name == parsed_name
end
---@class (exact) oil.ParseResult
---@field data table Parsed entry data
---@field ranges table<string, integer[]> Locations of the various columns
---@field entry nil|oil.InternalEntry If the entry already exists
---Parse a single line in a buffer
---@param adapter oil.Adapter
---@param line string
---@param column_defs oil.ColumnSpec[]
---@return nil|oil.ParseResult
---@return nil|string Error
M.parse_line = function(adapter, line, column_defs)
local ret = {}
local ranges = {}
local start = 1
local value, rem = line:match('^/(%d+) (.+)$')
if not value then
return nil, 'Malformed ID at start of line'
end
ranges.id = { start, value:len() + 1 }
start = ranges.id[2] + 1
ret.id = tonumber(value)
-- Right after a mutation and we reset the cache, the parent url may not be available
local ok, parent_url = pcall(cache.get_parent_url, ret.id)
if ok then
-- If this line was pasted from another adapter, it may have different columns
local line_adapter = assert(config.get_adapter_by_scheme(parent_url))
if adapter ~= line_adapter then
adapter = line_adapter
column_defs = columns.get_supported_columns(adapter)
end
end
for _, def in ipairs(column_defs) do
local name = util.split_config(def)
local range = { start }
local start_len = string.len(rem)
value, rem = columns.parse_col(adapter, assert(rem), def)
if not rem then
return nil, string.format('Parsing %s failed', name)
end
ret[name] = value
range[2] = range[1] + start_len - string.len(rem) - 1
ranges[name] = range
start = range[2] + 1
end
local name = rem
if name then
local isdir
name, isdir = parsedir(vim.trim(name))
if name ~= '' then
ret.name = name
end
ret._type = isdir and 'directory' or 'file'
end
local entry = cache.get_entry_by_id(ret.id)
ranges.name = { start, start + string.len(rem) - 1 }
if not entry then
return { data = ret, ranges = ranges }
end
-- Parse the symlink syntax
local meta = entry[FIELD_META]
local entry_type = entry[FIELD_TYPE]
if entry_type == 'link' and meta and meta.link then
local name_pieces = vim.split(ret.name, ' -> ', { plain = true })
if #name_pieces ~= 2 then
ret.name = ''
return { data = ret, ranges = ranges }
end
ranges.name = { start, start + string.len(name_pieces[1]) - 1 }
ret.name = parsedir(vim.trim(name_pieces[1]))
ret.link_target = name_pieces[2]
ret._type = 'link'
end
-- Try to keep the same file type
if entry_type ~= 'directory' and entry_type ~= 'file' and ret._type ~= 'directory' then
ret._type = entry[FIELD_TYPE]
end
return { data = ret, entry = entry, ranges = ranges }
end
---@class (exact) oil.ParseError
---@field lnum integer
---@field col integer
---@field message string
---@param bufnr integer
---@return oil.Diff[] diffs
---@return oil.ParseError[] errors Parsing errors
M.parse = function(bufnr)
---@type oil.Diff[]
local diffs = {}
---@type oil.ParseError[]
local errors = {}
local bufname = vim.api.nvim_buf_get_name(bufnr)
local adapter = util.get_adapter(bufnr, true)
if not adapter then
table.insert(errors, {
lnum = 0,
col = 0,
message = string.format("Cannot parse buffer '%s': No adapter", bufname),
})
return diffs, errors
end
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)
local scheme, path = util.parse_url(bufname)
local column_defs = columns.get_supported_columns(adapter)
local parent_url = scheme .. path
local children = cache.list_url(parent_url)
-- map from name to entry ID for all entries previously in the buffer
---@type table<string, integer>
local original_entries = {}
for _, child in pairs(children) do
local name = child[FIELD_NAME]
if view.should_display(bufnr, child) then
original_entries[name] = child[FIELD_ID]
end
end
local seen_names = {}
local function check_dupe(name, i)
if fs.is_mac or fs.is_windows then
-- mac and windows use case-insensitive filesystems
name = name:lower()
end
if seen_names[name] then
table.insert(errors, { message = 'Duplicate filename', lnum = i - 1, end_lnum = i, col = 0 })
else
seen_names[name] = true
end
end
for i, line in ipairs(lines) do
-- hack to be compatible with Lua 5.1
-- use return instead of goto
(function()
if line:match('^/%d+') then
-- Parse the line for an existing entry
local result, err = M.parse_line(adapter, line, column_defs)
if not result or err then
table.insert(errors, {
message = err,
lnum = i - 1,
end_lnum = i,
col = 0,
})
return
elseif result.data.id == 0 then
-- Ignore entries with ID 0 (typically the "../" entry)
return
end
local parsed_entry = result.data
local entry = result.entry
local err_message
if not parsed_entry.name then
err_message = 'No filename found'
elseif not entry then
err_message = 'Could not find existing entry (was the ID changed?)'
elseif parsed_entry.name:match('/') or parsed_entry.name:match(fs.sep) then
err_message = 'Filename cannot contain path separator'
end
if err_message then
table.insert(errors, {
message = err_message,
lnum = i - 1,
end_lnum = i,
col = 0,
})
return
end
assert(entry)
check_dupe(parsed_entry.name, i)
local meta = entry[FIELD_META]
if original_entries[parsed_entry.name] == parsed_entry.id then
if entry[FIELD_TYPE] == 'link' and not compare_link_target(meta, parsed_entry) then
table.insert(diffs, {
type = 'new',
name = parsed_entry.name,
entry_type = 'link',
link = parsed_entry.link_target,
})
elseif entry[FIELD_TYPE] ~= parsed_entry._type then
table.insert(diffs, {
type = 'new',
name = parsed_entry.name,
entry_type = parsed_entry._type,
})
else
original_entries[parsed_entry.name] = nil
end
else
table.insert(diffs, {
type = 'new',
name = parsed_entry.name,
entry_type = parsed_entry._type,
id = parsed_entry.id,
link = parsed_entry.link_target,
})
end
for _, col_def in ipairs(column_defs) do
local col_name = util.split_config(col_def)
if columns.compare(adapter, col_name, entry, parsed_entry[col_name]) then
table.insert(diffs, {
type = 'change',
name = parsed_entry.name,
entry_type = entry[FIELD_TYPE],
column = col_name,
value = parsed_entry[col_name],
})
end
end
else
-- Parse a new entry
local name, isdir = parsedir(vim.trim(line))
if vim.startswith(name, '/') then
table.insert(errors, {
message = "Paths cannot start with '/'",
lnum = i - 1,
end_lnum = i,
col = 0,
})
return
end
if name ~= '' then
local link_pieces = vim.split(name, ' -> ', { plain = true })
local entry_type = isdir and 'directory' or 'file'
local link
if #link_pieces == 2 then
entry_type = 'link'
name, link = unpack(link_pieces)
end
check_dupe(name, i)
table.insert(diffs, {
type = 'new',
name = name,
entry_type = entry_type,
link = link,
})
end
end
end)()
end
for name, child_id in pairs(original_entries) do
table.insert(diffs, {
type = 'delete',
name = name,
id = child_id,
})
end
return diffs, errors
end
return M

View file

@ -0,0 +1,223 @@
local columns = require('oil.columns')
local config = require('oil.config')
local layout = require('oil.layout')
local loading = require('oil.loading')
local util = require('oil.util')
local Progress = {}
local FPS = 20
function Progress.new()
return setmetatable({
lines = { '', '' },
count = '',
spinner = '',
bufnr = nil,
winid = nil,
min_bufnr = nil,
min_winid = nil,
autocmds = {},
closing = false,
}, {
__index = Progress,
})
end
---@private
---@return boolean
function Progress:is_minimized()
return not self.closing
and not self.bufnr
and self.min_bufnr
and vim.api.nvim_buf_is_valid(self.min_bufnr)
end
---@param opts nil|table
--- cancel fun()
function Progress:show(opts)
opts = opts or {}
if self.winid and vim.api.nvim_win_is_valid(self.winid) then
return
end
local bufnr = vim.api.nvim_create_buf(false, true)
vim.bo[bufnr].bufhidden = 'wipe'
self.bufnr = bufnr
self.cancel = opts.cancel or self.cancel
local loading_iter = loading.get_bar_iter()
local spinner = loading.get_iter('dots')
if not self.timer then
self.timer = vim.loop.new_timer()
self.timer:start(
0,
math.floor(1000 / FPS),
vim.schedule_wrap(function()
self.lines[2] = string.format('%s %s', self.count, loading_iter())
self.spinner = spinner()
self:_render()
end)
)
end
local width, height = layout.calculate_dims(120, 10, config.progress)
self.winid = vim.api.nvim_open_win(self.bufnr, true, {
relative = 'editor',
width = width,
height = height,
row = math.floor((layout.get_editor_height() - height) / 2),
col = math.floor((layout.get_editor_width() - width) / 2),
zindex = 152, -- render on top of the floating window title
style = 'minimal',
border = config.progress.border,
})
vim.bo[self.bufnr].filetype = 'oil_progress'
for k, v in pairs(config.progress.win_options) do
vim.api.nvim_set_option_value(k, v, { scope = 'local', win = self.winid })
end
table.insert(
self.autocmds,
vim.api.nvim_create_autocmd('VimResized', {
callback = function()
self:_reposition()
end,
})
)
table.insert(
self.autocmds,
vim.api.nvim_create_autocmd('WinLeave', {
callback = function()
self:minimize()
end,
})
)
local cancel = self.cancel or function() end
local minimize = function()
if self.winid and vim.api.nvim_win_is_valid(self.winid) then
vim.api.nvim_win_close(self.winid, true)
end
end
vim.keymap.set('n', 'c', cancel, { buffer = self.bufnr, nowait = true })
vim.keymap.set('n', 'C', cancel, { buffer = self.bufnr, nowait = true })
vim.keymap.set('n', 'm', minimize, { buffer = self.bufnr, nowait = true })
vim.keymap.set('n', 'M', minimize, { buffer = self.bufnr, nowait = true })
end
function Progress:restore()
if self.closing then
return
elseif not self:is_minimized() then
error('Cannot restore progress window: not minimized')
end
self:_cleanup_minimized_win()
self:show()
end
function Progress:_render()
if self.bufnr and vim.api.nvim_buf_is_valid(self.bufnr) then
util.render_text(
self.bufnr,
self.lines,
{ winid = self.winid, actions = { '[M]inimize', '[C]ancel' } }
)
end
if self.min_bufnr and vim.api.nvim_buf_is_valid(self.min_bufnr) then
util.render_text(
self.min_bufnr,
{ string.format('%sOil: %s', self.spinner, self.count) },
{ winid = self.min_winid, h_align = 'left' }
)
end
end
function Progress:_reposition()
if self.winid and vim.api.nvim_win_is_valid(self.winid) then
local min_width = 120
local line_width = vim.api.nvim_strwidth(self.lines[1])
if line_width > min_width then
min_width = line_width
end
local width, height = layout.calculate_dims(min_width, 10, config.progress)
vim.api.nvim_win_set_config(self.winid, {
relative = 'editor',
width = width,
height = height,
row = math.floor((layout.get_editor_height() - height) / 2),
col = math.floor((layout.get_editor_width() - width) / 2),
zindex = 152, -- render on top of the floating window title
})
end
end
function Progress:_cleanup_main_win()
if self.winid then
if vim.api.nvim_win_is_valid(self.winid) then
vim.api.nvim_win_close(self.winid, true)
end
self.winid = nil
end
for _, id in ipairs(self.autocmds) do
vim.api.nvim_del_autocmd(id)
end
self.autocmds = {}
self.bufnr = nil
end
function Progress:_cleanup_minimized_win()
if self.min_winid and vim.api.nvim_win_is_valid(self.min_winid) then
vim.api.nvim_win_close(self.min_winid, true)
end
self.min_winid = nil
self.min_bufnr = nil
end
function Progress:minimize()
if self.closing then
return
end
self:_cleanup_main_win()
local bufnr = vim.api.nvim_create_buf(false, true)
vim.bo[bufnr].bufhidden = 'wipe'
local winid = vim.api.nvim_open_win(bufnr, false, {
relative = 'editor',
width = 16,
height = 1,
anchor = 'SE',
row = layout.get_editor_height(),
col = layout.get_editor_width(),
zindex = 152, -- render on top of the floating window title
style = 'minimal',
border = config.progress.minimized_border,
})
self.min_bufnr = bufnr
self.min_winid = winid
self:_render()
vim.notify_once('Restore progress window with :Oil --progress')
end
---@param action oil.Action
---@param idx integer
---@param total integer
function Progress:set_action(action, idx, total)
local adapter = util.get_adapter_for_action(action)
local change_line
if action.type == 'change' then
---@cast action oil.ChangeAction
change_line = columns.render_change_action(adapter, action)
else
change_line = adapter.render_action(action)
end
self.lines[1] = change_line
self.count = string.format('%d/%d', idx, total)
self:_reposition()
self:_render()
end
function Progress:close()
self.closing = true
if self.timer then
self.timer:close()
self.timer = nil
end
self:_cleanup_main_win()
self:_cleanup_minimized_win()
end
return Progress

160
lua/oil/mutator/trie.lua Normal file
View file

@ -0,0 +1,160 @@
local util = require('oil.util')
---@class (exact) oil.Trie
---@field new fun(): oil.Trie
---@field private root table
local Trie = {}
---@return oil.Trie
Trie.new = function()
---@type oil.Trie
return setmetatable({
root = { values = {}, children = {} },
}, {
__index = Trie,
})
end
---@param url string
---@return string[]
function Trie:_url_to_path_pieces(url)
local scheme, path = util.parse_url(url)
assert(path)
local pieces = vim.split(path, '/')
table.insert(pieces, 1, scheme)
return pieces
end
---@param url string
---@param value any
function Trie:insert_action(url, value)
local pieces = self:_url_to_path_pieces(url)
self:insert(pieces, value)
end
---@param url string
---@param value any
function Trie:remove_action(url, value)
local pieces = self:_url_to_path_pieces(url)
self:remove(pieces, value)
end
---@param path_pieces string[]
---@param value any
function Trie:insert(path_pieces, value)
local current = self.root
for _, piece in ipairs(path_pieces) do
local next_container = current.children[piece]
if not next_container then
next_container = { values = {}, children = {} }
current.children[piece] = next_container
end
current = next_container
end
table.insert(current.values, value)
end
---@param path_pieces string[]
---@param value any
function Trie:remove(path_pieces, value)
local current = self.root
for _, piece in ipairs(path_pieces) do
local next_container = current.children[piece]
if not next_container then
next_container = { values = {}, children = {} }
current.children[piece] = next_container
end
current = next_container
end
for i, v in ipairs(current.values) do
if v == value then
table.remove(current.values, i)
-- if vim.tbl_isempty(current.values) and vim.tbl_isempty(current.children) then
-- TODO remove container from trie
-- end
return
end
end
error('Value not present in trie: ' .. vim.inspect(value))
end
---Add the first action that affects a parent path of the url
---@param url string
---@param ret oil.InternalEntry[]
function Trie:accum_first_parents_of(url, ret)
local pieces = self:_url_to_path_pieces(url)
local containers = { self.root }
for _, piece in ipairs(pieces) do
local next_container = containers[#containers].children[piece]
table.insert(containers, next_container)
end
table.remove(containers)
while not vim.tbl_isempty(containers) do
local container = containers[#containers]
if not vim.tbl_isempty(container.values) then
vim.list_extend(ret, container.values)
break
end
table.remove(containers)
end
end
---Do a depth-first-search and add all children matching the filter
function Trie:_dfs(container, ret, filter)
if filter then
for _, action in ipairs(container.values) do
if filter(action) then
table.insert(ret, action)
end
end
else
vim.list_extend(ret, container.values)
end
for _, child in ipairs(container.children) do
self:_dfs(child, ret)
end
end
---Add all actions affecting children of the url
---@param url string
---@param ret oil.InternalEntry[]
---@param filter nil|fun(entry: oil.Action): boolean
function Trie:accum_children_of(url, ret, filter)
local pieces = self:_url_to_path_pieces(url)
local current = self.root
for _, piece in ipairs(pieces) do
current = current.children[piece]
if not current then
return
end
end
if current then
for _, child in pairs(current.children) do
self:_dfs(child, ret, filter)
end
end
end
---Add all actions at a specific path
---@param url string
---@param ret oil.InternalEntry[]
---@param filter? fun(entry: oil.Action): boolean
function Trie:accum_actions_at(url, ret, filter)
local pieces = self:_url_to_path_pieces(url)
local current = self.root
for _, piece in ipairs(pieces) do
current = current.children[piece]
if not current then
return
end
end
if current then
for _, action in ipairs(current.values) do
if not filter or filter(action) then
table.insert(ret, action)
end
end
end
end
return Trie