922 lines
26 KiB
Lua
922 lines
26 KiB
Lua
local Revision = require("git.revision")
|
|
local diff = require("git.diff")
|
|
local object = require("git.object")
|
|
local repo = require("git.repo")
|
|
local util = require("git.util")
|
|
|
|
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.SidebarEntry ow.Git.FileEntry | ow.Git.CommitEntry
|
|
|
|
---@class ow.Git.SidebarState
|
|
---@field gitdir string
|
|
---@field worktree string
|
|
---@field lines table<integer, ow.Git.SidebarEntry>
|
|
---@field sidebar_win integer?
|
|
---@field invocation_win integer?
|
|
---@field diff_left_win integer?
|
|
---@field diff_right_win integer?
|
|
---@field user_aucmd integer?
|
|
---@field last_shown_key string?
|
|
---@field last_render_key string?
|
|
|
|
---@type table<integer, ow.Git.SidebarState>
|
|
local state = {}
|
|
|
|
local group = vim.api.nvim_create_augroup("ow.git.sidebar", { clear = false })
|
|
local ns = vim.api.nvim_create_namespace("ow.git.sidebar")
|
|
|
|
---@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 == "gitsidebar" then
|
|
return win, buf
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param s ow.Git.SidebarState
|
|
---@return integer?
|
|
local function sidebar_win_for(s)
|
|
local win = s.sidebar_win
|
|
if win and vim.api.nvim_win_is_valid(win) then
|
|
return win
|
|
end
|
|
win = find_sidebar()
|
|
s.sidebar_win = win
|
|
return win
|
|
end
|
|
|
|
---@param entry ow.Git.SidebarEntry
|
|
---@return string?
|
|
local function entry_code(entry)
|
|
if entry.section == "Untracked" then
|
|
return "??"
|
|
elseif entry.section == "Unmerged" then
|
|
return entry.x .. entry.y
|
|
elseif entry.section == "Staged" then
|
|
return entry.x .. " "
|
|
elseif entry.section == "Unstaged" then
|
|
return " " .. entry.y
|
|
end
|
|
end
|
|
|
|
---@param entry ow.Git.SidebarEntry
|
|
---@return string? line
|
|
---@return string? hl_group
|
|
---@return integer? hl_len
|
|
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
|
|
---@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 stdout string
|
|
---@return ow.Git.BranchInfo, table<string, ow.Git.SidebarEntry[]>
|
|
local function parse_porcelain(stdout)
|
|
local branch = { ahead = 0, behind = 0 }
|
|
local groups = {
|
|
Untracked = {},
|
|
Unstaged = {},
|
|
Staged = {},
|
|
Unmerged = {},
|
|
Unpushed = {},
|
|
Unpulled = {},
|
|
}
|
|
for line in stdout: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
|
|
if x == "R" or x == "C" or y == "R" or y == "C" then
|
|
local arrow = rest:find(" -> ", 1, true)
|
|
if arrow then
|
|
orig = rest:sub(1, arrow - 1)
|
|
rest = rest:sub(arrow + 4)
|
|
end
|
|
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, {
|
|
section = "Staged",
|
|
path = entry.path,
|
|
orig = entry.orig,
|
|
x = entry.x,
|
|
y = entry.y,
|
|
})
|
|
end
|
|
if y ~= " " then
|
|
table.insert(groups.Unstaged, {
|
|
section = "Unstaged",
|
|
path = entry.path,
|
|
orig = entry.orig,
|
|
x = entry.x,
|
|
y = entry.y,
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return branch, groups
|
|
end
|
|
|
|
---@param worktree string
|
|
---@param branch ow.Git.BranchInfo
|
|
---@param groups table<string, ow.Git.SidebarEntry[]>
|
|
local function enrich_with_log(worktree, branch, groups)
|
|
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
|
|
local pending = {}
|
|
for _, f in ipairs(fetches) do
|
|
table.insert(pending, {
|
|
f = f,
|
|
sys = vim.system({
|
|
"git",
|
|
"log",
|
|
"--max-count=200",
|
|
"--format=%h %s",
|
|
f.range,
|
|
}, { cwd = worktree, text = true }),
|
|
})
|
|
end
|
|
for _, p in ipairs(pending) do
|
|
local result = p.sys:wait()
|
|
if result.code == 0 then
|
|
for line in (result.stdout or ""):gmatch("[^\r\n]+") do
|
|
local sha, subject = line:match("^(%S+)%s+(.+)$")
|
|
if sha then
|
|
table.insert(groups[p.f.section], {
|
|
section = p.f.section,
|
|
sha = sha,
|
|
subject = subject,
|
|
})
|
|
end
|
|
end
|
|
else
|
|
util.error(
|
|
"git log %s failed: %s",
|
|
p.f.range,
|
|
vim.trim(result.stderr or "")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param worktree string
|
|
---@param prefetched_stdout string?
|
|
---@param callback fun(branch: ow.Git.BranchInfo, groups: table<string, ow.Git.SidebarEntry[]>)
|
|
local function fetch_status(worktree, prefetched_stdout, callback)
|
|
if prefetched_stdout then
|
|
local branch, groups = parse_porcelain(prefetched_stdout)
|
|
enrich_with_log(worktree, branch, groups)
|
|
callback(branch, groups)
|
|
return
|
|
end
|
|
vim.system(
|
|
{
|
|
"git",
|
|
"-c",
|
|
"core.quotePath=false",
|
|
"status",
|
|
"--porcelain=v1",
|
|
"--branch",
|
|
},
|
|
{ cwd = worktree, text = true },
|
|
vim.schedule_wrap(function(obj)
|
|
if obj.code ~= 0 then
|
|
util.error("git status failed: %s", vim.trim(obj.stderr or ""))
|
|
local branch = { ahead = 0, behind = 0 }
|
|
local groups = {
|
|
Untracked = {},
|
|
Unstaged = {},
|
|
Staged = {},
|
|
Unmerged = {},
|
|
Unpushed = {},
|
|
Unpulled = {},
|
|
}
|
|
callback(branch, groups)
|
|
return
|
|
end
|
|
local branch, groups = parse_porcelain(obj.stdout or "")
|
|
enrich_with_log(worktree, branch, groups)
|
|
callback(branch, groups)
|
|
end)
|
|
)
|
|
end
|
|
|
|
---@param bufnr integer
|
|
---@param branch ow.Git.BranchInfo
|
|
---@param groups table<string, ow.Git.SidebarEntry[]>
|
|
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 branch ow.Git.BranchInfo
|
|
---@param groups table<string, ow.Git.SidebarEntry[]>
|
|
---@return string
|
|
local function fingerprint(branch, groups)
|
|
local parts = {
|
|
branch.head or "",
|
|
branch.upstream or "",
|
|
tostring(branch.ahead),
|
|
tostring(branch.behind),
|
|
}
|
|
for _, section in ipairs(SECTIONS) do
|
|
local entries = groups[section]
|
|
if entries then
|
|
for _, e in ipairs(entries) do
|
|
table.insert(
|
|
parts,
|
|
e.section
|
|
.. ":"
|
|
.. (e.path or e.sha or "")
|
|
.. ":"
|
|
.. (e.orig or "")
|
|
.. ":"
|
|
.. (e.x or "")
|
|
.. ":"
|
|
.. (e.y or "")
|
|
)
|
|
end
|
|
end
|
|
end
|
|
return table.concat(parts, "\0")
|
|
end
|
|
|
|
---@param bufnr integer
|
|
---@param prefetched_stdout string?
|
|
local function refresh(bufnr, prefetched_stdout)
|
|
local s = state[bufnr]
|
|
if not s then
|
|
return
|
|
end
|
|
|
|
local saved_path, saved_sha
|
|
local sidebar_win = sidebar_win_for(s)
|
|
if sidebar_win then
|
|
local lnum = vim.api.nvim_win_get_cursor(sidebar_win)[1]
|
|
local entry = s.lines[lnum]
|
|
if entry then
|
|
saved_path = entry.path
|
|
saved_sha = entry.sha
|
|
end
|
|
end
|
|
|
|
fetch_status(s.worktree, prefetched_stdout, function(branch, groups)
|
|
if not vim.api.nvim_buf_is_valid(bufnr) then
|
|
return
|
|
end
|
|
s.last_shown_key = nil
|
|
local fp = fingerprint(branch, groups)
|
|
if fp == s.last_render_key then
|
|
return
|
|
end
|
|
s.last_render_key = fp
|
|
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
|
|
local win = sidebar_win_for(s)
|
|
if win then
|
|
pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 })
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
---@param bufnr integer
|
|
---@return ow.Git.SidebarState?
|
|
---@return ow.Git.SidebarEntry?
|
|
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
|
|
|
|
---@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
|
|
---@return ow.Git.DiffSide
|
|
local function head_pane(worktree, path)
|
|
local rev = Revision.new({ base = "HEAD", path = path })
|
|
return {
|
|
buf = object.buf_for(worktree, rev),
|
|
name = rev:uri(),
|
|
}
|
|
end
|
|
|
|
---@param worktree string
|
|
---@param path string
|
|
---@return ow.Git.DiffSide
|
|
local function worktree_pane(worktree, path)
|
|
local buf = vim.fn.bufadd(vim.fs.joinpath(worktree, path))
|
|
vim.fn.bufload(buf)
|
|
return { buf = buf, name = nil }
|
|
end
|
|
|
|
---@param s ow.Git.SidebarState
|
|
---@param entry ow.Git.FileEntry
|
|
---@return ow.Git.DiffSide
|
|
local function index_pane(s, entry)
|
|
local rev = Revision.new({ stage = 0, path = entry.path })
|
|
return {
|
|
buf = object.buf_for(s.worktree, rev),
|
|
name = rev:uri(),
|
|
}
|
|
end
|
|
|
|
---@param s ow.Git.SidebarState
|
|
---@param entry ow.Git.FileEntry
|
|
---@return ow.Git.DiffSide?
|
|
local function older_pane(s, entry)
|
|
if entry.section == "Staged" then
|
|
if entry.x == "A" then
|
|
return nil
|
|
end
|
|
return head_pane(s.worktree, entry.orig or entry.path)
|
|
end
|
|
if entry.section == "Unstaged" then
|
|
return index_pane(s, entry)
|
|
end
|
|
return nil
|
|
end
|
|
|
|
---@param s ow.Git.SidebarState
|
|
---@param entry ow.Git.FileEntry
|
|
---@return ow.Git.DiffSide?
|
|
local function newer_pane(s, entry)
|
|
if entry.section == "Staged" then
|
|
if entry.x == "D" then
|
|
return nil
|
|
end
|
|
return index_pane(s, entry)
|
|
end
|
|
if entry.section == "Unstaged" then
|
|
if entry.y == "D" then
|
|
return nil
|
|
end
|
|
return worktree_pane(s.worktree, entry.path)
|
|
end
|
|
if entry.section == "Untracked" then
|
|
return worktree_pane(s.worktree, entry.path)
|
|
end
|
|
return nil
|
|
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 s ow.Git.SidebarState
|
|
---@return integer?
|
|
local function invocation_win_for(s)
|
|
local win = s.invocation_win
|
|
if not win or not vim.api.nvim_win_is_valid(win) then
|
|
return nil
|
|
end
|
|
if win == s.sidebar_win then
|
|
return nil
|
|
end
|
|
if
|
|
vim.api.nvim_win_get_tabpage(win)
|
|
~= vim.api.nvim_get_current_tabpage()
|
|
then
|
|
return nil
|
|
end
|
|
return win
|
|
end
|
|
|
|
---@param s ow.Git.SidebarState
|
|
---@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.FileEntry
|
|
---@return string
|
|
local function entry_key(entry)
|
|
return entry.section .. "|" .. entry.path .. "|" .. (entry.orig or "")
|
|
end
|
|
|
|
---@param target_win integer
|
|
---@param dir "left"|"right"
|
|
---@return integer
|
|
local function vsplit_at(target_win, dir)
|
|
local win = vim.api.nvim_open_win(
|
|
vim.api.nvim_win_get_buf(target_win),
|
|
true,
|
|
{ split = dir, win = target_win }
|
|
)
|
|
vim.cmd.clearjumps()
|
|
return win
|
|
end
|
|
|
|
---@param s ow.Git.SidebarState
|
|
---@param sidebar_win integer
|
|
---@param right_win integer?
|
|
---@return integer
|
|
local function ensure_right_win(s, sidebar_win, right_win)
|
|
if right_win then
|
|
return right_win
|
|
end
|
|
local target = invocation_win_for(s)
|
|
if target then
|
|
right_win = target
|
|
else
|
|
right_win = vsplit_at(sidebar_win, "right")
|
|
vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH)
|
|
end
|
|
reset_diff_win(right_win)
|
|
return right_win
|
|
end
|
|
|
|
---@param s ow.Git.SidebarState
|
|
---@param entry ow.Git.SidebarEntry
|
|
---@param focus_left boolean
|
|
local function view_entry(s, entry, focus_left)
|
|
if not entry.path then
|
|
return
|
|
end
|
|
---@cast entry ow.Git.FileEntry
|
|
local sidebar_win = sidebar_win_for(s)
|
|
if not sidebar_win then
|
|
return
|
|
end
|
|
|
|
local left = older_pane(s, entry)
|
|
local right = newer_pane(s, entry)
|
|
if not left and not right then
|
|
util.warning("no content for %s entry: %s", entry.section, entry.path)
|
|
return
|
|
end
|
|
|
|
local key = entry_key(entry)
|
|
local left_win, right_win = adopt_diff_wins(s, sidebar_win)
|
|
local want_pair = left and right
|
|
|
|
if s.last_shown_key == key then
|
|
local intact = (want_pair and left_win and right_win)
|
|
or (not want_pair and right_win and not left_win)
|
|
if intact then
|
|
local target = focus_left and (left_win or right_win) or sidebar_win
|
|
vim.api.nvim_set_current_win(target)
|
|
return
|
|
end
|
|
end
|
|
|
|
if not want_pair then
|
|
if left_win and vim.api.nvim_win_is_valid(left_win) then
|
|
pcall(vim.api.nvim_win_close, left_win, false)
|
|
left_win = nil
|
|
s.diff_left_win = nil
|
|
end
|
|
right_win = ensure_right_win(s, sidebar_win, right_win)
|
|
s.diff_right_win = right_win
|
|
vim.w[right_win].git_diff_role = "right"
|
|
local side = left or right
|
|
---@cast side ow.Git.DiffSide
|
|
diff.set_diff(right_win, false)
|
|
vim.api.nvim_win_set_buf(right_win, side.buf)
|
|
if side.name then
|
|
util.set_buf_name(side.buf, side.name)
|
|
end
|
|
s.last_shown_key = key
|
|
vim.api.nvim_set_current_win(focus_left and right_win or sidebar_win)
|
|
return
|
|
end
|
|
---@cast left ow.Git.DiffSide
|
|
---@cast right ow.Git.DiffSide
|
|
|
|
if left_win and not right_win then
|
|
right_win = vsplit_at(left_win, "right")
|
|
reset_diff_win(right_win)
|
|
elseif right_win and not left_win then
|
|
left_win = vsplit_at(right_win, "left")
|
|
reset_diff_win(left_win)
|
|
elseif not (left_win or right_win) then
|
|
right_win = ensure_right_win(s, sidebar_win, nil)
|
|
left_win = vsplit_at(right_win, "left")
|
|
reset_diff_win(left_win)
|
|
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
|
|
|
|
diff.update_pair(left_win, right_win, { left = left, right = right })
|
|
s.last_shown_key = key
|
|
|
|
vim.api.nvim_set_current_win(focus_left and left_win or sidebar_win)
|
|
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
|
|
view_entry(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 },
|
|
vim.schedule_wrap(function(obj)
|
|
if obj.code ~= 0 then
|
|
util.error("git add failed: %s", vim.trim(obj.stderr or ""))
|
|
end
|
|
end)
|
|
)
|
|
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 },
|
|
vim.schedule_wrap(function(obj)
|
|
if obj.code ~= 0 then
|
|
util.error(
|
|
"git restore --staged failed: %s",
|
|
vim.trim(obj.stderr or "")
|
|
)
|
|
end
|
|
end)
|
|
)
|
|
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
|
|
util.warning("file has staged changes, unstage first with 'u'")
|
|
return
|
|
end
|
|
|
|
local prompt, action
|
|
if entry.section == "Untracked" then
|
|
local is_dir = entry.path:sub(-1) == "/"
|
|
prompt = string.format(
|
|
"Delete untracked %s %s?",
|
|
is_dir and "directory" or "file",
|
|
entry.path
|
|
)
|
|
action = function()
|
|
local target = vim.fs.joinpath(s.worktree, entry.path)
|
|
local rc = vim.fn.delete(target, is_dir and "rf" or "")
|
|
if rc ~= 0 then
|
|
util.error("failed to delete %s", entry.path)
|
|
end
|
|
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 },
|
|
vim.schedule_wrap(function(obj)
|
|
if obj.code ~= 0 then
|
|
util.error(
|
|
"git checkout failed: %s",
|
|
vim.trim(obj.stderr or "")
|
|
)
|
|
end
|
|
end)
|
|
)
|
|
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()
|
|
local bufnr, win = util.new_scratch({ split = "left" })
|
|
vim.bo[bufnr].filetype = "gitsidebar"
|
|
|
|
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 = {},
|
|
sidebar_win = win,
|
|
invocation_win = previous_win,
|
|
}
|
|
|
|
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, args.data.porcelain_stdout)
|
|
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 _, worktree = repo.resolve_cwd()
|
|
if not worktree then
|
|
util.warning("not in a git repository")
|
|
return
|
|
end
|
|
open(worktree)
|
|
end
|
|
|
|
return M
|