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:
parent
d8f0d91b10
commit
6175bd6462
27 changed files with 1580 additions and 229 deletions
23
README.md
23
README.md
|
|
@ -43,11 +43,13 @@ oil.nvim supports all the usual plugin managers
|
|||
<summary>Packer</summary>
|
||||
|
||||
```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)
|
|||
<summary>Paq</summary>
|
||||
|
||||
```lua
|
||||
require "paq" {
|
||||
{'stevearc/oil.nvim'};
|
||||
}
|
||||
require("paq")({
|
||||
{ "stevearc/oil.nvim" },
|
||||
})
|
||||
```
|
||||
|
||||
</details>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
54
doc/oil.txt
54
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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
9
lua/oil/adapters/trash.lua
Normal file
9
lua/oil/adapters/trash.lua
Normal 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
|
||||
630
lua/oil/adapters/trash/freedesktop.lua
Normal file
630
lua/oil/adapters/trash/freedesktop.lua
Normal 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
|
||||
233
lua/oil/adapters/trash/mac.lua
Normal file
233
lua/oil/adapters/trash/mac.lua
Normal 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
|
||||
20
lua/oil/adapters/trash/windows.lua
Normal file
20
lua/oil/adapters/trash/windows.lua
Normal 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
|
||||
|
|
@ -4,10 +4,12 @@ local M = {}
|
|||
|
||||
local FIELD_ID = constants.FIELD_ID
|
||||
local FIELD_NAME = constants.FIELD_NAME
|
||||
local FIELD_META = constants.FIELD_META
|
||||
|
||||
local next_id = 1
|
||||
|
||||
-- Map<url, Map<entry name, oil.InternalEntry>>
|
||||
---@type table<string, table<string, oil.InternalEntry>>
|
||||
local url_directory = {}
|
||||
|
||||
---@type table<integer, oil.InternalEntry>
|
||||
|
|
@ -118,6 +120,15 @@ M.get_entry_by_id = function(id)
|
|||
return entries_by_id[id]
|
||||
end
|
||||
|
||||
---@param url string
|
||||
---@return nil|oil.InternalEntry
|
||||
M.get_entry_by_url = function(url)
|
||||
local scheme, path = util.parse_url(url)
|
||||
local parent_url = scheme .. vim.fn.fnamemodify(path, ":h")
|
||||
local basename = vim.fn.fnamemodify(path, ":t")
|
||||
return M.list_url(parent_url)[basename]
|
||||
end
|
||||
|
||||
---@param id integer
|
||||
---@return string
|
||||
M.get_parent_url = function(id)
|
||||
|
|
@ -129,18 +140,12 @@ M.get_parent_url = function(id)
|
|||
end
|
||||
|
||||
---@param url string
|
||||
---@return oil.InternalEntry[]
|
||||
---@return table<string, oil.InternalEntry>
|
||||
M.list_url = function(url)
|
||||
url = util.addslash(url)
|
||||
return url_directory[url] or {}
|
||||
end
|
||||
|
||||
M.get_entry_by_url = function(url)
|
||||
local parent, name = url:match("^(.+)/([^/]+)$")
|
||||
local cache = url_directory[parent]
|
||||
return cache and cache[name]
|
||||
end
|
||||
|
||||
---@param action oil.Action
|
||||
M.perform_action = function(action)
|
||||
if action.type == "create" then
|
||||
|
|
@ -172,6 +177,8 @@ M.perform_action = function(action)
|
|||
dest_parent = {}
|
||||
url_directory[dest_parent_url] = dest_parent
|
||||
end
|
||||
-- We have to clear the metadata because it can be inaccurate after the move
|
||||
entry[FIELD_META] = nil
|
||||
dest_parent[dest_name] = entry
|
||||
parent_url_by_id[entry[FIELD_ID]] = dest_parent_url
|
||||
entry[FIELD_NAME] = dest_name
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
return path
|
||||
end
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ local M = {}
|
|||
|
||||
---@alias oil.EntryType "file"|"directory"|"socket"|"link"|"fifo"
|
||||
---@alias oil.TextChunk string|string[]
|
||||
---@alias oil.CrossAdapterAction "copy"|"move"
|
||||
|
||||
---@class (exact) oil.Adapter
|
||||
---@field name string The unique name of the adapter (this will be set automatically)
|
||||
|
|
@ -20,7 +21,9 @@ local M = {}
|
|||
---@field perform_action? fun(action: oil.Action, cb: fun(err: nil|string)) Perform a mutation action. Only needed if adapter is modifiable.
|
||||
---@field read_file? fun(bufnr: integer) Used for adapters that deal with remote/virtual files. Read the contents of the file into a buffer.
|
||||
---@field write_file? fun(bufnr: integer) Used for adapters that deal with remote/virtual files. Write the contents of a buffer to the destination.
|
||||
---@field supported_adapters_for_copy? table<string, boolean> Mapping of adapter name to true for all other adapters that can be used as a src or dest for move/copy actions.
|
||||
---@field supported_cross_adapter_actions? table<string, oil.CrossAdapterAction> Mapping of adapter name to enum for all other adapters that can be used as a src or dest for move/copy actions.
|
||||
---@field filter_action? fun(action: oil.Action): boolean When present, filter out actions as they are created
|
||||
---@field filter_error? fun(action: oil.ParseError): boolean When present, filter out errors from parsing a buffer
|
||||
|
||||
-- TODO remove after https://github.com/folke/neodev.nvim/pull/163 lands
|
||||
---@diagnostic disable: undefined-field
|
||||
|
|
@ -110,34 +113,6 @@ M.discard_all_changes = function()
|
|||
end
|
||||
end
|
||||
|
||||
---Delete all files in the trash directory
|
||||
---@private
|
||||
---@note
|
||||
--- Trash functionality is incomplete and experimental.
|
||||
M.empty_trash = function()
|
||||
local config = require("oil.config")
|
||||
local fs = require("oil.fs")
|
||||
local util = require("oil.util")
|
||||
local trash_url = config.get_trash_url()
|
||||
if not trash_url then
|
||||
vim.notify("No trash directory configured", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local _, path = util.parse_url(trash_url)
|
||||
assert(path)
|
||||
local dir = fs.posix_to_os_path(path)
|
||||
if vim.fn.isdirectory(dir) == 1 then
|
||||
fs.recursive_delete("directory", dir, function(err)
|
||||
if err then
|
||||
vim.notify(string.format("Error emptying trash: %s", err), vim.log.levels.ERROR)
|
||||
else
|
||||
vim.notify("Trash emptied")
|
||||
fs.mkdirp(dir)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
---Change the display columns for oil
|
||||
---@param cols oil.ColumnSpec[]
|
||||
M.set_columns = function(cols)
|
||||
|
|
@ -177,9 +152,13 @@ end
|
|||
---Get the oil url for a given directory
|
||||
---@private
|
||||
---@param dir nil|string When nil, use the cwd
|
||||
---@return nil|string The parent url
|
||||
---@param use_oil_parent nil|boolean If in an oil buffer, return the parent (default true)
|
||||
---@return string The parent url
|
||||
---@return nil|string The basename (if present) of the file/dir we were just in
|
||||
M.get_url_for_path = function(dir)
|
||||
M.get_url_for_path = function(dir, use_oil_parent)
|
||||
if use_oil_parent == nil then
|
||||
use_oil_parent = true
|
||||
end
|
||||
local config = require("oil.config")
|
||||
local fs = require("oil.fs")
|
||||
local util = require("oil.util")
|
||||
|
|
@ -196,15 +175,16 @@ M.get_url_for_path = function(dir)
|
|||
return config.adapter_to_scheme.files .. path
|
||||
else
|
||||
local bufname = vim.api.nvim_buf_get_name(0)
|
||||
return M.get_buffer_parent_url(bufname)
|
||||
return M.get_buffer_parent_url(bufname, use_oil_parent)
|
||||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param bufname string
|
||||
---@param use_oil_parent boolean If in an oil buffer, return the parent
|
||||
---@return string
|
||||
---@return nil|string
|
||||
M.get_buffer_parent_url = function(bufname)
|
||||
M.get_buffer_parent_url = function(bufname, use_oil_parent)
|
||||
local config = require("oil.config")
|
||||
local fs = require("oil.fs")
|
||||
local pathutil = require("oil.pathutil")
|
||||
|
|
@ -223,13 +203,15 @@ M.get_buffer_parent_url = function(bufname)
|
|||
return parent_url, basename
|
||||
else
|
||||
assert(path)
|
||||
-- TODO maybe we should remove this special case and turn it into a config
|
||||
if scheme == "term://" then
|
||||
---@type string
|
||||
path = vim.fn.expand(path:match("^(.*)//")) ---@diagnostic disable-line: assign-type-mismatch
|
||||
return config.adapter_to_scheme.files .. util.addslash(path)
|
||||
end
|
||||
|
||||
if not use_oil_parent then
|
||||
return bufname
|
||||
end
|
||||
local adapter = config.get_adapter_by_scheme(scheme)
|
||||
local parent_url
|
||||
if adapter and adapter.get_parent then
|
||||
|
|
@ -672,7 +654,7 @@ M._get_highlights = function()
|
|||
{
|
||||
name = "OilDir",
|
||||
link = "Directory",
|
||||
desc = "Directories in an oil buffer",
|
||||
desc = "Directory names in an oil buffer",
|
||||
},
|
||||
{
|
||||
name = "OilDirIcon",
|
||||
|
|
@ -689,6 +671,11 @@ M._get_highlights = function()
|
|||
link = nil,
|
||||
desc = "Soft links in an oil buffer",
|
||||
},
|
||||
{
|
||||
name = "OilLinkTarget",
|
||||
link = "Comment",
|
||||
desc = "The target of a soft link",
|
||||
},
|
||||
{
|
||||
name = "OilFile",
|
||||
link = nil,
|
||||
|
|
@ -719,6 +706,26 @@ M._get_highlights = function()
|
|||
link = "Special",
|
||||
desc = "Change action in the oil preview window",
|
||||
},
|
||||
{
|
||||
name = "OilRestore",
|
||||
link = "OilCreate",
|
||||
desc = "Restore (from the trash) action in the oil preview window",
|
||||
},
|
||||
{
|
||||
name = "OilPurge",
|
||||
link = "OilDelete",
|
||||
desc = "Purge (Permanently delete a file from trash) action in the oil preview window",
|
||||
},
|
||||
{
|
||||
name = "OilTrash",
|
||||
link = "OilDelete",
|
||||
desc = "Trash (delete a file to trash) action in the oil preview window",
|
||||
},
|
||||
{
|
||||
name = "OilTrashSourcePath",
|
||||
link = "Comment",
|
||||
desc = "Virtual text that shows the original path of file in the trash",
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -855,14 +862,23 @@ M.setup = function(opts)
|
|||
config.setup(opts)
|
||||
set_colors()
|
||||
vim.api.nvim_create_user_command("Oil", function(args)
|
||||
local util = require("oil.util")
|
||||
if args.smods.tab == 1 then
|
||||
vim.cmd.tabnew()
|
||||
end
|
||||
local float = false
|
||||
for i, v in ipairs(args.fargs) do
|
||||
local trash = false
|
||||
local i = 1
|
||||
while i <= #args.fargs do
|
||||
local v = args.fargs[i]
|
||||
if v == "--float" then
|
||||
float = true
|
||||
table.remove(args.fargs, i)
|
||||
elseif v == "--trash" then
|
||||
trash = true
|
||||
table.remove(args.fargs, i)
|
||||
else
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -875,7 +891,13 @@ M.setup = function(opts)
|
|||
end
|
||||
|
||||
local method = float and "open_float" or "open"
|
||||
M[method](unpack(args.fargs))
|
||||
local path = args.fargs[1]
|
||||
if trash then
|
||||
local url = M.get_url_for_path(path, false)
|
||||
local _, new_path = util.parse_url(url)
|
||||
path = "oil-trash://" .. new_path
|
||||
end
|
||||
M[method](path)
|
||||
end, { desc = "Open oil file browser on a directory", nargs = "*", complete = "dir" })
|
||||
local aug = vim.api.nvim_create_augroup("Oil", {})
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ local constants = require("oil.constants")
|
|||
local lsp_helpers = require("oil.lsp_helpers")
|
||||
local oil = require("oil")
|
||||
local parser = require("oil.mutator.parser")
|
||||
local pathutil = require("oil.pathutil")
|
||||
local preview = require("oil.mutator.preview")
|
||||
local util = require("oil.util")
|
||||
local view = require("oil.view")
|
||||
|
|
@ -54,6 +53,7 @@ M.create_actions_from_diffs = function(all_diffs)
|
|||
---@type oil.Action[]
|
||||
local actions = {}
|
||||
|
||||
---@type table<integer, oil.Diff[]>
|
||||
local diff_by_id = setmetatable({}, {
|
||||
__index = function(t, key)
|
||||
local list = {}
|
||||
|
|
@ -61,6 +61,15 @@ M.create_actions_from_diffs = function(all_diffs)
|
|||
return list
|
||||
end,
|
||||
})
|
||||
---@param action oil.Action
|
||||
local function add_action(action)
|
||||
local adapter = assert(config.get_adapter_by_scheme(action.dest_url or action.url))
|
||||
if not adapter.filter_action or adapter.filter_action(action) then
|
||||
table.insert(actions, action)
|
||||
end
|
||||
end
|
||||
---@type table<integer, string>
|
||||
local dest_by_id = {}
|
||||
for bufnr, diffs in pairs(all_diffs) do
|
||||
local adapter = util.get_adapter(bufnr)
|
||||
if not adapter then
|
||||
|
|
@ -71,9 +80,7 @@ M.create_actions_from_diffs = function(all_diffs)
|
|||
if diff.type == "new" then
|
||||
if diff.id then
|
||||
local by_id = diff_by_id[diff.id]
|
||||
-- FIXME this is kind of a hack. We shouldn't be setting undocumented fields on the diff
|
||||
---@diagnostic disable-next-line: inject-field
|
||||
diff.dest = parent_url .. diff.name
|
||||
dest_by_id[diff.id] = parent_url .. diff.name
|
||||
table.insert(by_id, diff)
|
||||
else
|
||||
-- Parse nested files like foo/bar/baz
|
||||
|
|
@ -87,7 +94,7 @@ M.create_actions_from_diffs = function(all_diffs)
|
|||
-- Parse alternations like foo.{js,test.js}
|
||||
for _, alt in ipairs(vim.split(alternation, ",")) do
|
||||
local alt_url = url .. "/" .. v:gsub("{[^}]+}", alt)
|
||||
table.insert(actions, {
|
||||
add_action({
|
||||
type = "create",
|
||||
url = alt_url,
|
||||
entry_type = entry_type,
|
||||
|
|
@ -96,7 +103,7 @@ M.create_actions_from_diffs = function(all_diffs)
|
|||
end
|
||||
else
|
||||
url = url .. "/" .. v
|
||||
table.insert(actions, {
|
||||
add_action({
|
||||
type = "create",
|
||||
url = url,
|
||||
entry_type = entry_type,
|
||||
|
|
@ -106,7 +113,7 @@ M.create_actions_from_diffs = function(all_diffs)
|
|||
end
|
||||
end
|
||||
elseif diff.type == "change" then
|
||||
table.insert(actions, {
|
||||
add_action({
|
||||
type = "change",
|
||||
url = parent_url .. diff.name,
|
||||
entry_type = diff.entry_type,
|
||||
|
|
@ -115,8 +122,9 @@ M.create_actions_from_diffs = function(all_diffs)
|
|||
})
|
||||
else
|
||||
local by_id = diff_by_id[diff.id]
|
||||
-- HACK: set has_delete field on a list-like table of diffs
|
||||
by_id.has_delete = true
|
||||
-- Don't insert the delete. We already know that there is a delete because of the presense
|
||||
-- Don't insert the delete. We already know that there is a delete because of the presence
|
||||
-- in the diff_by_id map. The list will only include the 'new' diffs.
|
||||
end
|
||||
end
|
||||
|
|
@ -127,21 +135,23 @@ M.create_actions_from_diffs = function(all_diffs)
|
|||
if not entry then
|
||||
error(string.format("Could not find entry %d", id))
|
||||
end
|
||||
---HACK: access the has_delete field on the list-like table of diffs
|
||||
---@diagnostic disable-next-line: undefined-field
|
||||
if diffs.has_delete then
|
||||
local has_create = #diffs > 0
|
||||
if has_create then
|
||||
-- MOVE (+ optional copies) when has both creates and delete
|
||||
for i, diff in ipairs(diffs) do
|
||||
table.insert(actions, {
|
||||
add_action({
|
||||
type = i == #diffs and "move" or "copy",
|
||||
entry_type = entry[FIELD_TYPE],
|
||||
dest_url = diff.dest,
|
||||
dest_url = dest_by_id[diff.id],
|
||||
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
|
||||
})
|
||||
end
|
||||
else
|
||||
-- DELETE when no create
|
||||
table.insert(actions, {
|
||||
add_action({
|
||||
type = "delete",
|
||||
entry_type = entry[FIELD_TYPE],
|
||||
url = cache.get_parent_url(id) .. entry[FIELD_NAME],
|
||||
|
|
@ -150,11 +160,11 @@ M.create_actions_from_diffs = function(all_diffs)
|
|||
else
|
||||
-- COPY when create but no delete
|
||||
for _, diff in ipairs(diffs) do
|
||||
table.insert(actions, {
|
||||
add_action({
|
||||
type = "copy",
|
||||
entry_type = entry[FIELD_TYPE],
|
||||
src_url = cache.get_parent_url(id) .. entry[FIELD_NAME],
|
||||
dest_url = diff.dest,
|
||||
dest_url = dest_by_id[diff.id],
|
||||
})
|
||||
end
|
||||
end
|
||||
|
|
@ -353,30 +363,6 @@ end
|
|||
---@param actions oil.Action[]
|
||||
---@param cb fun(err: nil|string)
|
||||
M.process_actions = function(actions, cb)
|
||||
-- convert delete actions to move-to-trash
|
||||
local trash_url = config.get_trash_url()
|
||||
if trash_url then
|
||||
for i, v in ipairs(actions) do
|
||||
if v.type == "delete" then
|
||||
local scheme, path = util.parse_url(v.url)
|
||||
if config.adapters[scheme] == "files" then
|
||||
assert(path)
|
||||
---@type oil.MoveAction
|
||||
local move_action = {
|
||||
type = "move",
|
||||
src_url = v.url,
|
||||
entry_type = v.entry_type,
|
||||
dest_url = trash_url .. "/" .. pathutil.basename(path) .. string.format(
|
||||
"_%06d",
|
||||
math.random(999999)
|
||||
),
|
||||
}
|
||||
actions[i] = move_action
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- send all renames to LSP servers
|
||||
local moves = {}
|
||||
for _, action in ipairs(actions) do
|
||||
|
|
@ -390,12 +376,12 @@ M.process_actions = function(actions, cb)
|
|||
end
|
||||
lsp_helpers.will_rename_files(moves)
|
||||
|
||||
-- Convert cross-adapter moves to a copy + delete
|
||||
-- Convert some cross-adapter moves to a copy + delete
|
||||
for _, action in ipairs(actions) do
|
||||
if action.type == "move" then
|
||||
local src_scheme = util.parse_url(action.src_url)
|
||||
local dest_scheme = util.parse_url(action.dest_url)
|
||||
if src_scheme ~= dest_scheme then
|
||||
local _, cross_action = util.get_adapter_for_action(action)
|
||||
-- Only do the conversion if the cross-adapter support is "copy"
|
||||
if cross_action == "copy" then
|
||||
action.type = "copy"
|
||||
table.insert(actions, {
|
||||
type = "delete",
|
||||
|
|
@ -488,6 +474,10 @@ M.try_write_changes = function(confirm)
|
|||
if vim.bo[bufnr].modified then
|
||||
local diffs, errors = parser.parse(bufnr)
|
||||
all_diffs[bufnr] = diffs
|
||||
local adapter = assert(util.get_adapter(bufnr))
|
||||
if adapter.filter_error then
|
||||
errors = vim.tbl_filter(adapter.filter_error, errors)
|
||||
end
|
||||
if not vim.tbl_isempty(errors) then
|
||||
all_errors[bufnr] = errors
|
||||
end
|
||||
|
|
@ -539,7 +529,7 @@ M.try_write_changes = function(confirm)
|
|||
view.unlock_buffers()
|
||||
if err then
|
||||
vim.notify(string.format("[oil] Error applying actions: %s", err), vim.log.levels.ERROR)
|
||||
view.rerender_all_oil_buffers({ preserve_undo = false })
|
||||
view.rerender_all_oil_buffers()
|
||||
else
|
||||
local current_entry = oil.get_cursor_entry()
|
||||
if current_entry then
|
||||
|
|
@ -549,7 +539,8 @@ M.try_write_changes = function(confirm)
|
|||
vim.split(current_entry.parsed_name or current_entry.name, "/")[1]
|
||||
)
|
||||
end
|
||||
view.rerender_all_oil_buffers({ preserve_undo = M.trash })
|
||||
view.rerender_all_oil_buffers()
|
||||
vim.api.nvim_exec_autocmds("User", { pattern = "OilMutationComplete", modeline = false })
|
||||
end
|
||||
mutation_in_progress = false
|
||||
end)
|
||||
|
|
|
|||
|
|
@ -142,11 +142,18 @@ M.parse_line = function(adapter, line, column_defs)
|
|||
return { data = ret, entry = entry, ranges = ranges }
|
||||
end
|
||||
|
||||
---@class (exact) oil.ParseError
|
||||
---@field lnum integer
|
||||
---@field col integer
|
||||
---@field message string
|
||||
|
||||
---@param bufnr integer
|
||||
---@return oil.Diff[]
|
||||
---@return table[] Parsing errors
|
||||
---@return oil.Diff[] diffs
|
||||
---@return oil.ParseError[] errors Parsing errors
|
||||
M.parse = function(bufnr)
|
||||
---@type oil.Diff[]
|
||||
local diffs = {}
|
||||
---@type oil.ParseError[]
|
||||
local errors = {}
|
||||
local bufname = vim.api.nvim_buf_get_name(bufnr)
|
||||
local adapter = util.get_adapter(bufnr)
|
||||
|
|
@ -158,11 +165,14 @@ M.parse = function(bufnr)
|
|||
})
|
||||
return diffs, errors
|
||||
end
|
||||
local scheme, path = util.parse_url(bufname)
|
||||
local parent_url = scheme .. path
|
||||
local column_defs = columns.get_supported_columns(adapter)
|
||||
local children = cache.list_url(parent_url)
|
||||
|
||||
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)
|
||||
local scheme, path = util.parse_url(bufname)
|
||||
local column_defs = columns.get_supported_columns(adapter)
|
||||
local parent_url = scheme .. path
|
||||
local children = cache.list_url(parent_url)
|
||||
-- map from name to entry ID for all entries previously in the buffer
|
||||
---@type table<string, integer>
|
||||
local original_entries = {}
|
||||
for _, child in pairs(children) do
|
||||
local name = child[FIELD_NAME]
|
||||
|
|
@ -184,6 +194,7 @@ M.parse = function(bufnr)
|
|||
end
|
||||
for i, line in ipairs(lines) do
|
||||
if line:match("^/%d+") then
|
||||
-- Parse the line for an existing entry
|
||||
local result, err = M.parse_line(adapter, line, column_defs)
|
||||
if not result or err then
|
||||
table.insert(errors, {
|
||||
|
|
@ -256,6 +267,7 @@ M.parse = function(bufnr)
|
|||
end
|
||||
end
|
||||
else
|
||||
-- Parse a new entry
|
||||
local name, isdir = parsedir(vim.trim(line))
|
||||
if vim.startswith(name, "/") then
|
||||
table.insert(errors, {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
104
lua/oil/view.lua
104
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
|
||||
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
|
||||
end
|
||||
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
|
||||
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].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
|
||||
|
|
|
|||
|
|
@ -121,22 +121,44 @@ COL_DEFS = [
|
|||
"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(),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,5 +7,9 @@ syn match oilMove /^ MOVE /
|
|||
syn match oilDelete /^DELETE /
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
vim.cmd([[set runtimepath+=.]])
|
||||
vim.opt.runtimepath:append(".")
|
||||
|
||||
vim.o.swapfile = false
|
||||
vim.bo.swapfile = false
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
164
tests/trash_spec.lua
Normal file
164
tests/trash_spec.lua
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue