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