canola.nvim/lua/oil/init.lua
Barrett Ruth 4ab4765a84 feat: pass entry to is_hidden_file and is_always_hidden callbacks
Problem: the is_hidden_file and is_always_hidden config callbacks
only received (name, bufnr), making it impossible to filter by entry
type, permissions, or other metadata without reimplementing entry
lookup.

Solution: pass the full oil.Entry as a third argument to both
callbacks. Existing configs that only accept (name, bufnr) are
unaffected since Lua silently ignores extra arguments. The internal
should_display function signature changes from (name, bufnr) to
(bufnr, entry) to reflect its new contract.

Cherry-picked from: stevearc/oil.nvim#644
2026-02-20 16:29:08 -05:00

1441 lines
47 KiB
Lua

local M = {}
---@class (exact) oil.Entry
---@field name string
---@field type oil.EntryType
---@field id nil|integer Will be nil if it hasn't been persisted to disk yet
---@field parsed_name nil|string
---@field meta nil|table
---@alias oil.EntryType uv.aliases.fs_types
---@alias oil.HlRange { [1]: string, [2]: integer, [3]: integer } A tuple of highlight group name, col_start, col_end
---@alias oil.HlTuple { [1]: string, [2]: string } A tuple of text, highlight group
---@alias oil.HlRangeTuple { [1]: string, [2]: oil.HlRange[] } A tuple of text, internal highlights
---@alias oil.TextChunk string|oil.HlTuple|oil.HlRangeTuple
---@alias oil.CrossAdapterAction "copy"|"move"
---@class (exact) oil.Adapter
---@field name string The unique name of the adapter (this will be set automatically)
---@field list fun(path: string, column_defs: string[], cb: fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())) Async function to list a directory.
---@field is_modifiable fun(bufnr: integer): boolean Return true if this directory is modifiable (allows for directories with read-only permissions).
---@field get_column fun(name: string): nil|oil.ColumnDefinition If the adapter has any adapter-specific columns, return them when fetched by name.
---@field get_parent? fun(bufname: string): string Get the parent url of the given buffer
---@field normalize_url fun(url: string, callback: fun(url: string)) Before oil opens a url it will be normalized. This allows for link following, path normalizing, and converting an oil file url to the actual path of a file.
---@field get_entry_path? fun(url: string, entry: oil.Entry, callback: fun(path: string)) Similar to normalize_url, but used when selecting an entry
---@field render_action? fun(action: oil.Action): string Render a mutation action for display in the preview window. Only needed if adapter is modifiable.
---@field perform_action? fun(action: oil.Action, cb: fun(err: nil|string)) Perform a mutation action. Only needed if adapter is modifiable.
---@field read_file? fun(bufnr: integer) Used for adapters that deal with remote/virtual files. Read the contents of the file into a buffer.
---@field write_file? fun(bufnr: integer) Used for adapters that deal with remote/virtual files. Write the contents of a buffer to the destination.
---@field supported_cross_adapter_actions? table<string, oil.CrossAdapterAction> Mapping of adapter name to enum for all other adapters that can be used as a src or dest for move/copy actions.
---@field filter_action? fun(action: oil.Action): boolean When present, filter out actions as they are created
---@field filter_error? fun(action: oil.ParseError): boolean When present, filter out errors from parsing a buffer
---Get the entry on a specific line (1-indexed)
---@param bufnr integer
---@param lnum integer
---@return nil|oil.Entry
M.get_entry_on_line = function(bufnr, lnum)
local columns = require("oil.columns")
local parser = require("oil.mutator.parser")
local util = require("oil.util")
if vim.bo[bufnr].filetype ~= "oil" then
return nil
end
local adapter = util.get_adapter(bufnr)
if not adapter then
return nil
end
local line = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, true)[1]
if not line then
return nil
end
local column_defs = columns.get_supported_columns(adapter)
local result = parser.parse_line(adapter, line, column_defs)
if result then
if result.entry then
local entry = util.export_entry(result.entry)
entry.parsed_name = result.data.name
return entry
else
return {
id = result.data.id,
name = result.data.name,
type = result.data._type,
parsed_name = result.data.name,
}
end
end
-- This is a NEW entry that hasn't been saved yet
local name = vim.trim(line)
local entry_type
if vim.endswith(name, "/") then
name = name:sub(1, name:len() - 1)
entry_type = "directory"
else
entry_type = "file"
end
if name == "" then
return nil
else
return {
name = name,
type = entry_type,
parsed_name = name,
}
end
end
---Get the entry currently under the cursor
---@return nil|oil.Entry
M.get_cursor_entry = function()
local lnum = vim.api.nvim_win_get_cursor(0)[1]
return M.get_entry_on_line(0, lnum)
end
---Discard all changes made to oil buffers
M.discard_all_changes = function()
local view = require("oil.view")
for _, bufnr in ipairs(view.get_all_buffers()) do
if vim.bo[bufnr].modified then
view.render_buffer_async(bufnr, {}, function(err)
if err then
vim.notify(
string.format(
"Error rendering oil buffer %s: %s",
vim.api.nvim_buf_get_name(bufnr),
err
),
vim.log.levels.ERROR
)
end
end)
end
end
end
---Change the display columns for oil
---@param cols oil.ColumnSpec[]
M.set_columns = function(cols)
require("oil.view").set_columns(cols)
end
---Change the sort order for oil
---@param sort oil.SortSpec[] List of columns plus direction. See :help oil-columns to see which ones are sortable.
---@example
--- require("oil").set_sort({ { "type", "asc" }, { "size", "desc" } })
M.set_sort = function(sort)
require("oil.view").set_sort(sort)
end
---Change how oil determines if the file is hidden
---@param is_hidden_file fun(filename: string, bufnr: integer, entry: oil.Entry): boolean Return true if the file/dir should be hidden
M.set_is_hidden_file = function(is_hidden_file)
require("oil.view").set_is_hidden_file(is_hidden_file)
end
---Toggle hidden files and directories
M.toggle_hidden = function()
require("oil.view").toggle_hidden()
end
---Get the current directory
---@param bufnr? integer
---@return nil|string
M.get_current_dir = function(bufnr)
local config = require("oil.config")
local fs = require("oil.fs")
local util = require("oil.util")
local buf_name = vim.api.nvim_buf_get_name(bufnr or 0)
local scheme, path = util.parse_url(buf_name)
if config.adapters[scheme] == "files" then
assert(path)
return fs.posix_to_os_path(path)
end
end
---Get the oil url for a given directory
---@private
---@param dir nil|string When nil, use the cwd
---@param use_oil_parent nil|boolean If in an oil buffer, return the parent (default true)
---@return string The parent url
---@return nil|string The basename (if present) of the file/dir we were just in
M.get_url_for_path = function(dir, use_oil_parent)
if use_oil_parent == nil then
use_oil_parent = true
end
local config = require("oil.config")
local fs = require("oil.fs")
local util = require("oil.util")
if vim.bo.filetype == "netrw" and not dir then
dir = vim.b.netrw_curdir
end
if dir then
local scheme = util.parse_url(dir)
if scheme then
return dir
end
local abspath = vim.fn.fnamemodify(dir, ":p")
local path = fs.os_to_posix_path(abspath)
return config.adapter_to_scheme.files .. path
else
local bufname = vim.api.nvim_buf_get_name(0)
return M.get_buffer_parent_url(bufname, use_oil_parent)
end
end
---@private
---@param bufname string
---@param use_oil_parent boolean If in an oil buffer, return the parent
---@return string
---@return nil|string
M.get_buffer_parent_url = function(bufname, use_oil_parent)
local config = require("oil.config")
local fs = require("oil.fs")
local pathutil = require("oil.pathutil")
local util = require("oil.util")
local scheme, path = util.parse_url(bufname)
if not scheme then
local parent, basename
scheme = config.adapter_to_scheme.files
if bufname == "" then
parent = fs.os_to_posix_path(vim.fn.getcwd())
else
parent = fs.os_to_posix_path(vim.fn.fnamemodify(bufname, ":p:h"))
basename = vim.fn.fnamemodify(bufname, ":t")
end
local parent_url = util.addslash(scheme .. parent)
return parent_url, basename
else
assert(path)
if scheme == "term://" then
---@type string
path = vim.fn.expand(path:match("^(.*)//")) ---@diagnostic disable-line: assign-type-mismatch
return config.adapter_to_scheme.files .. util.addslash(path)
end
-- This is some unknown buffer scheme
if not config.adapters[scheme] then
return vim.fn.getcwd()
end
if not use_oil_parent then
return bufname
end
local adapter = assert(config.get_adapter_by_scheme(scheme))
local parent_url
if adapter and adapter.get_parent then
local adapter_scheme = config.adapter_to_scheme[adapter.name]
parent_url = adapter.get_parent(adapter_scheme .. path)
else
local parent = pathutil.parent(path)
parent_url = scheme .. util.addslash(parent)
end
if parent_url == bufname then
return parent_url
else
return util.addslash(parent_url), pathutil.basename(path)
end
end
end
---@class (exact) oil.OpenOpts
---@field preview? oil.OpenPreviewOpts When present, open the preview window after opening oil
---Open oil browser in a floating window
---@param dir? string When nil, open the parent of the current buffer, or the cwd if current buffer is not a file
---@param opts? oil.OpenOpts
---@param cb? fun() Called after the oil buffer is ready
M.open_float = function(dir, opts, cb)
opts = opts or {}
local config = require("oil.config")
local layout = require("oil.layout")
local util = require("oil.util")
local view = require("oil.view")
local parent_url, basename = M.get_url_for_path(dir)
if basename then
view.set_last_cursor(parent_url, basename)
end
local bufnr = vim.api.nvim_create_buf(false, true)
vim.bo[bufnr].bufhidden = "wipe"
local win_opts = layout.get_fullscreen_win_opts()
local original_winid = vim.api.nvim_get_current_win()
local winid = vim.api.nvim_open_win(bufnr, true, win_opts)
vim.w[winid].is_oil_win = true
vim.w[winid].oil_original_win = original_winid
for k, v in pairs(config.float.win_options) do
vim.api.nvim_set_option_value(k, v, { scope = "local", win = winid })
end
local autocmds = {}
table.insert(
autocmds,
vim.api.nvim_create_autocmd("WinLeave", {
desc = "Close floating oil window",
group = "Oil",
callback = vim.schedule_wrap(function()
if util.is_floating_win() or vim.fn.win_gettype() == "command" then
return
end
if vim.api.nvim_win_is_valid(winid) then
vim.api.nvim_win_close(winid, true)
end
for _, id in ipairs(autocmds) do
vim.api.nvim_del_autocmd(id)
end
autocmds = {}
end),
nested = true,
})
)
table.insert(
autocmds,
vim.api.nvim_create_autocmd("BufWinEnter", {
desc = "Reset local oil window options when buffer changes",
pattern = "*",
callback = function(params)
local winbuf = params.buf
if not vim.api.nvim_win_is_valid(winid) or vim.api.nvim_win_get_buf(winid) ~= winbuf then
return
end
for k, v in pairs(config.float.win_options) do
vim.api.nvim_set_option_value(k, v, { scope = "local", win = winid })
end
-- Update the floating window title
if vim.fn.has("nvim-0.9") == 1 and config.float.border ~= "none" then
local cur_win_opts = vim.api.nvim_win_get_config(winid)
vim.api.nvim_win_set_config(winid, {
relative = "editor",
row = cur_win_opts.row,
col = cur_win_opts.col,
width = cur_win_opts.width,
height = cur_win_opts.height,
title = util.get_title(winid),
})
end
end,
})
)
vim.cmd.edit({ args = { util.escape_filename(parent_url) }, mods = { keepalt = true } })
-- :edit will set buflisted = true, but we may not want that
if config.buf_options.buflisted ~= nil then
vim.api.nvim_set_option_value("buflisted", config.buf_options.buflisted, { buf = 0 })
end
util.run_after_load(0, function()
if opts.preview then
M.open_preview(opts.preview, cb)
elseif cb then
cb()
end
end)
if vim.fn.has("nvim-0.9") == 0 then
util.add_title_to_win(winid)
end
end
---Open oil browser in a floating window, or close it if open
---@param dir nil|string When nil, open the parent of the current buffer, or the cwd if current buffer is not a file
---@param opts? oil.OpenOpts
---@param cb? fun() Called after the oil buffer is ready
M.toggle_float = function(dir, opts, cb)
if vim.w.is_oil_win then
M.close()
if cb then
cb()
end
else
M.open_float(dir, opts, cb)
end
end
---@param oil_bufnr? integer
local function update_preview_window(oil_bufnr)
oil_bufnr = oil_bufnr or 0
local util = require("oil.util")
util.run_after_load(oil_bufnr, function()
local cursor_entry = M.get_cursor_entry()
local preview_win_id = util.get_preview_win()
if
cursor_entry
and preview_win_id
and cursor_entry.id ~= vim.w[preview_win_id].oil_entry_id
then
M.open_preview()
end
end)
end
---Open oil browser for a directory
---@param dir? string When nil, open the parent of the current buffer, or the cwd if current buffer is not a file
---@param opts? oil.OpenOpts
---@param cb? fun() Called after the oil buffer is ready
M.open = function(dir, opts, cb)
opts = opts or {}
local config = require("oil.config")
local util = require("oil.util")
local view = require("oil.view")
local parent_url, basename = M.get_url_for_path(dir)
if basename then
view.set_last_cursor(parent_url, basename)
end
vim.cmd.edit({ args = { util.escape_filename(parent_url) }, mods = { keepalt = true } })
-- :edit will set buflisted = true, but we may not want that
if config.buf_options.buflisted ~= nil then
vim.api.nvim_set_option_value("buflisted", config.buf_options.buflisted, { buf = 0 })
end
util.run_after_load(0, function()
if opts.preview then
M.open_preview(opts.preview, cb)
elseif cb then
cb()
end
end)
-- If preview window exists, update its content
update_preview_window()
end
---@class oil.CloseOpts
---@field exit_if_last_buf? boolean Exit vim if this oil buffer is the last open buffer
---Restore the buffer that was present when oil was opened
---@param opts? oil.CloseOpts
M.close = function(opts)
opts = opts or {}
local mode = vim.api.nvim_get_mode().mode
if mode:match("^[vVsS\22\19]") or mode:match("^no") then
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<Esc>", true, true, true), "n", false)
return
end
-- If we're in a floating oil window, close it and try to restore focus to the original window
if vim.w.is_oil_win then
local original_winid = vim.w.oil_original_win
vim.api.nvim_win_close(0, true)
if original_winid and vim.api.nvim_win_is_valid(original_winid) then
vim.api.nvim_set_current_win(original_winid)
end
return
end
local ok, bufnr = pcall(vim.api.nvim_win_get_var, 0, "oil_original_buffer")
if ok and vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_win_set_buf(0, bufnr)
if vim.w.oil_original_view then
vim.fn.winrestview(vim.w.oil_original_view)
end
return
end
-- Deleting the buffer closes all windows with that buffer open, so navigate to a different
-- buffer first
local oilbuf = vim.api.nvim_get_current_buf()
ok = pcall(vim.cmd.bprev)
-- If `bprev` failed, there are no buffers open
if not ok then
-- either exit or create a new blank buffer
if opts.exit_if_last_buf then
vim.cmd.quit()
else
vim.cmd.enew()
end
end
vim.api.nvim_buf_delete(oilbuf, { force = true })
end
---@class oil.OpenPreviewOpts
---@field vertical? boolean Open the buffer in a vertical split
---@field horizontal? boolean Open the buffer in a horizontal split
---@field split? "aboveleft"|"belowright"|"topleft"|"botright" Split modifier
---Preview the entry under the cursor in a split
---@param opts? oil.OpenPreviewOpts
---@param callback? fun(err: nil|string) Called once the preview window has been opened
M.open_preview = function(opts, callback)
opts = opts or {}
local config = require("oil.config")
local layout = require("oil.layout")
local util = require("oil.util")
local function finish(err)
if err then
vim.notify(err, vim.log.levels.ERROR)
end
if callback then
callback(err)
end
end
if not opts.horizontal and opts.vertical == nil then
opts.vertical = true
end
if not opts.split then
if opts.horizontal then
opts.split = vim.o.splitbelow and "belowright" or "aboveleft"
else
opts.split = vim.o.splitright and "belowright" or "aboveleft"
end
end
local preview_win = util.get_preview_win({ include_not_owned = true })
local prev_win = vim.api.nvim_get_current_win()
local bufnr = vim.api.nvim_get_current_buf()
local entry = M.get_cursor_entry()
if not entry then
return finish("Could not find entry under cursor")
end
local entry_title = entry.name
if entry.type == "directory" then
entry_title = entry_title .. "/"
end
if util.is_floating_win() then
if preview_win == nil then
local root_win_opts, preview_win_opts =
layout.split_window(0, config.float.preview_split, config.float.padding)
local win_opts_oil = {
relative = "editor",
width = root_win_opts.width,
height = root_win_opts.height,
row = root_win_opts.row,
col = root_win_opts.col,
border = config.float.border,
zindex = 45,
}
vim.api.nvim_win_set_config(0, win_opts_oil)
local win_opts = {
relative = "editor",
width = preview_win_opts.width,
height = preview_win_opts.height,
row = preview_win_opts.row,
col = preview_win_opts.col,
border = config.float.border,
zindex = 45,
focusable = false,
noautocmd = true,
style = "minimal",
}
if vim.fn.has("nvim-0.9") == 1 then
win_opts.title = entry_title
end
preview_win = vim.api.nvim_open_win(bufnr, true, win_opts)
vim.api.nvim_set_option_value("previewwindow", true, { scope = "local", win = preview_win })
vim.api.nvim_win_set_var(preview_win, "oil_preview", true)
vim.api.nvim_set_current_win(prev_win)
elseif vim.fn.has("nvim-0.9") == 1 then
vim.api.nvim_win_set_config(preview_win, { title = entry_title })
end
end
local cmd = preview_win and "buffer" or "sbuffer"
local mods = {
vertical = opts.vertical,
horizontal = opts.horizontal,
split = opts.split,
}
-- HACK Switching windows takes us out of visual mode.
-- Switching with nvim_set_current_win causes the previous visual selection (as used by `gv`) to
-- not get set properly. So we have to switch windows this way instead.
local hack_set_win = function(winid)
local winnr = vim.api.nvim_win_get_number(winid)
vim.cmd.wincmd({ args = { "w" }, count = winnr })
end
util.get_edit_path(bufnr, entry, function(normalized_url)
local mc = package.loaded["multicursor-nvim"]
local has_multicursors = mc and mc.hasCursors()
local is_visual_mode = util.is_visual_mode()
if preview_win then
if is_visual_mode then
hack_set_win(preview_win)
else
vim.api.nvim_set_current_win(preview_win)
end
end
local entry_is_file = not vim.endswith(normalized_url, "/")
local filebufnr
if entry_is_file then
if config.preview_win.disable_preview(normalized_url) then
filebufnr = vim.api.nvim_create_buf(false, true)
vim.bo[filebufnr].bufhidden = "wipe"
vim.bo[filebufnr].buftype = "nofile"
util.render_text(filebufnr, "Preview disabled", { winid = preview_win })
elseif
config.preview_win.preview_method ~= "load"
and not util.file_matches_bufreadcmd(normalized_url)
then
filebufnr =
util.read_file_to_scratch_buffer(normalized_url, config.preview_win.preview_method)
end
end
if not filebufnr then
filebufnr = vim.fn.bufadd(normalized_url)
if entry_is_file and vim.fn.bufloaded(filebufnr) == 0 then
vim.bo[filebufnr].bufhidden = "wipe"
vim.b[filebufnr].oil_preview_buffer = true
end
end
---@diagnostic disable-next-line: param-type-mismatch
local ok, err = pcall(vim.cmd, {
cmd = cmd,
args = { filebufnr },
mods = mods,
})
-- Ignore swapfile errors
if not ok and err and not err:match("^Vim:E325:") then
vim.api.nvim_echo({ { err, "Error" } }, true, {})
end
-- If we called open_preview during an autocmd, then the edit command may not trigger the
-- BufReadCmd to load the buffer. So we need to do it manually.
if util.is_oil_bufnr(filebufnr) then
M.load_oil_buffer(filebufnr)
end
vim.api.nvim_set_option_value("previewwindow", true, { scope = "local", win = 0 })
vim.api.nvim_win_set_var(0, "oil_preview", true)
for k, v in pairs(config.preview_win.win_options) do
vim.api.nvim_set_option_value(k, v, { scope = "local", win = preview_win })
end
vim.w.oil_entry_id = entry.id
vim.w.oil_source_win = prev_win
if has_multicursors then
hack_set_win(prev_win)
mc.restoreCursors()
elseif is_visual_mode then
hack_set_win(prev_win)
-- Restore the visual selection
vim.cmd.normal({ args = { "gv" }, bang = true })
else
vim.api.nvim_set_current_win(prev_win)
end
finish()
end)
end
---@class (exact) oil.SelectOpts
---@field vertical? boolean Open the buffer in a vertical split
---@field horizontal? boolean Open the buffer in a horizontal split
---@field split? "aboveleft"|"belowright"|"topleft"|"botright" Split modifier
---@field tab? boolean Open the buffer in a new tab
---@field close? boolean Close the original oil buffer once selection is made
---@field handle_buffer_callback? fun(buf_id: integer) If defined, all other buffer related options here would be ignored. This callback allows you to take over the process of opening the buffer yourself.
---Select the entry under the cursor
---@param opts nil|oil.SelectOpts
---@param callback nil|fun(err: nil|string) Called once all entries have been opened
M.select = function(opts, callback)
local cache = require("oil.cache")
local config = require("oil.config")
local constants = require("oil.constants")
local util = require("oil.util")
local FIELD_META = constants.FIELD_META
opts = vim.tbl_extend("keep", opts or {}, {})
local function finish(err)
if err then
vim.notify(err, vim.log.levels.ERROR)
end
if callback then
callback(err)
end
end
if not opts.split and (opts.horizontal or opts.vertical) then
if opts.horizontal then
opts.split = vim.o.splitbelow and "belowright" or "aboveleft"
else
opts.split = vim.o.splitright and "belowright" or "aboveleft"
end
end
if opts.tab and opts.split then
return finish("Cannot use split=true when tab = true")
end
local adapter = util.get_adapter(0)
if not adapter then
return finish("Not an oil buffer")
end
local visual_range = util.get_visual_range()
---@type oil.Entry[]
local entries = {}
if visual_range then
for i = visual_range.start_lnum, visual_range.end_lnum do
local entry = M.get_entry_on_line(0, i)
if entry then
table.insert(entries, entry)
end
end
else
local entry = M.get_cursor_entry()
if entry then
table.insert(entries, entry)
end
end
if vim.tbl_isempty(entries) then
return finish("Could not find entry under cursor")
end
-- Check if any of these entries are moved from their original location
local bufname = vim.api.nvim_buf_get_name(0)
local any_moved = false
for _, entry in ipairs(entries) do
-- Ignore entries with ID 0 (typically the "../" entry)
if entry.id ~= 0 then
local is_new_entry = entry.id == nil
local is_moved_from_dir = entry.id and cache.get_parent_url(entry.id) ~= bufname
local is_renamed = entry.parsed_name ~= entry.name
local internal_entry = entry.id and cache.get_entry_by_id(entry.id)
if internal_entry then
local meta = internal_entry[FIELD_META]
if meta and meta.display_name then
is_renamed = entry.parsed_name ~= meta.display_name
end
end
if is_new_entry or is_moved_from_dir or is_renamed then
any_moved = true
break
end
end
end
if any_moved and config.prompt_save_on_select_new_entry then
local ok, choice = pcall(vim.fn.confirm, "Save changes?", "Yes\nNo", 1)
if not ok then
return finish()
elseif choice == 1 then
M.save()
return finish()
end
end
local prev_win = vim.api.nvim_get_current_win()
local oil_bufnr = vim.api.nvim_get_current_buf()
-- Async iter over entries so we can normalize the url before opening
local i = 1
local function open_next_entry(cb)
local entry = entries[i]
i = i + 1
if not entry then
return cb()
end
if util.is_directory(entry) then
-- If this is a new directory BUT we think we already have an entry with this name, disallow
-- entry. This prevents the case of MOVE /foo -> /bar + CREATE /foo.
-- If you enter the new /foo, it will show the contents of the old /foo.
if not entry.id and cache.list_url(bufname)[entry.name] then
return cb("Please save changes before entering new directory")
end
else
-- Close floating window before opening a file
if vim.w.is_oil_win then
M.close()
end
end
-- Normalize the url before opening to prevent needing to rename them inside the BufReadCmd
-- Renaming buffers during opening can lead to missed autocmds
util.get_edit_path(oil_bufnr, entry, function(normalized_url)
local mods = {
vertical = opts.vertical,
horizontal = opts.horizontal,
split = opts.split,
keepalt = false,
}
local filebufnr = vim.fn.bufadd(normalized_url)
local entry_is_file = not vim.endswith(normalized_url, "/")
-- The :buffer command doesn't set buflisted=true
-- So do that for normal files or for oil dirs if config set buflisted=true
if entry_is_file or config.buf_options.buflisted then
vim.bo[filebufnr].buflisted = true
end
local cmd = "buffer"
if opts.tab then
vim.cmd.tabnew({ mods = mods })
-- Make sure the new buffer from tabnew gets cleaned up
vim.bo.bufhidden = "wipe"
elseif opts.split then
cmd = "sbuffer"
end
if opts.handle_buffer_callback ~= nil then
opts.handle_buffer_callback(filebufnr)
else
---@diagnostic disable-next-line: param-type-mismatch
local ok, err = pcall(vim.cmd, {
cmd = cmd,
args = { filebufnr },
mods = mods,
})
-- Ignore swapfile errors
if not ok and err and not err:match("^Vim:E325:") then
vim.api.nvim_echo({ { err, "Error" } }, true, {})
end
end
open_next_entry(cb)
end)
end
open_next_entry(function(err)
if err then
return finish(err)
end
if
opts.close
and vim.api.nvim_win_is_valid(prev_win)
and prev_win ~= vim.api.nvim_get_current_win()
then
vim.api.nvim_win_call(prev_win, function()
M.close()
end)
end
update_preview_window()
finish()
end)
end
---@param bufnr integer
---@return boolean
local function maybe_hijack_directory_buffer(bufnr)
local config = require("oil.config")
local fs = require("oil.fs")
local util = require("oil.util")
if not config.default_file_explorer then
return false
end
local bufname = vim.api.nvim_buf_get_name(bufnr)
if bufname == "" then
return false
end
if util.parse_url(bufname) or vim.fn.isdirectory(bufname) == 0 then
return false
end
local new_name = util.addslash(
config.adapter_to_scheme.files .. fs.os_to_posix_path(vim.fn.fnamemodify(bufname, ":p"))
)
local replaced = util.rename_buffer(bufnr, new_name)
return not replaced
end
---@private
M._get_highlights = function()
return {
{
name = "OilEmpty",
link = "Comment",
desc = "Empty column values",
},
{
name = "OilHidden",
link = "Comment",
desc = "Hidden entry in an oil buffer",
},
{
name = "OilDir",
link = "Directory",
desc = "Directory names in an oil buffer",
},
{
name = "OilDirHidden",
link = "OilHidden",
desc = "Hidden directory names in an oil buffer",
},
{
name = "OilDirIcon",
link = "OilDir",
desc = "Icon for directories",
},
{
name = "OilSocket",
link = "Keyword",
desc = "Socket files in an oil buffer",
},
{
name = "OilSocketHidden",
link = "OilHidden",
desc = "Hidden socket files in an oil buffer",
},
{
name = "OilLink",
link = nil,
desc = "Soft links in an oil buffer",
},
{
name = "OilOrphanLink",
link = nil,
desc = "Orphaned soft links in an oil buffer",
},
{
name = "OilLinkHidden",
link = "OilHidden",
desc = "Hidden soft links in an oil buffer",
},
{
name = "OilOrphanLinkHidden",
link = "OilLinkHidden",
desc = "Hidden orphaned soft links in an oil buffer",
},
{
name = "OilLinkTarget",
link = "Comment",
desc = "The target of a soft link",
},
{
name = "OilOrphanLinkTarget",
link = "DiagnosticError",
desc = "The target of an orphaned soft link",
},
{
name = "OilLinkTargetHidden",
link = "OilHidden",
desc = "The target of a hidden soft link",
},
{
name = "OilOrphanLinkTargetHidden",
link = "OilOrphanLinkTarget",
desc = "The target of an hidden orphaned soft link",
},
{
name = "OilFile",
link = nil,
desc = "Normal files in an oil buffer",
},
{
name = "OilFileHidden",
link = "OilHidden",
desc = "Hidden normal files in an oil buffer",
},
{
name = "OilCreate",
link = "DiagnosticInfo",
desc = "Create action in the oil preview window",
},
{
name = "OilDelete",
link = "DiagnosticError",
desc = "Delete action in the oil preview window",
},
{
name = "OilMove",
link = "DiagnosticWarn",
desc = "Move action in the oil preview window",
},
{
name = "OilCopy",
link = "DiagnosticHint",
desc = "Copy action in the oil preview window",
},
{
name = "OilChange",
link = "Special",
desc = "Change action in the oil preview window",
},
{
name = "OilRestore",
link = "OilCreate",
desc = "Restore (from the trash) action in the oil preview window",
},
{
name = "OilPurge",
link = "OilDelete",
desc = "Purge (Permanently delete a file from trash) action in the oil preview window",
},
{
name = "OilTrash",
link = "OilDelete",
desc = "Trash (delete a file to trash) action in the oil preview window",
},
{
name = "OilTrashSourcePath",
link = "Comment",
desc = "Virtual text that shows the original path of file in the trash",
},
}
end
local function set_colors()
for _, conf in ipairs(M._get_highlights()) do
if conf.link then
vim.api.nvim_set_hl(0, conf.name, { default = true, link = conf.link })
end
end
-- TODO can remove this call once we drop support for Neovim 0.8. FloatTitle was introduced as a
-- built-in highlight group in 0.9, and we can start to rely on colorschemes setting it.
---@diagnostic disable-next-line: deprecated
if vim.fn.has("nvim-0.9") == 0 and not pcall(vim.api.nvim_get_hl_by_name, "FloatTitle", true) then
---@diagnostic disable-next-line: deprecated
local border = vim.api.nvim_get_hl_by_name("FloatBorder", true)
---@diagnostic disable-next-line: deprecated
local normal = vim.api.nvim_get_hl_by_name("Normal", true)
vim.api.nvim_set_hl(
0,
"FloatTitle",
{ fg = normal.foreground, bg = border.background or normal.background }
)
end
end
---Save all changes
---@param opts nil|table
--- confirm nil|boolean Show confirmation when true, never when false, respect skip_confirm_for_simple_edits if nil
---@param cb? fun(err: nil|string) Called when mutations complete.
---@note
--- If you provide your own callback function, there will be no notification for errors.
M.save = function(opts, cb)
opts = opts or {}
if not cb then
cb = function(err)
if err and err ~= "Canceled" then
vim.notify(err, vim.log.levels.ERROR)
end
end
end
local mutator = require("oil.mutator")
mutator.try_write_changes(opts.confirm, cb)
end
local function restore_alt_buf()
if vim.bo.filetype == "oil" then
require("oil.view").set_win_options()
vim.api.nvim_win_set_var(0, "oil_did_enter", true)
elseif vim.w.oil_did_enter then
vim.api.nvim_win_del_var(0, "oil_did_enter")
-- We are entering a non-oil buffer *after* having been in an oil buffer
local has_orig, orig_buffer = pcall(vim.api.nvim_win_get_var, 0, "oil_original_buffer")
if has_orig and vim.api.nvim_buf_is_valid(orig_buffer) then
if vim.api.nvim_get_current_buf() ~= orig_buffer then
-- If we are editing a new file after navigating around oil, set the alternate buffer
-- to be the last buffer we were in before opening oil
vim.fn.setreg("#", orig_buffer)
else
-- If we are editing the same buffer that we started oil from, set the alternate to be
-- what it was before we opened oil
local has_orig_alt, alt_buffer =
pcall(vim.api.nvim_win_get_var, 0, "oil_original_alternate")
if has_orig_alt and vim.api.nvim_buf_is_valid(alt_buffer) then
vim.fn.setreg("#", alt_buffer)
end
end
end
end
end
---@private
---@param bufnr integer
M.load_oil_buffer = function(bufnr)
local config = require("oil.config")
local keymap_util = require("oil.keymap_util")
local loading = require("oil.loading")
local util = require("oil.util")
local view = require("oil.view")
local bufname = vim.api.nvim_buf_get_name(bufnr)
local scheme, path = util.parse_url(bufname)
if config.adapter_aliases[scheme] then
scheme = config.adapter_aliases[scheme]
bufname = scheme .. path
util.rename_buffer(bufnr, bufname)
end
-- Early return if we're already loading or have already loaded this buffer
if loading.is_loading(bufnr) or vim.b[bufnr].filetype ~= nil then
return
end
local adapter = assert(config.get_adapter_by_scheme(scheme))
if vim.endswith(bufname, "/") then
-- This is a small quality-of-life thing. If the buffer name ends with a `/`, we know it's a
-- directory, and can set the filetype early. This is helpful for adapters with a lot of latency
-- (e.g. ssh) because it will set up the filetype keybinds at the *beginning* of the loading
-- process.
vim.bo[bufnr].filetype = "oil"
keymap_util.set_keymaps(config.keymaps, bufnr)
end
loading.set_loading(bufnr, true)
local winid = vim.api.nvim_get_current_win()
local function finish(new_url)
-- If the buffer was deleted while we were normalizing the name, early return
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
-- Since this was async, we may have left the window with this buffer. People often write
-- BufReadPre/Post autocmds with the expectation that the current window is the one that
-- contains the buffer. Let's then do our best to make sure that that assumption isn't violated.
winid = util.buf_get_win(bufnr, winid) or vim.api.nvim_get_current_win()
vim.api.nvim_win_call(winid, function()
if new_url ~= bufname then
if util.rename_buffer(bufnr, new_url) then
-- If the buffer was replaced then don't initialize it. It's dead. The replacement will
-- have BufReadCmd called for it
return
end
-- If the renamed buffer doesn't have a scheme anymore, this is a normal file.
-- Finish setting it up as a normal buffer.
local new_scheme = util.parse_url(new_url)
if not new_scheme then
loading.set_loading(bufnr, false)
vim.cmd.doautocmd({ args = { "BufReadPre", new_url }, mods = { emsg_silent = true } })
vim.cmd.doautocmd({ args = { "BufReadPost", new_url }, mods = { emsg_silent = true } })
return
end
bufname = new_url
end
if vim.endswith(bufname, "/") then
vim.cmd.doautocmd({ args = { "BufReadPre", bufname }, mods = { emsg_silent = true } })
view.initialize(bufnr)
vim.cmd.doautocmd({ args = { "BufReadPost", bufname }, mods = { emsg_silent = true } })
else
vim.bo[bufnr].buftype = "acwrite"
adapter.read_file(bufnr)
end
restore_alt_buf()
end)
end
adapter.normalize_url(bufname, finish)
end
local function close_preview_window_if_not_in_oil()
local util = require("oil.util")
local preview_win_id = util.get_preview_win()
if not preview_win_id or not vim.w[preview_win_id].oil_entry_id then
return
end
local oil_source_win = vim.w[preview_win_id].oil_source_win
if oil_source_win and vim.api.nvim_win_is_valid(oil_source_win) then
local src_buf = vim.api.nvim_win_get_buf(oil_source_win)
if util.is_oil_bufnr(src_buf) then
return
end
end
-- This can fail if it's the last window open
pcall(vim.api.nvim_win_close, preview_win_id, true)
end
local _on_key_ns = 0
---Initialize oil
---@param opts oil.setupOpts|nil
M.setup = function(opts)
local Ringbuf = require("oil.ringbuf")
local config = require("oil.config")
config.setup(opts)
set_colors()
local callback = function(args)
local util = require("oil.util")
if args.smods.tab > 0 then
vim.cmd.tabnew()
end
local float = false
local trash = false
local preview = false
local i = 1
while i <= #args.fargs do
local v = args.fargs[i]
if v == "--float" then
float = true
table.remove(args.fargs, i)
elseif v == "--trash" then
trash = true
table.remove(args.fargs, i)
elseif v == "--preview" then
-- In the future we may want to support specifying options for the preview window (e.g.
-- vertical/horizontal), but if you want that level of control maybe just use the API
preview = true
table.remove(args.fargs, i)
elseif v == "--progress" then
local mutator = require("oil.mutator")
if mutator.is_mutating() then
mutator.show_progress()
else
vim.notify("No mutation in progress", vim.log.levels.WARN)
end
return
else
i = i + 1
end
end
if not float and (args.smods.vertical or args.smods.horizontal or args.smods.split ~= "") then
local range = args.count > 0 and { args.count } or nil
local cmdargs = { mods = { split = args.smods.split }, range = range }
if args.smods.vertical then
vim.cmd.vsplit(cmdargs)
else
vim.cmd.split(cmdargs)
end
end
local method = float and "open_float" or "open"
local path = args.fargs[1]
local open_opts = {}
if trash then
local url = M.get_url_for_path(path, false)
local _, new_path = util.parse_url(url)
path = "oil-trash://" .. new_path
end
if preview then
open_opts.preview = {}
end
M[method](path, open_opts)
end
vim.api.nvim_create_user_command(
"Oil",
callback,
{ desc = "Open oil file browser on a directory", nargs = "*", complete = "dir", count = true }
)
local aug = vim.api.nvim_create_augroup("Oil", {})
if config.default_file_explorer then
vim.g.loaded_netrw = 1
vim.g.loaded_netrwPlugin = 1
-- If netrw was already loaded, clear this augroup
if vim.fn.exists("#FileExplorer") then
vim.api.nvim_create_augroup("FileExplorer", { clear = true })
end
end
local patterns = {}
local filetype_patterns = {}
for scheme in pairs(config.adapters) do
table.insert(patterns, scheme .. "*")
filetype_patterns[scheme .. ".*"] = { "oil", { priority = 10 } }
end
for scheme in pairs(config.adapter_aliases) do
table.insert(patterns, scheme .. "*")
filetype_patterns[scheme .. ".*"] = { "oil", { priority = 10 } }
end
local scheme_pattern = table.concat(patterns, ",")
-- We need to add these patterns to the filetype matcher so the filetype doesn't get overridden
-- by other patterns. See https://github.com/stevearc/oil.nvim/issues/47
vim.filetype.add({
pattern = filetype_patterns,
})
local keybuf = Ringbuf.new(7)
if _on_key_ns == 0 then
_on_key_ns = vim.on_key(function(char)
keybuf:push(char)
end, _on_key_ns)
end
vim.api.nvim_create_autocmd("ColorScheme", {
desc = "Set default oil highlights",
group = aug,
pattern = "*",
callback = set_colors,
})
vim.api.nvim_create_autocmd("BufReadCmd", {
group = aug,
pattern = scheme_pattern,
nested = true,
callback = function(params)
M.load_oil_buffer(params.buf)
end,
})
vim.api.nvim_create_autocmd("BufWriteCmd", {
group = aug,
pattern = scheme_pattern,
nested = true,
callback = function(params)
local last_keys = keybuf:as_str()
local winid = vim.api.nvim_get_current_win()
-- If the user issued a :wq or similar, we should quit after saving
local quit_after_save = vim.endswith(last_keys, ":wq\r")
or vim.endswith(last_keys, ":x\r")
or vim.endswith(last_keys, "ZZ")
local quit_all = vim.endswith(last_keys, ":wqa\r")
or vim.endswith(last_keys, ":wqal\r")
or vim.endswith(last_keys, ":wqall\r")
local bufname = vim.api.nvim_buf_get_name(params.buf)
if vim.endswith(bufname, "/") then
vim.cmd.doautocmd({ args = { "BufWritePre", params.file }, mods = { silent = true } })
M.save(nil, function(err)
if err then
if err ~= "Canceled" then
vim.notify(err, vim.log.levels.ERROR)
end
elseif winid == vim.api.nvim_get_current_win() then
if quit_after_save then
vim.cmd.quit()
elseif quit_all then
vim.cmd.quitall()
end
end
end)
vim.cmd.doautocmd({ args = { "BufWritePost", params.file }, mods = { silent = true } })
else
local adapter = assert(config.get_adapter_by_scheme(bufname))
adapter.write_file(params.buf)
end
end,
})
vim.api.nvim_create_autocmd("BufLeave", {
desc = "Save alternate buffer for later",
group = aug,
pattern = "*",
callback = function()
local util = require("oil.util")
if not util.is_oil_bufnr(0) then
vim.w.oil_original_buffer = vim.api.nvim_get_current_buf()
vim.w.oil_original_view = vim.fn.winsaveview()
---@diagnostic disable-next-line: param-type-mismatch
vim.w.oil_original_alternate = vim.fn.bufnr("#")
end
end,
})
vim.api.nvim_create_autocmd("BufEnter", {
desc = "Set/unset oil window options and restore alternate buffer",
group = aug,
pattern = "*",
callback = function()
local util = require("oil.util")
local bufname = vim.api.nvim_buf_get_name(0)
local scheme = util.parse_url(bufname)
local is_oil_buf = scheme and config.adapters[scheme]
-- We want to filter out oil buffers that are not directories (i.e. ssh files)
local is_oil_dir_or_unknown = (vim.bo.filetype == "oil" or vim.bo.filetype == "")
if is_oil_buf and is_oil_dir_or_unknown then
local view = require("oil.view")
view.maybe_set_cursor()
-- While we are in an oil buffer, set the alternate file to the buffer we were in prior to
-- opening oil
local has_orig, orig_buffer = pcall(vim.api.nvim_win_get_var, 0, "oil_original_buffer")
if has_orig and vim.api.nvim_buf_is_valid(orig_buffer) then
vim.fn.setreg("#", orig_buffer)
end
view.set_win_options()
vim.w.oil_did_enter = true
elseif vim.fn.isdirectory(bufname) == 0 then
-- Only run this logic if we are *not* in an oil buffer (and it's not a directory, which
-- will be replaced by an oil:// url)
-- Oil buffers have to run it in BufReadCmd after confirming they are a directory or a file
restore_alt_buf()
end
close_preview_window_if_not_in_oil()
end,
})
vim.api.nvim_create_autocmd({ "BufWinEnter", "WinNew", "WinEnter" }, {
desc = "Reset bufhidden when entering a preview buffer",
group = aug,
pattern = "*",
callback = function()
-- If we have entered a "preview" buffer in a non-preview window, reset bufhidden
if vim.b.oil_preview_buffer and not vim.wo.previewwindow then
vim.bo.bufhidden = vim.api.nvim_get_option_value("bufhidden", { scope = "global" })
vim.b.oil_preview_buffer = nil
end
end,
})
if not config.silence_scp_warning then
vim.api.nvim_create_autocmd("BufNew", {
desc = "Warn about scp:// usage",
group = aug,
pattern = "scp://*",
once = true,
callback = function()
vim.notify(
"If you are trying to browse using Oil, use oil-ssh:// instead of scp://\nSet `silence_scp_warning = true` in oil.setup() to disable this message.\nSee https://github.com/stevearc/oil.nvim/issues/27 for more information.",
vim.log.levels.WARN
)
end,
})
end
vim.api.nvim_create_autocmd("WinNew", {
desc = "Restore window options when splitting an oil window",
group = aug,
pattern = "*",
nested = true,
callback = function(params)
local util = require("oil.util")
if not util.is_oil_bufnr(params.buf) or vim.w.oil_did_enter then
return
end
-- This new window is a split off of an oil window. We need to transfer the window
-- variables. First, locate the parent window
local parent_win
-- First search windows in this tab, then search all windows
local winids = vim.list_extend(vim.api.nvim_tabpage_list_wins(0), vim.api.nvim_list_wins())
for _, winid in ipairs(winids) do
if vim.api.nvim_win_is_valid(winid) then
if vim.w[winid].oil_did_enter then
parent_win = winid
break
end
end
end
if not parent_win then
vim.notify(
"Oil split could not find parent window. Please try to replicate whatever you just did and report a bug on github",
vim.log.levels.WARN
)
return
end
-- Then transfer over the relevant window vars
vim.w.oil_did_enter = true
vim.w.oil_original_buffer = vim.w[parent_win].oil_original_buffer
vim.w.oil_original_view = vim.w[parent_win].oil_original_view
vim.w.oil_original_alternate = vim.w[parent_win].oil_original_alternate
end,
})
-- mksession doesn't save oil buffers in a useful way. We have to manually load them after a
-- session finishes loading. See https://github.com/stevearc/oil.nvim/issues/29
vim.api.nvim_create_autocmd("SessionLoadPost", {
desc = "Load oil buffers after a session is loaded",
group = aug,
pattern = "*",
callback = function(params)
if vim.g.SessionLoad ~= 1 then
return
end
local util = require("oil.util")
local scheme = util.parse_url(params.file)
if config.adapters[scheme] and vim.api.nvim_buf_line_count(params.buf) == 1 then
M.load_oil_buffer(params.buf)
end
end,
})
if config.default_file_explorer then
vim.api.nvim_create_autocmd("BufAdd", {
desc = "Detect directory buffer and open oil file browser",
group = aug,
pattern = "*",
nested = true,
callback = function(params)
maybe_hijack_directory_buffer(params.buf)
end,
})
local bufnr = vim.api.nvim_get_current_buf()
if maybe_hijack_directory_buffer(bufnr) and vim.v.vim_did_enter == 1 then
M.load_oil_buffer(bufnr)
end
end
end
return M