-
Notifications
You must be signed in to change notification settings - Fork 49
Open
Description
The request is maybe the plugin allows users to configure their own style of inline-diff, or the plugin adds different styles for inline-diff so that the users can choose from. Personally, I like overleaf-diff's style as in the picture.
The removed words are virtual with a strike-through. The added words are also highlighted.
The configure in the picture is my own small "plugin", which I modified from inlinediff.nvim to get virtual inline + strike-through
local M = {}
local api = vim.api
M.ns = api.nvim_create_namespace("overleafdiff")
M.enabled = false
M.last_diff_output = nil -- Cache to prevent unnecessary redraws
M.last_diff_buf = nil -- Buffer id that `last_diff_output` corresponds to
M.default_config = {
debounce_time = 200,
colors = {
OverleafDiffAddContext = "GitSignsAddPreview",
OverleafDiffAddChange = "GitSignsChangeInLine",
OverleafDiffDeleteContext = "GitSignsDeletePreview",
OverleafDiffDeleteChange = { strikethrough = true, fg = "#E24B4A", bg = "#FCEBEB" },
},
ignored_buftype = { "terminal", "nofile" },
ignored_filetype = { "TelescopePrompt", "NvimTree", "dap-repl", "neo-tree" },
}
M.config = vim.deepcopy(M.default_config)
-- Constants for extmark priorities and rendering
local PRIORITY_CONTEXT = 100
local PRIORITY_CHANGE = 120
local MIN_PADDING_LENGTH = 40
local MAX_PADDING_LENGTH = 300
local DIFF_PREFIX = { UNCHANGED = " ", DELETED = "-", ADDED = "+" }
M.setup_highlights = function()
local c = M.config.colors
for name, link_or_color in pairs(c) do
if type(link_or_color) == "string" and not link_or_color:match("^#") then
api.nvim_set_hl(0, name, { link = link_or_color, default = false })
elseif type(link_or_color) == "table" then
api.nvim_set_hl(0, name, vim.tbl_extend("force", link_or_color, { default = false }))
else
api.nvim_set_hl(0, name, { bg = link_or_color, default = false })
end
end
end
M.setup_autocmds_colorscheme = function()
local colorscheme_group = api.nvim_create_augroup("OverleafDiffColorscheme", { clear = true })
api.nvim_create_autocmd("ColorScheme", {
group = colorscheme_group,
callback = function()
vim.schedule(M.setup_highlights)
end,
desc = "Refresh inline-diff highlights on colorscheme change",
})
end
local function build_char_array(s)
local chars = {}
local n = vim.str_utfindex(s, "utf-8") or 0
for i = 0, n - 1 do
table.insert(chars, vim.fn.strcharpart(s, i, 1))
end
return chars
end
local function byte_map_for(s)
local map = {}
local n = vim.str_utfindex(s, "utf-8") or 0
for i = 0, n - 1 do
local start = vim.str_byteindex(s, "utf-8", i, false)
local finish = vim.str_byteindex(s, "utf-8", i + 1, false)
table.insert(map, { byte = start, char_len = finish - start })
end
return map
end
local function is_in_list(value, list)
for _, v in ipairs(list or {}) do
if value == v then
return false
end
end
return true
end
local function is_buffer_valid(bufnr)
if not api.nvim_buf_is_valid(bufnr) or not api.nvim_buf_is_loaded(bufnr) then
return false
end
local ok, buftype = pcall(api.nvim_get_option_value, "buftype", { buf = bufnr })
if ok and buftype ~= "" and not is_in_list(buftype, M.config.ignored_buftype) then
return false
end
local ok2, ft = pcall(api.nvim_get_option_value, "filetype", { buf = bufnr })
if ok2 and ft ~= "" and not is_in_list(ft, M.config.ignored_filetype) then
return false
end
local ok3, listed = pcall(api.nvim_get_option_value, "buflisted", { buf = bufnr })
if not ok3 or not listed then
return false
end
if api.nvim_buf_get_name(bufnr) == "" then
local ok4, mod = pcall(api.nvim_get_option_value, "modifiable", { buf = bufnr })
return ok4 and mod
end
return true
end
local function compute_unified_diff(old_content, new_content)
local diff_out = vim.diff(old_content, new_content, {
algorithm = "minimal",
result_type = "unified",
ctxlen = 3,
interhunkctxlen = 4,
})
return (diff_out and diff_out ~= "") and diff_out or nil
end
local function run_git_diff(bufnr, cb)
local path = api.nvim_buf_get_name(bufnr)
if path == "" then
return
end
local fullpath = vim.fn.fnamemodify(path, ":p")
local buf_content = table.concat(api.nvim_buf_get_lines(bufnr, 0, -1, false), "\n") .. "\n"
vim.system(
{ "git", "rev-parse", "--show-toplevel" },
{ cwd = vim.fn.fnamemodify(fullpath, ":h"), text = true },
function(root_obj)
local index_content = ""
if not root_obj or root_obj.code ~= 0 then
return cb(compute_unified_diff(index_content, buf_content))
end
-- Compute repo-relative path
local repo_root = root_obj.stdout:gsub("\n$", "")
local real_fullpath = vim.loop.fs_realpath(fullpath) or fullpath
local real_repo_root = vim.loop.fs_realpath(repo_root) or repo_root
local relpath = real_fullpath:sub(1, #real_repo_root) == real_repo_root
and real_fullpath:sub(#real_repo_root + 1):gsub("^/", "")
or vim.fn.fnamemodify(fullpath, ":t")
vim.system({ "git", "show", ":./" .. (relpath or "") }, { cwd = repo_root, text = true }, function(obj)
if obj and obj.code == 0 and obj.stdout then
index_content = obj.stdout
end
cb(compute_unified_diff(index_content, buf_content))
end)
end
)
end
local function parse_hunks(output)
local hunks = {}
local lines = vim.split(output, "\n", { trimempty = true })
local i = 1
while i <= #lines do
local line = lines[i]
if line:match("^@@") then
local ns, nc = line:match("@@ .* %+(%d+),?(%d*) @@")
if ns then
local hunk = { new_start = tonumber(ns), lines = {} }
i = i + 1
while i <= #lines do
local l = lines[i]
if l:match("^diff") or l:match("^index") or l:match("^@@") then
i = i - 1
break
end
local prefix = l:sub(1, 1)
if prefix == " " or prefix == "+" or prefix == "-" then
table.insert(hunk.lines, l)
end
i = i + 1
end
table.insert(hunks, hunk)
else
i = i + 1
end
else
i = i + 1
end
end
return hunks
end
local function compute_char_diff(old_chars, new_chars)
if #old_chars == 0 or #new_chars == 0 then
return nil
end
return vim.diff(table.concat(old_chars, "\n"), table.concat(new_chars, "\n"), {
algorithm = "minimal",
result_type = "indices",
ctxlen = 0,
interhunkctxlen = 4,
indent_heuristic = false,
linematch = 0,
})
end
local function apply_char_highlights(bufnr, _virts_old, old_lines, new_lines, buf_line_idx, buf_lines_new)
for i = 1, math.min(#old_lines, #new_lines) do
local s_old = old_lines[i]
local s_new = buf_lines_new[i] or new_lines[i]
local c_old = build_char_array(s_old)
local c_new = build_char_array(s_new)
local diffs = compute_char_diff(c_old, c_new)
if not diffs then
goto continue
end
local new_map = byte_map_for(s_new)
local base_line = buf_line_idx + i - 1
for _, d in ipairs(diffs) do
local old_start, old_count = d[1], d[2]
local new_start, new_count = d[3], d[4]
if old_count > 0 then
local del_chars = {}
for k = old_start, old_start + old_count - 1 do
table.insert(del_chars, c_old[k])
end
local anchor_col
local del_text
if new_count > 0 then
-- replacement: anchor at new_start directly
anchor_col = new_map[new_start] and new_map[new_start].byte or #s_new
del_text = table.concat(del_chars)
elseif new_start < 1 then
-- deletion at very start of line (new_start=0): anchor at col 0
anchor_col = 0
del_text = table.concat(del_chars)
else
-- pure deletion mid/end of line: anchor AFTER char at new_start
local info = new_map[new_start]
anchor_col = info and (info.byte + info.char_len) or #s_new
del_text = table.concat(del_chars)
end
api.nvim_buf_set_extmark(bufnr, M.ns, base_line, anchor_col, {
virt_text = { { del_text, "OverleafDiffDeleteChange" } },
virt_text_pos = "inline",
priority = PRIORITY_CHANGE,
})
end
if new_count > 0 then
for k = new_start, new_start + new_count - 1 do
local info = new_map[k]
if info then
api.nvim_buf_set_extmark(bufnr, M.ns, base_line, info.byte, {
end_col = info.byte + info.char_len,
hl_group = "OverleafDiffAddChange",
priority = PRIORITY_CHANGE,
})
end
end
end
end
::continue::
end
end
local function get_padding_text()
local win_width = vim.api.nvim_win_get_width(0)
local padding_length = math.min(math.max(win_width, MIN_PADDING_LENGTH), MAX_PADDING_LENGTH)
return string.rep(" ", padding_length)
end
local function flush_diff_group(bufnr, idx, old, new, _padding)
if #old == 0 and #new == 0 then
return idx
end
-- only highlight purely added lines (no corresponding deleted content)
if #old == 0 and #new > 0 then
for j = 0, #new - 1 do
api.nvim_buf_set_extmark(bufnr, M.ns, idx + j, 0, {
end_line = idx + j + 1,
hl_group = "OverleafDiffAddContext",
hl_eol = true,
priority = PRIORITY_CONTEXT,
})
end
end
local buf_lines = #new > 0 and api.nvim_buf_get_lines(bufnr, idx, idx + #new, false) or {}
apply_char_highlights(bufnr, {}, old, new, idx, buf_lines)
-- purely deleted lines (no corresponding new line)
if #old > #new then
local win_width = vim.api.nvim_win_get_width(0)
local numberwidth = vim.wo.number and (vim.wo.numberwidth or 4) or 0
local signcolumn_width = vim.wo.signcolumn == "yes" and 2 or 0
local foldcolumn_width = tonumber(vim.wo.foldcolumn) or 0
local max_width = math.max(40, win_width - numberwidth - signcolumn_width - foldcolumn_width - 2)
local surplus = {}
for k = #new + 1, #old do
local text = old[k]
local chars = build_char_array(text)
if #chars <= max_width then
table.insert(surplus, { { text, "OverleafDiffDeleteChange" } })
else
local pos = 1
while pos <= #chars do
local chunk_end = math.min(pos + max_width - 1, #chars)
local chunk = {}
for c = pos, chunk_end do
table.insert(chunk, chars[c])
end
table.insert(surplus, { { table.concat(chunk), "OverleafDiffDeleteChange" } })
pos = chunk_end + 1
end
end
end
if #surplus > 0 then
local anchor = math.max(idx + #new - 1, idx)
api.nvim_buf_set_extmark(bufnr, M.ns, anchor, 0, {
virt_lines = surplus,
virt_lines_above = true,
})
end
end
return idx + #new
end
local function render_hunk(bufnr, hunk)
local idx, old, new = hunk.new_start - 1, {}, {}
local padding = get_padding_text()
for _, l in ipairs(hunk.lines) do
local prefix, content = l:sub(1, 1), l:sub(2)
if prefix == DIFF_PREFIX.UNCHANGED then
idx = flush_diff_group(bufnr, idx, old, new, padding) + 1
old, new = {}, {}
elseif prefix == DIFF_PREFIX.DELETED then
table.insert(old, content)
elseif prefix == DIFF_PREFIX.ADDED then
table.insert(new, content)
end
end
flush_diff_group(bufnr, idx, old, new, padding)
end
M.refresh = function(bufnr)
bufnr = bufnr or api.nvim_get_current_buf()
if not M.enabled or not is_buffer_valid(bufnr) then
return
end
run_git_diff(
bufnr,
vim.schedule_wrap(function(output)
if M.last_diff_buf == bufnr and output == M.last_diff_output then
return
end
M.last_diff_output, M.last_diff_buf = output, bufnr
api.nvim_buf_clear_namespace(bufnr, M.ns, 0, -1)
if output then
for _, h in ipairs(parse_hunks(output)) do
render_hunk(bufnr, h)
end
end
end)
)
end
local debounce_timer, augroup = nil, nil
local function clear_cache(bufnr)
if not bufnr or M.last_diff_buf == bufnr then
M.last_diff_buf, M.last_diff_output = nil, nil
end
end
local function stop_timer()
if debounce_timer then
pcall(function()
debounce_timer:stop()
if not debounce_timer:is_closing() then
debounce_timer:close()
end
end)
debounce_timer = nil
end
end
local function start_timer(ms, cb)
stop_timer()
if not ms or ms == 0 then
return
end
debounce_timer = vim.uv.new_timer()
if not debounce_timer then
return
end
debounce_timer:start(
ms,
0,
vim.schedule_wrap(function()
stop_timer()
if cb then
cb()
end
end)
)
end
local function setup_autocmds()
if augroup then
api.nvim_del_augroup_by_id(augroup)
end
augroup = api.nvim_create_augroup("OverleafDiffAuto", { clear = true })
api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "TextChangedP" }, {
group = augroup,
callback = function()
local bufnr = api.nvim_get_current_buf()
if not is_buffer_valid(bufnr) then
return
end
if not M.enabled or (M.config.debounce_time or 0) == 0 then
M.refresh(bufnr)
else
start_timer(M.config.debounce_time, function()
if M.enabled then
M.refresh(bufnr)
end
end)
end
end,
})
api.nvim_create_autocmd({ "BufUnload", "BufDelete" }, {
group = augroup,
callback = function(ctx)
clear_cache(ctx.buf)
end,
})
end
function M.toggle()
M.enabled = not M.enabled
clear_cache()
if M.enabled then
if vim.fn.hlexists("OverleafDiffAddContext") == 0 then
M.setup_highlights()
end
setup_autocmds()
M.refresh()
else
if augroup then
pcall(api.nvim_del_augroup_by_id, augroup)
augroup = nil
end
stop_timer()
for _, b in ipairs(api.nvim_list_bufs()) do
if api.nvim_buf_is_loaded(b) then
pcall(api.nvim_buf_clear_namespace, b, M.ns, 0, -1)
end
end
end
end
M.setup = function(opts)
M.config = vim.tbl_deep_extend("force", M.default_config, opts or {})
M.setup_highlights()
if not api.nvim_get_commands({})["OverleafDiff"] then
pcall(api.nvim_create_user_command, "OverleafDiff", function(cmd)
local arg = (cmd.args or ""):match("^%s*(%S*)") or ""
if arg == "" or arg == "toggle" then
M.toggle()
elseif arg == "refresh" then
M.refresh()
else
print('OverleafDiff: unknown arg "' .. arg .. '". Use "toggle" or "refresh"')
end
end, {
nargs = "?",
complete = function(lead)
local res = {}
for _, v in ipairs({ "toggle", "refresh" }) do
if v:sub(1, #lead) == lead then
table.insert(res, v)
end
end
return res
end,
desc = "OverleafDiff commands: toggle or refresh",
})
end
end
return MThe core of this includes these two functions:
- apply_char_highlights:
- Uses virt_text_pos = "inline" (Neovim 0.10+) to inject deleted chars in-flow rather than as virt_lines
- Drives anchoring directly from vim.diff indices (d[1]..d[4]) to avoid byte-position drift across multiple hunks
- flush_diff_group: controls what gets virt_lines
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels