diff --git a/lua/nvim-tree/actions/finders/search-node.lua b/lua/nvim-tree/actions/finders/search-node.lua index 3ad65c377d5..3b0f0c04e00 100644 --- a/lua/nvim-tree/actions/finders/search-node.lua +++ b/lua/nvim-tree/actions/finders/search-node.lua @@ -23,7 +23,7 @@ local function search(search_dir, input_path) local function iter(dir) local realpath, path, name, stat, handle, _ - local filter_status = explorer.filters:prepare() + explorer.filters:prepare() handle, _ = vim.loop.fs_scandir(dir) if not handle then @@ -46,7 +46,7 @@ local function search(search_dir, input_path) break end - if not explorer.filters:should_filter(path, stat, filter_status) then + if not explorer.filters:should_filter(path) then if string.find(path, "/" .. input_path .. "$") then return path end diff --git a/lua/nvim-tree/api.lua b/lua/nvim-tree/api.lua index 822dbf20c0e..149b6549ac7 100644 --- a/lua/nvim-tree/api.lua +++ b/lua/nvim-tree/api.lua @@ -42,6 +42,7 @@ local Api = { commands = {}, diagnostics = {}, decorator = {}, + filters = {}, } ---Print error when setup not called. @@ -108,6 +109,17 @@ local function wrap_explorer_member_args(explorer_member, member_method, ...) end) end +---@param filter_api_method string +---@return fun(path: string): boolean +local function wrap_explorer_filter_function(filter_api_method) + return wrap(function(path) + local explorer = core.get_explorer() + if explorer then + return explorer.filters.api[filter_api_method](explorer.filters, path) + end + end) +end + ---Invoke a member's method on the singleton explorer. ---Print error when setup not called. ---@param explorer_member string explorer member name @@ -356,4 +368,12 @@ end) ---@type nvim_tree.api.decorator.UserDecorator Api.decorator.UserDecorator = UserDecorator --[[@as nvim_tree.api.decorator.UserDecorator]] +Api.filters.custom = wrap_explorer_filter_function("custom") +Api.filters.dotfile = wrap_explorer_filter_function("dotfile") +Api.filters.git_ignored = wrap_explorer_filter_function("git_ignored") +Api.filters.git_clean = wrap_explorer_filter_function("git_clean") +Api.filters.no_buffer = wrap_explorer_filter_function("no_buffer") +Api.filters.no_bookmark = wrap_explorer_filter_function("no_bookmark") +Api.filters.filter_reason = wrap_explorer_filter_function("filter_reason") + return Api diff --git a/lua/nvim-tree/enum.lua b/lua/nvim-tree/enum.lua index e99082282ac..a2f8489c546 100644 --- a/lua/nvim-tree/enum.lua +++ b/lua/nvim-tree/enum.lua @@ -4,11 +4,12 @@ local M = {} ---@enum FILTER_REASON M.FILTER_REASON = { none = 0, -- It's not filtered - git = 1, + git_clean = 1, buf = 2, dotfile = 4, custom = 8, bookmark = 16, + git_ignore = 32 } return M diff --git a/lua/nvim-tree/explorer/filters.lua b/lua/nvim-tree/explorer/filters.lua index 62230687e40..b490aa04abe 100644 --- a/lua/nvim-tree/explorer/filters.lua +++ b/lua/nvim-tree/explorer/filters.lua @@ -4,14 +4,23 @@ local FILTER_REASON = require("nvim-tree.enum").FILTER_REASON local Class = require("nvim-tree.classic") ---@alias FilterType "custom" | "dotfiles" | "git_ignored" | "git_clean" | "no_buffer" | "no_bookmark" +---@alias GitFilterType "git_clean" | "git_ignored" + +---@class FilterStatus +---@field project GitProject | nil +---@field bufinfo table +---@field bookmarks table ---@class (exact) Filters: Class ---@field enabled boolean ---@field state table +---@field api table ---@field private explorer Explorer ---@field private exclude_list string[] filters.exclude ---@field private ignore_list table filters.custom string table ---@field private custom_function (fun(absolute_path: string): boolean)|nil filters.custom function +---@field protected status FilterStatus +---@field private filter_cache table local Filters = Class:extend() ---@class Filters @@ -38,6 +47,8 @@ function Filters:new(args) no_bookmark = self.explorer.opts.filters.no_bookmark, } + self.filter_cache = {} + local custom_filter = self.explorer.opts.filters.custom if type(custom_filter) == "function" then self.custom_function = custom_filter @@ -50,6 +61,35 @@ function Filters:new(args) end end +--- Cache filter function results so subsequent calls to the same path in a loop +--- iteration are as fast as possible +---@private +---@param fn fun(self: Filters, path: string): boolean +---@param reason FILTER_REASON +---@return fun(self: Filters, path: string): boolean +local function cache_wrapper(fn, reason) + + ---@param self Filters + ---@param path string + ---@return FILTER_REASON + ---@diagnostic disable:invisible + local function inner(self, path) + + if self.filter_cache[reason] == nil then + self.filter_cache[reason] = {} + end + + if self.filter_cache[reason][path] == nil then + self.filter_cache[reason][path] = fn(self, path) + end + + return self.filter_cache[reason][path] + end + ---@diagnostic enable:invisible + + return inner +end + ---@private ---@param path string ---@return boolean @@ -64,10 +104,11 @@ end ---Check if the given path is git clean/ignored ---@private +---@param filter_type GitFilterType ---@param path string Absolute path ---@param project GitProject from prepare ---@return boolean -function Filters:git(path, project) +function Filters:git(filter_type, path, project) if type(project) ~= "table" or type(project.files) ~= "table" or type(project.dirs) ~= "table" then return false end @@ -77,31 +118,44 @@ function Filters:git(path, project) xy = xy or project.dirs.direct[path] and project.dirs.direct[path][1] xy = xy or project.dirs.indirect[path] and project.dirs.indirect[path][1] - -- filter ignored; overrides clean as they are effectively dirty - if self.state.git_ignored and xy == "!!" then + if filter_type == "git_ignored" and xy == "!!" then return true end - -- filter clean - if self.state.git_clean and not xy then + if filter_type == "git_clean" and not xy then return true end return false end +---@param path string +---@return boolean +function Filters:git_clean(path) + -- filter ignored; overrides clean as they are effectively dirty + if self.state.git_ignored and self:git_ignored("path") then + return true + else + return self:git("git_clean", path, self.status.project) + end +end + +---@param path string +---@return boolean +function Filters:git_ignored(path) + return self:git("git_ignored", path, self.status.project) +end + ---Check if the given path has no listed buffer ----@private ---@param path string Absolute path ----@param bufinfo table vim.fn.getbufinfo { buflisted = 1 } ---@return boolean -function Filters:buf(path, bufinfo) - if not self.state.no_buffer or type(bufinfo) ~= "table" then +function Filters:buf(path) + if type(self.status.bufinfo) ~= "table" then return false end -- filter files with no open buffer and directories containing no open buffers - for _, b in ipairs(bufinfo) do + for _, b in ipairs(self.status.bufinfo) do if b.name == path or b.name:find(path .. "/", 1, true) then return false end @@ -110,28 +164,26 @@ function Filters:buf(path, bufinfo) return true end ----@private ---@param path string ---@return boolean function Filters:dotfile(path) - return self.state.dotfiles and utils.path_basename(path):sub(1, 1) == "." + return utils.path_basename(path):sub(1, 1) == "." end ---Bookmark is present ----@private ---@param path string ----@param path_type string|nil filetype of path ----@param bookmarks table path, filetype table of bookmarked files ---@return boolean -function Filters:bookmark(path, path_type, bookmarks) - if not self.state.no_bookmark then - return false - end +function Filters:bookmark(path) + local bookmarks = self.status.bookmarks + -- if bookmark is empty, we should see a empty filetree if next(bookmarks) == nil then return true end + local stat, _ = vim.loop.fs_stat(path) + local path_type = stat and stat.type + local mark_parent = utils.path_add_trailing(path) for mark, mark_type in pairs(bookmarks) do if path == mark then @@ -156,21 +208,16 @@ function Filters:bookmark(path, path_type, bookmarks) return true end ----@private ---@param path string ---@return boolean function Filters:custom(path) - if not self.state.custom then - return false + -- filter user's custom function + if type(self.custom_function) == "function" then + return self.custom_function(path) end local basename = utils.path_basename(path) - -- filter user's custom function - if self.custom_function and self.custom_function(path) then - return true - end - -- filter custom regexes local relpath = utils.path_relative(path, vim.loop.cwd()) for pat, _ in pairs(self.ignore_list) do @@ -196,32 +243,30 @@ end --- bufinfo: empty unless no_buffer set: vim.fn.getbufinfo { buflisted = 1 } --- bookmarks: absolute paths to boolean function Filters:prepare(project) - local status = { + self.status = { project = project or {}, bufinfo = {}, bookmarks = {}, } - if self.state.no_buffer then - status.bufinfo = vim.fn.getbufinfo({ buflisted = 1 }) - end + self.filter_cache = {} + + self.status.bufinfo = vim.fn.getbufinfo({ buflisted = 1 }) local explorer = require("nvim-tree.core").get_explorer() if explorer then for _, node in pairs(explorer.marks:list()) do - status.bookmarks[node.absolute_path] = node.type + self.status.bookmarks[node.absolute_path] = node.type end end - return status + return self.status end ---Check if the given path should be filtered. ---@param path string Absolute path ----@param fs_stat uv.fs_stat.result|nil fs_stat of file ----@param status table from prepare ---@return boolean -function Filters:should_filter(path, fs_stat, status) +function Filters:should_filter(path) if not self.enabled then return false end @@ -231,19 +276,18 @@ function Filters:should_filter(path, fs_stat, status) return false end - return self:git(path, status.project) - or self:buf(path, status.bufinfo) - or self:dotfile(path) - or self:custom(path) - or self:bookmark(path, fs_stat and fs_stat.type, status.bookmarks) + return (self.state.custom and self:custom(path)) + or (self.state.git_clean and self:git_clean(path)) + or (self.state.git_ignored and self:git_ignored(path)) + or (self.state.no_buffer and self:buf(path)) + or (self.state.dotfiles and self:dotfile(path)) + or (self.state.no_bookmark and self:bookmark(path)) end --- Check if the given path should be filtered, and provide the reason why it was ---@param path string Absolute path ----@param fs_stat uv.fs_stat.result|nil fs_stat of file ----@param status table from prepare ---@return FILTER_REASON -function Filters:should_filter_as_reason(path, fs_stat, status) +function Filters:should_filter_as_reason(path) if not self.enabled then return FILTER_REASON.none end @@ -252,16 +296,28 @@ function Filters:should_filter_as_reason(path, fs_stat, status) return FILTER_REASON.none end - if self:git(path, status.project) then - return FILTER_REASON.git - elseif self:buf(path, status.bufinfo) then + if not self:should_filter(path) then + return FILTER_REASON.none + end + + if self.state.custom and self:custom(path) then + return FILTER_REASON.custom + + elseif self.state.git_clean and self:git_clean(path) then + return FILTER_REASON.git_clean + + elseif self.state.git_ignored and self:git_ignored(path) then + return FILTER_REASON.git_ignore + + elseif self.state.no_buffer and self:buf(path) then return FILTER_REASON.buf - elseif self:dotfile(path) then + + elseif self.state.dotfiles and self:dotfile(path) then return FILTER_REASON.dotfile - elseif self:custom(path) then - return FILTER_REASON.custom - elseif self:bookmark(path, fs_stat and fs_stat.type, status.bookmarks) then + + elseif self.state.no_bookmark and self:bookmark(path) then return FILTER_REASON.bookmark + else return FILTER_REASON.none end @@ -284,4 +340,22 @@ function Filters:toggle(type) end end +---@diagnostic disable:inject-field +Filters.custom = cache_wrapper(Filters.custom, FILTER_REASON.custom) +Filters.dotfile = cache_wrapper(Filters.dotfile, FILTER_REASON.dotfile) +Filters.git_ignored = cache_wrapper(Filters.git_ignored, FILTER_REASON.git_ignore) +Filters.git_clean = cache_wrapper(Filters.git_clean, FILTER_REASON.git_clean) +Filters.buf = cache_wrapper(Filters.buf, FILTER_REASON.buf) +Filters.bookmark = cache_wrapper(Filters.bookmark, FILTER_REASON.bookmark) +---@diagnostic enable:inject-field + +Filters.api = { + custom = Filters.custom, + dotfile = Filters.dotfile, + git_ignored = Filters.git_ignored, + git_clean = Filters.git_clean, + no_buffer = Filters.buf, + no_bookmark = Filters.bookmark, +} + return Filters diff --git a/lua/nvim-tree/explorer/init.lua b/lua/nvim-tree/explorer/init.lua index 58972164afe..146741543c3 100644 --- a/lua/nvim-tree/explorer/init.lua +++ b/lua/nvim-tree/explorer/init.lua @@ -205,7 +205,7 @@ function Explorer:reload(node, project) local profile = log.profile_start("reload %s", node.absolute_path) - local filter_status = self.filters:prepare(project) + self.filters:prepare(project) if node.group_next then node.nodes = { node.group_next } @@ -220,11 +220,12 @@ function Explorer:reload(node, project) -- To reset we must 'zero' everything that we use node.hidden_stats = vim.tbl_deep_extend("force", node.hidden_stats or {}, { - git = 0, - buf = 0, - dotfile = 0, - custom = 0, - bookmark = 0, + git_clean = 0, + git_ignore = 0, + buf = 0, + dotfile = 0, + custom = 0, + bookmark = 0, }) while true do @@ -238,7 +239,7 @@ function Explorer:reload(node, project) -- path incorrectly specified as an integer local stat = vim.loop.fs_lstat(abs) ---@diagnostic disable-line param-type-mismatch - local filter_reason = self.filters:should_filter_as_reason(abs, stat, filter_status) + local filter_reason = self.filters:should_filter_as_reason(abs) if filter_reason == FILTER_REASON.none then remain_childs[abs] = true @@ -373,7 +374,7 @@ function Explorer:populate_children(handle, cwd, node, project, parent) local node_ignored = node:is_git_ignored() local nodes_by_path = utils.bool_record(node.nodes, "absolute_path") - local filter_status = parent.filters:prepare(project) + parent.filters:prepare(project) node.hidden_stats = vim.tbl_deep_extend("force", node.hidden_stats or {}, { git = 0, @@ -397,7 +398,7 @@ function Explorer:populate_children(handle, cwd, node, project, parent) -- path incorrectly specified as an integer local stat = vim.loop.fs_lstat(abs) ---@diagnostic disable-line param-type-mismatch - local filter_reason = parent.filters:should_filter_as_reason(abs, stat, filter_status) + local filter_reason = parent.filters:should_filter_as_reason(abs) if filter_reason == FILTER_REASON.none and not nodes_by_path[abs] then local child = node_factory.create({ explorer = self,