diff --git a/README.md b/README.md index ca133825..72fa369c 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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: @@ -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 diff --git a/VERSION b/VERSION index 3d6ac35b..ed283c86 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.66.0 +2.67.0 diff --git a/doc/codediff.txt b/doc/codediff.txt index 63a3216b..2b68e119 100644 --- a/doc/codediff.txt +++ b/doc/codediff.txt @@ -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, @@ -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* @@ -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 diff --git a/doc/tags b/doc/tags index f1c5ef03..bddab00c 100644 --- a/doc/tags +++ b/doc/tags @@ -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* diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index 51d54336..bf0cf35b 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -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) diff --git a/lua/codediff/ui/core.lua b/lua/codediff/ui/core.lua index bbe8927b..b17bd324 100644 --- a/lua/codediff/ui/core.lua +++ b/lua/codediff/ui/core.lua @@ -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 @@ -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 @@ -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 @@ -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) @@ -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) diff --git a/lua/codediff/ui/gutter_signs.lua b/lua/codediff/ui/gutter_signs.lua new file mode 100644 index 00000000..a75811ed --- /dev/null +++ b/lua/codediff/ui/gutter_signs.lua @@ -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 diff --git a/lua/codediff/ui/highlights.lua b/lua/codediff/ui/highlights.lua index 9a350b98..0ebe8eb9 100644 --- a/lua/codediff/ui/highlights.lua +++ b/lua/codediff/ui/highlights.lua @@ -170,6 +170,22 @@ function M.setup() vim.api.nvim_set_hl(0, "CodeDiffCharInsert", char_insert_color) vim.api.nvim_set_hl(0, "CodeDiffCharDelete", char_delete_color) + vim.api.nvim_set_hl(0, "CodeDiffGutterInsert", { link = "CodeDiffLineInsert", default = true }) + vim.api.nvim_set_hl(0, "CodeDiffGutterDelete", { link = "CodeDiffLineDelete", default = true }) + + -- Neovim applies number_hl_group to virtual lines anchored to the same buffer line. + -- Keep these groups foreground-only so filler rows do not inherit a colored number-column background. + vim.api.nvim_set_hl(0, "CodeDiffGutterInsertNumber", { + fg = char_insert_color.bg, + ctermfg = char_insert_color.ctermbg, + default = true, + }) + vim.api.nvim_set_hl(0, "CodeDiffGutterDeleteNumber", { + fg = char_delete_color.bg, + ctermfg = char_delete_color.ctermbg, + default = true, + }) + -- Moved code highlights (derived from DiffChange — the standard "changed" color) local diff_change_hl = vim.api.nvim_get_hl(0, { name = "DiffChange", link = false }) local move_fallback = effective_bg(diff_change_hl) or 0x4f5258 diff --git a/lua/codediff/ui/inline.lua b/lua/codediff/ui/inline.lua index c91b95ec..70f65c1d 100644 --- a/lua/codediff/ui/inline.lua +++ b/lua/codediff/ui/inline.lua @@ -4,6 +4,7 @@ local M = {} local config = require("codediff.config") local highlights = require("codediff.ui.highlights") +local gutter_signs = require("codediff.ui.gutter_signs") local compat = require("codediff.core.compat") -- Dedicated namespace for inline diff (separate from side-by-side namespaces) @@ -382,6 +383,7 @@ end function M.render_inline_diff(bufnr, diff_result, original_lines, modified_lines, opts) -- Clear previous inline decorations vim.api.nvim_buf_clear_namespace(bufnr, M.ns_inline, 0, -1) + gutter_signs.clear_buffer(bufnr) -- Clear merged highlight cache on re-render (colorscheme may have changed) merged_hl_cache = {} @@ -472,6 +474,7 @@ end function M.clear(bufnr) if bufnr and vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_clear_namespace(bufnr, M.ns_inline, 0, -1) + gutter_signs.clear_buffer(bufnr) end end diff --git a/lua/codediff/ui/lifecycle/accessors.lua b/lua/codediff/ui/lifecycle/accessors.lua index 899d479a..bb221fa2 100644 --- a/lua/codediff/ui/lifecycle/accessors.lua +++ b/lua/codediff/ui/lifecycle/accessors.lua @@ -14,6 +14,12 @@ local function is_virtual_revision(revision) return revision ~= nil and revision ~= "WORKING" end +local function clear_gutter_signs(sess) + local gutter_signs = require("codediff.ui.gutter_signs") + gutter_signs.clear_buffer(sess.original_bufnr) + gutter_signs.clear_buffer(sess.modified_bufnr) +end + -- ============================================================================ -- PUBLIC API - GETTERS (return copies/values, safe) -- ============================================================================ @@ -229,6 +235,9 @@ function M.update_layout(tabpage, layout) end sess.layout = layout + if layout == "inline" then + clear_gutter_signs(sess) + end return true end @@ -295,6 +304,14 @@ function M.update_buffers(tabpage, original_bufnr, modified_bufnr) end local state = require("codediff.ui.lifecycle.state") + local gutter_signs = require("codediff.ui.gutter_signs") + + if sess.original_bufnr ~= original_bufnr and sess.original_bufnr ~= modified_bufnr then + gutter_signs.clear_buffer(sess.original_bufnr) + end + if sess.modified_bufnr ~= original_bufnr and sess.modified_bufnr ~= modified_bufnr then + gutter_signs.clear_buffer(sess.modified_bufnr) + end -- Hand mappings back to any buffer that is leaving the session. Without this -- the previous file keeps codediff's keys until the tab is closed. @@ -386,6 +403,9 @@ function M.set_result(tabpage, result_bufnr, result_win) if result_win and vim.api.nvim_win_is_valid(result_win) then vim.w[result_win].codediff_restore = 1 end + if result_win then + clear_gutter_signs(sess) + end return true end diff --git a/lua/codediff/ui/lifecycle/state.lua b/lua/codediff/ui/lifecycle/state.lua index 3560343e..908e0dfb 100644 --- a/lua/codediff/ui/lifecycle/state.lua +++ b/lua/codediff/ui/lifecycle/state.lua @@ -49,6 +49,7 @@ local function clear_buffer_highlights(bufnr) vim.api.nvim_buf_clear_namespace(bufnr, highlights.ns_conflict, 0, -1) local ns_inline = vim.api.nvim_create_namespace("codediff-inline") vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) + require("codediff.ui.gutter_signs").clear_buffer(bufnr) end M.clear_buffer_highlights = clear_buffer_highlights diff --git a/lua/codediff/ui/move.lua b/lua/codediff/ui/move.lua index a929f7fc..d7c02ff8 100644 --- a/lua/codediff/ui/move.lua +++ b/lua/codediff/ui/move.lua @@ -3,6 +3,7 @@ local M = {} local highlights = require("codediff.ui.highlights") local filler_renderer = require("codediff.ui.filler") +local gutter_signs = require("codediff.ui.gutter_signs") local ns_highlight = highlights.ns_highlight -- ============================================================================ @@ -69,26 +70,12 @@ end -- Per-Line Highlights and Signs -- ============================================================================ -local function get_sign(line, first, last) - if first == last then - return "─" - end - if line == first then - return "┌" - end - if line == last then - return "└" - end - return "│" -end - local function highlight_moved_lines(bufnr, first, last, line_count) for line = first, last do if line > line_count then break end local line_idx = line - 1 - local sign = get_sign(line, first, last) -- Range extmark for highlight (overrides diff highlight at priority 250) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns_highlight, line_idx, 0, { end_line = line_idx + 1, @@ -97,14 +84,13 @@ local function highlight_moved_lines(bufnr, first, last, line_count) hl_eol = true, priority = 250, }) - -- Separate point extmark for sign + number highlight (no end_line — won't bleed) + -- Separate point extmark for number highlight (no end_line — won't bleed) pcall(vim.api.nvim_buf_set_extmark, bufnr, ns_highlight, line_idx, 0, { number_hl_group = "CodeDiffMoveTo", - sign_text = sign, - sign_hl_group = "CodeDiffMoveTo", priority = 250, }) end + gutter_signs.set_move_range(bufnr, first, math.min(last, line_count)) end -- ============================================================================ diff --git a/lua/codediff/ui/view/welcome_window.lua b/lua/codediff/ui/view/welcome_window.lua index 66d2e8cc..a11e30b6 100644 --- a/lua/codediff/ui/view/welcome_window.lua +++ b/lua/codediff/ui/view/welcome_window.lua @@ -5,17 +5,13 @@ local welcome = require("codediff.ui.welcome") local option_names = { "number", "relativenumber", - "signcolumn", "foldcolumn", - "statuscolumn", } local welcome_opts = { number = false, relativenumber = false, - signcolumn = "no", foldcolumn = "0", - statuscolumn = " ", } local function is_valid_window(winid) diff --git a/tests/ui/gutter_signs_spec.lua b/tests/ui/gutter_signs_spec.lua new file mode 100644 index 00000000..099596a6 --- /dev/null +++ b/tests/ui/gutter_signs_spec.lua @@ -0,0 +1,313 @@ +local config = require("codediff.config") +local gutter_signs = require("codediff.ui.gutter_signs") +local path = require("codediff.core.path") +local gutter_signs_namespace = vim.api.nvim_create_namespace("codediff-gutter-signs") + +local get_signs = function(bufnr) + local marks = vim.api.nvim_buf_get_extmarks(bufnr, gutter_signs_namespace, 0, -1, { details = true }) + return vim.tbl_map(function(mark) + return { + row = mark[2], + text = vim.trim(mark[4].sign_text or ""), + raw_text = mark[4].sign_text, + hl = mark[4].sign_hl_group, + number_hl = mark[4].number_hl_group, + priority = mark[4].priority, + end_row = mark[4].end_row, + } + end, marks) +end + +local find_sign = function(bufnr, text) + for _, sign in ipairs(get_signs(bufnr)) do + if sign.text == text then + return sign + end + end +end + +local create_session = function() + local original_bufnr = vim.api.nvim_create_buf(false, true) + local modified_bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(original_bufnr, 0, -1, false, { "one", "two", "three" }) + vim.api.nvim_buf_set_lines(modified_bufnr, 0, -1, false, { "one", "changed", "three" }) + + local original_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(original_win, original_bufnr) + vim.cmd("rightbelow vsplit") + local modified_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(modified_win, modified_bufnr) + + return { + layout = "side-by-side", + suspended = false, + original_bufnr = original_bufnr, + modified_bufnr = modified_bufnr, + original_win = original_win, + modified_win = modified_win, + } +end + +local destroy_session = function(session) + gutter_signs.clear_buffer(session.original_bufnr) + gutter_signs.clear_buffer(session.modified_bufnr) + if vim.api.nvim_win_is_valid(session.modified_win) then + vim.api.nvim_win_close(session.modified_win, true) + end + for _, bufnr in ipairs({ session.original_bufnr, session.modified_bufnr }) do + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end +end + +local changed_ranges = { + { + original = { start_line = 2, end_line = 3 }, + modified = { start_line = 2, end_line = 3 }, + }, +} + +describe("Gutter signs", function() + local session + + before_each(function() + config.options = vim.deepcopy(config.defaults) + session = create_session() + end) + + after_each(function() + destroy_session(session) + end) + + it("does not create signs or change window options when disabled", function() + vim.wo[session.original_win].signcolumn = "yes:2" + vim.wo[session.original_win].statuscolumn = "%C%=%l %s" + + gutter_signs.set_changed_ranges(session.original_bufnr, session.modified_bufnr, changed_ranges) + + assert.same({}, get_signs(session.original_bufnr)) + assert.equals("yes:2", vim.wo[session.original_win].signcolumn) + assert.equals("%C%=%l %s", vim.wo[session.original_win].statuscolumn) + end) + + it("preserves move signs when changed signs are disabled", function() + gutter_signs.set_changed_ranges(session.original_bufnr, session.modified_bufnr, changed_ranges) + gutter_signs.set_move_range(session.original_bufnr, 1, 3) + + assert.equals(250, find_sign(session.original_bufnr, "┌").priority) + assert.equals(250, find_sign(session.original_bufnr, "│").priority) + assert.equals(250, find_sign(session.original_bufnr, "└").priority) + end) + + it("uses the enabled defaults for changed signs", function() + config.options.diff.gutter_signs = {} + gutter_signs.set_changed_ranges(session.original_bufnr, session.modified_bufnr, changed_ranges) + + local original = get_signs(session.original_bufnr)[1] + assert.same({ + row = 1, + raw_text = "-", + priority = 100, + hl = "CodeDiffGutterDelete", + number_hl = "CodeDiffGutterDeleteNumber", + }, { + row = original.row, + raw_text = original.raw_text, + priority = original.priority, + hl = original.hl, + number_hl = original.number_hl, + }) + + local modified = get_signs(session.modified_bufnr)[1] + assert.equals("+", modified.raw_text) + assert.equals("CodeDiffGutterInsert", modified.hl) + assert.equals("CodeDiffGutterInsertNumber", modified.number_hl) + end) + + it("supports custom text and disabled number highlights", function() + config.options.diff.gutter_signs = { + insert_text = "++", + delete_text = "--", + highlight_numbers = false, + } + gutter_signs.set_changed_ranges(session.original_bufnr, session.modified_bufnr, changed_ranges) + + local original = get_signs(session.original_bufnr)[1] + local modified = get_signs(session.modified_bufnr)[1] + assert.equals("--", original.raw_text) + assert.equals("++", modified.raw_text) + assert.is_nil(original.number_hl) + assert.is_nil(modified.number_hl) + end) + + it("leaves unchanged lines alone when unchanged_priority is nil", function() + config.options.diff.gutter_signs = { changed_priority = 100 } + gutter_signs.set_changed_ranges(session.original_bufnr, session.modified_bufnr, {}) + + assert.same({}, get_signs(session.original_bufnr)) + end) + + it("uses one ranged whitespace blocker for the whole buffer", function() + config.options.diff.gutter_signs = { changed_priority = 100, unchanged_priority = 99 } + gutter_signs.set_changed_ranges(session.original_bufnr, session.modified_bufnr, changed_ranges) + + local blocker = find_sign(session.original_bufnr, "") + assert.equals("", blocker.text) + assert.equals(99, blocker.priority) + assert.equals(0, blocker.row) + assert.equals(2, blocker.end_row) + assert.equals(100, find_sign(session.original_bufnr, "-").priority) + end) + + it("keeps external signs and window options untouched", function() + config.options.diff.gutter_signs = { changed_priority = 100, unchanged_priority = 99 } + vim.wo[session.original_win].signcolumn = "yes:2" + vim.wo[session.original_win].statuscolumn = "%C%=%l %s" + local external_namespace = vim.api.nvim_create_namespace("codediff-gutter-signs-test-external") + vim.api.nvim_buf_set_extmark(session.original_bufnr, external_namespace, 0, 0, { + sign_text = "X", + priority = 98, + }) + + gutter_signs.set_changed_ranges(session.original_bufnr, session.modified_bufnr, {}) + + local external = vim.api.nvim_buf_get_extmarks(session.original_bufnr, external_namespace, 0, -1, { details = true }) + assert.equals(99, get_signs(session.original_bufnr)[1].priority) + assert.equals(98, external[1][4].priority) + assert.equals("yes:2", vim.wo[session.original_win].signcolumn) + assert.equals("%C%=%l %s", vim.wo[session.original_win].statuscolumn) + end) + + it("lets move signs override changed signs", function() + config.options.diff.gutter_signs = { changed_priority = 100 } + gutter_signs.set_changed_ranges(session.original_bufnr, session.modified_bufnr, { + { + original = { start_line = 1, end_line = 4 }, + modified = { start_line = 1, end_line = 4 }, + }, + }) + gutter_signs.set_move_range(session.original_bufnr, 1, 3) + + assert.equals(0, find_sign(session.original_bufnr, "┌").row) + assert.equals(1, find_sign(session.original_bufnr, "│").row) + assert.equals(2, find_sign(session.original_bufnr, "└").row) + + -- Priority, not extmark creation order, has to decide the winner. + local changed_priority = find_sign(session.original_bufnr, "-").priority + for _, glyph in ipairs({ "┌", "│", "└" }) do + assert.is_true(find_sign(session.original_bufnr, glyph).priority > changed_priority) + end + end) + + it("keeps move signs at their standalone priority when changed signs are enabled", function() + config.options.diff.gutter_signs = false + gutter_signs.set_move_range(session.original_bufnr, 1, 1) + local without_gutter = find_sign(session.original_bufnr, "─").priority + + gutter_signs.clear_buffer(session.original_bufnr) + config.options.diff.gutter_signs = { changed_priority = 100 } + gutter_signs.set_move_range(session.original_bufnr, 1, 1) + + assert.equals(without_gutter, find_sign(session.original_bufnr, "─").priority) + end) + + it("still renders the diff when Neovim rejects the sign text", function() + local core = require("codediff.ui.core") + local highlights = require("codediff.ui.highlights") + highlights.setup() + config.options.diff.gutter_signs = { insert_text = "→→→" } + + local original_lines = vim.api.nvim_buf_get_lines(session.original_bufnr, 0, -1, false) + local modified_lines = vim.api.nvim_buf_get_lines(session.modified_bufnr, 0, -1, false) + local ok = pcall( + core.render_diff, + session.original_bufnr, + session.modified_bufnr, + original_lines, + modified_lines, + { changes = changed_ranges, moves = {} } + ) + + assert.is_true(ok) + local rendered = vim.api.nvim_buf_get_extmarks(session.modified_bufnr, highlights.ns_highlight, 0, -1, {}) + assert.is_true(#rendered > 0) + end) + + it("keeps a growing whole-file sign anchored across replacement and appends", function() + config.options.diff.gutter_signs = { changed_priority = 100 } + vim.api.nvim_buf_set_lines(session.modified_bufnr, 0, -1, false, { "loading" }) + gutter_signs.set_whole_file(session.modified_bufnr, "modified") + + vim.api.nvim_buf_set_lines(session.modified_bufnr, 0, -1, false, { "one", "two", "three" }) + local sign = get_signs(session.modified_bufnr)[1] + assert.equals("+", sign.text) + assert.equals("CodeDiffGutterInsert", sign.hl) + assert.equals("CodeDiffGutterInsertNumber", sign.number_hl) + assert.equals(0, sign.row) + assert.equals(3, sign.end_row) + + vim.api.nvim_buf_set_lines(session.modified_bufnr, 3, -1, false, { "loaded later" }) + assert.equals(4, get_signs(session.modified_bufnr)[1].end_row) + end) + + it("restores whole-file signs after tab resume", function() + config.options.diff.gutter_signs = { changed_priority = 100 } + local lifecycle = require("codediff.ui.lifecycle") + local state = require("codediff.ui.lifecycle.state") + local tabpage = vim.api.nvim_get_current_tabpage() + lifecycle.create_session( + tabpage, + "standalone", + nil, + path.empty(), + path.make_ref("modified.txt", nil), + nil, + nil, + session.original_bufnr, + session.modified_bufnr, + session.original_win, + session.modified_win, + { changes = {}, moves = {} } + ) + local tracked = lifecycle.get_session(tabpage) + tracked.single_side = "modified" + gutter_signs.set_whole_file(session.modified_bufnr, "modified") + + state.suspend_diff(tabpage) + assert.same({}, get_signs(session.modified_bufnr)) + state.resume_diff(tabpage) + + assert.equals("+", get_signs(session.modified_bufnr)[1].text) + lifecycle.cleanup(tabpage) + end) + + it("clears signs during inline and conflict transitions", function() + config.options.diff.gutter_signs = { changed_priority = 100 } + local lifecycle = require("codediff.ui.lifecycle") + local tabpage = vim.api.nvim_get_current_tabpage() + lifecycle.create_session( + tabpage, + "standalone", + nil, + path.make_ref("original.txt", nil), + path.make_ref("modified.txt", nil), + nil, + nil, + session.original_bufnr, + session.modified_bufnr, + session.original_win, + session.modified_win, + { changes = {}, moves = {} } + ) + + gutter_signs.set_changed_ranges(session.original_bufnr, session.modified_bufnr, changed_ranges) + lifecycle.update_layout(tabpage, "inline") + assert.same({}, get_signs(session.original_bufnr)) + + gutter_signs.set_changed_ranges(session.original_bufnr, session.modified_bufnr, changed_ranges) + lifecycle.set_result(tabpage, session.modified_bufnr, session.modified_win) + assert.same({}, get_signs(session.original_bufnr)) + lifecycle.cleanup(tabpage) + end) +end) diff --git a/tests/ui/highlights_spec.lua b/tests/ui/highlights_spec.lua index 315fca2b..44b67c2b 100644 --- a/tests/ui/highlights_spec.lua +++ b/tests/ui/highlights_spec.lua @@ -12,6 +12,10 @@ local function reset_codediff() pcall(vim.api.nvim_set_hl, 0, "CodeDiffLineDelete", {}) pcall(vim.api.nvim_set_hl, 0, "CodeDiffCharInsert", {}) pcall(vim.api.nvim_set_hl, 0, "CodeDiffCharDelete", {}) + vim.cmd("highlight clear CodeDiffGutterInsert") + vim.cmd("highlight clear CodeDiffGutterDelete") + vim.cmd("highlight clear CodeDiffGutterInsertNumber") + vim.cmd("highlight clear CodeDiffGutterDeleteNumber") config.options = vim.deepcopy(config.defaults) require("codediff").setup({}) end @@ -176,6 +180,39 @@ describe("highlights.lua color derivation", function() assert.is_nil(insert.bold) end) + it("defines stable gutter highlight defaults", function() + highlights.setup() + + assert.equals("CodeDiffLineInsert", vim.api.nvim_get_hl(0, { name = "CodeDiffGutterInsert", link = true }).link) + assert.equals("CodeDiffLineDelete", vim.api.nvim_get_hl(0, { name = "CodeDiffGutterDelete", link = true }).link) + + local char_insert = vim.api.nvim_get_hl(0, { name = "CodeDiffCharInsert", link = false }) + local char_delete = vim.api.nvim_get_hl(0, { name = "CodeDiffCharDelete", link = false }) + local number_insert = vim.api.nvim_get_hl(0, { name = "CodeDiffGutterInsertNumber", link = false }) + local number_delete = vim.api.nvim_get_hl(0, { name = "CodeDiffGutterDeleteNumber", link = false }) + assert.equals(char_insert.bg, number_insert.fg) + assert.equals(char_insert.ctermbg, number_insert.ctermfg) + assert.is_nil(number_insert.bg) + assert.is_nil(number_insert.ctermbg) + assert.equals(char_delete.bg, number_delete.fg) + assert.equals(char_delete.ctermbg, number_delete.ctermfg) + assert.is_nil(number_delete.bg) + assert.is_nil(number_delete.ctermbg) + end) + + it("preserves colorscheme gutter highlight overrides", function() + vim.api.nvim_set_hl(0, "CodeDiffGutterInsert", { fg = 0x123456, bg = 0x654321 }) + vim.api.nvim_set_hl(0, "CodeDiffGutterInsertNumber", { bg = 0xabcdef }) + + highlights.setup() + + local sign = vim.api.nvim_get_hl(0, { name = "CodeDiffGutterInsert", link = false }) + local number = vim.api.nvim_get_hl(0, { name = "CodeDiffGutterInsertNumber", link = false }) + assert.equals(0x123456, sign.fg) + assert.equals(0x654321, sign.bg) + assert.equals(0xabcdef, number.bg) + end) + it("re-derives on :colorscheme change", function() -- First setup: bg-based DiffAdd vim.api.nvim_set_hl(0, "DiffAdd", { bg = 0x111111 }) diff --git a/tests/ui/view/move_rendering_spec.lua b/tests/ui/view/move_rendering_spec.lua index bf72600b..9495814a 100644 --- a/tests/ui/view/move_rendering_spec.lua +++ b/tests/ui/view/move_rendering_spec.lua @@ -1,6 +1,6 @@ -- Test: Move rendering in side-by-side mode --- Verifies CodeDiffLineMove highlights, sign column symbols, and number_hl_group --- on moved code blocks detected by the diff engine. +-- Verifies CodeDiffLineMove highlights and number_hl_group on moved code +-- blocks detected by the diff engine. local view = require("codediff.ui.view") local diff = require("codediff.core.diff") @@ -67,20 +67,6 @@ local function lines_with_hl(bufnr, hl_group) return set end --- Return map of 0-indexed line -> sign_text for extmarks that have sign_text -local function signs_on_buf(bufnr) - local marks = get_extmarks(bufnr) - local map = {} - for _, m in ipairs(marks) do - local details = m[4] - if details.sign_text then - -- Neovim pads sign_text to 2 chars; trim trailing whitespace for comparison - map[m[2]] = { text = vim.trim(details.sign_text), hl = details.sign_hl_group } - end - end - return map -end - -- Return map of 0-indexed line -> number_hl_group local function number_hl_on_buf(bufnr) local marks = get_extmarks(bufnr) @@ -94,6 +80,12 @@ local function number_hl_on_buf(bufnr) return map end +local function assert_no_highlight_signs(bufnr) + for _, mark in ipairs(get_extmarks(bufnr)) do + assert.is_nil(mark[4].sign_text) + end +end + describe("Move rendering (side-by-side)", function() after_each(function() while vim.fn.tabpagenr("$") > 1 do @@ -158,89 +150,15 @@ describe("Move rendering (side-by-side)", function() end) -- ────────────────────────────────────────────────────────────────────────── - -- Test 2: Sign column symbols correct on original side - -- ────────────────────────────────────────────────────────────────────────── - it("sets correct sign column symbols on original side", function() - local tabpage, lp, rp = create_move_view(orig, mod, "sign_orig") - - local orig_buf = lifecycle.get_buffers(tabpage) - assert(orig_buf, "original buffer must exist") - - local signs = signs_on_buf(orig_buf) - - local block_len = orig_last - orig_first + 1 - - for line = orig_first, orig_last do - local line_0 = line - 1 - assert(signs[line_0], "sign expected on original line " .. line) - - local expected_sign - if block_len == 1 then - expected_sign = "─" - elseif line == orig_first then - expected_sign = "┌" - elseif line == orig_last then - expected_sign = "└" - else - expected_sign = "│" - end - - assert.equal(expected_sign, signs[line_0].text, - "original line " .. line .. " sign mismatch") - assert.equal("CodeDiffMoveTo", signs[line_0].hl, - "original line " .. line .. " sign_hl_group mismatch") - end - - vim.fn.delete(lp) - vim.fn.delete(rp) - end) - - -- ────────────────────────────────────────────────────────────────────────── - -- Test 3: Sign column symbols correct on modified side - -- ────────────────────────────────────────────────────────────────────────── - it("sets correct sign column symbols on modified side", function() - local tabpage, lp, rp = create_move_view(orig, mod, "sign_mod") - - local _, mod_buf = lifecycle.get_buffers(tabpage) - assert(mod_buf, "modified buffer must exist") - - local signs = signs_on_buf(mod_buf) - - local block_len = mod_last - mod_first + 1 - - for line = mod_first, mod_last do - local line_0 = line - 1 - assert(signs[line_0], "sign expected on modified line " .. line) - - local expected_sign - if block_len == 1 then - expected_sign = "─" - elseif line == mod_first then - expected_sign = "┌" - elseif line == mod_last then - expected_sign = "└" - else - expected_sign = "│" - end - - assert.equal(expected_sign, signs[line_0].text, - "modified line " .. line .. " sign mismatch") - assert.equal("CodeDiffMoveTo", signs[line_0].hl, - "modified line " .. line .. " sign_hl_group mismatch") - end - - vim.fn.delete(lp) - vim.fn.delete(rp) - end) - - -- ────────────────────────────────────────────────────────────────────────── - -- Test 4: number_hl_group is CodeDiffMoveTo on moved lines + -- Test 2: number_hl_group is CodeDiffMoveTo on moved lines -- ────────────────────────────────────────────────────────────────────────── it("sets number_hl_group to CodeDiffMoveTo on moved lines", function() local tabpage, lp, rp = create_move_view(orig, mod, "numhl") local orig_buf, mod_buf = lifecycle.get_buffers(tabpage) assert(orig_buf and mod_buf, "buffers must exist") + assert_no_highlight_signs(orig_buf) + assert_no_highlight_signs(mod_buf) -- Original side local nhl_orig = number_hl_on_buf(orig_buf) @@ -261,7 +179,7 @@ describe("Move rendering (side-by-side)", function() end) -- ────────────────────────────────────────────────────────────────────────── - -- Test 5: No move rendering when compute_moves = false + -- Test 3: No move rendering when compute_moves = false -- ────────────────────────────────────────────────────────────────────────── it("produces no CodeDiffLineMove extmarks when compute_moves is false", function() local tabpage, lp, rp = create_move_view(orig, mod, "nomove", { @@ -281,8 +199,8 @@ describe("Move rendering (side-by-side)", function() vim.fn.delete(rp) end) - -- Test 6: All test pairs — moved lines get correct highlights and signs - it("renders correct highlights and signs for ALL test pairs", function() + -- Test 4: All test pairs — moved lines get correct highlights + it("renders correct highlights for ALL test pairs", function() local pairs_dir = "scripts/test_pairs" local handle = vim.loop.fs_scandir(pairs_dir) assert.is_truthy(handle, "test_pairs directory should exist") diff --git a/tests/ui/welcome_spec.lua b/tests/ui/welcome_spec.lua index a9110f8f..8abdae6b 100644 --- a/tests/ui/welcome_spec.lua +++ b/tests/ui/welcome_spec.lua @@ -130,9 +130,9 @@ describe("Welcome Page", function() assert.is_false(vim.wo[main_win].number) assert.is_false(vim.wo[main_win].relativenumber) - assert.equals("no", vim.wo[main_win].signcolumn) + assert.equals("yes:1", vim.wo[main_win].signcolumn) assert.equals("0", vim.wo[main_win].foldcolumn) - assert.equals(" ", vim.wo[main_win].statuscolumn) + assert.equals("%l", vim.wo[main_win].statuscolumn) assert.is_true(vim.wo[other_win].number) assert.is_false(vim.wo[other_win].relativenumber) @@ -276,9 +276,9 @@ describe("Welcome Page", function() assert.is_true(session.single_pane == true, "Session should be in single_pane mode") assert.is_false(vim.wo[session.modified_win].number) assert.is_false(vim.wo[session.modified_win].relativenumber) - assert.equals("no", vim.wo[session.modified_win].signcolumn) + assert.equals("yes:1", vim.wo[session.modified_win].signcolumn) assert.equals("0", vim.wo[session.modified_win].foldcolumn) - assert.equals(" ", vim.wo[session.modified_win].statuscolumn) + assert.equals("%l", vim.wo[session.modified_win].statuscolumn) -- Restore changes: modify the file again repo.write_file("test.txt", { "line 1", "line 2 changed again" })