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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -231,10 +231,10 @@ Sources with worktree on the new side (`:Differ`, `:Differ <rev>`, `:Differ <a>.
| `: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 <n>` | Set the fold threshold around hunks |
| `:Differ context <n>` | Fold unchanged lines more than `<n>` 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.

Expand Down
15 changes: 9 additions & 6 deletions doc/differ.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -300,16 +300,19 @@ RUNTIME CONTROLS *differ-usage-runtime-controls*
:Differ context full Show the whole file, no folds (the
default)

:Differ context <n> Set the fold threshold around hunks
:Differ context <n> Fold unchanged lines more than <n>
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
Expand Down
6 changes: 2 additions & 4 deletions lua/differ/command.lua
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -30,7 +28,7 @@ end
-- :Differ context <n|full|+|->, 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
Expand Down
34 changes: 32 additions & 2 deletions lua/differ/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down
27 changes: 13 additions & 14 deletions lua/differ/git/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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"))
Expand Down
39 changes: 24 additions & 15 deletions lua/differ/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions lua/differ/merge/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 0 additions & 4 deletions lua/differ/render/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "")
Expand Down
6 changes: 2 additions & 4 deletions lua/differ/render/split.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 2 additions & 4 deletions lua/differ/render/stacked.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,14 @@ 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
local function mark(foldable)
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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading