Skip to content

Quality-of-life improvements for reviewing diffs - #2

Merged
jrosiek merged 21 commits into
mainfrom
panel-resize-and-tab-focus
Oct 7, 2026
Merged

jrosiek merged 21 commits into
mainfrom
panel-resize-and-tab-focus

Conversation

@jrosiek

@jrosiek jrosiek commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Summary

Quality-of-life improvements for reviewing a diff: the layout behaves on resize, the view remembers where you were in each file, unchanged lines fold away, files can be marked as reviewed, and large git diffs load faster with a loading message shown while they run.

Improvements

Layout and resizing

  1. Header box follows the panel width. Resizing the panel (dragging the border, :vertical resize) redraws the header box at the new width. A global 'statuscolumn' no longer makes it wrap.
  2. Diff panes keep their ratio on resize. Resizing Neovim used to give the whole change to the head pane. Both panes now share the space next to the tree in their last ratio, and repeated resizes do not drift. Dragging the separator sets a new ratio. A resize made while another tab is current is applied when you return to the diff tab.
  3. Even split in a narrow terminal. When the space next to the tree was under about 40 columns, the base pane could shrink to a single column. The panes now always start at equal widths.
  4. Double-click the pane split to even it out. This makes both diff panes the same width and leaves the tree alone. It is also available as equalize_panes().
  5. Double-click the panel border to reset it. This gives the side panel its configured tree.width again. It is also available as reset_tree_width().
  6. Long tree rows stay on one line. The panel no longer follows the global 'wrap' option. A row too long for the panel is cut at the edge and ends with ….

Navigation and focus

  1. <Tab> returns to the diff pane used last instead of always going to the base pane.
  2. Cursor position and pane are kept per file. Each file keeps its own line, column and pane (base or head). Coming back to a file puts you where you left it. A file not shown before opens at its first hunk in the head pane.
  3. Mouse-wheel scrolling keeps both panes in step. 'scrollbind' only follows the current window, so wheel-scrolling the other pane used to leave its partner behind.
  4. focus_diff_on_select (default on). Selecting a file in the tree moves focus to its diff pane, at the position it was left. Set it to false to keep focus in the tree. It is also available as focus_diff().
  5. Double-click a tree row opens a file like <CR>, or toggles a directory.

Folding unchanged lines

  1. Unchanged lines around hunks are folded. Folds are created in both panes. A closed fold reads ━━━━ ▸ N unchanged lines ━━━━, with the count centred on a tinted band. New settings:
    • context_size (default 3): the number of unchanged lines kept visible around each change. 0 turns folding off.
    • min_fold_size (default 2): shorter runs of unchanged lines stay visible.
    • fold_by_default (default true): whether folds start closed.
    • fold_fill (default "━"): the character of the fold rule.
    • fold_accent (default "Directory"): the highlight group that colours closed folds.
  2. Fold states stay the same in both panes. Opening or closing a fold in one pane, by any means (fold commands, :foldopen, a search, the API), is mirrored in the other pane, and the views stay aligned. If folds are deleted or added by hand, the panes get the plugin's folds back.
  3. Fold states are remembered per file. They are kept alongside the cursor position, so returning to a file restores which folds were open. A fold that would hide the remembered cursor line is opened.

