diff --git a/lua/oil/adapters/trash.lua b/lua/oil/adapters/trash.lua index 3c7ffeb9..b0070747 100644 --- a/lua/oil/adapters/trash.lua +++ b/lua/oil/adapters/trash.lua @@ -3,7 +3,7 @@ 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") + return require("oil.adapters.trash.windows") else return require("oil.adapters.trash.freedesktop") end diff --git a/lua/oil/adapters/trash/windows.lua b/lua/oil/adapters/trash/windows.lua index f2c37c91..97bec8f4 100644 --- a/lua/oil/adapters/trash/windows.lua +++ b/lua/oil/adapters/trash/windows.lua @@ -1,20 +1,487 @@ --- Work in progress +local util = require("oil.util") +local uv = vim.uv or vim.loop +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 FIELD_META = constants.FIELD_META +local FIELD_TYPE = constants.FIELD_TYPE + 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 string +local function get_trash_dir() + local cwd = assert(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 + +---@param path string +---@return string +local win_addslash = function(path) + if not vim.endswith(path, "\\") then + return path .. "\\" + else + return path + end +end + +---@class oil.WindowsRawEntry +---@field IsFolder boolean +---@field DeletionDate integer +---@field Name string +---@field Path string +---@field OriginalPath string + +---@param cb fun(err?: string, raw_entries: oil.WindowsRawEntry[]?) +local get_raw_entries = function(cb) + ---@type string? + local stdout + + local jid = vim.fn.jobstart({ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + -- The first line configures Windows Powershell to use UTF-8 for input and output + -- 0xa is the constant for Recycle Bin. See https://learn.microsoft.com/en-us/windows/win32/api/shldisp/ne-shldisp-shellspecialfolderconstants + [[ +$OutputEncoding = [Console]::InputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding +$shell = New-Object -ComObject 'Shell.Application' +$folder = $shell.NameSpace(0xa) +$data = @(foreach ($i in $folder.items()) + { + @{ + IsFolder=$i.IsFolder; + DeletionDate=([DateTimeOffset]$i.extendedproperty('datedeleted')).ToUnixTimeSeconds(); + Name=$i.Name; + Path=$i.Path; + OriginalPath=-join($i.ExtendedProperty('DeletedFrom'), "\", $i.Name) + } + }) +ConvertTo-Json $data +]], + }, { + stdout_buffered = true, + on_stdout = function(_, data) + stdout = table.concat(data, "\n") + end, + on_exit = function(_, code) + if code ~= 0 then + return cb("Error listing files on trash") + end + assert(stdout) + local raw_entries = vim.json.decode(stdout) + cb(nil, raw_entries) + end, + }) + if jid <= 0 then + cb("Could not list windows devices") + end +end + +---@class oil.WindowsTrashInfo +---@field trash_file string? +---@field original_path string? +---@field deletion_date string? +---@field info_file string? + +---@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) + local _, path = util.parse_url(url) + path = fs.posix_to_os_path(assert(path)) + + local trash_dir = get_trash_dir() + local show_all_files = fs.is_subpath(path, trash_dir) + + get_raw_entries(function(err, raw_entries) + if err then + cb(err) + return + end + + local raw_displayed_entries = vim.tbl_filter( + ---@param entry {IsFolder: boolean, DeletionDate: integer, Name: string, Path: string, OriginalPath: string} + function(entry) + local parent = win_addslash(assert(vim.fn.fnamemodify(entry.OriginalPath, ":h"))) + local is_in_path = path == parent + local is_subpath = fs.is_subpath(path, parent) + return is_in_path or is_subpath or show_all_files + end, + raw_entries + ) + local displayed_entries = vim.tbl_map( + ---@param entry {IsFolder: boolean, DeletionDate: integer, Name: string, Path: string, OriginalPath: string} + ---@return {[1]:nil, [2]:string, [3]:string, [4]:{stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}} + function(entry) + local parent = win_addslash(assert(vim.fn.fnamemodify(entry.OriginalPath, ":h"))) + + --- @type oil.InternalEntry + local cache_entry + if path == parent or show_all_files then + local deleted_file_tail = assert(vim.fn.fnamemodify(entry.Path, ":t")) + local deleted_file_head = assert(vim.fn.fnamemodify(entry.Path, ":h")) + local info_file_head = deleted_file_head + --- @type string? + local info_file + cache_entry = + cache.create_entry(url, deleted_file_tail, entry.IsFolder and "directory" or "file") + -- info_file on windows has the following format: $I<6 char hash>. + -- the hash is the same for the deleted file and the info file + -- so, we take the hash (and extension) from the deleted file + -- + -- see https://superuser.com/questions/368890/how-does-the-recycle-bin-in-windows-work/1736690#1736690 + local info_file_tail = deleted_file_tail:match("^%$R(.*)$") --[[@as string?]] + if info_file_tail then + info_file_tail = "$I" .. info_file_tail + info_file = info_file_head .. "\\" .. info_file_tail + end + cache_entry[FIELD_META] = { + stat = nil, + trash_info = { + trash_file = entry.Path, + original_path = entry.OriginalPath, + deletion_date = entry.DeletionDate, + info_file = info_file, + }, + display_name = entry.Name, + } + 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) + cache_entry = cache.create_entry(url, name, "directory") + + cache_entry[FIELD_META] = {} + end + end + return cache_entry + end, + raw_displayed_entries + ) + cb(nil, displayed_entries) + end) +end + +M.is_modifiable = function(_bufnr) + return true +end + +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) + +local file_columns = {} +file_columns.mtime = { + render = function(entry, conf) + local meta = entry[FIELD_META] + if not meta then + return nil + end + ---@type oil.WindowsTrashInfo + local trash_info = meta.trash_info + local time = trash_info and trash_info.deletion_date + 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 oil.WindowsTrashInfo + local trash_info = meta.trash_info + if trash_info and trash_info.deletion_date 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 + +---@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 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") + assert(os_path) + 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] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]] + 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 entry.type == "directory" then + path = win_addslash(path) + end + cb("oil://" .. path) +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.WindowsTrashInfo + 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.WindowsTrashInfo +---@param cb fun(err?: string, raw_entries: oil.WindowsRawEntry[]?) +local purge = function(trash_info, cb) + fs.recursive_delete("file", trash_info.info_file, function(err) + if err then + return cb(err) + end + fs.recursive_delete("file", trash_info.trash_file, cb) + end) +end + +---@param path string +---@param type string +---@param cb fun(err?: string, trash_info?: oil.TrashInfo) +local function create_trash_info_and_copy(path, type, cb) + local temp_path = path .. "temp" + -- create a temporary copy on the same location + fs.recursive_copy( + type, + path, + temp_path, + vim.schedule_wrap(function(err) + if err then + return cb(err) + end + -- delete original file + M.delete_to_trash(path, function(err2) + if err2 then + return cb(err2) + end + -- rename temporary copy to the original file name + fs.recursive_move(type, temp_path, path, cb) + 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] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]] + 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) + dest_path = fs.posix_to_os_path(dest_path) + local entry = assert(cache.get_entry_by_url(action.src_url)) + local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]] + 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) + 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) + path = fs.posix_to_os_path(path) + local entry = assert(cache.get_entry_by_url(action.src_url)) + create_trash_info_and_copy(path, entry[FIELD_TYPE], cb) + elseif dest_adapter.name == "files" then + -- Restore + local _, dest_path = util.parse_url(action.dest_url) + assert(dest_path) + dest_path = fs.posix_to_os_path(dest_path) + local entry = assert(cache.get_entry_by_url(action.src_url)) + local meta = entry[FIELD_META] --[[@as {stat: uv_fs_t, trash_info: oil.WindowsTrashInfo, display_name: string}]] + 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 + +M.supported_cross_adapter_actions = { files = "move" } + +---@param path string +---@param cb fun(err?: string) +M.delete_to_trash = function(path, cb) + local jid = vim.fn.jobstart({ + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + -- 0 is the constant for Windows Desktop. See https://learn.microsoft.com/en-us/windows/win32/api/shldisp/ne-shldisp-shellspecialfolderconstants + ([[ +$path = Get-Item '%s' +$shell = New-Object -ComObject 'Shell.Application' +$folder = $shell.NameSpace(0) +$folder.ParseName($path.FullName).InvokeVerb('delete') +]]):format(path:gsub("'", "''")), + }, { + stdout_buffered = true, + on_exit = function(_, code) + if code ~= 0 then + return cb("Error sendig file to trash") + end + cb() + end, + }) + if jid <= 0 then + cb("Could not list windows devices") + end +end return M diff --git a/lua/oil/config.lua b/lua/oil/config.lua index e7000c90..eab0adce 100644 --- a/lua/oil/config.lua +++ b/lua/oil/config.lua @@ -1,5 +1,3 @@ -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. @@ -163,17 +161,6 @@ M.setup = function(opts) new_conf.keymaps = opts.keymaps or {} end - if new_conf.delete_to_trash then - local is_windows = uv.os_uname().version:match("Windows") - if is_windows then - vim.notify( - "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 - end - end - for k, v in pairs(new_conf) do M[k] = v end diff --git a/lua/oil/fs.lua b/lua/oil/fs.lua index e0b11ddc..82def6c9 100644 --- a/lua/oil/fs.lua +++ b/lua/oil/fs.lua @@ -87,7 +87,7 @@ M.posix_to_os_path = function(path) if vim.startswith(path, "/") then local drive = path:match("^/(%a+)") local rem = path:sub(drive:len() + 2) - return string.format("%s:\\%s", drive, rem:gsub("/", "\\")) + return string.format("%s:%s", drive, rem:gsub("/", "\\")) else local newpath = path:gsub("/", "\\") return newpath diff --git a/lua/oil/init.lua b/lua/oil/init.lua index 05b28305..06157109 100644 --- a/lua/oil/init.lua +++ b/lua/oil/init.lua @@ -429,7 +429,9 @@ end M.select = function(opts, callback) local cache = require("oil.cache") local config = require("oil.config") + local constants = require("oil.constants") local pathutil = require("oil.pathutil") + local FIELD_META = constants.FIELD_META opts = vim.tbl_extend("keep", opts or {}, {}) local function finish(err) if err then @@ -498,6 +500,13 @@ M.select = function(opts, callback) local is_new_entry = entry.id == nil local is_moved_from_dir = entry.id and cache.get_parent_url(entry.id) ~= bufname local is_renamed = entry.parsed_name ~= entry.name + local internal_entry = entry.id and cache.get_entry_by_id(entry.id) + if internal_entry then + local meta = internal_entry[FIELD_META] + if meta and meta.display_name then + is_renamed = entry.parsed_name ~= meta.display_name + end + end if is_new_entry or is_moved_from_dir or is_renamed then any_moved = true break