From 18829182e47a59c60f71b513917d56a6b29e1359 Mon Sep 17 00:00:00 2001 From: Dave Aitken Date: Sun, 8 Mar 2026 16:29:42 +0000 Subject: [PATCH] Fix diff viewer scroll sync and add context folding to 2-pane diffs Scroll sync was janky because scrollbind stayed active during content replacement (causing windows to fight each other), cursor positions weren't reset between file switches, and there was no recovery from mouse-scroll-induced desync. Fixes: - Suspend scrollbind during content update, reset cursors to {1,0}, then re-enable with deferred syncbind via vim.schedule - Add BufEnter autocmd on diff buffers to re-sync scrollbind when focusing a window (recovers from mouse scroll on inactive window) - Apply same fixes to both 2-pane and 3-pane buffer modules Also adds context folding to 2-pane diffs (previously only 3-pane had this): large context-only regions are folded with 3 lines of context shown around each change, matching the existing 3-way behavior. --- lua/gitlad/ui/views/diff/buffer.lua | 117 ++++++++++++++++++++- lua/gitlad/ui/views/diff/buffer_triple.lua | 29 ++++- lua/gitlad/ui/views/diff/init.lua | 17 +++ tests/unit/test_diff_buffer.lua | 104 ++++++++++++++++++ 4 files changed, 263 insertions(+), 4 deletions(-) diff --git a/lua/gitlad/ui/views/diff/buffer.lua b/lua/gitlad/ui/views/diff/buffer.lua index 779a854..a27e5f1 100644 --- a/lua/gitlad/ui/views/diff/buffer.lua +++ b/lua/gitlad/ui/views/diff/buffer.lua @@ -144,6 +144,14 @@ function DiffBufferPair:set_content(aligned, file_path) -- Replace filler lines with ~ characters M._apply_filler_content(self.left_lines, self.right_lines, self.line_map) + -- Suspend scrollbind during content update to prevent the windows from + -- fighting each other as line counts change mid-replacement. + for _, winnr in ipairs({ self.left_winnr, self.right_winnr }) do + if vim.api.nvim_win_is_valid(winnr) then + vim.api.nvim_set_option_value("scrollbind", false, { win = winnr, scope = "local" }) + end + end + -- Unlock both buffers vim.bo[self.left_bufnr].modifiable = true vim.bo[self.right_bufnr].modifiable = true @@ -192,8 +200,25 @@ function DiffBufferPair:set_content(aligned, file_path) vim.bo[self.right_bufnr].modifiable = false end - -- Sync scroll positions - vim.cmd("syncbind") + -- Reset both windows to top-left before re-enabling scrollbind. + -- This ensures a clean baseline so scrollbind offsets start at zero. + for _, winnr in ipairs({ self.left_winnr, self.right_winnr }) do + if vim.api.nvim_win_is_valid(winnr) then + vim.api.nvim_win_set_cursor(winnr, { 1, 0 }) + end + end + + -- Re-enable scrollbind and force sync. + -- Deferred via vim.schedule so that all pending buffer/window operations + -- (filetype detection, extmark placement, treesitter attachment) settle first. + vim.schedule(function() + for _, winnr in ipairs({ self.left_winnr, self.right_winnr }) do + if vim.api.nvim_win_is_valid(winnr) then + vim.api.nvim_set_option_value("scrollbind", true, { win = winnr, scope = "local" }) + end + end + vim.cmd("syncbind") + end) end --- Apply line-level highlights based on the line_map. @@ -249,6 +274,94 @@ function DiffBufferPair:apply_diff_highlights() end end +--- Compute fold ranges for context-only regions in a 2-pane line_map. +--- Marks non-context lines + surrounding context_lines as visible, then returns +--- contiguous invisible regions of 2+ lines as fold ranges. +---@param line_map AlignedLineInfo[] Line metadata from content.align_sides() +---@param context_lines? number Lines of context around changes (default 3) +---@return number[][] fold_ranges List of {start, end} pairs (1-based line numbers) +function M.compute_fold_ranges(line_map, context_lines) + context_lines = context_lines or 3 + local total = #line_map + if total == 0 then + return {} + end + + -- Mark all lines as invisible initially + local visible = {} + for i = 1, total do + visible[i] = false + end + + -- Find non-context lines and mark them + surrounding context as visible + for i, info in ipairs(line_map) do + local is_context = (info.left_type == "context") and (info.right_type == "context") + if not is_context then + local start = math.max(1, i - context_lines) + local stop = math.min(total, i + context_lines) + for j = start, stop do + visible[j] = true + end + end + end + + -- Collect contiguous invisible regions as fold ranges + local ranges = {} + local fold_start = nil + for i = 1, total do + if not visible[i] then + if not fold_start then + fold_start = i + end + else + if fold_start then + local fold_end = i - 1 + -- Only fold ranges of 2+ lines (single-line folds aren't useful) + if fold_end - fold_start + 1 >= 2 then + table.insert(ranges, { fold_start, fold_end }) + end + fold_start = nil + end + end + end + -- Handle trailing fold + if fold_start then + local fold_end = total + if fold_end - fold_start + 1 >= 2 then + table.insert(ranges, { fold_start, fold_end }) + end + end + + return ranges +end + +--- Apply folds to both windows to hide large context regions between changes. +---@param line_map AlignedLineInfo[] Line metadata from alignment +---@param context_lines? number Lines of context around changes (default 3) +function DiffBufferPair:apply_folds(line_map, context_lines) + local fold_ranges = M.compute_fold_ranges(line_map, context_lines) + + if #fold_ranges == 0 then + return + end + + -- Apply folds in both windows + for _, winnr in ipairs({ self.left_winnr, self.right_winnr }) do + if vim.api.nvim_win_is_valid(winnr) then + vim.api.nvim_win_call(winnr, function() + vim.wo[winnr].foldtext = 'v:lua.require("gitlad.ui.views.diff.gutter").foldtext()' + vim.wo[winnr].foldenable = true + -- Clear existing folds + vim.cmd("normal! zE") + -- Create folds for each range + for _, range in ipairs(fold_ranges) do + vim.cmd(range[1] .. "," .. range[2] .. "fold") + end + end) + end + end +end + --- Get real (non-filler) lines from a buffer, stripping filler-extmarked lines. --- Only works for editable buffers that have filler extmarks set. ---@param bufnr number Buffer number to extract lines from diff --git a/lua/gitlad/ui/views/diff/buffer_triple.lua b/lua/gitlad/ui/views/diff/buffer_triple.lua index a1d30b9..afd91a0 100644 --- a/lua/gitlad/ui/views/diff/buffer_triple.lua +++ b/lua/gitlad/ui/views/diff/buffer_triple.lua @@ -183,6 +183,15 @@ function DiffBufferTriple:set_content(aligned, file_path) -- Replace filler lines with ~ characters M._apply_filler_content(self.left_lines, self.mid_lines, self.right_lines, self.line_map) + -- Suspend scrollbind during content update to prevent the windows from + -- fighting each other as line counts change mid-replacement. + local all_winnrs = { self.left_winnr, self.mid_winnr, self.right_winnr } + for _, winnr in ipairs(all_winnrs) do + if vim.api.nvim_win_is_valid(winnr) then + vim.api.nvim_set_option_value("scrollbind", false, { win = winnr, scope = "local" }) + end + end + -- Unlock all buffers vim.bo[self.left_bufnr].modifiable = true vim.bo[self.mid_bufnr].modifiable = true @@ -258,8 +267,24 @@ function DiffBufferTriple:set_content(aligned, file_path) vim.bo[self.right_bufnr].modifiable = false end - -- Sync scroll positions - vim.cmd("syncbind") + -- Reset all windows to top-left before re-enabling scrollbind. + for _, winnr in ipairs(all_winnrs) do + if vim.api.nvim_win_is_valid(winnr) then + vim.api.nvim_win_set_cursor(winnr, { 1, 0 }) + end + end + + -- Re-enable scrollbind and force sync. + -- Deferred via vim.schedule so that all pending buffer/window operations + -- (filetype detection, extmark placement, treesitter attachment) settle first. + vim.schedule(function() + for _, winnr in ipairs(all_winnrs) do + if vim.api.nvim_win_is_valid(winnr) then + vim.api.nvim_set_option_value("scrollbind", true, { win = winnr, scope = "local" }) + end + end + vim.cmd("syncbind") + end) end --- Apply line-level highlights for all three panes. diff --git a/lua/gitlad/ui/views/diff/init.lua b/lua/gitlad/ui/views/diff/init.lua index 0e5e4ff..4dd08d7 100644 --- a/lua/gitlad/ui/views/diff/init.lua +++ b/lua/gitlad/ui/views/diff/init.lua @@ -304,6 +304,7 @@ function DiffView:select_file(index) local file_pair = file_pairs[index] local aligned = content.align_sides(file_pair) self.buffer_pair:set_content(aligned, file_pair.new_path) + self.buffer_pair:apply_folds(aligned.line_map) -- Apply review overlays if we have review state self:_apply_review_overlays(file_pair.new_path) @@ -1013,6 +1014,22 @@ function DiffView:_setup_keymaps() }) end + -- Re-sync scrollbind whenever a diff window gains focus. + -- This recovers from desync caused by mouse-scrolling an inactive window + -- (Vim's "quickadj" behavior intentionally bypasses scrollbind for mouse scroll + -- on non-focused windows). + for _, bufnr in ipairs(buffers) do + vim.api.nvim_create_autocmd("BufEnter", { + buffer = bufnr, + callback = function() + if self._closed then + return true -- Remove autocmd + end + vim.cmd("syncbind") + end, + }) + end + for _, bufnr in ipairs(buffers) do keymap.set(bufnr, "n", "q", function() self:close() diff --git a/tests/unit/test_diff_buffer.lua b/tests/unit/test_diff_buffer.lua index 23faa77..3fc88f2 100644 --- a/tests/unit/test_diff_buffer.lua +++ b/tests/unit/test_diff_buffer.lua @@ -606,4 +606,108 @@ T["diff_buffer"]["_find_file_index"]["matches old_path for renamed files"] = fun eq(diff_view._find_file_index(file_pairs, "new_name.lua"), 1) end +-- ============================================================================= +-- compute_fold_ranges tests +-- ============================================================================= + +T["diff_buffer"]["compute_fold_ranges"] = MiniTest.new_set() + +T["diff_buffer"]["compute_fold_ranges"]["returns empty for empty line_map"] = function() + local buffer = require("gitlad.ui.views.diff.buffer") + eq(buffer.compute_fold_ranges({}), {}) +end + +T["diff_buffer"]["compute_fold_ranges"]["returns empty when all lines are non-context"] = function() + local buffer = require("gitlad.ui.views.diff.buffer") + local line_map = { + { left_type = "delete", right_type = "filler" }, + { left_type = "filler", right_type = "add" }, + } + eq(buffer.compute_fold_ranges(line_map), {}) +end + +T["diff_buffer"]["compute_fold_ranges"]["folds large context region between hunks"] = function() + local buffer = require("gitlad.ui.views.diff.buffer") + + -- Build: 1 change, 10 context lines, 1 change + local line_map = {} + table.insert(line_map, { left_type = "change", right_type = "change" }) + for _ = 1, 10 do + table.insert(line_map, { left_type = "context", right_type = "context" }) + end + table.insert(line_map, { left_type = "change", right_type = "change" }) + + -- With default context_lines=3: lines 1-4 visible (change + 3 context), + -- lines 5-8 folded, lines 9-12 visible (3 context + change) + local ranges = buffer.compute_fold_ranges(line_map) + eq(#ranges, 1) + eq(ranges[1], { 5, 8 }) +end + +T["diff_buffer"]["compute_fold_ranges"]["folds leading context before first hunk"] = function() + local buffer = require("gitlad.ui.views.diff.buffer") + + -- 8 context lines then 1 change + local line_map = {} + for _ = 1, 8 do + table.insert(line_map, { left_type = "context", right_type = "context" }) + end + table.insert(line_map, { left_type = "change", right_type = "change" }) + + -- Lines 1-5 should be folded, lines 6-9 visible (3 context + change) + local ranges = buffer.compute_fold_ranges(line_map) + eq(#ranges, 1) + eq(ranges[1], { 1, 5 }) +end + +T["diff_buffer"]["compute_fold_ranges"]["folds trailing context after last hunk"] = function() + local buffer = require("gitlad.ui.views.diff.buffer") + + -- 1 change then 8 context lines + local line_map = {} + table.insert(line_map, { left_type = "change", right_type = "change" }) + for _ = 1, 8 do + table.insert(line_map, { left_type = "context", right_type = "context" }) + end + + -- Lines 1-4 visible (change + 3 context), lines 5-9 folded + local ranges = buffer.compute_fold_ranges(line_map) + eq(#ranges, 1) + eq(ranges[1], { 5, 9 }) +end + +T["diff_buffer"]["compute_fold_ranges"]["respects custom context_lines"] = function() + local buffer = require("gitlad.ui.views.diff.buffer") + + -- 1 change, 10 context, 1 change + local line_map = {} + table.insert(line_map, { left_type = "change", right_type = "change" }) + for _ = 1, 10 do + table.insert(line_map, { left_type = "context", right_type = "context" }) + end + table.insert(line_map, { left_type = "change", right_type = "change" }) + + -- With context_lines=1: lines 1-2 visible, lines 3-10 folded, lines 11-12 visible + local ranges = buffer.compute_fold_ranges(line_map, 1) + eq(#ranges, 1) + eq(ranges[1], { 3, 10 }) +end + +T["diff_buffer"]["compute_fold_ranges"]["does not fold single-line regions"] = function() + local buffer = require("gitlad.ui.views.diff.buffer") + + -- 1 change, 7 context, 1 change (with context_lines=3: only 1 line gap) + local line_map = {} + table.insert(line_map, { left_type = "change", right_type = "change" }) + for _ = 1, 7 do + table.insert(line_map, { left_type = "context", right_type = "context" }) + end + table.insert(line_map, { left_type = "change", right_type = "change" }) + + -- With context_lines=3: 1 change + 3 context visible, 1 context gap, 3 context + 1 change visible + -- Gap is only 1 line, too small to fold + local ranges = buffer.compute_fold_ranges(line_map) + eq(#ranges, 0) +end + return T