diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f0106..2c97810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ 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) +- 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 +- `: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..80a3129 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. 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 384dbd1..0e3d5c2 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. 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 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/lua/differ/config.lua b/lua/differ/config.lua index 8c768b7..9147258 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" 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/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/init.lua b/lua/differ/init.lua index fc5d5bf..342e3f7 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 cfg.context, + context = config.context_lines(opts.context or cfg.context), wrap = cfg.wrap, counter = cfg.diff_counter, cursorline_tint = cfg.cursorline_tint, @@ -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/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/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 b2d70aa..fd066ab 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, @@ -459,24 +461,53 @@ 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) - for ci, col in ipairs(self.columns) do +-- 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 +---@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 - local was_closed = closed and closed[ci] if win and vim.api.nvim_win_is_valid(win) then set_wo(win, "foldmethod", "manual") set_wo(win, "foldtext", FOLDTEXT_EXPR) @@ -486,7 +517,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 side and was_opened(opened, side, col, f) then vim.cmd(("silent! %dfoldopen"):format(f.first)) end end @@ -496,6 +527,26 @@ function View:_apply_folds(closed) end end +-- the open folds of every column, against the current model's texts +---@return differ.view.OpenedFolds +function View:_opened_folds() + 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 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) + end + end + return opened +end + -- the view owning the current buffer, if any. commands dispatch through this ---@return differ.View|nil function View.current() @@ -518,27 +569,56 @@ function View:is_open() return col ~= nil and col.winid ~= nil and vim.api.nvim_win_is_valid(col.winid) end +-- 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 +---@param opened differ.view.OpenedFolds +function View:_remember_folds(opened) + local key = diff_key(self.model) + if #opened.runs == 0 then + self.fold_memory[key] = nil + else + self.fold_memory[key] = 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 if self.edit_win and self.model.path ~= model.path then self:_release_edit_window() end + 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() -- new file's ranges; windows unchanged so refold in place + 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 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) @@ -554,8 +634,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 @@ -567,27 +648,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 @@ -1423,10 +1486,11 @@ 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) 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)) @@ -1830,7 +1894,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 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 anchor = vim.api.nvim_get_current_win() @@ -1855,7 +1920,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 @@ -1863,7 +1928,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/command_spec.lua b/test/nvim/command_spec.lua index 341b309..e96dbad 100644 --- a/test/nvim/command_spec.lua +++ b/test/nvim/command_spec.lua @@ -236,6 +236,54 @@ 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) + + 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() it("repositions the live panel", function() local p = open_panel() diff --git a/test/nvim/config_spec.lua b/test/nvim/config_spec.lua index eda2bb7..0a7919f 100644 --- a/test/nvim/config_spec.lua +++ b/test/nvim/config_spec.lua @@ -108,3 +108,48 @@ 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) + +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) 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/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 7cd34b7..2d82cfb 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 @@ -374,65 +387,321 @@ describe("view context controls", function() v:close() end) - it("creates the collapsed region as an open native fold the user can close", 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 }, - }) + it("creates the collapsed region as a closed native fold the user can open", function() + local v = gap_view(1) 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 - 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) } + assert.are.equal("manual", vim.wo[v.columns[1].winid].foldmethod) + -- buffer rows 5..7 are the foldable middle, collapsed to its start row + assert.are.equal(5, foldclosed(v, 5)) + open_fold(v, 5) + assert.are.equal(-1, foldclosed(v, 5)) + v:close() + end) + + 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(-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(0, level) v:close() end) - it("keeps a manually-closed fold closed across a context change", function() + 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 + 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 + 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 + local function opened_fold_view() + local v = gap_view(1) + v:open() + open_fold(v, 5) + return v + end + + 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 - vim.api.nvim_win_call(win, function() - vim.cmd(("normal! %dGzc"):format(fold_row)) + 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) - assert.are.equal( - fold_row, - vim.api.nvim_win_call(win, function() - return vim.fn.foldclosed(fold_row) - 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 + for ci in ipairs(v.columns) do + assert.are.equal(-1, foldclosed(v, real_fold(v).first, ci)) + end + v:toggle_layout() -- -> stacked + 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")) + assert.are.equal(-1, foldclosed(v, real_fold(v).first)) + v:close() + end) + + -- hunks X, Y, Z with a 5-line gap before each of Y and Z, at context 1 + 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", + "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 foldclosed(v, row_of(v, text)) + 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 = three_hunk_view() + 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")) + 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 = three_hunk_view() + 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")) + assert.are.equal(row_of(v, "10"), foldclosed_at(v, "11")) -- the joined trailing run + v:close() + end) + + 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() + 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, keep_folds = true } ) - 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, - vim.api.nvim_win_call(win, function() - return vim.fn.foldclosed(new_fold_row) -- still closed, not reset open - end) + 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 + local v = three_hunk_view() + open_fold(v, row_of(v, "11")) + 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("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("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 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 + -- 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", @@ -443,44 +712,23 @@ 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! %dGzc"):format(big_gap_fold.first)) - end) - assert.are.equal( - big_gap_fold.first, - 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( - shifted_fold.first, - vim.api.nvim_win_call(win, function() - return vim.fn.foldclosed(shifted_fold.first) -- still closed, not reset open - end) - ) + assert.are.equal(-1, foldclosed(v, real_fold(v).first)) 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 - local win = v.columns[1].winid + v:set_context(0) -- shifts the fold's range; it was never opened local fold_row = real_fold(v).first - assert.are.equal( - -1, - 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) 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 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()