diff --git a/README.md b/README.md index 52b9d4d..4426dd2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Features: - Status sidebar with stage/unstage/discard actions - Log viewer - Diff splits against any revision, the index, or the worktree -- Gutter signs + optional diff overlay +- Gutter signs + optional hunk overlay - Per-hunk stage / reset / preview / select - Blame: cursor-line popup, inline annotation, full-file gutter - Commit proxy (compose in a Neovim buffer) @@ -36,7 +36,7 @@ vim.pack.add({ "https://git.owall.se/oscar/git.nvim" }) | `:Gdiffsplit [vertical\|horizontal] []` | Open a diff split (default vertical, vs index) | | `:Gedit [:path]` | Open an object as a buffer (`git://` URI) | | `:Gstatus [sidebar\|split\|current]` | Open the status view (default `split`) | -| `:GitDiffOverlay` | Toggle the in-buffer diff overlay | +| `:GitHunkOverlay` | Toggle the in-buffer hunk overlay | ## Mappings @@ -55,7 +55,7 @@ vim.pack.add({ "https://git.owall.se/oscar/git.nvim" }) | `(git-hunk-select)` | Visually select the hunk under cursor | | `(git-hunk-stage-toggle)` | Stage / unstage hunk or visual hunks | | `(git-hunk-reset)` | Reset hunk | -| `(git-hunk-overlay-toggle)` | Toggle the in-buffer diff overlay | +| `(git-hunk-overlay)` | Toggle the in-buffer hunk overlay | | `(git-hunk-next)` | Jump to next hunk | | `(git-hunk-prev)` | Jump to previous hunk | diff --git a/lua/git/hunks.lua b/lua/git/hunks.lua index 9ca667e..517a8e4 100644 --- a/lua/git/hunks.lua +++ b/lua/git/hunks.lua @@ -71,6 +71,16 @@ local function diff_opts() 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[] @@ -260,6 +270,163 @@ 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[][]? @@ -330,29 +497,61 @@ 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) +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 = vim.list_extend({}, cached) + local chunks = apply_inline_to_chunks( + line, + vim.list_extend({}, cached), + inline + ) table.insert(chunks, { string.rep(" ", pad), "GitHunkDeleteLine", }) table.insert(virt, chunks) else - table.insert(virt, { - { line .. string.rep(" ", pad), "GitHunkDeleteLine" }, + 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[][]? @@ -382,6 +581,7 @@ local function render_overlay(buf) 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 @@ -397,6 +597,20 @@ local function render_overlay(buf) 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 @@ -414,7 +628,7 @@ local function render_overlay(buf) 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 = delete_virt_lines(h, hl_lines, old_inline), virt_lines_above = above, right_gravity = false, invalidate = true, diff --git a/plugin/git.lua b/plugin/git.lua index c1835fc..d2df8d0 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -241,6 +241,12 @@ end, { desc = "Open git status view", }) +vim.api.nvim_create_user_command("GitHunkOverlay", function() + require("git.hunks").toggle_overlay() +end, { + desc = "Toggle the git hunk overlay in the current buffer", +}) + vim.keymap.set("n", "(git-edit)", function() local rev = vim.fn.input("Edit git object: ") if rev == "" then @@ -320,14 +326,9 @@ end, { silent = true, desc = "Preview hunk under cursor" }) vim.keymap.set("n", "(git-hunk-select)", function() require("git.hunks").select_hunk() end, { silent = true, desc = "Select hunk under cursor" }) -vim.keymap.set("n", "(git-diff-overlay)", function() +vim.keymap.set("n", "(git-hunk-overlay)", function() require("git.hunks").toggle_overlay() -end, { silent = true, desc = "Toggle the git diff overlay" }) - -vim.api.nvim_create_user_command("GitDiffOverlay", function() - require("git.hunks").toggle_overlay() -end, { desc = "Toggle the git diff overlay in the current buffer" }) - +end, { silent = true, desc = "Toggle the git hunk overlay" }) vim.keymap.set("n", "(git-blame-popup)", function() require("git.blame").line_popup() end, { silent = true, desc = "Show git blame for the current line" }) diff --git a/test/git/hunks_test.lua b/test/git/hunks_test.lua index 6110e68..554ab51 100644 --- a/test/git/hunks_test.lua +++ b/test/git/hunks_test.lua @@ -64,6 +64,19 @@ local function find_float() end end +---@param hl string|string[]? +---@param group string +---@return boolean +local function has_hl(hl, group) + if hl == group then + return true + end + if type(hl) == "table" then + return vim.tbl_contains(hl, group) + end + return false +end + t.test("pure add: hunk shape and add signs", function() local _, buf, state = setup("a\nd\n", "a\nb\nc\nd\n") t.eq(#state.hunks, 1, "one hunk for a pure addition") @@ -173,7 +186,19 @@ t.test("overlay: change hunk shows deletion and addition", function() vim.startswith(piece[1], "b"), "deleted line shows the old content" ) - t.eq(piece[2], "GitHunkDeleteLine") + t.truthy(has_hl(piece[2], "GitHunkDeleteLine")) + t.truthy(has_hl(piece[2], "DiffText")) + local seen_inline = false + for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do + local d = assert(m[4]) + if d.hl_group == "DiffText" then + seen_inline = true + t.eq(m[2], 1, "inline addition highlight is on the changed line") + t.eq(m[3], 0, "inline addition starts at the changed byte") + t.eq(d.end_col, 1, "inline addition ends after the changed byte") + end + end + t.truthy(seen_inline, "the added side gets an inline highlight") end) t.test("overlay: delete hunk shows only deletion lines", function()