diff --git a/README.md b/README.md
index 009ddb46..c198c8b2 100644
--- a/README.md
+++ b/README.md
@@ -43,11 +43,13 @@ oil.nvim supports all the usual plugin managers
Packer
```lua
-require('packer').startup(function()
- use {
- 'stevearc/oil.nvim',
- config = function() require('oil').setup() end
- }
+require("packer").startup(function()
+ use({
+ "stevearc/oil.nvim",
+ config = function()
+ require("oil").setup()
+ end,
+ })
end)
```
@@ -57,9 +59,9 @@ end)
Paq
```lua
-require "paq" {
- {'stevearc/oil.nvim'};
-}
+require("paq")({
+ { "stevearc/oil.nvim" },
+})
```
@@ -154,8 +156,6 @@ require("oil").setup({
delete_to_trash = false,
-- Skip the confirmation popup for simple operations
skip_confirm_for_simple_edits = false,
- -- Change this to customize the command used when deleting to trash
- trash_command = "trash-put",
-- Selecting a new/moved/renamed file or directory will prompt you to save changes first
prompt_save_on_select_new_entry = true,
-- Oil will automatically delete hidden buffers after this delay
@@ -184,6 +184,7 @@ require("oil").setup({
["gs"] = "actions.change_sort",
["gx"] = "actions.open_external",
["g."] = "actions.toggle_hidden",
+ ["g\\"] = "actions.toggle_trash",
},
-- Set to false to disable all of the above keymaps
use_default_keymaps = true,
@@ -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.
-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
diff --git a/doc/oil.txt b/doc/oil.txt
index 0bd9fe64..2da5bb2c 100644
--- a/doc/oil.txt
+++ b/doc/oil.txt
@@ -8,6 +8,7 @@ CONTENTS *oil-content
3. Columns |oil-columns|
4. Actions |oil-actions|
5. Highlights |oil-highlights|
+ 6. Trash |oil-trash|
--------------------------------------------------------------------------------
OPTIONS *oil-options*
@@ -45,8 +46,6 @@ OPTIONS *oil-option
delete_to_trash = false,
-- Skip the confirmation popup for simple operations
skip_confirm_for_simple_edits = false,
- -- Change this to customize the command used when deleting to trash
- trash_command = "trash-put",
-- Selecting a new/moved/renamed file or directory will prompt you to save changes first
prompt_save_on_select_new_entry = true,
-- Oil will automatically delete hidden buffers after this delay
@@ -75,6 +74,7 @@ OPTIONS *oil-option
["gs"] = "actions.change_sort",
["gx"] = "actions.open_external",
["g."] = "actions.toggle_hidden",
+ ["g\\"] = "actions.toggle_trash",
},
-- Set to false to disable all of the above keymaps
use_default_keymaps = true,
@@ -343,6 +343,8 @@ birthtime *column-birthtim
ACTIONS *oil-actions*
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 to the current oil directory
@@ -408,11 +410,14 @@ tcd *actions.tc
toggle_hidden *actions.toggle_hidden*
Toggle hidden files and directories
+toggle_trash *actions.toggle_trash*
+ Jump to and from the trash for the current directory
+
--------------------------------------------------------------------------------
HIGHLIGHTS *oil-highlights*
OilDir *hl-OilDir*
- Directories in an oil buffer
+ Directory names in an oil buffer
OilDirIcon *hl-OilDirIcon*
Icon for directories
@@ -423,6 +428,9 @@ OilSocket *hl-OilSocke
OilLink *hl-OilLink*
Soft links in an oil buffer
+OilLinkTarget *hl-OilLinkTarget*
+ The target of a soft link
+
OilFile *hl-OilFile*
Normal files in an oil buffer
@@ -441,5 +449,45 @@ OilCopy *hl-OilCop
OilChange *hl-OilChange*
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:
diff --git a/lua/oil/actions.lua b/lua/oil/actions.lua
index d446a0b5..2402750e 100644
--- a/lua/oil/actions.lua
+++ b/lua/oil/actions.lua
@@ -1,6 +1,9 @@
local oil = require("oil")
local util = require("oil.util")
+-- TODO remove after https://github.com/folke/neodev.nvim/pull/163 lands
+---@diagnostic disable: inject-field
+
local M = {}
M.show_help = {
@@ -302,6 +305,35 @@ M.change_sort = {
end,
}
+M.toggle_trash = {
+ desc = "Jump to and from the trash for the current directory",
+ callback = function()
+ local fs = require("oil.fs")
+ local bufname = vim.api.nvim_buf_get_name(0)
+ local scheme, path = util.parse_url(bufname)
+ local bufnr = vim.api.nvim_get_current_buf()
+ local url
+ if scheme == "oil://" then
+ url = "oil-trash://" .. path
+ elseif scheme == "oil-trash://" then
+ url = "oil://" .. path
+ -- The non-linux trash implementations don't support per-directory trash,
+ -- so jump back to the stored source buffer.
+ if not fs.is_linux then
+ local src_bufnr = vim.b.oil_trash_toggle_src
+ if src_bufnr and vim.api.nvim_buf_is_valid(src_bufnr) then
+ url = vim.api.nvim_buf_get_name(src_bufnr)
+ end
+ end
+ else
+ vim.notify("No trash found for buffer", vim.log.levels.WARN)
+ return
+ end
+ vim.cmd.edit({ args = { url } })
+ vim.b.oil_trash_toggle_src = bufnr
+ end,
+}
+
---List actions for documentation generation
---@private
M._get_actions = function()
diff --git a/lua/oil/adapters/files.lua b/lua/oil/adapters/files.lua
index fcd3bcf2..72131539 100644
--- a/lua/oil/adapters/files.lua
+++ b/lua/oil/adapters/files.lua
@@ -7,6 +7,7 @@ local permissions = require("oil.adapters.files.permissions")
local trash = require("oil.adapters.files.trash")
local util = require("oil.util")
local uv = vim.uv or vim.loop
+
local M = {}
local FIELD_NAME = constants.FIELD_NAME
@@ -147,7 +148,11 @@ if not fs.is_windows then
}
end
-local current_year = vim.fn.strftime("%Y")
+local current_year
+-- Make sure we run this import-time effect in the main loop (mostly for tests)
+vim.schedule(function()
+ current_year = vim.fn.strftime("%Y")
+end)
for _, time_key in ipairs({ "ctime", "mtime", "atime", "birthtime" }) do
file_columns[time_key] = {
@@ -436,7 +441,12 @@ M.render_action = function(action)
elseif action.type == "delete" then
local _, path = util.parse_url(action.url)
assert(path)
- return string.format("DELETE %s", M.to_short_os_path(path, action.entry_type))
+ local short_path = M.to_short_os_path(path, action.entry_type)
+ if config.delete_to_trash then
+ return string.format(" TRASH %s", short_path)
+ else
+ return string.format("DELETE %s", short_path)
+ end
elseif action.type == "move" or action.type == "copy" then
local dest_adapter = config.get_adapter_by_scheme(action.dest_url)
if dest_adapter == M then
@@ -451,7 +461,7 @@ M.render_action = function(action)
M.to_short_os_path(dest_path, action.entry_type)
)
else
- -- We should never hit this because we don't implement supported_adapters_for_copy
+ -- We should never hit this because we don't implement supported_cross_adapter_actions
error("files adapter doesn't support cross-adapter move/copy")
end
else
@@ -494,7 +504,15 @@ M.perform_action = function(action, cb)
assert(path)
path = fs.posix_to_os_path(path)
if config.delete_to_trash then
- trash.recursive_delete(path, cb)
+ if config.trash_command then
+ vim.notify_once(
+ "Oil now has native support for trash. Remove the `trash_command` from your config to try it out!",
+ vim.log.levels.WARN
+ )
+ trash.recursive_delete(path, cb)
+ else
+ require("oil.adapters.trash").delete_to_trash(path, cb)
+ end
else
fs.recursive_delete(action.entry_type, path, cb)
end
@@ -507,9 +525,9 @@ M.perform_action = function(action, cb)
assert(dest_path)
src_path = fs.posix_to_os_path(src_path)
dest_path = fs.posix_to_os_path(dest_path)
- fs.recursive_move(action.entry_type, src_path, dest_path, vim.schedule_wrap(cb))
+ fs.recursive_move(action.entry_type, src_path, dest_path, cb)
else
- -- We should never hit this because we don't implement supported_adapters_for_copy
+ -- We should never hit this because we don't implement supported_cross_adapter_actions
cb("files adapter doesn't support cross-adapter move")
end
elseif action.type == "copy" then
@@ -523,7 +541,7 @@ M.perform_action = function(action, cb)
dest_path = fs.posix_to_os_path(dest_path)
fs.recursive_copy(action.entry_type, src_path, dest_path, cb)
else
- -- We should never hit this because we don't implement supported_adapters_for_copy
+ -- We should never hit this because we don't implement supported_cross_adapter_actions
cb("files adapter doesn't support cross-adapter copy")
end
else
diff --git a/lua/oil/adapters/ssh.lua b/lua/oil/adapters/ssh.lua
index 1fa0057f..7707b954 100644
--- a/lua/oil/adapters/ssh.lua
+++ b/lua/oil/adapters/ssh.lua
@@ -348,7 +348,7 @@ M.perform_action = function(action, cb)
end
end
-M.supported_adapters_for_copy = { files = true }
+M.supported_cross_adapter_actions = { files = "copy" }
---@param bufnr integer
M.read_file = function(bufnr)
diff --git a/lua/oil/adapters/trash.lua b/lua/oil/adapters/trash.lua
new file mode 100644
index 00000000..3c7ffeb9
--- /dev/null
+++ b/lua/oil/adapters/trash.lua
@@ -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
diff --git a/lua/oil/adapters/trash/freedesktop.lua b/lua/oil/adapters/trash/freedesktop.lua
new file mode 100644
index 00000000..3ba211ff
--- /dev/null
+++ b/lua/oil/adapters/trash/freedesktop.lua
@@ -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
diff --git a/lua/oil/adapters/trash/mac.lua b/lua/oil/adapters/trash/mac.lua
new file mode 100644
index 00000000..46d4b4ae
--- /dev/null
+++ b/lua/oil/adapters/trash/mac.lua
@@ -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
diff --git a/lua/oil/adapters/trash/windows.lua b/lua/oil/adapters/trash/windows.lua
new file mode 100644
index 00000000..f2c37c91
--- /dev/null
+++ b/lua/oil/adapters/trash/windows.lua
@@ -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
diff --git a/lua/oil/cache.lua b/lua/oil/cache.lua
index 0f6a27e6..09ed44fc 100644
--- a/lua/oil/cache.lua
+++ b/lua/oil/cache.lua
@@ -4,10 +4,12 @@ local M = {}
local FIELD_ID = constants.FIELD_ID
local FIELD_NAME = constants.FIELD_NAME
+local FIELD_META = constants.FIELD_META
local next_id = 1
-- Map>
+---@type table>
local url_directory = {}
---@type table
@@ -118,6 +120,15 @@ M.get_entry_by_id = function(id)
return entries_by_id[id]
end
+---@param url string
+---@return nil|oil.InternalEntry
+M.get_entry_by_url = function(url)
+ local scheme, path = util.parse_url(url)
+ local parent_url = scheme .. vim.fn.fnamemodify(path, ":h")
+ local basename = vim.fn.fnamemodify(path, ":t")
+ return M.list_url(parent_url)[basename]
+end
+
---@param id integer
---@return string
M.get_parent_url = function(id)
@@ -129,18 +140,12 @@ M.get_parent_url = function(id)
end
---@param url string
----@return oil.InternalEntry[]
+---@return table
M.list_url = function(url)
url = util.addslash(url)
return url_directory[url] or {}
end
-M.get_entry_by_url = function(url)
- local parent, name = url:match("^(.+)/([^/]+)$")
- local cache = url_directory[parent]
- return cache and cache[name]
-end
-
---@param action oil.Action
M.perform_action = function(action)
if action.type == "create" then
@@ -172,6 +177,8 @@ M.perform_action = function(action)
dest_parent = {}
url_directory[dest_parent_url] = dest_parent
end
+ -- We have to clear the metadata because it can be inaccurate after the move
+ entry[FIELD_META] = nil
dest_parent[dest_name] = entry
parent_url_by_id[entry[FIELD_ID]] = dest_parent_url
entry[FIELD_NAME] = dest_name
diff --git a/lua/oil/columns.lua b/lua/oil/columns.lua
index 7140f2a2..7e217afa 100644
--- a/lua/oil/columns.lua
+++ b/lua/oil/columns.lua
@@ -221,6 +221,9 @@ if has_devicons then
icon = conf and conf.directory or ""
hl = "OilDirIcon"
else
+ if meta and meta.display_name then
+ name = meta.display_name
+ end
icon, hl = devicons.get_icon(name)
icon = icon or (conf and conf.default_file or "")
end
diff --git a/lua/oil/config.lua b/lua/oil/config.lua
index 8bc18027..0e419d26 100644
--- a/lua/oil/config.lua
+++ b/lua/oil/config.lua
@@ -1,3 +1,5 @@
+local uv = vim.uv or vim.loop
+
local default_config = {
-- Oil will take over directory buffers (e.g. `vim .` or `:e src/`)
-- Set to false if you still want to use netrw.
@@ -30,8 +32,6 @@ local default_config = {
delete_to_trash = false,
-- Skip the confirmation popup for simple operations
skip_confirm_for_simple_edits = false,
- -- Change this to customize the command used when deleting to trash
- trash_command = "trash-put",
-- Selecting a new/moved/renamed file or directory will prompt you to save changes first
prompt_save_on_select_new_entry = true,
-- Oil will automatically delete hidden buffers after this delay
@@ -60,6 +60,7 @@ local default_config = {
["gs"] = "actions.change_sort",
["gx"] = "actions.open_external",
["g."] = "actions.toggle_hidden",
+ ["g\\"] = "actions.toggle_trash",
},
-- Set to false to disable all of the above keymaps
use_default_keymaps = true,
@@ -142,6 +143,7 @@ local default_config = {
default_config.adapters = {
["oil://"] = "files",
["oil-ssh://"] = "ssh",
+ ["oil-trash://"] = "trash",
}
default_config.adapter_aliases = {}
@@ -154,13 +156,10 @@ M.setup = function(opts)
end
if new_conf.delete_to_trash then
- local trash_bin = vim.split(new_conf.trash_command, " ")[1]
- if vim.fn.executable(trash_bin) == 0 then
+ local is_windows = uv.os_uname().version:match("Windows")
+ if is_windows then
vim.notify(
- string.format(
- "oil.nvim: delete_to_trash is true, but '%s' executable not found.\nDeleted files will be permanently removed.",
- new_conf.trash_command
- ),
+ "oil.nvim: delete_to_trash is true, but trash is not yet supported on Windows.\nDeleted files will be permanently removed",
vim.log.levels.WARN
)
new_conf.delete_to_trash = false
@@ -176,46 +175,6 @@ M.setup = function(opts)
M.adapter_to_scheme[v] = k
end
M._adapter_by_scheme = {}
- if type(M.trash) == "string" then
- M.trash = vim.fn.fnamemodify(vim.fn.expand(M.trash), ":p")
- end
-end
-
----@return nil|string
-M.get_trash_url = function()
- if not M.trash then
- return nil
- end
- local fs = require("oil.fs")
- if M.trash == true then
- local data_home = os.getenv("XDG_DATA_HOME") or vim.fn.expand("~/.local/share")
- local preferred = fs.join(data_home, "trash")
- local candidates = {
- preferred,
- }
- if fs.is_windows then
- -- TODO permission issues when using the recycle bin. The folder gets created without
- -- read/write perms, so all operations fail
- -- local cwd = vim.fn.getcwd()
- -- table.insert(candidates, 1, cwd:sub(1, 3) .. "$Recycle.Bin")
- -- table.insert(candidates, 1, "C:\\$Recycle.Bin")
- else
- table.insert(candidates, fs.join(data_home, "Trash", "files"))
- table.insert(candidates, fs.join(os.getenv("HOME"), ".Trash"))
- end
- local trash_dir = preferred
- for _, candidate in ipairs(candidates) do
- if vim.fn.isdirectory(candidate) == 1 then
- trash_dir = candidate
- break
- end
- end
-
- local oil_trash_dir = vim.fn.fnamemodify(fs.join(trash_dir, "nvim", "oil"), ":p")
- fs.mkdirp(oil_trash_dir)
- M.trash = oil_trash_dir
- end
- return M.adapter_to_scheme.files .. fs.os_to_posix_path(M.trash)
end
---@param scheme nil|string
diff --git a/lua/oil/fs.lua b/lua/oil/fs.lua
index 4f612a50..3de1acd6 100644
--- a/lua/oil/fs.lua
+++ b/lua/oil/fs.lua
@@ -7,6 +7,8 @@ M.is_windows = uv.os_uname().version:match("Windows")
M.is_mac = uv.os_uname().sysname == "Darwin"
+M.is_linux = not M.is_windows and not M.is_mac
+
---@type string
M.sep = M.is_windows and "\\" or "/"
@@ -114,20 +116,31 @@ end
local home_dir = assert(uv.os_homedir())
---@param path string
+---@param relative_to? string Shorten relative to this path (default cwd)
---@return string
-M.shorten_path = function(path)
- local cwd = vim.fn.getcwd()
- if M.is_subpath(cwd, path) then
- local relative = path:sub(cwd:len() + 2)
- if relative == "" then
- relative = "."
+M.shorten_path = function(path, relative_to)
+ if not relative_to then
+ relative_to = vim.fn.getcwd()
+ end
+ local relpath
+ if M.is_subpath(relative_to, path) then
+ local idx = relative_to:len() + 1
+ -- Trim the dividing slash if it's not included in relative_to
+ if not vim.endswith(relative_to, "/") and not vim.endswith(relative_to, "\\") then
+ idx = idx + 1
+ end
+ relpath = path:sub(idx)
+ if relpath == "" then
+ relpath = "."
end
- return relative
end
if M.is_subpath(home_dir, path) then
- return "~" .. path:sub(home_dir:len() + 1)
+ local homepath = "~" .. path:sub(home_dir:len() + 1)
+ if not relpath or homepath:len() < relpath:len() then
+ return homepath
+ end
end
- return path
+ return relpath or path
end
M.mkdirp = function(dir)
@@ -177,7 +190,7 @@ M.listdir = function(dir, cb)
end
read_next()
---@diagnostic disable-next-line: param-type-mismatch
- end, 100) -- TODO do some testing for this
+ end, 10000)
end
---@param entry_type oil.EntryType
diff --git a/lua/oil/init.lua b/lua/oil/init.lua
index 8cdbd301..94424cd2 100644
--- a/lua/oil/init.lua
+++ b/lua/oil/init.lua
@@ -8,6 +8,7 @@ local M = {}
---@alias oil.EntryType "file"|"directory"|"socket"|"link"|"fifo"
---@alias oil.TextChunk string|string[]
+---@alias oil.CrossAdapterAction "copy"|"move"
---@class (exact) oil.Adapter
---@field name string The unique name of the adapter (this will be set automatically)
@@ -20,7 +21,9 @@ local M = {}
---@field perform_action? fun(action: oil.Action, cb: fun(err: nil|string)) Perform a mutation action. Only needed if adapter is modifiable.
---@field read_file? fun(bufnr: integer) Used for adapters that deal with remote/virtual files. Read the contents of the file into a buffer.
---@field write_file? fun(bufnr: integer) Used for adapters that deal with remote/virtual files. Write the contents of a buffer to the destination.
----@field supported_adapters_for_copy? table 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 Mapping of adapter name to enum for all other adapters that can be used as a src or dest for move/copy actions.
+---@field filter_action? fun(action: oil.Action): boolean When present, filter out actions as they are created
+---@field filter_error? fun(action: oil.ParseError): boolean When present, filter out errors from parsing a buffer
-- TODO remove after https://github.com/folke/neodev.nvim/pull/163 lands
---@diagnostic disable: undefined-field
@@ -110,34 +113,6 @@ M.discard_all_changes = function()
end
end
----Delete all files in the trash directory
----@private
----@note
---- Trash functionality is incomplete and experimental.
-M.empty_trash = function()
- local config = require("oil.config")
- local fs = require("oil.fs")
- local util = require("oil.util")
- local trash_url = config.get_trash_url()
- if not trash_url then
- vim.notify("No trash directory configured", vim.log.levels.WARN)
- return
- end
- local _, path = util.parse_url(trash_url)
- assert(path)
- local dir = fs.posix_to_os_path(path)
- if vim.fn.isdirectory(dir) == 1 then
- fs.recursive_delete("directory", dir, function(err)
- if err then
- vim.notify(string.format("Error emptying trash: %s", err), vim.log.levels.ERROR)
- else
- vim.notify("Trash emptied")
- fs.mkdirp(dir)
- end
- end)
- end
-end
-
---Change the display columns for oil
---@param cols oil.ColumnSpec[]
M.set_columns = function(cols)
@@ -177,9 +152,13 @@ end
---Get the oil url for a given directory
---@private
---@param dir nil|string When nil, use the cwd
----@return nil|string The parent url
+---@param use_oil_parent nil|boolean If in an oil buffer, return the parent (default true)
+---@return string The parent url
---@return nil|string The basename (if present) of the file/dir we were just in
-M.get_url_for_path = function(dir)
+M.get_url_for_path = function(dir, use_oil_parent)
+ if use_oil_parent == nil then
+ use_oil_parent = true
+ end
local config = require("oil.config")
local fs = require("oil.fs")
local util = require("oil.util")
@@ -196,15 +175,16 @@ M.get_url_for_path = function(dir)
return config.adapter_to_scheme.files .. path
else
local bufname = vim.api.nvim_buf_get_name(0)
- return M.get_buffer_parent_url(bufname)
+ return M.get_buffer_parent_url(bufname, use_oil_parent)
end
end
---@private
---@param bufname string
+---@param use_oil_parent boolean If in an oil buffer, return the parent
---@return string
---@return nil|string
-M.get_buffer_parent_url = function(bufname)
+M.get_buffer_parent_url = function(bufname, use_oil_parent)
local config = require("oil.config")
local fs = require("oil.fs")
local pathutil = require("oil.pathutil")
@@ -223,13 +203,15 @@ M.get_buffer_parent_url = function(bufname)
return parent_url, basename
else
assert(path)
- -- TODO maybe we should remove this special case and turn it into a config
if scheme == "term://" then
---@type string
path = vim.fn.expand(path:match("^(.*)//")) ---@diagnostic disable-line: assign-type-mismatch
return config.adapter_to_scheme.files .. util.addslash(path)
end
+ if not use_oil_parent then
+ return bufname
+ end
local adapter = config.get_adapter_by_scheme(scheme)
local parent_url
if adapter and adapter.get_parent then
@@ -672,7 +654,7 @@ M._get_highlights = function()
{
name = "OilDir",
link = "Directory",
- desc = "Directories in an oil buffer",
+ desc = "Directory names in an oil buffer",
},
{
name = "OilDirIcon",
@@ -689,6 +671,11 @@ M._get_highlights = function()
link = nil,
desc = "Soft links in an oil buffer",
},
+ {
+ name = "OilLinkTarget",
+ link = "Comment",
+ desc = "The target of a soft link",
+ },
{
name = "OilFile",
link = nil,
@@ -719,6 +706,26 @@ M._get_highlights = function()
link = "Special",
desc = "Change action in the oil preview window",
},
+ {
+ name = "OilRestore",
+ link = "OilCreate",
+ desc = "Restore (from the trash) action in the oil preview window",
+ },
+ {
+ name = "OilPurge",
+ link = "OilDelete",
+ desc = "Purge (Permanently delete a file from trash) action in the oil preview window",
+ },
+ {
+ name = "OilTrash",
+ link = "OilDelete",
+ desc = "Trash (delete a file to trash) action in the oil preview window",
+ },
+ {
+ name = "OilTrashSourcePath",
+ link = "Comment",
+ desc = "Virtual text that shows the original path of file in the trash",
+ },
}
end
@@ -855,14 +862,23 @@ M.setup = function(opts)
config.setup(opts)
set_colors()
vim.api.nvim_create_user_command("Oil", function(args)
+ local util = require("oil.util")
if args.smods.tab == 1 then
vim.cmd.tabnew()
end
local float = false
- for i, v in ipairs(args.fargs) do
+ local trash = false
+ local i = 1
+ while i <= #args.fargs do
+ local v = args.fargs[i]
if v == "--float" then
float = true
table.remove(args.fargs, i)
+ elseif v == "--trash" then
+ trash = true
+ table.remove(args.fargs, i)
+ else
+ i = i + 1
end
end
@@ -875,7 +891,13 @@ M.setup = function(opts)
end
local method = float and "open_float" or "open"
- M[method](unpack(args.fargs))
+ local path = args.fargs[1]
+ if trash then
+ local url = M.get_url_for_path(path, false)
+ local _, new_path = util.parse_url(url)
+ path = "oil-trash://" .. new_path
+ end
+ M[method](path)
end, { desc = "Open oil file browser on a directory", nargs = "*", complete = "dir" })
local aug = vim.api.nvim_create_augroup("Oil", {})
diff --git a/lua/oil/mutator/init.lua b/lua/oil/mutator/init.lua
index c3bfc5ea..29d04a6c 100644
--- a/lua/oil/mutator/init.lua
+++ b/lua/oil/mutator/init.lua
@@ -7,7 +7,6 @@ local constants = require("oil.constants")
local lsp_helpers = require("oil.lsp_helpers")
local oil = require("oil")
local parser = require("oil.mutator.parser")
-local pathutil = require("oil.pathutil")
local preview = require("oil.mutator.preview")
local util = require("oil.util")
local view = require("oil.view")
@@ -54,6 +53,7 @@ M.create_actions_from_diffs = function(all_diffs)
---@type oil.Action[]
local actions = {}
+ ---@type table
local diff_by_id = setmetatable({}, {
__index = function(t, key)
local list = {}
@@ -61,6 +61,15 @@ M.create_actions_from_diffs = function(all_diffs)
return list
end,
})
+ ---@param action oil.Action
+ local function add_action(action)
+ local adapter = assert(config.get_adapter_by_scheme(action.dest_url or action.url))
+ if not adapter.filter_action or adapter.filter_action(action) then
+ table.insert(actions, action)
+ end
+ end
+ ---@type table
+ local dest_by_id = {}
for bufnr, diffs in pairs(all_diffs) do
local adapter = util.get_adapter(bufnr)
if not adapter then
@@ -71,9 +80,7 @@ M.create_actions_from_diffs = function(all_diffs)
if diff.type == "new" then
if diff.id then
local by_id = diff_by_id[diff.id]
- -- FIXME this is kind of a hack. We shouldn't be setting undocumented fields on the diff
- ---@diagnostic disable-next-line: inject-field
- diff.dest = parent_url .. diff.name
+ dest_by_id[diff.id] = parent_url .. diff.name
table.insert(by_id, diff)
else
-- Parse nested files like foo/bar/baz
@@ -87,7 +94,7 @@ M.create_actions_from_diffs = function(all_diffs)
-- Parse alternations like foo.{js,test.js}
for _, alt in ipairs(vim.split(alternation, ",")) do
local alt_url = url .. "/" .. v:gsub("{[^}]+}", alt)
- table.insert(actions, {
+ add_action({
type = "create",
url = alt_url,
entry_type = entry_type,
@@ -96,7 +103,7 @@ M.create_actions_from_diffs = function(all_diffs)
end
else
url = url .. "/" .. v
- table.insert(actions, {
+ add_action({
type = "create",
url = url,
entry_type = entry_type,
@@ -106,7 +113,7 @@ M.create_actions_from_diffs = function(all_diffs)
end
end
elseif diff.type == "change" then
- table.insert(actions, {
+ add_action({
type = "change",
url = parent_url .. diff.name,
entry_type = diff.entry_type,
@@ -115,8 +122,9 @@ M.create_actions_from_diffs = function(all_diffs)
})
else
local by_id = diff_by_id[diff.id]
+ -- HACK: set has_delete field on a list-like table of diffs
by_id.has_delete = true
- -- Don't insert the delete. We already know that there is a delete because of the presense
+ -- Don't insert the delete. We already know that there is a delete because of the presence
-- in the diff_by_id map. The list will only include the 'new' diffs.
end
end
@@ -127,21 +135,23 @@ M.create_actions_from_diffs = function(all_diffs)
if not entry then
error(string.format("Could not find entry %d", id))
end
+ ---HACK: access the has_delete field on the list-like table of diffs
+ ---@diagnostic disable-next-line: undefined-field
if diffs.has_delete then
local has_create = #diffs > 0
if has_create then
-- MOVE (+ optional copies) when has both creates and delete
for i, diff in ipairs(diffs) do
- table.insert(actions, {
+ add_action({
type = i == #diffs and "move" or "copy",
entry_type = entry[FIELD_TYPE],
- dest_url = diff.dest,
+ dest_url = dest_by_id[diff.id],
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
})
end
else
-- DELETE when no create
- table.insert(actions, {
+ add_action({
type = "delete",
entry_type = entry[FIELD_TYPE],
url = cache.get_parent_url(id) .. entry[FIELD_NAME],
@@ -150,11 +160,11 @@ M.create_actions_from_diffs = function(all_diffs)
else
-- COPY when create but no delete
for _, diff in ipairs(diffs) do
- table.insert(actions, {
+ add_action({
type = "copy",
entry_type = entry[FIELD_TYPE],
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
- dest_url = diff.dest,
+ dest_url = dest_by_id[diff.id],
})
end
end
@@ -353,30 +363,6 @@ end
---@param actions oil.Action[]
---@param cb fun(err: nil|string)
M.process_actions = function(actions, cb)
- -- convert delete actions to move-to-trash
- local trash_url = config.get_trash_url()
- if trash_url then
- for i, v in ipairs(actions) do
- if v.type == "delete" then
- local scheme, path = util.parse_url(v.url)
- if config.adapters[scheme] == "files" then
- assert(path)
- ---@type oil.MoveAction
- local move_action = {
- type = "move",
- src_url = v.url,
- entry_type = v.entry_type,
- dest_url = trash_url .. "/" .. pathutil.basename(path) .. string.format(
- "_%06d",
- math.random(999999)
- ),
- }
- actions[i] = move_action
- end
- end
- end
- end
-
-- send all renames to LSP servers
local moves = {}
for _, action in ipairs(actions) do
@@ -390,12 +376,12 @@ M.process_actions = function(actions, cb)
end
lsp_helpers.will_rename_files(moves)
- -- Convert cross-adapter moves to a copy + delete
+ -- Convert some cross-adapter moves to a copy + delete
for _, action in ipairs(actions) do
if action.type == "move" then
- local src_scheme = util.parse_url(action.src_url)
- local dest_scheme = util.parse_url(action.dest_url)
- if src_scheme ~= dest_scheme then
+ local _, cross_action = util.get_adapter_for_action(action)
+ -- Only do the conversion if the cross-adapter support is "copy"
+ if cross_action == "copy" then
action.type = "copy"
table.insert(actions, {
type = "delete",
@@ -488,6 +474,10 @@ M.try_write_changes = function(confirm)
if vim.bo[bufnr].modified then
local diffs, errors = parser.parse(bufnr)
all_diffs[bufnr] = diffs
+ local adapter = assert(util.get_adapter(bufnr))
+ if adapter.filter_error then
+ errors = vim.tbl_filter(adapter.filter_error, errors)
+ end
if not vim.tbl_isempty(errors) then
all_errors[bufnr] = errors
end
@@ -539,7 +529,7 @@ M.try_write_changes = function(confirm)
view.unlock_buffers()
if err then
vim.notify(string.format("[oil] Error applying actions: %s", err), vim.log.levels.ERROR)
- view.rerender_all_oil_buffers({ preserve_undo = false })
+ view.rerender_all_oil_buffers()
else
local current_entry = oil.get_cursor_entry()
if current_entry then
@@ -549,7 +539,8 @@ M.try_write_changes = function(confirm)
vim.split(current_entry.parsed_name or current_entry.name, "/")[1]
)
end
- view.rerender_all_oil_buffers({ preserve_undo = M.trash })
+ view.rerender_all_oil_buffers()
+ vim.api.nvim_exec_autocmds("User", { pattern = "OilMutationComplete", modeline = false })
end
mutation_in_progress = false
end)
diff --git a/lua/oil/mutator/parser.lua b/lua/oil/mutator/parser.lua
index 1c0e32d2..e3823a15 100644
--- a/lua/oil/mutator/parser.lua
+++ b/lua/oil/mutator/parser.lua
@@ -142,11 +142,18 @@ M.parse_line = function(adapter, line, column_defs)
return { data = ret, entry = entry, ranges = ranges }
end
+---@class (exact) oil.ParseError
+---@field lnum integer
+---@field col integer
+---@field message string
+
---@param bufnr integer
----@return oil.Diff[]
----@return table[] Parsing errors
+---@return oil.Diff[] diffs
+---@return oil.ParseError[] errors Parsing errors
M.parse = function(bufnr)
+ ---@type oil.Diff[]
local diffs = {}
+ ---@type oil.ParseError[]
local errors = {}
local bufname = vim.api.nvim_buf_get_name(bufnr)
local adapter = util.get_adapter(bufnr)
@@ -158,11 +165,14 @@ M.parse = function(bufnr)
})
return diffs, errors
end
+
+ local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)
local scheme, path = util.parse_url(bufname)
- local parent_url = scheme .. path
local column_defs = columns.get_supported_columns(adapter)
+ local parent_url = scheme .. path
local children = cache.list_url(parent_url)
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)
+ -- map from name to entry ID for all entries previously in the buffer
+ ---@type table
local original_entries = {}
for _, child in pairs(children) do
local name = child[FIELD_NAME]
@@ -184,6 +194,7 @@ M.parse = function(bufnr)
end
for i, line in ipairs(lines) do
if line:match("^/%d+") then
+ -- Parse the line for an existing entry
local result, err = M.parse_line(adapter, line, column_defs)
if not result or err then
table.insert(errors, {
@@ -256,6 +267,7 @@ M.parse = function(bufnr)
end
end
else
+ -- Parse a new entry
local name, isdir = parsedir(vim.trim(line))
if vim.startswith(name, "/") then
table.insert(errors, {
diff --git a/lua/oil/util.lua b/lua/oil/util.lua
index 4020d70d..027b5db8 100644
--- a/lua/oil/util.lua
+++ b/lua/oil/util.lua
@@ -453,6 +453,7 @@ end
---@param action oil.Action
---@return oil.Adapter
+---@return nil|oil.CrossAdapterAction
M.get_adapter_for_action = function(action)
local adapter = config.get_adapter_by_scheme(action.url or action.src_url)
if not adapter then
@@ -462,15 +463,15 @@ M.get_adapter_for_action = function(action)
local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url))
if adapter ~= dest_adapter then
if
- adapter.supported_adapters_for_copy
- and adapter.supported_adapters_for_copy[dest_adapter.name]
+ adapter.supported_cross_adapter_actions
+ and adapter.supported_cross_adapter_actions[dest_adapter.name]
then
- return adapter
+ return adapter, adapter.supported_cross_adapter_actions[dest_adapter.name]
elseif
- dest_adapter.supported_adapters_for_copy
- and dest_adapter.supported_adapters_for_copy[adapter.name]
+ dest_adapter.supported_cross_adapter_actions
+ and dest_adapter.supported_cross_adapter_actions[adapter.name]
then
- return dest_adapter
+ return dest_adapter, dest_adapter.supported_cross_adapter_actions[adapter.name]
else
error(
string.format(
diff --git a/lua/oil/view.lua b/lua/oil/view.lua
index d9023392..4a2a7d03 100644
--- a/lua/oil/view.lua
+++ b/lua/oil/view.lua
@@ -1,7 +1,9 @@
+local uv = vim.uv or vim.loop
local cache = require("oil.cache")
local columns = require("oil.columns")
local config = require("oil.config")
local constants = require("oil.constants")
+local fs = require("oil.fs")
local keymap_util = require("oil.keymap_util")
local loading = require("oil.loading")
local util = require("oil.util")
@@ -142,10 +144,11 @@ M.unlock_buffers = function()
end
end
----@param opts table
+---@param opts? table
---@note
--- This DISCARDS ALL MODIFICATIONS a user has made to oil buffers
M.rerender_all_oil_buffers = function(opts)
+ opts = opts or {}
local buffers = M.get_all_buffers()
local hidden_buffers = {}
for _, bufnr in ipairs(buffers) do
@@ -177,8 +180,8 @@ end
---Get a list of visible oil buffers and a list of hidden oil buffers
---@note
--- If any buffers are modified, return values are nil
----@return nil|integer[]
----@return nil|integer[]
+---@return nil|integer[] visible
+---@return nil|integer[] hidden
local function get_visible_hidden_buffers()
local buffers = M.get_all_buffers()
local hidden_buffers = {}
@@ -227,6 +230,43 @@ local function get_first_mutable_column_col(adapter, ranges)
return min_col
end
+---Redraw original path virtual text for trash buffer
+---@param bufnr integer
+local function redraw_trash_virtual_text(bufnr)
+ if not vim.api.nvim_buf_is_valid(bufnr) or not vim.api.nvim_buf_is_loaded(bufnr) then
+ return
+ end
+ local parser = require("oil.mutator.parser")
+ local adapter = util.get_adapter(bufnr)
+ if not adapter or adapter.name ~= "trash" then
+ return
+ end
+ local _, buf_path = util.parse_url(vim.api.nvim_buf_get_name(bufnr))
+ local os_path = fs.posix_to_os_path(assert(buf_path))
+ local ns = vim.api.nvim_create_namespace("OilVtext")
+ vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
+ local column_defs = columns.get_supported_columns(adapter)
+ for lnum, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)) do
+ local result = parser.parse_line(adapter, line, column_defs)
+ local entry = result and result.entry
+ if entry then
+ local meta = entry[FIELD_META]
+ ---@type nil|oil.TrashInfo
+ local trash_info = meta and meta.trash_info
+ if trash_info then
+ vim.api.nvim_buf_set_extmark(bufnr, ns, lnum - 1, 0, {
+ virt_text = {
+ {
+ "➜ " .. fs.shorten_path(trash_info.original_path, os_path),
+ "OilTrashSourcePath",
+ },
+ },
+ })
+ end
+ end
+ end
+end
+
---@param bufnr integer
M.initialize = function(bufnr)
if bufnr == 0 then
@@ -255,7 +295,7 @@ M.initialize = function(bufnr)
nested = true,
buffer = bufnr,
callback = function()
- -- First wait a short time (10ms) for the buffer change to settle
+ -- First wait a short time (100ms) for the buffer change to settle
vim.defer_fn(function()
local visible_buffers = get_visible_hidden_buffers()
-- Only delete oil buffers if none of them are visible
@@ -271,7 +311,7 @@ M.initialize = function(bufnr)
end
end
end
- end, 10)
+ end, 100)
end,
})
vim.api.nvim_create_autocmd("BufDelete", {
@@ -351,6 +391,36 @@ M.initialize = function(bufnr)
end)
end,
})
+
+ -- Watch for TextChanged and update the trash original path extmarks
+ local adapter = util.get_adapter(bufnr)
+ if adapter and adapter.name == "trash" then
+ local debounce_timer = assert(uv.new_timer())
+ local pending = false
+ vim.api.nvim_create_autocmd("TextChanged", {
+ desc = "Update oil virtual text of original path",
+ buffer = bufnr,
+ callback = function()
+ -- Respond immediately to prevent flickering, the set the timer for a "cooldown period"
+ -- If this is called again during the cooldown window, we will rerender after cooldown.
+ if debounce_timer:is_active() then
+ pending = true
+ else
+ redraw_trash_virtual_text(bufnr)
+ end
+ debounce_timer:start(
+ 50,
+ 0,
+ vim.schedule_wrap(function()
+ if pending then
+ pending = false
+ redraw_trash_virtual_text(bufnr)
+ end
+ end)
+ )
+ end,
+ })
+ end
M.render_buffer_async(bufnr, {}, function(err)
if err then
vim.notify(
@@ -358,6 +428,7 @@ M.initialize = function(bufnr)
vim.log.levels.ERROR
)
else
+ vim.b[bufnr].oil_ready = true
vim.api.nvim_exec_autocmds(
"User",
{ pattern = "OilEnter", modeline = false, data = { buf = bufnr } }
@@ -478,6 +549,7 @@ local function render_buffer(bufnr, opts)
vim.bo[bufnr].modifiable = false
vim.bo[bufnr].modified = false
util.set_highlights(bufnr, highlights)
+
if opts.jump then
-- TODO why is the schedule necessary?
vim.schedule(function()
@@ -511,6 +583,10 @@ end
---@return oil.TextChunk[]
M.format_entry_cols = function(entry, column_defs, col_width, adapter)
local name = entry[FIELD_NAME]
+ local meta = entry[FIELD_META]
+ if meta and meta.display_name then
+ name = meta.display_name
+ end
-- First put the unique ID
local cols = {}
local id_key = cache.format_id(entry[FIELD_ID])
@@ -531,7 +607,6 @@ M.format_entry_cols = function(entry, column_defs, col_width, adapter)
elseif entry_type == "socket" then
table.insert(cols, { name, "OilSocket" })
elseif entry_type == "link" then
- local meta = entry[FIELD_META]
local link_text
if meta then
if meta.link_stat and meta.link_stat.type == "directory" then
@@ -548,7 +623,7 @@ M.format_entry_cols = function(entry, column_defs, col_width, adapter)
table.insert(cols, { name, "OilLink" })
if link_text then
- table.insert(cols, { link_text, "Comment" })
+ table.insert(cols, { link_text, "OilLinkTarget" })
end
else
table.insert(cols, { name, "OilFile" })
@@ -573,29 +648,22 @@ end
---@param bufnr integer
---@param opts nil|table
---- preserve_undo nil|boolean
--- refetch nil|boolean Defaults to true
---@param callback nil|fun(err: nil|string)
M.render_buffer_async = function(bufnr, opts, callback)
opts = vim.tbl_deep_extend("keep", opts or {}, {
- preserve_undo = false,
refetch = true,
})
if bufnr == 0 then
bufnr = vim.api.nvim_get_current_buf()
end
local bufname = vim.api.nvim_buf_get_name(bufnr)
- local scheme, dir = util.parse_url(bufname)
- local preserve_undo = opts.preserve_undo and config.adapters[scheme] == "files"
- if not preserve_undo then
- -- Undo should not return to a blank buffer
- -- Method taken from :h clear-undo
- vim.bo[bufnr].undolevels = -1
- end
+ local _, dir = util.parse_url(bufname)
+ -- Undo should not return to a blank buffer
+ -- Method taken from :h clear-undo
+ vim.bo[bufnr].undolevels = -1
local handle_error = vim.schedule_wrap(function(message)
- if not preserve_undo then
- vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
- end
+ vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
util.render_text(bufnr, { "Error: " .. message })
if callback then
callback(message)
@@ -612,7 +680,7 @@ M.render_buffer_async = function(bufnr, opts, callback)
handle_error(string.format("[oil] no adapter for buffer '%s'", bufname))
return
end
- local start_ms = vim.loop.hrtime() / 1e6
+ local start_ms = uv.hrtime() / 1e6
local seek_after_render_found = false
local first = true
vim.bo[bufnr].modifiable = false
@@ -624,9 +692,7 @@ M.render_buffer_async = function(bufnr, opts, callback)
end
loading.set_loading(bufnr, false)
render_buffer(bufnr, { jump = true })
- if not preserve_undo then
- vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
- end
+ vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" })
vim.bo[bufnr].modifiable = not buffers_locked and adapter.is_modifiable(bufnr)
if callback then
callback()
@@ -651,7 +717,7 @@ M.render_buffer_async = function(bufnr, opts, callback)
end
end
if fetch_more then
- local now = vim.loop.hrtime() / 1e6
+ local now = uv.hrtime() / 1e6
local delta = now - start_ms
-- If we've been chugging for more than 40ms, go ahead and render what we have
if delta > 40 then
diff --git a/scripts/generate.py b/scripts/generate.py
index 2f8e263f..114af0b5 100755
--- a/scripts/generate.py
+++ b/scripts/generate.py
@@ -121,22 +121,44 @@ class ColumnDef:
"An icon for the entry's type (requires nvim-web-devicons)",
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("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(
- "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(
"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(
- "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.body.extend(
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")
@@ -210,6 +232,37 @@ def get_columns_vimdoc() -> "VimdocSection":
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():
doc = Vimdoc("oil.txt", "oil")
funcs = parse_functions(os.path.join(ROOT, "lua", "oil", "init.lua"))
@@ -220,6 +273,7 @@ def generate_vimdoc():
get_columns_vimdoc(),
get_actions_vimdoc(),
get_highlights_vimdoc(),
+ get_trash_vimdoc(),
]
)
diff --git a/syntax/oil_preview.vim b/syntax/oil_preview.vim
index 41656ee3..b6c2fab7 100644
--- a/syntax/oil_preview.vim
+++ b/syntax/oil_preview.vim
@@ -3,9 +3,13 @@ if exists("b:current_syntax")
endif
syn match oilCreate /^CREATE /
-syn match oilMove /^ MOVE /
+syn match oilMove /^ MOVE /
syn match oilDelete /^DELETE /
-syn match oilCopy /^ COPY /
+syn match oilCopy /^ COPY /
syn match oilChange /^CHANGE /
+" Trash operations
+syn match oilRestore /^RESTORE /
+syn match oilPurge /^ PURGE /
+syn match oilTrash /^ TRASH /
let b:current_syntax = "oil_preview"
diff --git a/tests/files_spec.lua b/tests/files_spec.lua
index 3e5e4668..b268b4c3 100644
--- a/tests/files_spec.lua
+++ b/tests/files_spec.lua
@@ -11,8 +11,6 @@ a.describe("files adapter", function()
a.after_each(function()
if tmpdir then
tmpdir:dispose()
- a.util.scheduler()
- tmpdir = nil
end
test_util.reset_editor()
end)
diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua
index 262d9ece..70a66b18 100644
--- a/tests/minimal_init.lua
+++ b/tests/minimal_init.lua
@@ -1,4 +1,4 @@
-vim.cmd([[set runtimepath+=.]])
+vim.opt.runtimepath:append(".")
vim.o.swapfile = false
vim.bo.swapfile = false
diff --git a/tests/parser_spec.lua b/tests/parser_spec.lua
index f3a4262b..74872638 100644
--- a/tests/parser_spec.lua
+++ b/tests/parser_spec.lua
@@ -18,6 +18,7 @@ describe("parser", function()
after_each(function()
test_util.reset_editor()
end)
+
it("detects new files", function()
vim.cmd.edit({ args = { "oil-test:///foo/" } })
local bufnr = vim.api.nvim_get_current_buf()
diff --git a/tests/test_util.lua b/tests/test_util.lua
index e2ab2aa6..53d4bfed 100644
--- a/tests/test_util.lua
+++ b/tests/test_util.lua
@@ -25,6 +25,18 @@ M.reset_editor = function()
test_adapter.test_clear()
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)
local opts = {
pattern = "*",
@@ -58,4 +70,48 @@ M.feedkeys = function(actions, timestep)
a.util.sleep(timestep)
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
diff --git a/tests/tmpdir.lua b/tests/tmpdir.lua
index 62018f45..bb8a9c0a 100644
--- a/tests/tmpdir.lua
+++ b/tests/tmpdir.lua
@@ -1,16 +1,7 @@
local fs = require("oil.fs")
+local test_util = require("tests.test_util")
-local function throwiferr(err, ...)
- if err then
- error(err)
- else
- return ...
- end
-end
-
-local function await(fn, nargs, ...)
- return throwiferr(a.wrap(fn, nargs)(...))
-end
+local await = test_util.await
---@param path string
---@param cb fun(err: nil|string)
@@ -41,6 +32,7 @@ local TmpDir = {}
TmpDir.new = function()
local path = await(vim.loop.fs_mkdtemp, 2, "oil_test_XXXXXXXXX")
+ a.util.scheduler()
return setmetatable({ path = path }, {
__index = TmpDir,
})
@@ -60,6 +52,7 @@ function TmpDir:create(paths)
end
end
end
+ a.util.scheduler()
end
---@param filepath string
@@ -72,6 +65,7 @@ local read_file = function(filepath)
local stat = vim.loop.fs_fstat(fd)
local content = vim.loop.fs_read(fd, stat.size)
vim.loop.fs_close(fd)
+ a.util.scheduler()
return content
end
@@ -99,9 +93,9 @@ local assert_fs = function(root, paths)
local pieces = vim.split(k, "/")
local partial_path = ""
for i, piece in ipairs(pieces) do
- partial_path = fs.join(partial_path, piece) .. "/"
+ partial_path = partial_path .. piece .. "/"
if i ~= #pieces then
- unlisted_dirs[partial_path:sub(2)] = true
+ unlisted_dirs[partial_path] = true
end
end
end
@@ -152,8 +146,23 @@ function TmpDir:assert_fs(paths)
assert_fs(self.path, paths)
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()
await(fs.recursive_delete, 3, "directory", self.path)
+ a.util.scheduler()
end
return TmpDir
diff --git a/tests/trash_spec.lua b/tests/trash_spec.lua
new file mode 100644
index 00000000..e74ae77b
--- /dev/null
+++ b/tests/trash_spec.lua
@@ -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)
diff --git a/tests/url_spec.lua b/tests/url_spec.lua
index d9ecb309..32d801eb 100644
--- a/tests/url_spec.lua
+++ b/tests/url_spec.lua
@@ -13,7 +13,7 @@ describe("url", function()
}
for _, case in ipairs(cases) do
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_basename,