From 33179e2f15cd48b1a661f5a47088aa981c4ec0b5 Mon Sep 17 00:00:00 2001 From: Nicolas GB Date: Mon, 8 Jun 2026 09:09:28 +0200 Subject: [PATCH] feat(duplicate): Add a duplication mode (#118) --- README.md | 38 ++++++++++++ lua/jj/cmd/init.lua | 21 +++++++ lua/jj/cmd/log.lua | 141 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 194 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 087e697..b64f76e 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - [Squash changes from the log buffer](#squash-changes-from-the-log-buffer) - [Split changes from the log buffer](#split-changes-from-the-log-buffer) - [Rebase changes from the log buffer](#rebase-changes-from-the-log-buffer) + - [Duplicate changes from the log buffer](#duplicate-changes-from-the-log-buffer) - [Open a PR/MR from the log buffer](#open-a-prmr-from-the-log-buffer) - [Browse current file on remote](#browse-current-file-on-remote) - [Open a changed file](#open-a-changed-file) @@ -68,6 +69,7 @@ - `squash` - Squash the current diff to its parent or interactive squash mode from the log buffer - `split` - Split a change interactively in a floating terminal - `rebase` - Rebase changes to a destination + - `duplicate` - Duplicate one or more changes to a destination revision - `bookmark create/delete/track/forget` - Create, delete, track, and forget (untrack) bookmarks - `tag set/delete/push` - Create, delete, and push tags (push requires colocated repos) - `undo` - Undo the last operation @@ -276,6 +278,32 @@ From rebase mode, choose how to rebase: ![Rebase-from-log](https://github.com/NicolasGB/jj.nvim/raw/main/assets/rebase.gif) +### Duplicate changes from the log buffer + +Enter an interactive duplicate mode directly from the log buffer to duplicate one or more changes: + +- `` - Enter duplicate mode targeting the revision under cursor (in normal mode) or selected revisions (in visual mode) + +Once in duplicate mode, the interface highlights your selection and the current duplicate destination: + +- Selected changes are highlighted in your configured `selected_hl` color (default: dark magenta) +- The cursor position (target destination) is highlighted in your configured `targeted_hl` color (default: green) +- Move the cursor to preview different duplicate destinations with live highlighting + +From duplicate mode, choose how to duplicate: + +- `` or `o` - Duplicate onto (`-o`) the revision under cursor +- `a` - Duplicate after (`-A`) the revision under cursor +- `b` - Duplicate before (`-B`) the revision under cursor +- `` or `` - Duplicate onto (`-o`) ignoring immutability +- `` - Duplicate after (`-A`) ignoring immutability +- `` - Duplicate before (`-B`) ignoring immutability +- `` or `` - Exit duplicate mode without making changes + +**Visual mode selection:** Select multiple revisions in visual mode before pressing `` to duplicate them all at once. The plugin extracts each selected revision and duplicates them together. + +**Single revision:** In normal mode, place your cursor on a single revision and press `` to duplicate just that change. + ### Open a PR/MR from the log buffer - `o` - Open a PR/MR for the revision under cursor @@ -545,6 +573,16 @@ revision via `jj diffedit`. Immutable revisions show an error on write. before_immutable = "", -- Rebase before revision under cursor (ignore immutability) exit_mode = { "", "" }, -- Exit rebase mode }, + duplicate = "", -- Enter duplicate mode targeting revision under cursor or selected revisions + duplicate_mode = { + onto = { "", "o" }, -- Select revision under cursor as duplicate onto destination + after = "a", -- Duplicate after revision under cursor + before = "b", -- Duplicate before revision under cursor + onto_immutable = { "", "" }, -- Duplicate onto revision under cursor (ignore immutability) + after_immutable = "", -- Duplicate after revision under cursor (ignore immutability) + before_immutable = "", -- Duplicate before revision under cursor (ignore immutability) + exit_mode = { "", "" }, -- Exit duplicate mode + }, squash = "s", -- Enter squash mode targeting revision under cursor or selected revisions squash_mode = { into = "", -- Squash into revision under cursor diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index b39f0a1..87850e9 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -58,6 +58,8 @@ local split_module = require("jj.cmd.split") --- @field tag_set? string|string[] --- @field history? string|string[] --- @field change_revset? string|string[] +--- @field duplicate? string|string[] +--- @field duplicate_mode? jj.cmd.duplicate.keymaps --- @class jj.cmd.rebase.keymaps --- @field onto? string|string[] @@ -68,6 +70,15 @@ local split_module = require("jj.cmd.split") --- @field before_immutable? string|string[] --- @field exit_mode? string|string[] +--- @class jj.cmd.duplicate.keymaps +--- @field onto? string|string[] +--- @field after? string|string[] +--- @field before? string|string[] +--- @field onto_immutable? string|string[] +--- @field after_immutable? string|string[] +--- @field before_immutable? string|string[] +--- @field exit_mode? string|string[] + --- @class jj.cmd.squash.keymaps --- @field into? string|string[] --- @field into_immutable? string|string[] @@ -183,6 +194,16 @@ M.config = { into_immutable = "", exit_mode = { "", "" }, }, + duplicate = "", + duplicate_mode = { + onto = { "", "o" }, + after = "a", + before = "b", + onto_immutable = { "", "" }, + after_immutable = "", + before_immutable = "", + exit_mode = { "", "" }, + }, quick_squash = "", summary = "", summary_tooltip = { diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index 83db400..709d625 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -25,7 +25,7 @@ local HIGHLIGHT_RANGE = 2 -- Revision line + description line --- @field raw_flags? string ---@type jj.cmd.log_opts -local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = 20, raw_flags = nil } +local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = nil, raw_flags = nil } --- @param modes string|string[] --- @return string[] @@ -884,6 +884,22 @@ function M.handle_log_squash() utils.notify("Squash `started`.", vim.log.levels.INFO, 500) end +--- Duplicate bookmark(s) +function M.handle_log_duplicate() + local revsets_str = extract_revsets_from_terminal_buffer() + -- Validate revsets + if not revsets_str or revsets_str == "" then + return + end + vim.b.jj_duplicate_revsets = revsets_str + + -- Set highlights + setup_selected_highlights() + + M.transition_mode("duplicate") + utils.notify("Duplication `started`.", vim.log.levels.INFO, 500) +end + --- Quick squash the bookmark under the cursor into it's parent function M.handle_log_quick_squash() local revset = get_revset() @@ -1261,6 +1277,11 @@ function M.log_keymaps() handler = M.handle_log_squash, modes = { "n", "v" }, }, + duplicate = { + desc = "Duplicate bookmark(s)", + handler = M.handle_log_duplicate, + modes = { "n", "v" }, + }, quick_squash = { desc = "Squash the bookmark under the cursor into it's parent (-r) keeping parent's message (-u), alwas ignores immutability", handler = M.handle_log_quick_squash, @@ -1354,6 +1375,7 @@ function M.rebase_keymaps() exit_mode = { desc = "Exit rebase to normal mode", handler = M.handle_special_mode_exit, + args = { "Rebase" }, modes = { "n" }, }, } @@ -1384,6 +1406,63 @@ function M.squash_keymaps() exit_mode = { desc = "Exit squash to normal mode", handler = M.handle_special_mode_exit, + args = { "Squash" }, + modes = { "n" }, + }, + } + + return apply_nowait_to_unique_keymaps(cmd.resolve_keymaps_from_specs(keymaps, spec)) +end + +--- Duplicate mode keymaps +--- @return jj.core.buffer.keymap[] + +function M.duplicate_keymaps() + local cmd = require("jj.cmd") + local keymaps = cmd.config.keymaps.log.duplicate_mode or {} + + --- @type jj.cmd.keymap_specs + local spec = { + onto = { + desc = "Duplicate onto (-O) the revision under cursor", + handler = M.handle_duplicate_execute, + args = { "onto" }, + modes = { "n" }, + }, + after = { + desc = "Duplicate revset(s) after (-A) the revision under cursor", + handler = M.handle_duplicate_execute, + args = { "after" }, + modes = { "n" }, + }, + before = { + desc = "Duplicate revset(s) before (-B) the revision under cursor", + handler = M.handle_duplicate_execute, + args = { "before" }, + modes = { "n" }, + }, + onto_immutable = { + desc = "Duplicate onto (-O) the revision under cursor (ignores immutability)", + handler = M.handle_duplicate_execute, + args = { "onto", true }, + modes = { "n" }, + }, + after_immutable = { + desc = "Duplicate revset(s) after (-A) the revision under cursor (ignores immutability)", + handler = M.handle_duplicate_execute, + args = { "after", true }, + modes = { "n" }, + }, + before_immutable = { + desc = "Duplicate revset(s) before (-B) the revision under cursor (ignores immutability)", + handler = M.handle_duplicate_execute, + args = { "before", true }, + modes = { "n" }, + }, + exit_mode = { + desc = "Exit normal to normal mode", + handler = M.handle_special_mode_exit, + args = { "Duplication" }, modes = { "n" }, }, } @@ -1401,12 +1480,14 @@ function M.get_keymaps_for_mode(mode) return M.rebase_keymaps() elseif mode == "squash" then return M.squash_keymaps() + elseif mode == "duplicate" then + return M.duplicate_keymaps() end return {} end --- Transition between buffer modes by swapping keymaps ---- @param target_mode "normal"|"rebase"|"squash" Target mode name (e.g., "normal", "rebase") +--- @param target_mode "normal"|"rebase"|"squash"|"duplicate" Target mode name (e.g., "normal", "rebase") function M.transition_mode(target_mode) -- Get the mode keymaps if target_mode == vim.b.jj_mode then @@ -1439,9 +1520,12 @@ function M.transition_mode(target_mode) end --- Handle special mode exit -function M.handle_special_mode_exit() +--- @param mode "Rebase"|"Squash"|"Duplicate" The mode that is being exited, used for notification message +function M.handle_special_mode_exit(mode) -- Clear stored revsets vim.b.jj_rebase_revsets = nil + vim.b.jj_squash_revsets = nil + vim.b.jj_duplicate_revsets = nil M.transition_mode("normal") -- Clear highlights @@ -1449,7 +1533,7 @@ function M.handle_special_mode_exit() vim.api.nvim_buf_clear_namespace(buf, log_selected_ns_id, 0, -1) vim.api.nvim_buf_clear_namespace(buf, log_special_mode_target_ns_id, 0, -1) - utils.notify("Rebase `canceled`", vim.log.levels.INFO, 500) + utils.notify(string.format("%s `canceled`", mode), vim.log.levels.INFO, 500) end --- Handle rebase execution with mode @@ -1494,7 +1578,7 @@ function M.handle_rebase_execute(mode, ignore_immut) M.transition_mode("normal") -- Refresh log M.log({}) - end, "Error during rebase onto") + end, "Error during rebase.") end --- Handle squash execution @@ -1537,7 +1621,52 @@ function M.handle_squash_execute(mode, ignore_immut) M.transition_mode("normal") -- Refresh log M.log({}) - end, "Error during squash into") + end, "Error during squashing.") +end + +-- Handle duplicate execution +--- @param mode "onto" | "after" | "before" Rebase mode +--- @param ignore_immut boolean? Wether or not to ignore immutability +function M.handle_duplicate_execute(mode, ignore_immut) + -- Get all revsets in the format "xx xy xz" + local revsets = vim.b.jj_duplicate_revsets + local destination_revset = get_revset() + if not destination_revset or destination_revset == "" then + return + end + + local mode_flat = "-o" + if mode == "after" then + mode_flat = "-A" + elseif mode == "before" then + mode_flat = "-B" + end + + utils.notify(string.format("Duplicating...", revsets, mode, destination_revset), vim.log.levels.INFO, 500) + local cmd = string.format("jj duplicate %s %s %s", revsets, mode_flat, destination_revset) + + -- If ignore_immut is true, add the flag + -- This is not currently exposed in keymaps but could be in the future + if ignore_immut then + cmd = cmd .. " --ignore-immutable" + end + + runner.execute_command_async(cmd, function() + utils.notify( + string.format("Duplicated `%s` %s `%s` successfully", revsets, mode, destination_revset), + vim.log.levels.INFO + ) + vim.b.jj_duplicate_revsets = nil + + -- Clear all highlighting before transitioning + local buf = terminal.state.buf or 0 + vim.api.nvim_buf_clear_namespace(buf, log_selected_ns_id, 0, -1) + vim.api.nvim_buf_clear_namespace(buf, log_special_mode_target_ns_id, 0, -1) + + M.transition_mode("normal") + -- Refresh log + M.log({}) + end, "Error during duplication.") end return M