feat(explorer): added git status. Closes #817

This commit is contained in:
Folke Lemaitre 2025-01-31 19:08:30 +01:00
parent 09bf19f15b
commit 5cae48d93c
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
11 changed files with 278 additions and 32 deletions

View file

@ -16,6 +16,7 @@ local expanded = {} ---@type table<string, boolean>
---@field last? boolean
---@field sort? string
---@field internal? boolean internal parent directories not part of fd output
---@field status? string
---@class snacks.picker.explorer.State
---@field cwd string
@ -43,6 +44,9 @@ function State.new(picker)
picker.list.win:on({ "WinEnter", "BufEnter" }, function()
self:follow()
end)
picker.list.win:on("TermClose", function()
self:update()
end, { pattern = "*lazygit" })
-- schedule initial follow
if self.opts.follow_file then
self.on_find = function()
@ -69,7 +73,7 @@ end
---@param path string
function State:show(path)
local picker = self.picker()
if not picker then
if not picker or picker.closed then
return
end
path = vim.fs.normalize(path)
@ -202,6 +206,9 @@ function State:setup(opts, ctx)
end
picker.list:set_target()
self.on_find = function()
if picker.closed then
return
end
for item, idx in picker:iter() do
if not item.internal then
picker.list:view(idx)
@ -220,7 +227,7 @@ end
function State:update(opts)
opts = opts or {}
local picker = self.picker()
if not picker then
if not picker or picker.closed then
return
end
if opts.target ~= false then
@ -439,8 +446,13 @@ end
function M.explorer(opts, ctx)
local state = M.get_state(ctx.picker)
opts = state:setup(opts, ctx)
opts.notify = false
local Git = require("snacks.picker.source.git")
local files = require("snacks.picker.source.files").files(opts, ctx)
local git = Git.status(opts, ctx)
local dirs = {} ---@type table<string, snacks.picker.explorer.Item>
local last = {} ---@type table<snacks.picker.finder.Item, snacks.picker.finder.Item>
@ -456,10 +468,13 @@ function M.explorer(opts, ctx)
local cwd = state.cwd
dirs[cwd] = root
local items = {} ---@type table<string, snacks.picker.explorer.Item>
---@async
return function(cb)
if state.on_find then
ctx.picker.matcher.task:on("done", vim.schedule_wrap(state.on_find))
end
items[cwd] = root
cb(root)
---@param item snacks.picker.explorer.Item
@ -486,10 +501,27 @@ function M.explorer(opts, ctx)
last[parent] = item
end
end
items[item.file] = item
-- add to picker
cb(item)
end
-- gather git status in a separate coroutine,
-- so that both git and fd can run in parallel
local git_status = {} ---@type table<string, string>
local git_async ---@type snacks.picker.Async?
if opts.git_status then
git_async = require("snacks.picker.util.async").new(function()
git(function(item)
local path = Snacks.picker.util.path(item)
if path then
git_status[path] = item.status
end
end)
end)
end
-- get files and directories
files(function(item)
---@cast item snacks.picker.explorer.Item
item.cwd = nil -- we use absolute paths
@ -507,30 +539,46 @@ function M.explorer(opts, ctx)
end
-- Add parents when needed
if item.file:sub(1, #cwd) == cwd and #item.file > #cwd then
local path = item.file
local to = #cwd + 1 ---@type number?
while to do
to = path:find("/", to + 1, true)
if not to then
break
end
local dir = path:sub(1, to - 1)
if not dirs[dir] then
dirs[dir] = {
text = dir,
file = dir,
dir = true,
open = state:is_open(dir),
internal = true,
}
add(dirs[dir])
end
for dir in Snacks.picker.util.parents(item.file, cwd) do
if not dirs[dir] then
dirs[dir] = {
text = dir,
file = dir,
dir = true,
open = state:is_open(dir),
internal = true,
}
add(dirs[dir])
end
end
add(item)
end)
-- wait for git status to finish
if git_async then
git_async:wait()
end
local function add_git_status(path, status)
local item = items[path]
if item then
if item.status then
item.status = Git.merge_status(item.status, status)
else
item.status = status
end
end
end
-- Add git status to files and parents
for path, status in pairs(git_status) do
add_git_status(path, status)
add_git_status(cwd, status)
for dir in Snacks.picker.util.parents(path, cwd) do
add_git_status(dir, status)
end
end
end
end

View file

@ -253,4 +253,91 @@ function M.stash(opts, ctx)
}, ctx)
end
---@class snacks.picker.git.Status
---@field xy string
---@field status "modified" | "deleted" | "added" | "untracked" | "renamed" | "copied" | "ignored"
---@field unmerged? boolean
---@field staged? boolean
---@field priority? number
---@param xy string
---@return snacks.picker.git.Status
function M.git_status(xy)
local ss = {
A = "added",
D = "deleted",
M = "modified",
R = "renamed",
C = "copied",
["?"] = "untracked",
["!"] = "ignored",
}
local prios = "!?CRDAM"
---@param status string
---@param unmerged? boolean
---@param staged? boolean
local function s(status, unmerged, staged)
local prio = (prios:find(status, 1, true) or 0) + (unmerged and 20 or 0)
if not staged and not status:find("[!]") then
prio = prio + 10
end
return {
xy = xy,
status = ss[status],
unmerged = unmerged,
staged = staged,
priority = prio,
}
end
---@param c string
local function f(c)
return xy:gsub("T", "M"):match(c) --[[@as string?]]
end
if f("%?%?") then
return s("?")
elseif f("!!") then
return s("!")
elseif f("UU") then
return s("M", true)
elseif f("DD") then
return s("D", true)
elseif f("AA") then
return s("A", true)
elseif f("U") then
return s(f("A") and "A" or "D", true)
end
local m = f("^([MADRC])")
if m then
return s(m, nil, true)
end
m = f("([MADRC])$")
if m then
return s(m)
end
error("unknown status: " .. xy)
end
---@param a string
---@param b string
function M.merge_status(a, b)
if a == b then
return a
end
local as = M.git_status(a)
local bs = M.git_status(b)
if as.unmerged or bs.unmerged then
return as.priority > bs.priority and as.xy or bs.xy
end
if not as.staged or not bs.staged then
if as.status == bs.status then
return as.staged and b or a
end
return " M"
end
return "M "
end
return M