-
-
Notifications
You must be signed in to change notification settings - Fork 639
feat(#2994): add visual selection operations #3268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -158,6 +158,50 @@ function M.fn(node) | |
| end | ||
| end | ||
|
|
||
| ---Remove multiple nodes with a single confirmation prompt; used for visual selection operations. | ||
| ---@param nodes Node[] | ||
| function M.visual_fn(nodes) | ||
| if #nodes == 0 then | ||
| return | ||
| end | ||
|
|
||
| local function execute() | ||
| for i = #nodes, 1, -1 do | ||
| if nodes[i].name ~= ".." then | ||
| M.remove(nodes[i]) | ||
| end | ||
| end | ||
|
Comment on lines
+169
to
+173
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good work removing the root node from remove and trash operations. However, please
Same applies for trash. |
||
| 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 | ||
|
|
||
| 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 | ||
|
Comment on lines
+182
to
+192
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_visual_delete", 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 | ||
|
|
||
| function M.setup(opts) | ||
| M.config.ui = opts.ui | ||
| M.config.actions = opts.actions | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -119,6 +119,46 @@ function M.fn(node) | |
| end | ||
| end | ||
|
|
||
| ---Trash multiple nodes with a single confirmation prompt; used for visual selection operations. | ||
| ---@param nodes Node[] | ||
| function M.visual_fn(nodes) | ||
| if #nodes == 0 then | ||
| return | ||
| end | ||
|
|
||
| local function execute() | ||
| for i = #nodes, 1, -1 do | ||
| if nodes[i].name ~= ".." then | ||
| M.remove(nodes[i]) | ||
| 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 | ||
|
|
||
| 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 | ||
|
Comment on lines
+139
to
+149
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These lines are identical to those in ---@class TrashPrompt
---@field prompt_input string
---@field items_short string[]
---@field items_long string[]
---@param prompt_select string
---@return TrashPrompt
local function trash_prompt(prompt_select)We normally want to avoid making changes to unrelated code, however having duplicated in code overrides this. |
||
|
|
||
| lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_visual_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 | ||
|
|
||
| function M.setup(opts) | ||
| M.config.ui = opts.ui | ||
| M.config.trash = opts.trash | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Many thanks for the detail. |
||
| --- | ||
| ---e.g. the following are functionally identical: | ||
| ---```lua | ||
| --- | ||
| ---api.node.open.edit(nil, { focus = true }) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -86,6 +86,76 @@ 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lualine agrees with this mode detection. However, we should add the various select modes as well - some users like to use the mouse. |
||
| end | ||
|
|
||
| ---Exit visual mode synchronously. | ||
| local function exit_visual_mode() | ||
| local esc = vim.api.nvim_replace_termcodes("<Esc>", 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 | ||
|
|
||
| ---@class WrapNodeOrVisualOpts | ||
| ---@field visual_fn? fun(nodes: Node[]) bulk visual handler; when nil, fn is called per-node | ||
| ---@field filter_descendants? boolean filter out descendant nodes in visual mode (default true) | ||
|
|
||
| ---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. | ||
| --- | ||
| ---When opts.visual_fn is provided, it receives all nodes at once (for bulk | ||
| ---operations like remove/trash that need a single confirmation prompt). | ||
| ---When opts.visual_fn is nil, fn is called on each node individually. | ||
| --- | ||
| ---@param fn fun(node: Node, ...): any | ||
| ---@param opts? WrapNodeOrVisualOpts | ||
| ---@return fun(node: Node?, ...): any | ||
| local function wrap_node_or_visual(fn, opts) | ||
| opts = opts or {} | ||
| return function(node, ...) | ||
| if is_visual_mode() then | ||
| local nodes = get_visual_nodes() | ||
| if nodes then | ||
| if opts.filter_descendants ~= false then | ||
| nodes = utils.filter_descendant_nodes(nodes) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good work putting Please move similar to utils:
|
||
| end | ||
| if opts.visual_fn then | ||
| opts.visual_fn(nodes) | ||
| else | ||
| for _, n in ipairs(nodes) do | ||
| fn(n, ...) | ||
| end | ||
| end | ||
| end | ||
| else | ||
| node = node or wrap_explorer("get_node_at_cursor")() | ||
| if node then | ||
| return fn(node, ...) | ||
| end | ||
|
Comment on lines
+151
to
+154
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This duplicates |
||
| end | ||
| end | ||
| end | ||
|
|
||
| ---@class NodeEditOpts | ||
| ---@field quit_on_open boolean|nil default false | ||
| ---@field focus boolean|nil default true | ||
|
|
@@ -173,18 +243,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, { visual_fn = actions.fs.remove_file.visual_fn }) | ||
| api.fs.trash = wrap_node_or_visual(actions.fs.trash.fn, { visual_fn = actions.fs.trash.visual_fn }) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are very difficult to read and do not match the style of the surrounding code i.e. one api interface mapped to one function/method. Let's refactor this: Single entry point for remove: ---@param node Node
local function remove_one_node(node)
---
end
---@param nodes Node[]
local function remove_many_nodes(nodes)
---
end
---@param node_or_nodes Node|Node[] single node when called in normal mode, multiple nodes in visual mode
function M.fn(node_or_nodes)
if node_or_nodes:is(Node) then
remove_one_node(node_or_nodes)
else
remove_many_nodes(node_or_nodes)
endWe then map the api to this single entry point: api.fs.remove = wrap_node_or_visual(actions.fs.remove_file.fn)Yes, this goes against what I said earlier about proxies, however this is the lesser of two evils. We now have the problem of how to choose whether to filter descendents. Plan:
This moves implementation details from the api to the implementation itself. |
||
| 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")) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As per https://github.com/nvim-tree/nvim-tree.lua/pull/3268/changes#r2862232144 Passing a table of |
||
| 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 +317,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"), { filter_descendants = false }) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding the explicit notes is most gratefully appreciated.