diff --git a/README.md b/README.md index 009ddb46..c198c8b2 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,13 @@ oil.nvim supports all the usual plugin managers Packer ```lua -require('packer').startup(function() - use { - 'stevearc/oil.nvim', - config = function() require('oil').setup() end - } +require("packer").startup(function() + use({ + "stevearc/oil.nvim", + config = function() + require("oil").setup() + end, + }) end) ``` @@ -57,9 +59,9 @@ end) Paq ```lua -require "paq" { - {'stevearc/oil.nvim'}; -} +require("paq")({ + { "stevearc/oil.nvim" }, +}) ``` @@ -154,8 +156,6 @@ require("oil").setup({ delete_to_trash = false, -- Skip the confirmation popup for simple operations skip_confirm_for_simple_edits = false, - -- Change this to customize the command used when deleting to trash - trash_command = "trash-put", -- Selecting a new/moved/renamed file or directory will prompt you to save changes first prompt_save_on_select_new_entry = true, -- Oil will automatically delete hidden buffers after this delay @@ -184,6 +184,7 @@ require("oil").setup({ ["gs"] = "actions.change_sort", ["gx"] = "actions.open_external", ["g."] = "actions.toggle_hidden", + ["g\\"] = "actions.toggle_trash", }, -- Set to false to disable all of the above keymaps use_default_keymaps = true, @@ -277,7 +278,7 @@ nvim oil-ssh://[username@]hostname[:port]/[path] This may look familiar. In fact, this is the same url format that netrw uses. -Note that at the moment the ssh adapter does not support Windows machines, and it requires the server to have a `/bin/sh` binary as well as standard unix commands (`rm`, `mv`, `mkdir`, `chmod`, `cp`, `touch`, `ln`, `echo`). +Note that at the moment the ssh adapter does not support Windows machines, and it requires the server to have a `/bin/sh` binary as well as standard unix commands (`ls`, `rm`, `mv`, `mkdir`, `chmod`, `cp`, `touch`, `ln`, `echo`). ## API diff --git a/doc/oil.txt b/doc/oil.txt index 0bd9fe64..2da5bb2c 100644 --- a/doc/oil.txt +++ b/doc/oil.txt @@ -8,6 +8,7 @@ CONTENTS *oil-content 3. Columns |oil-columns| 4. Actions |oil-actions| 5. Highlights |oil-highlights| + 6. Trash |oil-trash| -------------------------------------------------------------------------------- OPTIONS *oil-options* @@ -45,8 +46,6 @@ OPTIONS *oil-option delete_to_trash = false, -- Skip the confirmation popup for simple operations skip_confirm_for_simple_edits = false, - -- Change this to customize the command used when deleting to trash - trash_command = "trash-put", -- Selecting a new/moved/renamed file or directory will prompt you to save changes first prompt_save_on_select_new_entry = true, -- Oil will automatically delete hidden buffers after this delay @@ -75,6 +74,7 @@ OPTIONS *oil-option ["gs"] = "actions.change_sort", ["gx"] = "actions.open_external", ["g."] = "actions.toggle_hidden", + ["g\\"] = "actions.toggle_trash", }, -- Set to false to disable all of the above keymaps use_default_keymaps = true, @@ -343,6 +343,8 @@ birthtime *column-birthtim ACTIONS *oil-actions* These are actions that can be used in the `keymaps` section of config options. +You can also call them directly with +`require("oil.actions").action_name.callback()` cd *actions.cd* :cd to the current oil directory @@ -408,11 +410,14 @@ tcd *actions.tc toggle_hidden *actions.toggle_hidden* Toggle hidden files and directories +toggle_trash *actions.toggle_trash* + Jump to and from the trash for the current directory + -------------------------------------------------------------------------------- HIGHLIGHTS *oil-highlights* OilDir *hl-OilDir* - Directories in an oil buffer + Directory names in an oil buffer OilDirIcon *hl-OilDirIcon* Icon for directories @@ -423,6 +428,9 @@ OilSocket *hl-OilSocke OilLink *hl-OilLink* Soft links in an oil buffer +OilLinkTarget *hl-OilLinkTarget* + The target of a soft link + OilFile *hl-OilFile* Normal files in an oil buffer @@ -441,5 +449,45 @@ OilCopy *hl-OilCop OilChange *hl-OilChange* Change action in the oil preview window +OilRestore *hl-OilRestore* + Restore (from the trash) action in the oil preview window + +OilPurge *hl-OilPurge* + Purge (Permanently delete a file from trash) action in the oil preview + window + +OilTrash *hl-OilTrash* + Trash (delete a file to trash) action in the oil preview window + +OilTrashSourcePath *hl-OilTrashSourcePath* + Virtual text that shows the original path of file in the trash + +-------------------------------------------------------------------------------- +TRASH *oil-trash* + + +Oil has built-in support for using the system trash. When +`delete_to_trash = true`, any deleted files will be sent to the trash instead +of being permanently deleted. You can browse the trash for a directory using +the `toggle_trash` action (bound to `g\` by default). You can view all files +in the trash with `:Oil --trash /`. + +To restore files, simply delete them from the trash and put them in the desired +destination, the same as any other file operation. If you delete files from the +trash they will be permanently deleted (purged). + +Linux: + Oil supports the FreeDesktop trash specification. + https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html + All features should work. + +Mac: + Oil has limited support for MacOS due to the proprietary nature of the + implementation. The trash bin can only be viewed as a single dir + (instead of being able to see files that were trashed from a directory). + +Windows: + Oil does not yet support the Windows trash. PRs are welcome! + ================================================================================ vim:tw=80:ts=2:ft=help:norl:syntax=help: diff --git a/lua/oil/actions.lua b/lua/oil/actions.lua index d446a0b5..2402750e 100644 --- a/lua/oil/actions.lua +++ b/lua/oil/actions.lua @@ -1,6 +1,9 @@ local oil = require("oil") local util = require("oil.util") +-- TODO remove after https://github.com/folke/neodev.nvim/pull/163 lands +---@diagnostic disable: inject-field + local M = {} M.show_help = { @@ -302,6 +305,35 @@ M.change_sort = { end, } +M.toggle_trash = { + desc = "Jump to and from the trash for the current directory", + callback = function() + local fs = require("oil.fs") + local bufname = vim.api.nvim_buf_get_name(0) + local scheme, path = util.parse_url(bufname) + local bufnr = vim.api.nvim_get_current_buf() + local url + if scheme == "oil://" then + url = "oil-trash://" .. path + elseif scheme == "oil-trash://" then + url = "oil://" .. path + -- The non-linux trash implementations don't support per-directory trash, + -- so jump back to the stored source buffer. + if not fs.is_linux then + local src_bufnr = vim.b.oil_trash_toggle_src + if src_bufnr and vim.api.nvim_buf_is_valid(src_bufnr) then + url = vim.api.nvim_buf_get_name(src_bufnr) + end + end + else + vim.notify("No trash found for buffer", vim.log.levels.WARN) + return + end + vim.cmd.edit({ args = { url } }) + vim.b.oil_trash_toggle_src = bufnr + end, +} + ---List actions for documentation generation ---@private M._get_actions = function() diff --git a/lua/oil/adapters/files.lua b/lua/oil/adapters/files.lua index fcd3bcf2..72131539 100644 --- a/lua/oil/adapters/files.lua +++ b/lua/oil/adapters/files.lua @@ -7,6 +7,7 @@ local permissions = require("oil.adapters.files.permissions") local trash = require("oil.adapters.files.trash") local util = require("oil.util") local uv = vim.uv or vim.loop + local M = {} local FIELD_NAME = constants.FIELD_NAME @@ -147,7 +148,11 @@ if not fs.is_windows then } end -local current_year = vim.fn.strftime("%Y") +local current_year +-- Make sure we run this import-time effect in the main loop (mostly for tests) +vim.schedule(function() + current_year = vim.fn.strftime("%Y") +end) for _, time_key in ipairs({ "ctime", "mtime", "atime", "birthtime" }) do file_columns[time_key] = { @@ -436,7 +441,12 @@ M.render_action = function(action) elseif action.type == "delete" then local _, path = util.parse_url(action.url) assert(path) - return string.format("DELETE %s", M.to_short_os_path(path, action.entry_type)) + local short_path = M.to_short_os_path(path, action.entry_type) + if config.delete_to_trash then + return string.format(" TRASH %s", short_path) + else + return string.format("DELETE %s", short_path) + end elseif action.type == "move" or action.type == "copy" then local dest_adapter = config.get_adapter_by_scheme(action.dest_url) if dest_adapter == M then @@ -451,7 +461,7 @@ M.render_action = function(action) M.to_short_os_path(dest_path, action.entry_type) ) else - -- We should never hit this because we don't implement supported_adapters_for_copy + -- We should never hit this because we don't implement supported_cross_adapter_actions error("files adapter doesn't support cross-adapter move/copy") end else @@ -494,7 +504,15 @@ M.perform_action = function(action, cb) assert(path) path = fs.posix_to_os_path(path) if config.delete_to_trash then - trash.recursive_delete(path, cb) + if config.trash_command then + vim.notify_once( + "Oil now has native support for trash. Remove the `trash_command` from your config to try it out!", + vim.log.levels.WARN + ) + trash.recursive_delete(path, cb) + else + require("oil.adapters.trash").delete_to_trash(path, cb) + end else fs.recursive_delete(action.entry_type, path, cb) end @@ -507,9 +525,9 @@ M.perform_action = function(action, cb) assert(dest_path) src_path = fs.posix_to_os_path(src_path) dest_path = fs.posix_to_os_path(dest_path) - fs.recursive_move(action.entry_type, src_path, dest_path, vim.schedule_wrap(cb)) + fs.recursive_move(action.entry_type, src_path, dest_path, cb) else - -- We should never hit this because we don't implement supported_adapters_for_copy + -- We should never hit this because we don't implement supported_cross_adapter_actions cb("files adapter doesn't support cross-adapter move") end elseif action.type == "copy" then @@ -523,7 +541,7 @@ M.perform_action = function(action, cb) dest_path = fs.posix_to_os_path(dest_path) fs.recursive_copy(action.entry_type, src_path, dest_path, cb) else - -- We should never hit this because we don't implement supported_adapters_for_copy + -- We should never hit this because we don't implement supported_cross_adapter_actions cb("files adapter doesn't support cross-adapter copy") end else diff --git a/lua/oil/adapters/ssh.lua b/lua/oil/adapters/ssh.lua index 1fa0057f..7707b954 100644 --- a/lua/oil/adapters/ssh.lua +++ b/lua/oil/adapters/ssh.lua @@ -348,7 +348,7 @@ M.perform_action = function(action, cb) end end -M.supported_adapters_for_copy = { files = true } +M.supported_cross_adapter_actions = { files = "copy" } ---@param bufnr integer M.read_file = function(bufnr) diff --git a/lua/oil/adapters/trash.lua b/lua/oil/adapters/trash.lua new file mode 100644 index 00000000..3c7ffeb9 --- /dev/null +++ b/lua/oil/adapters/trash.lua @@ -0,0 +1,9 @@ +local fs = require("oil.fs") + +if fs.is_mac then + return require("oil.adapters.trash.mac") +elseif fs.is_windows then + error("Trash is not implemented yet on Windows") +else + return require("oil.adapters.trash.freedesktop") +end diff --git a/lua/oil/adapters/trash/freedesktop.lua b/lua/oil/adapters/trash/freedesktop.lua new file mode 100644 index 00000000..3ba211ff --- /dev/null +++ b/lua/oil/adapters/trash/freedesktop.lua @@ -0,0 +1,630 @@ +-- Based on the FreeDesktop.org trash specification +-- https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html +local cache = require("oil.cache") +local config = require("oil.config") +local constants = require("oil.constants") +local files = require("oil.adapters.files") +local fs = require("oil.fs") +local util = require("oil.util") + +local uv = vim.uv or vim.loop +local FIELD_META = constants.FIELD_META + +local M = {} + +local function touch_dir(path) + uv.fs_mkdir(path, 448) -- 0700 +end + +local function ensure_trash_dir(path) + touch_dir(path) + touch_dir(fs.join(path, "info")) + touch_dir(fs.join(path, "files")) +end + +---Gets the location of the home trash dir, creating it if necessary +---@return string +local function get_home_trash_dir() + local xdg_home = vim.env.XDG_DATA_HOME + if not xdg_home then + xdg_home = fs.join(assert(uv.os_homedir()), ".local", "share") + end + local trash_dir = fs.join(xdg_home, "Trash") + ensure_trash_dir(trash_dir) + return trash_dir +end + +---@param mode integer +---@return boolean +local function is_sticky(mode) + local extra = bit.rshift(mode, 9) + return bit.band(extra, 4) ~= 0 +end + +---Get the topdir .Trash/$uid directory if present and valid +---@param path string +---@return string[] +local function get_top_trash_dirs(path) + local dirs = {} + local dev = (uv.fs_stat(path) or {}).dev + local top_trash_dirs = vim.fs.find(".Trash", { upward = true, path = path, limit = math.huge }) + for _, top_trash_dir in ipairs(top_trash_dirs) do + local stat = uv.fs_stat(top_trash_dir) + if stat and not dev then + dev = stat.dev + end + if stat and stat.dev == dev and stat.type == "directory" and is_sticky(stat.mode) then + local trash_dir = fs.join(top_trash_dir, tostring(uv.getuid())) + ensure_trash_dir(trash_dir) + table.insert(dirs, trash_dir) + end + end + + -- Also search for the .Trash-$uid + top_trash_dirs = vim.fs.find( + string.format(".Trash-%d", uv.getuid()), + { upward = true, path = path, limit = math.huge } + ) + for _, top_trash_dir in ipairs(top_trash_dirs) do + local stat = uv.fs_stat(top_trash_dir) + if stat and stat.dev == dev then + ensure_trash_dir(top_trash_dir) + table.insert(dirs, top_trash_dir) + end + end + + return dirs +end + +---@param path string +---@return string +local function get_write_trash_dir(path) + local dev = uv.fs_stat(path).dev + local home_trash = get_home_trash_dir() + if uv.fs_stat(home_trash).dev == dev then + return home_trash + end + + local top_trash_dirs = get_top_trash_dirs(path) + if not vim.tbl_isempty(top_trash_dirs) then + return top_trash_dirs[1] + end + + local parent = vim.fn.fnamemodify(path, ":h") + local next_parent = vim.fn.fnamemodify(parent, ":h") + while parent ~= next_parent and uv.fs_stat(next_parent).dev == dev do + parent = next_parent + next_parent = vim.fn.fnamemodify(parent, ":h") + end + + local top_trash = fs.join(parent, string.format(".Trash-%d", uv.getuid())) + ensure_trash_dir(top_trash) + return top_trash +end + +---@param path string +---@return string[] +local function get_read_trash_dirs(path) + local dirs = { get_home_trash_dir() } + vim.list_extend(dirs, get_top_trash_dirs(path)) + return dirs +end + +---@param url string +---@param callback fun(url: string) +M.normalize_url = function(url, callback) + local scheme, path = util.parse_url(url) + assert(path) + local os_path = vim.fn.fnamemodify(fs.posix_to_os_path(path), ":p") + uv.fs_realpath( + os_path, + vim.schedule_wrap(function(err, new_os_path) + local realpath = new_os_path or os_path + callback(scheme .. util.addslash(fs.os_to_posix_path(realpath))) + end) + ) +end + +---@param url string +---@param entry oil.Entry +---@param cb fun(path: string) +M.get_entry_path = function(url, entry, cb) + local internal_entry = assert(cache.get_entry_by_id(entry.id)) + local meta = internal_entry[FIELD_META] + ---@type oil.TrashInfo + local trash_info = meta.trash_info + if not trash_info then + -- This is a subpath in the trash + M.normalize_url(url, cb) + return + end + local path = fs.os_to_posix_path(trash_info.trash_file) + if meta.stat.type == "directory" then + path = util.addslash(path) + end + cb("oil://" .. path) +end + +---@class oil.TrashInfo +---@field trash_file string +---@field info_file string +---@field original_path string +---@field deletion_date number +---@field stat uv_fs_t + +---@param info_file string +---@param cb fun(err?: string, info?: oil.TrashInfo) +local function read_trash_info(info_file, cb) + if not vim.endswith(info_file, ".trashinfo") then + return cb("File is not .trashinfo") + end + uv.fs_open(info_file, "r", 448, function(err, fd) + if err then + return cb(err) + end + assert(fd) + uv.fs_fstat(fd, function(stat_err, stat) + if stat_err then + uv.fs_close(fd) + return cb(stat_err) + end + uv.fs_read( + fd, + assert(stat).size, + nil, + vim.schedule_wrap(function(read_err, content) + uv.fs_close(fd) + if read_err then + return cb(read_err) + end + assert(content) + local trash_info = { + info_file = info_file, + } + local lines = vim.split(content, "\r?\n") + if lines[1] ~= "[Trash Info]" then + return cb("File missing [Trash Info] header") + end + local trash_base = vim.fn.fnamemodify(info_file, ":h:h") + for _, line in ipairs(lines) do + local key, value = unpack(vim.split(line, "=", { plain = true, trimempty = true })) + if key == "Path" and not trash_info.original_path then + if not vim.startswith(value, "/") then + value = fs.join(trash_base, value) + end + trash_info.original_path = value + elseif key == "DeletionDate" and not trash_info.deletion_date then + trash_info.deletion_date = vim.fn.strptime("%Y-%m-%dT%H:%M:%S", value) + end + end + + if not trash_info.original_path or not trash_info.deletion_date then + return cb("File missing required fields") + end + + local basename = vim.fn.fnamemodify(info_file, ":t:r") + trash_info.trash_file = fs.join(trash_base, "files", basename) + uv.fs_stat(trash_info.trash_file, function(trash_stat_err, trash_stat) + if trash_stat_err then + cb(".trashinfo file points to non-existant file") + else + trash_info.stat = trash_stat + cb(nil, trash_info) + end + end) + end) + ) + end) + end) +end + +---@param url string +---@param column_defs string[] +---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun()) +M.list = function(url, column_defs, cb) + cb = vim.schedule_wrap(cb) + local _, path = util.parse_url(url) + assert(path) + local trash_dirs = get_read_trash_dirs(path) + local trash_idx = 0 + + local read_next_trash_dir + read_next_trash_dir = function() + trash_idx = trash_idx + 1 + local trash_dir = trash_dirs[trash_idx] + if not trash_dir then + return cb() + end + + -- Show all files from the trash directory if we are in the root of the device, which we can + -- tell if the trash dir is a subpath of our current path + local show_all_files = fs.is_subpath(path, trash_dir) + -- The first trash dir is a special case; it is in the home directory and we should only show + -- all entries if we are in the top root path "/" + if trash_idx == 1 then + show_all_files = path == "/" + end + + local info_dir = fs.join(trash_dir, "info") + ---@diagnostic disable-next-line: param-type-mismatch + uv.fs_opendir(info_dir, function(open_err, fd) + if open_err then + if open_err:match("^ENOENT: no such file or directory") then + -- If the directory doesn't exist, treat the list as a success. We will be able to traverse + -- and edit a not-yet-existing directory. + return read_next_trash_dir() + else + return cb(open_err) + end + end + local read_next + read_next = function() + uv.fs_readdir(fd, function(err, entries) + if err then + uv.fs_closedir(fd, function() + cb(err) + end) + return + elseif entries then + local internal_entries = {} + local poll = util.cb_collect(#entries, function(inner_err) + if inner_err then + cb(inner_err) + else + cb(nil, internal_entries, read_next) + end + end) + + for _, entry in ipairs(entries) do + read_trash_info( + fs.join(info_dir, entry.name), + vim.schedule_wrap(function(read_err, info) + if read_err then + -- Discard the error. We don't care if there's something wrong with one of these + -- files. + poll() + else + local parent = util.addslash(vim.fn.fnamemodify(info.original_path, ":h")) + if path == parent or show_all_files then + local name = vim.fn.fnamemodify(info.trash_file, ":t") + ---@diagnostic disable-next-line: undefined-field + local cache_entry = cache.create_entry(url, name, info.stat.type) + local display_name = vim.fn.fnamemodify(info.original_path, ":t") + cache_entry[FIELD_META] = { + stat = info.stat, + trash_info = info, + display_name = display_name, + } + table.insert(internal_entries, cache_entry) + end + if path ~= parent and (show_all_files or fs.is_subpath(path, parent)) then + local name = parent:sub(path:len() + 1) + local next_par = vim.fs.dirname(name) + while next_par ~= "." do + name = next_par + next_par = vim.fs.dirname(name) + end + ---@diagnostic disable-next-line: undefined-field + local cache_entry = cache.create_entry(url, name, "directory") + + cache_entry[FIELD_META] = { + stat = info.stat, + } + table.insert(internal_entries, cache_entry) + end + poll() + end + end) + ) + end + else + uv.fs_closedir(fd, function(close_err) + if close_err then + cb(close_err) + else + vim.schedule(read_next_trash_dir) + end + end) + end + end) + end + read_next() + ---@diagnostic disable-next-line: param-type-mismatch + end, 10000) + end + read_next_trash_dir() +end + +---@param bufnr integer +---@return boolean +M.is_modifiable = function(bufnr) + return true +end + +local file_columns = {} + +local current_year +-- Make sure we run this import-time effect in the main loop (mostly for tests) +vim.schedule(function() + current_year = vim.fn.strftime("%Y") +end) + +file_columns.mtime = { + render = function(entry, conf) + local meta = entry[FIELD_META] + ---@type oil.TrashInfo + local trash_info = meta.trash_info + local time = trash_info and trash_info.deletion_date or meta.stat and meta.stat.mtime.sec + if not time then + return nil + end + local fmt = conf and conf.format + local ret + if fmt then + ret = vim.fn.strftime(fmt, time) + else + local year = vim.fn.strftime("%Y", time) + if year ~= current_year then + ret = vim.fn.strftime("%b %d %Y", time) + else + ret = vim.fn.strftime("%b %d %H:%M", time) + end + end + return ret + end, + + get_sort_value = function(entry) + local meta = entry[FIELD_META] + ---@type nil|oil.TrashInfo + local trash_info = meta.trash_info + if trash_info then + return trash_info.deletion_date + else + return 0 + end + end, + + parse = function(line, conf) + local fmt = conf and conf.format + local pattern + if fmt then + pattern = fmt:gsub("%%.", "%%S+") + else + pattern = "%S+%s+%d+%s+%d%d:?%d%d" + end + return line:match("^(" .. pattern .. ")%s+(.+)$") + end, +} + +---@param name string +---@return nil|oil.ColumnDefinition +M.get_column = function(name) + return file_columns[name] +end + +M.supported_cross_adapter_actions = { files = "move" } + +---@param action oil.Action +---@return boolean +M.filter_action = function(action) + if action.type == "create" then + return false + elseif action.type == "delete" then + local entry = assert(cache.get_entry_by_url(action.url)) + local meta = entry[FIELD_META] + return meta.trash_info ~= nil + elseif action.type == "move" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + return src_adapter.name == "files" or dest_adapter.name == "files" + elseif action.type == "copy" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + return src_adapter.name == "files" or dest_adapter.name == "files" + else + error(string.format("Bad action type '%s'", action.type)) + end +end + +---@param err oil.ParseError +---@return boolean +M.filter_error = function(err) + if err.message == "Duplicate filename" then + return false + end + return true +end + +---@param action oil.Action +---@return string +M.render_action = function(action) + if action.type == "delete" then + local entry = assert(cache.get_entry_by_url(action.url)) + local meta = entry[FIELD_META] + ---@type oil.TrashInfo + local trash_info = meta.trash_info + local short_path = fs.shorten_path(trash_info.original_path) + return string.format(" PURGE %s", short_path) + elseif action.type == "move" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + if src_adapter.name == "files" then + local _, path = util.parse_url(action.src_url) + assert(path) + local short_path = files.to_short_os_path(path, action.entry_type) + return string.format(" TRASH %s", short_path) + elseif dest_adapter.name == "files" then + local _, path = util.parse_url(action.dest_url) + assert(path) + local short_path = files.to_short_os_path(path, action.entry_type) + return string.format("RESTORE %s", short_path) + else + error("Must be moving files into or out of trash") + end + elseif action.type == "copy" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + if src_adapter.name == "files" then + local _, path = util.parse_url(action.src_url) + assert(path) + local short_path = files.to_short_os_path(path, action.entry_type) + return string.format(" COPY %s -> TRASH", short_path) + elseif dest_adapter.name == "files" then + local _, path = util.parse_url(action.dest_url) + assert(path) + local short_path = files.to_short_os_path(path, action.entry_type) + return string.format("RESTORE %s", short_path) + else + error("Must be copying files into or out of trash") + end + else + error(string.format("Bad action type '%s'", action.type)) + end +end + +---@param trash_info oil.TrashInfo +---@param cb fun(err?: string) +local function purge(trash_info, cb) + fs.recursive_delete("file", trash_info.info_file, function(err) + if err then + return cb(err) + end + ---@diagnostic disable-next-line: undefined-field + fs.recursive_delete(trash_info.stat.type, trash_info.trash_file, cb) + end) +end + +---@param path string +---@param info_path string +---@param cb fun(err?: string) +local function write_info_file(path, info_path, cb) + uv.fs_open( + info_path, + "w", + 448, + vim.schedule_wrap(function(err, fd) + if err then + return cb(err) + end + assert(fd) + local deletion_date = vim.fn.strftime("%Y-%m-%dT%H:%M:%S") + local contents = string.format("[Trash Info]\nPath=%s\nDeletionDate=%s", path, deletion_date) + uv.fs_write(fd, contents, function(write_err) + uv.fs_close(fd, function(close_err) + cb(write_err or close_err) + end) + end) + end) + ) +end + +---@param path string +---@param cb fun(err?: string, trash_info?: oil.TrashInfo) +local function create_trash_info(path, cb) + local trash_dir = get_write_trash_dir(path) + local basename = vim.fs.basename(path) + local now = os.time() + local name = string.format("%s-%d.%d", basename, now, math.random(100000, 999999)) + local dest_path = fs.join(trash_dir, "files", name) + local dest_info = fs.join(trash_dir, "info", name .. ".trashinfo") + uv.fs_stat(path, function(err, stat) + if err then + return cb(err) + end + assert(stat) + write_info_file(path, dest_info, function(info_err) + if info_err then + return cb(info_err) + end + ---@type oil.TrashInfo + local trash_info = { + original_path = path, + trash_file = dest_path, + info_file = dest_info, + deletion_date = now, + stat = stat, + } + cb(nil, trash_info) + end) + end) +end + +---@param action oil.Action +---@param cb fun(err: nil|string) +M.perform_action = function(action, cb) + if action.type == "delete" then + local entry = assert(cache.get_entry_by_url(action.url)) + local meta = entry[FIELD_META] + ---@type oil.TrashInfo + local trash_info = meta.trash_info + purge(trash_info, cb) + elseif action.type == "move" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + if src_adapter.name == "files" then + local _, path = util.parse_url(action.src_url) + M.delete_to_trash(assert(path), cb) + elseif dest_adapter.name == "files" then + -- Restore + local _, dest_path = util.parse_url(action.dest_url) + assert(dest_path) + local entry = assert(cache.get_entry_by_url(action.src_url)) + local meta = entry[FIELD_META] + ---@type oil.TrashInfo + local trash_info = meta.trash_info + fs.recursive_move(action.entry_type, trash_info.trash_file, dest_path, function(err) + if err then + return cb(err) + end + uv.fs_unlink(trash_info.info_file, cb) + end) + else + error("Must be moving files into or out of trash") + end + elseif action.type == "copy" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + if src_adapter.name == "files" then + local _, path = util.parse_url(action.src_url) + assert(path) + create_trash_info(path, function(err, trash_info) + if err then + cb(err) + else + ---@diagnostic disable-next-line: undefined-field + local stat_type = trash_info.stat.type + fs.recursive_copy(stat_type, path, trash_info.trash_file, vim.schedule_wrap(cb)) + end + end) + elseif dest_adapter.name == "files" then + -- Restore + local _, dest_path = util.parse_url(action.dest_url) + assert(dest_path) + local entry = assert(cache.get_entry_by_url(action.src_url)) + local meta = entry[FIELD_META] + ---@type oil.TrashInfo + local trash_info = meta.trash_info + fs.recursive_copy(action.entry_type, trash_info.trash_file, dest_path, cb) + else + error("Must be moving files into or out of trash") + end + else + cb(string.format("Bad action type: %s", action.type)) + end +end + +---@param path string +---@param cb fun(err?: string) +M.delete_to_trash = function(path, cb) + create_trash_info(path, function(err, trash_info) + if err then + cb(err) + else + ---@diagnostic disable-next-line: undefined-field + local stat_type = trash_info.stat.type + fs.recursive_move(stat_type, path, trash_info.trash_file, vim.schedule_wrap(cb)) + end + end) +end + +return M diff --git a/lua/oil/adapters/trash/mac.lua b/lua/oil/adapters/trash/mac.lua new file mode 100644 index 00000000..46d4b4ae --- /dev/null +++ b/lua/oil/adapters/trash/mac.lua @@ -0,0 +1,233 @@ +local cache = require("oil.cache") +local config = require("oil.config") +local files = require("oil.adapters.files") +local fs = require("oil.fs") +local util = require("oil.util") + +local uv = vim.uv or vim.loop + +local M = {} + +local function touch_dir(path) + uv.fs_mkdir(path, 448) -- 0700 +end + +---Gets the location of the home trash dir, creating it if necessary +---@return string +local function get_trash_dir() + local trash_dir = fs.join(assert(uv.os_homedir()), ".Trash") + touch_dir(trash_dir) + return trash_dir +end + +---@param url string +---@param callback fun(url: string) +M.normalize_url = function(url, callback) + local scheme, path = util.parse_url(url) + assert(path) + callback(scheme .. "/") +end + +---@param url string +---@param entry oil.Entry +---@param cb fun(path: string) +M.get_entry_path = function(url, entry, cb) + local trash_dir = get_trash_dir() + local path = fs.join(trash_dir, entry.name) + if entry.type == "directory" then + path = "oil://" .. path + end + cb(path) +end + +---@param url string +---@param column_defs string[] +---@param cb fun(err?: string, entries?: oil.InternalEntry[], fetch_more?: fun()) +M.list = function(url, column_defs, cb) + cb = vim.schedule_wrap(cb) + local _, path = util.parse_url(url) + assert(path) + local trash_dir = get_trash_dir() + ---@diagnostic disable-next-line: param-type-mismatch + uv.fs_opendir(trash_dir, function(open_err, fd) + if open_err then + if open_err:match("^ENOENT: no such file or directory") then + -- If the directory doesn't exist, treat the list as a success. We will be able to traverse + -- and edit a not-yet-existing directory. + return cb() + else + return cb(open_err) + end + end + local read_next + read_next = function() + uv.fs_readdir(fd, function(err, entries) + if err then + uv.fs_closedir(fd, function() + cb(err) + end) + return + elseif entries then + local internal_entries = {} + local poll = util.cb_collect(#entries, function(inner_err) + if inner_err then + cb(inner_err) + else + cb(nil, internal_entries, read_next) + end + end) + + for _, entry in ipairs(entries) do + -- TODO: read .DS_Store and filter by original dir + local cache_entry = cache.create_entry(url, entry.name, entry.type) + table.insert(internal_entries, cache_entry) + poll() + end + else + uv.fs_closedir(fd, function(close_err) + if close_err then + cb(close_err) + else + cb() + end + end) + end + end) + end + read_next() + ---@diagnostic disable-next-line: param-type-mismatch + end, 10000) +end + +---@param bufnr integer +---@return boolean +M.is_modifiable = function(bufnr) + return true +end + +---@param name string +---@return nil|oil.ColumnDefinition +M.get_column = function(name) + return nil +end + +M.supported_cross_adapter_actions = { files = "move" } + +---@param action oil.Action +---@return string +M.render_action = function(action) + if action.type == "create" then + return string.format("CREATE %s", action.url) + elseif action.type == "delete" then + return string.format(" PURGE %s", action.url) + elseif action.type == "move" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + if src_adapter.name == "files" then + local _, path = util.parse_url(action.src_url) + assert(path) + local short_path = files.to_short_os_path(path, action.entry_type) + return string.format(" TRASH %s", short_path) + elseif dest_adapter.name == "files" then + local _, path = util.parse_url(action.dest_url) + assert(path) + local short_path = files.to_short_os_path(path, action.entry_type) + return string.format("RESTORE %s", short_path) + else + return string.format(" %s %s -> %s", action.type:upper(), action.src_url, action.dest_url) + end + elseif action.type == "copy" then + return string.format(" %s %s -> %s", action.type:upper(), action.src_url, action.dest_url) + else + error("Bad action type") + end +end + +---@param action oil.Action +---@param cb fun(err: nil|string) +M.perform_action = function(action, cb) + local trash_dir = get_trash_dir() + if action.type == "create" then + local _, path = util.parse_url(action.url) + assert(path) + path = trash_dir .. path + if action.entry_type == "directory" then + uv.fs_mkdir(path, 493, function(err) + -- Ignore if the directory already exists + if not err or err:match("^EEXIST:") then + cb() + else + cb(err) + end + end) -- 0755 + elseif action.entry_type == "link" and action.link then + local flags = nil + local target = fs.posix_to_os_path(action.link) + ---@diagnostic disable-next-line: param-type-mismatch + uv.fs_symlink(target, path, flags, cb) + else + fs.touch(path, cb) + end + elseif action.type == "delete" then + local _, path = util.parse_url(action.url) + assert(path) + local fullpath = trash_dir .. path + fs.recursive_delete(action.entry_type, fullpath, cb) + elseif action.type == "move" or action.type == "copy" then + local src_adapter = assert(config.get_adapter_by_scheme(action.src_url)) + local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) + local _, src_path = util.parse_url(action.src_url) + local _, dest_path = util.parse_url(action.dest_url) + assert(src_path and dest_path) + if src_adapter.name == "files" then + dest_path = trash_dir .. dest_path + elseif dest_adapter.name == "files" then + src_path = trash_dir .. src_path + else + dest_path = trash_dir .. dest_path + src_path = trash_dir .. src_path + end + + if action.type == "move" then + fs.recursive_move(action.entry_type, src_path, dest_path, cb) + else + fs.recursive_copy(action.entry_type, src_path, dest_path, cb) + end + else + cb(string.format("Bad action type: %s", action.type)) + end +end + +---@param path string +---@param cb fun(err?: string) +M.delete_to_trash = function(path, cb) + local basename = vim.fs.basename(path) + local trash_dir = get_trash_dir() + local dest = fs.join(trash_dir, basename) + uv.fs_stat( + path, + vim.schedule_wrap(function(stat_err, src_stat) + if stat_err then + return cb(stat_err) + end + assert(src_stat) + if uv.fs_stat(dest) then + local date_str = vim.fn.strftime(" %Y-%m-%dT%H:%M:%S") + local name_pieces = vim.split(basename, ".", { plain = true }) + if #name_pieces > 1 then + table.insert(name_pieces, #name_pieces - 1, date_str) + basename = table.concat(name_pieces) + else + basename = basename .. date_str + end + dest = fs.join(trash_dir, basename) + end + + local stat_type = src_stat.type + ---@cast stat_type oil.EntryType + fs.recursive_move(stat_type, path, dest, vim.schedule_wrap(cb)) + end) + ) +end + +return M diff --git a/lua/oil/adapters/trash/windows.lua b/lua/oil/adapters/trash/windows.lua new file mode 100644 index 00000000..f2c37c91 --- /dev/null +++ b/lua/oil/adapters/trash/windows.lua @@ -0,0 +1,20 @@ +-- Work in progress +local M = {} + +-- ---@return string +-- local function get_trash_dir() +-- -- TODO permission issues when using the recycle bin. The folder gets created without +-- -- read/write perms, so all operations fail +-- local cwd = vim.fn.getcwd() +-- local trash_dir = cwd:sub(1, 3) .. "$Recycle.Bin" +-- if vim.fn.isdirectory(trash_dir) == 1 then +-- return trash_dir +-- end +-- trash_dir = "C:\\$Recycle.Bin" +-- if vim.fn.isdirectory(trash_dir) == 1 then +-- return trash_dir +-- end +-- error("No trash found") +-- end + +return M diff --git a/lua/oil/cache.lua b/lua/oil/cache.lua index 0f6a27e6..09ed44fc 100644 --- a/lua/oil/cache.lua +++ b/lua/oil/cache.lua @@ -4,10 +4,12 @@ local M = {} local FIELD_ID = constants.FIELD_ID local FIELD_NAME = constants.FIELD_NAME +local FIELD_META = constants.FIELD_META local next_id = 1 -- Map> +---@type table> local url_directory = {} ---@type table @@ -118,6 +120,15 @@ M.get_entry_by_id = function(id) return entries_by_id[id] end +---@param url string +---@return nil|oil.InternalEntry +M.get_entry_by_url = function(url) + local scheme, path = util.parse_url(url) + local parent_url = scheme .. vim.fn.fnamemodify(path, ":h") + local basename = vim.fn.fnamemodify(path, ":t") + return M.list_url(parent_url)[basename] +end + ---@param id integer ---@return string M.get_parent_url = function(id) @@ -129,18 +140,12 @@ M.get_parent_url = function(id) end ---@param url string ----@return oil.InternalEntry[] +---@return table M.list_url = function(url) url = util.addslash(url) return url_directory[url] or {} end -M.get_entry_by_url = function(url) - local parent, name = url:match("^(.+)/([^/]+)$") - local cache = url_directory[parent] - return cache and cache[name] -end - ---@param action oil.Action M.perform_action = function(action) if action.type == "create" then @@ -172,6 +177,8 @@ M.perform_action = function(action) dest_parent = {} url_directory[dest_parent_url] = dest_parent end + -- We have to clear the metadata because it can be inaccurate after the move + entry[FIELD_META] = nil dest_parent[dest_name] = entry parent_url_by_id[entry[FIELD_ID]] = dest_parent_url entry[FIELD_NAME] = dest_name diff --git a/lua/oil/columns.lua b/lua/oil/columns.lua index 7140f2a2..7e217afa 100644 --- a/lua/oil/columns.lua +++ b/lua/oil/columns.lua @@ -221,6 +221,9 @@ if has_devicons then icon = conf and conf.directory or "" hl = "OilDirIcon" else + if meta and meta.display_name then + name = meta.display_name + end icon, hl = devicons.get_icon(name) icon = icon or (conf and conf.default_file or "") end diff --git a/lua/oil/config.lua b/lua/oil/config.lua index 8bc18027..0e419d26 100644 --- a/lua/oil/config.lua +++ b/lua/oil/config.lua @@ -1,3 +1,5 @@ +local uv = vim.uv or vim.loop + local default_config = { -- Oil will take over directory buffers (e.g. `vim .` or `:e src/`) -- Set to false if you still want to use netrw. @@ -30,8 +32,6 @@ local default_config = { delete_to_trash = false, -- Skip the confirmation popup for simple operations skip_confirm_for_simple_edits = false, - -- Change this to customize the command used when deleting to trash - trash_command = "trash-put", -- Selecting a new/moved/renamed file or directory will prompt you to save changes first prompt_save_on_select_new_entry = true, -- Oil will automatically delete hidden buffers after this delay @@ -60,6 +60,7 @@ local default_config = { ["gs"] = "actions.change_sort", ["gx"] = "actions.open_external", ["g."] = "actions.toggle_hidden", + ["g\\"] = "actions.toggle_trash", }, -- Set to false to disable all of the above keymaps use_default_keymaps = true, @@ -142,6 +143,7 @@ local default_config = { default_config.adapters = { ["oil://"] = "files", ["oil-ssh://"] = "ssh", + ["oil-trash://"] = "trash", } default_config.adapter_aliases = {} @@ -154,13 +156,10 @@ M.setup = function(opts) end if new_conf.delete_to_trash then - local trash_bin = vim.split(new_conf.trash_command, " ")[1] - if vim.fn.executable(trash_bin) == 0 then + local is_windows = uv.os_uname().version:match("Windows") + if is_windows then vim.notify( - string.format( - "oil.nvim: delete_to_trash is true, but '%s' executable not found.\nDeleted files will be permanently removed.", - new_conf.trash_command - ), + "oil.nvim: delete_to_trash is true, but trash is not yet supported on Windows.\nDeleted files will be permanently removed", vim.log.levels.WARN ) new_conf.delete_to_trash = false @@ -176,46 +175,6 @@ M.setup = function(opts) M.adapter_to_scheme[v] = k end M._adapter_by_scheme = {} - if type(M.trash) == "string" then - M.trash = vim.fn.fnamemodify(vim.fn.expand(M.trash), ":p") - end -end - ----@return nil|string -M.get_trash_url = function() - if not M.trash then - return nil - end - local fs = require("oil.fs") - if M.trash == true then - local data_home = os.getenv("XDG_DATA_HOME") or vim.fn.expand("~/.local/share") - local preferred = fs.join(data_home, "trash") - local candidates = { - preferred, - } - if fs.is_windows then - -- TODO permission issues when using the recycle bin. The folder gets created without - -- read/write perms, so all operations fail - -- local cwd = vim.fn.getcwd() - -- table.insert(candidates, 1, cwd:sub(1, 3) .. "$Recycle.Bin") - -- table.insert(candidates, 1, "C:\\$Recycle.Bin") - else - table.insert(candidates, fs.join(data_home, "Trash", "files")) - table.insert(candidates, fs.join(os.getenv("HOME"), ".Trash")) - end - local trash_dir = preferred - for _, candidate in ipairs(candidates) do - if vim.fn.isdirectory(candidate) == 1 then - trash_dir = candidate - break - end - end - - local oil_trash_dir = vim.fn.fnamemodify(fs.join(trash_dir, "nvim", "oil"), ":p") - fs.mkdirp(oil_trash_dir) - M.trash = oil_trash_dir - end - return M.adapter_to_scheme.files .. fs.os_to_posix_path(M.trash) end ---@param scheme nil|string diff --git a/lua/oil/fs.lua b/lua/oil/fs.lua index 4f612a50..3de1acd6 100644 --- a/lua/oil/fs.lua +++ b/lua/oil/fs.lua @@ -7,6 +7,8 @@ M.is_windows = uv.os_uname().version:match("Windows") M.is_mac = uv.os_uname().sysname == "Darwin" +M.is_linux = not M.is_windows and not M.is_mac + ---@type string M.sep = M.is_windows and "\\" or "/" @@ -114,20 +116,31 @@ end local home_dir = assert(uv.os_homedir()) ---@param path string +---@param relative_to? string Shorten relative to this path (default cwd) ---@return string -M.shorten_path = function(path) - local cwd = vim.fn.getcwd() - if M.is_subpath(cwd, path) then - local relative = path:sub(cwd:len() + 2) - if relative == "" then - relative = "." +M.shorten_path = function(path, relative_to) + if not relative_to then + relative_to = vim.fn.getcwd() + end + local relpath + if M.is_subpath(relative_to, path) then + local idx = relative_to:len() + 1 + -- Trim the dividing slash if it's not included in relative_to + if not vim.endswith(relative_to, "/") and not vim.endswith(relative_to, "\\") then + idx = idx + 1 + end + relpath = path:sub(idx) + if relpath == "" then + relpath = "." end - return relative end if M.is_subpath(home_dir, path) then - return "~" .. path:sub(home_dir:len() + 1) + local homepath = "~" .. path:sub(home_dir:len() + 1) + if not relpath or homepath:len() < relpath:len() then + return homepath + end end - return path + return relpath or path end M.mkdirp = function(dir) @@ -177,7 +190,7 @@ M.listdir = function(dir, cb) end read_next() ---@diagnostic disable-next-line: param-type-mismatch - end, 100) -- TODO do some testing for this + end, 10000) end ---@param entry_type oil.EntryType diff --git a/lua/oil/init.lua b/lua/oil/init.lua index 8cdbd301..94424cd2 100644 --- a/lua/oil/init.lua +++ b/lua/oil/init.lua @@ -8,6 +8,7 @@ local M = {} ---@alias oil.EntryType "file"|"directory"|"socket"|"link"|"fifo" ---@alias oil.TextChunk string|string[] +---@alias oil.CrossAdapterAction "copy"|"move" ---@class (exact) oil.Adapter ---@field name string The unique name of the adapter (this will be set automatically) @@ -20,7 +21,9 @@ local M = {} ---@field perform_action? fun(action: oil.Action, cb: fun(err: nil|string)) Perform a mutation action. Only needed if adapter is modifiable. ---@field read_file? fun(bufnr: integer) Used for adapters that deal with remote/virtual files. Read the contents of the file into a buffer. ---@field write_file? fun(bufnr: integer) Used for adapters that deal with remote/virtual files. Write the contents of a buffer to the destination. ----@field supported_adapters_for_copy? table Mapping of adapter name to true for all other adapters that can be used as a src or dest for move/copy actions. +---@field supported_cross_adapter_actions? table Mapping of adapter name to enum for all other adapters that can be used as a src or dest for move/copy actions. +---@field filter_action? fun(action: oil.Action): boolean When present, filter out actions as they are created +---@field filter_error? fun(action: oil.ParseError): boolean When present, filter out errors from parsing a buffer -- TODO remove after https://github.com/folke/neodev.nvim/pull/163 lands ---@diagnostic disable: undefined-field @@ -110,34 +113,6 @@ M.discard_all_changes = function() end end ----Delete all files in the trash directory ----@private ----@note ---- Trash functionality is incomplete and experimental. -M.empty_trash = function() - local config = require("oil.config") - local fs = require("oil.fs") - local util = require("oil.util") - local trash_url = config.get_trash_url() - if not trash_url then - vim.notify("No trash directory configured", vim.log.levels.WARN) - return - end - local _, path = util.parse_url(trash_url) - assert(path) - local dir = fs.posix_to_os_path(path) - if vim.fn.isdirectory(dir) == 1 then - fs.recursive_delete("directory", dir, function(err) - if err then - vim.notify(string.format("Error emptying trash: %s", err), vim.log.levels.ERROR) - else - vim.notify("Trash emptied") - fs.mkdirp(dir) - end - end) - end -end - ---Change the display columns for oil ---@param cols oil.ColumnSpec[] M.set_columns = function(cols) @@ -177,9 +152,13 @@ end ---Get the oil url for a given directory ---@private ---@param dir nil|string When nil, use the cwd ----@return nil|string The parent url +---@param use_oil_parent nil|boolean If in an oil buffer, return the parent (default true) +---@return string The parent url ---@return nil|string The basename (if present) of the file/dir we were just in -M.get_url_for_path = function(dir) +M.get_url_for_path = function(dir, use_oil_parent) + if use_oil_parent == nil then + use_oil_parent = true + end local config = require("oil.config") local fs = require("oil.fs") local util = require("oil.util") @@ -196,15 +175,16 @@ M.get_url_for_path = function(dir) return config.adapter_to_scheme.files .. path else local bufname = vim.api.nvim_buf_get_name(0) - return M.get_buffer_parent_url(bufname) + return M.get_buffer_parent_url(bufname, use_oil_parent) end end ---@private ---@param bufname string +---@param use_oil_parent boolean If in an oil buffer, return the parent ---@return string ---@return nil|string -M.get_buffer_parent_url = function(bufname) +M.get_buffer_parent_url = function(bufname, use_oil_parent) local config = require("oil.config") local fs = require("oil.fs") local pathutil = require("oil.pathutil") @@ -223,13 +203,15 @@ M.get_buffer_parent_url = function(bufname) return parent_url, basename else assert(path) - -- TODO maybe we should remove this special case and turn it into a config if scheme == "term://" then ---@type string path = vim.fn.expand(path:match("^(.*)//")) ---@diagnostic disable-line: assign-type-mismatch return config.adapter_to_scheme.files .. util.addslash(path) end + if not use_oil_parent then + return bufname + end local adapter = config.get_adapter_by_scheme(scheme) local parent_url if adapter and adapter.get_parent then @@ -672,7 +654,7 @@ M._get_highlights = function() { name = "OilDir", link = "Directory", - desc = "Directories in an oil buffer", + desc = "Directory names in an oil buffer", }, { name = "OilDirIcon", @@ -689,6 +671,11 @@ M._get_highlights = function() link = nil, desc = "Soft links in an oil buffer", }, + { + name = "OilLinkTarget", + link = "Comment", + desc = "The target of a soft link", + }, { name = "OilFile", link = nil, @@ -719,6 +706,26 @@ M._get_highlights = function() link = "Special", desc = "Change action in the oil preview window", }, + { + name = "OilRestore", + link = "OilCreate", + desc = "Restore (from the trash) action in the oil preview window", + }, + { + name = "OilPurge", + link = "OilDelete", + desc = "Purge (Permanently delete a file from trash) action in the oil preview window", + }, + { + name = "OilTrash", + link = "OilDelete", + desc = "Trash (delete a file to trash) action in the oil preview window", + }, + { + name = "OilTrashSourcePath", + link = "Comment", + desc = "Virtual text that shows the original path of file in the trash", + }, } end @@ -855,14 +862,23 @@ M.setup = function(opts) config.setup(opts) set_colors() vim.api.nvim_create_user_command("Oil", function(args) + local util = require("oil.util") if args.smods.tab == 1 then vim.cmd.tabnew() end local float = false - for i, v in ipairs(args.fargs) do + local trash = false + local i = 1 + while i <= #args.fargs do + local v = args.fargs[i] if v == "--float" then float = true table.remove(args.fargs, i) + elseif v == "--trash" then + trash = true + table.remove(args.fargs, i) + else + i = i + 1 end end @@ -875,7 +891,13 @@ M.setup = function(opts) end local method = float and "open_float" or "open" - M[method](unpack(args.fargs)) + local path = args.fargs[1] + if trash then + local url = M.get_url_for_path(path, false) + local _, new_path = util.parse_url(url) + path = "oil-trash://" .. new_path + end + M[method](path) end, { desc = "Open oil file browser on a directory", nargs = "*", complete = "dir" }) local aug = vim.api.nvim_create_augroup("Oil", {}) diff --git a/lua/oil/mutator/init.lua b/lua/oil/mutator/init.lua index c3bfc5ea..29d04a6c 100644 --- a/lua/oil/mutator/init.lua +++ b/lua/oil/mutator/init.lua @@ -7,7 +7,6 @@ local constants = require("oil.constants") local lsp_helpers = require("oil.lsp_helpers") local oil = require("oil") local parser = require("oil.mutator.parser") -local pathutil = require("oil.pathutil") local preview = require("oil.mutator.preview") local util = require("oil.util") local view = require("oil.view") @@ -54,6 +53,7 @@ M.create_actions_from_diffs = function(all_diffs) ---@type oil.Action[] local actions = {} + ---@type table local diff_by_id = setmetatable({}, { __index = function(t, key) local list = {} @@ -61,6 +61,15 @@ M.create_actions_from_diffs = function(all_diffs) return list end, }) + ---@param action oil.Action + local function add_action(action) + local adapter = assert(config.get_adapter_by_scheme(action.dest_url or action.url)) + if not adapter.filter_action or adapter.filter_action(action) then + table.insert(actions, action) + end + end + ---@type table + local dest_by_id = {} for bufnr, diffs in pairs(all_diffs) do local adapter = util.get_adapter(bufnr) if not adapter then @@ -71,9 +80,7 @@ M.create_actions_from_diffs = function(all_diffs) if diff.type == "new" then if diff.id then local by_id = diff_by_id[diff.id] - -- FIXME this is kind of a hack. We shouldn't be setting undocumented fields on the diff - ---@diagnostic disable-next-line: inject-field - diff.dest = parent_url .. diff.name + dest_by_id[diff.id] = parent_url .. diff.name table.insert(by_id, diff) else -- Parse nested files like foo/bar/baz @@ -87,7 +94,7 @@ M.create_actions_from_diffs = function(all_diffs) -- Parse alternations like foo.{js,test.js} for _, alt in ipairs(vim.split(alternation, ",")) do local alt_url = url .. "/" .. v:gsub("{[^}]+}", alt) - table.insert(actions, { + add_action({ type = "create", url = alt_url, entry_type = entry_type, @@ -96,7 +103,7 @@ M.create_actions_from_diffs = function(all_diffs) end else url = url .. "/" .. v - table.insert(actions, { + add_action({ type = "create", url = url, entry_type = entry_type, @@ -106,7 +113,7 @@ M.create_actions_from_diffs = function(all_diffs) end end elseif diff.type == "change" then - table.insert(actions, { + add_action({ type = "change", url = parent_url .. diff.name, entry_type = diff.entry_type, @@ -115,8 +122,9 @@ M.create_actions_from_diffs = function(all_diffs) }) else local by_id = diff_by_id[diff.id] + -- HACK: set has_delete field on a list-like table of diffs by_id.has_delete = true - -- Don't insert the delete. We already know that there is a delete because of the presense + -- Don't insert the delete. We already know that there is a delete because of the presence -- in the diff_by_id map. The list will only include the 'new' diffs. end end @@ -127,21 +135,23 @@ M.create_actions_from_diffs = function(all_diffs) if not entry then error(string.format("Could not find entry %d", id)) end + ---HACK: access the has_delete field on the list-like table of diffs + ---@diagnostic disable-next-line: undefined-field if diffs.has_delete then local has_create = #diffs > 0 if has_create then -- MOVE (+ optional copies) when has both creates and delete for i, diff in ipairs(diffs) do - table.insert(actions, { + add_action({ type = i == #diffs and "move" or "copy", entry_type = entry[FIELD_TYPE], - dest_url = diff.dest, + dest_url = dest_by_id[diff.id], src_url = cache.get_parent_url(id) .. entry[FIELD_NAME], }) end else -- DELETE when no create - table.insert(actions, { + add_action({ type = "delete", entry_type = entry[FIELD_TYPE], url = cache.get_parent_url(id) .. entry[FIELD_NAME], @@ -150,11 +160,11 @@ M.create_actions_from_diffs = function(all_diffs) else -- COPY when create but no delete for _, diff in ipairs(diffs) do - table.insert(actions, { + add_action({ type = "copy", entry_type = entry[FIELD_TYPE], src_url = cache.get_parent_url(id) .. entry[FIELD_NAME], - dest_url = diff.dest, + dest_url = dest_by_id[diff.id], }) end end @@ -353,30 +363,6 @@ end ---@param actions oil.Action[] ---@param cb fun(err: nil|string) M.process_actions = function(actions, cb) - -- convert delete actions to move-to-trash - local trash_url = config.get_trash_url() - if trash_url then - for i, v in ipairs(actions) do - if v.type == "delete" then - local scheme, path = util.parse_url(v.url) - if config.adapters[scheme] == "files" then - assert(path) - ---@type oil.MoveAction - local move_action = { - type = "move", - src_url = v.url, - entry_type = v.entry_type, - dest_url = trash_url .. "/" .. pathutil.basename(path) .. string.format( - "_%06d", - math.random(999999) - ), - } - actions[i] = move_action - end - end - end - end - -- send all renames to LSP servers local moves = {} for _, action in ipairs(actions) do @@ -390,12 +376,12 @@ M.process_actions = function(actions, cb) end lsp_helpers.will_rename_files(moves) - -- Convert cross-adapter moves to a copy + delete + -- Convert some cross-adapter moves to a copy + delete for _, action in ipairs(actions) do if action.type == "move" then - local src_scheme = util.parse_url(action.src_url) - local dest_scheme = util.parse_url(action.dest_url) - if src_scheme ~= dest_scheme then + local _, cross_action = util.get_adapter_for_action(action) + -- Only do the conversion if the cross-adapter support is "copy" + if cross_action == "copy" then action.type = "copy" table.insert(actions, { type = "delete", @@ -488,6 +474,10 @@ M.try_write_changes = function(confirm) if vim.bo[bufnr].modified then local diffs, errors = parser.parse(bufnr) all_diffs[bufnr] = diffs + local adapter = assert(util.get_adapter(bufnr)) + if adapter.filter_error then + errors = vim.tbl_filter(adapter.filter_error, errors) + end if not vim.tbl_isempty(errors) then all_errors[bufnr] = errors end @@ -539,7 +529,7 @@ M.try_write_changes = function(confirm) view.unlock_buffers() if err then vim.notify(string.format("[oil] Error applying actions: %s", err), vim.log.levels.ERROR) - view.rerender_all_oil_buffers({ preserve_undo = false }) + view.rerender_all_oil_buffers() else local current_entry = oil.get_cursor_entry() if current_entry then @@ -549,7 +539,8 @@ M.try_write_changes = function(confirm) vim.split(current_entry.parsed_name or current_entry.name, "/")[1] ) end - view.rerender_all_oil_buffers({ preserve_undo = M.trash }) + view.rerender_all_oil_buffers() + vim.api.nvim_exec_autocmds("User", { pattern = "OilMutationComplete", modeline = false }) end mutation_in_progress = false end) diff --git a/lua/oil/mutator/parser.lua b/lua/oil/mutator/parser.lua index 1c0e32d2..e3823a15 100644 --- a/lua/oil/mutator/parser.lua +++ b/lua/oil/mutator/parser.lua @@ -142,11 +142,18 @@ M.parse_line = function(adapter, line, column_defs) return { data = ret, entry = entry, ranges = ranges } end +---@class (exact) oil.ParseError +---@field lnum integer +---@field col integer +---@field message string + ---@param bufnr integer ----@return oil.Diff[] ----@return table[] Parsing errors +---@return oil.Diff[] diffs +---@return oil.ParseError[] errors Parsing errors M.parse = function(bufnr) + ---@type oil.Diff[] local diffs = {} + ---@type oil.ParseError[] local errors = {} local bufname = vim.api.nvim_buf_get_name(bufnr) local adapter = util.get_adapter(bufnr) @@ -158,11 +165,14 @@ M.parse = function(bufnr) }) return diffs, errors end + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) local scheme, path = util.parse_url(bufname) - local parent_url = scheme .. path local column_defs = columns.get_supported_columns(adapter) + local parent_url = scheme .. path local children = cache.list_url(parent_url) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) + -- map from name to entry ID for all entries previously in the buffer + ---@type table local original_entries = {} for _, child in pairs(children) do local name = child[FIELD_NAME] @@ -184,6 +194,7 @@ M.parse = function(bufnr) end for i, line in ipairs(lines) do if line:match("^/%d+") then + -- Parse the line for an existing entry local result, err = M.parse_line(adapter, line, column_defs) if not result or err then table.insert(errors, { @@ -256,6 +267,7 @@ M.parse = function(bufnr) end end else + -- Parse a new entry local name, isdir = parsedir(vim.trim(line)) if vim.startswith(name, "/") then table.insert(errors, { diff --git a/lua/oil/util.lua b/lua/oil/util.lua index 4020d70d..027b5db8 100644 --- a/lua/oil/util.lua +++ b/lua/oil/util.lua @@ -453,6 +453,7 @@ end ---@param action oil.Action ---@return oil.Adapter +---@return nil|oil.CrossAdapterAction M.get_adapter_for_action = function(action) local adapter = config.get_adapter_by_scheme(action.url or action.src_url) if not adapter then @@ -462,15 +463,15 @@ M.get_adapter_for_action = function(action) local dest_adapter = assert(config.get_adapter_by_scheme(action.dest_url)) if adapter ~= dest_adapter then if - adapter.supported_adapters_for_copy - and adapter.supported_adapters_for_copy[dest_adapter.name] + adapter.supported_cross_adapter_actions + and adapter.supported_cross_adapter_actions[dest_adapter.name] then - return adapter + return adapter, adapter.supported_cross_adapter_actions[dest_adapter.name] elseif - dest_adapter.supported_adapters_for_copy - and dest_adapter.supported_adapters_for_copy[adapter.name] + dest_adapter.supported_cross_adapter_actions + and dest_adapter.supported_cross_adapter_actions[adapter.name] then - return dest_adapter + return dest_adapter, dest_adapter.supported_cross_adapter_actions[adapter.name] else error( string.format( diff --git a/lua/oil/view.lua b/lua/oil/view.lua index d9023392..4a2a7d03 100644 --- a/lua/oil/view.lua +++ b/lua/oil/view.lua @@ -1,7 +1,9 @@ +local uv = vim.uv or vim.loop local cache = require("oil.cache") local columns = require("oil.columns") local config = require("oil.config") local constants = require("oil.constants") +local fs = require("oil.fs") local keymap_util = require("oil.keymap_util") local loading = require("oil.loading") local util = require("oil.util") @@ -142,10 +144,11 @@ M.unlock_buffers = function() end end ----@param opts table +---@param opts? table ---@note --- This DISCARDS ALL MODIFICATIONS a user has made to oil buffers M.rerender_all_oil_buffers = function(opts) + opts = opts or {} local buffers = M.get_all_buffers() local hidden_buffers = {} for _, bufnr in ipairs(buffers) do @@ -177,8 +180,8 @@ end ---Get a list of visible oil buffers and a list of hidden oil buffers ---@note --- If any buffers are modified, return values are nil ----@return nil|integer[] ----@return nil|integer[] +---@return nil|integer[] visible +---@return nil|integer[] hidden local function get_visible_hidden_buffers() local buffers = M.get_all_buffers() local hidden_buffers = {} @@ -227,6 +230,43 @@ local function get_first_mutable_column_col(adapter, ranges) return min_col end +---Redraw original path virtual text for trash buffer +---@param bufnr integer +local function redraw_trash_virtual_text(bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) or not vim.api.nvim_buf_is_loaded(bufnr) then + return + end + local parser = require("oil.mutator.parser") + local adapter = util.get_adapter(bufnr) + if not adapter or adapter.name ~= "trash" then + return + end + local _, buf_path = util.parse_url(vim.api.nvim_buf_get_name(bufnr)) + local os_path = fs.posix_to_os_path(assert(buf_path)) + local ns = vim.api.nvim_create_namespace("OilVtext") + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + local column_defs = columns.get_supported_columns(adapter) + for lnum, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, true)) do + local result = parser.parse_line(adapter, line, column_defs) + local entry = result and result.entry + if entry then + local meta = entry[FIELD_META] + ---@type nil|oil.TrashInfo + local trash_info = meta and meta.trash_info + if trash_info then + vim.api.nvim_buf_set_extmark(bufnr, ns, lnum - 1, 0, { + virt_text = { + { + "➜ " .. fs.shorten_path(trash_info.original_path, os_path), + "OilTrashSourcePath", + }, + }, + }) + end + end + end +end + ---@param bufnr integer M.initialize = function(bufnr) if bufnr == 0 then @@ -255,7 +295,7 @@ M.initialize = function(bufnr) nested = true, buffer = bufnr, callback = function() - -- First wait a short time (10ms) for the buffer change to settle + -- First wait a short time (100ms) for the buffer change to settle vim.defer_fn(function() local visible_buffers = get_visible_hidden_buffers() -- Only delete oil buffers if none of them are visible @@ -271,7 +311,7 @@ M.initialize = function(bufnr) end end end - end, 10) + end, 100) end, }) vim.api.nvim_create_autocmd("BufDelete", { @@ -351,6 +391,36 @@ M.initialize = function(bufnr) end) end, }) + + -- Watch for TextChanged and update the trash original path extmarks + local adapter = util.get_adapter(bufnr) + if adapter and adapter.name == "trash" then + local debounce_timer = assert(uv.new_timer()) + local pending = false + vim.api.nvim_create_autocmd("TextChanged", { + desc = "Update oil virtual text of original path", + buffer = bufnr, + callback = function() + -- Respond immediately to prevent flickering, the set the timer for a "cooldown period" + -- If this is called again during the cooldown window, we will rerender after cooldown. + if debounce_timer:is_active() then + pending = true + else + redraw_trash_virtual_text(bufnr) + end + debounce_timer:start( + 50, + 0, + vim.schedule_wrap(function() + if pending then + pending = false + redraw_trash_virtual_text(bufnr) + end + end) + ) + end, + }) + end M.render_buffer_async(bufnr, {}, function(err) if err then vim.notify( @@ -358,6 +428,7 @@ M.initialize = function(bufnr) vim.log.levels.ERROR ) else + vim.b[bufnr].oil_ready = true vim.api.nvim_exec_autocmds( "User", { pattern = "OilEnter", modeline = false, data = { buf = bufnr } } @@ -478,6 +549,7 @@ local function render_buffer(bufnr, opts) vim.bo[bufnr].modifiable = false vim.bo[bufnr].modified = false util.set_highlights(bufnr, highlights) + if opts.jump then -- TODO why is the schedule necessary? vim.schedule(function() @@ -511,6 +583,10 @@ end ---@return oil.TextChunk[] M.format_entry_cols = function(entry, column_defs, col_width, adapter) local name = entry[FIELD_NAME] + local meta = entry[FIELD_META] + if meta and meta.display_name then + name = meta.display_name + end -- First put the unique ID local cols = {} local id_key = cache.format_id(entry[FIELD_ID]) @@ -531,7 +607,6 @@ M.format_entry_cols = function(entry, column_defs, col_width, adapter) elseif entry_type == "socket" then table.insert(cols, { name, "OilSocket" }) elseif entry_type == "link" then - local meta = entry[FIELD_META] local link_text if meta then if meta.link_stat and meta.link_stat.type == "directory" then @@ -548,7 +623,7 @@ M.format_entry_cols = function(entry, column_defs, col_width, adapter) table.insert(cols, { name, "OilLink" }) if link_text then - table.insert(cols, { link_text, "Comment" }) + table.insert(cols, { link_text, "OilLinkTarget" }) end else table.insert(cols, { name, "OilFile" }) @@ -573,29 +648,22 @@ end ---@param bufnr integer ---@param opts nil|table ---- preserve_undo nil|boolean --- refetch nil|boolean Defaults to true ---@param callback nil|fun(err: nil|string) M.render_buffer_async = function(bufnr, opts, callback) opts = vim.tbl_deep_extend("keep", opts or {}, { - preserve_undo = false, refetch = true, }) if bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end local bufname = vim.api.nvim_buf_get_name(bufnr) - local scheme, dir = util.parse_url(bufname) - local preserve_undo = opts.preserve_undo and config.adapters[scheme] == "files" - if not preserve_undo then - -- Undo should not return to a blank buffer - -- Method taken from :h clear-undo - vim.bo[bufnr].undolevels = -1 - end + local _, dir = util.parse_url(bufname) + -- Undo should not return to a blank buffer + -- Method taken from :h clear-undo + vim.bo[bufnr].undolevels = -1 local handle_error = vim.schedule_wrap(function(message) - if not preserve_undo then - vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" }) - end + vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" }) util.render_text(bufnr, { "Error: " .. message }) if callback then callback(message) @@ -612,7 +680,7 @@ M.render_buffer_async = function(bufnr, opts, callback) handle_error(string.format("[oil] no adapter for buffer '%s'", bufname)) return end - local start_ms = vim.loop.hrtime() / 1e6 + local start_ms = uv.hrtime() / 1e6 local seek_after_render_found = false local first = true vim.bo[bufnr].modifiable = false @@ -624,9 +692,7 @@ M.render_buffer_async = function(bufnr, opts, callback) end loading.set_loading(bufnr, false) render_buffer(bufnr, { jump = true }) - if not preserve_undo then - vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" }) - end + vim.bo[bufnr].undolevels = vim.api.nvim_get_option_value("undolevels", { scope = "global" }) vim.bo[bufnr].modifiable = not buffers_locked and adapter.is_modifiable(bufnr) if callback then callback() @@ -651,7 +717,7 @@ M.render_buffer_async = function(bufnr, opts, callback) end end if fetch_more then - local now = vim.loop.hrtime() / 1e6 + local now = uv.hrtime() / 1e6 local delta = now - start_ms -- If we've been chugging for more than 40ms, go ahead and render what we have if delta > 40 then diff --git a/scripts/generate.py b/scripts/generate.py index 2f8e263f..114af0b5 100755 --- a/scripts/generate.py +++ b/scripts/generate.py @@ -121,22 +121,44 @@ class ColumnDef: "An icon for the entry's type (requires nvim-web-devicons)", HL + [ - LuaParam("default_file", "string", "Fallback icon for files when nvim-web-devicons returns nil"), + LuaParam( + "default_file", + "string", + "Fallback icon for files when nvim-web-devicons returns nil", + ), LuaParam("directory", "string", "Icon for directories"), - LuaParam("add_padding", "boolean", "Set to false to remove the extra whitespace after the icon"), + LuaParam( + "add_padding", + "boolean", + "Set to false to remove the extra whitespace after the icon", + ), ], ), ColumnDef("size", "files, ssh", False, True, "The size of the file", HL + []), ColumnDef( - "permissions", "files, ssh", True, False, "Access permissions of the file", HL + [] + "permissions", + "files, ssh", + True, + False, + "Access permissions of the file", + HL + [], + ), + ColumnDef( + "ctime", "files", False, True, "Change timestamp of the file", HL + TIME + [] ), - ColumnDef("ctime", "files", False, True, "Change timestamp of the file", HL + TIME + []), ColumnDef( "mtime", "files", False, True, "Last modified time of the file", HL + TIME + [] ), - ColumnDef("atime", "files", False, True, "Last access time of the file", HL + TIME + []), ColumnDef( - "birthtime", "files", False, True, "The time the file was created", HL + TIME + [] + "atime", "files", False, True, "Last access time of the file", HL + TIME + [] + ), + ColumnDef( + "birthtime", + "files", + False, + True, + "The time the file was created", + HL + TIME + [], ), ] @@ -170,7 +192,7 @@ def get_actions_vimdoc() -> "VimdocSection": section = VimdocSection("Actions", "oil-actions", ["\n"]) section.body.extend( wrap( - "These are actions that can be used in the `keymaps` section of config options." + """These are actions that can be used in the `keymaps` section of config options. You can also call them directly with `require("oil.actions").action_name.callback()`""" ) ) section.body.append("\n") @@ -210,6 +232,37 @@ def get_columns_vimdoc() -> "VimdocSection": return section +def get_trash_vimdoc() -> "VimdocSection": + section = VimdocSection("Trash", "oil-trash", []) + section.body.append( + """ +Oil has built-in support for using the system trash. When +`delete_to_trash = true`, any deleted files will be sent to the trash instead +of being permanently deleted. You can browse the trash for a directory using +the `toggle_trash` action (bound to `g\\` by default). You can view all files +in the trash with `:Oil --trash /`. + +To restore files, simply delete them from the trash and put them in the desired +destination, the same as any other file operation. If you delete files from the +trash they will be permanently deleted (purged). + +Linux: + Oil supports the FreeDesktop trash specification. + https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html + All features should work. + +Mac: + Oil has limited support for MacOS due to the proprietary nature of the + implementation. The trash bin can only be viewed as a single dir + (instead of being able to see files that were trashed from a directory). + +Windows: + Oil does not yet support the Windows trash. PRs are welcome! +""" + ) + return section + + def generate_vimdoc(): doc = Vimdoc("oil.txt", "oil") funcs = parse_functions(os.path.join(ROOT, "lua", "oil", "init.lua")) @@ -220,6 +273,7 @@ def generate_vimdoc(): get_columns_vimdoc(), get_actions_vimdoc(), get_highlights_vimdoc(), + get_trash_vimdoc(), ] ) diff --git a/syntax/oil_preview.vim b/syntax/oil_preview.vim index 41656ee3..b6c2fab7 100644 --- a/syntax/oil_preview.vim +++ b/syntax/oil_preview.vim @@ -3,9 +3,13 @@ if exists("b:current_syntax") endif syn match oilCreate /^CREATE / -syn match oilMove /^ MOVE / +syn match oilMove /^ MOVE / syn match oilDelete /^DELETE / -syn match oilCopy /^ COPY / +syn match oilCopy /^ COPY / syn match oilChange /^CHANGE / +" Trash operations +syn match oilRestore /^RESTORE / +syn match oilPurge /^ PURGE / +syn match oilTrash /^ TRASH / let b:current_syntax = "oil_preview" diff --git a/tests/files_spec.lua b/tests/files_spec.lua index 3e5e4668..b268b4c3 100644 --- a/tests/files_spec.lua +++ b/tests/files_spec.lua @@ -11,8 +11,6 @@ a.describe("files adapter", function() a.after_each(function() if tmpdir then tmpdir:dispose() - a.util.scheduler() - tmpdir = nil end test_util.reset_editor() end) diff --git a/tests/minimal_init.lua b/tests/minimal_init.lua index 262d9ece..70a66b18 100644 --- a/tests/minimal_init.lua +++ b/tests/minimal_init.lua @@ -1,4 +1,4 @@ -vim.cmd([[set runtimepath+=.]]) +vim.opt.runtimepath:append(".") vim.o.swapfile = false vim.bo.swapfile = false diff --git a/tests/parser_spec.lua b/tests/parser_spec.lua index f3a4262b..74872638 100644 --- a/tests/parser_spec.lua +++ b/tests/parser_spec.lua @@ -18,6 +18,7 @@ describe("parser", function() after_each(function() test_util.reset_editor() end) + it("detects new files", function() vim.cmd.edit({ args = { "oil-test:///foo/" } }) local bufnr = vim.api.nvim_get_current_buf() diff --git a/tests/test_util.lua b/tests/test_util.lua index e2ab2aa6..53d4bfed 100644 --- a/tests/test_util.lua +++ b/tests/test_util.lua @@ -25,6 +25,18 @@ M.reset_editor = function() test_adapter.test_clear() end +local function throwiferr(err, ...) + if err then + error(err) + else + return ... + end +end + +M.await = function(fn, nargs, ...) + return throwiferr(a.wrap(fn, nargs)(...)) +end + M.wait_for_autocmd = a.wrap(function(autocmd, cb) local opts = { pattern = "*", @@ -58,4 +70,48 @@ M.feedkeys = function(actions, timestep) a.util.sleep(timestep) end +M.actions = { + ---Open oil and wait for it to finish rendering + ---@param args string[] + open = function(args) + vim.schedule(function() + vim.cmd.Oil({ args = args }) + -- If this buffer was already open, manually dispatch the autocmd to finish the wait + if vim.b.oil_ready then + vim.api.nvim_exec_autocmds("User", { + pattern = "OilEnter", + modeline = false, + data = { buf = vim.api.nvim_get_current_buf() }, + }) + end + end) + M.wait_for_autocmd({ "User", pattern = "OilEnter" }) + end, + + ---Save all changes and wait for operation to complete + save = function() + vim.schedule_wrap(require("oil").save)({ confirm = false }) + M.wait_for_autocmd({ "User", pattern = "OilMutationComplete" }) + end, + + ---@param bufnr? integer + reload = function(bufnr) + M.await(require("oil.view").render_buffer_async, 3, bufnr or 0) + end, + + ---Move cursor to a file or directory in an oil buffer + ---@param filename string + focus = function(filename) + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, true) + local search = " " .. filename .. "$" + for i, line in ipairs(lines) do + if line:match(search) then + vim.api.nvim_win_set_cursor(0, { i, 0 }) + return + end + end + error("Could not find file " .. filename) + end, +} + return M diff --git a/tests/tmpdir.lua b/tests/tmpdir.lua index 62018f45..bb8a9c0a 100644 --- a/tests/tmpdir.lua +++ b/tests/tmpdir.lua @@ -1,16 +1,7 @@ local fs = require("oil.fs") +local test_util = require("tests.test_util") -local function throwiferr(err, ...) - if err then - error(err) - else - return ... - end -end - -local function await(fn, nargs, ...) - return throwiferr(a.wrap(fn, nargs)(...)) -end +local await = test_util.await ---@param path string ---@param cb fun(err: nil|string) @@ -41,6 +32,7 @@ local TmpDir = {} TmpDir.new = function() local path = await(vim.loop.fs_mkdtemp, 2, "oil_test_XXXXXXXXX") + a.util.scheduler() return setmetatable({ path = path }, { __index = TmpDir, }) @@ -60,6 +52,7 @@ function TmpDir:create(paths) end end end + a.util.scheduler() end ---@param filepath string @@ -72,6 +65,7 @@ local read_file = function(filepath) local stat = vim.loop.fs_fstat(fd) local content = vim.loop.fs_read(fd, stat.size) vim.loop.fs_close(fd) + a.util.scheduler() return content end @@ -99,9 +93,9 @@ local assert_fs = function(root, paths) local pieces = vim.split(k, "/") local partial_path = "" for i, piece in ipairs(pieces) do - partial_path = fs.join(partial_path, piece) .. "/" + partial_path = partial_path .. piece .. "/" if i ~= #pieces then - unlisted_dirs[partial_path:sub(2)] = true + unlisted_dirs[partial_path] = true end end end @@ -152,8 +146,23 @@ function TmpDir:assert_fs(paths) assert_fs(self.path, paths) end +function TmpDir:assert_exists(path) + a.util.scheduler() + path = fs.join(self.path, path) + local stat = vim.loop.fs_stat(path) + assert.truthy(stat, string.format("Expected path '%s' to exist", path)) +end + +function TmpDir:assert_not_exists(path) + a.util.scheduler() + path = fs.join(self.path, path) + local stat = vim.loop.fs_stat(path) + assert.falsy(stat, string.format("Expected path '%s' to not exist", path)) +end + function TmpDir:dispose() await(fs.recursive_delete, 3, "directory", self.path) + a.util.scheduler() end return TmpDir diff --git a/tests/trash_spec.lua b/tests/trash_spec.lua new file mode 100644 index 00000000..e74ae77b --- /dev/null +++ b/tests/trash_spec.lua @@ -0,0 +1,164 @@ +local uv = vim.uv or vim.loop +require("plenary.async").tests.add_to_env() +local TmpDir = require("tests.tmpdir") +local fs = require("oil.fs") +local test_util = require("tests.test_util") + +---Get the raw list of filenames from an unmodified oil buffer +---@param bufnr? integer +---@return string[] +local function parse_entries(bufnr) + bufnr = bufnr or 0 + if vim.bo[bufnr].modified then + error("parse_entries doesn't work on a modified oil buffer") + end + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, true) + return vim.tbl_map(function(line) + return line:match("^/%d+ +(.+)$") + end, lines) +end + +a.describe("freedesktop", function() + local tmpdir + a.before_each(function() + require("oil.config").delete_to_trash = true + tmpdir = TmpDir.new() + package.loaded["oil.adapters.trash"] = require("oil.adapters.trash.freedesktop") + local trash_dir = string.format(".Trash-%d", uv.getuid()) + tmpdir:create({ fs.join(trash_dir, "__dummy__") }) + end) + a.after_each(function() + if tmpdir then + tmpdir:dispose() + end + test_util.reset_editor() + package.loaded["oil.adapters.trash"] = nil + end) + + a.it("files can be moved to the trash", function() + tmpdir:create({ "a.txt", "foo/b.txt" }) + test_util.actions.open({ tmpdir.path }) + test_util.actions.focus("a.txt") + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.open({ "--trash", tmpdir.path }) + vim.api.nvim_feedkeys("p", "x", true) + test_util.actions.save() + tmpdir:assert_not_exists("a.txt") + tmpdir:assert_exists("foo/b.txt") + test_util.actions.reload() + assert.are.same({ "a.txt" }, parse_entries(0)) + end) + + a.it("deleting a file moves it to trash", function() + tmpdir:create({ "a.txt", "foo/b.txt" }) + test_util.actions.open({ tmpdir.path }) + test_util.actions.focus("a.txt") + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.save() + tmpdir:assert_not_exists("a.txt") + tmpdir:assert_exists("foo/b.txt") + test_util.actions.open({ "--trash", tmpdir.path }) + assert.are.same({ "a.txt" }, parse_entries(0)) + end) + + a.it("deleting a directory moves it to trash", function() + tmpdir:create({ "a.txt", "foo/b.txt" }) + test_util.actions.open({ tmpdir.path }) + test_util.actions.focus("foo/") + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.save() + tmpdir:assert_not_exists("foo") + tmpdir:assert_exists("a.txt") + test_util.actions.open({ "--trash", tmpdir.path }) + assert.are.same({ "foo/" }, parse_entries(0)) + end) + + a.it("deleting a file from trash deletes it permanently", function() + tmpdir:create({ "a.txt" }) + test_util.actions.open({ tmpdir.path }) + test_util.actions.focus("a.txt") + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.save() + test_util.actions.open({ "--trash", tmpdir.path }) + test_util.actions.focus("a.txt") + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.save() + test_util.actions.reload() + tmpdir:assert_not_exists("a.txt") + assert.are.same({}, parse_entries(0)) + end) + + a.it("cannot create files in the trash", function() + tmpdir:create({ "a.txt" }) + test_util.actions.open({ tmpdir.path }) + test_util.actions.focus("a.txt") + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.save() + test_util.actions.open({ "--trash", tmpdir.path }) + vim.api.nvim_feedkeys("onew_file.txt", "x", true) + test_util.actions.save() + test_util.actions.reload() + assert.are.same({ "a.txt" }, parse_entries(0)) + end) + + a.it("cannot rename files in the trash", function() + tmpdir:create({ "a.txt" }) + test_util.actions.open({ tmpdir.path }) + test_util.actions.focus("a.txt") + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.save() + test_util.actions.open({ "--trash", tmpdir.path }) + vim.api.nvim_feedkeys("0facwnew_name", "x", true) + test_util.actions.save() + test_util.actions.reload() + assert.are.same({ "a.txt" }, parse_entries(0)) + end) + + a.it("cannot copy files in the trash", function() + tmpdir:create({ "a.txt" }) + test_util.actions.open({ tmpdir.path }) + test_util.actions.focus("a.txt") + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.save() + test_util.actions.open({ "--trash", tmpdir.path }) + vim.api.nvim_feedkeys("yypp", "x", true) + test_util.actions.save() + test_util.actions.reload() + assert.are.same({ "a.txt" }, parse_entries(0)) + end) + + a.it("can restore files from trash", function() + tmpdir:create({ "a.txt" }) + test_util.actions.open({ tmpdir.path }) + test_util.actions.focus("a.txt") + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.save() + test_util.actions.open({ "--trash", tmpdir.path }) + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.open({ tmpdir.path }) + vim.api.nvim_feedkeys("p", "x", true) + test_util.actions.save() + test_util.actions.reload() + assert.are.same({ "a.txt" }, parse_entries(0)) + local uid = uv.getuid() + tmpdir:assert_fs({ + ["a.txt"] = "a.txt", + [".Trash-" .. uid .. "/__dummy__"] = ".Trash-" .. uid .. "/__dummy__", + [".Trash-" .. uid .. "/files/"] = true, + [".Trash-" .. uid .. "/info/"] = true, + }) + end) + + a.it("can have multiple files with the same name in trash", function() + tmpdir:create({ "a.txt" }) + test_util.actions.open({ tmpdir.path }) + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.save() + tmpdir:create({ "a.txt" }) + test_util.actions.reload() + vim.api.nvim_feedkeys("dd", "x", true) + test_util.actions.save() + test_util.actions.open({ "--trash", tmpdir.path }) + assert.are.same({ "a.txt", "a.txt" }, parse_entries(0)) + end) +end) diff --git a/tests/url_spec.lua b/tests/url_spec.lua index d9ecb309..32d801eb 100644 --- a/tests/url_spec.lua +++ b/tests/url_spec.lua @@ -13,7 +13,7 @@ describe("url", function() } for _, case in ipairs(cases) do local input, expected, expected_basename = unpack(case) - local output, basename = oil.get_buffer_parent_url(input) + local output, basename = oil.get_buffer_parent_url(input, true) assert.equals(expected, output, string.format('Parent url for path "%s" failed', input)) assert.equals( expected_basename,