Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1038769
Added native gutter signs support
richardgill Jul 17, 2026
b9f527b
Clarified gutter sign priority comments
richardgill Jul 17, 2026
e7bf3e2
docs: clarify native gutter sign configuration
richardgill Jul 17, 2026
ede01f7
Lower unchanged sign priority to 7
richardgill Jul 17, 2026
7b6aafb
feat: use fullwidth gutter signs by default
richardgill Jul 17, 2026
5655f16
Fix gutter signs after async buffer load
richardgill Jul 18, 2026
a142600
fix(ui): avoid gutter number backgrounds on filler lines
richardgill Jul 18, 2026
81e0032
feat: support gutter signs in inline diffs
richardgill Jul 19, 2026
37b9f56
Revert "feat: support gutter signs in inline diffs"
richardgill Jul 19, 2026
72b605b
Merge branch 'main' of https://github.com/esmuellert/codediff.nvim in…
richardgill Jul 22, 2026
a7e0ee9
Fix gutter sign resume with Path refs
richardgill Jul 22, 2026
67f7c48
Merge remote-tracking branch 'upstream/main' into native-gutter-signs
richardgill Jul 23, 2026
d3cb2ea
Merge remote-tracking branch 'upstream/main' into native-gutter-signs
richardgill Jul 25, 2026
3e79bf4
Merge branch 'main' of https://github.com/esmuellert/codediff.nvim in…
richardgill Jul 26, 2026
c240095
docs: simplify gutter sign terminology
richardgill Jul 26, 2026
074b939
Clarified gutter sign visibility and cleanup behavior
richardgill Jul 26, 2026
15a82bc
docs: use design notes as PR description
richardgill Jul 26, 2026
8adb78f
docs: align gutter sign documentation
richardgill Jul 27, 2026
6e09d5e
Merge branch 'main' of https://github.com/esmuellert/codediff.nvim in…
richardgill Aug 3, 2026
96b7f37
fix(gutter-signs): guard sign placement and restore move priority
esmuellert Aug 11, 2026
458ead7
chore: bump version to 2.67.0
esmuellert Aug 11, 2026
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
48 changes: 46 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e
jump_to_first_change = true, -- Auto-scroll to first change when opening a diff: false to stay at same line
highlight_added_deleted_files = false, -- Tint full contents of added, untracked, and deleted files
highlight_priority = 100, -- Priority for line-level diff highlights (increase to override LSP highlights)
gutter_signs = false, -- Gutter +/- signs; see Gutter signs below
compute_moves = false, -- Detect moved code blocks (opt-in, matches VSCode experimental.showMoves)
compact_context_lines = 3, -- Number of context lines around hunks in compact mode
compact_sync_folds = true, -- Sync fold open/close across panes (mirrors Vim diff mode behavior)
Expand Down Expand Up @@ -317,6 +318,39 @@ require("codediff").setup({

The C library will be downloaded automatically on first use. No `build` step needed!

### Gutter signs

```lua
-- Disabled by default; existing move annotations remain.
gutter_signs = false

-- Enabled defaults.
gutter_signs = {
insert_text = "+",
delete_text = "-",
highlight_numbers = true,
changed_priority = 100,
unchanged_priority = nil,
}

-- Hide other Neovim signs with lower priorities.
gutter_signs = {
changed_priority = 100,
unchanged_priority = 7,
}
```

Set this under `diff`. `insert_text` and `delete_text` must each occupy one or two display cells, as measured by `strdisplaywidth()`, because Neovim limits sign text to two display cells. The fullwidth defaults each occupy two display cells. A rejected sign is skipped with a warning and the rest of the diff still renders.

CodeDiff uses persistent Neovim signs and does not modify `signcolumn` or `statuscolumn`. This example keeps a sign column and places signs after line numbers:

```lua
vim.opt.signcolumn = "yes"
vim.opt.statuscolumn = "%C%=%l %s"
```

`signcolumn = "yes:2"` allows a second sign on each line. Changed signs use priority 100 by default. An unchanged blocker at priority 99 can hide lower-priority Gitsigns, remote signs, diagnostics, or other signs across unchanged lines while the changed signs still win. Gutter signs require Neovim 0.10 or newer, because a sign extmark spanning several lines only decorates every line from 0.10 on. When enabled, they appear in every window displaying a buffer used by an active CodeDiff view. CodeDiff removes them when the view is suspended or closed.

### Managing Library Installation

The plugin automatically manages the C library installation:
Expand Down Expand Up @@ -742,8 +776,18 @@ The plugin defines highlight groups matching VSCode's diff colors:
- `CodeDiffCharInsert` - Deep/dark green for inserted characters
- `CodeDiffCharDelete` - Deep/dark red for deleted characters
- `CodeDiffFiller` - Gray foreground for non-empty filler line patterns
- `CodeDiffLineMove` - Background for moved code lines (derived from DiffChange)
- `CodeDiffMoveTo` - Sign column and annotation color for move indicators
- `CodeDiffLineMove` - Background for moved lines (derived from DiffChange)
- `CodeDiffCharMove` - Character-level highlight for moved text
- `CodeDiffMoveFrom` - Sign/annotation color for move source
- `CodeDiffMoveTo` - Sign/annotation color for move destination
- `CodeDiffHelpSection` - Section headings in keymap help (links to Statement)
- `CodeDiffHelpKey` - Key bindings in keymap help (links to Special)
- `CodeDiffHelpSep` - Separators in keymap help (links to NonText)
- `CodeDiffHelpDesc` - Descriptions in keymap help (links to Normal)
- `CodeDiffGutterInsert` - Gutter insert sign (defaults to `CodeDiffLineInsert`)
- `CodeDiffGutterDelete` - Gutter delete sign (defaults to `CodeDiffLineDelete`)
- `CodeDiffGutterInsertNumber` - Gutter insert line number (defaults to `CodeDiffCharInsert`)
- `CodeDiffGutterDeleteNumber` - Gutter delete line number (defaults to `CodeDiffCharDelete`)
- `CodeDiffExplorerStatFiles` - Explorer file counts
- `CodeDiffExplorerStatInsertions` - Explorer insertion counts
- `CodeDiffExplorerStatDeletions` - Explorer deletion counts
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.66.0
2.67.0
52 changes: 52 additions & 0 deletions doc/codediff.txt
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ Setup entry point:
jump_to_first_change = true,
highlight_added_deleted_files = false,
highlight_priority = 100,
gutter_signs = false,
compute_moves = false,
compact_context_lines = 3,
compact_sync_folds = true,
Expand Down Expand Up @@ -443,6 +444,53 @@ Example: replace only group rows and right-align the file count:
})
<