Review tracking

  1. Visited and reviewed markers. A column at the right edge of the tree shows • for a file not shown yet and ✓ for a file marked as reviewed. Markers stay visible in a narrow panel. The glyphs are set by tree.icons.unvisited and tree.icons.reviewed, and the colours by DifftTreeUnvisited and DifftTreeReviewed.
  2. Mark files as reviewed. R, :DifftToggleReviewed or toggle_reviewed() toggles the mark on the shown file. In the tree, it applies to the file under the cursor or to every file in the directory under the cursor. With auto_review, showing a file marks it.
  3. Jump between unreviewed files. ]u and [u (next_unreviewed / prev_unreviewed) move to the next or previous unreviewed file. The header counts progress (3/12 reviewed), and a fully reviewed directory gets the reviewed marker.

Loading

  1. Faster git diffs. difft now runs on several changed files at once, up to max_parallel_difft_calls at a time (default 0, meaning one per CPU). It gets the same arguments and working directory as when git runs it as an external diff tool, so the results match git diff. On a 34-file range, loading took 2.5 s instead of 8.3 s. jj diffs are unchanged.
  2. Loading message. While the diff is computed, a centred box styled like the panel header shows "Loading…" and the range. If there are no changes or the diff fails, the previous tab is restored. nvim -c 'Difft …' waits for startup to settle, so the box is centred even when a GUI such as Neovide resizes the grid right after startup.
  3. Colours follow themes applied late. Derived highlight groups (diff backgrounds, panel, filler, folds) are blended again at VimEnter and whenever a diff view opens. This covers configs that apply their theme after plugin setup without a ColorScheme event, such as NvChad's starter config. Overrides given to setup() are kept.

Housekeeping

  • The README and help file now state that Neovim 0.10 is required, since the plugin uses vim.system and vim.uv.
  • An unused internal table was removed from the tree.
  • CI: the Rust workflow is now a general CI workflow, which also runs the Lua tests (just test-lua) on stable Neovim with the native library built and difft and jj installed, so the end-to-end git and jj tests run instead of being skipped.

Testing

Every item has plenary tests: rename, fold, focus, review, header_resize, parallel, loading and highlight. Behaviour that depends on the main loop (autocmds firing on idle, resize, mouse events) is tested in a child Neovim over RPC. The full suite passes locally and runs in CI.

🤖 Generated with Claude Code

jrosiek added 21 commits October 7, 2026 20:15
The header box is drawn at the tree window's text width instead of the
configured tree.width, which now only sets the initial width. A WinResized
autocmd redraws the header in place whenever that width differs from the
width it was drawn at, so dragging the border or :vertical resize keep the
box aligned. The tree below the header is left untouched. The autocmd
removes itself on the first resize after the view is closed.

The text width is the window width minus the columns drawn left of the
text. The panel now turns off all of them, including 'statuscolumn', which
takes space whenever it is set, so a global status column no longer wraps
the box. A column turned on later, for example by a status column plugin,
narrows the box on the next resize.
<Tab> from the file tree now moves focus to the diff pane used last instead
of always the base (left) pane. The pane focused when the view opens counts
as used, so the first <Tab> goes back there. The tracking autocmd removes
itself on the first window change after the view is closed.
Each file of the open diff keeps its own cursor position and pane: the line,
column and the diff pane (base or head) used last. Leaving a file records
it; returning puts the cursor back there, and moves focus to that pane when
a diff pane has focus. With the tree focused, focus stays in the tree and
<Tab> then goes to that pane. Both panes move to the position, as their rows
are aligned.

A file not shown before opens at its first hunk (or its top without
scroll_to_first_hunk) in the pane in use; until a pane is used, that is the
head pane, which also has focus when the view opens. Hunk navigation across
files still lands on a hunk. Positions belong to the open view and are
dropped when a new diff opens.
Resizing Neovim gave the whole change in width to the rightmost pane, so
only the head pane grew or shrank. The two panes now keep splitting the
space next to the tree in their last ratio; dragging the separator between
them sets a new ratio, while a change of the tree width keeps it. A resize
made while another tab is current is applied when the diff tab is entered.
The ratio is kept as a fraction, not re-read from the rounded column
widths, so repeated resizes do not drift away from it.

'scrollbind' only follows the current window, so scrolling the other pane
with the mouse wheel left its partner behind. The partner now takes the
same top line. Keyboard scrolling is unchanged.
Selecting a file in the tree now also moves focus to its diff pane: the
pane and position the file was left at, else the pane used last, else the
head pane. Toggling a directory keeps focus in the tree. On by default; with
`focus_diff_on_select = false`, focus stays in the tree as before.

`focus_diff()` moves focus to that pane and backs the `<Tab>` mapping in the
tree.
A double click on a file in the tree opens it like `select` (<CR>): the
file's remembered position comes back, and with focus_diff_on_select its
diff pane gets focus. A double click on a directory toggles it.
The plugin uses vim.system and vim.uv, which Neovim 0.10 introduced, so
the README and help file now list 0.10 as the minimum instead of 0.9.
The tree filled a file index to node ID table that nothing read.
The panel inherited the global wrap option, so in a narrow panel a long
row wrapped onto a second screen line. The panel now turns wrap off and
cuts a row that does not fit at the panel edge, ending it with "…" as the
header box does. Rows are redrawn when the panel width changes, keeping the
cursor and the current-file highlight.
A double click on the split between the two diff panes gives them the same
width, leaving the tree alone; later Neovim resizes keep that even split.
It works while any window of the diff view has focus. Other double clicks
keep their default, such as selecting a word in a pane.

equalize_panes() does the same from Lua.
A double click on the side panel's right border gives the panel its
configured width (tree.width) again; the diff panes keep their ratio. It
works while any window of the diff view has focus. reset_tree_width() does
the same from Lua.
New settings:
- context_size (default 3): unchanged lines kept visible above and below
  each change; the rest of the unchanged lines are folded in both diff
  panes. A file without changes is folded as a whole. 0 turns folding off
  and leaves the panes' own fold options untouched.
- min_fold_size (default 2): shorter runs of unchanged lines stay visible.
- fold_by_default (default true): whether the folds start closed.
- fold_fill (default "━"): the character of the rule across a closed fold;
  "=" is used when Neovim rejects it.
- fold_accent (default "Directory"): the highlight group whose colour
  closed folds take.
A closed fold reads "━━━━ ▸ N unchanged lines ━━━━", the count centred in
the pane, on a band of the fold_accent colour blended into the background
(DifftFold, used for Folded in the diff panes only).
Invalid values (context_size below 0, min_fold_size below 1) are reported
and ignored. The settings apply to files shown after they change.
Folds belong to each window, so opening or closing a fold of unchanged lines
in one pane left the other pane as it was. Once Neovim is idle, the pane
where a fold changed now passes its state to the other one, whatever made
the change: fold commands, :foldopen, a search opening a fold, the API. The
other pane's view is lined up again afterwards, as 'scrollbind' does not
follow a fold change made in another window.

Folds deleted or added in a pane (zE, zd, zf, :fold, the API) would leave
the panes showing different lines side by side. Such a pane gets the
plugin's folds back and loses any added fold. Each fold keeps its state
where it survived: from the pane where it is intact, or its last synced
state when it is gone from both.
Each file of the open diff keeps the open or closed state of its folds of
unchanged lines along with its cursor position. The states are recorded when
the file is shown and after every fold change, so they are current at any
time; switching files and closing the view first settle a change not yet
passed to the other pane. Returning to a file rebuilds the folds with those
states.

When the folds differ by then (context_size or min_fold_size changed),
fold_by_default applies, and a fold that would hide the remembered cursor
line is opened in both panes. States belong to the open view and are
dropped when a new diff opens.
A column at the right edge of the tree now shows each file's review state:
"•" for a file not shown yet in this diff view, nothing once it was shown,
and "✓" for a file marked as reviewed. Rows are cut and padded to end just
before that column, so the markers line up and stay visible in a narrow
panel. R (toggle_reviewed), the :DifftToggleReviewed command and
toggle_reviewed() toggle the mark of the shown file; in the tree, of the
file under the cursor, or of every file in the directory under it. With
auto_review, showing a file also marks it.

The glyphs are tree.icons.unvisited and tree.icons.reviewed, highlighted
with DifftTreeUnvisited and DifftTreeReviewed. The marks belong to the
open view, are current at any time, and start over in a new diff.
- ]u / [u (next_unreviewed, prev_unreviewed) show the next or previous
  file not marked as reviewed, in tree order and wrapping around, opening
  collapsed directories around it; with none left, a message says so.
- The header box counts reviewed files ("3/12 reviewed") instead of the
  file count once a file is marked.
- A directory shows the reviewed marker once every file in it is reviewed.
:vsplit gives the new pane at least 'winwidth' columns, so when the space
next to the tree was under about 40 columns the head pane took 20 and the
base pane got the rest, down to a single column. The panes are now set to
equal widths once created, which is also the ratio later resizes keep.
git called difft as its external diff tool, one changed file after another,
so a large diff took seconds on a single CPU. The changed files are now
listed with `git diff --name-status -z`, and difft runs on each file's exact
old and new bytes, up to max_parallel_difft_calls files at a time (default
0: one per CPU). difft gets the same arguments git gives an external diff
tool and runs from the repository root, so its results, including language
and .gitattributes handling, match `git diff` exactly. jj diffs are
unchanged.

On this repository (32 CPUs), HEAD~60..HEAD (34 files) loads in 2.5 s
instead of 8.3 s; the slowest single file sets the floor.
Computing a large diff blocks Neovim for a few seconds with no sign of
progress. open() now creates the diff tab first and draws a centred window
styled like the side panel's header box: "Loading…" in its top border and
the range row below, with the same highlights. Then it computes the diff
and builds the view in that tab. When there are no changes or the diff
fails, the tab is closed again and the previous tab is current, as before.

The window is placed for the current screen size, which can change around
startup. Input still queued is handled before it is placed, and :Difft run
while Neovim starts up (nvim -c 'Difft …') waits until startup has finished
and the size has stayed unchanged for a moment (at most a second): a GUI
may resize the grid right after startup, as Neovide does when it applies
its scale factor, and a resize arriving while the diff is computed cannot
be followed.
The plugin blends its own groups (diff backgrounds, panel colours, filler,
fold and header text) from the theme's Normal, Comment, Added, Removed and
Changed colours. It did so in setup() and again only on ColorScheme or a
'background' change. Some setups apply the theme later without either
event: NvChad's starter config runs plugin setup through lazy.nvim first
and then loads its theme from precompiled highlight files with plain
nvim_set_hl calls. The derived groups then stayed blended from Neovim's
default theme (for example a panel darker and bluer than the editor
background).

The groups are now also derived again once at VimEnter, after the whole
config has run, and whenever a diff view opens, keeping the overrides
given to setup(). Linked groups are unchanged.
The Rust workflow becomes a general CI workflow (ci.yaml, "CI"), still run
on pushes to main, pull requests and the merge queue. A new job runs the
Lua tests with `just test-lua` on stable Neovim, with plenary.nvim and
nui.nvim installed as start packages. It builds the native library and
installs difft and jj first, so the end-to-end git and jj tests run
instead of being skipped.
@jrosiek
jrosiek merged commit 7b0694b into main Oct 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant