From 31c5df111d8475eaa4bd73cf816841f20a3f0624 Mon Sep 17 00:00:00 2001 From: Vladimir Ceban Date: Fri, 20 Feb 2026 12:54:56 -0800 Subject: [PATCH] feat(#2994): add visual selection operations --- doc/nvim-tree-lua.txt | 254 ++++++++++++----------- lua/nvim-tree/_meta/api/fs.lua | 6 +- lua/nvim-tree/_meta/api/marks.lua | 2 +- lua/nvim-tree/actions/fs/clipboard.lua | 207 +++++++++++------- lua/nvim-tree/actions/fs/remove-file.lua | 64 ++++-- lua/nvim-tree/actions/fs/trash.lua | 65 ++++-- lua/nvim-tree/api.lua | 4 +- lua/nvim-tree/api/impl/post.lua | 29 ++- lua/nvim-tree/explorer/init.lua | 16 ++ lua/nvim-tree/help.lua | 45 +++- lua/nvim-tree/keymap.lua | 118 +++++------ lua/nvim-tree/marks/init.lua | 17 +- lua/nvim-tree/utils.lua | 69 ++++++ scripts/help-defaults.sh | 15 +- 14 files changed, 604 insertions(+), 307 deletions(-) diff --git a/doc/nvim-tree-lua.txt b/doc/nvim-tree-lua.txt index f060026388e..ad2dc970366 100644 --- a/doc/nvim-tree-lua.txt +++ b/doc/nvim-tree-lua.txt @@ -106,65 +106,65 @@ Open the tree: `:NvimTreeOpen` Show the mappings: `g?` -`` CD |nvim_tree.api.tree.change_root_to_node()| -`` Open: In Place |nvim_tree.api.node.open.replace_tree_buffer()| -`` Info |nvim_tree.api.node.show_info_popup()| -`` Rename: Omit Filename |nvim_tree.api.fs.rename_sub()| -`` Open: New Tab |nvim_tree.api.node.open.tab()| -`` Open: Vertical Split |nvim_tree.api.node.open.vertical()| -`` Open: Horizontal Split |nvim_tree.api.node.open.horizontal()| -`` Close Directory |nvim_tree.api.node.navigate.parent_close()| -`` Open |nvim_tree.api.node.open.edit()| -`` Delete |nvim_tree.api.fs.remove()| -`` Open Preview |nvim_tree.api.node.open.preview()| -`>` Next Sibling |nvim_tree.api.node.navigate.sibling.next()| -`<` Previous Sibling |nvim_tree.api.node.navigate.sibling.prev()| -`.` Run Command |nvim_tree.api.node.run.cmd()| -`-` Up |nvim_tree.api.tree.change_root_to_parent()| -`a` Create File Or Directory |nvim_tree.api.fs.create()| -`bd` Delete Bookmarked |nvim_tree.api.marks.bulk.delete()| -`bt` Trash Bookmarked |nvim_tree.api.marks.bulk.trash()| -`bmv` Move Bookmarked |nvim_tree.api.marks.bulk.move()| -`B` Toggle Filter: No Buffer |nvim_tree.api.filter.no_buffer.toggle()| -`c` Copy |nvim_tree.api.fs.copy.node()| -`C` Toggle Filter: Git Clean |nvim_tree.api.filter.git.clean.toggle()| -`[c` Prev Git |nvim_tree.api.node.navigate.git.prev()| -`]c` Next Git |nvim_tree.api.node.navigate.git.next()| -`d` Delete |nvim_tree.api.fs.remove()| -`D` Trash |nvim_tree.api.fs.trash()| -`E` Expand All |nvim_tree.api.tree.expand_all()| -`e` Rename: Basename |nvim_tree.api.fs.rename_basename()| -`]e` Next Diagnostic |nvim_tree.api.node.navigate.diagnostics.next()| -`[e` Prev Diagnostic |nvim_tree.api.node.navigate.diagnostics.prev()| -`F` Live Filter: Clear |nvim_tree.api.filter.live.clear()| -`f` Live Filter: Start |nvim_tree.api.filter.live.start()| -`g?` Help |nvim_tree.api.tree.toggle_help()| -`gy` Copy Absolute Path |nvim_tree.api.fs.copy.absolute_path()| -`ge` Copy Basename |nvim_tree.api.fs.copy.basename()| -`H` Toggle Filter: Dotfiles |nvim_tree.api.filter.dotfiles.toggle()| -`I` Toggle Filter: Git Ignored |nvim_tree.api.filter.git.ignored.toggle()| -`J` Last Sibling |nvim_tree.api.node.navigate.sibling.last()| -`K` First Sibling |nvim_tree.api.node.navigate.sibling.first()| -`L` Toggle Group Empty |nvim_tree.api.node.open.toggle_group_empty()| -`M` Toggle Filter: No Bookmark |nvim_tree.api.filter.no_bookmark.toggle()| -`m` Toggle Bookmark |nvim_tree.api.marks.toggle()| -`o` Open |nvim_tree.api.node.open.edit()| -`O` Open: No Window Picker |nvim_tree.api.node.open.no_window_picker()| -`p` Paste |nvim_tree.api.fs.paste()| -`P` Parent Directory |nvim_tree.api.node.navigate.parent()| -`q` Close |nvim_tree.api.tree.close()| -`r` Rename |nvim_tree.api.fs.rename()| -`R` Refresh |nvim_tree.api.tree.reload()| -`s` Run System |nvim_tree.api.node.run.system()| -`S` Search |nvim_tree.api.tree.search_node()| -`u` Rename: Full Path |nvim_tree.api.fs.rename_full()| -`U` Toggle Filter: Custom |nvim_tree.api.filter.custom.toggle()| -`W` Collapse All |nvim_tree.api.tree.collapse_all()| -`x` Cut |nvim_tree.api.fs.cut()| -`y` Copy Name |nvim_tree.api.fs.copy.filename()| -`Y` Copy Relative Path |nvim_tree.api.fs.copy.relative_path()| -`<2-LeftMouse>` Open |nvim_tree.api.node.open.edit()| -`<2-RightMouse>` CD |nvim_tree.api.tree.change_root_to_node()| + `` n CD |nvim_tree.api.tree.change_root_to_node()| + `` n Open: In Place |nvim_tree.api.node.open.replace_tree_buffer()| + `` n Info |nvim_tree.api.node.show_info_popup()| + `` n Rename: Omit Filename |nvim_tree.api.fs.rename_sub()| + `` n Open: New Tab |nvim_tree.api.node.open.tab()| + `` n Open: Vertical Split |nvim_tree.api.node.open.vertical()| + `` n Open: Horizontal Split |nvim_tree.api.node.open.horizontal()| + `` n Close Directory |nvim_tree.api.node.navigate.parent_close()| + `` n Open |nvim_tree.api.node.open.edit()| + `` nx Delete |nvim_tree.api.fs.remove()| + `` n Open Preview |nvim_tree.api.node.open.preview()| + `>` n Next Sibling |nvim_tree.api.node.navigate.sibling.next()| + `<` n Previous Sibling |nvim_tree.api.node.navigate.sibling.prev()| + `.` n Run Command |nvim_tree.api.node.run.cmd()| + `-` n Up |nvim_tree.api.tree.change_root_to_parent()| + `a` n Create File Or Directory |nvim_tree.api.fs.create()| + `bd` n Delete Bookmarked |nvim_tree.api.marks.bulk.delete()| + `bt` n Trash Bookmarked |nvim_tree.api.marks.bulk.trash()| + `bmv` n Move Bookmarked |nvim_tree.api.marks.bulk.move()| + `B` n Toggle Filter: No Buffer |nvim_tree.api.filter.no_buffer.toggle()| + `c` nx Copy |nvim_tree.api.fs.copy.node()| + `C` n Toggle Filter: Git Clean |nvim_tree.api.filter.git.clean.toggle()| + `[c` n Prev Git |nvim_tree.api.node.navigate.git.prev()| + `]c` n Next Git |nvim_tree.api.node.navigate.git.next()| + `d` nx Delete |nvim_tree.api.fs.remove()| + `D` nx Trash |nvim_tree.api.fs.trash()| + `E` n Expand All |nvim_tree.api.tree.expand_all()| + `e` n Rename: Basename |nvim_tree.api.fs.rename_basename()| + `]e` n Next Diagnostic |nvim_tree.api.node.navigate.diagnostics.next()| + `[e` n Prev Diagnostic |nvim_tree.api.node.navigate.diagnostics.prev()| + `F` n Live Filter: Clear |nvim_tree.api.filter.live.clear()| + `f` n Live Filter: Start |nvim_tree.api.filter.live.start()| + `g?` n Help |nvim_tree.api.tree.toggle_help()| + `gy` n Copy Absolute Path |nvim_tree.api.fs.copy.absolute_path()| + `ge` n Copy Basename |nvim_tree.api.fs.copy.basename()| + `H` n Toggle Filter: Dotfiles |nvim_tree.api.filter.dotfiles.toggle()| + `I` n Toggle Filter: Git Ignored |nvim_tree.api.filter.git.ignored.toggle()| + `J` n Last Sibling |nvim_tree.api.node.navigate.sibling.last()| + `K` n First Sibling |nvim_tree.api.node.navigate.sibling.first()| + `L` n Toggle Group Empty |nvim_tree.api.node.open.toggle_group_empty()| + `M` n Toggle Filter: No Bookmark |nvim_tree.api.filter.no_bookmark.toggle()| + `m` nx Toggle Bookmark |nvim_tree.api.marks.toggle()| + `o` n Open |nvim_tree.api.node.open.edit()| + `O` n Open: No Window Picker |nvim_tree.api.node.open.no_window_picker()| + `p` n Paste |nvim_tree.api.fs.paste()| + `P` n Parent Directory |nvim_tree.api.node.navigate.parent()| + `q` n Close |nvim_tree.api.tree.close()| + `r` n Rename |nvim_tree.api.fs.rename()| + `R` n Refresh |nvim_tree.api.tree.reload()| + `s` n Run System |nvim_tree.api.node.run.system()| + `S` n Search |nvim_tree.api.tree.search_node()| + `u` n Rename: Full Path |nvim_tree.api.fs.rename_full()| + `U` n Toggle Filter: Custom |nvim_tree.api.filter.custom.toggle()| + `W` n Collapse All |nvim_tree.api.tree.collapse_all()| + `x` nx Cut |nvim_tree.api.fs.cut()| + `y` n Copy Name |nvim_tree.api.fs.copy.filename()| + `Y` n Copy Relative Path |nvim_tree.api.fs.copy.relative_path()| + `<2-LeftMouse>` n Open |nvim_tree.api.node.open.edit()| + `<2-RightMouse>` n CD |nvim_tree.api.tree.change_root_to_node()| ============================================================================== Quickstart: Custom Mappings *nvim-tree-quickstart-custom-mappings* @@ -399,65 +399,65 @@ You are encouraged to copy these to your {on_attach} function. >lua end -- BEGIN_ON_ATTACH_DEFAULT - vim.keymap.set("n", "", api.tree.change_root_to_node, opts("CD")) - vim.keymap.set("n", "", api.node.open.replace_tree_buffer, opts("Open: In Place")) - vim.keymap.set("n", "", api.node.show_info_popup, opts("Info")) - vim.keymap.set("n", "", api.fs.rename_sub, opts("Rename: Omit Filename")) - vim.keymap.set("n", "", api.node.open.tab, opts("Open: New Tab")) - vim.keymap.set("n", "", api.node.open.vertical, opts("Open: Vertical Split")) - vim.keymap.set("n", "", api.node.open.horizontal, opts("Open: Horizontal Split")) - vim.keymap.set("n", "", api.node.navigate.parent_close, opts("Close Directory")) - vim.keymap.set("n", "", api.node.open.edit, opts("Open")) - vim.keymap.set("n", "", api.fs.remove, opts("Delete")) - vim.keymap.set("n", "", api.node.open.preview, opts("Open Preview")) - vim.keymap.set("n", ">", api.node.navigate.sibling.next, opts("Next Sibling")) - vim.keymap.set("n", "<", api.node.navigate.sibling.prev, opts("Previous Sibling")) - vim.keymap.set("n", ".", api.node.run.cmd, opts("Run Command")) - vim.keymap.set("n", "-", api.tree.change_root_to_parent, opts("Up")) - vim.keymap.set("n", "a", api.fs.create, opts("Create File Or Directory")) - vim.keymap.set("n", "bd", api.marks.bulk.delete, opts("Delete Bookmarked")) - vim.keymap.set("n", "bt", api.marks.bulk.trash, opts("Trash Bookmarked")) - vim.keymap.set("n", "bmv", api.marks.bulk.move, opts("Move Bookmarked")) - vim.keymap.set("n", "B", api.filter.no_buffer.toggle, opts("Toggle Filter: No Buffer")) - vim.keymap.set("n", "c", api.fs.copy.node, opts("Copy")) - vim.keymap.set("n", "C", api.filter.git.clean.toggle, opts("Toggle Filter: Git Clean")) - vim.keymap.set("n", "[c", api.node.navigate.git.prev, opts("Prev Git")) - vim.keymap.set("n", "]c", api.node.navigate.git.next, opts("Next Git")) - vim.keymap.set("n", "d", api.fs.remove, opts("Delete")) - vim.keymap.set("n", "D", api.fs.trash, opts("Trash")) - vim.keymap.set("n", "E", api.tree.expand_all, opts("Expand All")) - vim.keymap.set("n", "e", api.fs.rename_basename, opts("Rename: Basename")) - vim.keymap.set("n", "]e", api.node.navigate.diagnostics.next, opts("Next Diagnostic")) - vim.keymap.set("n", "[e", api.node.navigate.diagnostics.prev, opts("Prev Diagnostic")) - vim.keymap.set("n", "F", api.filter.live.clear, opts("Live Filter: Clear")) - vim.keymap.set("n", "f", api.filter.live.start, opts("Live Filter: Start")) - vim.keymap.set("n", "g?", api.tree.toggle_help, opts("Help")) - vim.keymap.set("n", "gy", api.fs.copy.absolute_path, opts("Copy Absolute Path")) - vim.keymap.set("n", "ge", api.fs.copy.basename, opts("Copy Basename")) - vim.keymap.set("n", "H", api.filter.dotfiles.toggle, opts("Toggle Filter: Dotfiles")) - vim.keymap.set("n", "I", api.filter.git.ignored.toggle, opts("Toggle Filter: Git Ignored")) - vim.keymap.set("n", "J", api.node.navigate.sibling.last, opts("Last Sibling")) - vim.keymap.set("n", "K", api.node.navigate.sibling.first, opts("First Sibling")) - vim.keymap.set("n", "L", api.node.open.toggle_group_empty, opts("Toggle Group Empty")) - vim.keymap.set("n", "M", api.filter.no_bookmark.toggle, opts("Toggle Filter: No Bookmark")) - vim.keymap.set("n", "m", api.marks.toggle, opts("Toggle Bookmark")) - vim.keymap.set("n", "o", api.node.open.edit, opts("Open")) - vim.keymap.set("n", "O", api.node.open.no_window_picker, opts("Open: No Window Picker")) - vim.keymap.set("n", "p", api.fs.paste, opts("Paste")) - vim.keymap.set("n", "P", api.node.navigate.parent, opts("Parent Directory")) - vim.keymap.set("n", "q", api.tree.close, opts("Close")) - vim.keymap.set("n", "r", api.fs.rename, opts("Rename")) - vim.keymap.set("n", "R", api.tree.reload, opts("Refresh")) - vim.keymap.set("n", "s", api.node.run.system, opts("Run System")) - vim.keymap.set("n", "S", api.tree.search_node, opts("Search")) - vim.keymap.set("n", "u", api.fs.rename_full, opts("Rename: Full Path")) - vim.keymap.set("n", "U", api.filter.custom.toggle, opts("Toggle Filter: Custom")) - vim.keymap.set("n", "W", api.tree.collapse_all, opts("Collapse All")) - vim.keymap.set("n", "x", api.fs.cut, opts("Cut")) - vim.keymap.set("n", "y", api.fs.copy.filename, opts("Copy Name")) - vim.keymap.set("n", "Y", api.fs.copy.relative_path, opts("Copy Relative Path")) - vim.keymap.set("n", "<2-LeftMouse>", api.node.open.edit, opts("Open")) - vim.keymap.set("n", "<2-RightMouse>", api.tree.change_root_to_node, opts("CD")) + vim.keymap.set("n", "", api.tree.change_root_to_node, opts("CD")) + vim.keymap.set("n", "", api.node.open.replace_tree_buffer, opts("Open: In Place")) + vim.keymap.set("n", "", api.node.show_info_popup, opts("Info")) + vim.keymap.set("n", "", api.fs.rename_sub, opts("Rename: Omit Filename")) + vim.keymap.set("n", "", api.node.open.tab, opts("Open: New Tab")) + vim.keymap.set("n", "", api.node.open.vertical, opts("Open: Vertical Split")) + vim.keymap.set("n", "", api.node.open.horizontal, opts("Open: Horizontal Split")) + vim.keymap.set("n", "", api.node.navigate.parent_close, opts("Close Directory")) + vim.keymap.set("n", "", api.node.open.edit, opts("Open")) + vim.keymap.set({ "n", "x" }, "", api.fs.remove, opts("Delete")) + vim.keymap.set("n", "", api.node.open.preview, opts("Open Preview")) + vim.keymap.set("n", ">", api.node.navigate.sibling.next, opts("Next Sibling")) + vim.keymap.set("n", "<", api.node.navigate.sibling.prev, opts("Previous Sibling")) + vim.keymap.set("n", ".", api.node.run.cmd, opts("Run Command")) + vim.keymap.set("n", "-", api.tree.change_root_to_parent, opts("Up")) + vim.keymap.set("n", "a", api.fs.create, opts("Create File Or Directory")) + vim.keymap.set("n", "bd", api.marks.bulk.delete, opts("Delete Bookmarked")) + vim.keymap.set("n", "bt", api.marks.bulk.trash, opts("Trash Bookmarked")) + vim.keymap.set("n", "bmv", api.marks.bulk.move, opts("Move Bookmarked")) + vim.keymap.set("n", "B", api.filter.no_buffer.toggle, opts("Toggle Filter: No Buffer")) + vim.keymap.set({ "n", "x" }, "c", api.fs.copy.node, opts("Copy")) + vim.keymap.set("n", "C", api.filter.git.clean.toggle, opts("Toggle Filter: Git Clean")) + vim.keymap.set("n", "[c", api.node.navigate.git.prev, opts("Prev Git")) + vim.keymap.set("n", "]c", api.node.navigate.git.next, opts("Next Git")) + vim.keymap.set({ "n", "x" }, "d", api.fs.remove, opts("Delete")) + vim.keymap.set({ "n", "x" }, "D", api.fs.trash, opts("Trash")) + vim.keymap.set("n", "E", api.tree.expand_all, opts("Expand All")) + vim.keymap.set("n", "e", api.fs.rename_basename, opts("Rename: Basename")) + vim.keymap.set("n", "]e", api.node.navigate.diagnostics.next, opts("Next Diagnostic")) + vim.keymap.set("n", "[e", api.node.navigate.diagnostics.prev, opts("Prev Diagnostic")) + vim.keymap.set("n", "F", api.filter.live.clear, opts("Live Filter: Clear")) + vim.keymap.set("n", "f", api.filter.live.start, opts("Live Filter: Start")) + vim.keymap.set("n", "g?", api.tree.toggle_help, opts("Help")) + vim.keymap.set("n", "gy", api.fs.copy.absolute_path, opts("Copy Absolute Path")) + vim.keymap.set("n", "ge", api.fs.copy.basename, opts("Copy Basename")) + vim.keymap.set("n", "H", api.filter.dotfiles.toggle, opts("Toggle Filter: Dotfiles")) + vim.keymap.set("n", "I", api.filter.git.ignored.toggle, opts("Toggle Filter: Git Ignored")) + vim.keymap.set("n", "J", api.node.navigate.sibling.last, opts("Last Sibling")) + vim.keymap.set("n", "K", api.node.navigate.sibling.first, opts("First Sibling")) + vim.keymap.set("n", "L", api.node.open.toggle_group_empty, opts("Toggle Group Empty")) + vim.keymap.set("n", "M", api.filter.no_bookmark.toggle, opts("Toggle Filter: No Bookmark")) + vim.keymap.set({ "n", "x" }, "m", api.marks.toggle, opts("Toggle Bookmark")) + vim.keymap.set("n", "o", api.node.open.edit, opts("Open")) + vim.keymap.set("n", "O", api.node.open.no_window_picker, opts("Open: No Window Picker")) + vim.keymap.set("n", "p", api.fs.paste, opts("Paste")) + vim.keymap.set("n", "P", api.node.navigate.parent, opts("Parent Directory")) + vim.keymap.set("n", "q", api.tree.close, opts("Close")) + vim.keymap.set("n", "r", api.fs.rename, opts("Rename")) + vim.keymap.set("n", "R", api.tree.reload, opts("Refresh")) + vim.keymap.set("n", "s", api.node.run.system, opts("Run System")) + vim.keymap.set("n", "S", api.tree.search_node, opts("Search")) + vim.keymap.set("n", "u", api.fs.rename_full, opts("Rename: Full Path")) + vim.keymap.set("n", "U", api.filter.custom.toggle, opts("Toggle Filter: Custom")) + vim.keymap.set("n", "W", api.tree.collapse_all, opts("Collapse All")) + vim.keymap.set({ "n", "x" }, "x", api.fs.cut, opts("Cut")) + vim.keymap.set("n", "y", api.fs.copy.filename, opts("Copy Name")) + vim.keymap.set("n", "Y", api.fs.copy.relative_path, opts("Copy Relative Path")) + vim.keymap.set("n", "<2-LeftMouse>", api.node.open.edit, opts("Open")) + vim.keymap.set("n", "<2-RightMouse>", api.tree.change_root_to_node, opts("CD")) -- END_ON_ATTACH_DEFAULT < Alternatively, you may apply these default mappings from your @@ -2313,6 +2313,10 @@ Example invocation of the `reload` function in the `tree` module: >lua Generally, functions accepting a |nvim_tree.api.Node| as their first argument will use the node under the cursor when that argument is not present or nil. +Some functions are mode-dependent: when invoked in visual mode they will +operate on all nodes in the visual selection instead of a single node. See +|nvim-tree-mappings-default| for which mappings support visual mode. + e.g. the following are functionally identical: >lua api.node.open.edit(nil, { focus = true }) @@ -2477,7 +2481,8 @@ copy.filename({node}) *nvim_tree.api.fs.copy.filename()* • {node} (`nvim_tree.api.Node?`) copy.node({node}) *nvim_tree.api.fs.copy.node()* - Copy to the nvim-tree clipboard. + Copy to the nvim-tree clipboard. In visual mode, copies all nodes in the + visual selection. Parameters: ~ • {node} (`nvim_tree.api.Node?`) @@ -2501,7 +2506,8 @@ create({node}) *nvim_tree.api.fs.create()* • {node} (`nvim_tree.api.Node?`) cut({node}) *nvim_tree.api.fs.cut()* - Cut to the nvim-tree clipboard. + Cut to the nvim-tree clipboard. In visual mode, cuts all nodes in the + visual selection. Parameters: ~ • {node} (`nvim_tree.api.Node?`) @@ -2518,7 +2524,8 @@ print_clipboard() *nvim_tree.api.fs.print_clipboard()* Print the contents of the nvim-tree clipboard. remove({node}) *nvim_tree.api.fs.remove()* - Delete from the file system. + Delete from the file system. In visual mode, deletes all nodes in the + visual selection with a single prompt. Parameters: ~ • {node} (`nvim_tree.api.Node?`) @@ -2554,7 +2561,8 @@ rename_sub({node}) *nvim_tree.api.fs.rename_sub()* • {node} (`nvim_tree.api.Node?`) trash({node}) *nvim_tree.api.fs.trash()* - Trash as per |nvim_tree.config.trash| + Trash as per |nvim_tree.config.trash|. In visual mode, trashes all nodes + in the visual selection with a single prompt. Parameters: ~ • {node} (`nvim_tree.api.Node?`) @@ -2631,7 +2639,7 @@ navigate.select() *nvim_tree.api.marks.navigate.select()* will be focused, a file will be opened. toggle({node}) *nvim_tree.api.marks.toggle()* - Toggle mark. + Toggle mark. In visual mode, toggles all nodes in the visual selection. Parameters: ~ • {node} (`nvim_tree.api.Node?`) file or directory diff --git a/lua/nvim-tree/_meta/api/fs.lua b/lua/nvim-tree/_meta/api/fs.lua index 2ef16644823..dfab8610434 100644 --- a/lua/nvim-tree/_meta/api/fs.lua +++ b/lua/nvim-tree/_meta/api/fs.lua @@ -28,6 +28,7 @@ function nvim_tree.api.fs.copy.filename(node) end --- ---Copy to the nvim-tree clipboard. +---In visual mode, copies all nodes in the visual selection. --- ---@param node? nvim_tree.api.Node function nvim_tree.api.fs.copy.node(node) end @@ -52,6 +53,7 @@ function nvim_tree.api.fs.create(node) end --- ---Cut to the nvim-tree clipboard. +---In visual mode, cuts all nodes in the visual selection. --- ---@param node? nvim_tree.api.Node function nvim_tree.api.fs.cut(node) end @@ -71,6 +73,7 @@ function nvim_tree.api.fs.print_clipboard() end --- ---Delete from the file system. +---In visual mode, deletes all nodes in the visual selection with a single prompt. --- ---@param node? nvim_tree.api.Node function nvim_tree.api.fs.remove(node) end @@ -106,7 +109,8 @@ function nvim_tree.api.fs.rename_node(node) end function nvim_tree.api.fs.rename_sub(node) end --- ----Trash as per |nvim_tree.config.trash| +---Trash as per |nvim_tree.config.trash|. +---In visual mode, trashes all nodes in the visual selection with a single prompt. --- ---@param node? nvim_tree.api.Node function nvim_tree.api.fs.trash(node) end diff --git a/lua/nvim-tree/_meta/api/marks.lua b/lua/nvim-tree/_meta/api/marks.lua index 332ff304c99..07244db7789 100644 --- a/lua/nvim-tree/_meta/api/marks.lua +++ b/lua/nvim-tree/_meta/api/marks.lua @@ -14,7 +14,7 @@ function nvim_tree.api.marks.get() end function nvim_tree.api.marks.list() end --- ----Toggle mark. +---Toggle mark. In visual mode, toggles all nodes in the visual selection. --- ---@param node? nvim_tree.api.Node file or directory function nvim_tree.api.marks.toggle(node) end diff --git a/lua/nvim-tree/actions/fs/clipboard.lua b/lua/nvim-tree/actions/fs/clipboard.lua index 494939d645b..a3f6f1502a3 100644 --- a/lua/nvim-tree/actions/fs/clipboard.lua +++ b/lua/nvim-tree/actions/fs/clipboard.lua @@ -9,6 +9,7 @@ local find_file = require("nvim-tree.actions.finders.find-file").fn local Class = require("nvim-tree.classic") local DirectoryNode = require("nvim-tree.node.directory") +local Node = require("nvim-tree.node") ---@alias ClipboardAction "copy" | "cut" ---@alias ClipboardData table @@ -108,67 +109,18 @@ local function do_copy(source, destination) return true end +---Paste a single item with no conflict handling. ---@param source string ---@param dest string ---@param action ClipboardAction ---@param action_fn ClipboardActionFn ----@return boolean|nil -- success ----@return string|nil -- error message -local function do_single_paste(source, dest, action, action_fn) - local notify_source = notify.render_path(source) - - log.line("copy_paste", "do_single_paste '%s' -> '%s'", source, dest) - - local dest_stats, err, err_name = vim.loop.fs_stat(dest) - if not dest_stats and err_name ~= "ENOENT" then - notify.error("Could not " .. action .. " " .. notify_source .. " - " .. (err or "???")) - return false, err - end - - local function on_process() - local success, error = action_fn(source, dest) - if not success then - notify.error("Could not " .. action .. " " .. notify_source .. " - " .. (error or "???")) - return false, error - end - - find_file(utils.path_remove_trailing(dest)) - end - - if dest_stats then - local input_opts = { - prompt = "Rename to ", - default = dest, - completion = "dir", - } - - if source == dest then - vim.ui.input(input_opts, function(new_dest) - utils.clear_prompt() - if new_dest then - do_single_paste(source, new_dest, action, action_fn) - end - end) - else - local prompt_select = "Overwrite " .. dest .. " ?" - local prompt_input = prompt_select .. " R(ename)/y/n: " - lib.prompt(prompt_input, prompt_select, { "", "y", "n" }, { "Rename", "Yes", "No" }, "nvimtree_overwrite_rename", function(item_short) - utils.clear_prompt() - if item_short == "y" then - on_process() - elseif item_short == "" or item_short == "r" then - vim.ui.input(input_opts, function(new_dest) - utils.clear_prompt() - if new_dest then - do_single_paste(source, new_dest, action, action_fn) - end - end) - end - end) - end - else - on_process() +local function do_paste_one(source, dest, action, action_fn) + log.line("copy_paste", "do_paste_one '%s' -> '%s'", source, dest) + local success, err = action_fn(source, dest) + if not success then + notify.error("Could not " .. action .. " " .. notify.render_path(source) .. " - " .. (err or "???")) end + find_file(utils.path_remove_trailing(dest)) end ---@param node Node @@ -196,23 +148,119 @@ function Clipboard:clear_clipboard() self.explorer.renderer:draw() end ----Copy one node ----@param node Node -function Clipboard:copy(node) - utils.array_remove(self.data.cut, node) - toggle(node, self.data.copy) +---Bulk add/remove nodes to/from a clipboard list. +---@private +---@param nodes Node[] filtered nodes to operate on +---@param from Node[] list to remove from (the opposite clipboard) +---@param to Node[] list to add to +---@param verb string notification verb ("added to" or "cut to") +function Clipboard:bulk_clipboard(nodes, from, to, verb) + local added = 0 + local removed = 0 + for _, node in ipairs(nodes) do + if node.name ~= ".." then + utils.array_remove(from, node) + if utils.array_remove(to, node) then + removed = removed + 1 + else + table.insert(to, node) + added = added + 1 + end + end + end + if added > 0 then + notify.info(string.format("%d nodes %s clipboard.", added, verb)) + elseif removed > 0 then + notify.info(string.format("%d nodes removed from clipboard.", removed)) + end self.explorer.renderer:draw() end ----Cut one node ----@param node Node -function Clipboard:cut(node) - utils.array_remove(self.data.copy, node) - toggle(node, self.data.cut) - self.explorer.renderer:draw() +---Copy one or more nodes +---@param node_or_nodes Node|Node[] +function Clipboard:copy(node_or_nodes) + if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then + utils.array_remove(self.data.cut, node_or_nodes) + toggle(node_or_nodes, self.data.copy) + self.explorer.renderer:draw() + else + self:bulk_clipboard(utils.filter_descendant_nodes(node_or_nodes), self.data.cut, self.data.copy, "added to") + end end ----Paste cut or cop +---Cut one or more nodes +---@param node_or_nodes Node|Node[] +function Clipboard:cut(node_or_nodes) + if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then + utils.array_remove(self.data.copy, node_or_nodes) + toggle(node_or_nodes, self.data.cut) + self.explorer.renderer:draw() + else + self:bulk_clipboard(utils.filter_descendant_nodes(node_or_nodes), self.data.copy, self.data.cut, "cut to") + end +end + +---Clear clipboard for action and reload to reflect filesystem changes from paste. +---@private +---@param action ClipboardAction +function Clipboard:finish_paste(action) + self.data[action] = {} + self.explorer:reload_explorer() +end + +---Resolve conflicting paste items with a single batch prompt. +---@private +---@param conflict {node: Node, dest: string}[] +---@param destination string +---@param action ClipboardAction +---@param action_fn ClipboardActionFn +function Clipboard:resolve_conflicts(conflict, destination, action, action_fn) + local prompt_select = #conflict .. " file(s) already exist" + local prompt_input = prompt_select .. ". R(ename suffix)/y/n: " + + lib.prompt(prompt_input, prompt_select, + { "", "y", "n" }, + { "Rename (suffix)", "Overwrite all", "Skip all" }, + "nvimtree_paste_conflict", + function(item_short) + utils.clear_prompt() + if item_short == "y" then + for _, item in ipairs(conflict) do + do_paste_one(item.node.absolute_path, item.dest, action, action_fn) + end + self:finish_paste(action) + elseif item_short == "" or item_short == "r" then + vim.ui.input({ prompt = "Suffix: " }, function(suffix) + utils.clear_prompt() + if not suffix or suffix == "" then + return + end + local still_conflict = {} + for _, item in ipairs(conflict) do + local basename = vim.fn.fnamemodify(item.node.name, ":r") + local extension = vim.fn.fnamemodify(item.node.name, ":e") + local new_name = extension ~= "" and (basename .. suffix .. "." .. extension) or (item.node.name .. suffix) + local new_dest = utils.path_join({ destination, new_name }) + local stats = vim.loop.fs_stat(new_dest) + if stats then + table.insert(still_conflict, { node = item.node, dest = new_dest }) + else + do_paste_one(item.node.absolute_path, new_dest, action, action_fn) + end + end + if #still_conflict > 0 then + self:resolve_conflicts(still_conflict, destination, action, action_fn) + else + self:finish_paste(action) + end + end) + else + self:finish_paste(action) + end + end) +end + +---Paste cut or copy with batch conflict resolution. ---@private ---@param node Node ---@param action ClipboardAction @@ -243,14 +291,29 @@ function Clipboard:do_paste(node, action, action_fn) destination = vim.fn.fnamemodify(destination, ":p:h") end + -- Partition into conflict / no-conflict + local no_conflict = {} + local conflict = {} for _, _node in ipairs(clip) do local dest = utils.path_join({ destination, _node.name }) - do_single_paste(_node.absolute_path, dest, action, action_fn) + local dest_stats = vim.loop.fs_stat(dest) + if dest_stats then + table.insert(conflict, { node = _node, dest = dest }) + else + table.insert(no_conflict, { node = _node, dest = dest }) + end end - self.data[action] = {} - if not self.explorer.opts.filesystem_watchers.enable then - self.explorer:reload_explorer() + -- Paste non-conflicting items immediately + for _, item in ipairs(no_conflict) do + do_paste_one(item.node.absolute_path, item.dest, action, action_fn) + end + + -- Resolve conflicts in batch + if #conflict > 0 then + self:resolve_conflicts(conflict, destination, action, action_fn) + else + self:finish_paste(action) end end diff --git a/lua/nvim-tree/actions/fs/remove-file.lua b/lua/nvim-tree/actions/fs/remove-file.lua index 5810e3d1e5d..bf2546e5c24 100644 --- a/lua/nvim-tree/actions/fs/remove-file.lua +++ b/lua/nvim-tree/actions/fs/remove-file.lua @@ -7,6 +7,8 @@ local notify = require("nvim-tree.notify") local DirectoryLinkNode = require("nvim-tree.node.directory-link") local DirectoryNode = require("nvim-tree.node.directory") +local Node = require("nvim-tree.node") +local RootNode = require("nvim-tree.node.root") local M = { config = {}, @@ -126,9 +128,10 @@ function M.remove(node) notify.info(notify_node .. " was properly removed.") end +---Remove a single node with confirmation. ---@param node Node -function M.fn(node) - if node.name == ".." then +local function remove_one(node) + if node:is(RootNode) then return end @@ -142,17 +145,7 @@ function M.fn(node) if M.config.ui.confirm.remove then local prompt_select = "Remove " .. node.name .. "?" - local prompt_input, items_short, items_long - - if M.config.ui.confirm.default_yes then - prompt_input = prompt_select .. " Y/n: " - items_short = { "", "n" } - items_long = { "Yes", "No" } - else - prompt_input = prompt_select .. " y/N: " - items_short = { "", "y" } - items_long = { "No", "Yes" } - end + local prompt_input, items_short, items_long = utils.confirm_prompt(prompt_select, M.config.ui.confirm.default_yes) lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_remove", function(item_short) utils.clear_prompt() @@ -165,6 +158,51 @@ function M.fn(node) end end +---Remove multiple nodes with a single confirmation prompt. +---@param nodes Node[] +local function remove_many(nodes) + if #nodes == 0 then + return + end + + nodes = utils.filter_descendant_nodes(nodes) + + local function execute() + for _, node in ipairs(nodes) do + if not node:is(RootNode) then + M.remove(node) + end + end + local explorer = core.get_explorer() + if not M.config.filesystem_watchers.enable and explorer then + explorer:reload_explorer() + end + end + + if M.config.ui.confirm.remove then + local prompt_select = string.format("Remove %d selected?", #nodes) + local prompt_input, items_short, items_long = utils.confirm_prompt(prompt_select, M.config.ui.confirm.default_yes) + + lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_remove", function(item_short) + utils.clear_prompt() + if item_short == "y" or item_short == (M.config.ui.confirm.default_yes and "") then + execute() + end + end) + else + execute() + end +end + +---@param node_or_nodes Node|Node[] +function M.fn(node_or_nodes) + if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then + remove_one(node_or_nodes) + else + remove_many(node_or_nodes) + end +end + function M.setup(opts) M.config.ui = opts.ui M.config.actions = opts.actions diff --git a/lua/nvim-tree/actions/fs/trash.lua b/lua/nvim-tree/actions/fs/trash.lua index c64dba0b72b..ba038e8cf1c 100644 --- a/lua/nvim-tree/actions/fs/trash.lua +++ b/lua/nvim-tree/actions/fs/trash.lua @@ -1,17 +1,18 @@ local core = require("nvim-tree.core") local lib = require("nvim-tree.lib") local notify = require("nvim-tree.notify") +local utils = require("nvim-tree.utils") +local events = require("nvim-tree.events") local DirectoryLinkNode = require("nvim-tree.node.directory-link") local DirectoryNode = require("nvim-tree.node.directory") +local Node = require("nvim-tree.node") +local RootNode = require("nvim-tree.node.root") local M = { config = {}, } -local utils = require("nvim-tree.utils") -local events = require("nvim-tree.events") - ---@param absolute_path string local function clear_buffer(absolute_path) local bufs = vim.fn.getbufinfo({ bufloaded = 1, buflisted = 1 }) @@ -84,9 +85,10 @@ function M.remove(node) end end +---Trash a single node with confirmation. ---@param node Node -function M.fn(node) - if node.name == ".." then +local function trash_one(node) + if node:is(RootNode) then return end @@ -96,17 +98,7 @@ function M.fn(node) if M.config.ui.confirm.trash then local prompt_select = "Trash " .. node.name .. "?" - local prompt_input, items_short, items_long - - if M.config.ui.confirm.default_yes then - prompt_input = prompt_select .. " Y/n: " - items_short = { "", "n" } - items_long = { "Yes", "No" } - else - prompt_input = prompt_select .. " y/N: " - items_short = { "", "y" } - items_long = { "No", "Yes" } - end + local prompt_input, items_short, items_long = utils.confirm_prompt(prompt_select, M.config.ui.confirm.default_yes) lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_trash", function(item_short) utils.clear_prompt() @@ -119,6 +111,47 @@ function M.fn(node) end end +---Trash multiple nodes with a single confirmation prompt. +---@param nodes Node[] +local function trash_many(nodes) + if #nodes == 0 then + return + end + + nodes = utils.filter_descendant_nodes(nodes) + + local function execute() + for _, node in ipairs(nodes) do + if not node:is(RootNode) then + M.remove(node) + end + end + end + + if M.config.ui.confirm.trash then + local prompt_select = string.format("Trash %d selected?", #nodes) + local prompt_input, items_short, items_long = utils.confirm_prompt(prompt_select, M.config.ui.confirm.default_yes) + + lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_trash", function(item_short) + utils.clear_prompt() + if item_short == "y" or item_short == (M.config.ui.confirm.default_yes and "") then + execute() + end + end) + else + execute() + end +end + +---@param node_or_nodes Node|Node[] +function M.fn(node_or_nodes) + if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then + trash_one(node_or_nodes) + else + trash_many(node_or_nodes) + end +end + function M.setup(opts) M.config.ui = opts.ui M.config.trash = opts.trash diff --git a/lua/nvim-tree/api.lua b/lua/nvim-tree/api.lua index 0e9d6d45c5c..e29bc3f6212 100644 --- a/lua/nvim-tree/api.lua +++ b/lua/nvim-tree/api.lua @@ -24,7 +24,9 @@ ---local api = require("nvim-tree.api") ---api.tree.reload() ---``` ----Generally, functions accepting a [nvim_tree.api.Node] as their first argument will use the node under the cursor when that argument is not present or nil. e.g. the following are functionally identical: +---Generally, functions accepting a [nvim_tree.api.Node] as their first argument will use the node under the cursor when that argument is not present or nil. Some functions are mode-dependent: when invoked in visual mode they will operate on all nodes in the visual selection instead of a single node. See |nvim-tree-mappings-default| for which mappings support visual mode. +--- +---e.g. the following are functionally identical: ---```lua --- ---api.node.open.edit(nil, { focus = true }) diff --git a/lua/nvim-tree/api/impl/post.lua b/lua/nvim-tree/api/impl/post.lua index b3937467904..ca177bdc516 100644 --- a/lua/nvim-tree/api/impl/post.lua +++ b/lua/nvim-tree/api/impl/post.lua @@ -86,6 +86,25 @@ local function wrap_explorer_member(explorer_member, member_method) end end +---Wrap a function to be mode-dependent: in visual mode, pass all nodes in the +---visual range; in normal mode, pass a single node. The implementation decides +---how to handle each case. +---@param fn fun(node_or_nodes: Node|Node[], ...): any +---@return fun(node: Node?, ...): any +local function wrap_node_or_visual(fn) + local wrapped = wrap_node(fn) + return function(node, ...) + if utils.is_visual_mode() then + local nodes = utils.get_visual_nodes() + if nodes then + fn(nodes, ...) + end + else + return wrapped(node, ...) + end + end +end + ---@class NodeEditOpts ---@field quit_on_open boolean|nil default false ---@field focus boolean|nil default true @@ -173,18 +192,18 @@ function M.hydrate(api) api.tree.winid = view.winid api.fs.create = wrap_node_or_nil(actions.fs.create_file.fn) - api.fs.remove = wrap_node(actions.fs.remove_file.fn) - api.fs.trash = wrap_node(actions.fs.trash.fn) + api.fs.remove = wrap_node_or_visual(actions.fs.remove_file.fn) + api.fs.trash = wrap_node_or_visual(actions.fs.trash.fn) api.fs.rename_node = wrap_node(actions.fs.rename_file.fn(":t")) api.fs.rename = wrap_node(actions.fs.rename_file.fn(":t")) api.fs.rename_sub = wrap_node(actions.fs.rename_file.fn(":p:h")) api.fs.rename_basename = wrap_node(actions.fs.rename_file.fn(":t:r")) api.fs.rename_full = wrap_node(actions.fs.rename_file.fn(":p")) - api.fs.cut = wrap_node(wrap_explorer_member("clipboard", "cut")) + api.fs.cut = wrap_node_or_visual(wrap_explorer_member("clipboard", "cut")) api.fs.paste = wrap_node(wrap_explorer_member("clipboard", "paste")) api.fs.clear_clipboard = wrap_explorer_member("clipboard", "clear_clipboard") api.fs.print_clipboard = wrap_explorer_member("clipboard", "print_clipboard") - api.fs.copy.node = wrap_node(wrap_explorer_member("clipboard", "copy")) + api.fs.copy.node = wrap_node_or_visual(wrap_explorer_member("clipboard", "copy")) api.fs.copy.absolute_path = wrap_node(wrap_explorer_member("clipboard", "copy_absolute_path")) api.fs.copy.filename = wrap_node(wrap_explorer_member("clipboard", "copy_filename")) api.fs.copy.basename = wrap_node(wrap_explorer_member("clipboard", "copy_basename")) @@ -247,7 +266,7 @@ function M.hydrate(api) api.marks.get = wrap_node(wrap_explorer_member("marks", "get")) api.marks.list = wrap_explorer_member("marks", "list") - api.marks.toggle = wrap_node(wrap_explorer_member("marks", "toggle")) + api.marks.toggle = wrap_node_or_visual(wrap_explorer_member("marks", "toggle")) api.marks.clear = wrap_explorer_member("marks", "clear") api.marks.bulk.delete = wrap_explorer_member("marks", "bulk_delete") api.marks.bulk.trash = wrap_explorer_member("marks", "bulk_trash") diff --git a/lua/nvim-tree/explorer/init.lua b/lua/nvim-tree/explorer/init.lua index b82056658e8..fcfce4821ea 100644 --- a/lua/nvim-tree/explorer/init.lua +++ b/lua/nvim-tree/explorer/init.lua @@ -642,6 +642,22 @@ function Explorer:find_node(fn) return node, i end +---Get all nodes in a line range (inclusive), for visual selection operations. +---@param start_line integer +---@param end_line integer +---@return Node[] +function Explorer:get_nodes_in_range(start_line, end_line) + local nodes_by_line = self:get_nodes_by_line(core.get_nodes_starting_line()) + local nodes = {} + for line = start_line, end_line do + local node = nodes_by_line[line] + if node and node.absolute_path then + table.insert(nodes, node) + end + end + return nodes +end + --- Return visible nodes indexed by line ---@param line_start number ---@return table diff --git a/lua/nvim-tree/help.lua b/lua/nvim-tree/help.lua index f5a971e3546..7757b706bbe 100644 --- a/lua/nvim-tree/help.lua +++ b/lua/nvim-tree/help.lua @@ -92,10 +92,21 @@ local function compute(map) local head_rhs1 = "exit: q" local head_rhs2 = string.format("sort by %s: s", M.config.sort_by == "key" and "description" or "keymap") - -- formatted lhs and desc from active keymap - local mappings = vim.tbl_map(function(m) - return { lhs = tidy_lhs(m.lhs), desc = tidy_desc(m.desc) } - end, map) + -- merge modes for duplicate lhs+desc entries e.g. "n" + "x" -> "nx" + local merged = {} + local mappings = {} + for _, m in ipairs(map) do + local lhs = tidy_lhs(m.lhs) + local desc = tidy_desc(m.desc) + local key = lhs .. "\0" .. desc + if merged[key] then + merged[key].mode = merged[key].mode .. m.mode + else + local entry = { lhs = lhs, desc = desc, mode = m.mode or "n" } + merged[key] = entry + table.insert(mappings, entry) + end + end -- sorter function for mappings local sort_fn @@ -113,21 +124,33 @@ local function compute(map) table.sort(mappings, sort_fn) - -- longest lhs and description + -- sort mode characters for deterministic display e.g. "nx" not "xn" + for _, entry in ipairs(mappings) do + local chars = {} + for c in entry.mode:gmatch(".") do + table.insert(chars, c) + end + table.sort(chars) + entry.mode = table.concat(chars) + end + + -- longest lhs, mode and description local max_lhs = 0 + local max_mode = 0 local max_desc = 0 - for _, l in pairs(mappings) do + for _, l in ipairs(mappings) do max_lhs = math.max(#l.lhs, max_lhs) + max_mode = math.max(#l.mode, max_mode) max_desc = math.max(#l.desc, max_desc) end -- increase desc if lines are shorter than the header - max_desc = math.max(max_desc, #head_lhs + #head_rhs1 - max_lhs) + max_desc = math.max(max_desc, #head_lhs + #head_rhs1 - max_lhs - max_mode) -- header text, not padded local lines = { - head_lhs .. string.rep(" ", max_desc + max_lhs - #head_lhs - #head_rhs1 + 2) .. head_rhs1, - string.rep(" ", max_desc + max_lhs - #head_rhs2 + 2) .. head_rhs2, + head_lhs .. string.rep(" ", max_lhs + max_mode + max_desc - #head_lhs - #head_rhs1 + 3) .. head_rhs1, + string.rep(" ", max_lhs + max_mode + max_desc - #head_rhs2 + 3) .. head_rhs2, } local width = #lines[1] @@ -139,10 +162,10 @@ local function compute(map) } -- mappings, left padded 1 - local fmt = string.format(" %%-%ds %%-%ds", max_lhs, max_desc) + local fmt = string.format(" %%-%ds %%-%ds %%-%ds", max_lhs, max_mode, max_desc) for i, l in ipairs(mappings) do -- format in left aligned columns - local line = string.format(fmt, l.lhs, l.desc) + local line = string.format(fmt, l.lhs, l.mode, l.desc) table.insert(lines, line) width = math.max(#line, width) diff --git a/lua/nvim-tree/keymap.lua b/lua/nvim-tree/keymap.lua index 64326b86ea3..622d0092ee6 100644 --- a/lua/nvim-tree/keymap.lua +++ b/lua/nvim-tree/keymap.lua @@ -44,65 +44,65 @@ function M.on_attach_default(bufnr) end -- BEGIN_ON_ATTACH_DEFAULT - vim.keymap.set("n", "", api.tree.change_root_to_node, opts("CD")) - vim.keymap.set("n", "", api.node.open.replace_tree_buffer, opts("Open: In Place")) - vim.keymap.set("n", "", api.node.show_info_popup, opts("Info")) - vim.keymap.set("n", "", api.fs.rename_sub, opts("Rename: Omit Filename")) - vim.keymap.set("n", "", api.node.open.tab, opts("Open: New Tab")) - vim.keymap.set("n", "", api.node.open.vertical, opts("Open: Vertical Split")) - vim.keymap.set("n", "", api.node.open.horizontal, opts("Open: Horizontal Split")) - vim.keymap.set("n", "", api.node.navigate.parent_close, opts("Close Directory")) - vim.keymap.set("n", "", api.node.open.edit, opts("Open")) - vim.keymap.set("n", "", api.fs.remove, opts("Delete")) - vim.keymap.set("n", "", api.node.open.preview, opts("Open Preview")) - vim.keymap.set("n", ">", api.node.navigate.sibling.next, opts("Next Sibling")) - vim.keymap.set("n", "<", api.node.navigate.sibling.prev, opts("Previous Sibling")) - vim.keymap.set("n", ".", api.node.run.cmd, opts("Run Command")) - vim.keymap.set("n", "-", api.tree.change_root_to_parent, opts("Up")) - vim.keymap.set("n", "a", api.fs.create, opts("Create File Or Directory")) - vim.keymap.set("n", "bd", api.marks.bulk.delete, opts("Delete Bookmarked")) - vim.keymap.set("n", "bt", api.marks.bulk.trash, opts("Trash Bookmarked")) - vim.keymap.set("n", "bmv", api.marks.bulk.move, opts("Move Bookmarked")) - vim.keymap.set("n", "B", api.filter.no_buffer.toggle, opts("Toggle Filter: No Buffer")) - vim.keymap.set("n", "c", api.fs.copy.node, opts("Copy")) - vim.keymap.set("n", "C", api.filter.git.clean.toggle, opts("Toggle Filter: Git Clean")) - vim.keymap.set("n", "[c", api.node.navigate.git.prev, opts("Prev Git")) - vim.keymap.set("n", "]c", api.node.navigate.git.next, opts("Next Git")) - vim.keymap.set("n", "d", api.fs.remove, opts("Delete")) - vim.keymap.set("n", "D", api.fs.trash, opts("Trash")) - vim.keymap.set("n", "E", api.tree.expand_all, opts("Expand All")) - vim.keymap.set("n", "e", api.fs.rename_basename, opts("Rename: Basename")) - vim.keymap.set("n", "]e", api.node.navigate.diagnostics.next, opts("Next Diagnostic")) - vim.keymap.set("n", "[e", api.node.navigate.diagnostics.prev, opts("Prev Diagnostic")) - vim.keymap.set("n", "F", api.filter.live.clear, opts("Live Filter: Clear")) - vim.keymap.set("n", "f", api.filter.live.start, opts("Live Filter: Start")) - vim.keymap.set("n", "g?", api.tree.toggle_help, opts("Help")) - vim.keymap.set("n", "gy", api.fs.copy.absolute_path, opts("Copy Absolute Path")) - vim.keymap.set("n", "ge", api.fs.copy.basename, opts("Copy Basename")) - vim.keymap.set("n", "H", api.filter.dotfiles.toggle, opts("Toggle Filter: Dotfiles")) - vim.keymap.set("n", "I", api.filter.git.ignored.toggle, opts("Toggle Filter: Git Ignored")) - vim.keymap.set("n", "J", api.node.navigate.sibling.last, opts("Last Sibling")) - vim.keymap.set("n", "K", api.node.navigate.sibling.first, opts("First Sibling")) - vim.keymap.set("n", "L", api.node.open.toggle_group_empty, opts("Toggle Group Empty")) - vim.keymap.set("n", "M", api.filter.no_bookmark.toggle, opts("Toggle Filter: No Bookmark")) - vim.keymap.set("n", "m", api.marks.toggle, opts("Toggle Bookmark")) - vim.keymap.set("n", "o", api.node.open.edit, opts("Open")) - vim.keymap.set("n", "O", api.node.open.no_window_picker, opts("Open: No Window Picker")) - vim.keymap.set("n", "p", api.fs.paste, opts("Paste")) - vim.keymap.set("n", "P", api.node.navigate.parent, opts("Parent Directory")) - vim.keymap.set("n", "q", api.tree.close, opts("Close")) - vim.keymap.set("n", "r", api.fs.rename, opts("Rename")) - vim.keymap.set("n", "R", api.tree.reload, opts("Refresh")) - vim.keymap.set("n", "s", api.node.run.system, opts("Run System")) - vim.keymap.set("n", "S", api.tree.search_node, opts("Search")) - vim.keymap.set("n", "u", api.fs.rename_full, opts("Rename: Full Path")) - vim.keymap.set("n", "U", api.filter.custom.toggle, opts("Toggle Filter: Custom")) - vim.keymap.set("n", "W", api.tree.collapse_all, opts("Collapse All")) - vim.keymap.set("n", "x", api.fs.cut, opts("Cut")) - vim.keymap.set("n", "y", api.fs.copy.filename, opts("Copy Name")) - vim.keymap.set("n", "Y", api.fs.copy.relative_path, opts("Copy Relative Path")) - vim.keymap.set("n", "<2-LeftMouse>", api.node.open.edit, opts("Open")) - vim.keymap.set("n", "<2-RightMouse>", api.tree.change_root_to_node, opts("CD")) + vim.keymap.set("n", "", api.tree.change_root_to_node, opts("CD")) + vim.keymap.set("n", "", api.node.open.replace_tree_buffer, opts("Open: In Place")) + vim.keymap.set("n", "", api.node.show_info_popup, opts("Info")) + vim.keymap.set("n", "", api.fs.rename_sub, opts("Rename: Omit Filename")) + vim.keymap.set("n", "", api.node.open.tab, opts("Open: New Tab")) + vim.keymap.set("n", "", api.node.open.vertical, opts("Open: Vertical Split")) + vim.keymap.set("n", "", api.node.open.horizontal, opts("Open: Horizontal Split")) + vim.keymap.set("n", "", api.node.navigate.parent_close, opts("Close Directory")) + vim.keymap.set("n", "", api.node.open.edit, opts("Open")) + vim.keymap.set({ "n", "x" }, "", api.fs.remove, opts("Delete")) + vim.keymap.set("n", "", api.node.open.preview, opts("Open Preview")) + vim.keymap.set("n", ">", api.node.navigate.sibling.next, opts("Next Sibling")) + vim.keymap.set("n", "<", api.node.navigate.sibling.prev, opts("Previous Sibling")) + vim.keymap.set("n", ".", api.node.run.cmd, opts("Run Command")) + vim.keymap.set("n", "-", api.tree.change_root_to_parent, opts("Up")) + vim.keymap.set("n", "a", api.fs.create, opts("Create File Or Directory")) + vim.keymap.set("n", "bd", api.marks.bulk.delete, opts("Delete Bookmarked")) + vim.keymap.set("n", "bt", api.marks.bulk.trash, opts("Trash Bookmarked")) + vim.keymap.set("n", "bmv", api.marks.bulk.move, opts("Move Bookmarked")) + vim.keymap.set("n", "B", api.filter.no_buffer.toggle, opts("Toggle Filter: No Buffer")) + vim.keymap.set({ "n", "x" }, "c", api.fs.copy.node, opts("Copy")) + vim.keymap.set("n", "C", api.filter.git.clean.toggle, opts("Toggle Filter: Git Clean")) + vim.keymap.set("n", "[c", api.node.navigate.git.prev, opts("Prev Git")) + vim.keymap.set("n", "]c", api.node.navigate.git.next, opts("Next Git")) + vim.keymap.set({ "n", "x" }, "d", api.fs.remove, opts("Delete")) + vim.keymap.set({ "n", "x" }, "D", api.fs.trash, opts("Trash")) + vim.keymap.set("n", "E", api.tree.expand_all, opts("Expand All")) + vim.keymap.set("n", "e", api.fs.rename_basename, opts("Rename: Basename")) + vim.keymap.set("n", "]e", api.node.navigate.diagnostics.next, opts("Next Diagnostic")) + vim.keymap.set("n", "[e", api.node.navigate.diagnostics.prev, opts("Prev Diagnostic")) + vim.keymap.set("n", "F", api.filter.live.clear, opts("Live Filter: Clear")) + vim.keymap.set("n", "f", api.filter.live.start, opts("Live Filter: Start")) + vim.keymap.set("n", "g?", api.tree.toggle_help, opts("Help")) + vim.keymap.set("n", "gy", api.fs.copy.absolute_path, opts("Copy Absolute Path")) + vim.keymap.set("n", "ge", api.fs.copy.basename, opts("Copy Basename")) + vim.keymap.set("n", "H", api.filter.dotfiles.toggle, opts("Toggle Filter: Dotfiles")) + vim.keymap.set("n", "I", api.filter.git.ignored.toggle, opts("Toggle Filter: Git Ignored")) + vim.keymap.set("n", "J", api.node.navigate.sibling.last, opts("Last Sibling")) + vim.keymap.set("n", "K", api.node.navigate.sibling.first, opts("First Sibling")) + vim.keymap.set("n", "L", api.node.open.toggle_group_empty, opts("Toggle Group Empty")) + vim.keymap.set("n", "M", api.filter.no_bookmark.toggle, opts("Toggle Filter: No Bookmark")) + vim.keymap.set({ "n", "x" }, "m", api.marks.toggle, opts("Toggle Bookmark")) + vim.keymap.set("n", "o", api.node.open.edit, opts("Open")) + vim.keymap.set("n", "O", api.node.open.no_window_picker, opts("Open: No Window Picker")) + vim.keymap.set("n", "p", api.fs.paste, opts("Paste")) + vim.keymap.set("n", "P", api.node.navigate.parent, opts("Parent Directory")) + vim.keymap.set("n", "q", api.tree.close, opts("Close")) + vim.keymap.set("n", "r", api.fs.rename, opts("Rename")) + vim.keymap.set("n", "R", api.tree.reload, opts("Refresh")) + vim.keymap.set("n", "s", api.node.run.system, opts("Run System")) + vim.keymap.set("n", "S", api.tree.search_node, opts("Search")) + vim.keymap.set("n", "u", api.fs.rename_full, opts("Rename: Full Path")) + vim.keymap.set("n", "U", api.filter.custom.toggle, opts("Toggle Filter: Custom")) + vim.keymap.set("n", "W", api.tree.collapse_all, opts("Collapse All")) + vim.keymap.set({ "n", "x" }, "x", api.fs.cut, opts("Cut")) + vim.keymap.set("n", "y", api.fs.copy.filename, opts("Copy Name")) + vim.keymap.set("n", "Y", api.fs.copy.relative_path, opts("Copy Relative Path")) + vim.keymap.set("n", "<2-LeftMouse>", api.node.open.edit, opts("Open")) + vim.keymap.set("n", "<2-RightMouse>", api.tree.change_root_to_node, opts("CD")) -- END_ON_ATTACH_DEFAULT end diff --git a/lua/nvim-tree/marks/init.lua b/lua/nvim-tree/marks/init.lua index abe67448908..7918bd8c343 100644 --- a/lua/nvim-tree/marks/init.lua +++ b/lua/nvim-tree/marks/init.lua @@ -10,6 +10,7 @@ local utils = require("nvim-tree.utils") local Class = require("nvim-tree.classic") local DirectoryNode = require("nvim-tree.node.directory") +local Node = require("nvim-tree.node") local function get_save_path(opts) if type(opts.bookmarks.persist) == "string" then @@ -100,9 +101,9 @@ function Marks:clear() self.explorer.renderer:draw() end ----@public +---@private ---@param node Node -function Marks:toggle(node) +function Marks:toggle_one(node) if node.absolute_path == nil then return end @@ -112,6 +113,18 @@ function Marks:toggle(node) else self.marks[node.absolute_path] = node end +end + +---@public +---@param node_or_nodes Node|Node[] +function Marks:toggle(node_or_nodes) + if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then + self:toggle_one(node_or_nodes) + else + for _, node in ipairs(node_or_nodes) do + self:toggle_one(node) + end + end if self.explorer.opts.bookmarks.persist then local ok, err = pcall(save_bookmarks, self.marks, self.explorer.opts) diff --git a/lua/nvim-tree/utils.lua b/lua/nvim-tree/utils.lua index b98301e6701..32106d543e1 100644 --- a/lua/nvim-tree/utils.lua +++ b/lua/nvim-tree/utils.lua @@ -483,4 +483,73 @@ function M.enumerate_options(opts, was_set) return res end +---Filter out nodes that are descendants of other nodes in the list. +---When a directory is selected along with its children, only the directory needs to be operated on. +---@param nodes Node[] +---@return Node[] +function M.filter_descendant_nodes(nodes) + return vim.tbl_filter(function(node) + local parent = node.parent + while parent do + if vim.tbl_contains(nodes, parent) then + return false + end + parent = parent.parent + end + return true + end, nodes) +end + +---Build confirmation prompt strings based on default_yes config. +---@param prompt_select string +---@param default_yes boolean +---@return string prompt_input +---@return string[] items_short +---@return string[] items_long +function M.confirm_prompt(prompt_select, default_yes) + if default_yes then + return prompt_select .. " Y/n: ", { "", "n" }, { "Yes", "No" } + else + return prompt_select .. " y/N: ", { "", "y" }, { "No", "Yes" } + end +end + +---Check if the current mode is visual or select (v, V, CTRL-V, s, S, CTRL-S). +---@return boolean +function M.is_visual_mode() + local mode = vim.api.nvim_get_mode().mode + local visual_modes = { + v = true, + V = true, + ["\22"] = true, -- \22 is CTRL-V + s = true, + S = true, + ["\19"] = true, -- \19 is CTRL-S + } + return visual_modes[mode] == true or false +end + +---Exit visual mode synchronously. +function M.exit_visual_mode() + local esc = vim.api.nvim_replace_termcodes("", true, false, true) + vim.api.nvim_feedkeys(esc, "nx", false) +end + +---Get the visual selection range nodes, exiting visual mode. +---@return Node[]? +function M.get_visual_nodes() + local explorer = require("nvim-tree.core").get_explorer() + if not explorer then + return nil + end + local start_line = vim.fn.line("v") + local end_line = vim.fn.line(".") + if start_line > end_line then + start_line, end_line = end_line, start_line + end + local nodes = explorer:get_nodes_in_range(start_line, end_line) + M.exit_visual_mode() + return nodes +end + return M diff --git a/scripts/help-defaults.sh b/scripts/help-defaults.sh index 6f358705244..4d633e03238 100755 --- a/scripts/help-defaults.sh +++ b/scripts/help-defaults.sh @@ -44,11 +44,20 @@ sed -i -e "/${begin}/,/${end}/{ /${begin}/{p; r /tmp/ON_ATTACH_DEFAULT.lua }; /${end}/p; d; }" "${WIP}" # help human +# extract mode, lhs, api, desc; handle both "n" and {"n", "x"} mode forms echo > /tmp/ON_ATTACH_DEFAULT.help -sed -E "s/^ *vim.keymap.set\(\"n\", \"(.*)\",.*api(.*),.*opts\(\"(.*)\".*$/'\`\1\`' '\3' '|nvim_tree.api\2()|'/g -" /tmp/ON_ATTACH_DEFAULT.lua | while read -r line +sed -E ' + s/^ *vim\.keymap\.set\(\{([^}]+)\}, *"([^"]+)",.*api(.*),.*opts\("([^"]*)".*/\1 \2 \3 \4/ + t reformat + s/^ *vim\.keymap\.set\("(.)", *"([^"]+)",.*api(.*),.*opts\("([^"]*)".*/\1 \2 \3 \4/ + t reformat + d + :reformat + s/"//g + s/, //g +' /tmp/ON_ATTACH_DEFAULT.lua | while read -r mode lhs apipath desc do - eval "printf '%-17.17s %-26.26s %s\n' ${line}" >> /tmp/ON_ATTACH_DEFAULT.help + printf ' %-17.17s %-4.4s %-26.26s %s\n' "\`${lhs}\`" "${mode}" "${desc}" "|nvim_tree.api${apipath}()|" >> /tmp/ON_ATTACH_DEFAULT.help done echo >> /tmp/ON_ATTACH_DEFAULT.help begin="Show the mappings:"