local repo = require("git.core.repo") local util = require("git.core.util") local M = {} local NS_SIGNS = vim.api.nvim_create_namespace("ow.git.hunks") local NS_OVERLAY = vim.api.nvim_create_namespace("ow.git.hunks.overlay") ---@alias ow.Git.Hunks.HunkType "add"|"change"|"delete" ---@class ow.Git.Hunks.Hunk ---@field old_start integer ---@field old_count integer ---@field new_start integer ---@field new_count integer ---@field type ow.Git.Hunks.HunkType ---@field old_lines string[] ---@field new_lines string[] ---@class ow.Git.Hunks.BufState ---@field repo ow.Git.Repo ---@field rel string ---@field index string[]? ---@field index_sha string? ---@field index_hl { src: string[], lines: table[][]? }? ---@field hunks ow.Git.Hunks.Hunk[] ---@field overlay boolean ---@field autocmds integer[] ---@type table local states = {} ---@param buf integer ---@return ow.Git.Hunks.BufState? function M.state(buf) return states[buf] end ---@param buf integer? ---@return integer local function resolve_buf(buf) return buf and buf ~= 0 and buf or vim.api.nvim_get_current_buf() end ---Mirror the hunk-affecting parts of the user's 'diffopt' so the gutter ---lines up with what `:diffsplit` shows. ---@return table local function diff_opts() local opts = { result_type = "indices", algorithm = "myers" } for _, item in ipairs(vim.split(vim.o.diffopt, ",", { plain = true })) do if item == "indent-heuristic" then opts.indent_heuristic = true else local algorithm = item:match("^algorithm:(.+)$") if algorithm then opts.algorithm = algorithm end local linematch = item:match("^linematch:(%d+)$") if linematch then opts.linematch = tonumber(linematch) end end end return opts end ---@param state ow.Git.Hunks.BufState ---@param new_lines string[] local function compute_hunks(state, new_lines) local old = table.concat(state.index or {}, "\n") local new = table.concat(new_lines, "\n") local raw = vim.text.diff(old, new, diff_opts()) ---@type ow.Git.Hunks.Hunk[] local hunks = {} if type(raw) ~= "table" then state.hunks = hunks return end for _, h in ipairs(raw) do local os_ = h[1] --[[@as integer]] local oc = h[2] --[[@as integer]] local ns_ = h[3] --[[@as integer]] local nc = h[4] --[[@as integer]] local typ ---@type ow.Git.Hunks.HunkType if oc == 0 then typ = "add" elseif nc == 0 then typ = "delete" else typ = "change" end local old_lines = {} if typ ~= "add" and state.index then for i = os_, os_ + oc - 1 do table.insert(old_lines, state.index[i] or "") end end local hunk_new = {} if typ ~= "delete" then for i = ns_, ns_ + nc - 1 do table.insert(hunk_new, new_lines[i] or "") end end table.insert(hunks, { old_start = os_, old_count = oc, new_start = ns_, new_count = nc, type = typ, old_lines = old_lines, new_lines = hunk_new, }) end state.hunks = hunks end ---@type table local DEFAULT_SIGNS = { add = "┃", change = "┃", delete = "▁" } ---@return table local function resolve_signs() local cfg = vim.g.git_hunk_signs if type(cfg) ~= "table" then return DEFAULT_SIGNS end return vim.tbl_extend("force", DEFAULT_SIGNS, cfg) end ---@param buf integer local function render_signs(buf) if not vim.api.nvim_buf_is_valid(buf) then return end vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1) local state = states[buf] if not state or state.overlay then return end local signs = resolve_signs() local line_count = vim.api.nvim_buf_line_count(buf) for _, h in ipairs(state.hunks) do if h.type == "delete" then local row = math.max(h.new_start, 1) - 1 if row >= line_count then row = math.max(line_count - 1, 0) end pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, row, 0, { sign_text = signs.delete, sign_hl_group = "GitHunkDeleted", priority = 100, }) else local hl = h.type == "add" and "GitHunkAdded" or "GitHunkChanged" local sign = h.type == "add" and signs.add or signs.change for r = h.new_start, h.new_start + h.new_count - 1 do local row = r - 1 if row >= 0 and row < line_count then pcall( vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, row, 0, { sign_text = sign, sign_hl_group = hl, priority = 100, } ) end end end end end local SKIP_CAPTURES = { spell = true, nospell = true, conceal = true } ---@param buf integer ---@param lines string[] ---@return table[][]? local function highlight_index(buf, lines) if not vim.treesitter.highlighter.active[buf] then return nil end local got, parser = pcall(vim.treesitter.get_parser, buf) if not got or not parser then return nil end local lang = parser:lang() local query = vim.treesitter.query.get(lang, "highlights") if not query then return nil end local source = table.concat(lines, "\n") local got_root, root = pcall(function() local trees = vim.treesitter.get_string_parser(source, lang):parse() local tree = trees and trees[1] return tree and tree:root() end) if not got_root or not root then return nil end ---@type table> local groups = {} for id, node in query:iter_captures(root, source) do local name = query.captures[id] if name and name:sub(1, 1) ~= "_" and not SKIP_CAPTURES[name] then local sr, sc, er, ec = node:range() for row = sr, math.min(er, #lines - 1) do local row_groups = groups[row] or {} groups[row] = row_groups local from = row == sr and sc or 0 local to = row == er and ec or #(lines[row + 1] or "") for col = from, to - 1 do row_groups[col] = name end end end end local out = {} for row = 0, #lines - 1 do local line = lines[row + 1] or "" local row_groups = groups[row] or {} local chunks = {} local col = 0 while col < #line do local name = row_groups[col] local stop = col + 1 while stop < #line and row_groups[stop] == name do stop = stop + 1 end local hl ---@type string|string[] if name then hl = { "GitHunkDeleteLine", "@" .. name } else hl = "GitHunkDeleteLine" end table.insert(chunks, { line:sub(col + 1, stop), hl }) col = stop end out[row + 1] = chunks end return out end ---@param h ow.Git.Hunks.Hunk ---@param hl_lines table[][]? per-index-line syntax chunks, or nil ---@return table[] local function delete_virt_lines(h, hl_lines) local width = vim.o.columns local virt = {} for i, line in ipairs(h.old_lines) do local pad = math.max(width - vim.api.nvim_strwidth(line), 0) local cached = hl_lines and hl_lines[h.old_start + i - 1] if cached then local chunks = vim.list_extend({}, cached) table.insert(chunks, { string.rep(" ", pad), "GitHunkDeleteLine", }) table.insert(virt, chunks) else table.insert(virt, { { line .. string.rep(" ", pad), "GitHunkDeleteLine" }, }) end end return virt end ---@param state ow.Git.Hunks.BufState ---@param buf integer ---@return table[][]? local function index_spans(state, buf) if not state.index then return nil end local cache = state.index_hl if cache and cache.src == state.index then return cache.lines end local lines = highlight_index(buf, state.index) state.index_hl = { src = state.index, lines = lines } return lines end ---@param buf integer local function render_overlay(buf) if not vim.api.nvim_buf_is_valid(buf) then return end vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1) local state = states[buf] if not state or not state.overlay then return end local line_count = vim.api.nvim_buf_line_count(buf) local hl_lines = index_spans(state, buf) for _, h in ipairs(state.hunks) do if h.type ~= "delete" then for r = h.new_start, h.new_start + h.new_count - 1 do local row = r - 1 if row >= 0 and row < line_count then pcall( vim.api.nvim_buf_set_extmark, buf, NS_OVERLAY, row, 0, { line_hl_group = "GitHunkAddLine", priority = 100, } ) end end end if h.type ~= "add" then local row, above if h.type == "delete" then if h.new_start <= 0 then row, above = 0, true elseif h.new_start >= line_count then row, above = math.max(line_count - 1, 0), false else row, above = h.new_start, true end else row, above = math.max(h.new_start - 1, 0), true end pcall(vim.api.nvim_buf_set_extmark, buf, NS_OVERLAY, row, 0, { virt_lines = delete_virt_lines(h, hl_lines), virt_lines_above = above, right_gravity = false, invalidate = true, }) end end end ---@param buf integer local function render(buf) render_signs(buf) render_overlay(buf) end ---@param state ow.Git.Hunks.BufState ---@param buf integer ---@param new_sha string local function load_index_and_render(state, buf, new_sha) util.git({ "cat-file", "-p", ":0:" .. state.rel }, { cwd = state.repo.worktree, silent = true, on_exit = function(res) if states[buf] ~= state or not vim.api.nvim_buf_is_valid(buf) then return end if res.code ~= 0 then state.index = {} state.index_sha = nil state.hunks = {} render(buf) return end state.index = util.split_lines(res.stdout or "") state.index_sha = new_sha local new_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) compute_hunks(state, new_lines) render(buf) end, }) end ---@param buf integer local function recompute(buf) if not vim.api.nvim_buf_is_valid(buf) then return end local state = states[buf] if not state then return end local new_sha = state.repo:index_sha(state.rel) if not new_sha then state.index = nil state.index_sha = nil state.hunks = {} render(buf) return end if new_sha == state.index_sha and state.index then local new_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) compute_hunks(state, new_lines) render(buf) return end load_index_and_render(state, buf, new_sha) end local schedule, sched_handle = util.keyed_debounce(recompute, 100) ---@param buf integer function M.attach(buf) if states[buf] then return end if not repo.is_worktree_buf(buf) then return end local r = repo.find(buf) if not r then return end local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(vim.api.nvim_buf_get_name(buf))) if not rel then return end ---@type ow.Git.Hunks.BufState local state = { repo = r, rel = rel, index = nil, index_sha = nil, hunks = {}, overlay = vim.g.git_hunk_overlay_default == true, autocmds = {}, } states[buf] = state local group = vim.api.nvim_create_augroup("ow.git.hunks." .. buf, { clear = true }) table.insert( state.autocmds, vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { group = group, buffer = buf, callback = function() schedule(buf) end, }) ) table.insert( state.autocmds, vim.api.nvim_create_autocmd("BufWritePost", { group = group, buffer = buf, callback = function() schedule(buf) end, }) ) schedule(buf) end ---@param buf integer function M.detach(buf) local state = states[buf] if not state then return end if vim.api.nvim_buf_is_valid(buf) then vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1) vim.api.nvim_buf_clear_namespace(buf, NS_OVERLAY, 0, -1) end for _, id in ipairs(state.autocmds) do pcall(vim.api.nvim_del_autocmd, id) end pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks." .. buf) sched_handle.cancel(buf) states[buf] = nil end ---@param buf integer? function M.toggle_overlay(buf) buf = resolve_buf(buf) local state = states[buf] if not state then util.warning("git hunks: buffer not attached") return end state.overlay = not state.overlay render(buf) end ---@param buf? integer ---@param row integer 1-indexed cursor line ---@return ow.Git.Hunks.Hunk? local function hunk_at(buf, row) buf = buf or vim.api.nvim_get_current_buf() local state = states[buf] if not state then return nil end for _, h in ipairs(state.hunks) do if h.type == "delete" then local anchor = math.max(h.new_start, 1) if anchor == row then return h end else local lo = h.new_start local hi = h.new_start + h.new_count - 1 if row >= lo and row <= hi then return h end end end return nil end ---@param buf integer? ---@return integer buf ---@return ow.Git.Hunks.BufState? state ---@return ow.Git.Hunks.Hunk? hunk local function cursor_hunk(buf) buf = resolve_buf(buf) local state = states[buf] if not state then return buf, nil, nil end return buf, state, hunk_at(buf, vim.api.nvim_win_get_cursor(0)[1]) end ---@param h ow.Git.Hunks.Hunk ---@return integer local function anchor_line(h) if h.type == "delete" then return math.max(h.new_start, 1) end return h.new_start end ---@param direction "next"|"prev" function M.nav(direction) local buf = vim.api.nvim_get_current_buf() local state = states[buf] if not state or #state.hunks == 0 then return end local cur = vim.api.nvim_win_get_cursor(0)[1] local hunks = state.hunks local target = direction == "next" and hunks[1] or hunks[#hunks] if direction == "next" then for _, h in ipairs(hunks) do if anchor_line(h) > cur then target = h break end end else for i = #hunks, 1, -1 do if anchor_line(hunks[i]) < cur then target = hunks[i] break end end end if not target then return end vim.api.nvim_win_set_cursor(0, { anchor_line(target), 0 }) end ---@param h ow.Git.Hunks.Hunk ---@return string[] local function hunk_body(h) local lines = { string.format( "@@ -%d,%d +%d,%d @@", h.old_start, h.old_count, h.new_start, h.new_count ), } for _, l in ipairs(h.old_lines) do table.insert(lines, "-" .. l) end for _, l in ipairs(h.new_lines) do table.insert(lines, "+" .. l) end return lines end ---@param h ow.Git.Hunks.Hunk ---@param state ow.Git.Hunks.BufState ---@return string local function build_patch(h, state) local lines = { "--- a/" .. state.rel, "+++ b/" .. state.rel } vim.list_extend(lines, hunk_body(h)) return table.concat(lines, "\n") .. "\n" end ---@param buf? integer function M.stage_hunk(buf) local _, state, h = cursor_hunk(buf) if not state then return end if not h then util.warning("git hunks: no hunk at cursor") return end util.git({ "apply", "--cached", "--unidiff-zero", "-" }, { cwd = state.repo.worktree, stdin = build_patch(h, state), on_exit = function(res) if res.code ~= 0 then util.error( "git apply failed: %s", vim.trim(res.stderr or "") ) end end, }) end ---@param buf? integer function M.reset_hunk(buf) local target, state, h = cursor_hunk(buf) if not state then return end if not h then util.warning("git hunks: no hunk at cursor") return end if h.type == "add" then vim.api.nvim_buf_set_lines( target, h.new_start - 1, h.new_start - 1 + h.new_count, false, {} ) elseif h.type == "delete" then vim.api.nvim_buf_set_lines( target, h.new_start, h.new_start, false, h.old_lines ) else vim.api.nvim_buf_set_lines( target, h.new_start - 1, h.new_start - 1 + h.new_count, false, h.old_lines ) end end ---@param buf? integer function M.select_hunk(buf) local _, _, h = cursor_hunk(buf) if not h or h.type == "delete" then return end local first = h.new_start local last = h.new_start + math.max(h.new_count, 1) - 1 vim.api.nvim_win_set_cursor(0, { first, 0 }) vim.cmd("normal! V") vim.api.nvim_win_set_cursor(0, { last, 0 }) end local preview_win ---@type integer? ---@param buf? integer function M.preview_hunk(buf) if preview_win and vim.api.nvim_win_is_valid(preview_win) then vim.api.nvim_set_current_win(preview_win) return end local target, state, h = cursor_hunk(buf) if not state then return end if not h then return end local lines = hunk_body(h) local pbuf = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines) vim.bo[pbuf].filetype = "diff" vim.bo[pbuf].bufhidden = "wipe" local width = 0 for _, l in ipairs(lines) do if #l > width then width = #l end end width = math.min(math.max(width + 2, 40), vim.o.columns - 4) local height = math.min(#lines, math.floor(vim.o.lines / 2)) local win = vim.api.nvim_open_win(pbuf, false, { relative = "cursor", row = 1, col = 0, width = width, height = height, style = "minimal", }) preview_win = win local function close() if vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_close(win, true) end end local group = vim.api.nvim_create_augroup("ow.git.hunks.preview", { clear = true }) vim.api.nvim_create_autocmd( { "CursorMoved", "CursorMovedI", "InsertEnter" }, { group = group, buffer = target, callback = close } ) vim.api.nvim_create_autocmd("WinLeave", { group = group, buffer = pbuf, callback = close, }) vim.api.nvim_create_autocmd("WinClosed", { group = group, pattern = tostring(win), callback = function() preview_win = nil pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks.preview") end, }) vim.keymap.set("n", "q", close, { buffer = pbuf, nowait = true }) end repo.on("change", function(r, change) for buf, state in pairs(states) do if state.repo == r and (change.paths[state.rel] or change.branch_changed) then schedule(buf) end end end) for _, buf in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(buf) then M.attach(buf) end end return M