A NeoVim plugin that replicates the Git source-control UX of VSCode's SCM sidebar — with an enhanced code-review annotation feature.
| Feature | Details |
|---|---|
| File Status Panel | Staged & unstaged changes with collapsible sections, status badges, right-aligned |
| Commit Graph Panel | Recent commit history with HEAD, branch, remote, and tag ref badges |
| Mode bar | A strip above the sidebar shows the branch you're looking at and the file panel's mode; click to switch either |
| Branch changes | Press <leader>gB to see everything the branch changed since it left main, like a pull request's "Files changed" tab |
| Branch preview | Browse another branch's commits without checking it out — pick from a floating list with <leader>gb |
| Split Diff View | Side-by-side old/new diff computed in-process with Neovim's built-in xdiff — no git diff subprocess. An edited line sits beside its new version; a rewrite, or a line split over several, reads as removed lines beside added ones, top-aligned as on GitHub |
| Unified Diff View | GitHub-style single column: old and new line numbers, removed lines above added ones; toggle with <leader>gv |
| Hunk headers | Each run of lines is headed by its @@ -12,7 +12,9 @@ range and the function it is in, as on GitHub; click one to reveal the lines hidden above it |
| Accurate syntax highlighting | Tree-sitter parses each whole file once (asynchronously on 0.11+), so collapsed context never breaks highlighting |
| Line-level colours | Subtle red for removed, green for added — layered on top of syntax colours |
| Word-level highlights | Darker red/green marks the exact tokens that changed within a line, when the two lines are mostly alike (between different lines they would mark nearly everything) |
| Filler lines | Grey visual-only placeholders, across the gutter too, keep both panes aligned |
| Real line numbers | The gutter shows file line numbers, not pane row numbers |
| Scroll sync | Split panes stay locked together, whether you scroll with the keyboard, the mouse wheel over either pane, or with smoothscroll on |
| Line wrapping | Long lines wrap at word boundaries, GitHub-style; the shorter side of each row is padded so both panes stay row-aligned (split view needs Neovim ≥ 0.10) |
| Gutter indicators | Changed lines' numbers on a stronger tint, then a cell of the row's tint before the code |
| Hunk staging | Stage or unstage the change under the cursor straight from the diff |
| Live updates | Panels follow git activity from anywhere (CLI, other tools); the open diff follows edits on disk, keeping your place |
| Fast navigation | ]f / [f move between files, gf jumps to the real file at the same line |
| Go to definition, hover | gd asks your language server and stays in the review: the definition's line in this diff, the diff of another changed file, or a preview of an unchanged one; <C-o> comes back. K shows what the symbol is (see Go to Definition and Hover) |
| Annotation notes | Select lines, press <leader>n, type a note — saved to an XDG Markdown file |
| Notes panel | Toggle with <leader>N; dd deletes a note, q closes |
| Pull request review | With gh logged in, read and write review comments on the branch's pull request, reply, resolve, and submit the review (see Pull Request Review) |
- NeoVim ≥ 0.9 (developed and tested on 0.11; asynchronous tree-sitter parsing needs 0.11, older versions parse synchronously)
giton$PATH- Optional: the GitHub CLI (
gh), logged in, for pull request review - No external plugin dependencies
{
"zion-off/diff",
config = function()
require("diff").setup()
end,
}use {
"zion-off/diff",
config = function()
require("diff").setup()
end,
}Call require("diff").setup(opts) once from your config. All fields are optional.
require("diff").setup({
-- Sidebar position: "left" (default) or "right"
sidebar_position = "left",
-- Sidebar width in columns (default: 40)
sidebar_width = 40,
-- Notes panel width in columns (default: 40)
notes_width = 40,
-- Auto-refresh panels on FocusGained / BufWritePost (default: true)
-- Also watches .git/index via libuv fs_event for immediate refresh.
auto_refresh = true,
-- Branch mode compares the branch against this branch (default: nil, which
-- uses the remote's default branch origin/HEAD, else a local main or master).
base_branch = nil,
-- Mouse interactivity in the sidebar (default: true). Clicking a file opens
-- its diff; clicking a commit expands/collapses it; clicking a section header
-- toggles it. Hovering tints whatever a click would act on (rows, and @@
-- hunk headers that hide lines) and highlights the window edge you can drag
-- to resize (edges need Neovim 0.11+).
-- Only enables Neovim's 'mouse' option while the interface is open, and
-- 'mousemoveevent' only while its tab is the current one, restoring both.
-- With 'mousemoveevent' on, moving the mouse in the middle of a key
-- sequence (after <leader>, say) cancels it.
mouse = true,
-- Lines of context around each change (nil shows whole files).
context_lines = 3,
-- Soft-wrap long lines in the diff panes (default: true). In the split view
-- the shorter side of each row is padded to the taller side's height, so the
-- panes stay aligned while scrolling. Split panes narrower than 20 text
-- columns scroll horizontally instead. Needs Neovim 0.10+ in the split view;
-- on 0.9 split panes stay unwrapped.
wrap = true,
-- How diffs are drawn: "split" (default, side by side) or "unified" (one
-- column, as on GitHub). keymaps.toggle_layout switches for the rest of the
-- session. A split with no room for two panes is drawn unified.
layout = "split",
-- Log verbosity: "trace" | "debug" | "info" | "warn" (default) | "error" | "off".
-- The log lives at stdpath("log")/diff.nvim.log; open it with :DiffNvimLog.
log_level = "warn",
-- Keybinding overrides (set any to false/"" to disable)
keymaps = {
toggle_sidebar = "<leader>gs",
toggle_sidebar_panel = "<leader>gS",
copy_notes_path = "<leader>gy",
open_diff = "<CR>",
stage_file = "s",
unstage_file = "u",
collapse = "z",
next_hunk = "]c",
prev_hunk = "[c",
next_file = "]f",
prev_file = "[f",
goto_file = "gf",
goto_definition = "gd",
hover = "K",
jump_back = "<C-o>", -- back from a definition jump; elsewhere what <C-o> does anyway
stage_hunk = "s",
unstage_hunk = "u",
leave_note = "<leader>n",
leave_comment = "<leader>c",
submit_comment = "<CR>", -- in the comment editor, normal mode
show_comments = "<leader>C",
open_review = "<leader>gr",
toggle_notes = "<leader>N",
preview_branch = "<leader>gb",
branch_changes = "<leader>gB",
expand_context = "zo",
expand_all = "zR",
collapse_all = "zM",
toggle_layout = "<leader>gv",
commit_tooltip = "K",
},
-- Highlight colour overrides — any valid :hi attribute table
highlights = {
-- e.g. { bg = "#0d1f0d" }
},
})| Key | Action |
|---|---|
<leader>gs |
Toggle interface |
| Key | Action |
|---|---|
<leader>gS |
Toggle sidebar panels (show/hide file + commit panels) |
<leader>gy |
Copy session notes file path to clipboard |
<leader>N |
Toggle notes panel |
<leader>gb |
Preview another branch (open branch picker) |
<leader>gB |
Toggle branch mode (see Branch Changes) |
<leader>gv |
Toggle the diff view between split and unified |
<leader>gr |
Review the branch's pull request (submit, discard drafts, open on GitHub) |
| Key | Action |
|---|---|
<CR> / click |
Open diff for file / toggle section or directory |
s |
Stage file (the cursor moves on to the next file) |
u |
Unstage file |
z |
Toggle directory / section collapse |
The file shown in the diff view is highlighted in the panel.
| Key | Action |
|---|---|
<CR> / click |
Expand/collapse a commit to list its changed files / open a file's diff |
K |
Show full commit message tooltip |
Opened with <leader>gb (or :DiffNvimPreviewBranch).
| Key | Action |
|---|---|
<any char> |
Filter the branch list (substring match) |
<BS> / <C-h> |
Delete last filter character |
<Down> / <C-n> / <Tab> |
Next branch |
<Up> / <C-p> / <S-Tab> |
Previous branch |
<CR> |
Preview the selected branch |
<Esc> / q / <C-c> |
Cancel |
| Key | Action |
|---|---|
]c / [c |
Next / previous change |
]f / [f |
Next / previous file (same panel section, or same commit) |
gf |
Open the real file at this line, in the window the interface was opened from |
gd |
Go to the definition of the symbol under the cursor (see Go to Definition and Hover) |
K |
Show what the symbol under the cursor is, from your language server; K again moves into the float |
<C-o> |
Back to where the last gd left from; with none, Neovim's own <C-o> |
s |
Stage the change under the cursor (unstaged working-tree diffs) |
u |
Unstage the change under the cursor (staged diffs) |
l / zo |
Reveal 10 more lines at each edge of the lines hidden above the @@ header under the cursor (zo uses the nearest one) |
zR |
Show all context |
zM |
Collapse back to the configured context |
<leader>gv |
Toggle split / unified, keeping the cursor on its line |
<leader>n |
Leave a note on current / visual selection |
<leader>c |
Comment on the pull request at the current line / visual selection |
<leader>C |
Show the pull request comments on this line |
<leader>N |
Toggle notes panel |
q |
Close diff view |
j / k step over filler rows. The cursor starts on the first change, and stays on its line when the file changes on disk.
Clicking a @@ header (or l / zo on it) reveals the lines hidden above it; in the split view the header's text is drawn in the left pane, its band running on through the right. In the unified view, a note left on a selection that covers both removed and added lines goes on the side of its first line.
Hunk staging uses zero-context patches. A change directly next to a final line without a trailing newline cannot be expressed that way; the plugin says so instead of guessing, and the whole file can still be staged from the panel.
| Key | Action |
|---|---|
dd |
Delete note under cursor |
q |
Close panel |
gd in the diff view goes to the definition of the symbol under the cursor without leaving the review:
- In this file: the cursor moves to the definition, revealing it first if it is in collapsed context.
- In another file the diff covers (same panel section, or same commit): the diff view opens that file at the definition.
- In a file the diff does not cover: a float previews it.
<CR>opens the file in the editor;qor<Esc>closes the float. - Several definitions: you pick one from a list.
<C-o> goes back to where each jump left from, across files.
Your language server answers about the file as the editor has it, so the line under the cursor is matched to its line in that file first. The first gd in a file loads it in the background, which starts your language server for it as opening it would. Nothing is loaded before then. At most 10 files loaded this way stay loaded, the least recently asked about going first, and the rest are unloaded when the diff view closes. A file you opened yourself in the meantime is never unloaded.
K shows what the symbol under the cursor is, as your language server describes it, in a float under the cursor; K again moves into the float. It asks the same way gd does, so the first K in a file loads it too.
Some lines have no matching line in the file the language server knows: a removed or rewritten line, or any line while you preview another branch. For those lines, K says why it has no answer, and gd searches the text of that side of the diff instead. It uses tree-sitter's scopes where the language has them, and Vim's own gd search otherwise, and the message says a text search was used. The search is also used when no language server attaches to the file within 5 seconds, or when the server does not answer within 10 seconds. After a file type has gone without a server once, its lookups use the search at once, until a server attaches to a file of that type.
| Command | Description |
|---|---|
:DiffNvimOpen |
Open sidebar |
:DiffNvimClose |
Close sidebar |
:DiffNvimToggle |
Toggle sidebar |
:DiffNvimPreviewBranch |
Preview another branch's commits without checking it out |
:DiffNvimNotes |
Toggle notes panel |
:DiffNvimLayout [split|unified] |
Show diffs split or unified (no argument toggles) |
:DiffNvimRefresh |
Re-fetch the panels, and the branch's pull request from GitHub |
:DiffNvimReview |
Review the branch's pull request |
:DiffNvimLog |
Open the log file |
A two-row strip at the top of the sidebar keeps the interface's modes in view:
⎇ feature/x ⌃⌄ ↑2 ↓1
Changes Since main
- The first row is the branch the panels show, as a pop-up button: click
anywhere on it (or press
<CR>on it) to open the branch picker, the same as<leader>gb. On the right it shows how far the branch is ahead of (↑) and behind (↓) its upstream, read with the working-tree status at no extra cost. Previewing another branch, or showing another worktree, tints the row and names the mode (Preview/Worktree); Done takes you back to your own branch. - The second row is a segmented control switching the file panel between your
working-tree changes and everything the branch changed since its base
(Since main), the same as
<leader>gB. The selected half is raised.
Press <leader>gB to switch the file panel into branch mode: instead of
staged and unstaged changes it lists every file the branch changed since its
merge base with the base branch — the same set a pull request's "Files changed"
tab shows (git diff <base>...HEAD). Commits that landed on the base branch
after the branch left it are not included, and uncommitted changes aren't either.
The commit panel is closed and the file panel takes the whole sidebar. Press
<leader>gB again to go back.
Each file opens as one diff from the merge base to the branch's latest version. In preview mode, branch mode shows the previewed branch's changes.
The base is the remote's default branch (origin/HEAD), falling back to a local
main or master. Set base_branch to use a different one. It is looked up
each time you enter branch mode.
Press <leader>gb (or run :DiffNvimPreviewBranch) to open a floating picker
listing all local and remote branches, sorted by most recent commit. Selecting a
branch puts the interface into preview mode:
- The commit panel is re-sourced from the chosen branch (
git log <branch>), so you can browse its history and open per-file diffs — all without checking it out and without touching your working tree. - The file status panel shows a
Preview: <branch>header instead of changes, since uncommitted working-tree changes belong only to the branch you actually have checked out.
The picker marks your current branch with (current); selecting it returns to
normal live mode. Preview mode is read-only and is cleared when the interface is
closed.
Branches checked out in another git worktree are marked (worktree). Selecting
one switches the interface to that worktree: the file panel shows its staged and
unstaged changes (diffs, staging and hunk staging all act on it) and the commit
panel its history. Pick the branch of the worktree you opened the interface in
to switch back.
When the GitHub CLI is installed and logged in (gh auth status) and the
branch the sidebar shows has an open pull request, the interface reviews it
the way GitHub's "Files changed" tab does. Without gh, or without a pull
request, none of this appears. Everything goes through gh, so the plugin
never handles a token.
- The mode bar names the pull request (
#123) and how many draft comments you have on it. Press<leader>gror run:DiffNvimReviewto open the review. - Branch mode compares the branch with the pull request's base branch, so
its files and lines match GitHub's. In a fork's clone it uses the remote that
points at the pull request's own repository, not the fork's
origin. - Comment threads show on their lines in the diff: a sign (
●open,◆your draft, a dimmed●when resolved or outdated) and the first comment at the end of the line. Press<leader>Con the line (or click the sign) to open the thread in a float under it. - In the thread float, the keys are
rreply,Rresolve or unresolve,eedit your comment,xdelete it (press twice to confirm),<Tab>move between buttons,<CR>press the one under the cursor,qor<leader>Cclose. - To comment, press
<leader>con a line or a visual selection. A float opens to write the comment in Markdown. Press<Esc>, then<CR>to add it (keymaps.submit_comment) or<Esc>again to cancel;<Tab>moves to the buttons. While a float holds text you haven't sent, opening another one (a reply, the review) is refused, so nothing you typed is lost. - Comments are drafts in a pending review, as with GitHub's "Start a review", until you submit the review as a comment, an approval or a request for changes. Drafts live on GitHub, so they survive closing Neovim and show in the browser. Replies made while you have drafts join them.
- The review float (
<leader>gr) has the summary box and its buttons on top and your draft comments listed under them, each with its file, line and text. The list scrolls with the mouse wheel, or withj/kafter<S-Tab>from the editor (<Tab>goes back). - Discard drafts in the review float throws the pending review away (press twice to confirm).
Where comments can go:
- On the branch's changes (branch mode), when your local branch is at the commit GitHub has for the pull request. If you have unpushed commits, or the pull request has commits you haven't pulled, the plugin asks you to push or pull first, because the line numbers would not match GitHub's. The comments on the branch's changes are hidden then, and the mode bar says "out of sync"; the comments on single commits still show.
- On a single commit of the pull request (from the commit panel), on new-side lines. Removed lines can't take comments there, because GitHub measures them against the pull request's base rather than the commit's parent. A commit shows the comments that were made on it.
- Not on uncommitted changes.
A draft review is tied to one commit. While you have drafts, new comments go only on that commit's diff; submit or discard the drafts to comment elsewhere.
Actions show at once: a comment, reply, resolve, edit, delete, submit or
discard changes what you see the moment you make it, and goes to GitHub in
the background, in the order you made them. If GitHub rejects one, a
notification says what failed and why, any text you wrote for it is put in
the unnamed register (p pastes it back), and the view goes back to what
GitHub has.
The pull request is looked up when the interface opens and when the branch it
shows changes, including a checkout made outside Neovim. It is fetched again after each action and on :DiffNvimRefresh,
never in the background.
Notes are stored in a per-session Markdown file at:
$XDG_DATA_HOME/diff.nvim/<repo-name>_<timestamp>.md
(Falls back to ~/.local/share/diff.nvim/ when XDG_DATA_HOME is unset.)
The timestamp (YYYYMMDDTHHmmss) is captured once when the plugin first writes
a note in the session. The file is created lazily — only when the first note
is actually written. Each Neovim session produces its own file.
Example filename: diff_20260507T142301.md
Each note looks like:
## Note — path/to/file.py, lines 42–57 (new side)
> The context manager here should use `contextlib.suppress` instead of bare
> except.
*2025-01-15 14:23:01*
---Copy the session file path to the clipboard with <leader>gy, then paste it
directly into a coding-agent prompt.
Override any group via vim.api.nvim_set_hl after setup(), or use the highlights config table:
| Group | Used for |
|---|---|
DiffNvimAdded |
Added-line background |
DiffNvimRemoved |
Removed-line background |
DiffNvimFiller |
Filler-line background |
DiffNvimAddedWord |
Word-level added token |
DiffNvimRemovedWord |
Word-level removed token |
DiffNvimSectionHeader |
"Staged Changes" / "Changes" headers |
DiffNvimChevron |
⌄ / › before a section or a commit |
DiffNvimFileName |
File name in a panel |
DiffNvimAddedFile |
Added or untracked file name |
DiffNvimDeletedFile |
Deleted file name |
DiffNvimDirName |
Directory row of a file tree |
DiffNvimHint |
Explanatory text (empty states, counts) |
DiffNvimStatusModified |
M badge |
DiffNvimStatusAdded |
A badge |
DiffNvimStatusDeleted |
D badge |
DiffNvimStatusRenamed |
R badge |
DiffNvimStatusUntracked |
? badge |
DiffNvimCommitHash |
Commit short hash |
DiffNvimCommitMeta |
Author and age under a commit subject |
DiffNvimCommitSubject |
Commit subject |
DiffNvimRefHead |
HEAD ref pill |
DiffNvimRefBranch |
Local branch pill |
DiffNvimRefRemote |
Remote tracking pill |
DiffNvimRefTag |
Tag pill |
DiffNvimNoteHeader |
## Note heading in notes panel |
DiffNvimNoteText |
Note body text |
DiffNvimActiveFile |
Panel row of the file shown in the diff view |
DiffNvimHover |
Clickable row under the mouse pointer |
DiffNvimEdgeHover |
Draggable window separator under the mouse pointer (drawn only while hovered) |
DiffNvimEdgeHoverStatus |
Status line between panels under the mouse pointer (when it is the draggable edge) |
DiffNvimHeader |
Filename bar above the diff |
DiffNvimHeaderName |
File name in the header |
DiffNvimHeaderLabel |
Directory and notes in the header |
DiffNvimPanelTitle |
Panel heading ("Commits", "Notes") |
DiffNvimPanelTitleDim |
Secondary text at the end of a panel heading |
DiffNvimRule |
Rule under the diff header (other dividers are blank and no longer use this group) |
DiffNvimGap |
Blank status line (a gap) under the plugin's windows |
DiffNvimHunkHeader |
@@ -12,7 +12,9 @@ hunk header |
DiffNvimHunkHeaderDecl |
Function name after a hunk header |
DiffNvimHunkHeaderHover / DiffNvimHunkHeaderDeclHover |
Hunk header under the mouse pointer (click to expand) |
DiffNvimAddedNr / DiffNvimRemovedNr |
Line numbers of added / removed lines |
DiffNvimAddedMarker / DiffNvimRemovedMarker |
Padding between the line numbers and the code, in the row's tint |
DiffNvimModeBranch |
Branch name in the mode bar |
DiffNvimModeCell / DiffNvimModeCellHover |
The branch row of the mode bar (under the mouse pointer) |
DiffNvimModeTag |
⌃⌄ and the upstream counts in the branch row |
DiffNvimModeAway / DiffNvimModeAwayBranch / DiffNvimModeAwayTag |
The branch row while previewing another branch or worktree |
DiffNvimModeDone |
Done, which returns to your branch |
DiffNvimModeAwayHover |
The branch row or Done under the mouse pointer while previewing |
DiffNvimModeTrack / DiffNvimModeTrackHover |
Unselected half of the mode control (under the mouse pointer) |
DiffNvimModeSegment |
Selected half of the mode control |
DiffNvimModePr / DiffNvimModeAwayPr |
The pull request label in the branch row (while previewing) |
DiffNvimThreadMarker / DiffNvimThreadDraft / DiffNvimThreadResolved |
Sign of a line with open / draft / resolved or outdated review comments |
DiffNvimThreadVirtText |
First comment shown at the end of the line |
DiffNvimThreadAuthor / DiffNvimThreadMeta |
Author, and age and state, in the comments float |
DiffNvimButton / DiffNvimButtonPrimary / DiffNvimButtonDanger |
Buttons in the review floats (the one Enter presses, ones that discard) |
DiffNvimButtonHover / DiffNvimButtonCursor |
Button under the mouse pointer / the cursor |
The plugin fires User autocommands that other code can hook into:
| Pattern | When | data |
|---|---|---|
DiffNvimViewChanged |
The diff view opened another file, or closed | { kind, path, staged, hash }, or {} when closed |
DiffNvimGitChanged |
The repository's index or HEAD changed | — |
DiffNvimPrChanged |
The branch's pull request or its review changed | — |
Set log_level = "debug" and run :DiffNvimLog. The log records every git command with its duration, watcher events, and how long each diff took to load and render.
Run the test suite with:
tests/run.sh # all specs
tests/run.sh interface # specs whose file name contains "interface"Each spec runs in its own headless Neovim and creates throwaway git repositories, so the tests need nvim and git on $PATH and nothing else.
MIT