diff --git a/README.md b/README.md index 50e7cd3..56d3cd9 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - [Enhanced Integrations](#enhanced-integrations) - [View change summary from the log buffer](#view-change-summary-from-the-log-buffer) - [Diff any change](#diff-any-change) + - [Diff revision history](#diff-revision-history) - [Describe a change](#describe-a-change) - [Edit changes](#edit-changes) - [Create new changes from the log buffer](#create-new-changes-from-the-log-buffer) @@ -73,6 +74,7 @@ - `browse` - Open the current file on your remote at the current line (or selected range) - `annotate` / `annotate_line` - View file blame and line history with change ID, author, and timestamp - `commit` - Describe the current change and create a new one after + - `diff_history` - Open a history-aware diff between two revisions when supported by your diff backend - Diff commands - `:Jdiff [revision]` - Vertical split diff against a jj revision - `:Jhdiff [revision]` - Horizontal split diff @@ -101,13 +103,25 @@ From the summary view, you can: ### Diff any change -You can diff any change in your log history by pressing `` on its line or on a summary file change. You can also visually select multiple changes to diff between the first and last selected. +You can diff any change in your log history by pressing `` on its line or on a summary file change. This opens the configured diff backend for the revision under the cursor. > [!NOTE] > Integrates with your preferred diff plugin or uses your native jj diff config. See [Diff Module](#diff-module). ![Diff-from-log](https://github.com/NicolasGB/jj.nvim/raw/main/assets/diff-log.gif) +### Diff revision history + +You can now open a history-aware diff between two revisions: + +- In the log buffer, visually select multiple revisions and press `` +- `jj.nvim` uses the first and last selected revisions as the range boundaries +- `diffview` opens `DiffviewFileHistory` for that range +- `codediff` opens `CodeDiff history` for that range +- The `native` backend currently shows a warning because history mode is not supported there yet + +This is useful when you want to inspect the evolution of a stack or compare a revision range with commit history preserved instead of showing a single plain diff. + ### Describe a change You can describe any change directly from the log buffer: @@ -339,6 +353,8 @@ The plugin provides a `:J` command that accepts jj subcommands: :Jbrowse " Open current file on remote at cursor line :Jbrowse main " Open current file on remote at the given revset :J split " Split a change interactively +:J diff_history " Prompt for a `left..right` range and open a history-aware diff +:J diff_history main..@ " Open a history-aware diff between main and the working copy :J bookmark create/move/delete :J tag set " Set a tag (prompts for revision and tag name) :J tag set abc123 " Set a tag on a specific revision @@ -475,6 +491,7 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing }, quick_squash = "", -- Quick squash revision under cursor into its parent (ignore immutability) split = "", -- Split the revision under cursor + history = "", -- Show a history-aware diff for the selected revision range tag_create = "", -- Create a tag on the revision under cursor summary = "", -- Show summary tooltip for revision under cursor summary_tooltip = { @@ -718,6 +735,11 @@ diff.show_revision({ rev = "abc123" }) -- Diff between two revisions diff.diff_revisions({ left = "main", right = "@" }) +-- Open a history-aware diff between two revisions +-- Supported by the `diffview` and `codediff` backends +-- The `native` backend currently warns instead +diff.diff_history_revisions({ left = "main", right = "@" }) + -- Convenience functions (LEGACY FUNCTIONS) diff.open_vdiff() -- Vertical split diff against parent diff.open_vdiff({ rev = "main" }) -- Vertical split against specific revision @@ -730,6 +752,7 @@ diff.open_hdiff({ rev = "@-2" }) -- Horizontal split against @-2 The diff module integrates seamlessly with the log buffer: - `` - Show diff for the revision under cursor in a floating window +- `` - In visual mode, open a history-aware diff for the first and last selected revisions These actions use the configured diff backend, allowing you to leverage your preferred diff viewer directly from the log. @@ -762,6 +785,12 @@ diff.register_backend("my-backend", { -- opts.path: optional file filter -- opts.display: "floating", "tab", or "split" end, + + -- Open a history-aware diff between two revisions + diff_history_revisions = function(opts) + -- opts.left: left/base revision + -- opts.right: right/target revision + end, }) ``` @@ -781,7 +810,7 @@ Or use it per-call: diff.diff_current({ backend = "my-backend", rev = "main" }) ``` -All three functions are optional—missing ones fall back to the `native` implementation. +All four backend functions are optional—missing ones fall back to the `native` implementation. ### Annotations diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index 59f448c..648d8cd 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -55,6 +55,7 @@ local split_module = require("jj.cmd.split") --- @field summary? string|string[] --- @field summary_tooltip? jj.cmd.summary_tooltip.keymaps --- @field tag_set? string|string[] +--- @field history? string|string[] --- @class jj.cmd.rebase.keymaps --- @field onto? string|string[] @@ -130,6 +131,10 @@ local split_module = require("jj.cmd.split") --- @class jj.cmd.fetch_pr_opts --- @field limit? number Limit the number of PRs to select from +--- @class jj.cmd.diff_history_opts +--- @field left? string The left revision for the range +--- @field right? string The right revision for the range + --- @type jj.cmd.opts M.config = { describe = { @@ -193,6 +198,7 @@ M.config = { }, split = "", tag_set = "", + history = "", }, status = { open_file = "", @@ -421,6 +427,34 @@ function M.diff(opts) end end +-- Diff history between two revisions +--- @param opts? jj.cmd.diff_history_opts +function M.diff_history(opts) + if not utils.ensure_jj() then + return + end + + local diff_module = require("jj.diff") + + if opts then + return diff_module.diff_history_revisions({ left = opts.left, right = opts.right }) + end + + -- Otherwise prompt the user for input + vim.ui.input({ prompt = "Range to diff with history" }, function(choice) + if choice then + local range = parser.parse_diff_range(choice) + if range then + diff_module.diff_history_revisions({ left = range.left, right = range.right }) + else + utils.notify("Invalid range format. Use `left..right` or `left...right`.", vim.log.levels.ERROR) + end + else + terminal.close_terminal_buffer() + end + end) +end + -- Jujutsu rebase function M.rebase() if not utils.ensure_jj() then @@ -1188,6 +1222,20 @@ function M.j(args) diff = function() M.diff({ current = false }) end, + diff_history = function() + if remaining_args_str == "" then + return M.diff_history() + end + + -- Otherwise it means a range has been provided + local parts = parser.parse_diff_range(remaining_args_str) + if not parts then + utils.notify("Invalid history range. Formant must be `..`", vim.log.levels.ERROR) + return + end + + M.diff_history({ left = parts.left, right = parts.right }) + end, status = function() M.status() end, @@ -1286,6 +1334,7 @@ function M.register_command() "bookmark", "describe", "diff", + "diff_history", "edit", "fetch", "git", diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index afc8a44..6f31f53 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -428,6 +428,26 @@ function M.handle_log_diff() end end +--- Handle log history +function M.handle_log_history() + local revsets = extract_revsets_from_terminal_buffer() + if not revsets then + utils.notify("No valid revision found in the log line", vim.log.levels.ERROR) + return + end + + -- Delete the pipes from the revsets string since jj new expects space separated revsets + revsets = revsets:gsub("| ", "") + + local is_multiple = revsets:find(" ") ~= nil + + if is_multiple then + -- Get the first and the last revset to diff + local list = vim.split(revsets, "%s+", { trimempty = true }) + diff.diff_history_revisions({ left = list[1], right = list[#list] }) + end +end + --- Handle describing a log line function M.handle_log_describe() -- Store the cursor pos for when we exit the describe @@ -1128,6 +1148,11 @@ function M.log_keymaps() handler = M.handle_log_tag_set, modes = { "n" }, }, + history = { + desc = "Show history diff between revisions (only works with multiple revsets selected)", + handler = M.handle_log_history, + modes = { "v" }, + }, } local merged_keymaps = cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps()) diff --git a/lua/jj/core/parser.lua b/lua/jj/core/parser.lua index d18954a..eb8e3d0 100644 --- a/lua/jj/core/parser.lua +++ b/lua/jj/core/parser.lua @@ -229,4 +229,20 @@ function M.parse_annotation_line(line) return result end +-- Given a range with the format "left..right", parse and return the two revisions +--- @param range_str string The range string to parse (e.g., "left..right") +--- @return {left: string, right: string}|nil A table +function M.parse_diff_range(range_str) + if not range_str then + return nil + end + + local left, right = range_str:match("^(%S+)%.%.(%S+)$") + if left and right then + return { left = left, right = right } + end + + return nil +end + return M diff --git a/lua/jj/diff/codediff.lua b/lua/jj/diff/codediff.lua index b71d6d8..0761e73 100644 --- a/lua/jj/diff/codediff.lua +++ b/lua/jj/diff/codediff.lua @@ -28,6 +28,21 @@ local function diff_two_changes(left, right) vim.cmd(string.format("CodeDiff %s %s", commit_id_right, commit_id_left)) end +local function diff_two_changes_with_history(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 + + -- test + vim.cmd(string.format("CodeDiff history %s..%s", commit_id_right, commit_id_left)) +end + ----------------------------------------------------------------------- -- Codediff Backend ----------------------------------------------------------------------- @@ -67,4 +82,12 @@ diff.register_backend("codediff", { diff_two_changes(opts.left, opts.right) end, + + diff_history_revisions = function(opts) + if not utils.has_dependency("codediff") then + return + end + + diff_two_changes_with_history(opts.left, opts.right) + end, }) diff --git a/lua/jj/diff/diffview.lua b/lua/jj/diff/diffview.lua index c269919..af8d83d 100644 --- a/lua/jj/diff/diffview.lua +++ b/lua/jj/diff/diffview.lua @@ -24,6 +24,21 @@ local function diff_two_changes(left, right) vim.cmd(string.format("DiffviewOpen %s..%s", commit_id_right, commit_id_left)) end +local function diff_two_changes_with_history(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 + + -- test + vim.cmd(string.format("DiffviewFileHistory --range=%s..%s", commit_id_right, commit_id_left)) +end + -- Register the diffview backend diff.register_backend("diffview", { diff_current = function(opts) @@ -59,4 +74,11 @@ diff.register_backend("diffview", { diff_two_changes(opts.left, opts.right) end, + diff_history_revisions = function(opts) + if not utils.has_dependency("diffview") then + return + end + + diff_two_changes_with_history(opts.left, opts.right) + end, }) diff --git a/lua/jj/diff/init.lua b/lua/jj/diff/init.lua index b01cd7f..32595ec 100644 --- a/lua/jj/diff/init.lua +++ b/lua/jj/diff/init.lua @@ -28,6 +28,7 @@ local utils = require("jj.utils") ---@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) +---@field diff_history_revisions? fun(opts: jj.diff.revisions_opts) ---@class jj.diff.config ---@field backend? jj.diff.backend @@ -99,7 +100,7 @@ end ----------------------------------------------------------------------- --- Single dispatcher (canonical entry point) ----@param kind "current"|"revision"|"revisions" +---@param kind "current"|"revision"|"revisions"|"history" ---@param opts table function M.open(kind, opts) opts = opts or {} @@ -120,6 +121,10 @@ function M.open(kind, opts) return backend.diff_revisions(opts) end return backends.native.diff_revisions(opts) + elseif kind == "history" then + if backend.diff_history_revisions then + return backend.diff_history_revisions(opts) + end else utils.notify("[Diff] unknown diff kind: " .. tostring(kind), vim.log.levels.ERROR) end @@ -143,6 +148,12 @@ function M.diff_revisions(opts) return M.open("revisions", opts) end +-- Diff between two revisions with history mode active +--- @param opts jj.diff.revisions_opts +function M.diff_history_revisions(opts) + return M.open("history", opts) +end + --- ----------------------------------------------------------------------- -- BACKWARDS COMPATIBLE API diff --git a/lua/jj/diff/native.lua b/lua/jj/diff/native.lua index f3da668..0228204 100644 --- a/lua/jj/diff/native.lua +++ b/lua/jj/diff/native.lua @@ -124,4 +124,10 @@ diff.register_backend("native", { 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, + diff_history_revisions = function(_) + utils.notify( + "Diffing revisions with history mode is not supported on the `native` backend.", + vim.log.levels.WARN + ) + end, })