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:
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user