feat(log): Implement squash --interactive from the log buffer (#145)

This commit is contained in:
Nicolas GB
2026-07-24 19:48:30 +02:00
committed by GitHub
parent 3d10ae14be
commit 15f842fb3d
4 changed files with 165 additions and 63 deletions
+19 -13
View File
@@ -203,26 +203,30 @@ Deleting and pushing tags (for colocated repositories) is also supported and cha
### Squash changes from the log buffer
Enter an interactive squash mode to squash one or more changes into a destination:
Enter squash mode from the log buffer to squash one or more changes into a destination:
- `s` - Enter squash mode targeting the revision under cursor (in normal mode) or selected revisions (in visual mode)
- `<S-s>` - Quick squash the revision under cursor into its parent
- `s` - Enter standard squash mode for the revision under cursor (Normal mode) or selected revisions (Visual mode)
- `is` - Enter **interactive** squash mode for the revision under cursor or Visual selection
- `<S-s>` - Immediately squash the revision under cursor into its parent
- `<S-i><S-s>` - Interactively squash the revision under cursor into its parent
Once in squash mode, the interface highlights your selection and the current squash destination:
Both standard and interactive squash modes highlight the operation before it runs:
- Selected changes are highlighted in your configured `selected_hl` color (default: dark magenta)
- The cursor position (potential squash destination) is highlighted in your configured `targeted_hl` color (default: green)
- Move the cursor to preview different squash destinations with live highlighting
- Selected changes use `highlights.log.selected` (dark magenta by default).
- The potential destination under the cursor uses `highlights.log.targeted` (green by default).
- Move the cursor to preview another destination; the target highlight updates live. A selected revision is not also shown as a target.
From squash mode, choose how to squash:
From either squash mode, choose the destination:
- `<CR>` - Squash into (`-t`) the revision under cursor
- `<S-CR>` - Squash into (`-t`) ignoring immutability
- `<S-CR>` - Squash into (`-t`) while ignoring immutability
- `<Esc>` or `<C-c>` - Exit squash mode without making changes
**Visual mode selection:** Select multiple revisions in visual mode before pressing `s` to squash them all at once. The plugin extracts each selected revision and squashes them together.
**Interactive squash:** After choosing a destination with `is` and `<CR>` (or `<S-CR>`), `jj.nvim` opens `jj squash --interactive` in a floating terminal. Use jj's interactive UI to select the content to squash; on success, the log is refreshed. The `is` mapping is intentionally a two-key sequence so it does not conflict with the normal `s` mapping.
**Quick squash:** In normal mode, press `<S-s>` to quickly squash the current revision into its parent. This ignores immutability.
**Visual mode selection:** Select multiple revisions in Visual mode before pressing `s` or `is` to squash them all at once. The plugin extracts each selected revision and squashes them together.
**Quick squash:** `<S-s>` performs the non-interactive operation and `<S-i><S-s>` opens jj's interactive UI. Both target the current revision's parent and ignore immutability.
![Squash-from-log](https://github.com/NicolasGB/jj.nvim/raw/main/assets/squash.gif)
@@ -692,13 +696,15 @@ revision via `jj diffedit`. Immutable revisions show an error on write.
before_immutable = "<S-b>", -- Duplicate before revision under cursor (ignore immutability)
exit_mode = { "<Esc>", "<C-c>" }, -- Exit duplicate mode
},
squash = "s", -- Enter squash mode targeting revision under cursor or selected revisions
squash = "s", -- Enter standard squash mode for revision under cursor or selected revisions
squash_interactive = "is", -- Enter interactive squash mode; choose a target, then use jj's interactive UI
squash_mode = {
into = "<CR>", -- Squash into revision under cursor
into_immutable = "<S-CR>", -- Squash into revision under cursor (ignore immutability)
exit_mode = { "<Esc>", "<C-c>" }, -- Exit squash mode
exit_mode = { "<Esc>", "<C-c>" }, -- Exit standard or interactive squash mode
},
quick_squash = "<S-s>", -- Quick squash revision under cursor into its parent (ignore immutability)
quick_interactive_squash = "<S-i><S-s>", -- Quick interactive squash into parent (ignore immutability)
split = "<C-s>", -- Split the revision under cursor
resolve = "gr", -- Resolve conflicts for revision under cursor
history = "<S-h>", -- Show a history-aware diff for the selected revision range
+5 -1
View File
@@ -52,8 +52,10 @@ local resolve_module = require("jj.cmd.resolve")
--- @field rebase? string|string[]
--- @field rebase_mode? jj.cmd.rebase.keymaps
--- @field squash? string|string[]
--- @field squash_interactive? string|string[]
--- @field squash_mode? jj.cmd.squash.keymaps
--- @field quick_squash? string|string[]
--- @field quick_interactive_squash? string|string[]
--- @field summary? string|string[]
--- @field summary_tooltip? jj.cmd.summary_tooltip.keymaps
--- @field tag_set? string|string[]
@@ -205,11 +207,14 @@ M.config = {
exit_mode = { "<Esc>", "<C-c>" },
},
squash = "s",
squash_interactive = "is",
squash_mode = {
into = "<CR>",
into_immutable = "<S-CR>",
exit_mode = { "<Esc>", "<C-c>" },
},
quick_squash = "<S-s>",
quick_interactive_squash = "<S-i><S-s>",
duplicate = "<C-y>",
duplicate_mode = {
onto = { "<CR>", "o" },
@@ -220,7 +225,6 @@ M.config = {
before_immutable = "<S-b>",
exit_mode = { "<Esc>", "<C-c>" },
},
quick_squash = "<S-s>",
summary = "<S-k>",
summary_tooltip = {
diff = "<S-d>",
+139 -49
View File
@@ -951,6 +951,21 @@ function M.handle_log_squash()
utils.notify("Squash `started`.", vim.log.levels.INFO, 500)
end
function M.handle_log_squash_interactive()
local revsets_str = extract_revsets_from_terminal_buffer()
-- Validate revsets
if not revsets_str or revsets_str == "" then
return
end
vim.b.jj_squash_revsets = revsets_str
-- Set highlights
setup_selected_highlights()
M.transition_mode("squash_interactive")
utils.notify("Interactive squash `started`.", vim.log.levels.INFO, 500)
end
--- Duplicate bookmark(s)
function M.handle_log_duplicate()
local revsets_str = extract_revsets_from_terminal_buffer()
@@ -967,19 +982,52 @@ function M.handle_log_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()
--- Quick squash the bookmark under the cursor into its parent
--- @param interactive boolean|nil If true, will add the --interactive flag to the jj squash command
function M.handle_log_quick_squash(interactive)
local revset = get_revset()
if not revset or revset == "" then
return
end
local cmd = { "jj", "squash", "-r", revset, "-u", "--ignore-immutable" }
utils.notify(string.format("Squashing `%s` into it's parent...", revset), vim.log.levels.INFO)
runner.execute_async(cmd, function()
utils.notify(string.format("Successfully squashed `%s` into it's parent", revset), vim.log.levels.INFO)
M.log({})
end, string.format("Error squashing `%s` into it's parent", revset))
if interactive then
table.insert(cmd, "--interactive")
terminal.run_floating(cmd, nil, {
title = "Squash Interactive",
modifiable = true,
keep_modifiable = true,
interactive = true,
on_exit = function(exit_code)
if exit_code == 0 then
utils.notify(
string.format("Successfully squashed `%s` into it's parent", revset),
vim.log.levels.INFO
)
M.log({})
else
utils.notify(
string.format("Cancelled squashing `%s` into it's parent", revset),
vim.log.levels.WARN
)
-- Since we previously replaced the floating with the squash we actually want to re run the log cmd
if require("jj").config.terminal.window.type == "floating" then
vim.schedule(function()
M.log({})
end)
end
end
end,
})
else
utils.notify(string.format("Squashing `%s` into it's parent...", revset), vim.log.levels.INFO)
runner.execute_async(cmd, function()
utils.notify(string.format("Successfully squashed `%s` into it's parent", revset), vim.log.levels.INFO)
M.log({})
end, string.format("Error squashing `%s` into it's parent", revset))
end
end
--- Handle log split
@@ -1402,16 +1450,27 @@ function M.log_keymaps()
handler = M.handle_log_squash,
modes = { "n", "v" },
},
squash_interactive = {
desc = "Squash bookmark(s) interactively",
handler = M.handle_log_squash_interactive,
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",
desc = "Squash the bookmark under the cursor into its parent (-r) keeping parent's message (-u), always ignores immutability",
handler = M.handle_log_quick_squash,
modes = { "n" },
},
quick_interactive_squash = {
desc = "Squash the bookmark under the cursor into its parent (-r) keeping parent's message (-u), always ignores immutability, but opens an interactive prompt",
handler = M.handle_log_quick_squash,
args = { true },
modes = { "n" },
},
summary = {
desc = "Show summary tooltip for revision under cursor",
handler = M.handle_log_summary,
@@ -1504,7 +1563,7 @@ function M.rebase_keymaps()
},
exit_mode = {
desc = "Exit rebase to normal mode",
handler = M.handle_special_mode_exit,
handler = M.exit_special_mode,
args = { "Rebase" },
modes = { "n" },
},
@@ -1514,8 +1573,9 @@ function M.rebase_keymaps()
end
--- Squash mode keymaps
--- @param interactive boolean
--- @return jj.core.buffer.keymap[]
function M.squash_keymaps()
function M.squash_keymaps(interactive)
local cmd = require("jj.cmd")
local keymaps = cmd.config.keymaps.log.squash_mode or {}
@@ -1524,18 +1584,18 @@ function M.squash_keymaps()
into = {
desc = "Squash into (-t) the revision under cursor",
handler = M.handle_squash_execute,
args = { "into" },
args = { "into", false, interactive },
modes = { "n" },
},
into_immutable = {
desc = "Squash onto (-i) the revision under cursor (ignores immutability)",
handler = M.handle_squash_execute,
args = { "into", true },
args = { "into", true, interactive },
modes = { "n" },
},
exit_mode = {
desc = "Exit squash to normal mode",
handler = M.handle_special_mode_exit,
handler = M.exit_special_mode,
args = { "Squash" },
modes = { "n" },
},
@@ -1591,7 +1651,7 @@ function M.duplicate_keymaps()
},
exit_mode = {
desc = "Exit normal to normal mode",
handler = M.handle_special_mode_exit,
handler = M.exit_special_mode,
args = { "Duplication" },
modes = { "n" },
},
@@ -1609,7 +1669,9 @@ function M.get_keymaps_for_mode(mode)
elseif mode == "rebase" then
return M.rebase_keymaps()
elseif mode == "squash" then
return M.squash_keymaps()
return M.squash_keymaps(false)
elseif mode == "squash_interactive" then
return M.squash_keymaps(true)
elseif mode == "duplicate" then
return M.duplicate_keymaps()
end
@@ -1617,7 +1679,7 @@ function M.get_keymaps_for_mode(mode)
end
--- Transition between buffer modes by swapping keymaps
--- @param target_mode "normal"|"rebase"|"squash"|"duplicate" Target mode name (e.g., "normal", "rebase")
--- @param target_mode "normal"|"rebase"|"squash"|"squash_interactive"|"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
@@ -1650,7 +1712,7 @@ end
--- 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)
function M.exit_special_mode(mode)
-- Clear stored revsets
vim.b.jj_rebase_revsets = nil
vim.b.jj_squash_revsets = nil
@@ -1667,7 +1729,7 @@ end
--- Handle rebase execution with mode
--- @param mode "onto" | "after" | "before" Rebase mode
--- @param ignore_immut boolean? Wether or not to ignore immutability
--- @param ignore_immut boolean? Whether or not to ignore immutability
function M.handle_rebase_execute(mode, ignore_immut)
-- Get all revsets in the format "xx xy xz"
local revsets = vim.b.jj_rebase_revsets
@@ -1704,14 +1766,8 @@ function M.handle_rebase_execute(mode, ignore_immut)
string.format("Rebased `%s` %s `%s` successfully", revsets, mode, destination_revset),
vim.log.levels.INFO
)
vim.b.jj_rebase_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")
M.exit_special_mode("Rebase")
-- Refresh log
M.log({})
end, "Error during rebase.")
@@ -1719,8 +1775,9 @@ end
--- Handle squash execution
--- @param mode "into" Squash mode
--- @param ignore_immut boolean? Wether or not to ignore immutability
function M.handle_squash_execute(mode, ignore_immut)
--- @param ignore_immut boolean? Whether or not to ignore immutability
--- @param interactive boolean|nil If true, will add the --interactive flag to the jj squash command
function M.handle_squash_execute(mode, ignore_immut, interactive)
-- Get all revsets in the format "xx xy xz"
local revsets = vim.b.jj_squash_revsets
local destination_revset = get_revset()
@@ -1742,27 +1799,66 @@ function M.handle_squash_execute(mode, ignore_immut)
table.insert(cmd, "--ignore-immutable")
end
runner.execute_async(cmd, function()
utils.notify(
string.format("Squashed `%s` into `%s` successfully", revsets, destination_revset),
vim.log.levels.INFO
)
vim.b.jj_squash_revsets = nil
if interactive then
-- If interactive is true, run the command in a floating terminal with --interactive flag
table.insert(cmd, "--interactive")
terminal.run_floating(cmd, nil, {
title = " JJ Squash ",
modifiable = true,
keep_modifiable = true,
interactive = true,
on_exit = function(exit_code)
if exit_code == 0 then
utils.notify(
string.format("Squashed `%s` into `%s` successfully", revsets, destination_revset),
vim.log.levels.INFO
)
vim.b.jj_squash_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)
-- Clear all highlighting before transitioning
M.exit_special_mode("Squash")
-- Refresh log
M.log({})
else
utils.notify(
string.format("Cancelled squashing `%s` into `%s`", revsets, destination_revset),
vim.log.levels.WARN
)
M.transition_mode("normal")
-- Refresh log
M.log({})
end, "Error during squashing.")
M.exit_special_mode("Squash")
-- Since we previously replaced the floating with the squash we actually want to re run the log cmd
if require("jj").config.terminal.window.type == "floating" then
vim.schedule(function()
M.log({})
end)
end
end
end,
})
else
-- Otherwise, run the command asynchronously without --interactive flag
runner.execute_async(cmd, function()
utils.notify(
string.format("Squashed `%s` into `%s` successfully", revsets, destination_revset),
vim.log.levels.INFO
)
vim.b.jj_squash_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.exit_special_mode("Squash")
-- Refresh log
M.log({})
end, "Error during squashing.")
end
end
-- Handle duplicate execution
--- @param mode "onto" | "after" | "before" Rebase mode
--- @param ignore_immut boolean? Wether or not to ignore immutability
--- @param ignore_immut boolean? Whether 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
@@ -1792,14 +1888,8 @@ function M.handle_duplicate_execute(mode, ignore_immut)
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")
M.exit_special_mode("Duplicate")
-- Refresh log
M.log({})
end, "Error during duplication.")
+2
View File
@@ -426,6 +426,7 @@ function M.run_floating(cmd, keymaps, float_opts)
{ modes = { "n", "v" }, lhs = "c", rhs = function() end },
{ modes = { "n", "v" }, lhs = "a", rhs = function() end },
{ modes = { "n", "v" }, lhs = "<S-a>", rhs = function() end },
{ modes = { "n", "v" }, lhs = "<S-i>", rhs = function() end },
{ modes = { "n", "v" }, lhs = "u", rhs = function() end },
}
-- IF it's interactive do not block them
@@ -599,6 +600,7 @@ function M.run(cmd, keymaps)
{ modes = { "n", "v" }, lhs = "c", rhs = function() end },
{ modes = { "n", "v" }, lhs = "a", rhs = function() end },
{ modes = { "n", "v" }, lhs = "<S-a>", rhs = function() end },
{ modes = { "n", "v" }, lhs = "<S-i>", rhs = function() end },
{ modes = { "n", "v" }, lhs = "u", rhs = function() end },
})