diff --git a/lua/neo-tree/defaults.lua b/lua/neo-tree/defaults.lua index 68ce4d52..f6d28dc8 100644 --- a/lua/neo-tree/defaults.lua +++ b/lua/neo-tree/defaults.lua @@ -23,7 +23,7 @@ local config = { enable_opened_markers = true, -- Enable tracking of opened files. Required for `components.name.highlight_opened_files` enable_refresh_on_write = true, -- Refresh the tree when a file is written. Only used if `use_libuv_file_watcher` is false. enable_cursor_hijack = false, -- If enabled neotree will keep the cursor on the first letter of the filename when moving in the tree. - git_status_async = true, + git_status_async = false, -- These options are for people with VERY large git repos git_status_async_options = { batch_size = 1000, -- how many lines of git status results to process at a time diff --git a/lua/neo-tree/git/ignored.lua b/lua/neo-tree/git/ignored.lua deleted file mode 100644 index 5c993b4b..00000000 --- a/lua/neo-tree/git/ignored.lua +++ /dev/null @@ -1,186 +0,0 @@ -local Job = require("plenary.job") -local uv = vim.uv or vim.loop - -local utils = require("neo-tree.utils") -local log = require("neo-tree.log") -local git_utils = require("neo-tree.git.utils") - -local M = {} -local sep = utils.path_separator - ----@param ignored string[] ----@param path string ----@param _type neotree.Filetype -M.is_ignored = function(ignored, path, _type) - if _type == "directory" and not utils.is_windows then - path = path .. sep - end - - return vim.tbl_contains(ignored, path) -end - -local git_root_cache = { - known_roots = {}, - dir_lookup = {}, -} -local get_root_for_item = function(item) - local dir = item.type == "directory" and item.path or item.parent_path - if type(git_root_cache.dir_lookup[dir]) ~= "nil" then - return git_root_cache.dir_lookup[dir] - end - --for _, root in ipairs(git_root_cache.known_roots) do - -- if vim.startswith(dir, root) then - -- git_root_cache.dir_lookup[dir] = root - -- return root - -- end - --end - local root = git_utils.get_repository_root(dir) - if root then - git_root_cache.dir_lookup[dir] = root - table.insert(git_root_cache.known_roots, root) - else - git_root_cache.dir_lookup[dir] = false - end - return root -end - ----@param state neotree.State ----@param items neotree.FileItem[] ----@param callback fun(results: string[]) ----@overload fun(state: neotree.State, items: neotree.FileItem[]):string[] -M.mark_ignored = function(state, items, callback) - local folders = {} - log.trace("================================================================================") - log.trace("IGNORED: mark_ignore BEGIN...") - - for _, item in ipairs(items) do - local folder = utils.split_path(item.path) - if folder then - folders[folder] = folders[folder] or {} - table.insert(folders[folder], item.path) - end - end - - ---@param results string[] - local function process_results(results) - if utils.is_windows then - --on Windows, git seems to return quotes and double backslash "path\\directory" - ---@param item string - results = vim.tbl_map(function(item) - item = item:gsub("\\\\", "\\") - return item - end, results) - else - --check-ignore does not indicate directories the same as 'status' so we need to - --add the trailing slash to the path manually if not on Windows. - log.trace("IGNORED: Checking types of", #results, "items to see which ones are directories") - for i, item in ipairs(results) do - local stat = uv.fs_stat(item) - if stat and stat.type == "directory" then - results[i] = item .. sep - end - end - end - ---@param item string - results = vim.tbl_map(function(item) - item = item:gsub("\\\\", "\\") - -- remove leading and trailing " from git output - item = item:gsub('^"', ""):gsub('"$', "") - -- convert octal encoded lines to utf-8 - item = git_utils.octal_to_utf8(item) - return item - end, results) - return results - end - - ---@param all_results string[] - local function finalize(all_results) - local ignored, not_ignored = 0, 0 - for _, item in ipairs(items) do - if M.is_ignored(all_results, item.path, item.type) then - item.filtered_by = item.filtered_by or {} - item.filtered_by.gitignored = true - ignored = ignored + 1 - else - not_ignored = not_ignored + 1 - end - end - log.trace("IGNORED: mark_ignored is complete, ignored:", ignored, ", not ignored:", not_ignored) - log.trace("================================================================================") - end - - ---@type string[] - local all_results = {} - if type(callback) == "function" then - local jobs = {} - local running_jobs = 0 - local job_count = 0 - local completed_jobs = 0 - - -- This is called when a job completes, and starts the next job if there are any left - -- or calls the callback if all jobs are complete. - -- It is also called once at the start to start the first 50 jobs. - -- - -- This is done to avoid running too many jobs at once, which can cause a crash from - -- having too many open files. - local run_more_jobs = function() - while #jobs > 0 and running_jobs < 50 and job_count > completed_jobs do - local next_job = table.remove(jobs, #jobs) - next_job:start() - running_jobs = running_jobs + 1 - end - - if completed_jobs == job_count then - finalize(all_results) - callback(all_results) - end - end - - for folder, folder_items in pairs(folders) do - local args = { "-C", folder, "check-ignore", "--stdin" } - ---@diagnostic disable-next-line: missing-fields - local job = Job:new({ - command = "git", - args = args, - enabled_recording = true, - writer = folder_items, - on_start = function() - log.trace("IGNORED: Running async git with args:", args) - end, - on_exit = function(self, code, _) - local results - if code ~= 0 then - log.debug("Failed to load ignored files for", folder, ":", self:stderr_result()) - results = {} - else - results = self:result() - end - vim.list_extend(all_results, process_results(results)) - - running_jobs = running_jobs - 1 - completed_jobs = completed_jobs + 1 - run_more_jobs() - end, - }) - table.insert(jobs, job) - job_count = job_count + 1 - end - - run_more_jobs() - else - for folder, folder_items in pairs(folders) do - local cmd = { "git", "-C", folder, "check-ignore", unpack(folder_items) } - log.trace("IGNORED: Running cmd:", cmd) - local result = vim.fn.systemlist(cmd) - if vim.v.shell_error == 128 then - log.debug("Failed to load ignored files for", state.path, ":", result) - result = {} - end - vim.list_extend(all_results, process_results(result)) - end - finalize(all_results) - return all_results - end -end - -return M diff --git a/lua/neo-tree/git/init.lua b/lua/neo-tree/git/init.lua index 863599be..bafe9c9c 100644 --- a/lua/neo-tree/git/init.lua +++ b/lua/neo-tree/git/init.lua @@ -1,13 +1,454 @@ -local status = require("neo-tree.git.status") -local ignored = require("neo-tree.git.ignored") -local git_utils = require("neo-tree.git.utils") - -local M = { - get_repository_root = git_utils.get_repository_root, - is_ignored = ignored.is_ignored, - mark_ignored = ignored.mark_ignored, - status = status.status, - status_async = status.status_async, -} +local utils = require("neo-tree.utils") +local events = require("neo-tree.events") +local log = require("neo-tree.log") +local Job = require("plenary.job") +local uv = vim.uv or vim.loop +local co = coroutine +local M = {} + +---@type table +M.status_cache = setmetatable({}, { + __mode = "v", + __newindex = function(_, root_dir, status) + require("neo-tree.sources.filesystem.lib.fs_watch").on_destroyed(root_dir, function() + rawset(M.status_cache, root_dir, nil) + events.fire_event(events.GIT_STATUS_CHANGED, { git_root = root_dir, status = status }) + end) + rawset(M.status_cache, root_dir, status) + end, +}) + +---@class (exact) neotree.git.Context : neotree.Config.GitStatusAsync +---@field git_status neotree.git.Status? +---@field git_root string +---@field lines_parsed integer + +---@param git_root string +---@param status_iter fun():string? +---@param batch_size integer? This will use coroutine.yield if provided. +---@param skip_bubbling boolean? +---@return neotree.git.Status +local parse_porcelain_output = function(git_root, status_iter, batch_size, skip_bubbling) + local git_root_dir = utils.normalize_path(git_root) .. utils.path_separator + local prev_line = "" + local num_in_batch = 0 + local git_status = {} + if batch_size == 0 then + batch_size = nil + end + local yield_if_batch_completed = function() end + + if batch_size then + yield_if_batch_completed = function() + num_in_batch = num_in_batch + 1 + if num_in_batch > batch_size then + num_in_batch = 0 + coroutine.yield(git_status) + end + end + end + for line in status_iter do + -- Example status: + -- 1 D. N... 100644 000000 000000 ade2881afa1dcb156a3aa576024aa0fecf789191 0000000000000000000000000000000000000000 deleted_staged.txt + -- 1 .D N... 100644 100644 000000 9c13483e67ceff219800303ec7af39c4f0301a5b 9c13483e67ceff219800303ec7af39c4f0301a5b deleted_unstaged.txt + -- 1 MM N... 100644 100644 100644 4417f3aca512ffdf247662e2c611ee03ff9255cc 29c0e9846cd6410a44c4ca3fdaf5623818bd2838 modified_mixed.txt + -- 1 M. N... 100644 100644 100644 f784736eecdd43cd8eb665615163cfc6506fca5f 8d6fad5bd11ac45c7c9e62d4db1c427889ed515b modified_staged.txt + -- 1 .M N... 100644 100644 100644 c9e1e027aa9430cb4ffccccf45844286d10285c1 c9e1e027aa9430cb4ffccccf45844286d10285c1 modified_unstaged.txt + -- 1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 89cae60d74c222609086441e29985f959b6ec546 new_staged_file.txt + -- 2 R. N... 100644 100644 100644 3454a7dc6b93d1098e3c3f3ec369589412abdf99 3454a7dc6b93d1098e3c3f3ec369589412abdf99 R100 renamed_staged_new.txt + -- renamed_staged_old.txt + -- 1 .T N... 100644 100644 120000 192f10ed8c11efb70155e8eb4cae6ec677347623 192f10ed8c11efb70155e8eb4cae6ec677347623 type_change.txt + -- ? .gitignore + -- ? untracked.txt + + -- 1 + -- 2 + local t = line:sub(1, 1) + if t == "1" then + local XY = line:sub(3, 4) + -- local submodule_state = line:sub(6, 9) + -- local mH = line:sub(11, 16) + -- local mI = line:sub(18, 23) + -- local mW = line:sub(25, 30) + -- local hH = line:sub(32, 71) + -- local hI = line:sub(73, 112) + local path = line:sub(114) + git_status[git_root_dir .. path] = XY + elseif t == "2" then + -- local XY = line:sub(3, 4) + -- local submodule_state = line:sub(6, 9) + -- local mH = line:sub(11, 16) + -- local mI = line:sub(18, 23) + -- local mW = line:sub(25, 30) + -- local hH = line:sub(32, 71) + -- local hI = line:sub(73, 112) + local rest = line:sub(114) + local first_space = rest:find(" ", 1, true) + local Xscore = rest:sub(1, first_space - 1) + local path = rest:sub(first_space + 1) + git_status[git_root_dir .. path] = Xscore + -- ignore the original path + status_iter() + elseif t == "u" then + local XY = line:sub(3, 4) + -- local submodule_state = line:sub(6, 9) + -- local m1 = line:sub(11, 16) + -- local m2 = line:sub(18, 23) + -- local m3 = line:sub(25, 30) + -- local mW = line:sub(32, 37) + -- local h1 = line:sub(39, 78) + -- local h2 = line:sub(80, 119) + -- local h3 = line:sub(121, 160) + local path = line:sub(162) + git_status[git_root_dir .. path] = XY + else + prev_line = line + break + end + -- X Y Meaning + -- ------------------------------------------------- + -- [AMD] not updated + -- M [ MTD] updated in index + -- T [ MTD] type changed in index + -- A [ MTD] added to index + -- D deleted from index + -- R [ MTD] renamed in index + -- C [ MTD] copied in index + -- [MTARC] index and work tree matches + -- [ MTARC] M work tree changed since index + -- [ MTARC] T type changed in work tree since index + -- [ MTARC] D deleted in work tree + -- R renamed in work tree + -- C copied in work tree + -- ------------------------------------------------- + -- D D unmerged, both deleted + -- A U unmerged, added by us + -- U D unmerged, deleted by them + -- U A unmerged, added by them + -- D U unmerged, deleted by us + -- A A unmerged, both added + -- U U unmerged, both modified + yield_if_batch_completed() + end + + -- ------------------------------------------------- + -- ? ? untracked + -- ! ! ignored + -- ------------------------------------------------- + if prev_line:sub(1, 1) == "?" then + git_status[git_root_dir .. prev_line:sub(3)] = "?" + for line in status_iter do + if line:sub(1, 1) ~= "?" then + prev_line = line + break + end + git_status[git_root_dir .. line:sub(3)] = "?" + end + yield_if_batch_completed() + end + + if not skip_bubbling then + -- bubble up every status besides ignored + local status_prio = { "U", "?", "M", "A" } + + for dir, status in pairs(git_status) do + if status ~= "!" then + local s = status:sub(1, 1) + for parent in utils.path_parents(dir, true) do + if parent == git_root then + -- bubble only up to the children of the git root + break + end + + local parent_status = git_status[parent] + if not parent_status then + git_status[parent] = status + else + -- Bubble up the most important status + local p = parent_status:sub(1, 1) + for _, c in ipairs(status_prio) do + if p == c then + break + end + if s == c then + git_status[parent] = c + end + end + end + end + end + yield_if_batch_completed() + end + end + if prev_line:sub(1, 1) == "!" then + git_status[git_root_dir .. prev_line:sub(3)] = "!" + for line in status_iter do + git_status[git_root_dir .. line:sub(3)] = "!" + end + yield_if_batch_completed() + end + + M.status_cache[git_root] = git_status + return git_status +end +---@alias neotree.git.Status table + +---Parse "git status" output for the current working directory. +---@param base string git ref base +---@param skip_bubbling boolean? Whether to skip bubling up status to directories +---@param path string? Path to run the git status command in, defaults to cwd. +---@return neotree.git.Status?, string? git_status the neotree.Git.Status of the given root, if there's a valid git status there +M.status = function(base, skip_bubbling, path) + local git_root = M.get_repository_root(path) + if not utils.truthy(git_root) then + return nil + end + ---@cast git_root -nil + + local status_cmd = { + "git", + "--no-optional-locks", + "-C", + git_root, + "status", + "--porcelain=v2", + "--untracked-files=normal", + "--ignored=traditional", + "-z", + } + local status_result = vim.fn.system(status_cmd) + + local status_ok = vim.v.shell_error == 0 + ---@type neotree.git.Context + local context = { + git_root = git_root, + git_status = nil, + lines_parsed = 0, + } + + if status_ok then + -- system() replaces \000 with \001 + local status_iter = vim.gsplit(status_result, "\001", { plain = true }) + local gs = parse_porcelain_output(git_root, status_iter, nil, skip_bubbling) + + context.git_status = gs + vim.schedule(function() + events.fire_event(events.GIT_STATUS_CHANGED, { + git_root = context.git_root, + git_status = context.git_status, + }) + end) + M.status_cache[git_root] = {} + end + + return context.git_status, git_root +end + +---@param path string path to run commands in +---@param base string git ref base +---@param opts neotree.Config.GitStatusAsync +M.status_async = function(path, base, opts) + M.get_repository_root(path, function(git_root) + if not git_root then + log.trace("status_async: not a git folder:", path) + return + end + ---@cast git_root -false + + log.trace("git.status.status_async called") + + local event_id = "git_status_" .. git_root + ---@type neotree.git.Context + local context = { + git_root = git_root, + git_status = {}, + lines = {}, + lines_parsed = 0, + batch_size = opts.batch_size or 1000, + batch_delay = opts.batch_delay or 10, + max_lines = opts.max_lines or 100000, + } + + utils.debounce(event_id, function() + local stdin = log.assert(uv.new_pipe()) + local stdout = log.assert(uv.new_pipe()) + local stderr = log.assert(uv.new_pipe()) + + local output_chunks = {} + log.trace("spawning git") + ---@diagnostic disable-next-line: missing-fields + uv.spawn("git", { + hide = true, + args = { + "--no-optional-locks", + "-C", + git_root, + "status", + "--porcelain=v2", + "--untracked-files=normal", + "--ignored=traditional", + "-z", + }, + stdio = { stdin, stdout, stderr }, + }, function(code, signal) + if code ~= 0 then + log.at.debug.format( + "git status async process exited abnormally, code: %s, signal: %s", + code, + signal + ) + return + end + local str = output_chunks[1] + if #output_chunks > 1 then + str = table.concat(output_chunks, "") + end + local status_iter = vim.gsplit(str, "\000", { plain = true }) + local parsing_task = co.create(parse_porcelain_output) + local _, git_status = + log.assert(co.resume(parsing_task, git_root, status_iter, context.batch_size)) + + stdin:shutdown() + stdout:shutdown() + stderr:shutdown() + stdin:close() + stdout:close() + stderr:close() + + local do_next_batch_later + do_next_batch_later = function() + if co.status(parsing_task) ~= "dead" then + _, git_status = log.assert(co.resume(parsing_task)) + vim.defer_fn(do_next_batch_later, context.batch_delay) + return + end + context.git_status = git_status + M.status_cache[git_root] = git_status + vim.schedule(function() + events.fire_event(events.GIT_STATUS_CHANGED, { + git_root = context.git_root, + git_status = context.git_status, + }) + end) + end + do_next_batch_later() + end) + + stdout:read_start(function(err, data) + log.assert(not err, err) + -- for some reason data can be a table here? + if type(data) == "string" then + table.insert(output_chunks, data) + end + end) + + stderr:read_start(function(err, data) + if err then + local errfmt = (err or "") .. "%s" + log.at.error.format(errfmt, data) + end + end) + end, 1000, utils.debounce_strategy.CALL_FIRST_AND_LAST, utils.debounce_action.START_ASYNC_JOB) + end) +end + +---@param state neotree.State +---@param items neotree.FileItem[] +M.mark_ignored = function(state, items) + local gs = state.git_status_lookup + if not gs then + return + end + for _, i in ipairs(items) do + local direct_lookup = gs[i.path] or gs[i.path .. utils.path_separator] + if direct_lookup == "!" then + i.filtered_by = i.filtered_by or {} + i.filtered_by.gitignored = true + end + end +end + +---@type table +do + local git_rootdir_cache = setmetatable({}, { __mode = "kv" }) + local finalize = function(path, git_root) + if utils.is_windows then + git_root = utils.windowize_path(git_root) + end + + log.trace("GIT ROOT for '", path, "' is '", git_root, "'") + git_rootdir_cache[path] = git_root + git_rootdir_cache[git_root] = git_root + end + + ---@param path string? Defaults to cwd + ---@param callback fun(git_root: string?)? + ---@return string? + M.get_repository_root = function(path, callback) + path = path or log.assert(vim.uv.cwd()) + + local cached_rootdir = git_rootdir_cache[path] + if cached_rootdir ~= nil then + log.trace("git.get_repository_root: cache hit for", path, "was", cached_rootdir) + if callback then + callback(cached_rootdir) + return + end + return cached_rootdir + end + + for parent in utils.path_parents(path, true) do + local cached_parent_entry = git_rootdir_cache[parent] + if cached_parent_entry ~= nil then + log.trace( + "git.get_repository_root: cache hit for parent of", + path, + ",", + parent, + "was", + cached_parent_entry + ) + git_rootdir_cache[path] = cached_parent_entry + return cached_parent_entry + end + end + + log.trace("git.get_repository_root: cache miss for", path) + local args = { "-C", path, "rev-parse", "--show-toplevel" } + + if type(callback) == "function" then + ---@diagnostic disable-next-line: missing-fields + Job:new({ + command = "git", + args = args, + enabled_recording = true, + on_exit = function(self, code, _) + if code ~= 0 then + log.trace("GIT ROOT ERROR", self:stderr_result()) + git_rootdir_cache[path] = false + callback(nil) + return + end + local git_root = self:result()[1] + + finalize(path, git_root) + callback(git_root) + end, + }):start() + return + end + + local ok, git_output = utils.execute_command({ "git", unpack(args) }) + if not ok then + log.trace("GIT ROOT NOT FOUND", git_output) + git_rootdir_cache[path] = false + return nil + end + local git_root = git_output[1] + + finalize(path, git_root) + return git_root + end +end return M diff --git a/lua/neo-tree/git/status.lua b/lua/neo-tree/git/status.lua deleted file mode 100644 index 3e4cc1ac..00000000 --- a/lua/neo-tree/git/status.lua +++ /dev/null @@ -1,350 +0,0 @@ -local utils = require("neo-tree.utils") -local events = require("neo-tree.events") -local Job = require("plenary.job") -local log = require("neo-tree.log") -local git_utils = require("neo-tree.git.utils") - -local M = {} - -local function get_simple_git_status_code(status) - -- Prioritze M then A over all others - if status:match("U") or status == "AA" or status == "DD" then - return "U" - elseif status:match("M") then - return "M" - elseif status:match("[ACR]") then - return "A" - elseif status:match("!$") then - return "!" - elseif status:match("?$") then - return "?" - else - local len = #status - while len > 0 do - local char = status:sub(len, len) - if char ~= " " then - return char - end - len = len - 1 - end - return status - end -end - -local function get_priority_git_status_code(status, other_status) - if not status then - return other_status - elseif not other_status then - return status - elseif status == "U" or other_status == "U" then - return "U" - elseif status == "?" or other_status == "?" then - return "?" - elseif status == "M" or other_status == "M" then - return "M" - elseif status == "A" or other_status == "A" then - return "A" - else - return status - end -end - ----@class (exact) neotree.git.Context ----@field git_status neotree.git.Status ----@field git_root string ----@field exclude_directories boolean ----@field lines_parsed integer - ----@alias neotree.git.Status table - ----@param context neotree.git.Context -local parse_git_status_line = function(context, line) - context.lines_parsed = context.lines_parsed + 1 - if type(line) ~= "string" then - return - end - if #line < 3 then - return - end - local git_root = context.git_root - local git_status = context.git_status - local exclude_directories = context.exclude_directories - - local line_parts = vim.split(line, " ") - if #line_parts < 2 then - return - end - local status = line_parts[1] - local relative_path = line_parts[2] - - -- rename output is `R000 from/filename to/filename` - if status:match("^R") then - relative_path = line_parts[3] - end - - -- remove any " due to whitespace or utf-8 in the path - relative_path = relative_path:gsub('^"', ""):gsub('"$', "") - -- convert octal encoded lines to utf-8 - relative_path = git_utils.octal_to_utf8(relative_path) - - if utils.is_windows == true then - relative_path = utils.windowize_path(relative_path) - end - local absolute_path = utils.path_join(git_root, relative_path) - -- merge status result if there are results from multiple passes - local existing_status = git_status[absolute_path] - if existing_status then - local merged = "" - local i = 0 - while i < 2 do - i = i + 1 - local existing_char = #existing_status >= i and existing_status:sub(i, i) or "" - local new_char = #status >= i and status:sub(i, i) or "" - local merged_char = get_priority_git_status_code(existing_char, new_char) - merged = merged .. merged_char - end - status = merged - end - git_status[absolute_path] = status - - if not exclude_directories then - -- Now bubble this status up to the parent directories - local parts = utils.split(absolute_path, utils.path_separator) - table.remove(parts) -- pop the last part so we don't override the file's status - utils.reduce(parts, "", function(acc, part) - local path = acc .. utils.path_separator .. part - if utils.is_windows == true then - path = path:gsub("^" .. utils.path_separator, "") - end - local path_status = git_status[path] - local file_status = get_simple_git_status_code(status) - git_status[path] = get_priority_git_status_code(path_status, file_status) - return path - end) - end -end ----Parse "git status" output for the current working directory. ----@base git ref base ----@exclude_directories boolean Whether to skip bubling up status to directories ----@path string Path to run the git status command in, defaults to cwd. ----@return neotree.git.Status, string? git_status the neotree.Git.Status of the given root -M.status = function(base, exclude_directories, path) - local git_root = git_utils.get_repository_root(path) - if not utils.truthy(git_root) then - return {} - end - - local C = git_root - local staged_cmd = { "git", "-C", C, "diff", "--staged", "--name-status", base, "--" } - local staged_ok, staged_result = utils.execute_command(staged_cmd) - if not staged_ok then - return {} - end - local unstaged_cmd = { "git", "-C", C, "diff", "--name-status" } - local unstaged_ok, unstaged_result = utils.execute_command(unstaged_cmd) - if not unstaged_ok then - return {} - end - local untracked_cmd = { "git", "-C", C, "ls-files", "--exclude-standard", "--others" } - local untracked_ok, untracked_result = utils.execute_command(untracked_cmd) - if not untracked_ok then - return {} - end - - local context = { - git_root = git_root, - git_status = {}, - exclude_directories = exclude_directories, - lines_parsed = 0, - } - - for _, line in ipairs(staged_result) do - parse_git_status_line(context, line) - end - for _, line in ipairs(unstaged_result) do - if line then - line = " " .. line - end - parse_git_status_line(context, line) - end - for _, line in ipairs(untracked_result) do - if line then - line = "? " .. line - end - parse_git_status_line(context, line) - end - - return context.git_status, git_root -end - -local function parse_lines_batch(context, job_complete_callback) - local i, batch_size = 0, context.batch_size - - if context.lines_total == nil then - -- first time through, get the total number of lines - context.lines_total = math.min(context.max_lines, #context.lines) - context.lines_parsed = 0 - if context.lines_total == 0 then - if type(job_complete_callback) == "function" then - job_complete_callback() - end - return - end - end - batch_size = math.min(context.batch_size, context.lines_total - context.lines_parsed) - - while i < batch_size do - i = i + 1 - parse_git_status_line(context, context.lines[context.lines_parsed + 1]) - end - - if context.lines_parsed >= context.lines_total then - if type(job_complete_callback) == "function" then - job_complete_callback() - end - else - -- add small delay so other work can happen - vim.defer_fn(function() - parse_lines_batch(context, job_complete_callback) - end, context.batch_delay) - end -end - -M.status_async = function(path, base, opts) - git_utils.get_repository_root(path, function(git_root) - if utils.truthy(git_root) then - log.trace("git.status.status_async called") - else - log.trace("status_async: not a git folder:", path) - return false - end - - local event_id = "git_status_" .. git_root - ---@type neotree.git.Context - local context = { - git_root = git_root, - git_status = {}, - exclude_directories = false, - lines = {}, - lines_parsed = 0, - batch_size = opts.batch_size or 1000, - batch_delay = opts.batch_delay or 10, - max_lines = opts.max_lines or 100000, - } - - local should_process = function(err, line, job, err_msg) - if vim.v.dying > 0 or vim.v.exiting ~= vim.NIL then - job:shutdown() - return false - end - if err and err > 0 then - log.error(err_msg, err, line) - return false - end - return true - end - - local job_complete_callback = function() - vim.schedule(function() - events.fire_event(events.GIT_STATUS_CHANGED, { - git_root = context.git_root, - git_status = context.git_status, - }) - end) - end - - local parse_lines = vim.schedule_wrap(function() - parse_lines_batch(context, job_complete_callback) - end) - - utils.debounce(event_id, function() - ---@diagnostic disable-next-line: missing-fields - local staged_job = Job:new({ - command = "git", - args = { "-C", git_root, "diff", "--staged", "--name-status", base, "--" }, - enable_recording = false, - maximium_results = context.max_lines, - on_stdout = function(err, line, job) - if should_process(err, line, job, "status_async staged error:") then - table.insert(context.lines, line) - end - end, - on_stderr = function(err, line) - if err and err > 0 then - log.error("status_async staged error: ", err, line) - end - end, - }) - - ---@diagnostic disable-next-line: missing-fields - local unstaged_job = Job:new({ - command = "git", - args = { "-C", git_root, "diff", "--name-status" }, - enable_recording = false, - maximium_results = context.max_lines, - on_stdout = function(err, line, job) - if should_process(err, line, job, "status_async unstaged error:") then - if line then - line = " " .. line - end - table.insert(context.lines, line) - end - end, - on_stderr = function(err, line) - if err and err > 0 then - log.error("status_async unstaged error: ", err, line) - end - end, - }) - - ---@diagnostic disable-next-line: missing-fields - local untracked_job = Job:new({ - command = "git", - args = { "-C", git_root, "ls-files", "--exclude-standard", "--others" }, - enable_recording = false, - maximium_results = context.max_lines, - on_stdout = function(err, line, job) - if should_process(err, line, job, "status_async untracked error:") then - if line then - line = "? " .. line - end - table.insert(context.lines, line) - end - end, - on_stderr = function(err, line) - if err and err > 0 then - log.error("status_async untracked error: ", err, line) - end - end, - }) - - ---@diagnostic disable-next-line: missing-fields - Job:new({ - command = "git", - args = { - "-C", - git_root, - "config", - "--get", - "status.showUntrackedFiles", - }, - enabled_recording = true, - on_exit = function(self, _, _) - local result = self:result() - log.debug("git status.showUntrackedFiles =", result[1]) - if result[1] == "no" then - unstaged_job:after(parse_lines) - Job.chain(staged_job, unstaged_job) - else - untracked_job:after(parse_lines) - Job.chain(staged_job, unstaged_job, untracked_job) - end - end, - }):start() - end, 1000, utils.debounce_strategy.CALL_FIRST_AND_LAST, utils.debounce_action.START_ASYNC_JOB) - - return true - end) -end - -return M diff --git a/lua/neo-tree/git/utils.lua b/lua/neo-tree/git/utils.lua index ba6b96e5..e69de29b 100644 --- a/lua/neo-tree/git/utils.lua +++ b/lua/neo-tree/git/utils.lua @@ -1,66 +0,0 @@ -local Job = require("plenary.job") - -local utils = require("neo-tree.utils") -local log = require("neo-tree.log") - -local M = {} - -M.get_repository_root = function(path, callback) - local args = { "rev-parse", "--show-toplevel" } - if utils.truthy(path) then - args = { "-C", path, "rev-parse", "--show-toplevel" } - end - if type(callback) == "function" then - ---@diagnostic disable-next-line: missing-fields - Job:new({ - command = "git", - args = args, - enabled_recording = true, - on_exit = function(self, code, _) - if code ~= 0 then - log.trace("GIT ROOT ERROR", self:stderr_result()) - callback(nil) - return - end - local git_root = self:result()[1] - - if utils.is_windows then - git_root = utils.windowize_path(git_root) - end - - log.trace("GIT ROOT for '", path, "' is '", git_root, "'") - callback(git_root) - end, - }):start() - else - local ok, git_output = utils.execute_command({ "git", unpack(args) }) - if not ok then - log.trace("GIT ROOT ERROR", git_output) - return nil - end - local git_root = git_output[1] - - if utils.is_windows then - git_root = utils.windowize_path(git_root) - end - - log.trace("GIT ROOT for '", path, "' is '", git_root, "'") - return git_root - end -end - -local convert_octal_char = function(octal) - return string.char(tonumber(octal, 8)) -end - -M.octal_to_utf8 = function(text) - -- git uses octal encoding for utf-8 filepaths, convert octal back to utf-8 - local success, converted = pcall(string.gsub, text, "\\([0-7][0-7][0-7])", convert_octal_char) - if success then - return converted - else - return text - end -end - -return M diff --git a/lua/neo-tree/log.lua b/lua/neo-tree/log.lua index 047a6d63..da0dc606 100644 --- a/lua/neo-tree/log.lua +++ b/lua/neo-tree/log.lua @@ -374,8 +374,12 @@ log_maker.new = function(config) else errmsg = "assertion failed!" end + local temp = config.use_console + config.use_console = false log.error(errmsg) - return assert(v, errmsg) + config.use_console = temp + -- actually raise the error so execution stops + error(errmsg, 2) end ---@param context string diff --git a/lua/neo-tree/sources/common/commands.lua b/lua/neo-tree/sources/common/commands.lua index 48c95120..f6e5b043 100644 --- a/lua/neo-tree/sources/common/commands.lua +++ b/lua/neo-tree/sources/common/commands.lua @@ -533,11 +533,7 @@ M.order_by_git_status = function(state) return git_status end - if node.filtered_by and node.filtered_by.gitignored then - return "!!" - else - return "" - end + return "" end require("neo-tree.sources.manager").refresh(state.name) diff --git a/lua/neo-tree/sources/common/file-items.lua b/lua/neo-tree/sources/common/file-items.lua index 7b7cce44..0ed469eb 100644 --- a/lua/neo-tree/sources/common/file-items.lua +++ b/lua/neo-tree/sources/common/file-items.lua @@ -234,41 +234,37 @@ local function create_item(context, path, _type, bufnr) local f = state.filtered_items local is_not_root = not utils.is_subpath(path, context.state.path) if f and is_not_root then + local fby = item.filtered_by or {} if f.never_show[name] then - item.filtered_by = item.filtered_by or {} - item.filtered_by.never_show = true + fby.never_show = true else if utils.is_filtered_by_pattern(f.never_show_by_pattern, path, name) then - item.filtered_by = item.filtered_by or {} - item.filtered_by.never_show = true + fby.never_show = true end end if f.always_show[name] then - item.filtered_by = item.filtered_by or {} - item.filtered_by.always_show = true + fby.always_show = true else if utils.is_filtered_by_pattern(f.always_show_by_pattern, path, name) then - item.filtered_by = item.filtered_by or {} - item.filtered_by.always_show = true + fby.always_show = true end end if f.hide_by_name[name] then - item.filtered_by = item.filtered_by or {} - item.filtered_by.name = true + fby.name = true end if utils.is_filtered_by_pattern(f.hide_by_pattern, path, name) then - item.filtered_by = item.filtered_by or {} - item.filtered_by.pattern = true + fby.pattern = true end if f.hide_dotfiles and string.sub(name, 1, 1) == "." then - item.filtered_by = item.filtered_by or {} - item.filtered_by.dotfiles = true + fby.dotfiles = true end if f.hide_hidden and utils.is_hidden(path) then - item.filtered_by = item.filtered_by or {} - item.filtered_by.hidden = true + fby.hidden = true + end + + if not vim.tbl_isempty(fby) then + item.filtered_by = fby end - -- NOTE: git_ignored logic moved to job_complete end set_parents(context, item) diff --git a/lua/neo-tree/sources/filesystem/commands.lua b/lua/neo-tree/sources/filesystem/commands.lua index 6a036bd7..33d794d9 100644 --- a/lua/neo-tree/sources/filesystem/commands.lua +++ b/lua/neo-tree/sources/filesystem/commands.lua @@ -154,7 +154,7 @@ local focus_next_git_modified = function(state, reverse) ---@cast g -nil local paths = { current_path } for path, status in pairs(g) do - if path ~= current_path and not vim.tbl_contains({ "!!", "?" }, status) then + if path ~= current_path and not vim.tbl_contains({ "!", "?" }, status) then --don't include files not in the current working directory if utils.is_subpath(state.path, path) then table.insert(paths, path) diff --git a/lua/neo-tree/sources/filesystem/lib/fs_scan.lua b/lua/neo-tree/sources/filesystem/lib/fs_scan.lua index ec96d434..058fcc54 100644 --- a/lua/neo-tree/sources/filesystem/lib/fs_scan.lua +++ b/lua/neo-tree/sources/filesystem/lib/fs_scan.lua @@ -114,90 +114,14 @@ local render_context = function(context) context = nil end ----@param context neotree.sources.filesystem.Context -local should_check_gitignore = function(context) - local state = context.state - if #context.all_items == 0 then - log.debug("No items, skipping git ignored/status lookups") - return false - end - if state.search_pattern and state.check_gitignore_in_search == false then - return false - end - if state.filtered_items.hide_gitignored then - return true - end - if state.enable_git_status == false then - return false - end - return true -end - ----@param context neotree.sources.filesystem.Context -local job_complete_async = function(context) - local state = context.state - local parent_id = context.parent_id - - file_nesting.nest_items(context) - - -- if state.search_pattern and #context.all_items > 50 then - -- -- don't do git ignored/status lookups when searching unless we are down to a reasonable number of items - -- return context - -- end - if should_check_gitignore(context) then - local mark_ignored_async = async.wrap(function(_state, _all_items, _callback) - ---lua-ls can't narrow this properly - ---@diagnostic disable-next-line: redundant-parameter - git.mark_ignored(_state, _all_items, _callback) - end, 3) - local all_items = mark_ignored_async(state, context.all_items) - - if parent_id then - vim.list_extend(state.git_ignored, all_items) - else - state.git_ignored = all_items - end - end - - ignored.mark_ignored(state, context.all_items) - return context -end - ---@param context neotree.sources.filesystem.Context local job_complete = function(context) local state = context.state - local parent_id = context.parent_id - file_nesting.nest_items(context) - ignored.mark_ignored(state, context.all_items) - if should_check_gitignore(context) then - if require("neo-tree").config.git_status_async then - ---lua-ls can't narrow this properly - ---@diagnostic disable-next-line: redundant-parameter - git.mark_ignored(state, context.all_items, function(all_items) - if parent_id then - vim.list_extend(state.git_ignored, all_items) - else - state.git_ignored = all_items - end - vim.schedule(function() - render_context(context) - end) - end) - return - else - local all_items = git.mark_ignored(state, context.all_items) - if parent_id then - vim.list_extend(state.git_ignored, all_items) - else - state.git_ignored = all_items - end - end - render_context(context) - else - render_context(context) - end + git.mark_ignored(state, context.all_items) + render_context(context) + return context end local function create_node(context, node) @@ -708,7 +632,7 @@ M.get_dir_items_async = function(state, parent_id, recursive) end async.util.join(scan_tasks) - job_complete_async(context) + job_complete(context) local finalize = async.wrap(function(_context, _callback) vim.schedule(function() diff --git a/lua/neo-tree/sources/filesystem/lib/fs_watch.lua b/lua/neo-tree/sources/filesystem/lib/fs_watch.lua index 2d9bbf90..2064e065 100644 --- a/lua/neo-tree/sources/filesystem/lib/fs_watch.lua +++ b/lua/neo-tree/sources/filesystem/lib/fs_watch.lua @@ -164,6 +164,21 @@ M.unwatch_git_index = function(path, async) end end +---@generic P : string +---@param path P +---@param callback fun(path: P) +M.on_destroyed = function(path, callback) + local ev = log.assert(uv.new_fs_event()) + ev:start(path, flags, function(err, path, events) + if events.change then + if not uv.fs_stat(path) then + callback(path) + ev:close() + end + end + end) +end + ---Stop watching all directories. This is the nuclear option and it affects all ---sources. M.unwatch_all = function() diff --git a/lua/neo-tree/sources/filesystem/lib/ignored.lua b/lua/neo-tree/sources/filesystem/lib/ignored.lua index e1bed0b5..3c26afed 100644 --- a/lua/neo-tree/sources/filesystem/lib/ignored.lua +++ b/lua/neo-tree/sources/filesystem/lib/ignored.lua @@ -43,9 +43,8 @@ end ---@param items neotree.FileItem[] ---@return string[] results M.mark_ignored = function(state, items) - local config = require("neo-tree").config - local ignore_files = config.filesystem.filtered_items.ignore_files - if not ignore_files or vim.tbl_isempty(config.filesystem.filtered_items.ignore_files) then + local ignore_files = state.filtered_items.ignore_files + if not ignore_files or vim.tbl_isempty(ignore_files) then return {} end ---@type table diff --git a/lua/neo-tree/utils/init.lua b/lua/neo-tree/utils/init.lua index 1296504b..df495bca 100644 --- a/lua/neo-tree/utils/init.lua +++ b/lua/neo-tree/utils/init.lua @@ -14,6 +14,11 @@ end local M = {} +---The file system path separator for the current platform. +M.is_windows = vim.fn.has("win32") == 1 or vim.fn.has("win32unix") == 1 +M.is_macos = vim.fn.has("mac") == 1 +M.path_separator = M.is_windows and "\\" or "/" + local diag_severity_to_string = function(severity) if severity == vim.diagnostic.severity.ERROR then return "Error" @@ -834,7 +839,7 @@ M.open_file = function(state, path, open_cmd, bufnr) result, err = M.force_new_split(state.current_position, escaped_path) end end - if result or err == "Vim(edit):E325: ATTENTION" then + if result or err:find("Vim(edit):E325: ATTENTION") then -- fixes #321 vim.bo[0].buflisted = true events.fire_event(events.FILE_OPENED, path) @@ -894,7 +899,7 @@ M.normalize_path = function(path) return path end ----Check if a path is a subpath of another. +---Check if a path is a subpath of another. In other words, whether the path starts with the base path. ---@param base string The base path. ---@param path string The path to check is a subpath. ---@return boolean path_is_subpath True if it is a subpath, false otherwise. @@ -910,10 +915,10 @@ M.is_subpath = function(base, path) base = M.normalize_path(base) path = M.normalize_path(path) if path:sub(1, #base) == base then - local base_parts = M.split(base, M.path_separator) - local path_parts = M.split(path, M.path_separator) - for i, base_part in ipairs(base_parts) do - if path_parts[i] ~= base_part then + local base_parts = M.split(base:sub(#base), M.path_separator) + local path_parts = M.split(path:sub(#base), M.path_separator) + for i, remaining_parts in ipairs(base_parts) do + if path_parts[i] ~= remaining_parts then return false end end @@ -999,9 +1004,39 @@ end ---Finds all paths that are parents of the current path, naively by removing the tail segments ---@param path string +---@param fast boolean? ---@return fun():string?,string? -M.path_parents = function(path) - path = M.normalize_path(path) +M.path_parents = function(path, fast) + if not fast then + path = M.normalize_path(path) + end + local prefix = M.abspath_prefix(path) + if fast then + local parent = path + local seperator_indices = {} + do + local res + local i = 1 + repeat + res = path:find(M.path_separator, i, true) + seperator_indices[#seperator_indices + 1] = res + if res then + i = res + 1 + end + until not res + end + local i = #seperator_indices + return function() + local idx = seperator_indices[i] + i = i - 1 + if not idx or #parent <= #prefix then + return nil + end + + parent = parent:sub(1, idx - 1) + return parent, parent:sub(idx + 1) + end + end ---@type string? local parent = path local tail @@ -1011,11 +1046,6 @@ M.path_parents = function(path) end end ----The file system path separator for the current platform. -M.is_windows = vim.fn.has("win32") == 1 or vim.fn.has("win32unix") == 1 -M.is_macos = vim.fn.has("mac") == 1 -M.path_separator = M.is_windows and "\\" or "/" - ---Remove the path separator from the end of a path in a cross-platform way. ---@param path string The path to remove the separator from. ---@return string string The path without any trailing separator. @@ -1581,7 +1611,7 @@ local slice = vim.fn.exists("*slice") == 1 and vim.fn.slice -- Function below provided by @akinsho, modified by @pynappo -- https://github.com/nvim-neo-tree/neo-tree.nvim/pull/427#discussion_r924947766 --- TODO: maybe use vim.stf_utf* functions instead of strchars, once neovim updates enough +-- TODO: maybe use vim.str_utf* functions instead of strchars, once neovim updates enough -- Truncate a string based on number of display columns/cells it occupies -- so that multibyte characters are not broken up mid-character