==============================================================================
GUTTER SIGNS *codediff-gutter-signs*

Gutter signs are disabled by default and require Neovim 0.10+, because a sign
extmark spanning several lines only decorates every line from 0.10 on. When
enabled, they appear in every window displaying a buffer used by an active
CodeDiff view. CodeDiff removes them when the view is suspended or closed.

>lua
-- Disabled by default; existing move annotations remain.
gutter_signs = false

-- Enabled defaults.
gutter_signs = {
insert_text = "+",
delete_text = "-",
highlight_numbers = true,
changed_priority = 100,
unchanged_priority = nil,
}

-- Hide other Neovim signs with lower priorities.
gutter_signs = {
changed_priority = 100,
unchanged_priority = 7,
}
<

Place `gutter_signs` under `diff`. `insert_text` and `delete_text` must each
occupy one or two display cells, as measured by strdisplaywidth(), because
Neovim limits sign text to two display cells. The fullwidth defaults each
occupy two display cells. A rejected sign is skipped with a warning and the
rest of the diff still renders.

CodeDiff does not modify signcolumn or statuscolumn. This example keeps a sign
column and places signs after line numbers:

>lua
vim.opt.signcolumn = "yes"
vim.opt.statuscolumn = "%C%=%l %s"
<

`signcolumn = "yes:2"` allows a second sign on each line. Changed signs use
priority 100 by default. An unchanged blocker at priority 99 can hide
lower-priority Gitsigns, remote signs, diagnostics, debugger signs, and other
signs across unchanged lines while changed signs still win.

==============================================================================
HIGHLIGHT GROUPS *codediff-highlight-groups*

Expand All @@ -460,6 +508,10 @@ CodeDiff defines highlight groups matching VSCode's diff colors:
- CodeDiffHelpKey - key bindings in keymap help (links to Special)
- CodeDiffHelpSep - separators in keymap help (links to NonText)
- CodeDiffHelpDesc - descriptions in keymap help (links to Normal)
- CodeDiffGutterInsert - Gutter insert sign (defaults to CodeDiffLineInsert)
- CodeDiffGutterDelete - Gutter delete sign (defaults to CodeDiffLineDelete)
- CodeDiffGutterInsertNumber - Gutter insert line number (defaults to CodeDiffCharInsert)
- CodeDiffGutterDeleteNumber - Gutter delete line number (defaults to CodeDiffCharDelete)

