From f5f7731cfee6b687eaa0e3cda2e729cc4702ca0a Mon Sep 17 00:00:00 2001 From: Fredrik Averpil Date: Wed, 25 Mar 2026 12:03:02 +0100 Subject: [PATCH] feat(api): add public inline diff rendering API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose codediff's inline diff rendering as a reusable Lua API for both standalone use and plugin consumers (e.g. gitsigns.nvim). Low-level API (re-exports, no state): - codediff.diff() — pure diff computation - codediff.render_inline_diff() — render on any buffer - codediff.clear_inline_diff() — clear decorations High-level API (standalone with git integration): - codediff.render_inline() — toggle inline diff per-buffer or globally - codediff.change_base() — change base revision for comparison --- lua/codediff/core/git.lua | 16 +++ lua/codediff/init.lua | 45 ++++++ lua/codediff/ui/inline_render.lua | 227 ++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 lua/codediff/ui/inline_render.lua diff --git a/lua/codediff/core/git.lua b/lua/codediff/core/git.lua index d260c574..a0465e72 100644 --- a/lua/codediff/core/git.lua +++ b/lua/codediff/core/git.lua @@ -302,6 +302,22 @@ function M.get_file_content(revision, git_root, rel_path, callback) end) end +-- Get file content for a buffer's file at a given revision (async convenience) +-- Chains get_git_root -> get_relative_path -> get_file_content +-- callback: function(err, lines, git_root, rel_path) +function M.get_buf_file_content(file_path, revision, callback) + M.get_git_root(file_path, function(err_root, git_root) + if err_root then + callback(err_root, nil) + return + end + local rel_path = M.get_relative_path(file_path, git_root) + M.get_file_content(revision, git_root, rel_path, function(err_content, lines) + callback(err_content, lines, git_root, rel_path) + end) + end) +end + -- Check if a git status code indicates a merge conflict -- Git uses these status codes for conflicts: -- U = unmerged (both modified, added by us/them, deleted by us/them) diff --git a/lua/codediff/init.lua b/lua/codediff/init.lua index 431a4850..590d3bfb 100644 --- a/lua/codediff/init.lua +++ b/lua/codediff/init.lua @@ -40,4 +40,49 @@ function M.prev_file() return navigation.prev_file() end +-- ============================================================================ +-- Inline diff public API +-- ============================================================================ + +-- Compute diff between two sets of lines (pure, no side effects) +-- @param original_lines string[]: reference content +-- @param modified_lines string[]: current content +-- @param opts? table: { ignore_trim_whitespace?: bool, max_computation_time_ms?: int, compute_moves?: bool, extend_to_subwords?: bool } +-- @return table: { changes: table[], moves: table[], hit_timeout: boolean } +function M.diff(original_lines, modified_lines, opts) + return require("codediff.core.diff").compute_diff(original_lines, modified_lines, opts) +end + +-- Render inline diff on a buffer (deleted lines as virtual lines, added lines highlighted) +-- @param bufnr number: buffer to render on (should contain the modified content) +-- @param diff_result table: result from codediff.diff() +-- @param original_lines string[]: reference content (used for virtual line text) +-- @param modified_lines string[]: current buffer content +-- @param opts? table: { filetype?: string } +function M.render_inline_diff(bufnr, diff_result, original_lines, modified_lines, opts) + return require("codediff.ui.inline").render_inline_diff(bufnr, diff_result, original_lines, modified_lines, opts) +end + +-- Clear inline diff decorations from a buffer +-- @param bufnr number: buffer to clear +function M.clear_inline_diff(bufnr) + return require("codediff.ui.inline").clear(bufnr) +end + +-- Toggle inline diff rendering (standalone mode with git integration) +-- @param show nil|bool: nil = toggle, true = show, false = hide +-- @param global bool|nil: if true, applies to all git-tracked buffers +function M.render_inline(show, global) + local inline_render = require("codediff.ui.inline_render") + inline_render.toggle(show, global) +end + +-- Change the base revision for diff comparison +-- @param base string|nil: revision (e.g. "HEAD", "HEAD~1", "main"), nil = reset to index +-- @param global bool|nil: if true, applies to all buffers +function M.change_base(base, global) + local inline_render = require("codediff.ui.inline_render") + inline_render.change_base(base, global) +end + return M diff --git a/lua/codediff/ui/inline_render.lua b/lua/codediff/ui/inline_render.lua new file mode 100644 index 00000000..35cb1732 --- /dev/null +++ b/lua/codediff/ui/inline_render.lua @@ -0,0 +1,227 @@ +-- Inline diff rendering orchestrator +-- Manages per-buffer state, async git pipeline, and global mode autocmds. +-- Completely independent from :CodeDiff sessions (uses separate state). +local M = {} + +local DEFAULT_BASE = ":0" -- git index, matches `git diff` default + +-- Per-buffer state: keyed by bufnr +local inline_state = {} + +-- Global mode state +local global_state = { + enabled = false, + base = nil, -- nil = use DEFAULT_BASE + augroup = nil, +} + +-- Guard for lazy highlight initialization +local highlights_initialized = false + +local function ensure_highlights() + if not highlights_initialized then + local highlights = require("codediff.ui.highlights") + highlights.setup() + highlights_initialized = true + end +end + +-- Check if a buffer is part of an active :CodeDiff session. +-- The codediff-inline namespace is shared, so rendering on a buffer +-- that already has a :CodeDiff inline view would cause conflicts. +local function is_in_codediff_session(bufnr) + local ok, lifecycle = pcall(require, "codediff.ui.lifecycle") + if not ok then + return false + end + -- find_tabpage_by_buffer returns tabpage if buffer is in an active session + return lifecycle.find_tabpage_by_buffer(bufnr) ~= nil +end + +-- Internal: fetch git content, compute diff, and render +function M.render_buf(bufnr, base) + base = base or global_state.base or DEFAULT_BASE + + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local file_path = vim.api.nvim_buf_get_name(bufnr) + if file_path == "" then + return + end + + if is_in_codediff_session(bufnr) then + return + end + + local git = require("codediff.core.git") + git.get_buf_file_content(file_path, base, function(err, original_lines) + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + if err then + -- Non-git buffer or git error: silent no-op + if err:match("Not in a git repository") then + return + end + -- File not in base revision (new file): treat as all-added + if err:match("not found in revision") then + original_lines = {} + else + vim.notify("[codediff] " .. err, vim.log.levels.WARN) + return + end + end + + ensure_highlights() + + local modified_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local config = require("codediff.config") + local diff_result = require("codediff.core.diff").compute_diff(original_lines, modified_lines, { + max_computation_time_ms = config.options.diff.max_computation_time_ms, + ignore_trim_whitespace = config.options.diff.ignore_trim_whitespace, + }) + + require("codediff.ui.inline").render_inline_diff(bufnr, diff_result, original_lines, modified_lines) + + inline_state[bufnr] = { active = true, base = base } + end) + end) +end + +-- Clear inline diff decorations and remove state for a buffer +function M.clear_buf(bufnr) + local inline = require("codediff.ui.inline") + inline.clear(bufnr) + inline_state[bufnr] = nil +end + +-- Toggle inline diff rendering +-- @param show boolean|nil: true = show, false = hide, nil = toggle +-- @param global boolean|nil: if true, applies to all git-tracked buffers +function M.toggle(show, global) + if global then + -- Resolve toggle + if show == nil then + show = not global_state.enabled + end + if show then + M.enable_global() + else + M.disable_global() + end + else + local bufnr = vim.api.nvim_get_current_buf() + -- Resolve toggle + if show == nil then + show = not (inline_state[bufnr] and inline_state[bufnr].active) + end + if show then + M.render_buf(bufnr) + else + M.clear_buf(bufnr) + end + end +end + +-- Change base revision for inline diff +-- @param base string|nil: revision (e.g. "HEAD", "HEAD~1", "main"), nil = reset to index +-- @param global boolean|nil: if true, applies to all buffers +function M.change_base(base, global) + base = base or DEFAULT_BASE + + if global then + global_state.base = base + for buf, state in pairs(inline_state) do + if state.active and vim.api.nvim_buf_is_valid(buf) then + M.render_buf(buf, base) + end + end + else + local bufnr = vim.api.nvim_get_current_buf() + if inline_state[bufnr] and inline_state[bufnr].active then + M.render_buf(bufnr, base) + end + end +end + +-- Enable global mode: render inline diffs on all file buffers, auto-render on BufEnter +function M.enable_global() + if global_state.enabled then + return + end + global_state.enabled = true + + local augroup = vim.api.nvim_create_augroup("codediff_inline_global", { clear = true }) + global_state.augroup = augroup + + -- Render current buffer immediately + local bufnr = vim.api.nvim_get_current_buf() + if vim.bo[bufnr].buftype == "" then + M.render_buf(bufnr) + end + + vim.api.nvim_create_autocmd("BufEnter", { + group = augroup, + callback = function(args) + if vim.bo[args.buf].buftype == "" and not (inline_state[args.buf] and inline_state[args.buf].active) then + M.render_buf(args.buf) + end + end, + }) + + vim.api.nvim_create_autocmd("BufWritePost", { + group = augroup, + callback = function(args) + if inline_state[args.buf] and inline_state[args.buf].active then + M.render_buf(args.buf) + end + end, + }) + + vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { + group = augroup, + callback = function(args) + inline_state[args.buf] = nil + end, + }) + + vim.api.nvim_create_autocmd("ColorScheme", { + group = augroup, + callback = function() + highlights_initialized = false + for buf, state in pairs(inline_state) do + if state.active and vim.api.nvim_buf_is_valid(buf) then + M.render_buf(buf) + end + end + end, + }) +end + +-- Disable global mode: clear all inline diffs and remove autocmds +function M.disable_global() + if not global_state.enabled then + return + end + + local inline = require("codediff.ui.inline") + for buf, _ in pairs(inline_state) do + if vim.api.nvim_buf_is_valid(buf) then + inline.clear(buf) + end + end + inline_state = {} + + if global_state.augroup then + vim.api.nvim_del_augroup_by_id(global_state.augroup) + global_state.augroup = nil + end + + global_state.enabled = false +end + +return M