diff --git a/lua/code-preview/diff.lua b/lua/code-preview/diff.lua index 59aac87..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 } @@ -20,10 +21,10 @@ 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 +204,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/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 e1c674f..aeada5b 100644 --- a/tests/plugin/diff_lifecycle_spec.lua +++ b/tests/plugin/diff_lifecycle_spec.lua @@ -12,6 +12,133 @@ local function tmp_file(name, content) return path end +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" }) + vim.api.nvim_set_hl(0, "TestLinkTargetTwo", { fg = "#00ff00" }) + 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) + + -- 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() before_each(function() changes.clear_all()