From ac6afc8fd884514e76dab9e565de031eb3b737d4 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:35:09 +0100 Subject: [PATCH 01/16] fix: start finite-context folds closed --- lua/differ/merge/init.lua | 3 +- lua/differ/view.lua | 73 +++++++++++++-------------- test/nvim/view_spec.lua | 103 ++++++++++++++++++++++++++++---------- 3 files changed, 112 insertions(+), 67 deletions(-) diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 4e7dfe4..d6a833a 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -723,8 +723,7 @@ local function dress(win) end -- (re)create the native folds for a window from its fold ranges, left open by default --- (the structure stays so zM/zc collapse the unchanged regions on demand), mirroring --- view.lua:_apply_folds +-- (the structure stays so zM/zc collapse the unchanged regions on demand) ---@param win integer ---@param folds differ.FoldRange[]|nil local function apply_folds(win, folds) diff --git a/lua/differ/view.lua b/lua/differ/view.lua index b2d70aa..62b174c 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -459,24 +459,15 @@ function View:_stage_offset(idx) return off end --- (re)create the native folds for each column's window from its fold ranges, left --- open by default (the structure stays so zc/za collapse them on demand), unless --- `closed` says otherwise. `closed[i]` (per column, keyed by the fold's `gap` --- boundary index, not position in the list) re-closes the fold the user had --- manually closed before a context change shifted the ranges; omit it to open --- everything (a file switch or the initial open, where the previous fold state --- doesn't carry over). matching by `gap` rather than list position survives a --- neighbouring gap disappearing from the list entirely at the new context (not --- just becoming a non-real single-line range): a gap's boundary index is fixed --- by which hunks flank it, regardless of whether *that* gap folds at either --- context. reapplied only where the ranges or windows change: a context change --- (d= / d-), a file switch, a layout toggle, and open; never on scroll or redraw. --- with context = full the renderer returns no ranges. ----@param closed? table[] -- per-column, keyed by fold.gap: "was this one closed" -function View:_apply_folds(closed) +-- (re)create the native folds for each column's window from its fold ranges, closed +-- except the ones `opened` names. `opened[i]` is per column, keyed by the fold's `gap` +-- boundary index rather than list position, so a neighbouring gap vanishing at a new +-- context can't shift a later fold's key. with context = full there are no ranges +---@param opened? table[] +function View:_apply_folds(opened) for ci, col in ipairs(self.columns) do local win = col.winid - local was_closed = closed and closed[ci] + local col_opened = opened and opened[ci] or {} if win and vim.api.nvim_win_is_valid(win) then set_wo(win, "foldmethod", "manual") set_wo(win, "foldtext", FOLDTEXT_EXPR) @@ -486,7 +477,7 @@ function View:_apply_folds(closed) for _, f in ipairs(col.folds or {}) do if f.last > f.first then vim.cmd(("silent! %d,%dfold"):format(f.first, f.last)) -- :fold starts closed - if not (was_closed and f.gap ~= nil and was_closed[f.gap]) then + if f.gap ~= nil and col_opened[f.gap] then vim.cmd(("silent! %dfoldopen"):format(f.first)) end end @@ -496,6 +487,26 @@ function View:_apply_folds(closed) end end +-- per column, the `gap` of every fold currently open, for _apply_folds to reopen +---@return table[] +function View:_opened_folds() + local opened = {} + for ci, col in ipairs(self.columns) do + opened[ci] = {} + local win = col.winid + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_call(win, function() + for _, f in ipairs(col.folds or {}) do + if f.last > f.first and f.gap ~= nil then + opened[ci][f.gap] = vim.fn.foldclosed(f.first) == -1 + end + end + end) + end + end + return opened +end + -- the view owning the current buffer, if any. commands dispatch through this ---@return differ.View|nil function View.current() @@ -534,11 +545,15 @@ function View:set_source(model, staging, opts) if self.edit_win and self.model.path ~= model.path then self:_release_edit_window() end + local opened = nil + if self.model.path == model.path then + opened = self:_opened_folds() -- a same-file re-source keeps the folds opened with zo + end self.model = model self.staging = staging self:_init_staged() -- a new file: reseed staged state from the fresh git read self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) - self:_apply_folds() -- new file's ranges; windows unchanged so refold in place + self:_apply_folds(opened) -- windows unchanged so refold in place if opts and opts.focus_line then -- hold the precise position across a refresh self:focus_new_line(opts.focus_line, true, opts.focus_col) @@ -567,27 +582,9 @@ end -- count, so no relayout, content/map/gutter/highlights refresh in place ---@param n number function View:set_context(n) - -- snapshot which folds are closed before rerender replaces col.folds with the - -- ranges at the new context, so a manually-closed fold (zc/zm) survives the - -- boundary shift instead of reopening under the user. keyed by fold.gap, not - -- list position, so a neighbouring gap vanishing at the new context can't - -- shift a later fold's key out from under it (see _apply_folds) - local closed = {} - for ci, col in ipairs(self.columns) do - closed[ci] = {} - local win = col.winid - if win and vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_call(win, function() - for _, f in ipairs(col.folds or {}) do - if f.last > f.first and f.gap ~= nil then - closed[ci][f.gap] = vim.fn.foldclosed(f.first) ~= -1 - end - end - end) - end - end + local opened = self:_opened_folds() -- before rerender replaces col.folds self:rerender({ layout = self.layout, context = n, deep_diff = self.deep_diff }) - self:_apply_folds(closed) -- ranges shifted with the context; windows unchanged + self:_apply_folds(opened) -- ranges shifted with the context; windows unchanged end -- widen/narrow context by `delta`. narrowing from whole-file seeds FULL_STEP_DOWN diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index 7cd34b7..c77edb3 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -374,7 +374,7 @@ describe("view context controls", function() v:close() end) - it("creates the collapsed region as an open native fold the user can close", function() + it("creates the collapsed region as a closed native fold the user can open", function() local v = View.new(model("1\n2\n3\n4\n5\n6\n7\n8\n9\n", "1\nX\n3\n4\n5\n6\n7\nY\n9\n"), { layout = "stacked", context = 1, @@ -383,31 +383,38 @@ describe("view context controls", function() v:open() local win = v.columns[1].winid assert.are.equal("manual", vim.wo[win].foldmethod) - -- buffer rows 5..7 are the foldable middle: a real fold (level >= 1) but open - -- by default, so za/zo/zc/zm act on it and zc collapses it to its start row + -- buffer rows 5..7 are the foldable middle, collapsed to its start row local fold = vim.api.nvim_win_call(win, function() local c = vim.fn.foldclosed(5) - local l = vim.fn.foldlevel(5) - vim.cmd("normal! 5Gzc") - return { open = c, level = l, after_close = vim.fn.foldclosed(5) } + vim.cmd("normal! 5Gzo") + return { closed = c, after_open = vim.fn.foldclosed(5) } end) - assert.are.equal(-1, fold.open) -- open by default - assert.is_true(fold.level >= 1) -- but a real fold lives there - assert.are.equal(5, fold.after_close) -- and zc collapses it + assert.are.equal(5, fold.closed) + assert.are.equal(-1, fold.after_open) v:close() end) - it("keeps a manually-closed fold closed across a context change", function() + it("builds no native folds at whole-file context", function() + local v = gap_view() + v:open() + local level = vim.api.nvim_win_call(v.columns[1].winid, function() + return vim.fn.foldlevel(5) + end) + assert.are.equal(0, level) + v:close() + end) + + it("keeps an opened fold open across a context change", function() local v = gap_view() v:open() v:set_context(1) -- folds the 5-line gap local win = v.columns[1].winid local fold_row = real_fold(v).first vim.api.nvim_win_call(win, function() - vim.cmd(("normal! %dGzc"):format(fold_row)) + vim.cmd(("normal! %dGzo"):format(fold_row)) end) assert.are.equal( - fold_row, + -1, vim.api.nvim_win_call(win, function() return vim.fn.foldclosed(fold_row) end) @@ -415,24 +422,66 @@ describe("view context controls", function() v:set_context(0) -- narrows further: same gap, wider fold, range shifts local new_fold_row = real_fold(v).first assert.are.equal( - new_fold_row, + -1, vim.api.nvim_win_call(win, function() - return vim.fn.foldclosed(new_fold_row) -- still closed, not reset open + return vim.fn.foldclosed(new_fold_row) end) ) v:close() end) + -- the gap_view file at context 1, with its one fold opened by zo + local function opened_fold_view() + local v = View.new(model("1\n2\n3\n4\n5\n6\n7\n8\n9\n", "1\nX\n3\n4\n5\n6\n7\nY\n9\n"), { + layout = "stacked", + context = 1, + deep_diff = { enabled = true }, + }) + v:open() + vim.api.nvim_win_call(v.columns[1].winid, function() + vim.cmd("normal! 5Gzo") + end) + return v + end + + local function first_fold_closed_at(v) + local fold_row = real_fold(v).first + return fold_row, + vim.api.nvim_win_call(v.columns[1].winid, function() + return vim.fn.foldclosed(fold_row) + end) + end + + it("closes every fold again on a file switch", function() + local v = opened_fold_view() + v:set_source(diff.build({ + path = "y", + old_rev = "A", + new_rev = "B", + old_text = "a\nb\nc\nd\ne\nf\ng\nh\ni\n", + new_text = "a\nB\nc\nd\ne\nf\ng\nH\ni\n", + })) + local fold_row, closed_at = first_fold_closed_at(v) + assert.are.equal(fold_row, closed_at) + v:close() + end) + + it("keeps an opened fold open when the same file is re-sourced", function() + local v = opened_fold_view() + v:set_source(model("1\n2\n3\n4\n5\n6\n7\n8\n9\n", "1\nX\n3\n4\n5\n6\n7\nZ\n9\n")) + local _, closed_at = first_fold_closed_at(v) + assert.are.equal(-1, closed_at) + v:close() + end) + it( - "keeps the right fold closed when an earlier gap vanishes entirely at wider context", + "keeps the right fold open when an earlier gap vanishes entirely at wider context", function() -- a 2-line gap then a 10-line gap between three hunks. at context=0 both are -- real folds; widening to context=2 fully absorbs the 2-line gap as lead/tail - -- context (walk.lua never even emits a foldable line for it, so it's not just - -- non-real, it's absent from col.folds), while the 10-line gap stays real. - -- _apply_folds/set_context restore closed state by *position among real - -- folds*, so the vanished first entry shifts the second fold's index down - -- and its closed state is lost + -- context (walk.lua never even emits a foldable line for it, so it's absent + -- from col.folds), while the 10-line gap stays real. keyed by list position, + -- the vanished first entry would shift the second fold's index down local v = View.new( model( "1\ng1\ng2\n2\nh1\nh2\nh3\nh4\nh5\nh6\nh7\nh8\nh9\nh10\n3\n", @@ -446,10 +495,10 @@ describe("view context controls", function() local win = v.columns[1].winid local big_gap_fold = nth_real_fold(v, 2) vim.api.nvim_win_call(win, function() - vim.cmd(("normal! %dGzc"):format(big_gap_fold.first)) + vim.cmd(("normal! %dGzo"):format(big_gap_fold.first)) end) assert.are.equal( - big_gap_fold.first, + -1, vim.api.nvim_win_call(win, function() return vim.fn.foldclosed(big_gap_fold.first) end) @@ -459,24 +508,24 @@ describe("view context controls", function() assert.are.equal(1, fold_count(v)) -- only the big gap's fold remains local shifted_fold = nth_real_fold(v, 1) assert.are.equal( - shifted_fold.first, + -1, vim.api.nvim_win_call(win, function() - return vim.fn.foldclosed(shifted_fold.first) -- still closed, not reset open + return vim.fn.foldclosed(shifted_fold.first) end) ) v:close() end ) - it("still opens an untouched fold after a context change", function() + it("keeps an untouched fold closed after a context change", function() local v = gap_view() v:open() v:set_context(1) - v:set_context(0) -- shifts the fold's range; it was never closed + v:set_context(0) -- shifts the fold's range; it was never opened local win = v.columns[1].winid local fold_row = real_fold(v).first assert.are.equal( - -1, + fold_row, vim.api.nvim_win_call(win, function() return vim.fn.foldclosed(fold_row) end) From 641d85bdc3bc4331278ddbb86e75baf7e9ab0425 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:06:22 +0100 Subject: [PATCH 02/16] fix: keep opened folds across a layout toggle --- lua/differ/view.lua | 36 +++++++++++++++++------------------- test/nvim/view_spec.lua | 25 ++++++++++++++++++++----- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 62b174c..9a93f7b 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -459,15 +459,12 @@ function View:_stage_offset(idx) return off end --- (re)create the native folds for each column's window from its fold ranges, closed --- except the ones `opened` names. `opened[i]` is per column, keyed by the fold's `gap` --- boundary index rather than list position, so a neighbouring gap vanishing at a new --- context can't shift a later fold's key. with context = full there are no ranges ----@param opened? table[] +-- (re)create each column's native folds, closed unless `opened` holds their `gap` +---@param opened? table function View:_apply_folds(opened) - for ci, col in ipairs(self.columns) do + opened = opened or {} + for _, col in ipairs(self.columns) do local win = col.winid - local col_opened = opened and opened[ci] or {} if win and vim.api.nvim_win_is_valid(win) then set_wo(win, "foldmethod", "manual") set_wo(win, "foldtext", FOLDTEXT_EXPR) @@ -477,7 +474,7 @@ function View:_apply_folds(opened) for _, f in ipairs(col.folds or {}) do if f.last > f.first then vim.cmd(("silent! %d,%dfold"):format(f.first, f.last)) -- :fold starts closed - if f.gap ~= nil and col_opened[f.gap] then + if f.gap ~= nil and opened[f.gap] then vim.cmd(("silent! %dfoldopen"):format(f.first)) end end @@ -487,18 +484,17 @@ function View:_apply_folds(opened) end end --- per column, the `gap` of every fold currently open, for _apply_folds to reopen ----@return table[] +-- the `gap` of every open fold, in any column +---@return table function View:_opened_folds() local opened = {} - for ci, col in ipairs(self.columns) do - opened[ci] = {} + for _, col in ipairs(self.columns) do local win = col.winid if win and vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_call(win, function() for _, f in ipairs(col.folds or {}) do - if f.last > f.first and f.gap ~= nil then - opened[ci][f.gap] = vim.fn.foldclosed(f.first) == -1 + if f.last > f.first and f.gap ~= nil and vim.fn.foldclosed(f.first) == -1 then + opened[f.gap] = true end end end) @@ -547,13 +543,13 @@ function View:set_source(model, staging, opts) end local opened = nil if self.model.path == model.path then - opened = self:_opened_folds() -- a same-file re-source keeps the folds opened with zo + opened = self:_opened_folds() end self.model = model self.staging = staging self:_init_staged() -- a new file: reseed staged state from the fresh git read self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) - self:_apply_folds(opened) -- windows unchanged so refold in place + self:_apply_folds(opened) if opts and opts.focus_line then -- hold the precise position across a refresh self:focus_new_line(opts.focus_line, true, opts.focus_col) @@ -569,8 +565,9 @@ function View:set_layout(layout) if layout == self.layout then return end + local opened = self:_opened_folds() -- before rerender replaces the columns self:rerender({ layout = layout, context = self.context, deep_diff = self.deep_diff }) - self:_relayout() + self:_relayout(opened) end -- flip stacked <-> split @@ -1827,7 +1824,8 @@ end -- lay the columns into windows. the first column anchors on its existing window -- (or the current one on first open); extra columns reuse their window or vsplit -- a fresh one; >1 column scroll-binds. single authority for open + layout toggle -function View:_relayout() +---@param opened? table +function View:_relayout(opened) local anchor = self.columns[1].winid if not (anchor and vim.api.nvim_win_is_valid(anchor)) then anchor = vim.api.nvim_get_current_win() @@ -1852,7 +1850,7 @@ function View:_relayout() vim.api.nvim_set_current_win(self.columns[1].winid) vim.cmd("syncbind") end - self:_apply_folds() -- windows now exist; build the folds over unchanged regions + self:_apply_folds(opened) -- windows now exist; build the folds over unchanged regions self:_paint_cursorline() -- windows now exist; show the cursor line over the bg self:_arm_close_guard() -- re-arm now the winids are current end diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index c77edb3..b2f0045 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -466,6 +466,24 @@ describe("view context controls", function() v:close() end) + it("keeps an opened fold open across a layout toggle", function() + local v = opened_fold_view() + v:toggle_layout() -- -> split + local fold_row = real_fold(v).first + for _, col in ipairs(v.columns) do + assert.are.equal( + -1, + vim.api.nvim_win_call(col.winid, function() + return vim.fn.foldclosed(fold_row) + end) + ) + end + v:toggle_layout() -- -> stacked + local _, closed_at = first_fold_closed_at(v) + assert.are.equal(-1, closed_at) + v:close() + end) + it("keeps an opened fold open when the same file is re-sourced", function() local v = opened_fold_view() v:set_source(model("1\n2\n3\n4\n5\n6\n7\n8\n9\n", "1\nX\n3\n4\n5\n6\n7\nZ\n9\n")) @@ -477,11 +495,8 @@ describe("view context controls", function() it( "keeps the right fold open when an earlier gap vanishes entirely at wider context", function() - -- a 2-line gap then a 10-line gap between three hunks. at context=0 both are - -- real folds; widening to context=2 fully absorbs the 2-line gap as lead/tail - -- context (walk.lua never even emits a foldable line for it, so it's absent - -- from col.folds), while the 10-line gap stays real. keyed by list position, - -- the vanished first entry would shift the second fold's index down + -- a 2-line gap then a 10-line gap. context=2 absorbs the 2-line gap entirely + -- (absent from col.folds), so the 10-line gap's fold moves up the list local v = View.new( model( "1\ng1\ng2\n2\nh1\nh2\nh3\nh4\nh5\nh6\nh7\nh8\nh9\nh10\n3\n", From 32ce3ee52fcf33f57b7c073ecade8649bd885ab0 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:09:27 +0100 Subject: [PATCH 03/16] fix: run :Differ context and layout from the file panel --- lua/differ/command.lua | 6 ++---- test/nvim/command_spec.lua | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/lua/differ/command.lua b/lua/differ/command.lua index bba0cc9..bf36989 100644 --- a/lua/differ/command.lua +++ b/lua/differ/command.lua @@ -1,8 +1,6 @@ -- :Differ subcommand router. phase 1 wires the runtime view controls; -- diff/pr/log/mergetool subcommands arrive with their frontends (phases 2+) -local View = require("differ.view") - local M = {} ---@param msg string @@ -14,7 +12,7 @@ end -- :Differ layout [stacked|split], no arg flips the current view's layout ---@param arg string|nil function M.layout(arg) - local view = View.current() + local view = require("differ").active_view() if not view then return notify("no diff view here", vim.log.levels.WARN) end @@ -30,7 +28,7 @@ end -- :Differ context , sets/adjusts the per-view context lines ---@param arg string|nil function M.context(arg) - local view = View.current() + local view = require("differ").active_view() if not view then return notify("no diff view here", vim.log.levels.WARN) end diff --git a/test/nvim/command_spec.lua b/test/nvim/command_spec.lua index 341b309..50551fd 100644 --- a/test/nvim/command_spec.lua +++ b/test/nvim/command_spec.lua @@ -236,6 +236,39 @@ describe("command panel", function() end) end) +describe("command view controls from the panel", function() + local function view_under_panel() + vim.cmd("silent! only") + local model = require("differ.model.diff").build({ + path = "a.lua", + old_rev = "A", + new_rev = "B", + old_text = "1\n2\n3\n4\n5\n6\n7\n8\n9\n", + new_text = "1\nX\n3\n4\n5\n6\n7\nY\n9\n", + }) + local v = require("differ.view") + .new(model, { layout = "stacked", context = math.huge, deep_diff = { enabled = true } }) + :open() + return v, open_panel() + end + + it(":Differ context acts on the view the panel drives", function() + local v, p = view_under_panel() + command.context("0") + assert.are.equal(0, v.context) + p:close() + v:close() + end) + + it(":Differ layout acts on the view the panel drives", function() + local v, p = view_under_panel() + command.layout("split") + assert.are.equal("split", v.layout) + p:close() + v:close() + end) +end) + describe("command panel position", function() it("repositions the live panel", function() local p = open_panel() From d116490223aac45537b8bc3dc8ecc68459833fa4 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:26:29 +0100 Subject: [PATCH 04/16] fix: validate context in config and accept "full" --- lua/differ/config.lua | 34 ++++++++++++++++++++++++++++++++-- lua/differ/init.lua | 2 +- test/nvim/config_spec.lua | 14 ++++++++++++++ test/unit/config_spec.lua | 29 +++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/lua/differ/config.lua b/lua/differ/config.lua index 8c768b7..caf5e27 100644 --- a/lua/differ/config.lua +++ b/lua/differ/config.lua @@ -22,7 +22,7 @@ ---@class differ.Config ---@field layout differ.Layout ----@field context number -- math.huge = whole file, no folds +---@field context number|"full" -- "full" or math.huge = whole file, no folds ---@field wrap boolean ---@field diff_counter boolean ---@field cursorline_tint boolean @@ -57,7 +57,7 @@ local COMMENTS = { "expanded", "peek", "markers" } ---@type differ.Config M.defaults = { layout = "stacked", - context = math.huge, -- whole file, no folds + context = "full", -- whole file, no folds wrap = true, -- soft-wrap long lines in the diff view diff_counter = true, -- "hunk K/N" counter in the diff window's winbar -- tint the diff cursor line by the line's change kind (a stronger add/delete @@ -234,6 +234,21 @@ local function check_enum(path, value, diags) ) end +local function valid_context(value) + if value == "full" or value == math.huge then + return true + end + return type(value) == "number" and value >= 0 and value == math.floor(value) +end + +local function check_context(value, diags) + if not valid_context(value) then + diags[#diags + 1] = ('context must be a non-negative integer or "full" (got %s)'):format( + show(value) + ) + end +end + -- one level down into a known table option: unknown sub-keys and their enums. -- every nested default is non-nil, so absence from the default is a real typo local function check_section(key, value, diags) @@ -302,6 +317,8 @@ function M.validate(user) for key, value in pairs(user) do if key == "keymaps" then check_keymaps(value, diags) + elseif key == "context" then + check_context(value, diags) elseif M.defaults[key] == nil and not NILABLE[key] then diags[#diags + 1] = ('unknown option "%s"'):format(key) else @@ -374,6 +391,16 @@ local function clamp_closed(cfg) end end +-- the line count a view takes for a `context` option +---@param context number|"full" +---@return number +function M.context_lines(context) + if type(context) == "number" then + return context + end + return math.huge -- "full" +end + -- merge user opts over defaults and return the resolved config. keymaps are resolved -- into per-surface tables separately (a plain deep-extend would index-merge the -- multi-lhs lists) @@ -384,6 +411,9 @@ function M.resolve(user) local cfg = vim.tbl_deep_extend("force", M.defaults, user) cfg.keymaps = M.resolve_keymaps(user.keymaps) clamp_closed(cfg) + if not valid_context(cfg.context) then + cfg.context = M.defaults.context + end return cfg end diff --git a/lua/differ/init.lua b/lua/differ/init.lua index fc5d5bf..9986f0e 100644 --- a/lua/differ/init.lua +++ b/lua/differ/init.lua @@ -96,7 +96,7 @@ function M.diff_model(model, opts) return require("differ.view") .new(model, { layout = opts.layout or cfg.layout, - context = opts.context or cfg.context, + context = opts.context or config.context_lines(cfg.context), wrap = cfg.wrap, counter = cfg.diff_counter, cursorline_tint = cfg.cursorline_tint, diff --git a/test/nvim/config_spec.lua b/test/nvim/config_spec.lua index eda2bb7..c520dba 100644 --- a/test/nvim/config_spec.lua +++ b/test/nvim/config_spec.lua @@ -108,3 +108,17 @@ describe("config.resolve clamping", function() assert.are.equal("char", cfg.deep_diff.granularity) end) end) + +describe("config.resolve context", function() + it("keeps a valid context as written, 0 included", function() + assert.are.equal("full", config.resolve(nil).context) + assert.are.equal(math.huge, config.resolve({ context = math.huge }).context) + assert.are.equal(0, config.resolve({ context = 0 }).context) + assert.are.equal(3, config.resolve({ context = 3 }).context) + end) + + it("puts an invalid context back to its default", function() + assert.are.equal("full", config.resolve({ context = -1 }).context) + assert.are.equal("full", config.resolve({ context = "10" }).context) + end) +end) diff --git a/test/unit/config_spec.lua b/test/unit/config_spec.lua index a3abfe2..932f3dc 100644 --- a/test/unit/config_spec.lua +++ b/test/unit/config_spec.lua @@ -99,6 +99,27 @@ describe("config.validate", function() }, config.validate({ comments = { display = "inline" } })) end) + it("accepts a whole-number or whole-file context", function() + for _, context in ipairs({ 0, 3, "full", math.huge }) do + assert.are.same({}, config.validate({ context = context })) + end + end) + + it("flags a context that isn't a non-negative integer or full", function() + assert.are.same( + { 'context must be a non-negative integer or "full" (got -1)' }, + config.validate({ context = -1 }) + ) + assert.are.same( + { 'context must be a non-negative integer or "full" (got "10")' }, + config.validate({ context = "10" }) + ) + assert.are.same( + { 'context must be a non-negative integer or "full" (got 1.5)' }, + config.validate({ context = 1.5 }) + ) + end) + it("flags a misspelled keymap action, top-level and per-surface", function() assert.are.same( { 'unknown keymap action "keymaps.next_hunkk"' }, @@ -152,6 +173,14 @@ describe("config.validate", function() end) end) +describe("config.context_lines", function() + it("reads full as the whole file and passes a count through", function() + assert.are.equal(math.huge, config.context_lines("full")) + assert.are.equal(math.huge, config.context_lines(math.huge)) + assert.are.equal(0, config.context_lines(0)) + end) +end) + describe("config.closed", function() -- a renamed or dropped option would leave a dangling path here; validate() on -- the defaults can't see one, since it only walks keys that are actually set From d51f0f563275294465c3f83a639ff6cfb6dc046d Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:34:41 +0100 Subject: [PATCH 05/16] docs: describe folds that start closed --- CHANGELOG.md | 12 ++++++++++++ README.md | 6 +++--- doc/differ.txt | 15 +++++++++------ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f0106..11f162c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- `context` accepts `"full"` in config, matching `:Differ context full`, and it is now the default (`math.huge` still works) + +### Fixed + +- A finite `context` built its folds open, so nothing collapsed until `zM`, and every file switch reopened them. Folds now start closed and stay closed across files, and a fold opened with `zo` survives context/layout changes +- `:Differ context` and `:Differ layout` reported "no diff view here" from the file panel. They now act on the diff the panel drives +- An invalid `context` in config was accepted silently. It now warns and falls back to `"full"` + +## [0.1.44] — 2026-09-11 + ### Fixed - The diff drew its cursor line over the text even with `cursorlineopt=number`, and the panel, history and PR checks lists lost their cursor row under that setting. The diff now follows `cursorlineopt`, and the lists always highlight the row diff --git a/README.md b/README.md index 6ab7174..730c683 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ require("differ").setup({ }, layout = "stacked", -- "stacked" | "split" (can be toggled with :Differ layout in-session) - context = math.huge, -- fold threshold; math.huge = whole file, no folds + context = "full", -- "full" = whole file, no folds | integer = lines of context, folds start closed wrap = true, -- soft-wrap long lines in the diff view diff_counter = true, -- hunk counter in the diff window's winbar cursorline_tint = true, -- add/delete tint on the cursor line; only drawn when 'cursorline' is on @@ -231,10 +231,10 @@ Sources with worktree on the new side (`:Differ`, `:Differ `, `:Differ . | `:Differ layout [stacked\|split]` | Set layout; no argument toggles between | | `:Differ panel [left\|right\|top\|bottom]` | Reposition the live panel or history sidebar | | `:Differ context full` | Show the whole file, no folds (the default) | -| `:Differ context ` | Set the fold threshold around hunks | +| `:Differ context ` | Fold unchanged lines more than `` away from a hunk | | `:Differ context +` / `-` | Widen / narrow the threshold by one | -`context` decides where a native fold forms once an unchanged run exceeds it either side of a hunk; every line is in the buffer either way, so search, yank and motions are unaffected. Folds are created open, so nothing is hidden until you close one (`zc` / `za` / `zM`). `d-` narrows out of whole-file by landing on a threshold of 10 and stepping down from there; `d=` has nothing wider to reach, so it does nothing. +`context` decides where a native fold forms once an unchanged run exceeds it either side of a hunk; every line is in the buffer either way, so search, yank and motions are unaffected. Folds start closed and stay closed from file to file, so a number for `context` in `setup()` opens every session folded. A fold you open (`zo` / `za`) stays open through a context or layout change. `d-` narrows out of whole-file by landing on a threshold of 10, which collapses the file, and steps down from there; `d=` has nothing wider to reach, so it does nothing. Set `command_alias` in `setup()` to register a shorter name for the same command, e.g. `command_alias = "D"` gives `:D HEAD~1`, `:D log`. If you lazy-load on `cmd`, list the alias there too (`cmd = { "Differ", "D" }`); see [troubleshooting](TROUBLESHOOTING.md#command_alias-and-lazy-loading) (`:h differ-troubleshooting-command_alias-and-lazy-loading`) for why. diff --git a/doc/differ.txt b/doc/differ.txt index 384dbd1..b70e729 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -153,7 +153,7 @@ you’re in doubt, `:Differ sidecar` reports which one is actually running. }, layout = "stacked", -- "stacked" | "split" (can be toggled with :Differ layout in-session) - context = math.huge, -- fold threshold; math.huge = whole file, no folds + context = "full", -- "full" = whole file, no folds | integer = lines of context, folds start closed wrap = true, -- soft-wrap long lines in the diff view diff_counter = true, -- hunk counter in the diff window's winbar cursorline_tint = true, -- add/delete tint on the cursor line; only drawn when 'cursorline' is on @@ -300,16 +300,19 @@ RUNTIME CONTROLS *differ-usage-runtime-controls* :Differ context full Show the whole file, no folds (the default) - :Differ context Set the fold threshold around hunks + :Differ context Fold unchanged lines more than + away from a hunk :Differ context + / - Widen / narrow the threshold by one ------------------------------------------------------------------------------ `context` decides where a native fold forms once an unchanged run exceeds it either side of a hunk; every line is in the buffer either way, so search, yank -and motions are unaffected. Folds are created open, so nothing is hidden until -you close one (`zc` / `za` / `zM`). `d-` narrows out of whole-file by landing -on a threshold of 10 and stepping down from there; `d=` has nothing wider to -reach, so it does nothing. +and motions are unaffected. Folds start closed and stay closed from file to +file, so a number for `context` in `setup()` opens every session folded. A fold +you open (`zo` / `za`) stays open through a context or layout change. `d-` +narrows out of whole-file by landing on a threshold of 10, which collapses the +file, and steps down from there; `d=` has nothing wider to reach, so it does +nothing. Set `command_alias` in `setup()` to register a shorter name for the same command, e.g. `command_alias = "D"` gives `:D HEAD~1`, `:D log`. If you From 7c7e36cfac06c1527aba2a1b3c4d8479c8f74acb Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:59:28 +0100 Subject: [PATCH 06/16] fix: keep opened folds across a hunk revert --- lua/differ/view.lua | 34 +++++++++++++++---- test/nvim/view_spec.lua | 73 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 9a93f7b..862256e 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -460,9 +460,8 @@ function View:_stage_offset(idx) end -- (re)create each column's native folds, closed unless `opened` holds their `gap` ----@param opened? table +---@param opened table function View:_apply_folds(opened) - opened = opened or {} for _, col in ipairs(self.columns) do local win = col.winid if win and vim.api.nvim_win_is_valid(win) then @@ -541,7 +540,7 @@ function View:set_source(model, staging, opts) if self.edit_win and self.model.path ~= model.path then self:_release_edit_window() end - local opened = nil + local opened = {} if self.model.path == model.path then opened = self:_opened_folds() end @@ -1308,6 +1307,27 @@ function View:_rekey_staged(removed, before) self.staged_hunks = out end +-- the same shift for the gaps of opened folds: the two gaps either side of the +-- removed hunk join into one, open if either was +---@param opened table +---@param removed integer +---@param before integer -- hunk count before the revert +---@return table +function View:_rekey_opened(opened, removed, before) + if #self.model.hunks ~= before - 1 then + return {} + end + local out = {} + for gap in pairs(opened) do + if gap < removed then + out[gap] = true + else + out[gap - 1] = true + end + end + return out +end + -- the new-side line to land on once `h` is reverted: the cursor's own line, shifted by -- whatever the revert added or removed above it. a cursor inside the reverted region -- has no line of its own to return to, so it lands at the region's start @@ -1417,10 +1437,12 @@ function View:revert_hunk() return end + local opened = self:_opened_folds() -- before rerender replaces col.folds self.model = require("differ.model.diff").revert_hunk(self.model, idx) self:_rekey_staged(idx, before) + opened = self:_rekey_opened(opened, idx, before) self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) - self:_apply_folds() + self:_apply_folds(opened) -- stay where the reverted hunk was rather than being pulled to the next one if focus then self:_hold_new_line(reverted_focus(hunk, focus)) @@ -1824,7 +1846,7 @@ end -- lay the columns into windows. the first column anchors on its existing window -- (or the current one on first open); extra columns reuse their window or vsplit -- a fresh one; >1 column scroll-binds. single authority for open + layout toggle ----@param opened? table +---@param opened table function View:_relayout(opened) local anchor = self.columns[1].winid if not (anchor and vim.api.nvim_win_is_valid(anchor)) then @@ -1858,7 +1880,7 @@ end -- open the view: stacked takes the current window, split adds a scroll-bound pane ---@return differ.View function View:open() - self:_relayout() + self:_relayout({}) self:_focus_first_hunk() -- land on the first hunk; the tinted cursor line shows its kind self:_paint_cursorline() return self diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index b2f0045..98de77a 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -492,6 +492,79 @@ describe("view context controls", function() v:close() end) + -- hunks X, Y, Z with a 5-line gap before each of Y and Z, at context 1 + local function revertable_view() + return View.new( + model( + "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n", + "1\nX\n3\n4\n5\n6\n7\nY\n9\n10\n11\n12\n13\nZ\n15\n" + ), + { + layout = "stacked", + context = 1, + deep_diff = { enabled = true }, + can_stage = true, + staging = { + revert = function() + return true + end, + refresh = function() end, + }, + } + ):open() + end + + local function row_of(v, text) + for i, l in ipairs(vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false)) do + if l == text then + return i + end + end + end + + local function foldclosed_at(v, text) + return vim.api.nvim_win_call(v.columns[1].winid, function() + return vim.fn.foldclosed(row_of(v, text)) + end) + end + + local function revert_at(v, text) + local confirm = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end + finally(function() + vim.fn.confirm = confirm + end) + vim.api.nvim_set_current_win(v.columns[1].winid) + vim.api.nvim_win_set_cursor(0, { row_of(v, text), 0 }) + v:revert_hunk() + end + + it("keeps an opened fold open when a hunk above it is reverted", function() + local v = revertable_view() + vim.api.nvim_win_call(v.columns[1].winid, function() + vim.cmd(("normal! %dGzo"):format(row_of(v, "11"))) + end) + revert_at(v, "X") -- the Y..Z gap renumbers from 2 to 1 + assert.are.equal(2, #v.model.hunks) + assert.are.equal(-1, foldclosed_at(v, "11")) + assert.are.equal(row_of(v, "1"), foldclosed_at(v, "4")) -- the leading run, now folded + v:close() + end) + + it("keeps an opened fold open when a hunk below it is reverted", function() + local v = revertable_view() + vim.api.nvim_win_call(v.columns[1].winid, function() + vim.cmd(("normal! %dGzo"):format(row_of(v, "5"))) + end) + revert_at(v, "Z") + assert.are.equal(2, #v.model.hunks) + assert.are.equal(-1, foldclosed_at(v, "5")) + assert.are.equal(row_of(v, "10"), foldclosed_at(v, "11")) -- the joined trailing run + v:close() + end) + it( "keeps the right fold open when an earlier gap vanishes entirely at wider context", function() From 4a1e9a6be6c301056f544826f30a267daacd6333 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:29:22 +0100 Subject: [PATCH 07/16] fix: keep opened folds only when re-reading the same diff --- lua/differ/view.lua | 13 ++++++++++++- test/nvim/view_spec.lua | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 862256e..688f506 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -524,6 +524,17 @@ function View:is_open() return col ~= nil and col.winid ~= nil and vim.api.nvim_win_is_valid(col.winid) end +-- whether `b` re-reads the diff `a` shows, so a fold's gap names the same run in both +---@param a differ.DiffModel +---@param b differ.DiffModel +---@return boolean +local function same_diff(a, b) + return a.path == b.path + and a.old_rev == b.old_rev + and a.new_rev == b.new_rev + and #a.hunks == #b.hunks +end + -- swap the diffed file in place: same windows/layout/context, new model. the -- panel calls this when a different file is selected so the View is re-sourced, -- not recreated (separation of concerns). column count is layout-determined, @@ -541,7 +552,7 @@ function View:set_source(model, staging, opts) self:_release_edit_window() end local opened = {} - if self.model.path == model.path then + if same_diff(self.model, model) then opened = self:_opened_folds() end self.model = model diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index 98de77a..87cd1b4 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -493,7 +493,7 @@ describe("view context controls", function() end) -- hunks X, Y, Z with a 5-line gap before each of Y and Z, at context 1 - local function revertable_view() + local function three_hunk_view() return View.new( model( "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n", @@ -542,7 +542,7 @@ describe("view context controls", function() end it("keeps an opened fold open when a hunk above it is reverted", function() - local v = revertable_view() + local v = three_hunk_view() vim.api.nvim_win_call(v.columns[1].winid, function() vim.cmd(("normal! %dGzo"):format(row_of(v, "11"))) end) @@ -554,7 +554,7 @@ describe("view context controls", function() end) it("keeps an opened fold open when a hunk below it is reverted", function() - local v = revertable_view() + local v = three_hunk_view() vim.api.nvim_win_call(v.columns[1].winid, function() vim.cmd(("normal! %dGzo"):format(row_of(v, "5"))) end) @@ -565,6 +565,36 @@ describe("view context controls", function() v:close() end) + it("closes an opened fold when another diff of the same file is sourced", function() + -- the next commit in a file's history, and the file's other staging pair + for _, revs in ipairs({ { "B", "C" }, { "B", "WORKTREE" } }) do + local v = three_hunk_view() + vim.api.nvim_win_call(v.columns[1].winid, function() + vim.cmd(("normal! %dGzo"):format(row_of(v, "11"))) + end) + v:set_source(diff.build({ + path = "x", + old_rev = revs[1], + new_rev = revs[2], + old_text = v.model.new_text, + new_text = "1\nX2\n3\n4\n5\n6\n7\nY2\n9\n10\n11\n12\n13\nZ2\n15\n", + })) + assert.are.equal(row_of(v, "10"), foldclosed_at(v, "11")) + v:close() + end + end) + + it("closes an opened fold when a re-source changes the hunk count", function() + local v = three_hunk_view() + vim.api.nvim_win_call(v.columns[1].winid, function() + vim.cmd(("normal! %dGzo"):format(row_of(v, "11"))) + end) + v:set_source(model(v.model.old_text, "1\n2\n3\n4\n5\n6\n7\nY\n9\n10\n11\n12\n13\nZ\n15\n")) + assert.are.equal(2, #v.model.hunks) + assert.are.equal(row_of(v, "10"), foldclosed_at(v, "11")) + v:close() + end) + it( "keeps the right fold open when an earlier gap vanishes entirely at wider context", function() From 5c05924436352cf07874c1934c274364832b2e18 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:32:33 +0100 Subject: [PATCH 08/16] fix: accept "full" context for a single diff --- lua/differ/init.lua | 6 +++--- test/nvim/config_spec.lua | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/lua/differ/init.lua b/lua/differ/init.lua index 9986f0e..324dbc4 100644 --- a/lua/differ/init.lua +++ b/lua/differ/init.lua @@ -70,14 +70,14 @@ end ---@field old_rev string|nil ---@field new_rev string|nil ---@field layout differ.Layout|nil ----@field context number|nil +---@field context number|"full"|nil -- open a View from an already-built DiffModel. the git frontend and panel use -- this; `opts` overrides the layout/context defaults and carries the hunk-staging -- capability for worktree-status panels ---@class differ.DiffModelOpts ---@field layout? differ.Layout ----@field context? number +---@field context? number|"full" ---@field staging? differ.view.Staging ---@field can_stage? boolean ---@field on_edit_unstage? fun(path: string) @@ -96,7 +96,7 @@ function M.diff_model(model, opts) return require("differ.view") .new(model, { layout = opts.layout or cfg.layout, - context = opts.context or config.context_lines(cfg.context), + context = config.context_lines(opts.context or cfg.context), wrap = cfg.wrap, counter = cfg.diff_counter, cursorline_tint = cfg.cursorline_tint, diff --git a/test/nvim/config_spec.lua b/test/nvim/config_spec.lua index c520dba..0a7919f 100644 --- a/test/nvim/config_spec.lua +++ b/test/nvim/config_spec.lua @@ -122,3 +122,34 @@ describe("config.resolve context", function() assert.are.equal("full", config.resolve({ context = "10" }).context) end) end) + +describe("context reaching the view", function() + after_each(function() + require("differ").setup({}) + end) + + -- the view's context for a config `context` and a per-diff one + local function view_context(configured, given) + require("differ").setup({ context = configured }) + local v = require("differ").diff({ + path = "x", + old_text = "a\n", + new_text = "b\n", + context = given, + }) + local context = v.context + v:close() + return context + end + + it("reads full as the whole file, from config or a single diff", function() + assert.are.equal(math.huge, view_context(nil, nil)) + assert.are.equal(math.huge, view_context("full", nil)) + assert.are.equal(math.huge, view_context(3, "full")) + end) + + it("passes a count through, a single diff's over config", function() + assert.are.equal(3, view_context(3, nil)) + assert.are.equal(0, view_context("full", 0)) + end) +end) From 63e1bb99e1750ff7f2583ef54fc7918bf53af200 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:46:07 +0100 Subject: [PATCH 09/16] fix: act only on a diff view in the current tab --- lua/differ/init.lua | 33 +++++++++++++++++++++------------ test/nvim/command_spec.lua | 15 +++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/lua/differ/init.lua b/lua/differ/init.lua index 324dbc4..342e3f7 100644 --- a/lua/differ/init.lua +++ b/lua/differ/init.lua @@ -184,23 +184,32 @@ function M.close() require("differ.git").close() end +-- the view a panel or history sidebar drives from its origin window, when that +-- window is in the current tab +---@param sidebar? { origin_win?: integer } +---@return differ.View|nil +local function driven_view(sidebar) + if not sidebar then + return nil + end + local win = sidebar.origin_win + if not (win and vim.api.nvim_win_is_valid(win)) then + return nil + end + if vim.api.nvim_win_get_tabpage(win) ~= vim.api.nvim_get_current_tabpage() then + return nil + end + return require("differ.view").for_buf(vim.api.nvim_win_get_buf(win)) +end + -- the diff view to act on: the one under the cursor, or, when focused in the file -- panel, the view the panel drives (its origin window's buffer). lets the diff -- commands (gofile, hunk nav) work from either the diff window or the panel ---@return differ.View|nil function M.active_view() - local View = require("differ.view") - local view = View.current() - if view then - return view - end - local panel = require("differ.panel").current() - local history = require("differ.history").current() - local origin = (panel and panel.origin_win) or (history and history.origin_win) - if origin and vim.api.nvim_win_is_valid(origin) then - return View.for_buf(vim.api.nvim_win_get_buf(origin)) - end - return nil + return require("differ.view").current() + or driven_view(require("differ.panel").current()) + or driven_view(require("differ.history").current()) end -- jump-to-file (the `de` keymap): from the diff under the cursor, close the diff --git a/test/nvim/command_spec.lua b/test/nvim/command_spec.lua index 50551fd..e96dbad 100644 --- a/test/nvim/command_spec.lua +++ b/test/nvim/command_spec.lua @@ -267,6 +267,21 @@ describe("command view controls from the panel", function() p:close() v:close() end) + + it("leaves a session in another tab alone", function() + local v, p = view_under_panel() + vim.cmd("tabnew") + local tab = vim.api.nvim_get_current_tabpage() + _G.notifs = {} + command.layout("split") + local on_tab = vim.api.nvim_get_current_tabpage() == tab + vim.cmd("tabclose") + p:close() + v:close() + assert.is_true(on_tab) + assert.are.equal("stacked", v.layout) + assert.matches("no diff view here", _G.notifs[#_G.notifs].msg) + end) end) describe("command panel position", function() From 59bb68d571da85ef34bdd8e738805ce8de26fc46 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:00:58 +0100 Subject: [PATCH 10/16] refactor: share fold helpers in the view spec --- lua/differ/config.lua | 2 +- test/nvim/view_spec.lua | 146 +++++++++++----------------------------- 2 files changed, 42 insertions(+), 106 deletions(-) diff --git a/lua/differ/config.lua b/lua/differ/config.lua index caf5e27..9147258 100644 --- a/lua/differ/config.lua +++ b/lua/differ/config.lua @@ -235,7 +235,7 @@ local function check_enum(path, value, diags) end local function valid_context(value) - if value == "full" or value == math.huge then + if value == "full" then return true end return type(value) == "number" and value >= 0 and value == math.floor(value) diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index 87cd1b4..95e0ec7 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -354,11 +354,24 @@ describe("view context controls", function() return nth_real_fold(view, 1) end + -- foldclosed() of buffer row `row` in column `ci`, the first by default + local function foldclosed(v, row, ci) + return vim.api.nvim_win_call(v.columns[ci or 1].winid, function() + return vim.fn.foldclosed(row) + end) + end + + local function open_fold(v, row) + vim.api.nvim_win_call(v.columns[1].winid, function() + vim.cmd(("normal! %dGzo"):format(row)) + end) + end + -- two hunks with a 5-line gap between them - local function gap_view() + local function gap_view(context) return View.new(model("1\n2\n3\n4\n5\n6\n7\n8\n9\n", "1\nX\n3\n4\n5\n6\n7\nY\n9\n"), { layout = "stacked", - context = math.huge, + context = context or math.huge, deep_diff = { enabled = true }, }) end @@ -375,22 +388,13 @@ describe("view context controls", function() end) it("creates the collapsed region as a closed native fold the user can open", function() - local v = View.new(model("1\n2\n3\n4\n5\n6\n7\n8\n9\n", "1\nX\n3\n4\n5\n6\n7\nY\n9\n"), { - layout = "stacked", - context = 1, - deep_diff = { enabled = true }, - }) + local v = gap_view(1) v:open() - local win = v.columns[1].winid - assert.are.equal("manual", vim.wo[win].foldmethod) + assert.are.equal("manual", vim.wo[v.columns[1].winid].foldmethod) -- buffer rows 5..7 are the foldable middle, collapsed to its start row - local fold = vim.api.nvim_win_call(win, function() - local c = vim.fn.foldclosed(5) - vim.cmd("normal! 5Gzo") - return { closed = c, after_open = vim.fn.foldclosed(5) } - end) - assert.are.equal(5, fold.closed) - assert.are.equal(-1, fold.after_open) + assert.are.equal(5, foldclosed(v, 5)) + open_fold(v, 5) + assert.are.equal(-1, foldclosed(v, 5)) v:close() end) @@ -408,50 +412,21 @@ describe("view context controls", function() local v = gap_view() v:open() v:set_context(1) -- folds the 5-line gap - local win = v.columns[1].winid - local fold_row = real_fold(v).first - vim.api.nvim_win_call(win, function() - vim.cmd(("normal! %dGzo"):format(fold_row)) - end) - assert.are.equal( - -1, - vim.api.nvim_win_call(win, function() - return vim.fn.foldclosed(fold_row) - end) - ) + open_fold(v, real_fold(v).first) + assert.are.equal(-1, foldclosed(v, real_fold(v).first)) v:set_context(0) -- narrows further: same gap, wider fold, range shifts - local new_fold_row = real_fold(v).first - assert.are.equal( - -1, - vim.api.nvim_win_call(win, function() - return vim.fn.foldclosed(new_fold_row) - end) - ) + assert.are.equal(-1, foldclosed(v, real_fold(v).first)) v:close() end) - -- the gap_view file at context 1, with its one fold opened by zo + -- the gap_view file at context 1, with its one fold opened local function opened_fold_view() - local v = View.new(model("1\n2\n3\n4\n5\n6\n7\n8\n9\n", "1\nX\n3\n4\n5\n6\n7\nY\n9\n"), { - layout = "stacked", - context = 1, - deep_diff = { enabled = true }, - }) + local v = gap_view(1) v:open() - vim.api.nvim_win_call(v.columns[1].winid, function() - vim.cmd("normal! 5Gzo") - end) + open_fold(v, 5) return v end - local function first_fold_closed_at(v) - local fold_row = real_fold(v).first - return fold_row, - vim.api.nvim_win_call(v.columns[1].winid, function() - return vim.fn.foldclosed(fold_row) - end) - end - it("closes every fold again on a file switch", function() local v = opened_fold_view() v:set_source(diff.build({ @@ -461,34 +436,26 @@ describe("view context controls", function() old_text = "a\nb\nc\nd\ne\nf\ng\nh\ni\n", new_text = "a\nB\nc\nd\ne\nf\ng\nH\ni\n", })) - local fold_row, closed_at = first_fold_closed_at(v) - assert.are.equal(fold_row, closed_at) + local fold_row = real_fold(v).first + assert.are.equal(fold_row, foldclosed(v, fold_row)) v:close() end) it("keeps an opened fold open across a layout toggle", function() local v = opened_fold_view() v:toggle_layout() -- -> split - local fold_row = real_fold(v).first - for _, col in ipairs(v.columns) do - assert.are.equal( - -1, - vim.api.nvim_win_call(col.winid, function() - return vim.fn.foldclosed(fold_row) - end) - ) + for ci in ipairs(v.columns) do + assert.are.equal(-1, foldclosed(v, real_fold(v).first, ci)) end v:toggle_layout() -- -> stacked - local _, closed_at = first_fold_closed_at(v) - assert.are.equal(-1, closed_at) + assert.are.equal(-1, foldclosed(v, real_fold(v).first)) v:close() end) it("keeps an opened fold open when the same file is re-sourced", function() local v = opened_fold_view() v:set_source(model("1\n2\n3\n4\n5\n6\n7\n8\n9\n", "1\nX\n3\n4\n5\n6\n7\nZ\n9\n")) - local _, closed_at = first_fold_closed_at(v) - assert.are.equal(-1, closed_at) + assert.are.equal(-1, foldclosed(v, real_fold(v).first)) v:close() end) @@ -523,9 +490,7 @@ describe("view context controls", function() end local function foldclosed_at(v, text) - return vim.api.nvim_win_call(v.columns[1].winid, function() - return vim.fn.foldclosed(row_of(v, text)) - end) + return foldclosed(v, row_of(v, text)) end local function revert_at(v, text) @@ -543,9 +508,7 @@ describe("view context controls", function() it("keeps an opened fold open when a hunk above it is reverted", function() local v = three_hunk_view() - vim.api.nvim_win_call(v.columns[1].winid, function() - vim.cmd(("normal! %dGzo"):format(row_of(v, "11"))) - end) + open_fold(v, row_of(v, "11")) revert_at(v, "X") -- the Y..Z gap renumbers from 2 to 1 assert.are.equal(2, #v.model.hunks) assert.are.equal(-1, foldclosed_at(v, "11")) @@ -555,9 +518,7 @@ describe("view context controls", function() it("keeps an opened fold open when a hunk below it is reverted", function() local v = three_hunk_view() - vim.api.nvim_win_call(v.columns[1].winid, function() - vim.cmd(("normal! %dGzo"):format(row_of(v, "5"))) - end) + open_fold(v, row_of(v, "5")) revert_at(v, "Z") assert.are.equal(2, #v.model.hunks) assert.are.equal(-1, foldclosed_at(v, "5")) @@ -569,9 +530,7 @@ describe("view context controls", function() -- the next commit in a file's history, and the file's other staging pair for _, revs in ipairs({ { "B", "C" }, { "B", "WORKTREE" } }) do local v = three_hunk_view() - vim.api.nvim_win_call(v.columns[1].winid, function() - vim.cmd(("normal! %dGzo"):format(row_of(v, "11"))) - end) + open_fold(v, row_of(v, "11")) v:set_source(diff.build({ path = "x", old_rev = revs[1], @@ -586,9 +545,7 @@ describe("view context controls", function() it("closes an opened fold when a re-source changes the hunk count", function() local v = three_hunk_view() - vim.api.nvim_win_call(v.columns[1].winid, function() - vim.cmd(("normal! %dGzo"):format(row_of(v, "11"))) - end) + open_fold(v, row_of(v, "11")) v:set_source(model(v.model.old_text, "1\n2\n3\n4\n5\n6\n7\nY\n9\n10\n11\n12\n13\nZ\n15\n")) assert.are.equal(2, #v.model.hunks) assert.are.equal(row_of(v, "10"), foldclosed_at(v, "11")) @@ -610,27 +567,12 @@ describe("view context controls", function() v:open() assert.are.equal(2, fold_count(v)) -- both gaps are real folds at context=0 - local win = v.columns[1].winid - local big_gap_fold = nth_real_fold(v, 2) - vim.api.nvim_win_call(win, function() - vim.cmd(("normal! %dGzo"):format(big_gap_fold.first)) - end) - assert.are.equal( - -1, - vim.api.nvim_win_call(win, function() - return vim.fn.foldclosed(big_gap_fold.first) - end) - ) + open_fold(v, nth_real_fold(v, 2).first) + assert.are.equal(-1, foldclosed(v, nth_real_fold(v, 2).first)) v:set_context(2) -- the 2-line gap is fully absorbed as context and disappears assert.are.equal(1, fold_count(v)) -- only the big gap's fold remains - local shifted_fold = nth_real_fold(v, 1) - assert.are.equal( - -1, - vim.api.nvim_win_call(win, function() - return vim.fn.foldclosed(shifted_fold.first) - end) - ) + assert.are.equal(-1, foldclosed(v, real_fold(v).first)) v:close() end ) @@ -640,14 +582,8 @@ describe("view context controls", function() v:open() v:set_context(1) v:set_context(0) -- shifts the fold's range; it was never opened - local win = v.columns[1].winid local fold_row = real_fold(v).first - assert.are.equal( - fold_row, - vim.api.nvim_win_call(win, function() - return vim.fn.foldclosed(fold_row) - end) - ) + assert.are.equal(fold_row, foldclosed(v, fold_row)) v:close() end) From 53ee3badf4d6947ec834b94b87a093090f4f4038 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:26:39 +0100 Subject: [PATCH 11/16] refactor: track opened folds by line range instead of gap index --- lua/differ/render/init.lua | 4 -- lua/differ/render/split.lua | 6 +-- lua/differ/render/stacked.lua | 6 +-- lua/differ/view.lua | 95 +++++++++++++++++++++-------------- test/nvim/stacked_spec.lua | 2 +- test/nvim/view_spec.lua | 62 ++++++++++++++++++++++- test/unit/split_spec.lua | 4 +- test/unit/stacked_spec.lua | 2 +- 8 files changed, 126 insertions(+), 55 deletions(-) diff --git a/lua/differ/render/init.lua b/lua/differ/render/init.lua index 0da28fa..27ddf68 100644 --- a/lua/differ/render/init.lua +++ b/lua/differ/render/init.lua @@ -11,10 +11,6 @@ ---@class differ.FoldRange ---@field first integer -- 1-based buffer line, inclusive ---@field last integer ----@field gap? integer -- boundary index this gap sits at (0 = before the first hunk, --- hi = between hunk hi and hi+1), stable across a context change --- regardless of whether the gap folds at any given context. --- merge.lua's regions-based folds don't set this ---@class differ.Column ---@field lines string[] -- this column's buffer content (filler rows = "") diff --git a/lua/differ/render/split.lua b/lua/differ/render/split.lua index 55eed26..7faa686 100644 --- a/lua/differ/render/split.lua +++ b/lua/differ/render/split.lua @@ -28,7 +28,6 @@ function M.render(model, opts) local old_lines, new_lines = {}, {} local folds = {} local fold_start = nil - local gap = 0 -- boundary index: 0 before the first hunk, hi between hunk hi and hi+1 -- push one aligned row, keeping both columns the same length ---@param ltext string|nil @@ -47,7 +46,7 @@ function M.render(model, opts) if foldable then fold_start = fold_start or #old_lines elseif fold_start then - folds[#folds + 1] = { first = fold_start, last = #old_lines - 1, gap = gap } + folds[#folds + 1] = { first = fold_start, last = #old_lines - 1 } fold_start = nil end end @@ -152,11 +151,10 @@ function M.render(model, opts) inss[#inss + 1] = k end flush(dels, inss) - gap = hi -- entering the gap between this hunk and the next end, }) if fold_start then - folds[#folds + 1] = { first = fold_start, last = #old_lines, gap = gap } + folds[#folds + 1] = { first = fold_start, last = #old_lines } end return result() diff --git a/lua/differ/render/stacked.lua b/lua/differ/render/stacked.lua index 4cb8cb5..e8125e9 100644 --- a/lua/differ/render/stacked.lua +++ b/lua/differ/render/stacked.lua @@ -22,7 +22,6 @@ function M.render(model, opts) local lines = {} local folds = {} local fold_start = nil - local gap = 0 -- boundary index: 0 before the first hunk, hi between hunk hi and hi+1 -- extend/close the running fold run as lines are pushed; foldable context -- lines accumulate, anything else closes the run at the previous line @@ -30,7 +29,7 @@ function M.render(model, opts) if foldable then fold_start = fold_start or #lines elseif fold_start then - folds[#folds + 1] = { first = fold_start, last = #lines - 1, gap = gap } + folds[#folds + 1] = { first = fold_start, last = #lines - 1 } fold_start = nil end end @@ -95,11 +94,10 @@ function M.render(model, opts) }) mark(false) end - gap = hi -- entering the gap between this hunk and the next end, }) if fold_start then - folds[#folds + 1] = { first = fold_start, last = #lines, gap = gap } + folds[#folds + 1] = { first = fold_start, last = #lines } end return { diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 688f506..e434185 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -459,9 +459,52 @@ function View:_stage_offset(idx) return off end --- (re)create each column's native folds, closed unless `opened` holds their `gap` ----@param opened table +-- open folds as the first and last rows of their unchanged runs, whose old/new line +-- numbers index the texts alongside +---@class differ.view.OpenedFolds +---@field old_text? string +---@field new_text? string +---@field runs { first: differ.RailLine, last: differ.RailLine }[] + +---@type differ.view.OpenedFolds +local NONE_OPENED = { runs = {} } + +-- the side whose line numbers mean the same lines in `opened` and `model`: one whose +-- text is unchanged. nil when both changed +---@param opened differ.view.OpenedFolds +---@param model differ.DiffModel +---@return "old"|"new"|nil +local function anchor_side(opened, model) + if opened.old_text == model.old_text then + return "old" + end + if opened.new_text == model.new_text then + return "new" + end + return nil +end + +-- whether fold `f` of column `col` shares a line with an opened run, on `side` +---@param opened differ.view.OpenedFolds +---@param side "old"|"new" +---@param col differ.ViewColumn +---@param f differ.FoldRange +---@return boolean +local function was_opened(opened, side, col, f) + local first, last = col.map.lines[f.first][side], col.map.lines[f.last][side] + for _, run in ipairs(opened.runs) do + if first <= run.last[side] and run.first[side] <= last then + return true + end + end + return false +end + +-- (re)create each column's native folds, closed unless they share a line with an +-- opened run +---@param opened differ.view.OpenedFolds function View:_apply_folds(opened) + local side = anchor_side(opened, self.model) for _, col in ipairs(self.columns) do local win = col.winid if win and vim.api.nvim_win_is_valid(win) then @@ -473,7 +516,7 @@ function View:_apply_folds(opened) for _, f in ipairs(col.folds or {}) do if f.last > f.first then vim.cmd(("silent! %d,%dfold"):format(f.first, f.last)) -- :fold starts closed - if f.gap ~= nil and opened[f.gap] then + if side and was_opened(opened, side, col, f) then vim.cmd(("silent! %dfoldopen"):format(f.first)) end end @@ -483,17 +526,18 @@ function View:_apply_folds(opened) end end --- the `gap` of every open fold, in any column ----@return table +-- the open folds of every column, against the current model's texts +---@return differ.view.OpenedFolds function View:_opened_folds() - local opened = {} + local opened = { old_text = self.model.old_text, new_text = self.model.new_text, runs = {} } for _, col in ipairs(self.columns) do local win = col.winid if win and vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_call(win, function() for _, f in ipairs(col.folds or {}) do - if f.last > f.first and f.gap ~= nil and vim.fn.foldclosed(f.first) == -1 then - opened[f.gap] = true + if f.last > f.first and vim.fn.foldclosed(f.first) == -1 then + opened.runs[#opened.runs + 1] = + { first = col.map.lines[f.first], last = col.map.lines[f.last] } end end end) @@ -524,15 +568,12 @@ function View:is_open() return col ~= nil and col.winid ~= nil and vim.api.nvim_win_is_valid(col.winid) end --- whether `b` re-reads the diff `a` shows, so a fold's gap names the same run in both +-- whether `b` re-reads the diff `a` shows: the same file between the same revs ---@param a differ.DiffModel ---@param b differ.DiffModel ---@return boolean local function same_diff(a, b) - return a.path == b.path - and a.old_rev == b.old_rev - and a.new_rev == b.new_rev - and #a.hunks == #b.hunks + return a.path == b.path and a.old_rev == b.old_rev and a.new_rev == b.new_rev end -- swap the diffed file in place: same windows/layout/context, new model. the @@ -551,7 +592,7 @@ function View:set_source(model, staging, opts) if self.edit_win and self.model.path ~= model.path then self:_release_edit_window() end - local opened = {} + local opened = NONE_OPENED if same_diff(self.model, model) then opened = self:_opened_folds() end @@ -1318,27 +1359,6 @@ function View:_rekey_staged(removed, before) self.staged_hunks = out end --- the same shift for the gaps of opened folds: the two gaps either side of the --- removed hunk join into one, open if either was ----@param opened table ----@param removed integer ----@param before integer -- hunk count before the revert ----@return table -function View:_rekey_opened(opened, removed, before) - if #self.model.hunks ~= before - 1 then - return {} - end - local out = {} - for gap in pairs(opened) do - if gap < removed then - out[gap] = true - else - out[gap - 1] = true - end - end - return out -end - -- the new-side line to land on once `h` is reverted: the cursor's own line, shifted by -- whatever the revert added or removed above it. a cursor inside the reverted region -- has no line of its own to return to, so it lands at the region's start @@ -1451,7 +1471,6 @@ function View:revert_hunk() local opened = self:_opened_folds() -- before rerender replaces col.folds self.model = require("differ.model.diff").revert_hunk(self.model, idx) self:_rekey_staged(idx, before) - opened = self:_rekey_opened(opened, idx, before) self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) self:_apply_folds(opened) -- stay where the reverted hunk was rather than being pulled to the next one @@ -1857,7 +1876,7 @@ end -- lay the columns into windows. the first column anchors on its existing window -- (or the current one on first open); extra columns reuse their window or vsplit -- a fresh one; >1 column scroll-binds. single authority for open + layout toggle ----@param opened table +---@param opened differ.view.OpenedFolds function View:_relayout(opened) local anchor = self.columns[1].winid if not (anchor and vim.api.nvim_win_is_valid(anchor)) then @@ -1891,7 +1910,7 @@ end -- open the view: stacked takes the current window, split adds a scroll-bound pane ---@return differ.View function View:open() - self:_relayout({}) + self:_relayout(NONE_OPENED) self:_focus_first_hunk() -- land on the first hunk; the tinted cursor line shows its kind self:_paint_cursorline() return self diff --git a/test/nvim/stacked_spec.lua b/test/nvim/stacked_spec.lua index 6b17b83..eb8523f 100644 --- a/test/nvim/stacked_spec.lua +++ b/test/nvim/stacked_spec.lua @@ -81,7 +81,7 @@ describe("render.stacked end-to-end content", function() local model = build("1\n2\n3\n4\n5\n6\n7\n8\n9\n", "1\nX\n3\n4\n5\n6\n7\nY\n9\n") local r = render(model, { context = 1 }) assert.are.equal(11, #r.lines) -- nothing dropped (9 unchanged + X + Y) - assert.are.same({ { first = 5, last = 7, gap = 1 } }, r.folds) -- the 4,5,6 middle folds + assert.are.same({ { first = 5, last = 7 } }, r.folds) -- the 4,5,6 middle folds end) it("renders a placeholder for a binary file, never the raw bytes", function() diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index 95e0ec7..ab9216b 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -543,15 +543,75 @@ describe("view context controls", function() end end) - it("closes an opened fold when a re-source changes the hunk count", function() + it("keeps an opened stretch open when a re-read drops a hunk above it", function() local v = three_hunk_view() open_fold(v, row_of(v, "11")) v:set_source(model(v.model.old_text, "1\n2\n3\n4\n5\n6\n7\nY\n9\n10\n11\n12\n13\nZ\n15\n")) assert.are.equal(2, #v.model.hunks) + assert.are.equal(-1, foldclosed_at(v, "11")) + assert.are.equal(row_of(v, "1"), foldclosed_at(v, "4")) -- the new leading run + v:close() + end) + + it("keeps an opened stretch open when only the old side changes", function() + -- a stage from outside differ: the index takes X, the worktree is untouched + local v = three_hunk_view() + open_fold(v, row_of(v, "11")) + v:set_source(model("1\nX\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n", v.model.new_text)) + assert.are.equal(-1, foldclosed_at(v, "11")) + v:close() + end) + + it("closes every fold when a re-read changes both sides", function() + local v = three_hunk_view() + open_fold(v, row_of(v, "11")) + v:set_source( + model( + "1\nX\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n", + "1\nX\n3\n4\n5\n6\n7\nY\n9\n10\n11\n12\n13\nZ\n16\n" + ) + ) assert.are.equal(row_of(v, "10"), foldclosed_at(v, "11")) v:close() end) + -- 30 lines named l1..l30, with `edits` swapped in by line number + local function numbered(edits) + local out = {} + for i = 1, 30 do + out[i] = edits[i] or ("l" .. i) + end + return table.concat(out, "\n") .. "\n" + end + + local function numbered_view(edits) + return View.new(model(numbered({}), numbered(edits)), { + layout = "stacked", + context = 1, + deep_diff = { enabled = true }, + }):open() + end + + it("keeps the opened stretch open when a re-read moves the hunks around it", function() + local v = numbered_view({ [2] = "X", [8] = "Y", [14] = "Z" }) + open_fold(v, row_of(v, "l11")) + -- X undone and W added: still three hunks, but l9..l13 now comes first + v:set_source(model(numbered({}), numbered({ [8] = "Y", [14] = "Z", [24] = "W" }))) + assert.are.equal(-1, foldclosed_at(v, "l11")) + assert.are.equal(row_of(v, "l16"), foldclosed_at(v, "l19")) + v:close() + end) + + it("keeps both halves open when a re-read splits an opened stretch", function() + local v = numbered_view({ [2] = "X", [20] = "Y" }) + open_fold(v, row_of(v, "l11")) + v:set_source(model(numbered({}), numbered({ [2] = "X", [11] = "M", [20] = "Y" }))) + assert.are.equal(-1, foldclosed_at(v, "l6")) + assert.are.equal(-1, foldclosed_at(v, "l15")) + assert.are.equal(row_of(v, "l22"), foldclosed_at(v, "l25")) -- never opened + v:close() + end) + it( "keeps the right fold open when an earlier gap vanishes entirely at wider context", function() diff --git a/test/unit/split_spec.lua b/test/unit/split_spec.lua index 8693359..5bee5ca 100644 --- a/test/unit/split_spec.lua +++ b/test/unit/split_spec.lua @@ -215,8 +215,8 @@ describe("render.split context collapsing", function() assert.are.equal(#r.old_lines, #r.new_lines) assert.are.equal(7, #r.old_lines) -- full content, nothing dropped -- lines 4..7 (the gap middle after hunk 1) fold; both columns share the range - assert.are.same({ { first = 4, last = 7, gap = 1 } }, r.old_folds) - assert.are.same({ { first = 4, last = 7, gap = 1 } }, r.new_folds) + assert.are.same({ { first = 4, last = 7 } }, r.old_folds) + assert.are.same({ { first = 4, last = 7 } }, r.new_folds) end) end) diff --git a/test/unit/stacked_spec.lua b/test/unit/stacked_spec.lua index e2e587e..12c80d6 100644 --- a/test/unit/stacked_spec.lua +++ b/test/unit/stacked_spec.lua @@ -101,7 +101,7 @@ describe("render.stacked context collapsing", function() "9", -- 11 trailing context }, r.lines) -- the middle 3 lines (buffer rows 5..7) collapse under a native fold - assert.are.same({ { first = 5, last = 7, gap = 1 } }, r.folds) + assert.are.same({ { first = 5, last = 7 } }, r.folds) end) it("marks nothing foldable under full context", function() From e4c5967145e3cc60125d2093d8679e53b1a89fe6 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:34:18 +0100 Subject: [PATCH 12/16] feat: remember opened folds per diff for the session --- lua/differ/view.lua | 31 +++++++++++++------- test/nvim/view_spec.lua | 64 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/lua/differ/view.lua b/lua/differ/view.lua index e434185..9552230 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -104,6 +104,7 @@ local armed_view = nil ---@field can_stage boolean -- session-level: bind s/u (worktree-status panels) ---@field staging differ.view.Staging|nil -- per-source capability (nil off-side) ---@field staged_hunks table -- hunk index -> staged, for marking +---@field fold_memory table -- diff_key -> its open folds when last left ---@field on_edit_unstage fun(path: string)|nil -- frontend hook: unstage + re-source for edit-in-review ---@field extra_keymaps differ.panel.ExtraMap[]|nil -- session-supplied buffer maps (pr unviewed nav) ---@field on_rerender fun()|nil -- session hook after a re-render, to re-apply overlays (pr threads) @@ -167,6 +168,7 @@ function View.new(model, opts) on_cursor = opts.on_cursor, on_repurpose = opts.on_repurpose, staged_hunks = {}, + fold_memory = {}, id = next_id(), _suppress_close = false, _closing = false, @@ -568,12 +570,22 @@ function View:is_open() return col ~= nil and col.winid ~= nil and vim.api.nvim_win_is_valid(col.winid) end --- whether `b` re-reads the diff `a` shows: the same file between the same revs ----@param a differ.DiffModel ----@param b differ.DiffModel ----@return boolean -local function same_diff(a, b) - return a.path == b.path and a.old_rev == b.old_rev and a.new_rev == b.new_rev +-- one key per diff: the file and the revs it is diffed between +---@param model differ.DiffModel +---@return string +local function diff_key(model) + return table.concat({ model.path, model.old_rev, model.new_rev }, "\0") +end + +-- store the open folds of the diff on screen, dropping its entry when none are open +function View:_remember_folds() + local key = diff_key(self.model) + local opened = self:_opened_folds() + if #opened.runs == 0 then + self.fold_memory[key] = nil + else + self.fold_memory[key] = opened + end end -- swap the diffed file in place: same windows/layout/context, new model. the @@ -592,15 +604,12 @@ function View:set_source(model, staging, opts) if self.edit_win and self.model.path ~= model.path then self:_release_edit_window() end - local opened = NONE_OPENED - if same_diff(self.model, model) then - opened = self:_opened_folds() - end + self:_remember_folds() self.model = model self.staging = staging self:_init_staged() -- a new file: reseed staged state from the fresh git read self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) - self:_apply_folds(opened) + self:_apply_folds(self.fold_memory[diff_key(model)] or NONE_OPENED) if opts and opts.focus_line then -- hold the precise position across a refresh self:focus_new_line(opts.focus_line, true, opts.focus_col) diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index ab9216b..31d3473 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -427,20 +427,66 @@ describe("view context controls", function() return v end - it("closes every fold again on a file switch", function() - local v = opened_fold_view() - v:set_source(diff.build({ + local function other_file() + return diff.build({ path = "y", old_rev = "A", new_rev = "B", old_text = "a\nb\nc\nd\ne\nf\ng\nh\ni\n", new_text = "a\nB\nc\nd\ne\nf\ng\nH\ni\n", - })) + }) + end + + it("opens a file it hasn't shown yet with every fold closed", function() + local v = opened_fold_view() + v:set_source(other_file()) local fold_row = real_fold(v).first assert.are.equal(fold_row, foldclosed(v, fold_row)) v:close() end) + it("reopens a file's opened folds on coming back to it", function() + local v = opened_fold_view() + local x = v.model + v:set_source(other_file()) + v:set_source(x) + assert.are.equal(-1, foldclosed(v, real_fold(v).first)) + v:close() + end) + + it("keeps a fold closed on coming back once it was closed again", function() + local v = opened_fold_view() + local x = v.model + v:set_source(other_file()) + v:set_source(x) + vim.api.nvim_win_call(v.columns[1].winid, function() + vim.cmd("normal! 5Gzc") + end) + v:set_source(other_file()) + v:set_source(x) + assert.are.equal(5, foldclosed(v, 5)) + v:close() + end) + + it("remembers each diff of a file apart", function() + local v = opened_fold_view() + local ab = v.model + local bc = diff.build({ + path = "x", + old_rev = "B", + new_rev = "C", + old_text = ab.new_text, + new_text = "1\nX\n3\n4\n5\n6\n7\nY2\n9\n", + }) + v:set_source(bc) + assert.are.equal(real_fold(v).first, foldclosed(v, real_fold(v).first)) + v:set_source(ab) + assert.are.equal(-1, foldclosed(v, real_fold(v).first)) + v:set_source(bc) + assert.are.equal(real_fold(v).first, foldclosed(v, real_fold(v).first)) + v:close() + end) + it("keeps an opened fold open across a layout toggle", function() local v = opened_fold_view() v:toggle_layout() -- -> split @@ -612,6 +658,16 @@ describe("view context controls", function() v:close() end) + it("reopens a file's folds after it was edited while away", function() + local v = three_hunk_view() + open_fold(v, row_of(v, "11")) + local old_text = v.model.old_text + v:set_source(other_file()) + v:set_source(model(old_text, "1\n2\n3\n4\n5\n6\n7\nY\n9\n10\n11\n12\n13\nZ\n15\n")) + assert.are.equal(-1, foldclosed_at(v, "11")) + v:close() + end) + it( "keeps the right fold open when an earlier gap vanishes entirely at wider context", function() From 8ea7290022c6b6817d40f48f6854b07b447db444 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:41:33 +0100 Subject: [PATCH 13/16] docs: describe fold state that persists per diff --- CHANGELOG.md | 2 +- README.md | 2 +- doc/differ.txt | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f162c..2eb7ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- A finite `context` built its folds open, so nothing collapsed until `zM`, and every file switch reopened them. Folds now start closed and stay closed across files, and a fold opened with `zo` survives context/layout changes +- A finite `context` built its folds open, so nothing collapsed until `zM`, and every file switch reopened them. Diff view folds now start closed, and a fold you open stays open for the rest of the session, including when you come back to its file - `:Differ context` and `:Differ layout` reported "no diff view here" from the file panel. They now act on the diff the panel drives - An invalid `context` in config was accepted silently. It now warns and falls back to `"full"` diff --git a/README.md b/README.md index 730c683..80a3129 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ Sources with worktree on the new side (`:Differ`, `:Differ `, `:Differ . | `:Differ context ` | Fold unchanged lines more than `` away from a hunk | | `:Differ context +` / `-` | Widen / narrow the threshold by one | -`context` decides where a native fold forms once an unchanged run exceeds it either side of a hunk; every line is in the buffer either way, so search, yank and motions are unaffected. Folds start closed and stay closed from file to file, so a number for `context` in `setup()` opens every session folded. A fold you open (`zo` / `za`) stays open through a context or layout change. `d-` narrows out of whole-file by landing on a threshold of 10, which collapses the file, and steps down from there; `d=` has nothing wider to reach, so it does nothing. +`context` decides where a native fold forms once an unchanged run exceeds it either side of a hunk; every line is in the buffer either way, so search, yank and motions are unaffected. With a number for `context`, folds start closed, so setting one in `setup()` opens every session folded. A fold you open (`zo` / `za`) stays open for the rest of the session, including when you come back to its file. `d-` narrows out of whole-file by landing on a threshold of 10, which collapses the file, and steps down from there; `d=` has nothing wider to reach, so it does nothing. Set `command_alias` in `setup()` to register a shorter name for the same command, e.g. `command_alias = "D"` gives `:D HEAD~1`, `:D log`. If you lazy-load on `cmd`, list the alias there too (`cmd = { "Differ", "D" }`); see [troubleshooting](TROUBLESHOOTING.md#command_alias-and-lazy-loading) (`:h differ-troubleshooting-command_alias-and-lazy-loading`) for why. diff --git a/doc/differ.txt b/doc/differ.txt index b70e729..0e3d5c2 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -307,12 +307,12 @@ RUNTIME CONTROLS *differ-usage-runtime-controls* ------------------------------------------------------------------------------ `context` decides where a native fold forms once an unchanged run exceeds it either side of a hunk; every line is in the buffer either way, so search, yank -and motions are unaffected. Folds start closed and stay closed from file to -file, so a number for `context` in `setup()` opens every session folded. A fold -you open (`zo` / `za`) stays open through a context or layout change. `d-` -narrows out of whole-file by landing on a threshold of 10, which collapses the -file, and steps down from there; `d=` has nothing wider to reach, so it does -nothing. +and motions are unaffected. With a number for `context`, folds start closed, so +setting one in `setup()` opens every session folded. A fold you open (`zo` / +`za`) stays open for the rest of the session, including when you come back to +its file. `d-` narrows out of whole-file by landing on a threshold of 10, which +collapses the file, and steps down from there; `d=` has nothing wider to reach, +so it does nothing. Set `command_alias` in `setup()` to register a shorter name for the same command, e.g. `command_alias = "D"` gives `:D HEAD~1`, `:D log`. If you From 3fe1ea570a667aa0ff8e4565948a56504d20d99e Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:00:02 +0100 Subject: [PATCH 14/16] fix: keep opened folds when staging swaps the diff underneath --- lua/differ/view.lua | 13 +++++++++---- test/nvim/view_spec.lua | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 9552230..4373180 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -578,9 +578,9 @@ local function diff_key(model) end -- store the open folds of the diff on screen, dropping its entry when none are open -function View:_remember_folds() +---@param opened differ.view.OpenedFolds +function View:_remember_folds(opened) local key = diff_key(self.model) - local opened = self:_opened_folds() if #opened.runs == 0 then self.fold_memory[key] = nil else @@ -604,12 +604,17 @@ function View:set_source(model, staging, opts) if self.edit_win and self.model.path ~= model.path then self:_release_edit_window() end - self:_remember_folds() + local on_screen = self:_opened_folds() + self:_remember_folds(on_screen) self.model = model self.staging = staging self:_init_staged() -- a new file: reseed staged state from the fresh git read self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) - self:_apply_folds(self.fold_memory[diff_key(model)] or NONE_OPENED) + if opts and opts.focus_line then + self:_apply_folds(on_screen) -- underneath the user, even onto another diff + else + self:_apply_folds(self.fold_memory[diff_key(model)] or NONE_OPENED) + end if opts and opts.focus_line then -- hold the precise position across a refresh self:focus_new_line(opts.focus_line, true, opts.focus_col) diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index 31d3473..2bacaed 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -572,6 +572,26 @@ describe("view context controls", function() v:close() end) + it("keeps an opened fold open when another diff is sourced underneath the user", function() + -- staging the last unstaged hunk: the index takes the worktree text and the + -- view moves to HEAD..INDEX, holding the cursor + local v = three_hunk_view() + open_fold(v, row_of(v, "11")) + v:set_source( + diff.build({ + path = "x", + old_rev = "HEAD", + new_rev = "INDEX", + old_text = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n16\n", + new_text = v.model.new_text, + }), + nil, + { focus_line = 11 } + ) + assert.are.equal(-1, foldclosed_at(v, "11")) + v:close() + end) + it("closes an opened fold when another diff of the same file is sourced", function() -- the next commit in a file's history, and the file's other staging pair for _, revs in ipairs({ { "B", "C" }, { "B", "WORKTREE" } }) do From f63a605ca55878a8bd58dc480068826c9c505e39 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:01:50 +0100 Subject: [PATCH 15/16] docs: list remembered folds under added --- CHANGELOG.md | 3 ++- lua/differ/view.lua | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eb7ee0..2c97810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - `context` accepts `"full"` in config, matching `:Differ context full`, and it is now the default (`math.huge` still works) +- A fold you open in the diff view stays open for the rest of the session, including when you come back to its file ### Fixed -- A finite `context` built its folds open, so nothing collapsed until `zM`, and every file switch reopened them. Diff view folds now start closed, and a fold you open stays open for the rest of the session, including when you come back to its file +- A finite `context` built its folds open, so nothing collapsed until `zM`, and every file switch reopened them. Diff view folds now start closed - `:Differ context` and `:Differ layout` reported "no diff view here" from the file panel. They now act on the diff the panel drives - An invalid `context` in config was accepted silently. It now warns and falls back to `"full"` diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 4373180..518b534 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -461,8 +461,7 @@ function View:_stage_offset(idx) return off end --- open folds as the first and last rows of their unchanged runs, whose old/new line --- numbers index the texts alongside +-- open folds by the rail lines at their ends, with the texts those line numbers refer to ---@class differ.view.OpenedFolds ---@field old_text? string ---@field new_text? string From 057230134ebea90069f8a21f383144559b54776a Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:15:11 +0100 Subject: [PATCH 16/16] fix: keep opened folds when editing from a staged diff --- lua/differ/git/init.lua | 27 ++++++++------- lua/differ/view.lua | 15 ++++++--- test/nvim/git_spec.lua | 73 +++++++++++++++++++++++++++++++++++++++++ test/nvim/view_spec.lua | 13 ++++++-- 4 files changed, 107 insertions(+), 21 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 8e997b1..bd7ed61 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1402,14 +1402,11 @@ function M.panel(opts) -- (re)source the diff view from an entry's current git state. false when the -- entry has no diff anymore (committed / fully staged / reverted outside differ). - -- `focus_line`/`focus_col` (a new-side file position) hold the cursor across an - -- in-place refresh of the same file; without them the view lands on the first - -- unstaged hunk + -- `source_opts` goes to View:set_source when the view is open ---@param entry differ.FileEntry - ---@param focus_line integer|nil - ---@param focus_col integer|nil + ---@param source_opts? differ.view.SourceOpts ---@return boolean shown - local function show_entry(entry, focus_line, focus_col) + local function show_entry(entry, source_opts) local model = model_for(entry) if #model.hunks == 0 and not model.binary then -- a real change with no lines to show (a mode change, a rename, a final @@ -1423,11 +1420,7 @@ function M.panel(opts) end local staging = stage_for(entry, model) if view and view:is_open() then - view:set_source( - model, - staging, - focus_line and { focus_line = focus_line, focus_col = focus_col } or nil - ) + view:set_source(model, staging, source_opts) else view = require("differ").diff_model(model, { staging = staging, @@ -1488,7 +1481,12 @@ function M.panel(opts) -- switch, so landing on the first hunk would lose the reviewer's place; the -- unstaged pair just emptied, so index and worktree agree and the line carries local focus_line, focus_col = view:cursor_new_line() - if not show_entry(survivor, focus_line, focus_col) then + if + not show_entry( + survivor, + { focus_line = focus_line, focus_col = focus_col, keep_folds = true } + ) + then return false end panel:mark_selected(survivor) @@ -1505,7 +1503,7 @@ function M.panel(opts) refresh_panel() -- the file moves to the Unstaged section; records last_sig for _, e in ipairs(entries_for_path(path)) do if not e.staged then - show_entry(e) -- switch the diff to index↔worktree, updating active_entry + show_entry(e, { keep_folds = true }) -- switch the diff to index↔worktree return end end @@ -1531,7 +1529,8 @@ function M.panel(opts) end end local was = active_entry - if pick and show_entry(pick, focus_line, focus_col) then + local source_opts = { focus_line = focus_line, focus_col = focus_col, keep_folds = true } + if pick and show_entry(pick, source_opts) then if outside and (pick.staged ~= was.staged or pick.status ~= was.status) then local msg = "%s changed outside differ; showing its %s change instead" notify(msg:format(pick.path, pick.staged and "staged" or "unstaged")) diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 518b534..fd066ab 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -587,16 +587,21 @@ function View:_remember_folds(opened) end end +---@class differ.view.SourceOpts +---@field focus_line? integer +---@field focus_col? integer +---@field keep_folds? boolean + -- swap the diffed file in place: same windows/layout/context, new model. the -- panel calls this when a different file is selected so the View is re-sourced, -- not recreated (separation of concerns). column count is layout-determined, -- so it never changes here, no relayout. `staging` rides along because the stage -- direction is per-file (a staged entry unstages, an unstaged one stages). --- `opts.focus_line` is a new-side file line to snap to (a re-source of the same --- file underneath the user); without it the cursor lands on the first unstaged hunk +-- `opts.focus_line` holds the cursor on a new-side line, else it lands on the first +-- unstaged hunk; `opts.keep_folds` keeps the folds on screen over the diff's memory ---@param model differ.DiffModel ---@param staging differ.view.Staging|nil ----@param opts? { focus_line?: integer, focus_col?: integer } +---@param opts? differ.view.SourceOpts function View:set_source(model, staging, opts) -- a switch to a different file leaves any edit window stale; drop it. a same-file -- re-source (the watcher after a `:w`) keeps it so editing continues uninterrupted @@ -609,8 +614,8 @@ function View:set_source(model, staging, opts) self.staging = staging self:_init_staged() -- a new file: reseed staged state from the fresh git read self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) - if opts and opts.focus_line then - self:_apply_folds(on_screen) -- underneath the user, even onto another diff + if opts and opts.keep_folds then + self:_apply_folds(on_screen) else self:_apply_folds(self.fold_memory[diff_key(model)] or NONE_OPENED) end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 77c0982..0233c9c 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -1333,6 +1333,24 @@ describe(":Differ diff hunk staging", function() vim.api.nvim_set_current_win(p.origin_win) return require("differ.view").current() end + -- the first buffer row of the diff showing `text` + local function row_at(v, text) + for row, l in ipairs(vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false)) do + if l == text then + return row + end + end + end + local function fold_closed_at(v, text) + return vim.api.nvim_win_call(v.columns[1].winid, function() + return vim.fn.foldclosed(row_at(v, text)) + end) + end + local function open_fold_at(v, text) + vim.api.nvim_win_call(v.columns[1].winid, function() + vim.cmd(("normal! %dGzo"):format(row_at(v, text))) + end) + end -- the staged (index) content of `path` local function indexed(root, path) return git(root, "show", ":" .. path) @@ -1785,6 +1803,35 @@ describe(":Differ diff hunk staging", function() p:close() end) + it("keeps the folds the user opened when staging follows to the staged side", function() + local root = fresh_repo() + local lines = {} + for i = 1, 20 do + lines[i] = tostring(i) + end + write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") + git(root, "commit", "-q", "-am", "20 lines") + lines[1] = "1x" + write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") + git(root, "add", "a.lua") -- staged: a hunk at line 1 + lines[20] = "20x" + write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") -- unstaged: line 20 + vim.cmd.edit(root .. "/a.lua") + + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + v:set_context(1) + open_fold_at(v, "10") + + vim.api.nvim_set_current_win(p.origin_win) + vim.api.nvim_win_set_cursor(0, { row_at(v, "20x"), 0 }) + v:stage_hunk() + assert.are.equal("staged", v.staging.initial) + assert.are.equal(-1, fold_closed_at(v, "10")) + p:close() + end) + -- only the unstaged side follows. emptying the staged one stays put, which is what -- lets s put the hunk straight back where it was rather than ping-ponging the view it("stays on the staged pair when its last staged hunk is unstaged", function() @@ -2645,6 +2692,32 @@ describe(":Differ diff hunk staging", function() p:close() end) + it("df on a staged diff keeps the folds the user opened", function() + local root = fresh_repo() + local lines = {} + for i = 1, 20 do + lines[i] = tostring(i) + end + write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") + git(root, "commit", "-q", "-am", "20 lines") + lines[2], lines[18] = "2x", "18x" + write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") + git(root, "add", "a.lua") + vim.cmd.edit(root .. "/a.lua") + + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + v:set_context(1) + assert.are_not.equal(-1, fold_closed_at(v, "10")) + open_fold_at(v, "10") + + v:edit_file() + assert.are.equal("WORKTREE", v.model.new_rev) + assert.are.equal(-1, fold_closed_at(v, "10")) + p:close() + end) + -- the file half of "search by state": from the bottom of the list the walk carries -- on past the end rather than reporting there's nothing left it("wraps the review walk past the end of the file list", function() diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index 2bacaed..2d82cfb 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -572,7 +572,7 @@ describe("view context controls", function() v:close() end) - it("keeps an opened fold open when another diff is sourced underneath the user", function() + it("keeps the folds on screen when told to, even onto another diff", function() -- staging the last unstaged hunk: the index takes the worktree text and the -- view moves to HEAD..INDEX, holding the cursor local v = three_hunk_view() @@ -586,12 +586,21 @@ describe("view context controls", function() new_text = v.model.new_text, }), nil, - { focus_line = 11 } + { focus_line = 11, keep_folds = true } ) assert.are.equal(-1, foldclosed_at(v, "11")) v:close() end) + it("brings back a diff's own folds when only the cursor is held", function() + local v = opened_fold_view() + local x = v.model + v:set_source(other_file()) + v:set_source(x, nil, { focus_line = 6 }) + assert.are.equal(-1, foldclosed(v, real_fold(v).first)) + v:close() + end) + it("closes an opened fold when another diff of the same file is sourced", function() -- the next commit in a file's history, and the file's other staging pair for _, revs in ipairs({ { "B", "C" }, { "B", "WORKTREE" } }) do