From 6040c054cb5ca33136697421259c7a1bcbefdfe4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:44:57 -0500 Subject: [PATCH] fix: carry forward highlighted hunks on reparse to reduce flicker (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Toggling large diffs via fugitive's `=` caused the top of the buffer to re-render and glitch. `ensure_cache` always created a new cache entry with `pending_clear=true` and `highlighted={}`, forcing `on_win` to clear and re-highlight every visible hunk — including stable ones above the toggle point that never changed. ## Solution On reparse, compare old and new hunk lists using a prefix + suffix matching strategy. Hunks that match (same filename, line count, and sampled content) carry forward their `highlighted` state so `on_win` skips them. Comparison is O(1) per hunk. Only runs when the old entry had `pending_clear=false`; `invalidate_cache`/`ColorScheme` paths still force full re-highlight. Closes #131 --- lua/diffs/init.lua | 75 ++++++++++++++++- spec/decoration_provider_spec.lua | 128 ++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 0499457..b3f2800 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -222,6 +222,75 @@ local function invalidate_cache(bufnr) end end +---@param a diffs.Hunk +---@param b diffs.Hunk +---@return boolean +local function hunks_eq(a, b) + local n = #a.lines + if n ~= #b.lines or a.filename ~= b.filename then + return false + end + if a.lines[1] ~= b.lines[1] then + return false + end + if n > 1 and a.lines[n] ~= b.lines[n] then + return false + end + if n > 2 then + local mid = math.floor(n / 2) + 1 + if a.lines[mid] ~= b.lines[mid] then + return false + end + end + return true +end + +---@param old_entry diffs.HunkCacheEntry +---@param new_hunks diffs.Hunk[] +---@return table +local function carry_forward_highlighted(old_entry, new_hunks) + local old_hunks = old_entry.hunks + local old_hl = old_entry.highlighted + local old_n = #old_hunks + local new_n = #new_hunks + local highlighted = {} + + local prefix_len = 0 + local limit = math.min(old_n, new_n) + for i = 1, limit do + if not hunks_eq(old_hunks[i], new_hunks[i]) then + break + end + if old_hl[i] then + highlighted[i] = true + end + prefix_len = i + end + + local suffix_len = 0 + local max_suffix = limit - prefix_len + for j = 0, max_suffix - 1 do + local old_idx = old_n - j + local new_idx = new_n - j + if not hunks_eq(old_hunks[old_idx], new_hunks[new_idx]) then + break + end + if old_hl[old_idx] then + highlighted[new_idx] = true + end + suffix_len = j + 1 + end + + dbg( + 'carry_forward: %d prefix + %d suffix of %d old -> %d new hunks', + prefix_len, + suffix_len, + old_n, + new_n + ) + return highlighted +end + ---@param bufnr integer local function ensure_cache(bufnr) if not vim.api.nvim_buf_is_valid(bufnr) then @@ -246,11 +315,12 @@ local function ensure_cache(bufnr) local lc = vim.api.nvim_buf_line_count(bufnr) local bc = vim.api.nvim_buf_get_offset(bufnr, lc) dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick) + local carried = entry and not entry.pending_clear and carry_forward_highlighted(entry, hunks) hunk_cache[bufnr] = { hunks = hunks, tick = tick, - highlighted = {}, - pending_clear = true, + highlighted = carried or {}, + pending_clear = not carried, line_count = lc, byte_count = bc, } @@ -862,6 +932,7 @@ M._test = { hunk_cache = hunk_cache, ensure_cache = ensure_cache, invalidate_cache = invalidate_cache, + hunks_eq = hunks_eq, } return M diff --git a/spec/decoration_provider_spec.lua b/spec/decoration_provider_spec.lua index f4249db..4b7b2dd 100644 --- a/spec/decoration_provider_spec.lua +++ b/spec/decoration_provider_spec.lua @@ -139,6 +139,134 @@ describe('decoration_provider', function() end) end) + describe('hunk stability', function() + it('carries forward highlighted for stable hunks on section expansion', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(2, #entry.hunks) + + entry.pending_clear = false + entry.highlighted = { [1] = true, [2] = true } + + vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, { + '@@ -5,1 +5,2 @@', + ' local z = 4', + '+local w = 5', + }) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.are.equal(3, #updated.hunks) + assert.is_true(updated.highlighted[1] == true) + assert.is_nil(updated.highlighted[2]) + assert.is_true(updated.highlighted[3] == true) + assert.is_false(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('carries forward highlighted for stable hunks on section collapse', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -5,1 +5,2 @@', + ' local z = 4', + '+local w = 5', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + assert.are.equal(3, #entry.hunks) + + entry.pending_clear = false + entry.highlighted = { [1] = true, [2] = true, [3] = true } + + vim.api.nvim_buf_set_lines(bufnr, 5, 8, false, {}) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.are.equal(2, #updated.hunks) + assert.is_true(updated.highlighted[1] == true) + assert.is_true(updated.highlighted[2] == true) + assert.is_false(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('bypasses carry-forward when pending_clear was true', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + '@@ -10,2 +10,3 @@', + ' function M.foo()', + '+ return true', + ' end', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + entry.highlighted = { [1] = true, [2] = true } + entry.pending_clear = true + + vim.api.nvim_buf_set_lines(bufnr, 5, 5, false, { + '@@ -5,1 +5,2 @@', + ' local z = 4', + '+local w = 5', + }) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.are.same({}, updated.highlighted) + assert.is_true(updated.pending_clear) + delete_buffer(bufnr) + end) + + it('does not carry forward when all hunks changed', function() + local bufnr = create_buffer({ + 'M test.lua', + '@@ -1,2 +1,2 @@', + ' local x = 1', + '-local y = 2', + '+local y = 3', + }) + diffs.attach(bufnr) + local entry = diffs._test.hunk_cache[bufnr] + + entry.pending_clear = false + entry.highlighted = { [1] = true } + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + 'M other.lua', + '@@ -1,1 +1,2 @@', + ' local a = 1', + '+local b = 2', + }) + diffs._test.ensure_cache(bufnr) + + local updated = diffs._test.hunk_cache[bufnr] + assert.is_nil(updated.highlighted[1]) + assert.is_false(updated.pending_clear) + delete_buffer(bufnr) + end) + end) + describe('multiple hunks in cache', function() it('stores all parsed hunks for a multi-hunk buffer', function() local bufnr = create_buffer({