-- Custom module to config a few things for the mini.files: -- - git highlighting -- - refresh -- - file open in external programs local M = {} local uv = vim.uv or vim.loop local cache_ttl_ms = 1000 local status_cache = {} local git_root_cache = {} local known_git_roots = {} local function is_path_inside(path, root) return path == root or vim.startswith(path, root .. "/") end local function new_status_node() return { children = {}, status = nil, priority = 0, is_dir_ignored = false, is_dir_untracked = false, } end local function is_conflict(status) if #status < 2 then return false end local index_status = status:sub(1, 1) local worktree_status = status:sub(2, 2) return index_status == "U" or worktree_status == "U" or status == "AA" or status == "DD" end local function status_priority(status) -- Folder aggregation follows the Zed-inspired order create < edit < -- delete. `subtree_status()` uses the greatest priority, so deletion -- must outrank every create or edit status. if #status < 2 then return 0 end if is_conflict(status) then return 8 end if status == "!!" then return 1 end if status == "??" then return 2 end local index_status = status:sub(1, 1) local worktree_status = status:sub(2, 2) if index_status == "D" or worktree_status == "D" then return 4 end if index_status == "A" then return 2 end if index_status == "R" or index_status == "C" then return 3 end if index_status == "M" or worktree_status == "M" then return 3 end return 0 end local function status_highlight(status) if not status then return nil end if status == "!!" then return "Comment" end if is_conflict(status) then return "MiniDiffSignDelete" end if status == "??" or status:sub(1, 1) == "A" then return "MiniDiffSignAdd" end if status:sub(1, 1) == "D" or status:sub(2, 2) == "D" then return "MiniDiffSignDelete" end if status:sub(1, 1) == "M" or status:sub(1, 1) == "R" or status:sub(1, 1) == "C" or status:sub(2, 2) == "M" then return "MiniDiffSignChange" end return nil end local function insert_status(root, relative_path, status, is_directory) local priority = status_priority(status) if priority == 0 then return end local node = root local segments = vim.split(relative_path, "/", { plain = true, trimempty = true }) for index, segment in ipairs(segments) do node.children[segment] = node.children[segment] or new_status_node() node = node.children[segment] if index == #segments then if priority > node.priority then node.status = status node.priority = priority end if is_directory and status == "!!" then node.is_dir_ignored = true end if is_directory and status == "??" then node.is_dir_untracked = true end end end end local function subtree_status(node) local best_status = node.status local best_priority = node.priority for _, child in pairs(node.children) do local child_status, child_priority = subtree_status(child) if child_priority > best_priority then best_status = child_status best_priority = child_priority end end return best_status, best_priority end local function parse_status(output, root) local data = { root = root, statuses = {}, trie = new_status_node(), timestamp = uv.now(), } local records = vim.split(output, "\0", { plain = true, trimempty = true }) local record_index = 1 while record_index <= #records do local record = records[record_index] if #record >= 4 then local status = record:sub(1, 2) local relative_path = record:sub(4) -- With -z, rename/copy output contains the new path followed by -- the old path. Oil highlights the new path only. if status:sub(1, 1) == "R" or status:sub(1, 1) == "C" then record_index = record_index + 1 end local is_directory = relative_path:sub(-1) == "/" relative_path = relative_path:gsub("^%./", ""):gsub("/$", "") if relative_path ~= "" then local absolute_path = vim.fs.normalize(vim.fs.joinpath(root, relative_path)) data.statuses[absolute_path] = status insert_status(data.trie, relative_path, status, is_directory) end end record_index = record_index + 1 end return data end local function find_cached_root(path) local result for root in pairs(known_git_roots) do if is_path_inside(path, root) and (result == nil or #root > #result) then result = root end end return result end local function find_git_root(path) local cached_root = find_cached_root(path) if cached_root ~= nil then return cached_root end local cached = git_root_cache[path] if cached ~= nil then return cached or nil end local result = vim.system({ "git", "-C", path, "rev-parse", "--show-toplevel" }, { text = true }):wait() if result.code ~= 0 then git_root_cache[path] = false return nil end local root = vim.trim(result.stdout) if root == "" then git_root_cache[path] = false return nil end root = vim.fs.normalize(root) git_root_cache[path] = root known_git_roots[root] = true return root end local function get_status_data(fs_entry) local path = fs_entry.path if fs_entry.fs_type ~= "directory" then path = vim.fs.dirname(path) end path = vim.fs.normalize(path) local root = find_git_root(path) if root == nil then return nil end local cached = status_cache[root] if cached ~= nil and uv.now() - cached.timestamp < cache_ttl_ms then return cached end local result = vim.system({ "git", "-C", root, "--no-optional-locks", "status", "--porcelain", "--ignored", "-z", }, { text = true }):wait() if result.code ~= 0 then status_cache[root] = parse_status("", root) return status_cache[root] end local data = parse_status(result.stdout, root) status_cache[root] = data return data end local function lookup_status(data, fs_entry) local path = vim.fs.normalize(fs_entry.path) local direct_status = data.statuses[path] if fs_entry.fs_type ~= "directory" and direct_status ~= nil then return direct_status end if not is_path_inside(path, data.root) then return nil end local relative_path = path:sub(#data.root + 2) local node = data.trie for _, segment in ipairs(vim.split(relative_path, "/", { plain = true, trimempty = true })) do if node.is_dir_ignored then return "!!" end if node.is_dir_untracked then return "??" end node = node.children[segment] if node == nil then return nil end end if node.is_dir_ignored then return "!!" end if node.is_dir_untracked then return "??" end if next(node.children) == nil then return node.status end local status = subtree_status(node) -- Match the Oil configuration: an ignored descendant does not grey its -- parent directory unless Git reported that directory as ignored. if fs_entry.fs_type == "directory" and status == "!!" then return nil end return status end function M.filter(fs_entry) return fs_entry.name ~= ".git" end local function get_entry_status(fs_entry) local data = get_status_data(fs_entry) if data == nil then return nil end return lookup_status(data, fs_entry) end function M.highlight(fs_entry) local status = get_entry_status(fs_entry) return status_highlight(status) or (fs_entry.fs_type == "directory" and "MiniFilesDirectory" or "MiniFilesFile") end function M.invalidate() status_cache = {} git_root_cache = {} known_git_roots = {} end local function refresh() M.invalidate() if _G.MiniFiles == nil or MiniFiles.get_explorer_state() == nil then return end vim.schedule(function() if MiniFiles.get_explorer_state() ~= nil then MiniFiles.refresh() end end) end local function refresh_filetree() M.invalidate() if _G.MiniFiles == nil or MiniFiles.get_explorer_state() == nil then return end vim.schedule(function() if MiniFiles.get_explorer_state() ~= nil then MiniFiles.refresh({ content = { filter = M.filter, highlight = M.highlight, }, }) end end) end local function open_external() local fs_entry = MiniFiles.get_fs_entry() if fs_entry == nil then vim.notify("No file system entry under the cursor", vim.log.levels.WARN) return end vim.ui.open(fs_entry.path) end function M.setup() local group = vim.api.nvim_create_augroup("romanzy_mini_files_git", { clear = true }) vim.api.nvim_create_autocmd("User", { group = group, pattern = "MiniFilesBufferCreate", callback = function(args) if args.data == nil then return end for _, lhs in ipairs({ "gR", "" }) do vim.keymap.set("n", lhs, refresh_filetree, { buffer = args.data.buf_id, desc = "refresh mini.files filetree", }) end vim.keymap.set("n", "gx", open_external, { buffer = args.data.buf_id, desc = "open mini.files entry externally", }) end, }) vim.api.nvim_create_autocmd({ "BufWritePost", "FocusGained", "TermClose" }, { group = group, callback = refresh, }) vim.api.nvim_create_autocmd("User", { group = group, pattern = { "GitSignsUpdate", "LazyGitClosed", "MiniFilesActionCreate", "MiniFilesActionDelete", "MiniFilesActionRename", "MiniFilesActionCopy", "MiniFilesActionMove", }, callback = refresh, }) return { content = { filter = M.filter, highlight = M.highlight, }, } end return M