feat(git): add custom status sidebar and diff viewer

This commit is contained in:
2026-04-27 10:44:59 +02:00
parent bc93d6e2f7
commit 5a3e39574d
8 changed files with 1111 additions and 115 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ syntax match gitlogGraphLine /^[*|\\\/_ ]\+$/
\ contains=gitlogGraph
highlight default link gitlogGraph Comment
highlight default link gitlogHash Identifier
highlight default link gitlogHash GitSha
highlight default link gitlogDate Number
highlight default link gitlogAuthor String
highlight default link gitlogRef Constant
+73
View File
@@ -0,0 +1,73 @@
local repo = require("git.repo")
local HIGHLIGHTS = {
GitDeleted = "Removed",
GitIgnored = "Comment",
GitUnstaged = "Changed",
GitRenamed = "GitStaged",
GitSha = "Identifier",
GitStaged = "Constant",
GitUnmerged = "Todo",
GitUnpulled = "Removed",
GitUnpushed = "Added",
GitUntracked = "Added",
}
local M = {}
function M.status()
return vim.b.git_status or ""
end
---@param path string
---@return string?
function M.head(path)
return repo.head(path)
end
function M.setup()
for name, link in pairs(HIGHLIGHTS) do
vim.api.nvim_set_hl(0, name, { link = link, default = true })
end
vim.filetype.add({
pattern = {
["git://[^/]+/(.+)"] = function(_, bufnr, inner)
return vim.filetype.match({ filename = inner, buf = bufnr })
end,
},
})
local group = vim.api.nvim_create_augroup("ow.git", { clear = true })
vim.api.nvim_create_autocmd(
{ "BufReadPost", "BufNewFile", "BufWritePost", "FileChangedShellPost" },
{
group = group,
callback = function(args)
repo.refresh_buf(args.buf)
end,
}
)
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
group = group,
callback = function(args)
repo.unregister(args.buf)
end,
})
vim.api.nvim_create_autocmd("FocusGained", {
group = group,
callback = function()
repo.refresh_buf(vim.api.nvim_get_current_buf())
end,
})
vim.api.nvim_create_autocmd("VimLeavePre", {
group = group,
callback = function()
repo.stop_all()
end,
})
vim.keymap.set("n", "<leader>gg", function()
require("git.status_win").toggle()
end, { desc = "Toggle git status sidebar" })
end
return M
+73 -76
View File
@@ -1,15 +1,5 @@
local util = require("util")
local HIGHLIGHTS = {
GitDeleted = "Statement",
GitDirty = "Statement",
GitIgnored = "Comment",
GitMerge = "Constant",
GitNew = "PreProc",
GitRenamed = "PreProc",
GitStaged = "Constant",
}
local UNMERGED = {
DD = true,
AU = true,
@@ -20,36 +10,44 @@ local UNMERGED = {
UU = true,
}
---@param code string
---@return string?
local function format(code)
---@param code string porcelain v1 XY code
---@return string? char
---@return string? hl_group
local function indicator(code)
if code == "" then
return nil
end
local char, hl
if code == "??" then
char, hl = "?", "GitNew"
elseif code == "!!" then
char, hl = "!", "GitIgnored"
elseif UNMERGED[code] then
char, hl = "U", "GitMerge"
else
local x, y = code:sub(1, 1), code:sub(2, 2)
if x == "R" or y == "R" then
char, hl = "R", "GitRenamed"
elseif y == "M" or y == "T" then
char, hl = "M", "GitDirty"
elseif y == "D" then
char, hl = "D", "GitDeleted"
elseif y == " " and x == "D" then
char, hl = "D", "GitStaged"
elseif y == " " and x == "A" then
char, hl = "A", "GitStaged"
elseif y == " " and x ~= " " then
char, hl = "M", "GitStaged"
else
char, hl = "M", "GitDirty"
end
return "?", "GitUntracked"
end
if code == "!!" then
return "!", "GitIgnored"
end
if UNMERGED[code] then
return "U", "GitUnmerged"
end
local x, y = code:sub(1, 1), code:sub(2, 2)
if x == "R" or y == "R" then
return "R", "GitRenamed"
end
if y == " " and x ~= " " then
return x, "GitStaged"
end
if y == "D" then
return "D", "GitDeleted"
end
if y == "M" or y == "T" then
return "M", "GitUnstaged"
end
return "M", "GitUnstaged"
end
---@param code string
---@return string?
local function format(code)
local char, hl = indicator(code)
if not char then
return nil
end
return string.format("%%#%s#%s%%*", hl, char)
end
@@ -243,46 +241,45 @@ local function refresh_buf(buf)
repo:refresh()
end
local M = {}
function M.status()
return vim.b.git_status or ""
end
function M.setup()
for name, link in pairs(HIGHLIGHTS) do
vim.api.nvim_set_hl(0, name, { link = link, default = true })
local function stop_all()
for _, repo in pairs(repo_by_gitdir) do
repo:stop_watcher()
end
local group = vim.api.nvim_create_augroup("ow.git", { clear = true })
vim.api.nvim_create_autocmd(
{ "BufReadPost", "BufNewFile", "BufWritePost", "FileChangedShellPost" },
{
group = group,
callback = function(args)
refresh_buf(args.buf)
end,
}
)
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
group = group,
callback = function(args)
unregister(args.buf)
end,
})
vim.api.nvim_create_autocmd("FocusGained", {
group = group,
callback = function()
refresh_buf(vim.api.nvim_get_current_buf())
end,
})
vim.api.nvim_create_autocmd("VimLeavePre", {
group = group,
callback = function()
for _, repo in pairs(repo_by_gitdir) do
repo:stop_watcher()
end
end,
})
end
return M
---@param path string
---@return string?
local function head(path)
local gitdir = resolve(path)
if not gitdir then
return nil
end
local f = io.open(vim.fs.joinpath(gitdir, "HEAD"), "r")
if not f then
return nil
end
local first = f:read("*l")
f:close()
if not first then
return nil
end
local branch = first:match("^ref:%s*refs/heads/(%S+)")
if branch then
return branch
end
local sha = first:match("^(%x+)")
if sha then
return sha:sub(1, 7)
end
return nil
end
return {
UNMERGED = UNMERGED,
head = head,
indicator = indicator,
refresh_buf = refresh_buf,
resolve = resolve,
stop_all = stop_all,
unregister = unregister,
}
+915
View File
@@ -0,0 +1,915 @@
local log = require("log")
local repo = require("git.repo")
local M = {}
local SECTIONS = {
"Untracked",
"Unstaged",
"Staged",
"Unmerged",
"Unpushed",
"Unpulled",
}
local SIDEBAR_WIDTH = 50
---@class ow.Git.FileEntry
---@field section string
---@field path string
---@field orig string?
---@field x string?
---@field y string?
---@class ow.Git.CommitEntry
---@field section string
---@field sha string
---@field subject string?
---@alias ow.Git.StatusEntry ow.Git.FileEntry | ow.Git.CommitEntry
---@class ow.Git.StatusState
---@field gitdir string
---@field worktree string
---@field lines table<integer, ow.Git.StatusEntry>
---@field diff_left_win integer?
---@field diff_right_win integer?
---@field user_aucmd integer?
---@field last_shown_key string?
---@type table<integer, ow.Git.StatusState>
local state = {}
local group =
vim.api.nvim_create_augroup("ow.git.status_win", { clear = false })
local ns = vim.api.nvim_create_namespace("ow.git.status_win")
---@return integer? win
---@return integer? bufnr
local function find_sidebar()
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
local buf = vim.api.nvim_win_get_buf(win)
if vim.bo[buf].filetype == "gitstatus" then
return win, buf
end
end
end
---@param entry ow.Git.StatusEntry
---@return string?
local function entry_code(entry)
if entry.section == "Untracked" then
return "??"
elseif entry.section == "Unmerged" then
return (entry.x or " ") .. (entry.y or " ")
elseif entry.section == "Staged" then
return (entry.x or " ") .. " "
elseif entry.section == "Unstaged" then
return " " .. (entry.y or " ")
end
end
---@param entry ow.Git.StatusEntry
---@return string? line
---@return string? hl_group
---@return integer? hl_len byte length of the symbol portion at column 2
local function format_entry(entry)
if entry.sha then
return string.format(" %s %s", entry.sha, entry.subject or ""),
"GitSha",
#entry.sha
end
local code = entry_code(entry)
if not code then
return nil
end
local char, hl = repo.indicator(code)
if not char then
return nil
end
local label = entry.orig and (entry.orig .. " -> " .. entry.path)
or entry.path
return string.format(" %s %s", char, label), hl, #char
end
---@class ow.Git.BranchInfo
---@field head string?
---@field upstream string?
---@field ahead integer
---@field behind integer
---@param line string '## branch.line' from porcelain v1
---@return ow.Git.BranchInfo
local function parse_branch_line(line)
local info = { ahead = 0, behind = 0 }
local content = line:sub(4)
local arrow = content:find("...", 1, true)
if not arrow then
info.head = content
return info
end
info.head = content:sub(1, arrow - 1)
local rest = content:sub(arrow + 3)
local bracket = rest:find(" %[")
if not bracket then
info.upstream = rest
return info
end
info.upstream = rest:sub(1, bracket - 1)
local inside = rest:match("%[([^%]]+)%]")
if inside then
info.ahead = (tonumber(inside:match("ahead (%d+)")) or 0) --[[@as integer]]
info.behind = (tonumber(inside:match("behind (%d+)")) or 0) --[[@as integer]]
end
return info
end
---@param worktree string
---@param callback fun(branch: ow.Git.BranchInfo, groups: table<string, ow.Git.StatusEntry[]>)
local function fetch_status(worktree, callback)
vim.system({
"git",
"-c",
"core.quotePath=false",
"status",
"--porcelain=v1",
"--branch",
}, { cwd = worktree, text = true }, function(obj)
vim.schedule(function()
local branch = { ahead = 0, behind = 0 }
local groups = {
Untracked = {},
Unstaged = {},
Staged = {},
Unmerged = {},
Unpushed = {},
Unpulled = {},
}
if obj.code == 0 then
for line in (obj.stdout or ""):gmatch("[^\r\n]+") do
if line:sub(1, 2) == "##" then
branch = parse_branch_line(line)
else
local x = line:sub(1, 1)
local y = line:sub(2, 2)
local rest = line:sub(4)
local orig
local arrow = rest:find(" -> ", 1, true)
if arrow then
orig = rest:sub(1, arrow - 1)
rest = rest:sub(arrow + 4)
end
local entry = {
section = nil,
path = rest,
orig = orig,
x = x,
y = y,
}
if x == "?" and y == "?" then
entry.section = "Untracked"
table.insert(groups.Untracked, entry)
elseif repo.UNMERGED[x .. y] then
entry.section = "Unmerged"
table.insert(groups.Unmerged, entry)
else
if x ~= " " then
table.insert(
groups.Staged,
vim.tbl_extend(
"force",
entry,
{ section = "Staged" }
)
)
end
if y ~= " " then
table.insert(
groups.Unstaged,
vim.tbl_extend(
"force",
entry,
{ section = "Unstaged" }
)
)
end
end
end
end
end
local fetches = {}
if branch.upstream and branch.ahead > 0 then
table.insert(
fetches,
{ section = "Unpushed", range = "@{upstream}..HEAD" }
)
end
if branch.upstream and branch.behind > 0 then
table.insert(
fetches,
{ section = "Unpulled", range = "HEAD..@{upstream}" }
)
end
if #fetches == 0 then
callback(branch, groups)
return
end
local pending = #fetches
for _, f in ipairs(fetches) do
vim.system({
"git",
"log",
"--format=%h %s",
f.range,
}, { cwd = worktree, text = true }, function(
log_obj
)
vim.schedule(function()
if log_obj.code == 0 then
for line in
(log_obj.stdout or ""):gmatch("[^\r\n]+")
do
local sha, subject =
line:match("^(%S+)%s+(.+)$")
if sha then
table.insert(groups[f.section], {
section = f.section,
sha = sha,
subject = subject,
})
end
end
end
pending = pending - 1
if pending == 0 then
callback(branch, groups)
end
end)
end)
end
end)
end)
end
---@param bufnr integer
---@param branch ow.Git.BranchInfo
---@param groups table<string, ow.Git.StatusEntry[]>
local function render(bufnr, branch, groups)
local lines = { "Head: " .. (branch.head or "?") }
if branch.upstream then
local push = "Push: " .. branch.upstream
if branch.ahead > 0 then
push = push .. " +" .. branch.ahead
end
if branch.behind > 0 then
push = push .. " -" .. branch.behind
end
table.insert(lines, push)
end
table.insert(lines, "")
local meta = {}
local marks = {}
for _, section in ipairs(SECTIONS) do
local entries = groups[section]
if entries and #entries > 0 then
table.insert(lines, string.format("%s (%d)", section, #entries))
for _, entry in ipairs(entries) do
local line, hl, hl_len = format_entry(entry)
if line then
table.insert(lines, line)
meta[#lines] = entry
if hl and hl_len then
table.insert(marks, {
row = #lines - 1,
col = 2,
end_col = 2 + hl_len,
hl = hl,
})
end
end
end
table.insert(lines, "")
end
end
vim.bo[bufnr].modifiable = true
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].modifiable = false
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
for _, m in ipairs(marks) do
vim.api.nvim_buf_set_extmark(bufnr, ns, m.row, m.col, {
end_col = m.end_col,
hl_group = m.hl,
})
end
state[bufnr].lines = meta
end
---@param bufnr integer
local function refresh(bufnr)
local s = state[bufnr]
if not s then
return
end
local saved_path, saved_sha
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(win) == bufnr then
local lnum = vim.api.nvim_win_get_cursor(win)[1]
local entry = s.lines[lnum]
if entry then
saved_path = entry.path
saved_sha = entry.sha
end
break
end
end
fetch_status(s.worktree, function(branch, groups)
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
render(bufnr, branch, groups)
if not saved_path and not saved_sha then
return
end
for lnum, entry in pairs(s.lines) do
if
(saved_path and entry.path == saved_path)
or (saved_sha and entry.sha == saved_sha)
then
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(win) == bufnr then
pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 })
end
end
break
end
end
end)
end
---@param bufnr integer
---@return ow.Git.StatusState?
---@return ow.Git.StatusEntry?
local function current_entry(bufnr)
local s = state[bufnr]
if not s then
return nil, nil
end
local lnum = vim.api.nvim_win_get_cursor(0)[1]
return s, s.lines[lnum]
end
---@param buf integer
---@param worktree string
---@param path string
local function attach_index_writer(buf, worktree, path)
vim.api.nvim_create_autocmd("BufWriteCmd", {
buffer = buf,
callback = function()
local body = table.concat(
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
"\n"
) .. "\n"
local hash = vim.system(
{ "git", "hash-object", "-w", "--stdin" },
{ cwd = worktree, stdin = body, text = true }
):wait()
if hash.code ~= 0 then
log.error("git hash-object failed: %s", hash.stderr or "")
return
end
local sha = vim.trim(hash.stdout or "")
local mode = "100644"
local ls = vim.system(
{ "git", "ls-files", "-s", "--", path },
{ cwd = worktree, text = true }
):wait()
if ls.code == 0 and ls.stdout then
local m = ls.stdout:match("^(%d+)")
if m then
mode = m
end
end
local upd = vim.system({
"git",
"update-index",
"--cacheinfo",
mode .. "," .. sha .. "," .. path,
}, { cwd = worktree, text = true }):wait()
if upd.code ~= 0 then
log.error("git update-index failed: %s", upd.stderr or "")
return
end
vim.bo[buf].modified = false
end,
})
end
---@param worktree string
---@param ref string '' for index, 'HEAD' for HEAD
---@param path string
---@param is_index boolean? true to hook :w to update the git index
---@return integer
local function git_show_buf(worktree, ref, path, is_index)
local result = vim.system(
{ "git", "show", ref .. ":" .. path },
{ cwd = worktree, text = true }
):wait()
local content = result.code == 0 and (result.stdout or "") or ""
local lines = vim.split(content, "\n", { plain = true, trimempty = false })
if #lines > 0 and lines[#lines] == "" then
table.remove(lines)
end
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].buftype = is_index and "acwrite" or "nofile"
vim.bo[buf].bufhidden = "wipe"
vim.bo[buf].swapfile = false
if not is_index then
vim.bo[buf].modifiable = false
end
if is_index then
attach_index_writer(buf, worktree, path)
end
vim.bo[buf].modified = false
return buf
end
---@return integer
local function empty_buf()
local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].buftype = "nofile"
vim.bo[buf].bufhidden = "wipe"
vim.bo[buf].swapfile = false
vim.bo[buf].modifiable = false
vim.bo[buf].modified = false
return buf
end
---@param abs_path string
---@return integer
local function load_file_buf(abs_path)
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if
vim.api.nvim_buf_is_loaded(buf)
and vim.api.nvim_buf_get_name(buf) == abs_path
then
return buf
end
end
local buf = vim.fn.bufadd(abs_path)
vim.fn.bufload(buf)
return buf
end
---@class ow.Git.DiffSide
---@field buf integer
---@field name string?
---@class ow.Git.DiffPair
---@field left ow.Git.DiffSide
---@field right ow.Git.DiffSide
---@param worktree string
---@param path string
---@param content boolean
---@return ow.Git.DiffSide
local function head_pane(worktree, path, content)
return {
buf = content and git_show_buf(worktree, "HEAD", path) or empty_buf(),
name = "git://HEAD/" .. path,
}
end
---@param worktree string
---@param path string
---@param exists boolean
---@return ow.Git.DiffSide
local function worktree_pane(worktree, path, exists)
if exists then
return {
buf = load_file_buf(vim.fs.joinpath(worktree, path)),
name = nil,
}
end
return { buf = empty_buf(), name = "git://worktree/" .. path }
end
---@param s ow.Git.StatusState
---@param entry ow.Git.FileEntry
---@return ow.Git.DiffSide
local function index_pane(s, entry)
local in_index = not (
entry.section == "Untracked"
or (entry.section == "Staged" and entry.x == "D")
)
return {
buf = in_index and git_show_buf(s.worktree, "", entry.path, true)
or empty_buf(),
name = "git://index/" .. entry.path,
}
end
---@param s ow.Git.StatusState
---@param entry ow.Git.FileEntry
---@return ow.Git.DiffSide?
local function other_pane(s, entry)
local p = entry.path
local worktree = s.worktree
if entry.section == "Staged" then
if entry.x == "A" then
return head_pane(worktree, p, false)
end
if entry.x == "D" then
return head_pane(worktree, p, true)
end
-- HEAD holds the pre-rename path
return head_pane(worktree, entry.orig or p, true)
end
if entry.section == "Unstaged" then
return worktree_pane(worktree, p, entry.y ~= "D")
end
if entry.section == "Untracked" then
return worktree_pane(worktree, p, true)
end
end
---@param s ow.Git.StatusState
---@param entry ow.Git.StatusEntry
---@return ow.Git.DiffPair?
local function compute_pair(s, entry)
if not entry.path then
return nil
end
---@cast entry ow.Git.FileEntry
local other = other_pane(s, entry)
if not other then
return nil
end
return { left = index_pane(s, entry), right = other }
end
---@param win integer
local function reset_diff_win(win)
vim.api.nvim_win_call(win, function()
vim.cmd(
"setlocal winfixwidth< number< relativenumber< signcolumn< wrap< cursorline<"
)
end)
end
---@param sidebar_win integer
---@return integer?
local function find_default_main_win(sidebar_win)
local non_sidebar = {}
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if win ~= sidebar_win then
table.insert(non_sidebar, win)
end
end
if #non_sidebar ~= 1 then
return nil
end
local buf = vim.api.nvim_win_get_buf(non_sidebar[1])
if
vim.api.nvim_buf_get_name(buf) == ""
and vim.bo[buf].buftype == ""
and not vim.bo[buf].modified
and vim.api.nvim_buf_line_count(buf) == 1
and vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] == ""
then
return non_sidebar[1]
end
end
---@param win integer
---@param enabled boolean
local function set_diff(win, enabled)
vim.api.nvim_win_call(win, function()
vim.cmd(enabled and "diffthis" or "diffoff")
end)
if enabled then
vim.wo[win].foldenable = true
vim.wo[win].foldlevel = 0
end
end
---@param s ow.Git.StatusState
---@param sidebar_win integer
---@return integer? left
---@return integer? right
local function adopt_diff_wins(s, sidebar_win)
local left = s.diff_left_win
local right = s.diff_right_win
if left and not vim.api.nvim_win_is_valid(left) then
left = nil
end
if right and not vim.api.nvim_win_is_valid(right) then
right = nil
end
if left and right then
return left, right
end
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
if win ~= sidebar_win then
local role = vim.w[win].git_diff_role
if role == "left" and not left then
left = win
elseif role == "right" and not right then
right = win
end
end
end
return left, right
end
---@param entry ow.Git.StatusEntry
---@return string
local function entry_key(entry)
return entry.section
.. "|"
.. (entry.path or entry.sha or "")
.. "|"
.. (entry.orig or "")
end
---@param s ow.Git.StatusState
---@param entry ow.Git.StatusEntry
---@param focus_left boolean
local function show_diff(s, entry, focus_left)
local sidebar_win = find_sidebar()
if not sidebar_win then
return
end
local left_win, right_win = adopt_diff_wins(s, sidebar_win)
local key = entry_key(entry)
if s.last_shown_key == key and left_win and right_win then
if focus_left then
vim.api.nvim_set_current_win(left_win)
else
vim.api.nvim_set_current_win(sidebar_win)
end
return
end
local pair = compute_pair(s, entry)
if not pair then
return
end
if left_win and not right_win then
vim.api.nvim_set_current_win(left_win)
vim.cmd("rightbelow vertical split")
right_win = vim.api.nvim_get_current_win()
reset_diff_win(right_win)
elseif right_win and not left_win then
vim.api.nvim_set_current_win(right_win)
vim.cmd("leftabove vertical split")
left_win = vim.api.nvim_get_current_win()
reset_diff_win(left_win)
elseif not (left_win or right_win) then
local default_main = find_default_main_win(sidebar_win)
if default_main then
right_win = default_main
reset_diff_win(right_win)
vim.api.nvim_set_current_win(default_main)
vim.cmd("leftabove vertical split")
left_win = vim.api.nvim_get_current_win()
reset_diff_win(left_win)
else
-- No reusable default-empty window. Open the diff pair by
-- splitting from the sidebar. winfixwidth keeps the sidebar at 50
-- when there are other windows to absorb the split; if the
-- sidebar is the only window in the tab, the split has to take
-- from the sidebar itself, so restore the width explicitly.
vim.api.nvim_set_current_win(sidebar_win)
vim.cmd("rightbelow vertical split")
right_win = vim.api.nvim_get_current_win()
reset_diff_win(right_win)
vim.cmd("leftabove vertical split")
left_win = vim.api.nvim_get_current_win()
reset_diff_win(left_win)
vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH)
end
local combined = vim.api.nvim_win_get_width(left_win)
+ vim.api.nvim_win_get_width(right_win)
vim.api.nvim_win_set_width(left_win, math.floor(combined / 2))
end
assert(left_win and right_win, "diff windows must be set")
vim.w[left_win].git_diff_role = "left"
vim.w[right_win].git_diff_role = "right"
s.diff_left_win = left_win
s.diff_right_win = right_win
vim.api.nvim_win_set_buf(left_win, pair.left.buf)
vim.api.nvim_win_set_buf(right_win, pair.right.buf)
for _, side in ipairs({ pair.left, pair.right }) do
if side.name then
pcall(vim.api.nvim_buf_set_name, side.buf, side.name)
local ft = vim.filetype.match({ buf = side.buf })
if ft then
vim.bo[side.buf].filetype = ft
end
end
end
set_diff(left_win, true)
set_diff(right_win, true)
s.last_shown_key = key
if focus_left then
vim.api.nvim_set_current_win(left_win)
else
vim.api.nvim_set_current_win(sidebar_win)
end
end
---@param focus_left boolean
local function preview_or_open(focus_left)
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry then
return
end
show_diff(s, entry, focus_left)
end
local function action_stage()
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry or not entry.path then
return
end
---@cast entry ow.Git.FileEntry
if entry.section == "Staged" then
return
end
vim.system({ "git", "add", "--", entry.path }, { cwd = s.worktree })
end
local function action_unstage()
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry or not entry.path then
return
end
---@cast entry ow.Git.FileEntry
if entry.section ~= "Staged" then
return
end
local cmd = { "git", "restore", "--staged", "--" }
if entry.orig then
table.insert(cmd, entry.orig)
end
table.insert(cmd, entry.path)
vim.system(cmd, { cwd = s.worktree })
end
local function action_discard()
local s, entry = current_entry(vim.api.nvim_get_current_buf())
if not s or not entry or not entry.path then
return
end
---@cast entry ow.Git.FileEntry
if entry.section == "Staged" then
log.warning("file has staged changes; unstage first with 'u'")
return
end
local prompt, action
if entry.section == "Untracked" then
prompt = string.format("Delete untracked file %s?", entry.path)
action = function()
os.remove(vim.fs.joinpath(s.worktree, entry.path))
refresh(vim.api.nvim_get_current_buf())
end
elseif entry.section == "Unstaged" then
prompt = string.format("Discard changes to %s?", entry.path)
action = function()
vim.system(
{ "git", "checkout", "--", entry.path },
{ cwd = s.worktree }
)
end
else
return
end
if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then
action()
end
end
local function action_help()
print(table.concat({
"git status sidebar",
" <Tab> preview diff (keep focus)",
" <CR> open diff (focus left pane)",
" s stage file",
" u unstage file",
" X discard worktree changes (untracked: delete file)",
" g? show this help",
}, "\n"))
end
---@param worktree string
local function open(worktree)
local existing = find_sidebar()
if existing then
vim.api.nvim_set_current_win(existing)
return
end
local gitdir = repo.resolve(worktree)
if not gitdir then
return
end
local previous_win = vim.api.nvim_get_current_win()
vim.cmd("leftabove vertical new")
local win = vim.api.nvim_get_current_win()
local bufnr = vim.api.nvim_get_current_buf()
vim.bo[bufnr].buftype = "nofile"
vim.bo[bufnr].bufhidden = "wipe"
vim.bo[bufnr].swapfile = false
vim.bo[bufnr].filetype = "gitstatus"
vim.bo[bufnr].modifiable = false
vim.wo[win].number = false
vim.wo[win].relativenumber = false
vim.wo[win].wrap = false
vim.wo[win].signcolumn = "no"
vim.wo[win].cursorline = true
vim.wo[win].winfixwidth = true
vim.api.nvim_win_set_width(win, SIDEBAR_WIDTH)
state[bufnr] = { gitdir = gitdir, worktree = worktree, lines = {} }
local function k(lhs, rhs, desc)
vim.keymap.set(
"n",
lhs,
rhs,
{ buffer = bufnr, silent = true, desc = desc }
)
end
k("<Tab>", function()
preview_or_open(false)
end, "Preview diff")
k("<CR>", function()
preview_or_open(true)
end, "Open diff")
k("s", action_stage, "Stage file")
k("u", action_unstage, "Unstage file")
k("X", action_discard, "Discard worktree changes")
k("g?", action_help, "Help")
state[bufnr].user_aucmd = vim.api.nvim_create_autocmd("User", {
pattern = "GitRefresh",
group = group,
callback = function(args)
if args.data and args.data.gitdir == gitdir then
refresh(bufnr)
end
end,
})
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
buffer = bufnr,
group = group,
callback = function()
local s = state[bufnr]
if not s then
return
end
if s.user_aucmd then
pcall(vim.api.nvim_del_autocmd, s.user_aucmd)
end
state[bufnr] = nil
end,
})
vim.api.nvim_set_current_win(previous_win)
refresh(bufnr)
end
function M.toggle()
local sidebar_win = find_sidebar()
if sidebar_win then
vim.api.nvim_win_close(sidebar_win, false)
return
end
local path = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf())
if path == "" then
path = vim.fn.getcwd()
end
local _, worktree = repo.resolve(path)
if not worktree then
log.warning("not in a git repository")
return
end
open(worktree)
end
return M
+2 -2
View File
@@ -111,8 +111,8 @@ require("nvim-tree").setup({
full_name = true,
root_folder_label = function(path)
local label = vim.fn.fnamemodify(path, ":~")
local git_head = vim.fn.FugitiveHead()
if git_head ~= "" then
local git_head = require("git").head(path)
if git_head then
label = label .. ("  %s"):format(git_head)
end
return label
+3 -3
View File
@@ -50,9 +50,9 @@ local highlights = {
DiffAdd = { bg = "#1a2f22" },
DiffChange = { bg = "#15304a" },
DiffDelete = { bg = "#311c1e" },
GitDeleted = { fg = c.red },
GitDirty = { fg = c.yellow },
GitNew = { fg = c.green },
-- GitDeleted = { fg = c.red },
-- GitUnstaged = { fg = c.yellow },
-- GitUntracked = { fg = c.green },
}
for kind, color in pairs(completion_kind_colors) do
highlights["LspKind" .. kind] = { fg = color }
-33
View File
@@ -1,35 +1,3 @@
local function open_git_status()
local previous_win = vim.api.nvim_get_current_win()
vim.cmd("leftabove vertical G")
vim.api.nvim_win_set_width(0, 50)
vim.api.nvim_set_option_value("winfixwidth", true, { scope = "local" })
vim.api.nvim_set_current_win(previous_win)
end
local function get_git_status_win()
local current_tabpage = vim.api.nvim_get_current_tabpage()
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(current_tabpage)) do
local buf = vim.api.nvim_win_get_buf(win)
local buftype = vim.api.nvim_get_option_value("buftype", { buf = buf })
if
buftype == "nowrite"
and vim.api.nvim_buf_get_name(buf):match("^fugitive://.*%.git//$")
then
return win
end
end
end
local function toggle_git_status()
local win = get_git_status_win()
if win then
vim.api.nvim_win_close(win, false)
return
end
open_git_status()
end
vim.api.nvim_create_user_command("Glog", function(opts)
local mods = opts.mods ~= "" and (opts.mods .. " ") or ""
vim.cmd(
@@ -58,7 +26,6 @@ end)
vim.keymap.set("n", "<leader>gp", function()
vim.cmd.G("push")
end)
vim.keymap.set("n", "<leader>gg", toggle_git_status)
vim.api.nvim_create_autocmd("User", {
pattern = "GitRefresh",
+44
View File
@@ -0,0 +1,44 @@
if exists("b:current_syntax")
finish
endif
syntax match gitstatusLabel /\v^(Head|Push)\ze:/
syntax match gitstatusBranch /\v(^(Head|Push):\s+)@<=\S+/
syntax match gitstatusAhead /\v\+\d+/
syntax match gitstatusBehind /\v-\d+/
syntax region gitstatusUntrackedHeader start=/\v^Untracked>/ end=/\v^$/
syntax region gitstatusUnstagedHeader start=/\v^Unstaged>/ end=/\v^$/
syntax region gitstatusStagedHeader start=/\v^Staged>/ end=/\v^$/
syntax region gitstatusUnmergedHeader start=/\v^Unmerged>/ end=/\v^$/
syntax region gitstatusUnpushedHeader start=/\v^Unpushed>/ end=/\v^$/
syntax region gitstatusUnpulledHeader start=/\v^Unpulled>/ end=/\v^$/
syntax match gitstatusUntrackedLabel /\v^Untracked/ contained containedin=gitstatusUntrackedHeader
syntax match gitstatusUnstagedLabel /\v^Unstaged/ contained containedin=gitstatusUnstagedHeader
syntax match gitstatusStagedLabel /\v^Staged/ contained containedin=gitstatusStagedHeader
syntax match gitstatusUnmergedLabel /\v^Unmerged/ contained containedin=gitstatusUnmergedHeader
syntax match gitstatusUnpushedLabel /\v^Unpushed/ contained containedin=gitstatusUnpushedHeader
syntax match gitstatusUnpulledLabel /\v^Unpulled/ contained containedin=gitstatusUnpulledHeader
syntax match gitstatusHeaderCount /\v\(\zs\d+\ze\)/ contained containedin=gitstatusUntrackedHeader,
\ gitstatusUnstagedHeader,
\ gitstatusStagedHeader,
\ gitstatusUnmergedHeader,
\ gitstatusUnpushedHeader,
\ gitstatusUnpulledHeader
highlight default link gitstatusLabel Label
highlight default link gitstatusBranch None
highlight default link gitstatusAhead GitUnpushed
highlight default link gitstatusBehind GitUnpulled
highlight default link gitstatusHeaderCount Number
highlight default link gitstatusUntrackedLabel gitstatusLabel
highlight default link gitstatusUnstagedLabel gitstatusLabel
highlight default link gitstatusStagedLabel gitstatusLabel
highlight default link gitstatusUnmergedLabel gitstatusLabel
highlight default link gitstatusUnpushedLabel gitstatusLabel
highlight default link gitstatusUnpulledLabel gitstatusLabel
let b:current_syntax = "gitstatus"