Feat: Rewrite diff module to allow for external dependencies as jj.nvim's diff tool (#74)

- Refactor diff into modular architecture (native, diffview, codediff)
- Add the possibility for users to define their own diff backend
- Support external diff tools like diffview.nvim codediff.nvim
- Enable visual mode selection to diff two changes in log buffer
- Add summary tooltip integration with terminal
- Update documentation with new diff workflows
This commit is contained in:
Nicolas GB
2026-01-25 09:39:31 -08:00
committed by GitHub
parent 8ed2aa52aa
commit 335663c3ee
14 changed files with 590 additions and 162 deletions
+64
View File
@@ -0,0 +1,64 @@
local utils = require("jj.utils")
---@type jj.diff
local diff = require("jj.diff")
--- Givewn two changes, show their diff using codediff
--- @param left string
--- @param right string
local function diff_two_changes(left, right)
local commit_id_left = utils.get_commit_id(left)
if commit_id_left == nil then
return
end
local commit_id_right = utils.get_commit_id(right)
if commit_id_right == nil then
return
end
vim.cmd(string.format("CodeDiff %s %s", commit_id_right, commit_id_left))
end
-----------------------------------------------------------------------
-- Codediff Backend
-----------------------------------------------------------------------
diff.register_backend("codediff", {
diff_current = function(opts)
if not utils.has_dependency("codediff") then
return
end
-- Extract the commit id from opts.rev
local commit_id = "HEAD~1"
if opts.rev then
local t_commit_id = utils.get_commit_id(opts.rev)
if t_commit_id == nil then
return
end
commit_id = t_commit_id
end
vim.cmd(string.format("CodeDiff file %s", commit_id))
end,
show_revision = function(opts)
if not utils.has_dependency("codediff") then
return
end
-- When comparing a revision we always compare it to it's parent to get the diff
local right = string.format("%s-", opts.rev)
diff_two_changes(opts.rev, right)
end,
diff_revisions = function(opts)
if not utils.has_dependency("codediff") then
return
end
diff_two_changes(opts.left, opts.right)
end,
})
+64
View File
@@ -0,0 +1,64 @@
local utils = require("jj.utils")
---@type jj.diff
local diff = require("jj.diff")
-----------------------------------------------------------------------
-- Diffvew Backend
-----------------------------------------------------------------------
--- Givewn two changes, show their diff using diffview.nvim
--- @param left string
--- @param right string
local function diff_two_changes(left, right)
-- Extract the commit id from opts.rev
local commit_id_left = utils.get_commit_id(left)
if commit_id_left == nil then
return
end
local commit_id_right = utils.get_commit_id(right)
if commit_id_right == nil then
return
end
vim.cmd(string.format("DiffviewOpen %s..%s", commit_id_right, commit_id_left))
end
-- Register the diffview backend
diff.register_backend("diffview", {
diff_current = function(opts)
if not utils.has_dependency("diffview") then
return
end
-- Extract the commit id from opts.rev
local commit_id = "HEAD~1"
if opts.rev then
local t_commit_id = utils.get_commit_id(opts.rev)
if t_commit_id == nil then
return
end
commit_id = t_commit_id
end
vim.cmd(string.format("DiffviewOpen %s -- %%", commit_id))
vim.cmd("DiffviewToggleFiles")
end,
show_revision = function(opts)
if not utils.has_dependency("diffview") then
return
end
-- When comparing a revision we always compare it to it's parent to get the diff
local right = string.format("%s-", opts.rev)
diff_two_changes(opts.rev, right)
end,
diff_revisions = function(opts)
if not utils.has_dependency("diffview") then
return
end
diff_two_changes(opts.left, opts.right)
end,
})
+164
View File
@@ -0,0 +1,164 @@
---@class jj.diff
local M = {}
local utils = require("jj.utils")
---@alias jj.diff.backend "native"|"diffview"|"codediff"|string
---@class jj.diff.current_opts
---@field rev? string -- revision to diff against (default: "@-")
---@field path? string -- path to diff (default: current buffer path)
---@field backend? jj.diff.backend
---@field layout? "vertical"|"horizontal" -- only used by native backend
---@class jj.diff.revision_opts
---@field rev string -- revision to show
---@field path? string -- optional single-file filter
---@field backend? jj.diff.backend
---@field display? "floating"|"tab"|"split" -- hint to backend
---@class jj.diff.revisions_opts
---@field left string -- left/base revision
---@field right string -- right/target revision
---@field path? string -- optional single-file filter
---@field backend? jj.diff.backend
---@field display? "floating"|"tab"|"split"
---@class jj.diff.BackendImpl
---@field diff_current? fun(opts: jj.diff.current_opts)
---@field show_revision? fun(opts: jj.diff.revision_opts)
---@field diff_revisions? fun(opts: jj.diff.revisions_opts)
---@class jj.diff.config
---@field backend? jj.diff.backend
---@field backends? table<string, table>
---@class jj.diff.diff_opts
---@field rev string the revision to diff against
---@type jj.diff.config
M.config = {
backend = "native",
backends = {},
}
---@type table<string, jj.diff.BackendImpl>
local backends = {}
-----------------------------------------------------------------------
-- Backend Registry
-----------------------------------------------------------------------
--- Register or override a backend implementation
---@param name string
---@param impl jj.diff.BackendImpl
function M.register_backend(name, impl)
backends[name] = impl
end
--- Get the configured default backend name
---@return string
local function get_config_backend()
local ok, cfg = pcall(function()
return require("jj").config.diff
end)
if ok and cfg and cfg.backend then
return cfg.backend
end
return M.config.backend or "native"
end
--- Get a backend implementation by name, falling back to native
---@param name? string
---@return jj.diff.BackendImpl
local function get_backend(name)
name = name or get_config_backend()
local impl = backends[name]
if not impl then
utils.notify(
string.format("[Diff] backend '%s' not available, falling back to 'native'", name),
vim.log.levels.WARN
)
impl = backends.native
end
return impl
end
--- Setup the diff module
---@param cfg? jj.diff.config
function M.setup(cfg)
M.config = vim.tbl_deep_extend("force", M.config, cfg or {})
-- Laod default backends
pcall(require, "jj.diff.diffview")
pcall(require, "jj.diff.codediff")
pcall(require, "jj.diff.native")
end
-----------------------------------------------------------------------
-- Unified Public API
-----------------------------------------------------------------------
--- Single dispatcher (canonical entry point)
---@param kind "current"|"revision"|"revisions"
---@param opts table
function M.open(kind, opts)
opts = opts or {}
local backend = get_backend(opts.backend)
if kind == "current" then
if backend.diff_current then
return backend.diff_current(opts)
end
return backends.native.diff_current(opts)
elseif kind == "revision" then
if backend.show_revision then
return backend.show_revision(opts)
end
return backends.native.show_revision(opts)
elseif kind == "revisions" then
if backend.diff_revisions then
return backend.diff_revisions(opts)
end
return backends.native.diff_revisions(opts)
else
utils.notify("[Diff] unknown diff kind: " .. tostring(kind), vim.log.levels.ERROR)
end
end
--- Diff current buffer against a revision
---@param opts? jj.diff.current_opts
function M.diff_current(opts)
return M.open("current", opts or {})
end
--- Show what changed in a single revision
---@param opts jj.diff.revision_opts
function M.show_revision(opts)
return M.open("revision", opts)
end
--- Diff between two revisions
---@param opts jj.diff.revisions_opts
function M.diff_revisions(opts)
return M.open("revisions", opts)
end
---
-----------------------------------------------------------------------
-- BACKWARDS COMPATIBLE API
-----------------------------------------------------------------------
-- Open a vertical diff split for a specific revision of the current file
--- @param opts? jj.diff.diff_opts Any passed arguments
function M.open_vdiff(opts)
M.diff_current(vim.tbl_extend("force", { layout = "vertical" }, { rev = opts and opts.rev }))
end
-- Open a horizontal diff split for a specific revision of the current file
--- @param opts? jj.diff.diff_opts Any passed arguments
function M.open_hdiff(opts)
M.diff_current(vim.tbl_extend("force", { layout = "horizontal" }, { rev = opts and opts.rev }))
end
---@return jj.diff
return M
+127
View File
@@ -0,0 +1,127 @@
local utils = require("jj.utils")
local buffer = require("jj.core.buffer")
local diff = require("jj.diff")
--- Get the content of a file at a specific revision
--- @param rev string The revision
--- @param path string The file path
--- @return table lines The file content
local function get_file_content(rev, path)
local cmd = string.format("jj file show -r %s %s", vim.fn.shellescape(rev), vim.fn.shellescape(path))
local content = vim.fn.system(cmd)
local success = vim.v.shell_error == 0
if success then
return vim.split(content, "\n", { trimempty = true })
else
return {}
end
end
--- Open a read-only buffer for a specific revision of a file
--- @param rev string The revision
--- @param path string The file path
local function open_revision(rev, path)
local lines = get_file_content(rev, path)
local buf = vim.api.nvim_create_buf(false, true)
local buf_name = string.format("jj://%s/%s", rev, path)
vim.api.nvim_buf_set_name(buf, buf_name)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
local ft = vim.filetype.match({ filename = path })
if ft then
vim.bo[buf].filetype = ft
end
vim.bo[buf].buftype = "nofile"
vim.bo[buf].bufhidden = "wipe"
vim.bo[buf].readonly = true
vim.bo[buf].swapfile = false
vim.bo[buf].modifiable = true
vim.api.nvim_win_set_buf(0, buf)
end
-----------------------------------------------------------------------
-- Native Backend
-----------------------------------------------------------------------
diff.register_backend("native", {
--- Opens a side-by-side diff of the current buffer against a revision.
--- Creates a split with the revision content on the left and the current buffer on the right.
--- Closing either side will clean up both and restore the original cursor position.
diff_current = function(opts)
if not utils.ensure_jj() then
return
end
-- Save current state to restore after diff is closed
local prev_buf = vim.api.nvim_get_current_buf()
local prev_cur_pos = buffer.get_cursor(prev_buf) or { 1, 0 }
local rev = opts.rev or "@-"
local path = opts.path or vim.api.nvim_buf_get_name(0)
local layout = opts.layout or "vertical"
local split_fun = layout == "horizontal" and vim.cmd.split or vim.cmd.vsplit
local orig_win = vim.api.nvim_get_current_win()
-- Use better diff algorithm for code moves and indentation
local saved_diffopt = vim.o.diffopt
vim.opt.diffopt:append("algorithm:patience,indent-heuristic")
-- Set up diff: current buffer on right, revision on left
vim.cmd.diffthis()
split_fun({ mods = { split = "aboveleft" } })
open_revision(rev, path)
vim.cmd.diffthis()
local rev_buf = vim.api.nvim_get_current_buf()
local augroup = vim.api.nvim_create_augroup("JJDiffCleanup" .. rev_buf, { clear = true })
-- Cleanup closes both sides, exits diff mode, and restores cursor.
local function cleanup()
vim.api.nvim_del_augroup_by_id(augroup)
vim.schedule(function()
if vim.api.nvim_buf_is_valid(rev_buf) then
vim.api.nvim_buf_delete(rev_buf, { force = true })
end
if vim.api.nvim_win_is_valid(orig_win) then
vim.api.nvim_set_current_win(orig_win)
vim.cmd.diffoff()
end
buffer.set_cursor(prev_buf, prev_cur_pos)
vim.o.diffopt = saved_diffopt
end)
end
-- Trigger cleanup when either the revision buffer or original window is closed
vim.api.nvim_create_autocmd({ "BufWipeout", "BufHidden" }, {
group = augroup,
buffer = rev_buf,
once = true,
callback = cleanup,
})
vim.api.nvim_create_autocmd("WinClosed", {
group = augroup,
pattern = tostring(orig_win),
once = true,
callback = cleanup,
})
end,
show_revision = function(opts)
local terminal = require("jj.ui.terminal")
local cmd = string.format("jj show -r %s --quiet --no-pager", opts.rev)
terminal.run_floating(cmd, require("jj.cmd").floating_keymaps())
end,
diff_revisions = function(opts)
local terminal = require("jj.ui.terminal")
local cmd = string.format("jj diff -f %s -t %s --quiet --no-pager", opts.left, opts.right)
terminal.run_floating(cmd, require("jj.cmd").floating_keymaps())
end,
})