local popup = require("git.core.popup") 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") local NS_PREVIEW = vim.api.nvim_create_namespace("ow.git.hunks.preview") local NS_PREVIEW_OVERFLOW = vim.api.nvim_create_namespace("ow.git.hunks.preview.overflow") ---@alias ow.Git.Hunks.HunkType "add"|"change"|"delete" ---@class ow.Git.Hunks.Hunk ---@field old_start integer 1-indexed first old line ---@field old_count integer ---@field new_start integer 1-indexed first new line ---@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 head string[]? ---@field head_sha string? ---@field index_hl { src: string[], lines: table[][]? }? ---@field hunks ow.Git.Hunks.Hunk[] ---@field staged 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 ---@return boolean local function inline_diff_enabled() for _, item in ipairs(vim.split(vim.o.diffopt, ",", { plain = true })) do if item == "inline:none" then return false end end return true end ---@param old_lines string[] ---@param new_lines string[] ---@return ow.Git.Hunks.Hunk[] local function compute_hunks(old_lines, new_lines) local raw = vim.text.diff( table.concat(old_lines, "\n"), table.concat(new_lines, "\n"), diff_opts() ) ---@type ow.Git.Hunks.Hunk[] local hunks = {} if type(raw) ~= "table" then return hunks 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 = {} if typ ~= "add" then for i = os_, os_ + oc - 1 do table.insert(old, old_lines[i] or "") end end local new = {} if typ ~= "delete" then for i = ns_, ns_ + nc - 1 do table.insert(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, new_lines = new, }) end return 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 ---@type table local SIGN_HL = { add = "GitHunkAdded", change = "GitHunkChanged", delete = "GitHunkRemoved", } ---@type table local STAGED_SIGN_HL = { add = "GitHunkStagedAdded", change = "GitHunkStagedChanged", delete = "GitHunkStagedRemoved", } ---@param h ow.Git.Hunks.Hunk ---@param line_count integer ---@return integer[] 0-indexed buffer rows for the hunk local function hunk_rows(h, line_count) 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 return { row } end local rows = {} 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 table.insert(rows, row) end end return rows end ---@param h ow.Git.Hunks.Hunk ---@return integer 1-indexed last index line the hunk occupies local function index_end(h) if h.old_count == 0 then return h.old_start end return h.old_start + h.old_count - 1 end ---@param unstaged ow.Git.Hunks.Hunk[] ---@param iline integer 1-indexed index line ---@return integer? 1-indexed buffer line local function index_to_buffer(unstaged, iline) local delta = 0 for _, h in ipairs(unstaged) do if h.old_count > 0 and iline >= h.old_start and iline <= index_end(h) then return nil end if iline > index_end(h) then delta = delta + h.new_count - h.old_count end end return iline + delta end ---@param state ow.Git.Hunks.BufState ---@param line_count integer ---@return { row: integer, hunk: ow.Git.Hunks.Hunk }[] row is a 0-indexed buffer row local function staged_signs(state, line_count) local out = {} for _, h in ipairs(state.staged) do local index_lines = {} if h.type == "delete" then table.insert(index_lines, math.max(h.new_start, 1)) else for i = h.new_start, h.new_start + h.new_count - 1 do table.insert(index_lines, i) end end for _, iline in ipairs(index_lines) do local bline = index_to_buffer(state.hunks, iline) if bline then local row = math.min(math.max(bline - 1, 0), line_count - 1) table.insert(out, { row = row, hunk = h }) end end end return out 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) local signed = {} for _, h in ipairs(state.hunks) do for _, row in ipairs(hunk_rows(h, line_count)) do signed[row] = true pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, row, 0, { sign_text = signs[h.type], sign_hl_group = SIGN_HL[h.type], priority = 100, }) end end for _, s in ipairs(staged_signs(state, line_count)) do if not signed[s.row] then pcall(vim.api.nvim_buf_set_extmark, buf, NS_SIGNS, s.row, 0, { sign_text = signs[s.hunk.type], sign_hl_group = STAGED_SIGN_HL[s.hunk.type], priority = 100, }) end end end local SKIP_CAPTURES = { spell = true, nospell = true, conceal = true } ---@alias ow.Git.Hunks.InlineSpan { start_col: integer, end_col: integer } ---@param line string ---@return { text: string, start_col: integer, end_col: integer }[] local function char_pieces(line) local out = {} local start = 1 for c in line:gmatch("[%z\1-\127\194-\244][\128-\191]*") do local stop = start + #c - 1 table.insert(out, { text = c, start_col = start - 1, end_col = stop, }) start = stop + 1 end if start <= #line then table.insert(out, { text = line:sub(start), start_col = start - 1, end_col = #line, }) end return out end ---@param pieces { text: string, start_col: integer, end_col: integer }[] ---@param start_idx integer ---@param count integer ---@return ow.Git.Hunks.InlineSpan? local function char_span(pieces, start_idx, count) if count <= 0 then return nil end local first = pieces[start_idx] local last = pieces[start_idx + count - 1] if not first or not last then return nil end return { start_col = first.start_col, end_col = last.end_col } end ---@param old_line string ---@param new_line string ---@return ow.Git.Hunks.InlineSpan[] old_spans ---@return ow.Git.Hunks.InlineSpan[] new_spans local function inline_spans(old_line, new_line) if old_line == new_line or not inline_diff_enabled() then return {}, {} end local old_pieces = char_pieces(old_line) local new_pieces = char_pieces(new_line) if #old_pieces == 0 then return {}, #new_pieces > 0 and { { start_col = 0, end_col = #new_line } } or {} end if #new_pieces == 0 then return { { start_col = 0, end_col = #old_line } }, {} end local old_text = {} for _, c in ipairs(old_pieces) do table.insert(old_text, c.text) end local new_text = {} for _, c in ipairs(new_pieces) do table.insert(new_text, c.text) end local raw = vim.text.diff( table.concat(old_text, "\n"), table.concat(new_text, "\n"), diff_opts() ) if type(raw) ~= "table" then return { { start_col = 0, end_col = #old_line } }, { { start_col = 0, end_col = #new_line }, } end local old_spans = {} local new_spans = {} 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 old_span = char_span(old_pieces, os_, oc) if old_span then table.insert(old_spans, old_span) end local new_span = char_span(new_pieces, ns_, nc) if new_span then table.insert(new_spans, new_span) end end return old_spans, new_spans end ---@param hl string|string[] ---@return string[] local function hl_list(hl) if type(hl) == "table" then return vim.list_extend({}, hl) end return { hl } end ---@param col integer ---@param spans ow.Git.Hunks.InlineSpan[] ---@return boolean local function in_inline_span(col, spans) for _, span in ipairs(spans) do if col >= span.start_col and col < span.end_col then return true end end return false end ---@param line string ---@param chunks table[] ---@param spans ow.Git.Hunks.InlineSpan[]? ---@return table[] local function apply_inline_to_chunks(line, chunks, spans) if not spans or #spans == 0 then return chunks end local out = {} local col = 0 for _, chunk in ipairs(chunks) do local text = chunk[1] local hl = chunk[2] local pos = 1 while pos <= #text do local inline = in_inline_span(col, spans) local stop = pos + 1 while stop <= #text and in_inline_span(col + stop - pos, spans) == inline do stop = stop + 1 end local piece = text:sub(pos, stop - 1) local piece_hl = hl if inline then piece_hl = hl_list(hl) table.insert(piece_hl, "DiffText") end table.insert(out, { piece, piece_hl }) col = col + #piece pos = stop end end if col < #line then return chunks end return out end ---@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 ---@param inline_by_line table? ---@return table[] local function delete_virt_lines(h, hl_lines, inline_by_line) 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] local inline = inline_by_line and inline_by_line[i] if cached then local chunks = apply_inline_to_chunks( line, vim.list_extend({}, cached), inline ) table.insert(chunks, { string.rep(" ", pad), "GitHunkDeleteLine", }) table.insert(virt, chunks) else local chunks = apply_inline_to_chunks( line, { { line, "GitHunkDeleteLine" } }, inline ) table.insert(chunks, { string.rep(" ", pad), "GitHunkDeleteLine", }) table.insert(virt, chunks) end end return virt end ---@param h ow.Git.Hunks.Hunk ---@return table old_by_line ---@return table new_by_line local function hunk_inline_spans(h) if h.type ~= "change" or #h.old_lines ~= #h.new_lines then return {}, {} end local old_by_line = {} local new_by_line = {} for i = 1, math.min(#h.old_lines, #h.new_lines) do local old_line = h.old_lines[i] or "" local new_line = h.new_lines[i] or "" local old_spans, new_spans = inline_spans(old_line, new_line) old_by_line[i] = old_spans new_by_line[i] = new_spans end return old_by_line, new_by_line 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 local old_inline, new_inline = hunk_inline_spans(h) 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, } ) for _, span in ipairs(new_inline[r - h.new_start + 1] or {}) do pcall( vim.api.nvim_buf_set_extmark, buf, NS_OVERLAY, row, span.start_col, { end_col = span.end_col, hl_group = "DiffText", priority = 101, } ) end 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, old_inline), 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 rev string ---@param want string? the wanted blob sha ---@param have string? the currently-loaded blob sha ---@param apply fun(lines: string[]?, sha: string?) ---@param after fun() local function ensure_content(state, buf, rev, want, have, apply, after) if not want then apply(nil, nil) return after() end if want == have then return after() end util.git({ "cat-file", "-p", rev }, { 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 apply(util.split_lines(res.stdout or ""), want) else apply(nil, nil) end after() 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 r = state.repo ensure_content( state, buf, ":0:" .. state.rel, r:index_sha(state.rel), state.index_sha, function(lines, sha) state.index = lines state.index_sha = sha end, function() ensure_content( state, buf, "HEAD:" .. state.rel, r:head_sha(state.rel), state.head_sha, function(lines, sha) state.head = lines state.head_sha = sha end, function() local new = vim.api.nvim_buf_get_lines(buf, 0, -1, false) state.hunks = state.index and compute_hunks(state.index, new) or {} state.staged = state.head and state.index and compute_hunks(state.head, state.index) or {} render(buf) end ) end ) end local schedule, sched_handle = util.keyed_debounce(recompute, 100) ---@param buf integer function M._flush(buf) sched_handle.flush(buf) end ---@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, head = nil, head_sha = nil, hunks = {}, staged = {}, overlay = false, 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 hunks ow.Git.Hunks.Hunk[] ---@param row integer 1-indexed cursor line ---@return ow.Git.Hunks.Hunk? local function hunk_at(hunks, row) for _, h in ipairs(hunks) do if h.type == "delete" then if math.max(h.new_start, 1) == row then return h end elseif row >= h.new_start and row <= h.new_start + h.new_count - 1 then return h 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(state.hunks, vim.api.nvim_win_get_cursor(0)[1]) end ---@param h ow.Git.Hunks.Hunk ---@return integer 1-indexed buffer line to anchor the cursor on 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 local PATCH_CONTEXT = 3 ---@param h ow.Git.Hunks.Hunk ---@return integer old_before count of old lines before the hunk's changed content ---@return integer new_before count of new lines before the hunk's changed content local function hunk_offsets(h) if h.type == "add" then return h.old_start, h.new_start - 1 elseif h.type == "delete" then return h.old_start - 1, h.new_start end return h.old_start - 1, h.new_start - 1 end ---@param h ow.Git.Hunks.Hunk ---@return ow.Git.Hunks.Hunk local function invert(h) local typ ---@type ow.Git.Hunks.HunkType if h.type == "add" then typ = "delete" elseif h.type == "delete" then typ = "add" else typ = "change" end return { old_start = h.new_start, old_count = h.new_count, new_start = h.old_start, new_count = h.old_count, type = typ, old_lines = h.new_lines, new_lines = h.old_lines, } end ---@param h ow.Git.Hunks.Hunk ---@param old_lines string[] ---@param rel string ---@param include_header boolean? ---@param context integer? ---@return string[] patch lines ---@return boolean zero_context local function build_patch_lines(h, old_lines, rel, include_header, context) context = context or PATCH_CONTEXT local old_before, new_before = hunk_offsets(h) local pre = {} for i = math.max(old_before - context + 1, 1), old_before do pre[#pre + 1] = old_lines[i] or "" end local post = {} local after = old_before + h.old_count for i = after + 1, math.min(after + context, #old_lines) do post[#post + 1] = old_lines[i] or "" end local old_n = #pre + h.old_count + #post local new_n = #pre + h.new_count + #post local old_start = old_n > 0 and old_before - #pre + 1 or old_before local new_start = new_n > 0 and new_before - #pre + 1 or new_before local body = { string.format( "@@ -%d,%d +%d,%d @@", old_start, old_n, new_start, new_n ), } for _, l in ipairs(pre) do body[#body + 1] = " " .. l end for _, l in ipairs(h.old_lines) do body[#body + 1] = "-" .. l end for _, l in ipairs(h.new_lines) do body[#body + 1] = "+" .. l end for _, l in ipairs(post) do body[#body + 1] = " " .. l end local lines = {} if include_header ~= false then lines = { "--- a/" .. rel, "+++ b/" .. rel } end vim.list_extend(lines, body) return lines, #pre == 0 and #post == 0 end ---@param hunks ow.Git.Hunks.Hunk[] ---@param old_lines string[] ---@param rel string ---@param context integer? ---@return string patch ---@return boolean zero_context local function build_patch(hunks, old_lines, rel, context) local lines = { "--- a/" .. rel, "+++ b/" .. rel } local zero_context = false for _, h in ipairs(hunks) do local body, zero = build_patch_lines(h, old_lines, rel, false, context) vim.list_extend(lines, body) zero_context = zero_context or zero end return table.concat(lines, "\n") .. "\n", zero_context end ---@param state ow.Git.Hunks.BufState ---@param buf integer ---@param patch string ---@param zero_context boolean local function apply_patch(state, buf, patch, zero_context) local args = { "apply", "--cached" } if zero_context then table.insert(args, "--unidiff-zero") end table.insert(args, "-") util.git(args, { cwd = state.repo.worktree, stdin = patch, on_exit = function(res) if res.code ~= 0 then util.error("git apply failed: %s", vim.trim(res.stderr or "")) return end local s = states[buf] if s then s.index_sha = nil schedule(buf) end end, }) end ---@param h ow.Git.Hunks.Hunk ---@return integer first 1-indexed buffer line ---@return integer last 1-indexed buffer line local function hunk_range(h) if h.type == "delete" then local line = math.max(h.new_start, 1) return line, line end return h.new_start, h.new_start + h.new_count - 1 end ---@param h ow.Git.Hunks.Hunk ---@param first integer ---@param last integer ---@return boolean local function hunk_overlaps(h, first, last) local h_first, h_last = hunk_range(h) return h_first <= last and h_last >= first end ---@param state ow.Git.Hunks.BufState ---@param buf integer ---@param first integer ---@param last integer ---@return ow.Git.Hunks.Hunk[] local function staged_hunks_in_range(state, buf, first, last) local out = {} local seen = {} local line_count = vim.api.nvim_buf_line_count(buf) for _, s in ipairs(staged_signs(state, line_count)) do local line = s.row + 1 if line >= first and line <= last and not seen[s.hunk] then seen[s.hunk] = true table.insert(out, s.hunk) end end return out end ---@param first integer ---@param last integer ---@param buf? integer function M.toggle_stage_range(first, last, buf) buf = resolve_buf(buf) local state = states[buf] if not state or not state.index then return end if first > last then first, last = last, first end local unstaged = {} for _, h in ipairs(state.hunks) do if hunk_overlaps(h, first, last) then table.insert(unstaged, h) end end if #unstaged > 0 then local context = #unstaged > 1 and 0 or nil local patch, zero = build_patch(unstaged, state.index, state.rel, context) apply_patch(state, buf, patch, zero) return end local staged = staged_hunks_in_range(state, buf, first, last) if #staged > 0 then local inverted = {} for _, h in ipairs(staged) do table.insert(inverted, invert(h)) end local context = #inverted > 1 and 0 or nil local patch, zero = build_patch(inverted, state.index, state.rel, context) apply_patch(state, buf, patch, zero) return end util.warning("git hunks: no hunk in selection") end ---@param buf? integer function M.toggle_stage(buf) local row = vim.api.nvim_win_get_cursor(0)[1] M.toggle_stage_range(row, row, buf) end ---@param buf? integer function M.toggle_stage_selection(buf) local cursor_line = vim.api.nvim_win_get_cursor(0)[1] M.toggle_stage_range(vim.fn.line("v"), cursor_line, buf) 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].bufhidden = "wipe" util.paint_diff_lines(pbuf, NS_PREVIEW, lines, 0) local width, height = popup.size_for(lines, { min_width = 40, padding = 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 popup.paint_overflow(pbuf, win, NS_PREVIEW_OVERFLOW) 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.api.nvim_create_autocmd("WinScrolled", { group = group, pattern = tostring(win), callback = function() popup.paint_overflow(pbuf, win, NS_PREVIEW_OVERFLOW) 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