262 lines
12 KiB
Lua
262 lines
12 KiB
Lua
-- Recursive copy used by the gM mapping below. Unix keeps using `cp -r`
|
|
-- (unchanged behavior); Windows has no `cp`, so it gets an equivalent
|
|
-- written against libuv. One difference there: if the destination
|
|
-- directory already exists, contents are merged into it rather than the
|
|
-- source being nested inside it, which is what `cp -r` would do.
|
|
-- Returns true on success, or false plus an error message.
|
|
local function copy_path(src, dest)
|
|
if vim.fn.has("win32") == 0 then
|
|
local result = vim.fn.system({ "cp", "-r", src, dest })
|
|
return vim.v.shell_error == 0, result
|
|
end
|
|
|
|
local uv = vim.uv or vim.loop
|
|
local function copy(s, d)
|
|
local stat = uv.fs_stat(s)
|
|
if not stat then
|
|
return false, "no such file or directory: " .. s
|
|
end
|
|
if stat.type == "directory" then
|
|
local made, mkerr = uv.fs_mkdir(d, 493) -- 0755
|
|
if not made then
|
|
local existing = uv.fs_stat(d)
|
|
if not (existing and existing.type == "directory") then
|
|
return false, mkerr
|
|
end
|
|
end
|
|
local handle = uv.fs_scandir(s)
|
|
while handle do
|
|
local name = uv.fs_scandir_next(handle)
|
|
if not name then
|
|
break
|
|
end
|
|
local ok, err = copy(s .. "/" .. name, d .. "/" .. name)
|
|
if not ok then
|
|
return false, err
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
local ok, err = uv.fs_copyfile(s, d)
|
|
return ok and true or false, err
|
|
end
|
|
return copy(src, dest)
|
|
end
|
|
|
|
return {
|
|
-- NvimTree (On the right)
|
|
{
|
|
"nvim-tree/nvim-tree.lua",
|
|
dependencies = { "nvim-tree/nvim-web-devicons" },
|
|
config = function()
|
|
require("nvim-tree").setup({
|
|
view = {
|
|
side = "right",
|
|
width = 30,
|
|
},
|
|
-- Routes the paste overwrite/rename CHOICE through
|
|
-- vim.ui.select tagged with kind = "nvimtree_overwrite_rename"
|
|
-- (nvim-tree's own documented hook for this), which the gP
|
|
-- batch-paste below intercepts.
|
|
select_prompts = true,
|
|
|
|
-- Keep nvim-tree's own default mappings and add gP on top of
|
|
-- them, via the officially documented on_attach recipe --
|
|
-- rather than the TreeAttachedPost event this used before,
|
|
-- whose handler is passed the bufnr directly as a plain
|
|
-- number (confirmed from nvim-tree's own docs), not a table
|
|
-- with a .buf field -- indexing that number is what threw
|
|
-- "attempt to index local 'data' (a number value)".
|
|
on_attach = function(bufnr)
|
|
local api = require("nvim-tree.api")
|
|
|
|
-- The helper that installs the defaults has been named
|
|
-- differently across versions (api.config.mappings.
|
|
-- default_on_attach vs the older api.map.on_attach.default)
|
|
-- -- try both rather than assume one, so this doesn't
|
|
-- silently lose every default mapping on a version where
|
|
-- the other name is the real one.
|
|
if not pcall(api.config.mappings.default_on_attach, bufnr) then
|
|
pcall(api.map.on_attach.default, bufnr)
|
|
end
|
|
|
|
-- gP: paste, resolving every naming collision in this one
|
|
-- paste with a single :s///-style pattern/replacement
|
|
--
|
|
-- CAVEAT: this assumes a single-file-style overwrite/rename
|
|
-- choice per conflict. nvim-tree turns out to have a
|
|
-- SEPARATE, differently-shaped dialog specifically for
|
|
-- multi-file conflicts in one paste ("N file(s) already
|
|
-- exist" / Rename (suffix) / Overwrite all / Skip all),
|
|
-- which this doesn't detect or intercept -- so gP is only
|
|
-- confirmed reliable for a single conflicting file. For
|
|
-- "always rename every file, conflict or not" -- which is
|
|
-- what was actually being asked for -- gM below is the
|
|
-- better fit: it doesn't touch nvim-tree's paste/conflict
|
|
-- system at all, just copies marked files directly.
|
|
-- instead of nvim-tree's normal one-at-a-time
|
|
-- overwrite/rename prompt per conflicting file. Plain `p`
|
|
-- (bound above by default_on_attach) is untouched -- this
|
|
-- is a deliberately separate mapping, not a replacement.
|
|
--
|
|
-- nvim-tree doesn't expose a way to enumerate the clipboard
|
|
-- or pre-scan for collisions before pasting (only
|
|
-- print/clear it), and conflicts are resolved one at a time
|
|
-- internally as the paste runs -- there's no "here's the
|
|
-- whole batch" moment to hook into directly. This gets the
|
|
-- same practical result a different way: ask ONCE upfront
|
|
-- for the pattern, then answer nvim-tree's own conflict
|
|
-- prompts programmatically for the duration of that paste
|
|
-- using vim.fn.substitute() (real Vim regex, same engine
|
|
-- :s/// itself uses) against the filename nvim-tree
|
|
-- pre-fills as its default.
|
|
--
|
|
-- vim.ui.select/vim.ui.input are wrapped HERE, at call time
|
|
-- inside the keymap function, rather than once when this
|
|
-- plugin's config runs. nvim-tree loads eagerly at startup,
|
|
-- but dressing.nvim (which ALSO wraps these same two
|
|
-- functions, to prettify them) loads lazily on VeryLazy,
|
|
-- afterward -- wrapping once at config time would capture
|
|
-- the plain pre-dressing versions, which then get silently
|
|
-- overwritten (discarding this wrapper entirely) once
|
|
-- dressing finishes loading. Wrapping fresh on every call
|
|
-- instead always captures whatever's actually active.
|
|
vim.keymap.set('n', 'gP', function()
|
|
vim.ui.input({ prompt = "Rename pattern/replacement (Vim regex, e.g. foo/bar): " }, function(input)
|
|
if not input or input == "" then
|
|
api.fs.paste()
|
|
return
|
|
end
|
|
local pattern, replacement = input:match("^(.-)/(.*)$")
|
|
if not pattern then
|
|
vim.notify("Expected pattern/replacement, e.g. foo/bar", vim.log.levels.WARN)
|
|
return
|
|
end
|
|
|
|
local real_select = vim.ui.select
|
|
local real_input = vim.ui.input
|
|
|
|
vim.ui.select = function(items, opts, on_choice)
|
|
if opts and opts.kind == "nvimtree_overwrite_rename" then
|
|
for i, item in ipairs(items) do
|
|
if tostring(item):lower():match("rename") then
|
|
on_choice(item, i)
|
|
return
|
|
end
|
|
end
|
|
end
|
|
real_select(items, opts, on_choice)
|
|
end
|
|
|
|
vim.ui.input = function(input_opts, on_confirm)
|
|
if input_opts and input_opts.default then
|
|
local ok, new_name = pcall(vim.fn.substitute, input_opts.default, pattern, replacement, "")
|
|
on_confirm(ok and new_name ~= "" and new_name or input_opts.default)
|
|
return
|
|
end
|
|
real_input(input_opts, on_confirm)
|
|
end
|
|
|
|
-- Restored via a short defer rather than immediately
|
|
-- after api.fs.paste() returns, since it's not
|
|
-- confirmed whether paste() resolves every prompt
|
|
-- synchronously before returning to caller or defers
|
|
-- some of them -- restoring immediately risked only
|
|
-- patching the FIRST conflict in a multi-file paste and
|
|
-- silently falling back to normal prompts for the rest.
|
|
-- If that's what happens, this assumption is the next
|
|
-- thing to revisit.
|
|
api.fs.paste()
|
|
vim.defer_fn(function()
|
|
vim.ui.select = real_select
|
|
vim.ui.input = real_input
|
|
end, 2000)
|
|
end)
|
|
end, { desc = "nvim-tree: Paste (Batch Rename Regex)", buffer = bufnr, silent = true })
|
|
|
|
-- Clears the copy/cut clipboard outright. Useful if it's
|
|
-- holding a stale entry -- a file that's since been
|
|
-- renamed, moved, or deleted -- which pastes as "ENOENT: no
|
|
-- such file or directory" until cleared and re-copied fresh.
|
|
vim.keymap.set('n', 'gC', api.fs.clear_clipboard, { desc = "nvim-tree: Clear Clipboard", buffer = bufnr, silent = true })
|
|
|
|
-- gM: copy every MARKED file/folder (toggle a mark with m,
|
|
-- shown as a star) into the directory under the cursor,
|
|
-- applying a single :s///-style pattern/replacement to
|
|
-- EVERY name unconditionally -- not just on collision. This
|
|
-- is deliberately separate from nvim-tree's own copy/paste
|
|
-- clipboard (c/x/p/gP above) entirely: it reads the marked
|
|
-- list via api.marks.list() (a real, documented, stable
|
|
-- API -- unlike the clipboard, which has no equivalent way
|
|
-- to enumerate its contents) and shells out to `cp -r` to
|
|
-- do the actual copying itself, the same approach used in
|
|
-- nvim-tree's own official custom-copy recipe. Marks are
|
|
-- left as-is afterward (not auto-cleared), matching how the
|
|
-- regular clipboard also isn't cleared after a paste --
|
|
-- toggle them off individually with m if you're done with
|
|
-- them.
|
|
vim.keymap.set('n', 'gM', function()
|
|
local marks = api.marks.list()
|
|
if not marks or #marks == 0 then
|
|
vim.notify("No marked files (mark with 'm' first)", vim.log.levels.WARN)
|
|
return
|
|
end
|
|
local cursor_node = api.tree.get_node_under_cursor()
|
|
if not cursor_node then
|
|
return
|
|
end
|
|
local dest_dir = cursor_node.type == "directory" and cursor_node.absolute_path
|
|
or vim.fn.fnamemodify(cursor_node.absolute_path, ":h")
|
|
|
|
vim.ui.input({ prompt = "Rename pattern/replacement (Vim regex, e.g. Foo/Bar): " }, function(input)
|
|
if not input or input == "" then
|
|
return
|
|
end
|
|
local pattern, replacement = input:match("^(.-)/(.*)$")
|
|
if not pattern then
|
|
vim.notify("Expected pattern/replacement, e.g. Foo/Bar", vim.log.levels.WARN)
|
|
return
|
|
end
|
|
for _, node in ipairs(marks) do
|
|
if not vim.loop.fs_stat(node.absolute_path) then
|
|
-- Marks persist by path in nvim-tree's own state and
|
|
-- are NOT cleared when the underlying file/folder is
|
|
-- deleted, so a stale mark can point at a path that
|
|
-- no longer exists (e.g. you marked it, then deleted
|
|
-- it). Clean up the stale mark itself here instead
|
|
-- of attempting (and failing) a copy from it.
|
|
pcall(api.marks.toggle, node)
|
|
vim.notify("Skipped stale mark (no longer exists): " .. node.absolute_path, vim.log.levels.WARN)
|
|
else
|
|
local base = vim.fn.fnamemodify(node.absolute_path, ":t")
|
|
local ok, new_name = pcall(vim.fn.substitute, base, pattern, replacement, "")
|
|
if not ok or new_name == "" then
|
|
new_name = base
|
|
end
|
|
local dest_path = dest_dir .. "/" .. new_name
|
|
if dest_path == node.absolute_path then
|
|
-- The pattern didn't match this particular name
|
|
-- (so it came back unchanged) and the destination
|
|
-- is the same directory the file's already in --
|
|
-- that's a copy onto itself, which cp correctly
|
|
-- refuses. Skip it up front with a clearer reason
|
|
-- instead of surfacing cp's raw "same file" error.
|
|
vim.notify("Skipped " .. base .. ": pattern didn't match, and destination is the same as source", vim.log.levels.WARN)
|
|
else
|
|
local copied, result = copy_path(node.absolute_path, dest_path)
|
|
if not copied then
|
|
vim.notify("Copy failed for " .. base .. ": " .. tostring(result), vim.log.levels.ERROR)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
api.tree.reload()
|
|
end)
|
|
end, { desc = "nvim-tree: Copy Marked (Regex Rename)", buffer = bufnr, silent = true })
|
|
end,
|
|
})
|
|
vim.keymap.set('n', '<leader>e', ':NvimTreeToggle<CR>', { silent = true, desc = "Toggle File Explorer" })
|
|
end,
|
|
},
|
|
}
|