Meh I'll figure out submodules later

This commit is contained in:
mustard 2025-09-16 01:01:02 +02:00
parent 4ca9d44a90
commit 8cb281f436
352 changed files with 66107 additions and 0 deletions

View file

@ -0,0 +1,183 @@
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[]
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
if not folders[folder] then
folders[folder] = {}
end
table.insert(folders[folder], item.path)
end
end
local function process_result(result)
if utils.is_windows then
--on Windows, git seems to return quotes and double backslash "path\\directory"
result = vim.tbl_map(function(item)
item = item:gsub("\\\\", "\\")
return item
end, result)
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", #result, "items to see which ones are directories")
for i, item in ipairs(result) do
local stat = uv.fs_stat(item)
if stat and stat.type == "directory" then
result[i] = item .. sep
end
end
end
result = vim.tbl_map(function(item)
-- 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, result)
return result
end
local function finalize(all_results)
local show_gitignored = state.filtered_items and state.filtered_items.hide_gitignored == false
log.trace("IGNORED: Comparing results to mark items as ignored:", show_gitignored)
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
item.filtered_by.show_gitignored = show_gitignored
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
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 result
if code ~= 0 then
log.debug("Failed to load ignored files for", folder, ":", self:stderr_result())
result = {}
else
result = self:result()
end
vim.list_extend(all_results, process_result(result))
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_result(result))
end
finalize(all_results)
return all_results
end
end
return M

View file

@ -0,0 +1,13 @@
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,
}
return M

View file

@ -0,0 +1,350 @@
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<string, string>
---@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

View file

@ -0,0 +1,66 @@
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