mirror of
https://github.com/folke/snacks.nvim
synced 2025-08-04 18:58:12 +00:00
feat(explorer): added git status. Closes #817
This commit is contained in:
parent
09bf19f15b
commit
5cae48d93c
11 changed files with 278 additions and 32 deletions
|
@ -299,9 +299,9 @@ local defaults = {
|
|||
nowait = " "
|
||||
},
|
||||
tree = {
|
||||
vertical = "│ ",
|
||||
middle = "├╴",
|
||||
last = "└╴",
|
||||
vertical = "│ ",
|
||||
middle = "├╴",
|
||||
last = "└╴",
|
||||
},
|
||||
undo = {
|
||||
saved = " ",
|
||||
|
@ -316,7 +316,16 @@ local defaults = {
|
|||
-- selected = " ",
|
||||
},
|
||||
git = {
|
||||
commit = " ",
|
||||
enabled = true, -- show git icons
|
||||
commit = " ", -- used by git log
|
||||
staged = "●", -- staged changes. always overrides the type icons
|
||||
added = "",
|
||||
deleted = "",
|
||||
ignored = " ",
|
||||
modified = "○",
|
||||
renamed = "",
|
||||
unmerged = " ",
|
||||
untracked = "?",
|
||||
},
|
||||
diagnostics = {
|
||||
Error = " ",
|
||||
|
|
|
@ -55,13 +55,16 @@ Snacks.util.set_hl({
|
|||
GitIssue = "Number",
|
||||
GitType = "Title", -- conventional commit type
|
||||
GitScope = "Italic", -- conventional commit scope
|
||||
GitStatus = "NonText",
|
||||
GitStatus = "Special",
|
||||
GitStatusAdded = "Added",
|
||||
GitStatusModified = "Changed",
|
||||
GitStatusModified = "DiagnosticWarn",
|
||||
GitStatusDeleted = "Removed",
|
||||
GitStatusRenamed = "SnacksPickerGitStatus",
|
||||
GitStatusCopied = "SnacksPickerGitStatus",
|
||||
GitStatusUntracked = "SnacksPickerGitStatus",
|
||||
GitStatusUntracked = "NonText",
|
||||
GitStatusIgnored = "NonText",
|
||||
GitStatusUnmerged = "DiagnosticError",
|
||||
GitStatusStaged = "DiagnosticHint",
|
||||
ManSection = "Number",
|
||||
PickWin = "Search",
|
||||
PickWinCurrent = "CurSearch",
|
||||
|
|
|
@ -40,10 +40,12 @@ M.buffers = {
|
|||
---@class snacks.picker.explorer.Config: snacks.picker.files.Config|{}
|
||||
---@field follow_file? boolean follow the file from the current buffer
|
||||
---@field tree? boolean show the file tree (default: true)
|
||||
---@field git_status? boolean show git status (default: true)
|
||||
M.explorer = {
|
||||
finder = "explorer",
|
||||
sort = { fields = { "sort" } },
|
||||
tree = true,
|
||||
git_status = true,
|
||||
supports_live = true,
|
||||
follow_file = true,
|
||||
focus = "list",
|
||||
|
|
|
@ -85,7 +85,7 @@ function M:run(picker)
|
|||
-- PERF: fast path for empty pattern
|
||||
if not (self.sorting or picker.finder.task:running()) then
|
||||
picker.list.items = picker.finder.items
|
||||
picker:update()
|
||||
picker:update({ force = true })
|
||||
return
|
||||
end
|
||||
|
||||
|
@ -151,7 +151,7 @@ function M:run(picker)
|
|||
end
|
||||
until idx >= #picker.finder.items and not picker.finder.task:running()
|
||||
|
||||
picker:update()
|
||||
picker:update({ force = true })
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
|
@ -623,7 +623,9 @@ function M:progress(ms)
|
|||
end
|
||||
|
||||
---@hide
|
||||
function M:update()
|
||||
---@param opts? {force?: boolean}
|
||||
function M:update(opts)
|
||||
opts = opts or {}
|
||||
if self.closed then
|
||||
return
|
||||
end
|
||||
|
@ -631,7 +633,7 @@ function M:update()
|
|||
-- Schedule the update if we are in a fast event
|
||||
if vim.in_fast_event() then
|
||||
return vim.schedule(function()
|
||||
self:update()
|
||||
self:update(opts)
|
||||
end)
|
||||
end
|
||||
|
||||
|
@ -675,6 +677,9 @@ function M:update()
|
|||
if not self.list.paused then
|
||||
self.input:update()
|
||||
end
|
||||
if opts.force then
|
||||
self.list.dirty = true
|
||||
end
|
||||
self.list:update()
|
||||
end
|
||||
end
|
||||
|
|
|
@ -102,6 +102,10 @@ function M.file(item, picker)
|
|||
|
||||
vim.list_extend(ret, M.filename(item, picker))
|
||||
|
||||
if item.status then
|
||||
vim.list_extend(ret, M.file_git_status(item, picker))
|
||||
end
|
||||
|
||||
if item.comment then
|
||||
table.insert(ret, { item.comment, "SnacksPickerComment" })
|
||||
table.insert(ret, { " " })
|
||||
|
@ -497,6 +501,37 @@ function M.git_status(item, picker)
|
|||
return ret
|
||||
end
|
||||
|
||||
function M.file_git_status(item, picker)
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
local status = require("snacks.picker.source.git").git_status(item.status)
|
||||
|
||||
local hl = "SnacksPickerGitStatus"
|
||||
if status.unmerged then
|
||||
hl = "SnacksPickerGitStatusUnmerged"
|
||||
elseif status.staged then
|
||||
hl = "SnacksPickerGitStatusStaged"
|
||||
else
|
||||
hl = "SnacksPickerGitStatus" .. status.status:sub(1, 1):upper() .. status.status:sub(2)
|
||||
end
|
||||
|
||||
local icon = status.status:sub(1, 1):upper()
|
||||
icon = status.status == "untracked" and "?" or status.status == "ignored" and "!" or icon
|
||||
if picker.opts.icons.git.enabled then
|
||||
icon = picker.opts.icons.git[status.status] or icon --[[@as string]]
|
||||
if status.staged then
|
||||
icon = picker.opts.icons.git.staged
|
||||
end
|
||||
end
|
||||
|
||||
ret[#ret + 1] = {
|
||||
col = 0,
|
||||
virt_text = { { icon, hl }, { " " } },
|
||||
virt_text_pos = "right_align",
|
||||
hl_mode = "combine",
|
||||
}
|
||||
return ret
|
||||
end
|
||||
|
||||
function M.register(item)
|
||||
local ret = {} ---@type snacks.picker.Highlight[]
|
||||
ret[#ret + 1] = { " " }
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -189,6 +189,9 @@ end
|
|||
|
||||
---@async
|
||||
function Async:wait()
|
||||
if not self:running() then
|
||||
return self
|
||||
end
|
||||
if coroutine.running() == self._co then
|
||||
error("Cannot wait on self")
|
||||
end
|
||||
|
|
|
@ -411,4 +411,19 @@ function M.pick_win(opts)
|
|||
end
|
||||
end
|
||||
|
||||
---@param path string
|
||||
---@param cwd? string
|
||||
---@return fun(): string?
|
||||
function M.parents(path, cwd)
|
||||
cwd = cwd or uv.cwd()
|
||||
if not (cwd and path:sub(1, #cwd) == cwd and #path > #cwd) then
|
||||
return function() end
|
||||
end
|
||||
local to = #cwd + 1 ---@type number?
|
||||
return function()
|
||||
to = path:find("/", to + 1, true)
|
||||
return to and path:sub(1, to - 1) or nil
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
39
tests/picker/git_status_spec.lua
Normal file
39
tests/picker/git_status_spec.lua
Normal file
|
@ -0,0 +1,39 @@
|
|||
---@module 'luassert'
|
||||
|
||||
local Git = require("snacks.picker.source.git")
|
||||
|
||||
describe("git status", function()
|
||||
-- git status codes are always 2 characters
|
||||
local tests = {
|
||||
-- Unmerged cases
|
||||
["AA"] = { xy = "AA", status = "added", unmerged = true },
|
||||
["UU"] = { xy = "UU", status = "modified", unmerged = true },
|
||||
["AU"] = { xy = "AU", status = "added", unmerged = true },
|
||||
["DD"] = { xy = "DD", status = "deleted", unmerged = true },
|
||||
["UD"] = { xy = "UD", status = "deleted", unmerged = true },
|
||||
["DU"] = { xy = "DU", status = "deleted", unmerged = true },
|
||||
["UA"] = { xy = "UA", status = "added", unmerged = true },
|
||||
|
||||
-- Regular cases
|
||||
[" M"] = { xy = " M", status = "modified" },
|
||||
[" D"] = { xy = " D", status = "deleted" },
|
||||
[" R"] = { xy = " R", status = "renamed" },
|
||||
["??"] = { xy = "??", status = "untracked" },
|
||||
["!!"] = { xy = "!!", status = "ignored" },
|
||||
|
||||
-- Staged cases
|
||||
["M "] = { xy = "M ", status = "modified", staged = true },
|
||||
["T "] = { xy = "T ", status = "modified", staged = true },
|
||||
["D "] = { xy = "D ", status = "deleted", staged = true },
|
||||
["A "] = { xy = "A ", status = "added", staged = true },
|
||||
["AD"] = { xy = "AD", status = "added", staged = true },
|
||||
["C "] = { xy = "C ", status = "copied", staged = true },
|
||||
}
|
||||
for _, test in pairs(tests) do
|
||||
it("should parse `" .. test.xy .. "`", function()
|
||||
local status = Git.git_status(test.xy)
|
||||
status.priority = nil
|
||||
assert.are.same(test, status)
|
||||
end)
|
||||
end
|
||||
end)
|
Loading…
Add table
Add a link
Reference in a new issue