From ba869c91bb2270993d63d4aa50c4fd2d6fbb27c6 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/api.lua | 4 +- lua/nvim-tree/api/impl/post.lua | 102 +++++++++++- lua/nvim-tree/explorer/init.lua | 16 ++ lua/nvim-tree/help.lua | 35 ++-- lua/nvim-tree/keymap.lua | 118 +++++++------- lua/nvim-tree/marks/init.lua | 156 ++++++++++++++---- scripts/help-defaults.sh | 15 +- 10 files changed, 475 insertions(+), 233 deletions(-) diff --git a/doc/nvim-tree-lua.txt b/doc/nvim-tree-lua.txt index 8a484e99ae0..bacbb796e13 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 @@ -2308,6 +2308,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 }) @@ -2450,7 +2454,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?`) @@ -2474,7 +2479,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?`) @@ -2491,7 +2497,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?`) @@ -2527,7 +2534,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?`) @@ -2604,7 +2612,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/api.lua b/lua/nvim-tree/api.lua index b90a9e1a1e9..5a756f1ee79 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 11c4b5ede14..fb68490ad61 100644 --- a/lua/nvim-tree/api/impl/post.lua +++ b/lua/nvim-tree/api/impl/post.lua @@ -85,6 +85,90 @@ local function wrap_explorer_member(explorer_member, member_method) end end +---Check if the current mode is visual (v, V, or CTRL-V). +---@return boolean +local function is_visual_mode() + local mode = vim.api.nvim_get_mode().mode + return mode == "v" or mode == "V" or mode == "\22" -- \22 is CTRL-V +end + +---Exit visual mode synchronously. +local function 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[]? +local function 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) + exit_visual_mode() + return nodes +end + +---Wrap a single-node function to be mode-dependent: in visual mode, operate +---on all nodes in the visual range; in normal mode, operate on a single node. +---@param fn fun(node: Node, ...): any +---@param filter_descendants boolean? filter out descendant nodes in visual mode +---@return fun(node: Node?, ...): any +local function wrap_node_or_visual(fn, filter_descendants) + return function(node, ...) + if is_visual_mode() then + local nodes = get_visual_nodes() + if nodes then + if filter_descendants then + local explorer = require("nvim-tree.core").get_explorer() + if explorer then + nodes = explorer.marks:filter_descendant_nodes(nodes) + end + end + for _, n in ipairs(nodes) do + fn(n, ...) + end + end + else + node = node or wrap_explorer("get_node_at_cursor")() + if node then + return fn(node, ...) + end + end + end +end + +---Wrap a destructive operation to be mode-dependent: in visual mode, collect +---nodes and call a bulk method; in normal mode, call the single-node function. +---@param normal_fn fun(node: Node): any +---@param bulk_member string explorer member name for bulk op +---@param bulk_method string method name on member for bulk op +---@return fun(node: Node?): any +local function wrap_node_or_visual_bulk(normal_fn, bulk_member, bulk_method) + return function(node) + if is_visual_mode() then + local nodes = get_visual_nodes() + if nodes then + local explorer = require("nvim-tree.core").get_explorer() + if explorer then + explorer[bulk_member][bulk_method](explorer[bulk_member], nodes) + end + end + else + node = node or wrap_explorer("get_node_at_cursor")() + if node then + return normal_fn(node) + end + end + end +end + ---@class NodeEditOpts ---@field quit_on_open boolean|nil default false ---@field focus boolean|nil default true @@ -172,18 +256,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_bulk(actions.fs.remove_file.fn, "marks", "bulk_delete_nodes") + api.fs.trash = wrap_node_or_visual_bulk(actions.fs.trash.fn, "marks", "bulk_trash_nodes") 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"), true) 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"), true) 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")) @@ -229,8 +313,12 @@ function M.hydrate(api) api.node.expand = wrap_node(wrap_explorer("expand_node")) api.node.collapse = wrap_node(actions.tree.collapse.node) - api.node.buffer.delete = wrap_node(function(node, opts) actions.node.buffer.delete(node, opts) end) - api.node.buffer.wipe = wrap_node(function(node, opts) actions.node.buffer.wipe(node, opts) end) + api.node.buffer.delete = wrap_node(function(node, opts) + actions.node.buffer.delete(node, opts) + end) + api.node.buffer.wipe = wrap_node(function(node, opts) + actions.node.buffer.wipe(node, opts) + end) api.tree.reload_git = wrap_explorer("reload_git") @@ -246,7 +334,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 532684e74fb..a53398f0f27 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..b102e226a0d 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,23 @@ local function compute(map) table.sort(mappings, sort_fn) - -- longest lhs and description + -- 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 +152,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..53095621757 100644 --- a/lua/nvim-tree/marks/init.lua +++ b/lua/nvim-tree/marks/init.lua @@ -170,27 +170,70 @@ function Marks:list() return list end ----Delete marked; each removal will be optionally notified +---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. ---@public -function Marks:bulk_delete() - if not next(self.marks) then - notify.warn("No bookmarks to delete.") +---@param nodes Node[] +---@return Node[] +function Marks:filter_descendant_nodes(nodes) + local dominated = {} + for i, a in ipairs(nodes) do + for j, b in ipairs(nodes) do + if i ~= j then + local prefix = b.absolute_path .. utils.path_separator + if a.absolute_path:sub(1, #prefix) == prefix then + dominated[i] = true + break + end + end + end + end + local filtered = {} + for i, node in ipairs(nodes) do + if not dominated[i] then + table.insert(filtered, node) + end + end + return filtered +end + +---Bulk remove or trash nodes with optional confirmation prompt. +---@private +---@param nodes Node[] +---@param remove_fn fun(node: Node) +---@param confirm boolean +---@param prompt_select string +---@param prompt_id string +---@param after fun() +function Marks:bulk_remove(nodes, remove_fn, confirm, prompt_select, prompt_id, after) + if #nodes == 0 then return end local function execute() - for _, node in ipairs(self:list()) do - remove_file.remove(node) + for i = #nodes, 1, -1 do + remove_fn(nodes[i]) end - self:clear_reload() + after() end - if self.explorer.opts.ui.confirm.remove then - local prompt_select = "Remove bookmarked ?" - local prompt_input = prompt_select .. " y/N: " - lib.prompt(prompt_input, prompt_select, { "", "y" }, { "No", "Yes" }, "nvimtree_bulk_delete", function(item_short) + if confirm then + local default_yes = self.explorer.opts.ui.confirm.default_yes == true + local prompt_input, items_short, items_long + + if 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 + + lib.prompt(prompt_input, prompt_select, items_short, items_long, prompt_id, function(item_short) utils.clear_prompt() - if item_short == "y" then + if item_short == "y" or (default_yes and item_short ~= "n") then execute() end end) @@ -199,7 +242,25 @@ function Marks:bulk_delete() end end ----Trash marked; each removal will be optionally notified +---Delete marked +---@public +function Marks:bulk_delete() + if not next(self.marks) then + notify.warn("No bookmarks to delete.") + return + end + + self:bulk_remove( + self:list(), + remove_file.remove, + self.explorer.opts.ui.confirm.remove, + "Remove bookmarked ?", + "nvimtree_bulk_delete", + function() self:clear_reload() end + ) +end + +---Trash marked ---@public function Marks:bulk_trash() if not next(self.marks) then @@ -207,25 +268,66 @@ function Marks:bulk_trash() return end - local function execute() - for _, node in ipairs(self:list()) do - trash.remove(node) + self:bulk_remove( + self:list(), + trash.remove, + self.explorer.opts.ui.confirm.trash, + "Trash bookmarked ?", + "nvimtree_bulk_trash", + function() self:clear_reload() end + ) +end + +---Delete a list of nodes with a single prompt; used for visual selection operations. +---@public +---@param nodes Node[] +function Marks:bulk_delete_nodes(nodes) + -- Filter out parent directory entries ("..") to avoid deleting the parent directory. + local filtered = {} + for _, node in ipairs(nodes) do + if node.name ~= ".." then + table.insert(filtered, node) end - self:clear_reload() end - if self.explorer.opts.ui.confirm.trash then - local prompt_select = "Trash bookmarked ?" - local prompt_input = prompt_select .. " y/N: " - lib.prompt(prompt_input, prompt_select, { "", "y" }, { "No", "Yes" }, "nvimtree_bulk_trash", function(item_short) - utils.clear_prompt() - if item_short == "y" then - execute() + self:bulk_remove( + self:filter_descendant_nodes(filtered), + remove_file.remove, + self.explorer.opts.ui.confirm.remove, + string.format("Remove %d selected ?", #filtered), + "nvimtree_visual_delete", + function() + if not self.explorer.opts.filesystem_watchers.enable then + self.explorer:reload_explorer() end - end) - else - execute() + end + ) +end + +---Trash a list of nodes with a single prompt; used for visual selection operations. +---@public +---@param nodes Node[] +function Marks:bulk_trash_nodes(nodes) + -- Filter out parent directory entries ("..") to avoid trashing the parent directory. + local filtered = {} + for _, node in ipairs(nodes) do + if node.name ~= ".." then + table.insert(filtered, node) + end end + + self:bulk_remove( + self:filter_descendant_nodes(filtered), + trash.remove, + self.explorer.opts.ui.confirm.trash, + string.format("Trash %d selected ?", #filtered), + "nvimtree_visual_trash", + function() + if not self.explorer.opts.filesystem_watchers.enable then + self.explorer:reload_explorer() + end + end + ) end ---Move marked diff --git a/scripts/help-defaults.sh b/scripts/help-defaults.sh index 11ffefe4cc0..cf622d054b6 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:"