From e9f7321ca09c2a3c27580d8c382fc215a539d0bb Mon Sep 17 00:00:00 2001 From: petrosAth Date: Fri, 3 Jul 2026 00:29:49 +0300 Subject: [PATCH 1/2] fix(diff): wrap highlight-group-name strings for nvim_set_hl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit highlights.current/proposed/inline accepted a highlight-group-name string (e.g. `added = "GitSignsAddLn"`), but apply_highlights and apply_inline_highlights passed it straight into nvim_set_hl, whose third argument must be a table — throwing "Expected Lua table" and aborting the render. Add resolve_hl() to wrap a bare string as { link = str, default = true } before every nvim_set_hl call. --- lua/code-preview/diff.lua | 21 +++++--- tests/plugin/diff_lifecycle_spec.lua | 74 ++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/lua/code-preview/diff.lua b/lua/code-preview/diff.lua index 59aac87..d497f6f 100644 --- a/lua/code-preview/diff.lua +++ b/lua/code-preview/diff.lua @@ -16,14 +16,23 @@ local current_ns = vim.api.nvim_create_namespace("claude_diff_current_hl") local proposed_ns = vim.api.nvim_create_namespace("claude_diff_proposed_hl") local inline_ns = vim.api.nvim_create_namespace("claude_diff_inline_hl") +-- Normalize a highlights config value: a bare highlight-group-name string +-- links to that group; a table spec passes through unchanged. +local function resolve_hl(hl) + if type(hl) == "string" then + return { link = hl, default = true } + end + return hl +end + local function apply_highlights(config) local cur = config.highlights.current local pro = config.highlights.proposed for name, hl in pairs(cur) do - vim.api.nvim_set_hl(current_ns, name, hl) + vim.api.nvim_set_hl(current_ns, name, resolve_hl(hl)) end for name, hl in pairs(pro) do - vim.api.nvim_set_hl(proposed_ns, name, hl) + vim.api.nvim_set_hl(proposed_ns, name, resolve_hl(hl)) end end @@ -203,10 +212,10 @@ end local function apply_inline_highlights(config) local hl = config.highlights.inline or {} - vim.api.nvim_set_hl(0, "ClaudeDiffInlineAdded", hl.added or { bg = "#2e4c2e" }) - vim.api.nvim_set_hl(0, "ClaudeDiffInlineRemoved", hl.removed or { bg = "#4c2e2e" }) - vim.api.nvim_set_hl(0, "ClaudeDiffInlineAddedText", hl.added_text or { bg = "#3a6e3a" }) - vim.api.nvim_set_hl(0, "ClaudeDiffInlineRemovedText", hl.removed_text or { bg = "#6e3a3a" }) + vim.api.nvim_set_hl(0, "ClaudeDiffInlineAdded", resolve_hl(hl.added) or { bg = "#2e4c2e" }) + vim.api.nvim_set_hl(0, "ClaudeDiffInlineRemoved", resolve_hl(hl.removed) or { bg = "#4c2e2e" }) + vim.api.nvim_set_hl(0, "ClaudeDiffInlineAddedText", resolve_hl(hl.added_text) or { bg = "#3a6e3a" }) + vim.api.nvim_set_hl(0, "ClaudeDiffInlineRemovedText", resolve_hl(hl.removed_text) or { bg = "#6e3a3a" }) vim.api.nvim_set_hl(0, "ClaudeDiffInlineAddedSign", { fg = "#73e896", bold = true }) vim.api.nvim_set_hl(0, "ClaudeDiffInlineRemovedSign", { fg = "#f47070", bold = true }) end diff --git a/tests/plugin/diff_lifecycle_spec.lua b/tests/plugin/diff_lifecycle_spec.lua index e1c674f..50cb982 100644 --- a/tests/plugin/diff_lifecycle_spec.lua +++ b/tests/plugin/diff_lifecycle_spec.lua @@ -12,6 +12,80 @@ local function tmp_file(name, content) return path end +-- This describe block must run before any other test in this file (or spec +-- suite) calls show_diff — nvim_set_hl's `default = true` (the fix for +-- string-configured highlights, see apply_highlights/apply_inline_highlights +-- in diff.lua) only takes effect the *first* time a highlight group is +-- defined in the process. Once another test defines ClaudeDiffInlineAdded / +-- DiffAdd via a plain color table, a later default-linked definition is +-- silently ignored, which is why these tests run first in file order. +describe("diff highlights configured as strings", function() + -- Temporarily override highlights config for one test, restoring it afterwards. + local function with_highlights(highlights, fn) + local cfg = require("code-preview").config + local saved = vim.deepcopy(cfg.highlights) + cfg.highlights = vim.tbl_deep_extend("force", vim.deepcopy(saved), highlights) + local ok, err = pcall(fn) + cfg.highlights = saved + if not ok then error(err, 2) end + end + + before_each(function() + changes.clear_all() + diff.close_diff() + vim.api.nvim_set_hl(0, "TestLinkTarget", { fg = "#ff00ff" }) + end) + + it("accepts a highlight-group-name string for highlights.inline.*", function() + local orig = tmp_file("hl_inline_orig.txt", "line one\nline two") + local prop = tmp_file("hl_inline_prop.txt", "line one\nline TWO") + + local ok, err = pcall(function() + with_highlights({ inline = { added = "TestLinkTarget" } }, function() + local diff_cfg = require("code-preview").config.diff + local saved_layout = diff_cfg.layout + diff_cfg.layout = "inline" + diff.show_diff(orig, prop, "hl_inline.txt") + diff_cfg.layout = saved_layout + end) + end) + assert.is_true(ok, tostring(err)) + assert.is_true(diff.is_open()) + + local hl = vim.api.nvim_get_hl(0, { name = "ClaudeDiffInlineAdded" }) + assert.equals("TestLinkTarget", hl.link) + + diff.close_diff() + os.remove(orig) + os.remove(prop) + end) + + it("accepts a highlight-group-name string for highlights.current/proposed", function() + local orig = tmp_file("hl_tab_orig.txt", "line1\nline2") + local prop = tmp_file("hl_tab_prop.txt", "line1\nchanged") + + local ok, err = pcall(function() + with_highlights({ current = { DiffAdd = "TestLinkTarget" } }, function() + local diff_cfg = require("code-preview").config.diff + local saved_layout = diff_cfg.layout + diff_cfg.layout = "tab" + diff.show_diff(orig, prop, "hl_tab.txt") + diff_cfg.layout = saved_layout + end) + end) + assert.is_true(ok, tostring(err)) + assert.is_true(diff.is_open()) + + local current_ns = vim.api.nvim_get_namespaces()["claude_diff_current_hl"] + local hl = vim.api.nvim_get_hl(current_ns, { name = "DiffAdd" }) + assert.equals("TestLinkTarget", hl.link) + + diff.close_diff() + os.remove(orig) + os.remove(prop) + end) +end) + describe("diff lifecycle", function() before_each(function() changes.clear_all() From 3428789bf22138d9950c6fdc7427137918c3e6ad Mon Sep 17 00:00:00 2001 From: petrosAth Date: Fri, 3 Jul 2026 00:58:10 +0300 Subject: [PATCH 2/2] fix(hl): stop nvim_set_hl default flag from making highlight links sticky resolve_hl/define_hl passed `default = true` when a highlights config value was a bare group-name string. nvim_set_hl's default flag is sticky for the life of the process: once a group has any definition, a later default=true call is silently ignored, even to change the link target. Since apply_highlights/apply_inline_highlights run on every show_diff(), re-sourcing config with a changed string-linked highlight never took effect until Neovim was restarted. Also extracts the string-or-table resolution (previously duplicated between diff.lua's resolve_hl and neo_tree.lua's define_hl) into a shared lua/code-preview/hl.lua module. --- lua/code-preview/diff.lua | 10 +---- lua/code-preview/hl.lua | 29 ++++++++++++ lua/code-preview/neo_tree.lua | 8 ++-- tests/plugin/diff_lifecycle_spec.lua | 67 +++++++++++++++++++++++++--- 4 files changed, 94 insertions(+), 20 deletions(-) create mode 100644 lua/code-preview/hl.lua diff --git a/lua/code-preview/diff.lua b/lua/code-preview/diff.lua index d497f6f..f7270c3 100644 --- a/lua/code-preview/diff.lua +++ b/lua/code-preview/diff.lua @@ -1,6 +1,7 @@ local M = {} local log = require("code-preview.log") +local resolve_hl = require("code-preview.hl").resolve -- Active diffs keyed by absolute file path. -- Each entry: { tab, bufs, augroup, inline_win } @@ -16,15 +17,6 @@ local current_ns = vim.api.nvim_create_namespace("claude_diff_current_hl") local proposed_ns = vim.api.nvim_create_namespace("claude_diff_proposed_hl") local inline_ns = vim.api.nvim_create_namespace("claude_diff_inline_hl") --- Normalize a highlights config value: a bare highlight-group-name string --- links to that group; a table spec passes through unchanged. -local function resolve_hl(hl) - if type(hl) == "string" then - return { link = hl, default = true } - end - return hl -end - local function apply_highlights(config) local cur = config.highlights.current local pro = config.highlights.proposed diff --git a/lua/code-preview/hl.lua b/lua/code-preview/hl.lua new file mode 100644 index 0000000..12f35ee --- /dev/null +++ b/lua/code-preview/hl.lua @@ -0,0 +1,29 @@ +-- hl.lua — normalizes a highlights-config value into an nvim_set_hl-compatible +-- table. A bare highlight-group-name string links to that group; a table +-- passes through (optionally merged with an override table). Extracted from +-- near-duplicate resolve_hl (diff.lua) / define_hl (neo_tree.lua) helpers. +-- +-- IMPORTANT: never set `default = true` here. nvim_set_hl's `default` flag is +-- sticky for the life of the process (once a group has ANY definition in a +-- namespace, a later `default = true` call is silently ignored) — that +-- staleness is exactly the bug this module fixes. Do not reintroduce it. +local M = {} + +--- @param hl table|string|nil a highlights-config value +--- @param opts table|nil optional overrides merged on top (force) +--- @return table|nil nvim_set_hl-compatible table, or nil if hl is +--- neither a string nor a table (caller decides +--- the fallback, e.g. `resolve(hl) or {...}`) +function M.resolve(hl, opts) + if type(hl) == "string" then + hl = { link = hl } + elseif type(hl) ~= "table" then + return nil + end + if opts then + return vim.tbl_extend("force", hl, opts) + end + return hl +end + +return M diff --git a/lua/code-preview/neo_tree.lua b/lua/code-preview/neo_tree.lua index 46be464..3180fa1 100644 --- a/lua/code-preview/neo_tree.lua +++ b/lua/code-preview/neo_tree.lua @@ -2,6 +2,7 @@ local M = {} local changes = require("code-preview.changes") local log = require("code-preview.log") +local resolve_hl = require("code-preview.hl").resolve -- Guard: all neo-tree interaction goes through pcall local has_neo_tree = false @@ -16,10 +17,9 @@ local setup_done = false -- Define a highlight group from config (supports string link or table spec) local function define_hl(name, hl_config, opts) - if type(hl_config) == "string" then - vim.api.nvim_set_hl(0, name, vim.tbl_extend("force", { link = hl_config, default = true }, opts or {})) - elseif type(hl_config) == "table" then - vim.api.nvim_set_hl(0, name, vim.tbl_extend("force", hl_config, opts or {})) + local resolved = resolve_hl(hl_config, opts) + if resolved then + vim.api.nvim_set_hl(0, name, resolved) end end diff --git a/tests/plugin/diff_lifecycle_spec.lua b/tests/plugin/diff_lifecycle_spec.lua index 50cb982..aeada5b 100644 --- a/tests/plugin/diff_lifecycle_spec.lua +++ b/tests/plugin/diff_lifecycle_spec.lua @@ -12,13 +12,6 @@ local function tmp_file(name, content) return path end --- This describe block must run before any other test in this file (or spec --- suite) calls show_diff — nvim_set_hl's `default = true` (the fix for --- string-configured highlights, see apply_highlights/apply_inline_highlights --- in diff.lua) only takes effect the *first* time a highlight group is --- defined in the process. Once another test defines ClaudeDiffInlineAdded / --- DiffAdd via a plain color table, a later default-linked definition is --- silently ignored, which is why these tests run first in file order. describe("diff highlights configured as strings", function() -- Temporarily override highlights config for one test, restoring it afterwards. local function with_highlights(highlights, fn) @@ -34,6 +27,7 @@ describe("diff highlights configured as strings", function() changes.clear_all() diff.close_diff() vim.api.nvim_set_hl(0, "TestLinkTarget", { fg = "#ff00ff" }) + vim.api.nvim_set_hl(0, "TestLinkTargetTwo", { fg = "#00ff00" }) end) it("accepts a highlight-group-name string for highlights.inline.*", function() @@ -84,6 +78,65 @@ describe("diff highlights configured as strings", function() os.remove(orig) os.remove(prop) end) + + -- Regression test: a re-applied string-linked highlight must pick up a + -- changed target (e.g. after the user edits their config and re-sources + -- it). Previously nvim_set_hl's `default = true` made the first link + -- sticky for the rest of the process, so the second show_diff below would + -- have kept pointing at TestLinkTarget instead of TestLinkTargetTwo. + it("updates the link when a highlights.inline.* string value changes", function() + local orig = tmp_file("hl_inline_relink_orig.txt", "line one\nline two") + local prop = tmp_file("hl_inline_relink_prop.txt", "line one\nline TWO") + + local function show_with(target) + with_highlights({ inline = { added = target } }, function() + local diff_cfg = require("code-preview").config.diff + local saved_layout = diff_cfg.layout + diff_cfg.layout = "inline" + diff.show_diff(orig, prop, "hl_inline_relink.txt") + diff_cfg.layout = saved_layout + end) + end + + show_with("TestLinkTarget") + assert.equals("TestLinkTarget", vim.api.nvim_get_hl(0, { name = "ClaudeDiffInlineAdded" }).link) + diff.close_diff() + + show_with("TestLinkTargetTwo") + assert.equals("TestLinkTargetTwo", vim.api.nvim_get_hl(0, { name = "ClaudeDiffInlineAdded" }).link) + + diff.close_diff() + os.remove(orig) + os.remove(prop) + end) + + it("updates the link when a highlights.current/proposed string value changes", function() + local orig = tmp_file("hl_tab_relink_orig.txt", "line1\nline2") + local prop = tmp_file("hl_tab_relink_prop.txt", "line1\nchanged") + + local function show_with(target) + with_highlights({ current = { DiffAdd = target } }, function() + local diff_cfg = require("code-preview").config.diff + local saved_layout = diff_cfg.layout + diff_cfg.layout = "tab" + diff.show_diff(orig, prop, "hl_tab_relink.txt") + diff_cfg.layout = saved_layout + end) + end + + local current_ns = vim.api.nvim_get_namespaces()["claude_diff_current_hl"] + + show_with("TestLinkTarget") + assert.equals("TestLinkTarget", vim.api.nvim_get_hl(current_ns, { name = "DiffAdd" }).link) + diff.close_diff() + + show_with("TestLinkTargetTwo") + assert.equals("TestLinkTargetTwo", vim.api.nvim_get_hl(current_ns, { name = "DiffAdd" }).link) + + diff.close_diff() + os.remove(orig) + os.remove(prop) + end) end) describe("diff lifecycle", function()