Default behavior:
- Uses DiffAdd and DiffDelete for line-level highlights
Expand Down
1 change: 1 addition & 0 deletions doc/tags
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ codediff-events codediff.txt /*codediff-events*
codediff-explorer-line-formatters codediff.txt /*codediff-explorer-line-formatters*
codediff-explorer-line-statistics codediff.txt /*codediff-explorer-line-statistics*
codediff-filler-text codediff.txt /*codediff-filler-text*
codediff-gutter-signs codediff.txt /*codediff-gutter-signs*
codediff-highlight-groups codediff.txt /*codediff-highlight-groups*
codediff-history codediff.txt /*codediff-history*
codediff-history-line-range codediff.txt /*codediff-history-line-range*
Expand Down
1 change: 1 addition & 0 deletions lua/codediff/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ M.defaults = {
jump_to_first_change = true, -- Auto-scroll to first change when opening a diff: true = jump to first hunk, false = stay at same line
highlight_added_deleted_files = false, -- Tint the full contents of added, untracked, and deleted files
highlight_priority = 100, -- Priority for line-level diff highlights (increase to override LSP highlights)
gutter_signs = false, -- Gutter +/- signs; optionally block lower-priority signs on unchanged lines
compute_moves = false, -- Detect moved code blocks (opt-in, may increase diff computation time)
compact_context_lines = 3, -- Number of context lines around hunks in compact mode
compact_sync_folds = true, -- Sync fold open/close across panes in compact mode (mirrors Vim diff mode behavior)
Expand Down
6 changes: 6 additions & 0 deletions lua/codediff/ui/core.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ local M = {}
local config = require("codediff.config")
local highlights = require("codediff.ui.highlights")
local filler_renderer = require("codediff.ui.filler")
local gutter_signs = require("codediff.ui.gutter_signs")
local compat = require("codediff.core.compat")

-- Namespace references
Expand Down Expand Up @@ -285,6 +286,7 @@ function M.render_diff(left_bufnr, right_bufnr, original_lines, modified_lines,
vim.api.nvim_buf_clear_namespace(right_bufnr, ns_highlight, 0, -1)
vim.api.nvim_buf_clear_namespace(left_bufnr, ns_filler, 0, -1)
vim.api.nvim_buf_clear_namespace(right_bufnr, ns_filler, 0, -1)
gutter_signs.set_changed_ranges(left_bufnr, right_bufnr, lines_diff.changes)

local total_left_fillers = 0
local total_right_fillers = 0
Expand Down Expand Up @@ -358,6 +360,7 @@ end
function M.render_whole_file(bufnr, side)
vim.api.nvim_buf_clear_namespace(bufnr, ns_highlight, 0, -1)
vim.api.nvim_buf_clear_namespace(bufnr, ns_filler, 0, -1)
gutter_signs.set_whole_file(bufnr, side)

if not config.options.diff.highlight_added_deleted_files then
return
Expand Down Expand Up @@ -398,6 +401,7 @@ function M.render_single_buffer(bufnr, diff, side)
-- Clear existing highlights
vim.api.nvim_buf_clear_namespace(bufnr, ns_highlight, 0, -1)
vim.api.nvim_buf_clear_namespace(bufnr, ns_filler, 0, -1)
gutter_signs.clear_buffer(bufnr)

-- Get buffer lines for character highlight calculations
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
Expand Down Expand Up @@ -463,6 +467,8 @@ function M.render_merge_view(left_bufnr, right_bufnr, base_to_left_diff, base_to
vim.api.nvim_buf_clear_namespace(right_bufnr, ns_highlight, 0, -1)
vim.api.nvim_buf_clear_namespace(right_bufnr, ns_filler, 0, -1)
vim.api.nvim_buf_clear_namespace(right_bufnr, ns_conflict, 0, -1)
gutter_signs.clear_buffer(left_bufnr)
gutter_signs.clear_buffer(right_bufnr)

-- Get buffer lines for character highlight calculations
local left_lines = vim.api.nvim_buf_get_lines(left_bufnr, 0, -1, false)
Expand Down
177 changes: 177 additions & 0 deletions lua/codediff/ui/gutter_signs.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
local M = {}

local config = require("codediff.config")

local namespace = vim.api.nvim_create_namespace("codediff-gutter-signs")
-- A sign extmark spanning several rows only decorates every row from 0.10 on.
-- Older versions decorate start_row alone, so ranges there are drawn per line.
local has_ranged_signs = vim.fn.has("nvim-0.10") == 1
local default_move_priority = 250
local default_options = {
insert_text = "+",
delete_text = "-",
highlight_numbers = true,
changed_priority = 100,
}
local signs = {
original = {
text_option = "delete_text",
hl_group = "CodeDiffGutterDelete",
number_hl_group = "CodeDiffGutterDeleteNumber",
},
modified = {
text_option = "insert_text",
hl_group = "CodeDiffGutterInsert",
number_hl_group = "CodeDiffGutterInsertNumber",
},
unchanged = { text = " ", hl_group = "Normal" },
move_single = { text = "─", hl_group = "CodeDiffMoveTo" },
move_first = { text = "┌", hl_group = "CodeDiffMoveTo" },
move_middle = { text = "│", hl_group = "CodeDiffMoveTo" },
move_last = { text = "└", hl_group = "CodeDiffMoveTo" },
}

local get_options = function()
local options = config.options.diff.gutter_signs
if type(options) ~= "table" then
return nil
end
if not has_ranged_signs then
vim.notify_once("[codediff] diff.gutter_signs requires Neovim 0.10 or newer", vim.log.levels.WARN)
return nil
end
return vim.tbl_extend("force", default_options, options)
end

local get_changed_sign = function(side, options)
local sign = signs[side]
return {
text = options[sign.text_option],
hl_group = sign.hl_group,
number_hl_group = options.highlight_numbers and sign.number_hl_group or nil,
}
end

local set_extmark = function(bufnr, row, opts)
-- Neovim rejects sign text wider than two cells and out-of-range priorities.
-- Without this guard a bad option aborts the whole diff render.
local ok = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, row, 0, opts)
if not ok then
vim.notify_once("[codediff] diff.gutter_signs could not place a sign, check insert_text, delete_text and priorities", vim.log.levels.WARN)
end
return ok
end

local set_sign_range = function(bufnr, range, sign, priority)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) or not range or range.end_line <= range.start_line then
return
end

local line_count = vim.api.nvim_buf_line_count(bufnr)
local start_row = math.max(range.start_line - 1, 0)
local end_row = math.min(range.end_line - 1, line_count)
if end_row <= start_row then
return
end

set_extmark(bufnr, start_row, {
end_row = end_row - 1,
end_col = 0,
sign_text = sign.text,
sign_hl_group = sign.hl_group,
number_hl_group = sign.number_hl_group,
priority = priority,
strict = false,
})
end

local set_whole_buffer_sign = function(bufnr, sign, priority, tracks_appends)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
return
end

local line_count = vim.api.nvim_buf_line_count(bufnr)
set_extmark(bufnr, 0, {
end_row = tracks_appends and line_count or line_count - 1,
end_col = 0,
right_gravity = false,
end_right_gravity = tracks_appends,
sign_text = sign.text,
sign_hl_group = sign.hl_group,
number_hl_group = sign.number_hl_group,
priority = priority,
strict = false,
})
end

M.clear_buffer = function(bufnr)
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1)
end
end

M.set_changed_ranges = function(left_bufnr, right_bufnr, changes)
M.clear_buffer(left_bufnr)
M.clear_buffer(right_bufnr)

local options = get_options()
if not options then
return
end

if options.unchanged_priority then
set_whole_buffer_sign(left_bufnr, signs.unchanged, options.unchanged_priority, false)
set_whole_buffer_sign(right_bufnr, signs.unchanged, options.unchanged_priority, false)
end

local priority = options.changed_priority
local original_sign = get_changed_sign("original", options)
local modified_sign = get_changed_sign("modified", options)
for _, change in ipairs(changes or {}) do
set_sign_range(left_bufnr, change.original, original_sign, priority)
set_sign_range(right_bufnr, change.modified, modified_sign, priority)
end
end

local move_sign = function(line, first, last)
if first == last then
return signs.move_single
end
if line == first then
return signs.move_first
end
if line == last then
return signs.move_last
end
return signs.move_middle
end

M.set_move_range = function(bufnr, first, last)
if last < first then
return
end

-- A moved line is also a changed line, so the move glyph has to outrank the
-- changed sign instead of tying with it and depending on extmark creation order.
local options = get_options()
local priority = options and math.max(options.changed_priority + 1, default_move_priority) or default_move_priority

-- One sign per line, as move rendering has always done: a single ranged
-- extmark would decorate only its first row before Neovim 0.10.
for line = first, last do
set_sign_range(bufnr, { start_line = line, end_line = line + 1 }, move_sign(line, first, last), priority)
end
end

M.set_whole_file = function(bufnr, side)
M.clear_buffer(bufnr)

local options = get_options()
if not options then
return
end

set_whole_buffer_sign(bufnr, get_changed_sign(side, options), options.changed_priority, true)
end

return M
Loading
Loading