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

@ -43,11 +43,13 @@ oil.nvim supports all the usual plugin managers
<summary>Packer</summary> <summary>Packer</summary>
```lua ```lua
require('packer').startup(function() require("packer").startup(function()
use { use({
'stevearc/oil.nvim', "stevearc/oil.nvim",
config = function() require('oil').setup() end config = function()
} require("oil").setup()
end,
})
end) end)
``` ```
@ -57,9 +59,9 @@ end)
<summary>Paq</summary> <summary>Paq</summary>
```lua ```lua
require "paq" { require("paq")({
{'stevearc/oil.nvim'}; { "stevearc/oil.nvim" },
} })
``` ```
</details> </details>
@ -154,8 +156,6 @@ require("oil").setup({
delete_to_trash = false, delete_to_trash = false,
-- Skip the confirmation popup for simple operations -- Skip the confirmation popup for simple operations
skip_confirm_for_simple_edits = false, 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 -- Selecting a new/moved/renamed file or directory will prompt you to save changes first
prompt_save_on_select_new_entry = true, prompt_save_on_select_new_entry = true,
-- Oil will automatically delete hidden buffers after this delay -- Oil will automatically delete hidden buffers after this delay
@ -184,6 +184,7 @@ require("oil").setup({
["gs"] = "actions.change_sort", ["gs"] = "actions.change_sort",
["gx"] = "actions.open_external", ["gx"] = "actions.open_external",
["g."] = "actions.toggle_hidden", ["g."] = "actions.toggle_hidden",
["g\\"] = "actions.toggle_trash",
}, },
-- Set to false to disable all of the above keymaps -- Set to false to disable all of the above keymaps
use_default_keymaps = true, use_default_keymaps = true,
@ -277,7 +278,7 @@ nvim oil-ssh://[username@]hostname[:port]/[path]
This may look familiar. In fact, this is the same url format that netrw uses. This may look familiar. In fact, this is the same url format that netrw uses.
Note that at the moment the ssh adapter does not support Windows machines, and it requires the server to have a `/bin/sh` binary as well as standard unix commands (`rm`, `mv`, `mkdir`, `chmod`, `cp`, `touch`, `ln`, `echo`). Note that at the moment the ssh adapter does not support Windows machines, and it requires the server to have a `/bin/sh` binary as well as standard unix commands (`ls`, `rm`, `mv`, `mkdir`, `chmod`, `cp`, `touch`, `ln`, `echo`).
## API ## API

View file

@ -8,6 +8,7 @@ CONTENTS *oil-content
3. Columns |oil-columns| 3. Columns |oil-columns|
4. Actions |oil-actions| 4. Actions |oil-actions|
5. Highlights |oil-highlights| 5. Highlights |oil-highlights|
6. Trash |oil-trash|
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
OPTIONS *oil-options* OPTIONS *oil-options*
@ -45,8 +46,6 @@ OPTIONS *oil-option
delete_to_trash = false, delete_to_trash = false,
-- Skip the confirmation popup for simple operations -- Skip the confirmation popup for simple operations
skip_confirm_for_simple_edits = false, 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 -- Selecting a new/moved/renamed file or directory will prompt you to save changes first
prompt_save_on_select_new_entry = true, prompt_save_on_select_new_entry = true,
-- Oil will automatically delete hidden buffers after this delay -- Oil will automatically delete hidden buffers after this delay
@ -75,6 +74,7 @@ OPTIONS *oil-option
["gs"] = "actions.change_sort", ["gs"] = "actions.change_sort",
["gx"] = "actions.open_external", ["gx"] = "actions.open_external",
["g."] = "actions.toggle_hidden", ["g."] = "actions.toggle_hidden",
["g\\"] = "actions.toggle_trash",
}, },
-- Set to false to disable all of the above keymaps -- Set to false to disable all of the above keymaps
use_default_keymaps = true, use_default_keymaps = true,
@ -343,6 +343,8 @@ birthtime *column-birthtim
ACTIONS *oil-actions* ACTIONS *oil-actions*
These are actions that can be used in the `keymaps` section of config options. These are actions that can be used in the `keymaps` section of config options.
You can also call them directly with
`require("oil.actions").action_name.callback()`
cd *actions.cd* cd *actions.cd*
:cd to the current oil directory :cd to the current oil directory
@ -408,11 +410,14 @@ tcd *actions.tc
toggle_hidden *actions.toggle_hidden* toggle_hidden *actions.toggle_hidden*
Toggle hidden files and directories Toggle hidden files and directories
toggle_trash *actions.toggle_trash*
Jump to and from the trash for the current directory
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
HIGHLIGHTS *oil-highlights* HIGHLIGHTS *oil-highlights*
OilDir *hl-OilDir* OilDir *hl-OilDir*
Directories in an oil buffer Directory names in an oil buffer
OilDirIcon *hl-OilDirIcon* OilDirIcon *hl-OilDirIcon*
Icon for directories Icon for directories
@ -423,6 +428,9 @@ OilSocket *hl-OilSocke
OilLink *hl-OilLink* OilLink *hl-OilLink*
Soft links in an oil buffer Soft links in an oil buffer
OilLinkTarget *hl-OilLinkTarget*
The target of a soft link
OilFile *hl-OilFile* OilFile *hl-OilFile*
Normal files in an oil buffer Normal files in an oil buffer
@ -441,5 +449,45 @@ OilCopy *hl-OilCop
OilChange *hl-OilChange* OilChange *hl-OilChange*
Change action in the oil preview window Change action in the oil preview window
OilRestore *hl-OilRestore*
Restore (from the trash) action in the oil preview window
OilPurge *hl-OilPurge*
Purge (Permanently delete a file from trash) action in the oil preview
window
OilTrash *hl-OilTrash*
Trash (delete a file to trash) action in the oil preview window
OilTrashSourcePath *hl-OilTrashSourcePath*
Virtual text that shows the original path of file in the trash
--------------------------------------------------------------------------------
TRASH *oil-trash*
Oil has built-in support for using the system trash. When
`delete_to_trash = true`, any deleted files will be sent to the trash instead
of being permanently deleted. You can browse the trash for a directory using
the `toggle_trash` action (bound to `g\` by default). You can view all files
in the trash with `:Oil --trash /`.
To restore files, simply delete them from the trash and put them in the desired
destination, the same as any other file operation. If you delete files from the
trash they will be permanently deleted (purged).
Linux:
Oil supports the FreeDesktop trash specification.
https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html
All features should work.
Mac:
Oil has limited support for MacOS due to the proprietary nature of the
implementation. The trash bin can only be viewed as a single dir
(instead of being able to see files that were trashed from a directory).
Windows:
Oil does not yet support the Windows trash. PRs are welcome!
================================================================================ ================================================================================
vim:tw=80:ts=2:ft=help:norl:syntax=help: vim:tw=80:ts=2:ft=help:norl:syntax=help:

View file

@ -1,6 +1,9 @@
local oil = require("oil") local oil = require("oil")
local util = require("oil.util") local util = require("oil.util")
-- TODO remove after https://github.com/folke/neodev.nvim/pull/163 lands
---@diagnostic disable: inject-field
local M = {} local M = {}
M.show_help = { M.show_help = {
@ -302,6 +305,35 @@ M.change_sort = {
end, 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 ---List actions for documentation generation
---@private ---@private
M._get_actions = function() 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 trash = require("oil.adapters.files.trash")
local util = require("oil.util") local util = require("oil.util")
local uv = vim.uv or vim.loop local uv = vim.uv or vim.loop
local M = {} local M = {}
local FIELD_NAME = constants.FIELD_NAME local FIELD_NAME = constants.FIELD_NAME
@ -147,7 +148,11 @@ if not fs.is_windows then
} }
end 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 for _, time_key in ipairs({ "ctime", "mtime", "atime", "birthtime" }) do
file_columns[time_key] = { file_columns[time_key] = {
@ -436,7 +441,12 @@ M.render_action = function(action)
elseif action.type == "delete" then elseif action.type == "delete" then
local _, path = util.parse_url(action.url) local _, path = util.parse_url(action.url)
assert(path) 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 elseif action.type == "move" or action.type == "copy" then
local dest_adapter = config.get_adapter_by_scheme(action.dest_url) local dest_adapter = config.get_adapter_by_scheme(action.dest_url)
if dest_adapter == M then if dest_adapter == M then
@ -451,7 +461,7 @@ M.render_action = function(action)
M.to_short_os_path(dest_path, action.entry_type) M.to_short_os_path(dest_path, action.entry_type)
) )
else 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") error("files adapter doesn't support cross-adapter move/copy")
end end
else else
@ -494,7 +504,15 @@ M.perform_action = function(action, cb)
assert(path) assert(path)
path = fs.posix_to_os_path(path) path = fs.posix_to_os_path(path)
if config.delete_to_trash then 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 else
fs.recursive_delete(action.entry_type, path, cb) fs.recursive_delete(action.entry_type, path, cb)
end end
@ -507,9 +525,9 @@ M.perform_action = function(action, cb)
assert(dest_path) assert(dest_path)
src_path = fs.posix_to_os_path(src_path) src_path = fs.posix_to_os_path(src_path)
dest_path = fs.posix_to_os_path(dest_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 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") cb("files adapter doesn't support cross-adapter move")
end end
elseif action.type == "copy" then elseif action.type == "copy" then
@ -523,7 +541,7 @@ M.perform_action = function(action, cb)
dest_path = fs.posix_to_os_path(dest_path) dest_path = fs.posix_to_os_path(dest_path)
fs.recursive_copy(action.entry_type, src_path, dest_path, cb) fs.recursive_copy(action.entry_type, src_path, dest_path, cb)
else 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") cb("files adapter doesn't support cross-adapter copy")
end end
else else

View file

@ -348,7 +348,7 @@ M.perform_action = function(action, cb)
end end
end end
M.supported_adapters_for_copy = { files = true } M.supported_cross_adapter_actions = { files = "copy" }
---@param bufnr integer ---@param bufnr integer
M.read_file = function(bufnr) 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_ID = constants.FIELD_ID
local FIELD_NAME = constants.FIELD_NAME local FIELD_NAME = constants.FIELD_NAME
local FIELD_META = constants.FIELD_META
local next_id = 1 local next_id = 1
-- Map<url, Map<entry name, oil.InternalEntry>> -- Map<url, Map<entry name, oil.InternalEntry>>
---@type table<string, table<string, oil.InternalEntry>>
local url_directory = {} local url_directory = {}
---@type table<integer, oil.InternalEntry> ---@type table<integer, oil.InternalEntry>
@ -118,6 +120,15 @@ M.get_entry_by_id = function(id)
return entries_by_id[id] return entries_by_id[id]
end 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 ---@param id integer
---@return string ---@return string
M.get_parent_url = function(id) M.get_parent_url = function(id)
@ -129,18 +140,12 @@ M.get_parent_url = function(id)
end end
---@param url string ---@param url string
---@return oil.InternalEntry[] ---@return table<string, oil.InternalEntry>
M.list_url = function(url) M.list_url = function(url)
url = util.addslash(url) url = util.addslash(url)
return url_directory[url] or {} return url_directory[url] or {}
end 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 ---@param action oil.Action
M.perform_action = function(action) M.perform_action = function(action)
if action.type == "create" then if action.type == "create" then
@ -172,6 +177,8 @@ M.perform_action = function(action)
dest_parent = {} dest_parent = {}
url_directory[dest_parent_url] = dest_parent url_directory[dest_parent_url] = dest_parent
end end
-- We have to clear the metadata because it can be inaccurate after the move
entry[FIELD_META] = nil
dest_parent[dest_name] = entry dest_parent[dest_name] = entry
parent_url_by_id[entry[FIELD_ID]] = dest_parent_url parent_url_by_id[entry[FIELD_ID]] = dest_parent_url
entry[FIELD_NAME] = dest_name entry[FIELD_NAME] = dest_name

View file

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

View file

@ -1,3 +1,5 @@
local uv = vim.uv or vim.loop
local default_config = { local default_config = {
-- Oil will take over directory buffers (e.g. `vim .` or `:e src/`) -- Oil will take over directory buffers (e.g. `vim .` or `:e src/`)
-- Set to false if you still want to use netrw. -- Set to false if you still want to use netrw.
@ -30,8 +32,6 @@ local default_config = {
delete_to_trash = false, delete_to_trash = false,
-- Skip the confirmation popup for simple operations -- Skip the confirmation popup for simple operations
skip_confirm_for_simple_edits = false, 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 -- Selecting a new/moved/renamed file or directory will prompt you to save changes first
prompt_save_on_select_new_entry = true, prompt_save_on_select_new_entry = true,
-- Oil will automatically delete hidden buffers after this delay -- Oil will automatically delete hidden buffers after this delay
@ -60,6 +60,7 @@ local default_config = {
["gs"] = "actions.change_sort", ["gs"] = "actions.change_sort",
["gx"] = "actions.open_external", ["gx"] = "actions.open_external",
["g."] = "actions.toggle_hidden", ["g."] = "actions.toggle_hidden",
["g\\"] = "actions.toggle_trash",
}, },
-- Set to false to disable all of the above keymaps -- Set to false to disable all of the above keymaps
use_default_keymaps = true, use_default_keymaps = true,
@ -142,6 +143,7 @@ local default_config = {
default_config.adapters = { default_config.adapters = {
["oil://"] = "files", ["oil://"] = "files",
["oil-ssh://"] = "ssh", ["oil-ssh://"] = "ssh",
["oil-trash://"] = "trash",
} }
default_config.adapter_aliases = {} default_config.adapter_aliases = {}
@ -154,13 +156,10 @@ M.setup = function(opts)
end end
if new_conf.delete_to_trash then if new_conf.delete_to_trash then
local trash_bin = vim.split(new_conf.trash_command, " ")[1] local is_windows = uv.os_uname().version:match("Windows")
if vim.fn.executable(trash_bin) == 0 then if is_windows then
vim.notify( vim.notify(
string.format( "oil.nvim: delete_to_trash is true, but trash is not yet supported on Windows.\nDeleted files will be permanently removed",
"oil.nvim: delete_to_trash is true, but '%s' executable not found.\nDeleted files will be permanently removed.",
new_conf.trash_command
),
vim.log.levels.WARN vim.log.levels.WARN
) )
new_conf.delete_to_trash = false new_conf.delete_to_trash = false
@ -176,46 +175,6 @@ M.setup = function(opts)
M.adapter_to_scheme[v] = k M.adapter_to_scheme[v] = k
end end
M._adapter_by_scheme = {} 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 end
---@param scheme nil|string ---@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_mac = uv.os_uname().sysname == "Darwin"
M.is_linux = not M.is_windows and not M.is_mac
---@type string ---@type string
M.sep = M.is_windows and "\\" or "/" M.sep = M.is_windows and "\\" or "/"
@ -114,20 +116,31 @@ end
local home_dir = assert(uv.os_homedir()) local home_dir = assert(uv.os_homedir())
---@param path string ---@param path string
---@param relative_to? string Shorten relative to this path (default cwd)
---@return string ---@return string
M.shorten_path = function(path) M.shorten_path = function(path, relative_to)
local cwd = vim.fn.getcwd() if not relative_to then
if M.is_subpath(cwd, path) then relative_to = vim.fn.getcwd()
local relative = path:sub(cwd:len() + 2) end
if relative == "" then local relpath
relative = "." 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 end
return relative
end end
if M.is_subpath(home_dir, path) then 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 end
return path return relpath or path
end end
M.mkdirp = function(dir) M.mkdirp = function(dir)
@ -177,7 +190,7 @@ M.listdir = function(dir, cb)
end end
read_next() read_next()
---@diagnostic disable-next-line: param-type-mismatch ---@diagnostic disable-next-line: param-type-mismatch
end, 100) -- TODO do some testing for this end, 10000)
end end
---@param entry_type oil.EntryType ---@param entry_type oil.EntryType

View file

@ -8,6 +8,7 @@ local M = {}
---@alias oil.EntryType "file"|"directory"|"socket"|"link"|"fifo" ---@alias oil.EntryType "file"|"directory"|"socket"|"link"|"fifo"
---@alias oil.TextChunk string|string[] ---@alias oil.TextChunk string|string[]
---@alias oil.CrossAdapterAction "copy"|"move"
---@class (exact) oil.Adapter ---@class (exact) oil.Adapter
---@field name string The unique name of the adapter (this will be set automatically) ---@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 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 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 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 -- TODO remove after https://github.com/folke/neodev.nvim/pull/163 lands
---@diagnostic disable: undefined-field ---@diagnostic disable: undefined-field
@ -110,34 +113,6 @@ M.discard_all_changes = function()
end end
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 ---Change the display columns for oil
---@param cols oil.ColumnSpec[] ---@param cols oil.ColumnSpec[]
M.set_columns = function(cols) M.set_columns = function(cols)
@ -177,9 +152,13 @@ end
---Get the oil url for a given directory ---Get the oil url for a given directory
---@private ---@private
---@param dir nil|string When nil, use the cwd ---@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 ---@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 config = require("oil.config")
local fs = require("oil.fs") local fs = require("oil.fs")
local util = require("oil.util") local util = require("oil.util")
@ -196,15 +175,16 @@ M.get_url_for_path = function(dir)
return config.adapter_to_scheme.files .. path return config.adapter_to_scheme.files .. path
else else
local bufname = vim.api.nvim_buf_get_name(0) 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
end end
---@private ---@private
---@param bufname string ---@param bufname string
---@param use_oil_parent boolean If in an oil buffer, return the parent
---@return string ---@return string
---@return nil|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 config = require("oil.config")
local fs = require("oil.fs") local fs = require("oil.fs")
local pathutil = require("oil.pathutil") local pathutil = require("oil.pathutil")
@ -223,13 +203,15 @@ M.get_buffer_parent_url = function(bufname)
return parent_url, basename return parent_url, basename
else else
assert(path) assert(path)
-- TODO maybe we should remove this special case and turn it into a config
if scheme == "term://" then if scheme == "term://" then
---@type string ---@type string
path = vim.fn.expand(path:match("^(.*)//")) ---@diagnostic disable-line: assign-type-mismatch path = vim.fn.expand(path:match("^(.*)//")) ---@diagnostic disable-line: assign-type-mismatch
return config.adapter_to_scheme.files .. util.addslash(path) return config.adapter_to_scheme.files .. util.addslash(path)
end end
if not use_oil_parent then
return bufname
end
local adapter = config.get_adapter_by_scheme(scheme) local adapter = config.get_adapter_by_scheme(scheme)
local parent_url local parent_url
if adapter and adapter.get_parent then if adapter and adapter.get_parent then
@ -672,7 +654,7 @@ M._get_highlights = function()
{ {
name = "OilDir", name = "OilDir",
link = "Directory", link = "Directory",
desc = "Directories in an oil buffer", desc = "Directory names in an oil buffer",
}, },
{ {
name = "OilDirIcon", name = "OilDirIcon",
@ -689,6 +671,11 @@ M._get_highlights = function()
link = nil, link = nil,
desc = "Soft links in an oil buffer", desc = "Soft links in an oil buffer",
}, },
{
name = "OilLinkTarget",
link = "Comment",
desc = "The target of a soft link",
},
{ {
name = "OilFile", name = "OilFile",
link = nil, link = nil,
@ -719,6 +706,26 @@ M._get_highlights = function()
link = "Special", link = "Special",
desc = "Change action in the oil preview window", 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 end
@ -855,14 +862,23 @@ M.setup = function(opts)
config.setup(opts) config.setup(opts)
set_colors() set_colors()
vim.api.nvim_create_user_command("Oil", function(args) vim.api.nvim_create_user_command("Oil", function(args)
local util = require("oil.util")
if args.smods.tab == 1 then if args.smods.tab == 1 then
vim.cmd.tabnew() vim.cmd.tabnew()
end end
local float = false 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 if v == "--float" then
float = true float = true
table.remove(args.fargs, i) table.remove(args.fargs, i)
elseif v == "--trash" then
trash = true
table.remove(args.fargs, i)
else
i = i + 1
end end
end end
@ -875,7 +891,13 @@ M.setup = function(opts)
end end
local method = float and "open_float" or "open" 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" }) end, { desc = "Open oil file browser on a directory", nargs = "*", complete = "dir" })
local aug = vim.api.nvim_create_augroup("Oil", {}) 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 lsp_helpers = require("oil.lsp_helpers")
local oil = require("oil") local oil = require("oil")
local parser = require("oil.mutator.parser") local parser = require("oil.mutator.parser")
local pathutil = require("oil.pathutil")
local preview = require("oil.mutator.preview") local preview = require("oil.mutator.preview")
local util = require("oil.util") local util = require("oil.util")
local view = require("oil.view") local view = require("oil.view")
@ -54,6 +53,7 @@ M.create_actions_from_diffs = function(all_diffs)
---@type oil.Action[] ---@type oil.Action[]
local actions = {} local actions = {}
---@type table<integer, oil.Diff[]>
local diff_by_id = setmetatable({}, { local diff_by_id = setmetatable({}, {
__index = function(t, key) __index = function(t, key)
local list = {} local list = {}
@ -61,6 +61,15 @@ M.create_actions_from_diffs = function(all_diffs)
return list return list
end, 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 for bufnr, diffs in pairs(all_diffs) do
local adapter = util.get_adapter(bufnr) local adapter = util.get_adapter(bufnr)
if not adapter then if not adapter then
@ -71,9 +80,7 @@ M.create_actions_from_diffs = function(all_diffs)
if diff.type == "new" then if diff.type == "new" then
if diff.id then if diff.id then
local by_id = diff_by_id[diff.id] 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 dest_by_id[diff.id] = parent_url .. diff.name
---@diagnostic disable-next-line: inject-field
diff.dest = parent_url .. diff.name
table.insert(by_id, diff) table.insert(by_id, diff)
else else
-- Parse nested files like foo/bar/baz -- 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} -- Parse alternations like foo.{js,test.js}
for _, alt in ipairs(vim.split(alternation, ",")) do for _, alt in ipairs(vim.split(alternation, ",")) do
local alt_url = url .. "/" .. v:gsub("{[^}]+}", alt) local alt_url = url .. "/" .. v:gsub("{[^}]+}", alt)
table.insert(actions, { add_action({
type = "create", type = "create",
url = alt_url, url = alt_url,
entry_type = entry_type, entry_type = entry_type,
@ -96,7 +103,7 @@ M.create_actions_from_diffs = function(all_diffs)
end end
else else
url = url .. "/" .. v url = url .. "/" .. v
table.insert(actions, { add_action({
type = "create", type = "create",
url = url, url = url,
entry_type = entry_type, entry_type = entry_type,
@ -106,7 +113,7 @@ M.create_actions_from_diffs = function(all_diffs)
end end
end end
elseif diff.type == "change" then elseif diff.type == "change" then
table.insert(actions, { add_action({
type = "change", type = "change",
url = parent_url .. diff.name, url = parent_url .. diff.name,
entry_type = diff.entry_type, entry_type = diff.entry_type,
@ -115,8 +122,9 @@ M.create_actions_from_diffs = function(all_diffs)
}) })
else else
local by_id = diff_by_id[diff.id] 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 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. -- in the diff_by_id map. The list will only include the 'new' diffs.
end end
end end
@ -127,21 +135,23 @@ M.create_actions_from_diffs = function(all_diffs)
if not entry then if not entry then
error(string.format("Could not find entry %d", id)) error(string.format("Could not find entry %d", id))
end 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 if diffs.has_delete then
local has_create = #diffs > 0 local has_create = #diffs > 0
if has_create then if has_create then
-- MOVE (+ optional copies) when has both creates and delete -- MOVE (+ optional copies) when has both creates and delete
for i, diff in ipairs(diffs) do for i, diff in ipairs(diffs) do
table.insert(actions, { add_action({
type = i == #diffs and "move" or "copy", type = i == #diffs and "move" or "copy",
entry_type = entry[FIELD_TYPE], 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], src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
}) })
end end
else else
-- DELETE when no create -- DELETE when no create
table.insert(actions, { add_action({
type = "delete", type = "delete",
entry_type = entry[FIELD_TYPE], entry_type = entry[FIELD_TYPE],
url = cache.get_parent_url(id) .. entry[FIELD_NAME], url = cache.get_parent_url(id) .. entry[FIELD_NAME],
@ -150,11 +160,11 @@ M.create_actions_from_diffs = function(all_diffs)
else else
-- COPY when create but no delete -- COPY when create but no delete
for _, diff in ipairs(diffs) do for _, diff in ipairs(diffs) do
table.insert(actions, { add_action({
type = "copy", type = "copy",
entry_type = entry[FIELD_TYPE], entry_type = entry[FIELD_TYPE],
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME], src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
dest_url = diff.dest, dest_url = dest_by_id[diff.id],
}) })
end end
end end
@ -353,30 +363,6 @@ end
---@param actions oil.Action[] ---@param actions oil.Action[]
---@param cb fun(err: nil|string) ---@param cb fun(err: nil|string)
M.process_actions = function(actions, cb) 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 -- send all renames to LSP servers
local moves = {} local moves = {}
for _, action in ipairs(actions) do for _, action in ipairs(actions) do
@ -390,12 +376,12 @@ M.process_actions = function(actions, cb)
end end
lsp_helpers.will_rename_files(moves) 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 for _, action in ipairs(actions) do
if action.type == "move" then if action.type == "move" then
local src_scheme = util.parse_url(action.src_url) local _, cross_action = util.get_adapter_for_action(action)
local dest_scheme = util.parse_url(action.dest_url) -- Only do the conversion if the cross-adapter support is "copy"
if src_scheme ~= dest_scheme then if cross_action == "copy" then
action.type = "copy" action.type = "copy"
table.insert(actions, { table.insert(actions, {
type = "delete", type = "delete",
@ -488,6 +474,10 @@ M.try_write_changes = function(confirm)
if vim.bo[bufnr].modified then if vim.bo[bufnr].modified then
local diffs, errors = parser.parse(bufnr) local diffs, errors = parser.parse(bufnr)
all_diffs[bufnr] = diffs 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 if not vim.tbl_isempty(errors) then
all_errors[bufnr] = errors all_errors[bufnr] = errors
end end
@ -539,7 +529,7 @@ M.try_write_changes = function(confirm)
view.unlock_buffers() view.unlock_buffers()
if err then if err then
vim.notify(string.format("[oil] Error applying actions: %s", err), vim.log.levels.ERROR) 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 else
local current_entry = oil.get_cursor_entry() local current_entry = oil.get_cursor_entry()
if current_entry then 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] vim.split(current_entry.parsed_name or current_entry.name, "/")[1]
) )
end 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 end
mutation_in_progress = false mutation_in_progress = false
end) end)

View file

@ -142,11 +142,18 @@ M.parse_line = function(adapter, line, column_defs)
return { data = ret, entry = entry, ranges = ranges } return { data = ret, entry = entry, ranges = ranges }
end end
---@class (exact) oil.ParseError
---@field lnum integer
---@field col integer
---@field message string
---@param bufnr integer ---@param bufnr integer
---@return oil.Diff[] ---@return oil.Diff[] diffs
---@return table[] Parsing errors ---@return oil.ParseError[] errors Parsing errors
M.parse = function(bufnr) M.parse = function(bufnr)
---@type oil.Diff[]
local diffs = {} local diffs = {}
---@type oil.ParseError[]
local errors = {} local errors = {}
local bufname = vim.api.nvim_buf_get_name(bufnr) local bufname = vim.api.nvim_buf_get_name(bufnr)
local adapter = util.get_adapter(bufnr) local adapter = util.get_adapter(bufnr)
@ -158,11 +165,14 @@ M.parse = function(bufnr)
}) })
return diffs, errors return diffs, errors
end 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 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 = {} local original_entries = {}
for _, child in pairs(children) do for _, child in pairs(children) do
local name = child[FIELD_NAME] local name = child[FIELD_NAME]
@ -184,6 +194,7 @@ M.parse = function(bufnr)
end end
for i, line in ipairs(lines) do for i, line in ipairs(lines) do
if line:match("^/%d+") then if line:match("^/%d+") then
-- Parse the line for an existing entry
local result, err = M.parse_line(adapter, line, column_defs) local result, err = M.parse_line(adapter, line, column_defs)
if not result or err then if not result or err then
table.insert(errors, { table.insert(errors, {
@ -256,6 +267,7 @@ M.parse = function(bufnr)
end end
end end
else else
-- Parse a new entry
local name, isdir = parsedir(vim.trim(line)) local name, isdir = parsedir(vim.trim(line))
if vim.startswith(name, "/") then if vim.startswith(name, "/") then
table.insert(errors, { table.insert(errors, {

View file

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

View file

@ -1,7 +1,9 @@
local uv = vim.uv or vim.loop
local cache = require("oil.cache") local cache = require("oil.cache")
local columns = require("oil.columns") local columns = require("oil.columns")
local config = require("oil.config") local config = require("oil.config")
local constants = require("oil.constants") local constants = require("oil.constants")
local fs = require("oil.fs")
local keymap_util = require("oil.keymap_util") local keymap_util = require("oil.keymap_util")
local loading = require("oil.loading") local loading = require("oil.loading")
local util = require("oil.util") local util = require("oil.util")
@ -142,10 +144,11 @@ M.unlock_buffers = function()
end end
end end
---@param opts table ---@param opts? table
---@note ---@note
--- This DISCARDS ALL MODIFICATIONS a user has made to oil buffers --- This DISCARDS ALL MODIFICATIONS a user has made to oil buffers
M.rerender_all_oil_buffers = function(opts) M.rerender_all_oil_buffers = function(opts)
opts = opts or {}
local buffers = M.get_all_buffers() local buffers = M.get_all_buffers()
local hidden_buffers = {} local hidden_buffers = {}
for _, bufnr in ipairs(buffers) do 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 ---Get a list of visible oil buffers and a list of hidden oil buffers
---@note ---@note
--- If any buffers are modified, return values are nil --- If any buffers are modified, return values are nil
---@return nil|integer[] ---@return nil|integer[] visible
---@return nil|integer[] ---@return nil|integer[] hidden
local function get_visible_hidden_buffers() local function get_visible_hidden_buffers()
local buffers = M.get_all_buffers() local buffers = M.get_all_buffers()
local hidden_buffers = {} local hidden_buffers = {}
@ -227,6 +230,43 @@ local function get_first_mutable_column_col(adapter, ranges)
return min_col return min_col
end 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 ---@param bufnr integer
M.initialize = function(bufnr) M.initialize = function(bufnr)
if bufnr == 0 then if bufnr == 0 then
@ -255,7 +295,7 @@ M.initialize = function(bufnr)
nested = true, nested = true,
buffer = bufnr, buffer = bufnr,
callback = function() 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() vim.defer_fn(function()
local visible_buffers = get_visible_hidden_buffers() local visible_buffers = get_visible_hidden_buffers()
-- Only delete oil buffers if none of them are visible -- Only delete oil buffers if none of them are visible
@ -271,7 +311,7 @@ M.initialize = function(bufnr)
end end
end end
end end
end, 10) end, 100)
end, end,
}) })
vim.api.nvim_create_autocmd("BufDelete", { vim.api.nvim_create_autocmd("BufDelete", {
@ -351,6 +391,36 @@ M.initialize = function(bufnr)
end) end)
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) M.render_buffer_async(bufnr, {}, function(err)
if err then if err then
vim.notify( vim.notify(
@ -358,6 +428,7 @@ M.initialize = function(bufnr)
vim.log.levels.ERROR vim.log.levels.ERROR
) )
else else
vim.b[bufnr].oil_ready = true
vim.api.nvim_exec_autocmds( vim.api.nvim_exec_autocmds(
"User", "User",
{ pattern = "OilEnter", modeline = false, data = { buf = bufnr } } { 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].modifiable = false
vim.bo[bufnr].modified = false vim.bo[bufnr].modified = false
util.set_highlights(bufnr, highlights) util.set_highlights(bufnr, highlights)
if opts.jump then if opts.jump then
-- TODO why is the schedule necessary? -- TODO why is the schedule necessary?
vim.schedule(function() vim.schedule(function()
@ -511,6 +583,10 @@ end
---@return oil.TextChunk[] ---@return oil.TextChunk[]
M.format_entry_cols = function(entry, column_defs, col_width, adapter) M.format_entry_cols = function(entry, column_defs, col_width, adapter)
local name = entry[FIELD_NAME] 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 -- First put the unique ID
local cols = {} local cols = {}
local id_key = cache.format_id(entry[FIELD_ID]) 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 elseif entry_type == "socket" then
table.insert(cols, { name, "OilSocket" }) table.insert(cols, { name, "OilSocket" })
elseif entry_type == "link" then elseif entry_type == "link" then
local meta = entry[FIELD_META]
local link_text local link_text
if meta then if meta then
if meta.link_stat and meta.link_stat.type == "directory" 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" }) table.insert(cols, { name, "OilLink" })
if link_text then if link_text then
table.insert(cols, { link_text, "Comment" }) table.insert(cols, { link_text, "OilLinkTarget" })
end end
else else
table.insert(cols, { name, "OilFile" }) table.insert(cols, { name, "OilFile" })
@ -573,29 +648,22 @@ end
---@param bufnr integer ---@param bufnr integer
---@param opts nil|table ---@param opts nil|table
--- preserve_undo nil|boolean
--- refetch nil|boolean Defaults to true --- refetch nil|boolean Defaults to true
---@param callback nil|fun(err: nil|string) ---@param callback nil|fun(err: nil|string)
M.render_buffer_async = function(bufnr, opts, callback) M.render_buffer_async = function(bufnr, opts, callback)
opts = vim.tbl_deep_extend("keep", opts or {}, { opts = vim.tbl_deep_extend("keep", opts or {}, {
preserve_undo = false,
refetch = true, refetch = true,
}) })
if bufnr == 0 then if bufnr == 0 then
bufnr = vim.api.nvim_get_current_buf() bufnr = vim.api.nvim_get_current_buf()
end end
local bufname = vim.api.nvim_buf_get_name(bufnr) local bufname = vim.api.nvim_buf_get_name(bufnr)
local scheme, dir = util.parse_url(bufname) local _, dir = util.parse_url(bufname)
local preserve_undo = opts.preserve_undo and config.adapters[scheme] == "files" -- Undo should not return to a blank buffer
if not preserve_undo then -- Method taken from :h clear-undo
-- Undo should not return to a blank buffer vim.bo[bufnr].undolevels = -1
-- Method taken from :h clear-undo
vim.bo[bufnr].undolevels = -1
end
local handle_error = vim.schedule_wrap(function(message) 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" })
vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
end
util.render_text(bufnr, { "Error: " .. message }) util.render_text(bufnr, { "Error: " .. message })
if callback then if callback then
callback(message) 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)) handle_error(string.format("[oil] no adapter for buffer '%s'", bufname))
return return
end end
local start_ms = vim.loop.hrtime() / 1e6 local start_ms = uv.hrtime() / 1e6
local seek_after_render_found = false local seek_after_render_found = false
local first = true local first = true
vim.bo[bufnr].modifiable = false vim.bo[bufnr].modifiable = false
@ -624,9 +692,7 @@ M.render_buffer_async = function(bufnr, opts, callback)
end end
loading.set_loading(bufnr, false) loading.set_loading(bufnr, false)
render_buffer(bufnr, { jump = true }) render_buffer(bufnr, { jump = true })
if not preserve_undo then vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
end
vim.bo[bufnr].modifiable = not buffers_locked and adapter.is_modifiable(bufnr) vim.bo[bufnr].modifiable = not buffers_locked and adapter.is_modifiable(bufnr)
if callback then if callback then
callback() callback()
@ -651,7 +717,7 @@ M.render_buffer_async = function(bufnr, opts, callback)
end end
end end
if fetch_more then if fetch_more then
local now = vim.loop.hrtime() / 1e6 local now = uv.hrtime() / 1e6
local delta = now - start_ms local delta = now - start_ms
-- If we've been chugging for more than 40ms, go ahead and render what we have -- If we've been chugging for more than 40ms, go ahead and render what we have
if delta > 40 then if delta > 40 then

View file

@ -121,22 +121,44 @@ COL_DEFS = [
"An icon for the entry's type (requires nvim-web-devicons)", "An icon for the entry's type (requires nvim-web-devicons)",
HL HL
+ [ + [
LuaParam("default_file", "string", "Fallback icon for files when nvim-web-devicons returns nil"), LuaParam(
"default_file",
"string",
"Fallback icon for files when nvim-web-devicons returns nil",
),
LuaParam("directory", "string", "Icon for directories"), LuaParam("directory", "string", "Icon for directories"),
LuaParam("add_padding", "boolean", "Set to false to remove the extra whitespace after the icon"), LuaParam(
"add_padding",
"boolean",
"Set to false to remove the extra whitespace after the icon",
),
], ],
), ),
ColumnDef("size", "files, ssh", False, True, "The size of the file", HL + []), ColumnDef("size", "files, ssh", False, True, "The size of the file", HL + []),
ColumnDef( ColumnDef(
"permissions", "files, ssh", True, False, "Access permissions of the file", HL + [] "permissions",
"files, ssh",
True,
False,
"Access permissions of the file",
HL + [],
),
ColumnDef(
"ctime", "files", False, True, "Change timestamp of the file", HL + TIME + []
), ),
ColumnDef("ctime", "files", False, True, "Change timestamp of the file", HL + TIME + []),
ColumnDef( ColumnDef(
"mtime", "files", False, True, "Last modified time of the file", HL + TIME + [] "mtime", "files", False, True, "Last modified time of the file", HL + TIME + []
), ),
ColumnDef("atime", "files", False, True, "Last access time of the file", HL + TIME + []),
ColumnDef( ColumnDef(
"birthtime", "files", False, True, "The time the file was created", HL + TIME + [] "atime", "files", False, True, "Last access time of the file", HL + TIME + []
),
ColumnDef(
"birthtime",
"files",
False,
True,
"The time the file was created",
HL + TIME + [],
), ),
] ]
@ -170,7 +192,7 @@ def get_actions_vimdoc() -> "VimdocSection":
section = VimdocSection("Actions", "oil-actions", ["\n"]) section = VimdocSection("Actions", "oil-actions", ["\n"])
section.body.extend( section.body.extend(
wrap( wrap(
"These are actions that can be used in the `keymaps` section of config options." """These are actions that can be used in the `keymaps` section of config options. You can also call them directly with `require("oil.actions").action_name.callback()`"""
) )
) )
section.body.append("\n") section.body.append("\n")
@ -210,6 +232,37 @@ def get_columns_vimdoc() -> "VimdocSection":
return section return section
def get_trash_vimdoc() -> "VimdocSection":
section = VimdocSection("Trash", "oil-trash", [])
section.body.append(
"""
Oil has built-in support for using the system trash. When
`delete_to_trash = true`, any deleted files will be sent to the trash instead
of being permanently deleted. You can browse the trash for a directory using
the `toggle_trash` action (bound to `g\\` by default). You can view all files
in the trash with `:Oil --trash /`.
To restore files, simply delete them from the trash and put them in the desired
destination, the same as any other file operation. If you delete files from the
trash they will be permanently deleted (purged).
Linux:
Oil supports the FreeDesktop trash specification.
https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html
All features should work.
Mac:
Oil has limited support for MacOS due to the proprietary nature of the
implementation. The trash bin can only be viewed as a single dir
(instead of being able to see files that were trashed from a directory).
Windows:
Oil does not yet support the Windows trash. PRs are welcome!
"""
)
return section
def generate_vimdoc(): def generate_vimdoc():
doc = Vimdoc("oil.txt", "oil") doc = Vimdoc("oil.txt", "oil")
funcs = parse_functions(os.path.join(ROOT, "lua", "oil", "init.lua")) funcs = parse_functions(os.path.join(ROOT, "lua", "oil", "init.lua"))
@ -220,6 +273,7 @@ def generate_vimdoc():
get_columns_vimdoc(), get_columns_vimdoc(),
get_actions_vimdoc(), get_actions_vimdoc(),
get_highlights_vimdoc(), get_highlights_vimdoc(),
get_trash_vimdoc(),
] ]
) )

View file

@ -3,9 +3,13 @@ if exists("b:current_syntax")
endif endif
syn match oilCreate /^CREATE / syn match oilCreate /^CREATE /
syn match oilMove /^ MOVE / syn match oilMove /^ MOVE /
syn match oilDelete /^DELETE / syn match oilDelete /^DELETE /
syn match oilCopy /^ COPY / syn match oilCopy /^ COPY /
syn match oilChange /^CHANGE / syn match oilChange /^CHANGE /
" Trash operations
syn match oilRestore /^RESTORE /
syn match oilPurge /^ PURGE /
syn match oilTrash /^ TRASH /
let b:current_syntax = "oil_preview" let b:current_syntax = "oil_preview"

View file

@ -11,8 +11,6 @@ a.describe("files adapter", function()
a.after_each(function() a.after_each(function()
if tmpdir then if tmpdir then
tmpdir:dispose() tmpdir:dispose()
a.util.scheduler()
tmpdir = nil
end end
test_util.reset_editor() test_util.reset_editor()
end) end)

View file

@ -1,4 +1,4 @@
vim.cmd([[set runtimepath+=.]]) vim.opt.runtimepath:append(".")
vim.o.swapfile = false vim.o.swapfile = false
vim.bo.swapfile = false vim.bo.swapfile = false

View file

@ -18,6 +18,7 @@ describe("parser", function()
after_each(function() after_each(function()
test_util.reset_editor() test_util.reset_editor()
end) end)
it("detects new files", function() it("detects new files", function()
vim.cmd.edit({ args = { "oil-test:///foo/" } }) vim.cmd.edit({ args = { "oil-test:///foo/" } })
local bufnr = vim.api.nvim_get_current_buf() local bufnr = vim.api.nvim_get_current_buf()

View file

@ -25,6 +25,18 @@ M.reset_editor = function()
test_adapter.test_clear() test_adapter.test_clear()
end end
local function throwiferr(err, ...)
if err then
error(err)
else
return ...
end
end
M.await = function(fn, nargs, ...)
return throwiferr(a.wrap(fn, nargs)(...))
end
M.wait_for_autocmd = a.wrap(function(autocmd, cb) M.wait_for_autocmd = a.wrap(function(autocmd, cb)
local opts = { local opts = {
pattern = "*", pattern = "*",
@ -58,4 +70,48 @@ M.feedkeys = function(actions, timestep)
a.util.sleep(timestep) a.util.sleep(timestep)
end end
M.actions = {
---Open oil and wait for it to finish rendering
---@param args string[]
open = function(args)
vim.schedule(function()
vim.cmd.Oil({ args = args })
-- If this buffer was already open, manually dispatch the autocmd to finish the wait
if vim.b.oil_ready then
vim.api.nvim_exec_autocmds("User", {
pattern = "OilEnter",
modeline = false,
data = { buf = vim.api.nvim_get_current_buf() },
})
end
end)
M.wait_for_autocmd({ "User", pattern = "OilEnter" })
end,
---Save all changes and wait for operation to complete
save = function()
vim.schedule_wrap(require("oil").save)({ confirm = false })
M.wait_for_autocmd({ "User", pattern = "OilMutationComplete" })
end,
---@param bufnr? integer
reload = function(bufnr)
M.await(require("oil.view").render_buffer_async, 3, bufnr or 0)
end,
---Move cursor to a file or directory in an oil buffer
---@param filename string
focus = function(filename)
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, true)
local search = " " .. filename .. "$"
for i, line in ipairs(lines) do
if line:match(search) then
vim.api.nvim_win_set_cursor(0, { i, 0 })
return
end
end
error("Could not find file " .. filename)
end,
}
return M return M

View file

@ -1,16 +1,7 @@
local fs = require("oil.fs") local fs = require("oil.fs")
local test_util = require("tests.test_util")
local function throwiferr(err, ...) local await = test_util.await
if err then
error(err)
else
return ...
end
end
local function await(fn, nargs, ...)
return throwiferr(a.wrap(fn, nargs)(...))
end
---@param path string ---@param path string
---@param cb fun(err: nil|string) ---@param cb fun(err: nil|string)
@ -41,6 +32,7 @@ local TmpDir = {}
TmpDir.new = function() TmpDir.new = function()
local path = await(vim.loop.fs_mkdtemp, 2, "oil_test_XXXXXXXXX") local path = await(vim.loop.fs_mkdtemp, 2, "oil_test_XXXXXXXXX")
a.util.scheduler()
return setmetatable({ path = path }, { return setmetatable({ path = path }, {
__index = TmpDir, __index = TmpDir,
}) })
@ -60,6 +52,7 @@ function TmpDir:create(paths)
end end
end end
end end
a.util.scheduler()
end end
---@param filepath string ---@param filepath string
@ -72,6 +65,7 @@ local read_file = function(filepath)
local stat = vim.loop.fs_fstat(fd) local stat = vim.loop.fs_fstat(fd)
local content = vim.loop.fs_read(fd, stat.size) local content = vim.loop.fs_read(fd, stat.size)
vim.loop.fs_close(fd) vim.loop.fs_close(fd)
a.util.scheduler()
return content return content
end end
@ -99,9 +93,9 @@ local assert_fs = function(root, paths)
local pieces = vim.split(k, "/") local pieces = vim.split(k, "/")
local partial_path = "" local partial_path = ""
for i, piece in ipairs(pieces) do for i, piece in ipairs(pieces) do
partial_path = fs.join(partial_path, piece) .. "/" partial_path = partial_path .. piece .. "/"
if i ~= #pieces then if i ~= #pieces then
unlisted_dirs[partial_path:sub(2)] = true unlisted_dirs[partial_path] = true
end end
end end
end end
@ -152,8 +146,23 @@ function TmpDir:assert_fs(paths)
assert_fs(self.path, paths) assert_fs(self.path, paths)
end end
function TmpDir:assert_exists(path)
a.util.scheduler()
path = fs.join(self.path, path)
local stat = vim.loop.fs_stat(path)
assert.truthy(stat, string.format("Expected path '%s' to exist", path))
end
function TmpDir:assert_not_exists(path)
a.util.scheduler()
path = fs.join(self.path, path)
local stat = vim.loop.fs_stat(path)
assert.falsy(stat, string.format("Expected path '%s' to not exist", path))
end
function TmpDir:dispose() function TmpDir:dispose()
await(fs.recursive_delete, 3, "directory", self.path) await(fs.recursive_delete, 3, "directory", self.path)
a.util.scheduler()
end end
return TmpDir return TmpDir

164
tests/trash_spec.lua Normal file
View file

@ -0,0 +1,164 @@
local uv = vim.uv or vim.loop
require("plenary.async").tests.add_to_env()
local TmpDir = require("tests.tmpdir")
local fs = require("oil.fs")
local test_util = require("tests.test_util")
---Get the raw list of filenames from an unmodified oil buffer
---@param bufnr? integer
---@return string[]
local function parse_entries(bufnr)
bufnr = bufnr or 0
if vim.bo[bufnr].modified then
error("parse_entries doesn't work on a modified oil buffer")
end
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)
return vim.tbl_map(function(line)
return line:match("^/%d+ +(.+)$")
end, lines)
end
a.describe("freedesktop", function()
local tmpdir
a.before_each(function()
require("oil.config").delete_to_trash = true
tmpdir = TmpDir.new()
package.loaded["oil.adapters.trash"] = require("oil.adapters.trash.freedesktop")
local trash_dir = string.format(".Trash-%d", uv.getuid())
tmpdir:create({ fs.join(trash_dir, "__dummy__") })
end)
a.after_each(function()
if tmpdir then
tmpdir:dispose()
end
test_util.reset_editor()
package.loaded["oil.adapters.trash"] = nil
end)
a.it("files can be moved to the trash", function()
tmpdir:create({ "a.txt", "foo/b.txt" })
test_util.actions.open({ tmpdir.path })
test_util.actions.focus("a.txt")
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.open({ "--trash", tmpdir.path })
vim.api.nvim_feedkeys("p", "x", true)
test_util.actions.save()
tmpdir:assert_not_exists("a.txt")
tmpdir:assert_exists("foo/b.txt")
test_util.actions.reload()
assert.are.same({ "a.txt" }, parse_entries(0))
end)
a.it("deleting a file moves it to trash", function()
tmpdir:create({ "a.txt", "foo/b.txt" })
test_util.actions.open({ tmpdir.path })
test_util.actions.focus("a.txt")
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.save()
tmpdir:assert_not_exists("a.txt")
tmpdir:assert_exists("foo/b.txt")
test_util.actions.open({ "--trash", tmpdir.path })
assert.are.same({ "a.txt" }, parse_entries(0))
end)
a.it("deleting a directory moves it to trash", function()
tmpdir:create({ "a.txt", "foo/b.txt" })
test_util.actions.open({ tmpdir.path })
test_util.actions.focus("foo/")
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.save()
tmpdir:assert_not_exists("foo")
tmpdir:assert_exists("a.txt")
test_util.actions.open({ "--trash", tmpdir.path })
assert.are.same({ "foo/" }, parse_entries(0))
end)
a.it("deleting a file from trash deletes it permanently", function()
tmpdir:create({ "a.txt" })
test_util.actions.open({ tmpdir.path })
test_util.actions.focus("a.txt")
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.save()
test_util.actions.open({ "--trash", tmpdir.path })
test_util.actions.focus("a.txt")
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.save()
test_util.actions.reload()
tmpdir:assert_not_exists("a.txt")
assert.are.same({}, parse_entries(0))
end)
a.it("cannot create files in the trash", function()
tmpdir:create({ "a.txt" })
test_util.actions.open({ tmpdir.path })
test_util.actions.focus("a.txt")
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.save()
test_util.actions.open({ "--trash", tmpdir.path })
vim.api.nvim_feedkeys("onew_file.txt", "x", true)
test_util.actions.save()
test_util.actions.reload()
assert.are.same({ "a.txt" }, parse_entries(0))
end)
a.it("cannot rename files in the trash", function()
tmpdir:create({ "a.txt" })
test_util.actions.open({ tmpdir.path })
test_util.actions.focus("a.txt")
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.save()
test_util.actions.open({ "--trash", tmpdir.path })
vim.api.nvim_feedkeys("0facwnew_name", "x", true)
test_util.actions.save()
test_util.actions.reload()
assert.are.same({ "a.txt" }, parse_entries(0))
end)
a.it("cannot copy files in the trash", function()
tmpdir:create({ "a.txt" })
test_util.actions.open({ tmpdir.path })
test_util.actions.focus("a.txt")
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.save()
test_util.actions.open({ "--trash", tmpdir.path })
vim.api.nvim_feedkeys("yypp", "x", true)
test_util.actions.save()
test_util.actions.reload()
assert.are.same({ "a.txt" }, parse_entries(0))
end)
a.it("can restore files from trash", function()
tmpdir:create({ "a.txt" })
test_util.actions.open({ tmpdir.path })
test_util.actions.focus("a.txt")
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.save()
test_util.actions.open({ "--trash", tmpdir.path })
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.open({ tmpdir.path })
vim.api.nvim_feedkeys("p", "x", true)
test_util.actions.save()
test_util.actions.reload()
assert.are.same({ "a.txt" }, parse_entries(0))
local uid = uv.getuid()
tmpdir:assert_fs({
["a.txt"] = "a.txt",
[".Trash-" .. uid .. "/__dummy__"] = ".Trash-" .. uid .. "/__dummy__",
[".Trash-" .. uid .. "/files/"] = true,
[".Trash-" .. uid .. "/info/"] = true,
})
end)
a.it("can have multiple files with the same name in trash", function()
tmpdir:create({ "a.txt" })
test_util.actions.open({ tmpdir.path })
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.save()
tmpdir:create({ "a.txt" })
test_util.actions.reload()
vim.api.nvim_feedkeys("dd", "x", true)
test_util.actions.save()
test_util.actions.open({ "--trash", tmpdir.path })
assert.are.same({ "a.txt", "a.txt" }, parse_entries(0))
end)
end)

View file

@ -13,7 +13,7 @@ describe("url", function()
} }
for _, case in ipairs(cases) do for _, case in ipairs(cases) do
local input, expected, expected_basename = unpack(case) local input, expected, expected_basename = unpack(case)
local output, basename = oil.get_buffer_parent_url(input) local output, basename = oil.get_buffer_parent_url(input, true)
assert.equals(expected, output, string.format('Parent url for path "%s" failed', input)) assert.equals(expected, output, string.format('Parent url for path "%s" failed', input))
assert.equals( assert.equals(
expected_basename, expected_basename,