fix(hunks): remove hunk overlay

This commit is contained in:
2026-05-29 15:06:17 +02:00
parent 351b5690c2
commit 73df30f1d0
4 changed files with 2 additions and 561 deletions
+1 -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 hunk overlay
- Gutter signs
- 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,6 @@ 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`) |
| `:GitHunkOverlay` | Toggle the in-buffer hunk overlay |
## Mappings
@@ -55,7 +54,6 @@ 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 the in-buffer hunk overlay |
| `<Plug>(git-hunk-next)` | Jump to next hunk |
| `<Plug>(git-hunk-prev)` | Jump to previous hunk |
+1 -398
View File
@@ -5,7 +5,6 @@ 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")
@@ -28,10 +27,8 @@ local NS_PREVIEW_OVERFLOW =
---@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<integer, ow.Git.Hunks.BufState>
@@ -71,16 +68,6 @@ 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[]
@@ -241,7 +228,7 @@ local function render_signs(buf)
end
vim.api.nvim_buf_clear_namespace(buf, NS_SIGNS, 0, -1)
local state = states[buf]
if not state or state.overlay then
if not state then
return
end
local signs = resolve_signs()
@@ -268,379 +255,9 @@ local function render_signs(buf)
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<integer, table<integer, string>>
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<integer, ow.Git.Hunks.InlineSpan[]>?
---@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<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[][]?
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
@@ -758,7 +375,6 @@ function M.attach(buf)
head_sha = nil,
hunks = {},
staged = {},
overlay = false,
autocmds = {},
}
states[buf] = state
@@ -797,7 +413,6 @@ function M.detach(buf)
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)
@@ -807,18 +422,6 @@ function M.detach(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?
-11
View File
@@ -42,8 +42,6 @@ local DEFAULT_HIGHLIGHTS = {
GitHunkRemoved = "Removed",
GitHunkHeader = "Statement",
GitHunkAnnotation = "Title",
GitHunkAddLine = "DiffAdd",
GitHunkDeleteLine = "DiffDelete",
GitBlameAuthor = "GitAuthor",
GitBlameDate = "GitDate",
@@ -241,12 +239,6 @@ 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
@@ -326,9 +318,6 @@ 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-hunk-overlay)", function()
require("git.hunks").toggle_overlay()
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" })
-149
View File
@@ -47,14 +47,6 @@ local function sign_marks(buf)
return out
end
---@param buf integer
---@param ns_name string
---@return vim.api.keyset.get_extmark_item[]
local function detailed_marks(buf, ns_name)
local ns = vim.api.nvim_get_namespaces()[ns_name]
return vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true })
end
---@return integer?
local function find_float()
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
@@ -64,19 +56,6 @@ 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")
@@ -160,134 +139,6 @@ t.test("editing the buffer refreshes signs", function()
t.eq(hk.type, "change")
end)
t.test("overlay: change hunk shows deletion and addition", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
hunks.toggle_overlay(buf)
---@type integer?
local add_row
---@type vim.api.keyset.extmark_details?
local add_d
---@type vim.api.keyset.extmark_details?
local virt_d
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
local d = assert(m[4])
if d.line_hl_group then
add_row, add_d = m[2], d
elseif d.virt_lines then
virt_d = d
end
end
add_d = assert(add_d, "the added line should get a line highlight")
t.eq(add_row, 1, "addition highlighted on the changed line")
t.eq(add_d.line_hl_group, "GitHunkAddLine")
virt_d = assert(virt_d, "the deletion should render as virtual lines")
local piece = assert(assert(assert(virt_d.virt_lines)[1])[1])
t.truthy(
vim.startswith(piece[1], "b"),
"deleted line shows the old content"
)
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()
local _, buf = setup("a\nb\nc\n", "a\nc\n")
hunks.toggle_overlay(buf)
local marks = detailed_marks(buf, "ow.git.hunks.overlay")
t.eq(#marks, 1, "a pure delete has no addition highlight")
local d = assert(assert(marks[1])[4])
local piece = assert(assert(assert(d.virt_lines)[1])[1])
t.truthy(vim.startswith(piece[1], "b"))
t.eq(piece[2], "GitHunkDeleteLine")
end)
t.test("overlay: add hunk highlights the added lines", function()
local _, buf = setup("a\nd\n", "a\nb\nc\nd\n")
hunks.toggle_overlay(buf)
local rows = {}
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
local d = assert(m[4])
t.falsy(d.virt_lines, "a pure add has no deletion virtual lines")
t.eq(d.line_hl_group, "GitHunkAddLine")
table.insert(rows, m[2])
end
table.sort(rows)
t.eq(rows, { 1, 2 }, "both added lines highlighted")
end)
t.test("overlay: deleted lines are treesitter-highlighted", function()
local _, buf =
setup("-- a note\nlocal x = 1\nlocal y = 2\n", "local y = 2\n", "a.lua")
t.truthy(
pcall(vim.treesitter.start, buf, "lua"),
"the lua parser should be available"
)
hunks.toggle_overlay(buf)
---@type table[]?
local virt
for _, m in ipairs(detailed_marks(buf, "ow.git.hunks.overlay")) do
local d = assert(m[4])
if d.virt_lines then
virt = d.virt_lines
end
end
virt = assert(virt, "a deletion virtual line should render")
---@type table<string, boolean>
local seen = {}
for _, line in ipairs(virt) do
for _, c in ipairs(line) do
local hl = c[2]
if
type(hl) == "table"
and hl[1] == "GitHunkDeleteLine"
and hl[2]
then
seen[hl[2]] = true
end
end
end
t.truthy(seen["@comment"], "the deleted comment keeps its @comment group")
t.truthy(seen["@keyword"], "deleted code keeps its syntax groups")
end)
t.test("overlay: toggling swaps gutter signs for the overlay", function()
local _, buf = setup("a\nb\nc\n", "a\nB\nc\n")
t.truthy(
#detailed_marks(buf, "ow.git.hunks") > 0,
"gutter signs present while the overlay is off"
)
t.eq(#detailed_marks(buf, "ow.git.hunks.overlay"), 0)
hunks.toggle_overlay(buf)
t.truthy(
#detailed_marks(buf, "ow.git.hunks.overlay") > 0,
"overlay present once it is on"
)
t.eq(
#detailed_marks(buf, "ow.git.hunks"),
0,
"gutter signs replaced while the overlay is on"
)
hunks.toggle_overlay(buf)
t.eq(#detailed_marks(buf, "ow.git.hunks.overlay"), 0)
t.truthy(
#detailed_marks(buf, "ow.git.hunks") > 0,
"gutter signs restored after toggling the overlay off"
)
end)
t.test("toggle_stage stages the change into the index", function()
local dir, buf = setup("a\nb\nc\n", "a\nB\nc\n")
vim.api.nvim_win_set_cursor(0, { 2, 0 })