feat: trash support for linux and mac (#165)

* wip: skeleton code for trash adapter

* refactor: split trash implementation for mac and linux

* fix: ensure we create the .Trash/$uid dir

* feat: code complete linux trash implementation

* doc: write up trash features

* feat: code complete mac trash implementation

* cleanup: remove previous, terrible, undocumented trash feature

* fix: always disabled trash

* feat: show original path of trashed files

* doc: add a note about calling actions directly

* fix: bugs in trash implementation

* fix: schedule_wrap in mac trash

* doc: fix typo and line wrapping

* fix: parsing of arguments to :Oil command

* doc: small documentation tweaks

* doc: fix awkward wording in the toggle_trash action

* fix: warning on Windows when delete_to_trash = true

* feat: :Oil --trash can open specific trash directories

* fix: show all trash files in device root

* fix: trash mtime should be sortable

* fix: shorten_path handles optional trailing slash

* refactor: overhaul the UI

* fix: keep trash original path vtext from stacking

* refactor: replace disable_changes with an error filter

* fix: shorten path names in home directory relative to root

* doc: small README format changes

* cleanup: remove unnecessary preserve_undo logic

* test: add a functional test for the freedesktop trash adapter

* test: more functional tests for trash

* fix: schedule a callback to avoid main loop error

* refactor: clean up mutator logic

* doc: some comments and type annotations
This commit is contained in:
Steven Arcangeli 2023-11-05 12:40:58 -08:00 committed by GitHub
parent d8f0d91b10
commit 6175bd6462
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1580 additions and 229 deletions

View file

@ -1,6 +1,9 @@
local oil = require("oil")
local util = require("oil.util")
-- TODO remove after https://github.com/folke/neodev.nvim/pull/163 lands
---@diagnostic disable: inject-field
local M = {}
M.show_help = {
@ -302,6 +305,35 @@ M.change_sort = {
end,
}
M.toggle_trash = {
desc = "Jump to and from the trash for the current directory",
callback = function()
local fs = require("oil.fs")
local bufname = vim.api.nvim_buf_get_name(0)
local scheme, path = util.parse_url(bufname)
local bufnr = vim.api.nvim_get_current_buf()
local url
if scheme == "oil://" then
url = "oil-trash://" .. path
elseif scheme == "oil-trash://" then
url = "oil://" .. path
-- The non-linux trash implementations don't support per-directory trash,
-- so jump back to the stored source buffer.
if not fs.is_linux then
local src_bufnr = vim.b.oil_trash_toggle_src
if src_bufnr and vim.api.nvim_buf_is_valid(src_bufnr) then
url = vim.api.nvim_buf_get_name(src_bufnr)
end
end
else
vim.notify("No trash found for buffer", vim.log.levels.WARN)
return
end
vim.cmd.edit({ args = { url } })
vim.b.oil_trash_toggle_src = bufnr
end,
}
---List actions for documentation generation
---@private
M._get_actions = function()

View file

@ -7,6 +7,7 @@ local permissions = require("oil.adapters.files.permissions")
local trash = require("oil.adapters.files.trash")
local util = require("oil.util")
local uv = vim.uv or vim.loop
local M = {}
local FIELD_NAME = constants.FIELD_NAME
@ -147,7 +148,11 @@ if not fs.is_windows then
}
end
local current_year = vim.fn.strftime("%Y")
local current_year
-- Make sure we run this import-time effect in the main loop (mostly for tests)
vim.schedule(function()
current_year = vim.fn.strftime("%Y")
end)
for _, time_key in ipairs({ "ctime", "mtime", "atime", "birthtime" }) do
file_columns[time_key] = {
@ -436,7 +441,12 @@ M.render_action = function(action)
elseif action.type == "delete" then
local _, path = util.parse_url(action.url)
assert(path)
return string.format("DELETE %s", M.to_short_os_path(path, action.entry_type))
local short_path = M.to_short_os_path(path, action.entry_type)
if config.delete_to_trash then
return string.format(" TRASH %s", short_path)
else
return string.format("DELETE %s", short_path)
end
elseif action.type == "move" or action.type == "copy" then
local dest_adapter = config.get_adapter_by_scheme(action.dest_url)
if dest_adapter == M then
@ -451,7 +461,7 @@ M.render_action = function(action)
M.to_short_os_path(dest_path, action.entry_type)
)
else
-- We should never hit this because we don't implement supported_adapters_for_copy
-- We should never hit this because we don't implement supported_cross_adapter_actions
error("files adapter doesn't support cross-adapter move/copy")
end
else
@ -494,7 +504,15 @@ M.perform_action = function(action, cb)
assert(path)
path = fs.posix_to_os_path(path)
if config.delete_to_trash then
trash.recursive_delete(path, cb)
if config.trash_command then
vim.notify_once(
"Oil now has native support for trash. Remove the `trash_command` from your config to try it out!",
vim.log.levels.WARN
)
trash.recursive_delete(path, cb)
else
require("oil.adapters.trash").delete_to_trash(path, cb)
end
else
fs.recursive_delete(action.entry_type, path, cb)
end
@ -507,9 +525,9 @@ M.perform_action = function(action, cb)
assert(dest_path)
src_path = fs.posix_to_os_path(src_path)
dest_path = fs.posix_to_os_path(dest_path)
fs.recursive_move(action.entry_type, src_path, dest_path, vim.schedule_wrap(cb))
fs.recursive_move(action.entry_type, src_path, dest_path, cb)
else
-- We should never hit this because we don't implement supported_adapters_for_copy
-- We should never hit this because we don't implement supported_cross_adapter_actions
cb("files adapter doesn't support cross-adapter move")
end
elseif action.type == "copy" then
@ -523,7 +541,7 @@ M.perform_action = function(action, cb)
dest_path = fs.posix_to_os_path(dest_path)
fs.recursive_copy(action.entry_type, src_path, dest_path, cb)
else
-- We should never hit this because we don't implement supported_adapters_for_copy
-- We should never hit this because we don't implement supported_cross_adapter_actions
cb("files adapter doesn't support cross-adapter copy")
end
else

View file

@ -348,7 +348,7 @@ M.perform_action = function(action, cb)
end
end
M.supported_adapters_for_copy = { files = true }
M.supported_cross_adapter_actions = { files = "copy" }
---@param bufnr integer
M.read_file = function(bufnr)

View file

@ -0,0 +1,9 @@
local fs = require("oil.fs")
if fs.is_mac then
return require("oil.adapters.trash.mac")
elseif fs.is_windows then
error("Trash is not implemented yet on Windows")
else
return require("oil.adapters.trash.freedesktop")
end

View file

@ -0,0 +1,630 @@
-- Based on the FreeDesktop.org trash specification
-- https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html
local cache = require("oil.cache")
local config = require("oil.config")
local constants = require("oil.constants")
local files = require("oil.adapters.files")
local fs = require("oil.fs")
local util = require("oil.util")
local uv = vim.uv or vim.loop
local FIELD_META = constants.FIELD_META
local M = {}
local function touch_dir(path)
uv.fs_mkdir(path, 448) -- 0700
end
local function ensure_trash_dir(path)
touch_dir(path)
touch_dir(fs.join(path, "info"))
touch_dir(fs.join(path, "files"))
end
---Gets the location of the home trash dir, creating it if necessary
---@return string
local function get_home_trash_dir()
local xdg_home = vim.env.XDG_DATA_HOME
if not xdg_home then
xdg_home = fs.join(assert(uv.os_homedir()), ".local", "share")
end
local trash_dir = fs.join(xdg_home, "Trash")
ensure_trash_dir(trash_dir)
return trash_dir
end
---@param mode integer
---@return boolean
local function is_sticky(mode)
local extra = bit.rshift(mode, 9)
return bit.band(extra, 4) ~= 0
end
---Get the topdir .Trash/$uid directory if present and valid
---@param path string
---@return string[]
local function get_top_trash_dirs(path)
local dirs = {}
local dev = (uv.fs_stat(path) or {}).dev
local top_trash_dirs = vim.fs.find(".Trash", { upward = true, path = path, limit = math.huge })
for _, top_trash_dir in ipairs(top_trash_dirs) do
local stat = uv.fs_stat(top_trash_dir)
if stat and not dev then
dev = stat.dev
end
if stat and stat.dev == dev and stat.type == "directory" and is_sticky(stat.mode) then
local trash_dir = fs.join(top_trash_dir, tostring(uv.getuid()))
ensure_trash_dir(trash_dir)
table.insert(dirs, trash_dir)
end
end
-- Also search for the .Trash-$uid
top_trash_dirs = vim.fs.find(
string.format(".Trash-%d", uv.getuid()),
{ upward = true, path = path, limit = math.huge }
)
for _, top_trash_dir in ipairs(top_trash_dirs) do
local stat = uv.fs_stat(top_trash_dir)
if stat and stat.dev == dev then
ensure_trash_dir(top_trash_dir)
table.insert(dirs, top_trash_dir)
end
end
return dirs
end
---@param path string
---@return string
local function get_write_trash_dir(path)
local dev = uv.fs_stat(path).dev
local home_trash = get_home_trash_dir()
if uv.fs_stat(home_trash).dev == dev then
return home_trash
end
local top_trash_dirs = get_top_trash_dirs(path)
if not vim.tbl_isempty(top_trash_dirs) then
return top_trash_dirs[1]
end
local parent = vim.fn.fnamemodify(path, ":h")
local next_parent = vim.fn.fnamemodify(parent, ":h")
while parent ~= next_parent and uv.fs_stat(next_parent).dev == dev do
parent = next_parent
next_parent = vim.fn.fnamemodify(parent, ":h")
end
local top_trash = fs.join(parent, string.format(".Trash-%d", uv.getuid()))
ensure_trash_dir(top_trash)
return top_trash
end
---@param path string
---@return string[]
local function get_read_trash_dirs(path)
local dirs = { get_home_trash_dir() }
vim.list_extend(dirs, get_top_trash_dirs(path))
return dirs
end
---@param url string
---@param callback fun(url: string)
M.normalize_url = function(url, callback)
local scheme, path = util.parse_url(url)
assert(path)
local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ":p")
uv.fs_realpath(
os_path,
vim.schedule_wrap(function(err, new_os_path)
local realpath = new_os_path or os_path
callback(scheme .. util.addslash(fs.os_to_posix_path(realpath)))
end)
)
end
---@param url string
---@param entry oil.Entry
---@param cb fun(path: string)
M.get_entry_path = function(url, entry, cb)
local internal_entry = assert(cache.get_entry_by_id(entry.id))
local meta = internal_entry[FIELD_META]
---@type oil.TrashInfo
local trash_info = meta.trash_info
if not trash_info then
-- This is a subpath in the trash
M.normalize_url(url, cb)
return
end
local path = fs.os_to_posix_path(trash_info.trash_file)
if meta.stat.type == "directory" then
path = util.addslash(path)
end
cb("oil://" .. path)
end
---@class oil.TrashInfo
---@field trash_file string
---@field info_file string
---@field original_path string
---@field deletion_date number
---@field stat uv_fs_t
---@param info_file string
---@param cb fun(err?: string, info?: oil.TrashInfo)
local function read_trash_info(info_file, cb)
if not vim.endswith(info_file, ".trashinfo") then
return cb("File is not .trashinfo")
end
uv.fs_open(info_file, "r", 448, function(err, fd)
if err then
return cb(err)
end
assert(fd)
uv.fs_fstat(fd, function(stat_err, stat)
if stat_err then
uv.fs_close(fd)
return cb(stat_err)
end
uv.fs_read(
fd,
assert(stat).size,
nil,
vim.schedule_wrap(function(read_err, content)
uv.fs_close(fd)
if read_err then
return cb(read_err)
end
assert(content)
local trash_info = {
info_file = info_file,
}
local lines = vim.split(content, "\r?\n")
if lines[1] ~= "[Trash Info]" then
return cb("File missing [Trash Info] header")
end
local trash_base = vim.fn.fnamemodify(info_file, ":h:h")
for _, line in ipairs(lines) do
local key, value = unpack(vim.split(line, "=", { plain = true, trimempty = true }))
if key == "Path" and not trash_info.original_path then
if not vim.startswith(value, "/") then
value = fs.join(trash_base, value)
end
trash_info.original_path = value
elseif key == "DeletionDate" and not trash_info.deletion_date then
trash_info.deletion_date = vim.fn.strptime("%Y-%m-%dT%H:%M:%S", value)
end
end
if not trash_info.original_path or not trash_info.deletion_date then
return cb("File missing required fields")
end
local basename = vim.fn.fnamemodify(info_file, ":t:r")
trash_info.trash_file = fs.join(trash_base, "files", basename)
uv.fs_stat(trash_info.trash_file, function(trash_stat_err, trash_stat)
if trash_stat_err then
cb(".trashinfo file points to non-existant file")
else
trash_info.stat = trash_stat
cb(nil, trash_info)
end
end)
end)
)
end)
end)
end
---@param url string
---@param column_defs string[]
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
M.list = function(url, column_defs, cb)
cb = vim.schedule_wrap(cb)
local _, path = util.parse_url(url)
assert(path)
local trash_dirs = get_read_trash_dirs(path)
local trash_idx = 0
local read_next_trash_dir
read_next_trash_dir = function()
trash_idx = trash_idx + 1
local trash_dir = trash_dirs[trash_idx]
if not trash_dir then
return cb()
end
-- Show all files from the trash directory if we are in the root of the device, which we can
-- tell if the trash dir is a subpath of our current path
local show_all_files = fs.is_subpath(path, trash_dir)
-- The first trash dir is a special case; it is in the home directory and we should only show
-- all entries if we are in the top root path "/"
if trash_idx == 1 then
show_all_files = path == "/"
end
local info_dir = fs.join(trash_dir, "info")
---@diagnostic disable-next-line: param-type-mismatch
uv.fs_opendir(info_dir, function(open_err, fd)
if open_err then
if open_err:match("^ENOENT: no such file or directory") 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 read_next_trash_dir()
else
return cb(open_err)
end
end
local read_next
read_next = function()
uv.fs_readdir(fd, function(err, entries)
if err then
uv.fs_closedir(fd, function()
cb(err)
end)
return
elseif entries then
local internal_entries = {}
local poll = util.cb_collect(#entries, function(inner_err)
if inner_err then
cb(inner_err)
else
cb(nil, internal_entries, read_next)
end
end)
for _, entry in ipairs(entries) do
read_trash_info(
fs.join(info_dir, entry.name),
vim.schedule_wrap(function(read_err, info)
if read_err then
-- Discard the error. We don't care if there's something wrong with one of these
-- files.
poll()
else
local parent = util.addslash(vim.fn.fnamemodify(info.original_path, ":h"))
if path == parent or show_all_files then
local name = vim.fn.fnamemodify(info.trash_file, ":t")
---@diagnostic disable-next-line: undefined-field
local cache_entry = cache.create_entry(url, name, info.stat.type)
local display_name = vim.fn.fnamemodify(info.original_path, ":t")
cache_entry[FIELD_META] = {
stat = info.stat,
trash_info = info,
display_name = display_name,
}
table.insert(internal_entries, cache_entry)
end
if path ~= parent and (show_all_files or fs.is_subpath(path, parent)) then
local name = parent:sub(path:len() + 1)
local next_par = vim.fs.dirname(name)
while next_par ~= "." do
name = next_par
next_par = vim.fs.dirname(name)
end
---@diagnostic disable-next-line: undefined-field
local cache_entry = cache.create_entry(url, name, "directory")
cache_entry[FIELD_META] = {
stat = info.stat,
}
table.insert(internal_entries, cache_entry)
end
poll()
end
end)
)
end
else
uv.fs_closedir(fd, function(close_err)
if close_err then
cb(close_err)
else
vim.schedule(read_next_trash_dir)
end
end)
end
end)
end
read_next()
---@diagnostic disable-next-line: param-type-mismatch
end, 10000)
end
read_next_trash_dir()
end
---@param bufnr integer
---@return boolean
M.is_modifiable = function(bufnr)
return true
end
local file_columns = {}
local current_year
-- Make sure we run this import-time effect in the main loop (mostly for tests)
vim.schedule(function()
current_year = vim.fn.strftime("%Y")
end)
file_columns.mtime = {
render = function(entry, conf)
local meta = entry[FIELD_META]
---@type oil.TrashInfo
local trash_info = meta.trash_info
local time = trash_info and trash_info.deletion_date or meta.stat and meta.stat.mtime.sec
if not time then
return nil
end
local fmt = conf and conf.format
local ret
if fmt then
ret = vim.fn.strftime(fmt, time)
else
local year = vim.fn.strftime("%Y", time)
if year ~= current_year then
ret = vim.fn.strftime("%b %d %Y", time)
else
ret = vim.fn.strftime("%b %d %H:%M", time)
end
end
return ret
end,
get_sort_value = function(entry)
local meta = entry[FIELD_META]
---@type nil|oil.TrashInfo
local trash_info = meta.trash_info
if trash_info then
return trash_info.deletion_date
else
return 0
end
end,
parse = function(line, conf)
local fmt = conf and conf.format
local pattern
if fmt then
pattern = fmt:gsub("%%.", "%%S+")
else
pattern = "%S+%s+%d+%s+%d%d:?%d%d"
end
return line:match("^(" .. pattern .. ")%s+(.+)$")
end,
}
---@param name string
---@return nil|oil.ColumnDefinition
M.get_column = function(name)
return file_columns[name]
end
M.supported_cross_adapter_actions = { files = "move" }
---@param action oil.Action
---@return boolean
M.filter_action = function(action)
if action.type == "create" then
return false
elseif action.type == "delete" then
local entry = assert(cache.get_entry_by_url(action.url))
local meta = entry[FIELD_META]
return meta.trash_info ~= nil
elseif action.type == "move" then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
return src_adapter.name == "files" or dest_adapter.name == "files"
elseif action.type == "copy" then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
return src_adapter.name == "files" or dest_adapter.name == "files"
else
error(string.format("Bad action type '%s'", action.type))
end
end
---@param err oil.ParseError
---@return boolean
M.filter_error = function(err)
if err.message == "Duplicate filename" then
return false
end
return true
end
---@param action oil.Action
---@return string
M.render_action = function(action)
if action.type == "delete" then
local entry = assert(cache.get_entry_by_url(action.url))
local meta = entry[FIELD_META]
---@type oil.TrashInfo
local trash_info = meta.trash_info
local short_path = fs.shorten_path(trash_info.original_path)
return string.format(" PURGE %s", short_path)
elseif action.type == "move" then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == "files" then
local _, path = util.parse_url(action.src_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format(" TRASH %s", short_path)
elseif dest_adapter.name == "files" then
local _, path = util.parse_url(action.dest_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format("RESTORE %s", short_path)
else
error("Must be moving files into or out of trash")
end
elseif action.type == "copy" then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == "files" then
local _, path = util.parse_url(action.src_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format(" COPY %s -> TRASH", short_path)
elseif dest_adapter.name == "files" then
local _, path = util.parse_url(action.dest_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format("RESTORE %s", short_path)
else
error("Must be copying files into or out of trash")
end
else
error(string.format("Bad action type '%s'", action.type))
end
end
---@param trash_info oil.TrashInfo
---@param cb fun(err?: string)
local function purge(trash_info, cb)
fs.recursive_delete("file", trash_info.info_file, function(err)
if err then
return cb(err)
end
---@diagnostic disable-next-line: undefined-field
fs.recursive_delete(trash_info.stat.type, trash_info.trash_file, cb)
end)
end
---@param path string
---@param info_path string
---@param cb fun(err?: string)
local function write_info_file(path, info_path, cb)
uv.fs_open(
info_path,
"w",
448,
vim.schedule_wrap(function(err, fd)
if err then
return cb(err)
end
assert(fd)
local deletion_date = vim.fn.strftime("%Y-%m-%dT%H:%M:%S")
local contents = string.format("[Trash Info]\nPath=%s\nDeletionDate=%s", path, deletion_date)
uv.fs_write(fd, contents, function(write_err)
uv.fs_close(fd, function(close_err)
cb(write_err or close_err)
end)
end)
end)
)
end
---@param path string
---@param cb fun(err?: string, trash_info?: oil.TrashInfo)
local function create_trash_info(path, cb)
local trash_dir = get_write_trash_dir(path)
local basename = vim.fs.basename(path)
local now = os.time()
local name = string.format("%s-%d.%d", basename, now, math.random(100000, 999999))
local dest_path = fs.join(trash_dir, "files", name)
local dest_info = fs.join(trash_dir, "info", name .. ".trashinfo")
uv.fs_stat(path, function(err, stat)
if err then
return cb(err)
end
assert(stat)
write_info_file(path, dest_info, function(info_err)
if info_err then
return cb(info_err)
end
---@type oil.TrashInfo
local trash_info = {
original_path = path,
trash_file = dest_path,
info_file = dest_info,
deletion_date = now,
stat = stat,
}
cb(nil, trash_info)
end)
end)
end
---@param action oil.Action
---@param cb fun(err: nil|string)
M.perform_action = function(action, cb)
if action.type == "delete" then
local entry = assert(cache.get_entry_by_url(action.url))
local meta = entry[FIELD_META]
---@type oil.TrashInfo
local trash_info = meta.trash_info
purge(trash_info, cb)
elseif action.type == "move" then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == "files" then
local _, path = util.parse_url(action.src_url)
M.delete_to_trash(assert(path), cb)
elseif dest_adapter.name == "files" then
-- Restore
local _, dest_path = util.parse_url(action.dest_url)
assert(dest_path)
local entry = assert(cache.get_entry_by_url(action.src_url))
local meta = entry[FIELD_META]
---@type oil.TrashInfo
local trash_info = meta.trash_info
fs.recursive_move(action.entry_type, trash_info.trash_file, dest_path, function(err)
if err then
return cb(err)
end
uv.fs_unlink(trash_info.info_file, cb)
end)
else
error("Must be moving files into or out of trash")
end
elseif action.type == "copy" then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == "files" then
local _, path = util.parse_url(action.src_url)
assert(path)
create_trash_info(path, function(err, trash_info)
if err then
cb(err)
else
---@diagnostic disable-next-line: undefined-field
local stat_type = trash_info.stat.type
fs.recursive_copy(stat_type, path, trash_info.trash_file, vim.schedule_wrap(cb))
end
end)
elseif dest_adapter.name == "files" then
-- Restore
local _, dest_path = util.parse_url(action.dest_url)
assert(dest_path)
local entry = assert(cache.get_entry_by_url(action.src_url))
local meta = entry[FIELD_META]
---@type oil.TrashInfo
local trash_info = meta.trash_info
fs.recursive_copy(action.entry_type, trash_info.trash_file, dest_path, cb)
else
error("Must be moving files into or out of trash")
end
else
cb(string.format("Bad action type: %s", action.type))
end
end
---@param path string
---@param cb fun(err?: string)
M.delete_to_trash = function(path, cb)
create_trash_info(path, function(err, trash_info)
if err then
cb(err)
else
---@diagnostic disable-next-line: undefined-field
local stat_type = trash_info.stat.type
fs.recursive_move(stat_type, path, trash_info.trash_file, vim.schedule_wrap(cb))
end
end)
end
return M

View file

@ -0,0 +1,233 @@
local cache = require("oil.cache")
local config = require("oil.config")
local files = require("oil.adapters.files")
local fs = require("oil.fs")
local util = require("oil.util")
local uv = vim.uv or vim.loop
local M = {}
local function touch_dir(path)
uv.fs_mkdir(path, 448) -- 0700
end
---Gets the location of the home trash dir, creating it if necessary
---@return string
local function get_trash_dir()
local trash_dir = fs.join(assert(uv.os_homedir()), ".Trash")
touch_dir(trash_dir)
return trash_dir
end
---@param url string
---@param callback fun(url: string)
M.normalize_url = function(url, callback)
local scheme, path = util.parse_url(url)
assert(path)
callback(scheme .. "/")
end
---@param url string
---@param entry oil.Entry
---@param cb fun(path: string)
M.get_entry_path = function(url, entry, cb)
local trash_dir = get_trash_dir()
local path = fs.join(trash_dir, entry.name)
if entry.type == "directory" then
path = "oil://" .. path
end
cb(path)
end
---@param url string
---@param column_defs string[]
---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun())
M.list = function(url, column_defs, cb)
cb = vim.schedule_wrap(cb)
local _, path = util.parse_url(url)
assert(path)
local trash_dir = get_trash_dir()
---@diagnostic disable-next-line: param-type-mismatch
uv.fs_opendir(trash_dir, function(open_err, fd)
if open_err then
if open_err:match("^ENOENT: no such file or directory") 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 cb()
else
return cb(open_err)
end
end
local read_next
read_next = function()
uv.fs_readdir(fd, function(err, entries)
if err then
uv.fs_closedir(fd, function()
cb(err)
end)
return
elseif entries then
local internal_entries = {}
local poll = util.cb_collect(#entries, function(inner_err)
if inner_err then
cb(inner_err)
else
cb(nil, internal_entries, read_next)
end
end)
for _, entry in ipairs(entries) do
-- TODO: read .DS_Store and filter by original dir
local cache_entry = cache.create_entry(url, entry.name, entry.type)
table.insert(internal_entries, cache_entry)
poll()
end
else
uv.fs_closedir(fd, function(close_err)
if close_err then
cb(close_err)
else
cb()
end
end)
end
end)
end
read_next()
---@diagnostic disable-next-line: param-type-mismatch
end, 10000)
end
---@param bufnr integer
---@return boolean
M.is_modifiable = function(bufnr)
return true
end
---@param name string
---@return nil|oil.ColumnDefinition
M.get_column = function(name)
return nil
end
M.supported_cross_adapter_actions = { files = "move" }
---@param action oil.Action
---@return string
M.render_action = function(action)
if action.type == "create" then
return string.format("CREATE %s", action.url)
elseif action.type == "delete" then
return string.format(" PURGE %s", action.url)
elseif action.type == "move" then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if src_adapter.name == "files" then
local _, path = util.parse_url(action.src_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format(" TRASH %s", short_path)
elseif dest_adapter.name == "files" then
local _, path = util.parse_url(action.dest_url)
assert(path)
local short_path = files.to_short_os_path(path, action.entry_type)
return string.format("RESTORE %s", short_path)
else
return string.format(" %s %s -> %s", action.type:upper(), action.src_url, action.dest_url)
end
elseif action.type == "copy" then
return string.format(" %s %s -> %s", action.type:upper(), action.src_url, action.dest_url)
else
error("Bad action type")
end
end
---@param action oil.Action
---@param cb fun(err: nil|string)
M.perform_action = function(action, cb)
local trash_dir = get_trash_dir()
if action.type == "create" then
local _, path = util.parse_url(action.url)
assert(path)
path = trash_dir .. path
if action.entry_type == "directory" then
uv.fs_mkdir(path, 493, function(err)
-- Ignore if the directory already exists
if not err or err:match("^EEXIST:") then
cb()
else
cb(err)
end
end) -- 0755
elseif action.entry_type == "link" and action.link then
local flags = nil
local target = fs.posix_to_os_path(action.link)
---@diagnostic disable-next-line: param-type-mismatch
uv.fs_symlink(target, path, flags, cb)
else
fs.touch(path, cb)
end
elseif action.type == "delete" then
local _, path = util.parse_url(action.url)
assert(path)
local fullpath = trash_dir .. path
fs.recursive_delete(action.entry_type, fullpath, cb)
elseif action.type == "move" or action.type == "copy" then
local src_adapter = assert(config.get_adapter_by_scheme(action.src_url))
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
local _, src_path = util.parse_url(action.src_url)
local _, dest_path = util.parse_url(action.dest_url)
assert(src_path and dest_path)
if src_adapter.name == "files" then
dest_path = trash_dir .. dest_path
elseif dest_adapter.name == "files" then
src_path = trash_dir .. src_path
else
dest_path = trash_dir .. dest_path
src_path = trash_dir .. src_path
end
if action.type == "move" then
fs.recursive_move(action.entry_type, src_path, dest_path, cb)
else
fs.recursive_copy(action.entry_type, src_path, dest_path, cb)
end
else
cb(string.format("Bad action type: %s", action.type))
end
end
---@param path string
---@param cb fun(err?: string)
M.delete_to_trash = function(path, cb)
local basename = vim.fs.basename(path)
local trash_dir = get_trash_dir()
local dest = fs.join(trash_dir, basename)
uv.fs_stat(
path,
vim.schedule_wrap(function(stat_err, src_stat)
if stat_err then
return cb(stat_err)
end
assert(src_stat)
if uv.fs_stat(dest) then
local date_str = vim.fn.strftime(" %Y-%m-%dT%H:%M:%S")
local name_pieces = vim.split(basename, ".", { plain = true })
if #name_pieces > 1 then
table.insert(name_pieces, #name_pieces - 1, date_str)
basename = table.concat(name_pieces)
else
basename = basename .. date_str
end
dest = fs.join(trash_dir, basename)
end
local stat_type = src_stat.type
---@cast stat_type oil.EntryType
fs.recursive_move(stat_type, path, dest, vim.schedule_wrap(cb))
end)
)
end
return M

View file

@ -0,0 +1,20 @@
-- Work in progress
local M = {}
-- ---@return string
-- local function get_trash_dir()
-- -- TODO permission issues when using the recycle bin. The folder gets created without
-- -- read/write perms, so all operations fail
-- local cwd = vim.fn.getcwd()
-- local trash_dir = cwd:sub(1, 3) .. "$Recycle.Bin"
-- if vim.fn.isdirectory(trash_dir) == 1 then
-- return trash_dir
-- end
-- trash_dir = "C:\\$Recycle.Bin"
-- if vim.fn.isdirectory(trash_dir) == 1 then
-- return trash_dir
-- end
-- error("No trash found")
-- end
return M

View file

@ -4,10 +4,12 @@ local M = {}
local FIELD_ID = constants.FIELD_ID
local FIELD_NAME = constants.FIELD_NAME
local FIELD_META = constants.FIELD_META
local next_id = 1
-- Map<url, Map<entry name, oil.InternalEntry>>
---@type table<string, table<string, oil.InternalEntry>>
local url_directory = {}
---@type table<integer, oil.InternalEntry>
@ -118,6 +120,15 @@ M.get_entry_by_id = function(id)
return entries_by_id[id]
end
---@param url string
---@return nil|oil.InternalEntry
M.get_entry_by_url = function(url)
local scheme, path = util.parse_url(url)
local parent_url = scheme .. vim.fn.fnamemodify(path, ":h")
local basename = vim.fn.fnamemodify(path, ":t")
return M.list_url(parent_url)[basename]
end
---@param id integer
---@return string
M.get_parent_url = function(id)
@ -129,18 +140,12 @@ M.get_parent_url = function(id)
end
---@param url string
---@return oil.InternalEntry[]
---@return table<string, oil.InternalEntry>
M.list_url = function(url)
url = util.addslash(url)
return url_directory[url] or {}
end
M.get_entry_by_url = function(url)
local parent, name = url:match("^(.+)/([^/]+)$")
local cache = url_directory[parent]
return cache and cache[name]
end
---@param action oil.Action
M.perform_action = function(action)
if action.type == "create" then
@ -172,6 +177,8 @@ M.perform_action = function(action)
dest_parent = {}
url_directory[dest_parent_url] = dest_parent
end
-- We have to clear the metadata because it can be inaccurate after the move
entry[FIELD_META] = nil
dest_parent[dest_name] = entry
parent_url_by_id[entry[FIELD_ID]] = dest_parent_url
entry[FIELD_NAME] = dest_name

View file

@ -221,6 +221,9 @@ if has_devicons then
icon = conf and conf.directory or ""
hl = "OilDirIcon"
else
if meta and meta.display_name then
name = meta.display_name
end
icon, hl = devicons.get_icon(name)
icon = icon or (conf and conf.default_file or "")
end

View file

@ -1,3 +1,5 @@
local uv = vim.uv or vim.loop
local default_config = {
-- Oil will take over directory buffers (e.g. `vim .` or `:e src/`)
-- Set to false if you still want to use netrw.
@ -30,8 +32,6 @@ local default_config = {
delete_to_trash = false,
-- Skip the confirmation popup for simple operations
skip_confirm_for_simple_edits = false,
-- Change this to customize the command used when deleting to trash
trash_command = "trash-put",
-- Selecting a new/moved/renamed file or directory will prompt you to save changes first
prompt_save_on_select_new_entry = true,
-- Oil will automatically delete hidden buffers after this delay
@ -60,6 +60,7 @@ local default_config = {
["gs"] = "actions.change_sort",
["gx"] = "actions.open_external",
["g."] = "actions.toggle_hidden",
["g\\"] = "actions.toggle_trash",
},
-- Set to false to disable all of the above keymaps
use_default_keymaps = true,
@ -142,6 +143,7 @@ local default_config = {
default_config.adapters = {
["oil://"] = "files",
["oil-ssh://"] = "ssh",
["oil-trash://"] = "trash",
}
default_config.adapter_aliases = {}
@ -154,13 +156,10 @@ M.setup = function(opts)
end
if new_conf.delete_to_trash then
local trash_bin = vim.split(new_conf.trash_command, " ")[1]
if vim.fn.executable(trash_bin) == 0 then
local is_windows = uv.os_uname().version:match("Windows")
if is_windows then
vim.notify(
string.format(
"oil.nvim: delete_to_trash is true, but '%s' executable not found.\nDeleted files will be permanently removed.",
new_conf.trash_command
),
"oil.nvim: delete_to_trash is true, but trash is not yet supported on Windows.\nDeleted files will be permanently removed",
vim.log.levels.WARN
)
new_conf.delete_to_trash = false
@ -176,46 +175,6 @@ M.setup = function(opts)
M.adapter_to_scheme[v] = k
end
M._adapter_by_scheme = {}
if type(M.trash) == "string" then
M.trash = vim.fn.fnamemodify(vim.fn.expand(M.trash), ":p")
end
end
---@return nil|string
M.get_trash_url = function()
if not M.trash then
return nil
end
local fs = require("oil.fs")
if M.trash == true then
local data_home = os.getenv("XDG_DATA_HOME") or vim.fn.expand("~/.local/share")
local preferred = fs.join(data_home, "trash")
local candidates = {
preferred,
}
if fs.is_windows then
-- TODO permission issues when using the recycle bin. The folder gets created without
-- read/write perms, so all operations fail
-- local cwd = vim.fn.getcwd()
-- table.insert(candidates, 1, cwd:sub(1, 3) .. "$Recycle.Bin")
-- table.insert(candidates, 1, "C:\\$Recycle.Bin")
else
table.insert(candidates, fs.join(data_home, "Trash", "files"))
table.insert(candidates, fs.join(os.getenv("HOME"), ".Trash"))
end
local trash_dir = preferred
for _, candidate in ipairs(candidates) do
if vim.fn.isdirectory(candidate) == 1 then
trash_dir = candidate
break
end
end
local oil_trash_dir = vim.fn.fnamemodify(fs.join(trash_dir, "nvim", "oil"), ":p")
fs.mkdirp(oil_trash_dir)
M.trash = oil_trash_dir
end
return M.adapter_to_scheme.files .. fs.os_to_posix_path(M.trash)
end
---@param scheme nil|string

View file

@ -7,6 +7,8 @@ M.is_windows = uv.os_uname().version:match("Windows")
M.is_mac = uv.os_uname().sysname == "Darwin"
M.is_linux = not M.is_windows and not M.is_mac
---@type string
M.sep = M.is_windows and "\\" or "/"
@ -114,20 +116,31 @@ end
local home_dir = assert(uv.os_homedir())
---@param path string
---@param relative_to? string Shorten relative to this path (default cwd)
---@return string
M.shorten_path = function(path)
local cwd = vim.fn.getcwd()
if M.is_subpath(cwd, path) then
local relative = path:sub(cwd:len() + 2)
if relative == "" then
relative = "."
M.shorten_path = function(path, relative_to)
if not relative_to then
relative_to = vim.fn.getcwd()
end
local relpath
if M.is_subpath(relative_to, path) then
local idx = relative_to:len() + 1
-- Trim the dividing slash if it's not included in relative_to
if not vim.endswith(relative_to, "/") and not vim.endswith(relative_to, "\\") then
idx = idx + 1
end
relpath = path:sub(idx)
if relpath == "" then
relpath = "."
end
return relative
end
if M.is_subpath(home_dir, path) then
return "~" .. path:sub(home_dir:len() + 1)
local homepath = "~" .. path:sub(home_dir:len() + 1)
if not relpath or homepath:len() < relpath:len() then
return homepath
end
end
return path
return relpath or path
end
M.mkdirp = function(dir)
@ -177,7 +190,7 @@ M.listdir = function(dir, cb)
end
read_next()
---@diagnostic disable-next-line: param-type-mismatch
end, 100) -- TODO do some testing for this
end, 10000)
end
---@param entry_type oil.EntryType

View file

@ -8,6 +8,7 @@ local M = {}
---@alias oil.EntryType "file"|"directory"|"socket"|"link"|"fifo"
---@alias oil.TextChunk string|string[]
---@alias oil.CrossAdapterAction "copy"|"move"
---@class (exact) oil.Adapter
---@field name string The unique name of the adapter (this will be set automatically)
@ -20,7 +21,9 @@ local M = {}
---@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_adapters_for_copy? table<string, boolean> Mapping of adapter name to true for all other adapters that can be used as a src or dest for move/copy actions.
---@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
-- TODO remove after https://github.com/folke/neodev.nvim/pull/163 lands
---@diagnostic disable: undefined-field
@ -110,34 +113,6 @@ M.discard_all_changes = function()
end
end
---Delete all files in the trash directory
---@private
---@note
--- Trash functionality is incomplete and experimental.
M.empty_trash = function()
local config = require("oil.config")
local fs = require("oil.fs")
local util = require("oil.util")
local trash_url = config.get_trash_url()
if not trash_url then
vim.notify("No trash directory configured", vim.log.levels.WARN)
return
end
local _, path = util.parse_url(trash_url)
assert(path)
local dir = fs.posix_to_os_path(path)
if vim.fn.isdirectory(dir) == 1 then
fs.recursive_delete("directory", dir, function(err)
if err then
vim.notify(string.format("Error emptying trash: %s", err), vim.log.levels.ERROR)
else
vim.notify("Trash emptied")
fs.mkdirp(dir)
end
end)
end
end
---Change the display columns for oil
---@param cols oil.ColumnSpec[]
M.set_columns = function(cols)
@ -177,9 +152,13 @@ end
---Get the oil url for a given directory
---@private
---@param dir nil|string When nil, use the cwd
---@return nil|string The parent url
---@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)
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")
@ -196,15 +175,16 @@ M.get_url_for_path = function(dir)
return config.adapter_to_scheme.files .. path
else
local bufname = vim.api.nvim_buf_get_name(0)
return M.get_buffer_parent_url(bufname)
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)
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")
@ -223,13 +203,15 @@ M.get_buffer_parent_url = function(bufname)
return parent_url, basename
else
assert(path)
-- TODO maybe we should remove this special case and turn it into a config
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
if not use_oil_parent then
return bufname
end
local adapter = config.get_adapter_by_scheme(scheme)
local parent_url
if adapter and adapter.get_parent then
@ -672,7 +654,7 @@ M._get_highlights = function()
{
name = "OilDir",
link = "Directory",
desc = "Directories in an oil buffer",
desc = "Directory names in an oil buffer",
},
{
name = "OilDirIcon",
@ -689,6 +671,11 @@ M._get_highlights = function()
link = nil,
desc = "Soft links in an oil buffer",
},
{
name = "OilLinkTarget",
link = "Comment",
desc = "The target of a soft link",
},
{
name = "OilFile",
link = nil,
@ -719,6 +706,26 @@ M._get_highlights = function()
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
@ -855,14 +862,23 @@ M.setup = function(opts)
config.setup(opts)
set_colors()
vim.api.nvim_create_user_command("Oil", function(args)
local util = require("oil.util")
if args.smods.tab == 1 then
vim.cmd.tabnew()
end
local float = false
for i, v in ipairs(args.fargs) do
local trash = 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)
else
i = i + 1
end
end
@ -875,7 +891,13 @@ M.setup = function(opts)
end
local method = float and "open_float" or "open"
M[method](unpack(args.fargs))
local path = args.fargs[1]
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
M[method](path)
end, { desc = "Open oil file browser on a directory", nargs = "*", complete = "dir" })
local aug = vim.api.nvim_create_augroup("Oil", {})

View file

@ -7,7 +7,6 @@ local constants = require("oil.constants")
local lsp_helpers = require("oil.lsp_helpers")
local oil = require("oil")
local parser = require("oil.mutator.parser")
local pathutil = require("oil.pathutil")
local preview = require("oil.mutator.preview")
local util = require("oil.util")
local view = require("oil.view")
@ -54,6 +53,7 @@ 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 = {}
@ -61,6 +61,15 @@ M.create_actions_from_diffs = function(all_diffs)
return list
end,
})
---@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
table.insert(actions, action)
end
end
---@type table<integer, string>
local dest_by_id = {}
for bufnr, diffs in pairs(all_diffs) do
local adapter = util.get_adapter(bufnr)
if not adapter then
@ -71,9 +80,7 @@ M.create_actions_from_diffs = function(all_diffs)
if diff.type == "new" then
if diff.id then
local by_id = diff_by_id[diff.id]
-- FIXME this is kind of a hack. We shouldn't be setting undocumented fields on the diff
---@diagnostic disable-next-line: inject-field
diff.dest = parent_url .. diff.name
dest_by_id[diff.id] = parent_url .. diff.name
table.insert(by_id, diff)
else
-- Parse nested files like foo/bar/baz
@ -87,7 +94,7 @@ M.create_actions_from_diffs = function(all_diffs)
-- Parse alternations like foo.{js,test.js}
for _, alt in ipairs(vim.split(alternation, ",")) do
local alt_url = url .. "/" .. v:gsub("{[^}]+}", alt)
table.insert(actions, {
add_action({
type = "create",
url = alt_url,
entry_type = entry_type,
@ -96,7 +103,7 @@ M.create_actions_from_diffs = function(all_diffs)
end
else
url = url .. "/" .. v
table.insert(actions, {
add_action({
type = "create",
url = url,
entry_type = entry_type,
@ -106,7 +113,7 @@ M.create_actions_from_diffs = function(all_diffs)
end
end
elseif diff.type == "change" then
table.insert(actions, {
add_action({
type = "change",
url = parent_url .. diff.name,
entry_type = diff.entry_type,
@ -115,8 +122,9 @@ M.create_actions_from_diffs = function(all_diffs)
})
else
local by_id = diff_by_id[diff.id]
-- HACK: set has_delete field on a list-like table of diffs
by_id.has_delete = true
-- Don't insert the delete. We already know that there is a delete because of the presense
-- 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
@ -127,21 +135,23 @@ M.create_actions_from_diffs = function(all_diffs)
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
table.insert(actions, {
add_action({
type = i == #diffs and "move" or "copy",
entry_type = entry[FIELD_TYPE],
dest_url = diff.dest,
dest_url = dest_by_id[diff.id],
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
})
end
else
-- DELETE when no create
table.insert(actions, {
add_action({
type = "delete",
entry_type = entry[FIELD_TYPE],
url = cache.get_parent_url(id) .. entry[FIELD_NAME],
@ -150,11 +160,11 @@ M.create_actions_from_diffs = function(all_diffs)
else
-- COPY when create but no delete
for _, diff in ipairs(diffs) do
table.insert(actions, {
add_action({
type = "copy",
entry_type = entry[FIELD_TYPE],
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
dest_url = diff.dest,
dest_url = dest_by_id[diff.id],
})
end
end
@ -353,30 +363,6 @@ end
---@param actions oil.Action[]
---@param cb fun(err: nil|string)
M.process_actions = function(actions, cb)
-- convert delete actions to move-to-trash
local trash_url = config.get_trash_url()
if trash_url then
for i, v in ipairs(actions) do
if v.type == "delete" then
local scheme, path = util.parse_url(v.url)
if config.adapters[scheme] == "files" then
assert(path)
---@type oil.MoveAction
local move_action = {
type = "move",
src_url = v.url,
entry_type = v.entry_type,
dest_url = trash_url .. "/" .. pathutil.basename(path) .. string.format(
"_%06d",
math.random(999999)
),
}
actions[i] = move_action
end
end
end
end
-- send all renames to LSP servers
local moves = {}
for _, action in ipairs(actions) do
@ -390,12 +376,12 @@ M.process_actions = function(actions, cb)
end
lsp_helpers.will_rename_files(moves)
-- Convert cross-adapter moves to a copy + delete
-- Convert some cross-adapter moves to a copy + delete
for _, action in ipairs(actions) do
if action.type == "move" then
local src_scheme = util.parse_url(action.src_url)
local dest_scheme = util.parse_url(action.dest_url)
if src_scheme ~= dest_scheme 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
action.type = "copy"
table.insert(actions, {
type = "delete",
@ -488,6 +474,10 @@ M.try_write_changes = function(confirm)
if vim.bo[bufnr].modified then
local diffs, errors = parser.parse(bufnr)
all_diffs[bufnr] = diffs
local adapter = assert(util.get_adapter(bufnr))
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
@ -539,7 +529,7 @@ M.try_write_changes = function(confirm)
view.unlock_buffers()
if err then
vim.notify(string.format("[oil] Error applying actions: %s", err), vim.log.levels.ERROR)
view.rerender_all_oil_buffers({ preserve_undo = false })
view.rerender_all_oil_buffers()
else
local current_entry = oil.get_cursor_entry()
if current_entry then
@ -549,7 +539,8 @@ M.try_write_changes = function(confirm)
vim.split(current_entry.parsed_name or current_entry.name, "/")[1]
)
end
view.rerender_all_oil_buffers({ preserve_undo = M.trash })
view.rerender_all_oil_buffers()
vim.api.nvim_exec_autocmds("User", { pattern = "OilMutationComplete", modeline = false })
end
mutation_in_progress = false
end)

View file

@ -142,11 +142,18 @@ M.parse_line = function(adapter, line, column_defs)
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[]
---@return table[] Parsing errors
---@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)
@ -158,11 +165,14 @@ M.parse = function(bufnr)
})
return diffs, errors
end
local scheme, path = util.parse_url(bufname)
local parent_url = scheme .. path
local column_defs = columns.get_supported_columns(adapter)
local children = cache.list_url(parent_url)
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]
@ -184,6 +194,7 @@ M.parse = function(bufnr)
end
for i, line in ipairs(lines) do
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, {
@ -256,6 +267,7 @@ M.parse = function(bufnr)
end
end
else
-- Parse a new entry
local name, isdir = parsedir(vim.trim(line))
if vim.startswith(name, "/") then
table.insert(errors, {

View file

@ -453,6 +453,7 @@ end
---@param action oil.Action
---@return oil.Adapter
---@return nil|oil.CrossAdapterAction
M.get_adapter_for_action = function(action)
local adapter = config.get_adapter_by_scheme(action.url or action.src_url)
if not adapter then
@ -462,15 +463,15 @@ M.get_adapter_for_action = function(action)
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if adapter ~= dest_adapter then
if
adapter.supported_adapters_for_copy
and adapter.supported_adapters_for_copy[dest_adapter.name]
adapter.supported_cross_adapter_actions
and adapter.supported_cross_adapter_actions[dest_adapter.name]
then
return adapter
return adapter, adapter.supported_cross_adapter_actions[dest_adapter.name]
elseif
dest_adapter.supported_adapters_for_copy
and dest_adapter.supported_adapters_for_copy[adapter.name]
dest_adapter.supported_cross_adapter_actions
and dest_adapter.supported_cross_adapter_actions[adapter.name]
then
return dest_adapter
return dest_adapter, dest_adapter.supported_cross_adapter_actions[adapter.name]
else
error(
string.format(

View file

@ -1,7 +1,9 @@
local uv = vim.uv or vim.loop
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 keymap_util = require("oil.keymap_util")
local loading = require("oil.loading")
local util = require("oil.util")
@ -142,10 +144,11 @@ M.unlock_buffers = function()
end
end
---@param opts table
---@param opts? table
---@note
--- This DISCARDS ALL MODIFICATIONS a user has made to oil buffers
M.rerender_all_oil_buffers = function(opts)
opts = opts or {}
local buffers = M.get_all_buffers()
local hidden_buffers = {}
for _, bufnr in ipairs(buffers) do
@ -177,8 +180,8 @@ end
---Get a list of visible oil buffers and a list of hidden oil buffers
---@note
--- If any buffers are modified, return values are nil
---@return nil|integer[]
---@return nil|integer[]
---@return nil|integer[] visible
---@return nil|integer[] hidden
local function get_visible_hidden_buffers()
local buffers = M.get_all_buffers()
local hidden_buffers = {}
@ -227,6 +230,43 @@ local function get_first_mutable_column_col(adapter, ranges)
return min_col
end
---Redraw original path virtual text for trash buffer
---@param bufnr integer
local function redraw_trash_virtual_text(bufnr)
if not vim.api.nvim_buf_is_valid(bufnr) or not vim.api.nvim_buf_is_loaded(bufnr) then
return
end
local parser = require("oil.mutator.parser")
local adapter = util.get_adapter(bufnr)
if not adapter or adapter.name ~= "trash" then
return
end
local _, buf_path = util.parse_url(vim.api.nvim_buf_get_name(bufnr))
local os_path = fs.posix_to_os_path(assert(buf_path))
local ns = vim.api.nvim_create_namespace("OilVtext")
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
local column_defs = columns.get_supported_columns(adapter)
for lnum, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)) do
local result = parser.parse_line(adapter, line, column_defs)
local entry = result and result.entry
if entry then
local meta = entry[FIELD_META]
---@type nil|oil.TrashInfo
local trash_info = meta and meta.trash_info
if trash_info then
vim.api.nvim_buf_set_extmark(bufnr, ns, lnum - 1, 0, {
virt_text = {
{
"" .. fs.shorten_path(trash_info.original_path, os_path),
"OilTrashSourcePath",
},
},
})
end
end
end
end
---@param bufnr integer
M.initialize = function(bufnr)
if bufnr == 0 then
@ -255,7 +295,7 @@ M.initialize = function(bufnr)
nested = true,
buffer = bufnr,
callback = function()
-- First wait a short time (10ms) for the buffer change to settle
-- First wait a short time (100ms) for the buffer change to settle
vim.defer_fn(function()
local visible_buffers = get_visible_hidden_buffers()
-- Only delete oil buffers if none of them are visible
@ -271,7 +311,7 @@ M.initialize = function(bufnr)
end
end
end
end, 10)
end, 100)
end,
})
vim.api.nvim_create_autocmd("BufDelete", {
@ -351,6 +391,36 @@ M.initialize = function(bufnr)
end)
end,
})
-- Watch for TextChanged and update the trash original path extmarks
local adapter = util.get_adapter(bufnr)
if adapter and adapter.name == "trash" then
local debounce_timer = assert(uv.new_timer())
local pending = false
vim.api.nvim_create_autocmd("TextChanged", {
desc = "Update oil virtual text of original path",
buffer = bufnr,
callback = function()
-- Respond immediately to prevent flickering, the set the timer for a "cooldown period"
-- If this is called again during the cooldown window, we will rerender after cooldown.
if debounce_timer:is_active() then
pending = true
else
redraw_trash_virtual_text(bufnr)
end
debounce_timer:start(
50,
0,
vim.schedule_wrap(function()
if pending then
pending = false
redraw_trash_virtual_text(bufnr)
end
end)
)
end,
})
end
M.render_buffer_async(bufnr, {}, function(err)
if err then
vim.notify(
@ -358,6 +428,7 @@ M.initialize = function(bufnr)
vim.log.levels.ERROR
)
else
vim.b[bufnr].oil_ready = true
vim.api.nvim_exec_autocmds(
"User",
{ pattern = "OilEnter", modeline = false, data = { buf = bufnr } }
@ -478,6 +549,7 @@ local function render_buffer(bufnr, opts)
vim.bo[bufnr].modifiable = false
vim.bo[bufnr].modified = false
util.set_highlights(bufnr, highlights)
if opts.jump then
-- TODO why is the schedule necessary?
vim.schedule(function()
@ -511,6 +583,10 @@ end
---@return oil.TextChunk[]
M.format_entry_cols = function(entry, column_defs, col_width, adapter)
local name = entry[FIELD_NAME]
local meta = entry[FIELD_META]
if meta and meta.display_name then
name = meta.display_name
end
-- First put the unique ID
local cols = {}
local id_key = cache.format_id(entry[FIELD_ID])
@ -531,7 +607,6 @@ M.format_entry_cols = function(entry, column_defs, col_width, adapter)
elseif entry_type == "socket" then
table.insert(cols, { name, "OilSocket" })
elseif entry_type == "link" then
local meta = entry[FIELD_META]
local link_text
if meta then
if meta.link_stat and meta.link_stat.type == "directory" then
@ -548,7 +623,7 @@ M.format_entry_cols = function(entry, column_defs, col_width, adapter)
table.insert(cols, { name, "OilLink" })
if link_text then
table.insert(cols, { link_text, "Comment" })
table.insert(cols, { link_text, "OilLinkTarget" })
end
else
table.insert(cols, { name, "OilFile" })
@ -573,29 +648,22 @@ end
---@param bufnr integer
---@param opts nil|table
--- preserve_undo nil|boolean
--- refetch nil|boolean Defaults to true
---@param callback nil|fun(err: nil|string)
M.render_buffer_async = function(bufnr, opts, callback)
opts = vim.tbl_deep_extend("keep", opts or {}, {
preserve_undo = false,
refetch = true,
})
if bufnr == 0 then
bufnr = vim.api.nvim_get_current_buf()
end
local bufname = vim.api.nvim_buf_get_name(bufnr)
local scheme, dir = util.parse_url(bufname)
local preserve_undo = opts.preserve_undo and config.adapters[scheme] == "files"
if not preserve_undo then
-- Undo should not return to a blank buffer
-- Method taken from :h clear-undo
vim.bo[bufnr].undolevels = -1
end
local _, dir = util.parse_url(bufname)
-- Undo should not return to a blank buffer
-- Method taken from :h clear-undo
vim.bo[bufnr].undolevels = -1
local handle_error = vim.schedule_wrap(function(message)
if not preserve_undo then
vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
end
vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
util.render_text(bufnr, { "Error: " .. message })
if callback then
callback(message)
@ -612,7 +680,7 @@ M.render_buffer_async = function(bufnr, opts, callback)
handle_error(string.format("[oil] no adapter for buffer '%s'", bufname))
return
end
local start_ms = vim.loop.hrtime() / 1e6
local start_ms = uv.hrtime() / 1e6
local seek_after_render_found = false
local first = true
vim.bo[bufnr].modifiable = false
@ -624,9 +692,7 @@ M.render_buffer_async = function(bufnr, opts, callback)
end
loading.set_loading(bufnr, false)
render_buffer(bufnr, { jump = true })
if not preserve_undo then
vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
end
vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
vim.bo[bufnr].modifiable = not buffers_locked and adapter.is_modifiable(bufnr)
if callback then
callback()
@ -651,7 +717,7 @@ M.render_buffer_async = function(bufnr, opts, callback)
end
end
if fetch_more then
local now = vim.loop.hrtime() / 1e6
local now = uv.hrtime() / 1e6
local delta = now - start_ms
-- If we've been chugging for more than 40ms, go ahead and render what we have
if delta > 40 then