Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 115 additions & 2 deletions lua/gitlad/ui/views/diff/buffer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions lua/gitlad/ui/views/diff/buffer_triple.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions lua/gitlad/ui/views/diff/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/test_diff_buffer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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