diff --git a/README.md b/README.md index 4426dd2..d3841a2 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 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] []` | 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`) | -| `:GitHunkOverlay` | Toggle the in-buffer hunk overlay | ## Mappings @@ -55,7 +54,6 @@ 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 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 517a8e4..41affe7 100644 --- a/lua/git/hunks.lua +++ b/lua/git/hunks.lua @@ -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 @@ -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> - 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 @@ -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? diff --git a/plugin/git.lua b/plugin/git.lua index d2df8d0..898f075 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -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", "(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", "(git-hunk-select)", function() require("git.hunks").select_hunk() end, { silent = true, desc = "Select hunk under cursor" }) -vim.keymap.set("n", "(git-hunk-overlay)", function() - require("git.hunks").toggle_overlay() -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 554ab51..55d5a4b 100644 --- a/test/git/hunks_test.lua +++ b/test/git/hunks_test.lua @@ -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 - 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 })