feat(hunks)!: highlight inline overlay diffs

BREAKING CHANGE: rename :GitDiffOverlay to :GitHunkOverlay and <Plug>(git-diff-overlay) to <Plug>(git-hunk-overlay).
This commit is contained in:
2026-05-29 14:59:18 +02:00
parent e6616e260b
commit 351b5690c2
4 changed files with 256 additions and 16 deletions
+3 -3
View File
@@ -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] [<rev>]` | Open a diff split (default vertical, vs index) |
| `:Gedit <rev>[: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" })
| `<Plug>(git-hunk-select)` | Visually select the hunk under cursor |
| `<Plug>(git-hunk-stage-toggle)` | Stage / unstage hunk or visual hunks |
| `<Plug>(git-hunk-reset)` | Reset hunk |
| `<Plug>(git-hunk-overlay-toggle)` | Toggle the in-buffer diff overlay |
| `<Plug>(git-hunk-overlay)` | Toggle the in-buffer hunk overlay |
| `<Plug>(git-hunk-next)` | Jump to next hunk |
| `<Plug>(git-hunk-prev)` | Jump to previous hunk |
+219 -5
View File
@@ -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<integer, ow.Git.Hunks.InlineSpan[]>?
---@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<integer, ow.Git.Hunks.InlineSpan[]> old_by_line
---@return table<integer, ow.Git.Hunks.InlineSpan[]> 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,
+8 -7
View File
@@ -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", "<Plug>(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", "<Plug>(git-hunk-select)", function()
require("git.hunks").select_hunk()
end, { silent = true, desc = "Select hunk under cursor" })
vim.keymap.set("n", "<Plug>(git-diff-overlay)", function()
vim.keymap.set("n", "<Plug>(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", "<Plug>(git-blame-popup)", function()
require("git.blame").line_popup()
end, { silent = true, desc = "Show git blame for the current line" })
+26 -1
View File
@@ -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()