diff --git a/README.md b/README.md index 42a71ba..4f664bb 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - [Abandon changes from the log buffer](#abandon-changes-from-the-log-buffer) - [Fetch and push from the log buffer](#fetch-and-push-from-the-log-buffer) - [Manage bookmarks from the log buffer](#manage-bookmarks-from-the-log-buffer) + - [Squash changes from the log buffer](#squash-changes-from-the-log-buffer) - [Rebase changes from the log buffer](#rebase-changes-from-the-log-buffer) - [Open a PR/MR from the log buffer](#open-a-prmr-from-the-log-buffer) - [Open a changed file](#open-a-changed-file) @@ -53,7 +54,7 @@ - `diff` - Show changes with optional filtering by current file - `new` - Create a new change with optional parent selection - `edit` - Edit a change - - `squash` - Squash the current diff to it's parent + - `squash` - Squash the current diff to its parent or interactive squash mode from the log buffer - `rebase` - Rebase changes to a destination - `bookmark create/delete` - Create and delete bookmarks - `undo` - Undo the last operation @@ -119,6 +120,31 @@ You can fetch and push directly from the log buffer: - Select from existing bookmarks to move them - Or create a new bookmark at that revision +### Squash changes from the log buffer + +Enter an interactive squash mode 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) +- `` - Quick squash the revision under cursor into its parent + +Once in squash mode, the interface highlights your selection and the current squash destination: + +- 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 + +From squash mode, choose how to squash: + +- `` - Squash into (`-t`) the revision under cursor +- `` - Squash into (`-t`) ignoring immutability +- `` or `` - 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. + +**Quick squash:** In normal mode, press `` to quickly squash the current revision into its parent. This ignores immutability. + +![Squash-from-log](https://github.com/NicolasGB/jj.nvim/raw/main/assets/squash.gif) + ### Rebase changes from the log buffer Enter an interactive rebase mode directly from the log buffer to rebase one or more changes: @@ -311,6 +337,13 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing before_immutable = "", -- Rebase before revision under cursor (ignore immutability) exit_mode = { "", "" }, -- Exit rebase mode }, + squash = "s", -- Enter squash mode targeting revision under cursor or selected revisions + squash_mode = { + into = "", -- Squash into revision under cursor + into_immutable = "", -- Squash into revision under cursor (ignore immutability) + exit_mode = { "", "" }, -- Exit squash mode + }, + quick_squash = "", -- Quick squash revision under cursor into its parent (ignore immutability) }, -- Status buffer keymaps (set to nil to disable) status = { @@ -320,8 +353,8 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing -- Close keymaps (shared across all buffers) close = { "q", "" }, }, - } -} + +}} ``` diff --git a/assets/squash.gif b/assets/squash.gif new file mode 100644 index 0000000..21883e8 Binary files /dev/null and b/assets/squash.gif differ diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index 96b40c0..d63f83a 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -46,7 +46,11 @@ local status_module = require("jj.cmd.status") --- @field open_pr? string|string[] --- @field open_pr_list? string|string[] --- @field bookmark? string|string[] +--- @field rebase? string|string[] --- @field rebase_mode? jj.cmd.rebase.keymaps +--- @field squash? string|string[] +--- @field squash_mode? jj.cmd.squash.keymaps +--- @field quick_squash? string|string[] --- @class jj.cmd.rebase.keymaps --- @field onto? string|string[] @@ -57,6 +61,11 @@ local status_module = require("jj.cmd.status") --- @field before_immutable? string|string[] --- @field exit_mode? string|string[] +--- @class jj.cmd.squash.keymaps +--- @field into? string|string[] +--- @field into_immutable? string|string[] +--- @field exit_mode? string|string[] + --- @class jj.cmd.bookmark --- @field prefix? string Prefix to append when creating a bookmark @@ -138,6 +147,13 @@ M.config = { before_immutable = "", exit_mode = { "", "" }, }, + squash = "s", + squash_mode = { + into = "", + into_immutable = "", + exit_mode = { "", "" }, + }, + quick_squash = "", }, status = { open_file = "", diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index 29287d0..9a39d5a 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -8,8 +8,8 @@ local terminal = require("jj.ui.terminal") local log_selected_hl_group = "JJLogSelectedHlGroup" local log_selected_ns_id = vim.api.nvim_create_namespace(log_selected_hl_group) -local log_rebase_target_hl_group = "JJLogRebaseTargetHlGroup" -local log_rebase_target_ns_id = vim.api.nvim_create_namespace(log_rebase_target_hl_group) +local log_special_mode_target_hl_group = "JJLogSpecialModeTargetHlGroup" +local log_special_mode_target_ns_id = vim.api.nvim_create_namespace(log_special_mode_target_hl_group) local rebase_mode_autocmd_id = nil local last_rebase_target_line = nil local HIGHLIGHT_RANGE = 2 -- Revision line + description line @@ -33,7 +33,7 @@ function M.init_log_highlights() end vim.api.nvim_set_hl(0, log_selected_hl_group, cfg.selected) - vim.api.nvim_set_hl(0, log_rebase_target_hl_group, cfg.targeted) + vim.api.nvim_set_hl(0, log_special_mode_target_hl_group, cfg.targeted) end --- Find the revision line under cursor, handling description lines @@ -124,7 +124,7 @@ end local function apply_target_highlight(buf, revset_line, hl_group) vim.api.nvim_buf_set_extmark( buf, - log_rebase_target_ns_id, + log_special_mode_target_ns_id, revset_line, 0, { end_line = revset_line + HIGHLIGHT_RANGE, end_col = 0, hl_group = hl_group } @@ -137,7 +137,7 @@ local function clear_target_highlight(buf) if last_rebase_target_line ~= nil then vim.api.nvim_buf_clear_namespace( buf, - log_rebase_target_ns_id, + log_special_mode_target_ns_id, last_rebase_target_line, last_rebase_target_line + HIGHLIGHT_RANGE ) @@ -146,7 +146,7 @@ local function clear_target_highlight(buf) end --- Update rebase target highlight on cursor movement -local function update_rebase_target_highlight() +local function update_special_mode_target_highlight() local buf = terminal.state.buf if not buf then return @@ -162,7 +162,71 @@ local function update_rebase_target_highlight() -- Only highlight if rev is not in selection local is_in_selection = vim.b.jj_rebase_revsets and string.find(vim.b.jj_rebase_revsets, rev, 1, true) if not is_in_selection then - apply_target_highlight(buf, revset_line, log_rebase_target_hl_group) + apply_target_highlight(buf, revset_line, log_special_mode_target_hl_group) + end +end + +--- Extracts the revsets from either the current line or the selected lines in visual mode +--- @return string|nil The revsets string or nil if none found +local function extract_revsets_from_terminal_buffer() + local buf = terminal.state.buf + if not buf then + utils.notify("No open log buffer", vim.log.levels.ERROR) + return nil + end + + local revsets_str = nil + local mode = vim.fn.mode() + + -- Get revsets based on mode + if mode == "n" then + revsets_str = get_revset() + elseif mode == "v" or mode == "V" then + local start_line = vim.fn.line("v") + local end_line = vim.fn.line(".") + if start_line > end_line then + start_line, end_line = end_line, start_line + end + + local lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false) + local revsets = parser.get_all_revsets(lines) + if not revsets or #revsets == 0 then + utils.notify("No valid revisions found in selected lines", vim.log.levels.ERROR) + return nil + end + + revsets_str = table.concat(revsets, " | ") + -- Exit visual mode after extracting revsets + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + else + return nil + end + + return revsets_str +end + +--- Setup highlights for selected revisions in the log buffer +local function setup_selected_highlights() + local buf = terminal.state.buf + if not buf then + return + end + + -- Set highlights + local marks = get_highlight_marks() + if not marks or #marks == 0 then + utils.notify("No valid revisions found to highlight", vim.log.levels.ERROR) + return + end + + for _, mark in ipairs(marks) do + vim.api.nvim_buf_set_extmark( + buf, + log_selected_ns_id, + mark.line, + mark.col, + { end_line = mark.end_line, end_col = mark.end_col, hl_group = log_selected_hl_group } + ) end end @@ -178,7 +242,7 @@ function M.log(opts) terminal.store_cursor_position() -- Make sure to clear highlights before rerunning since the previous log buffer might have some vim.api.nvim_buf_clear_namespace(terminal.state.buf, log_selected_ns_id, 0, -1) - vim.api.nvim_buf_clear_namespace(terminal.state.buf, log_rebase_target_ns_id, 0, -1) + vim.api.nvim_buf_clear_namespace(terminal.state.buf, log_special_mode_target_ns_id, 0, -1) end local jj_cmd = "jj log" @@ -528,65 +592,49 @@ end --- Rebase bookmark(s) function M.handle_log_rebase() - local buf = terminal.state.buf - if not buf then - utils.notify("No open log buffer", vim.log.levels.ERROR) + local revsets_str = extract_revsets_from_terminal_buffer() + -- Validate revsets + if not revsets_str or revsets_str == "" then return end + vim.b.jj_rebase_revsets = revsets_str - local revsets_str = nil - local mode = vim.fn.mode() + -- Set highlights + setup_selected_highlights() - -- Get revsets based on mode - if mode == "n" then - revsets_str = get_revset() - elseif mode == "v" or mode == "V" then - local start_line = vim.fn.line("v") - local end_line = vim.fn.line(".") - if start_line > end_line then - start_line, end_line = end_line, start_line - end - - local lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false) - local revsets = parser.get_all_revsets(lines) - if not revsets or #revsets == 0 then - utils.notify("No valid revisions found in selected lines", vim.log.levels.ERROR) - return - end - - revsets_str = table.concat(revsets, " | ") - -- Exit visual mode before transition - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) - else - return - end + M.transition_mode("rebase") + utils.notify("Rebase `started`.", vim.log.levels.INFO, 500) +end +--- Squash bookmarks(s) +function M.handle_log_squash() + local revsets_str = extract_revsets_from_terminal_buffer() -- Validate revsets if not revsets_str or revsets_str == "" then return end - vim.b.jj_rebase_revsets = revsets_str - + vim.b.jj_squash_revsets = revsets_str -- Set highlights - local marks = get_highlight_marks() - if not marks or #marks == 0 then - utils.notify("No valid revisions found to highlight during rebase", vim.log.levels.ERROR) + setup_selected_highlights() + + M.transition_mode("squash") + utils.notify("Squash `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() + if not revset or revset == "" then return end - for _, mark in ipairs(marks) do - vim.api.nvim_buf_set_extmark( - buf, - log_selected_ns_id, - mark.line, - mark.col, - { end_line = mark.end_line, end_col = mark.end_col, hl_group = log_selected_hl_group } - ) - end - - M.transition_mode("rebase") - utils.notify("Rebase `started`.", vim.log.levels.INFO, 500) + local cmd = string.format("jj squash -r %s -u --ignore-immutable", revset) + utils.notify(string.format("Squashing `%s` into it's parent...", revset), vim.log.levels.INFO) + runner.execute_command_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 --- Resolve log keymaps from config, filtering out nil values @@ -698,6 +746,16 @@ function M.log_keymaps() handler = M.handle_log_rebase, modes = { "n", "v" }, }, + squash = { + desc = "Squash bookmark(s)", + handler = M.handle_log_squash, + 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, + modes = { "n" }, + }, } return cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps()) @@ -749,7 +807,37 @@ function M.rebase_keymaps() }, exit_mode = { desc = "Exit rebase to normal mode", - handler = M.handle_rebase_mode_exit, + handler = M.handle_special_mode_exit, + modes = { "n" }, + }, + } + + return cmd.resolve_keymaps_from_specs(keymaps, spec) +end + +--- Squash mode keymaps +--- @return jj.core.buffer.keymap[] +function M.squash_keymaps() + local cmd = require("jj.cmd") + local keymaps = cmd.config.keymaps.log.squash_mode or {} + + --- @type jj.cmd.keymap_specs + local spec = { + into = { + desc = "Squash into (-t) the revision under cursor", + handler = M.handle_squash_execute, + args = { "into" }, + modes = { "n" }, + }, + into_immutable = { + desc = "Squash onto (-i) the revision under cursor (ignores immutability)", + handler = M.handle_squash_execute, + args = { "into", true }, + modes = { "n" }, + }, + exit_mode = { + desc = "Exit squash to normal mode", + handler = M.handle_special_mode_exit, modes = { "n" }, }, } @@ -765,12 +853,14 @@ function M.get_keymaps_for_mode(mode) return M.log_keymaps() elseif mode == "rebase" then return M.rebase_keymaps() + elseif mode == "squash" then + return M.squash_keymaps() end return {} end --- Transition between buffer modes by swapping keymaps ---- @param target_mode string Target mode name (e.g., "normal", "rebase") +--- @param target_mode "normal"|"rebase"|"squash" 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 @@ -782,19 +872,19 @@ function M.transition_mode(target_mode) terminal.replace_terminal_keymaps(new_keymaps) -- Set up or tear down rebase mode autocmd - if target_mode == "rebase" then + if target_mode ~= "normal" then rebase_mode_autocmd_id = vim.api.nvim_create_autocmd("CursorMoved", { buffer = terminal.state.buf, - callback = update_rebase_target_highlight, + callback = update_special_mode_target_highlight, }) -- Highlight initial position - update_rebase_target_highlight() + update_special_mode_target_highlight() elseif rebase_mode_autocmd_id then vim.api.nvim_del_autocmd(rebase_mode_autocmd_id) rebase_mode_autocmd_id = nil -- Clear target highlight local buf = terminal.state.buf or 0 - vim.api.nvim_buf_clear_namespace(buf, log_rebase_target_ns_id, 0, -1) + vim.api.nvim_buf_clear_namespace(buf, log_special_mode_target_ns_id, 0, -1) last_rebase_target_line = nil end @@ -802,8 +892,8 @@ function M.transition_mode(target_mode) vim.b.jj_mode = target_mode end ---- Handle rebase mode exit -function M.handle_rebase_mode_exit() +--- Handle special mode exit +function M.handle_special_mode_exit() -- Clear stored revsets vim.b.jj_rebase_revsets = nil @@ -811,7 +901,7 @@ function M.handle_rebase_mode_exit() -- Clear highlights 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_rebase_target_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) end @@ -853,7 +943,7 @@ function M.handle_rebase_execute(mode, ignore_immut) -- 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_rebase_target_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 @@ -861,4 +951,47 @@ function M.handle_rebase_execute(mode, ignore_immut) end, "Error during rebase onto") 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) + -- Get all revsets in the format "xx xy xz" + local revsets = vim.b.jj_squash_revsets + local destination_revset = get_revset() + if not destination_revset or destination_revset == "" then + return + end + + utils.notify(string.format("Squashing...", revsets, mode, destination_revset), vim.log.levels.INFO, 500) + -- U flag to keep destination's message + local cmd = string.format("jj squash -f '%s' -u", revsets) + + if mode == "into" then + cmd = cmd .. string.format(" -t %s", destination_revset) + end + + -- 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("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.transition_mode("normal") + -- Refresh log + M.log({}) + end, "Error during squash into") +end + return M diff --git a/lua/jj/ui/terminal.lua b/lua/jj/ui/terminal.lua index 33113e2..3b01fcd 100644 --- a/lua/jj/ui/terminal.lua +++ b/lua/jj/ui/terminal.lua @@ -52,30 +52,32 @@ end --- Help for terminal buffer function M.keymap_help() - -- get the normal mode keys defined for the current buffer - local keys = vim.api.nvim_buf_get_keymap(0, "n") + -- get the normal mode keys defined for the current buffer + local keys = vim.api.nvim_buf_get_keymap(0, "n") - -- sort the keys table by desc - table.sort(keys, function(a, b) return (a.desc or "") < (b.desc or "") end) + -- sort the keys table by desc + table.sort(keys, function(a, b) + return (a.desc or "") < (b.desc or "") + end) - -- Figure out the width of the longest key for later formatting - -- Ignore keys for entries without a desc - local max_key_width = 0 - for _, mapping in ipairs(keys) do - local desc = mapping["desc"] - if desc ~= nil then - local key = mapping["lhs"] - local key_width = vim.api.nvim_strwidth(key) - if key_width > max_key_width then - max_key_width = key_width - end - end - end + -- Figure out the width of the longest key for later formatting + -- Ignore keys for entries without a desc + local max_key_width = 0 + for _, mapping in ipairs(keys) do + local desc = mapping["desc"] + if desc ~= nil then + local key = mapping["lhs"] + local key_width = vim.api.nvim_strwidth(key) + if key_width > max_key_width then + max_key_width = key_width + end + end + end - -- create a buffer and floating window to show the key mappings - local buf, win = buffer.create_float({ - title = " Key mappings ", - title_pos = "left", + -- create a buffer and floating window to show the key mappings + local buf, win = buffer.create_float({ + title = " Key mappings ", + title_pos = "left", enter = true, bufhidden = "hide", win_options = { @@ -85,41 +87,38 @@ function M.keymap_help() cursorline = false, signcolumn = "no", }, - }) + }) - -- helper function to pad a given key with spaces if necessary such that - -- its size will match max_key_width calculated above. - local function space_pad(key) - local key_width = vim.api.nvim_strwidth(key) - local delta = max_key_width - key_width - if delta > 0 then - return key .. string.rep(" ", delta) - end - return key - end + -- helper function to pad a given key with spaces if necessary such that + -- its size will match max_key_width calculated above. + local function space_pad(key) + local key_width = vim.api.nvim_strwidth(key) + local delta = max_key_width - key_width + if delta > 0 then + return key .. string.rep(" ", delta) + end + return key + end - -- create formated entries for items with a non-nil desc - local lines = {} - for _, entry in ipairs(keys) do - if entry["desc"] ~= nil then - local line = string.format(" %s %s", - space_pad(entry["lhs"]), - entry["desc"]) - table.insert(lines, line) - end + -- create formated entries for items with a non-nil desc + local lines = {} + for _, entry in ipairs(keys) do + if entry["desc"] ~= nil then + local line = string.format(" %s %s", space_pad(entry["lhs"]), entry["desc"]) + table.insert(lines, line) + end + end - end + -- add some helper text at the end + table.insert(lines, "") + table.insert(lines, ' Use "q" or ESC to close this window') - -- add some helper text at the end - table.insert(lines, "") - table.insert(lines, ' Use "q" or ESC to close this window') + -- add the formatted lines to the buffer + vim.api.nvim_buf_set_lines(buf, 3, -1, false, lines) - -- add the formatted lines to the buffer - vim.api.nvim_buf_set_lines(buf, 3, -1, false, lines) - - -- Use q or ESC to close the buffer - vim.keymap.set("n", "q", "close", { buffer = buf }) - vim.keymap.set("n", "", "close", { buffer = buf }) + -- Use q or ESC to close the buffer + vim.keymap.set("n", "q", "close", { buffer = buf }) + vim.keymap.set("n", "", "close", { buffer = buf }) end --- Close the current terminal buffer if it exists @@ -282,6 +281,12 @@ function M.run_floating(cmd, keymaps) end end + -- Remove prompt keymaps + buffer.remove_keymaps(state.floating_buf, { + { modes = { "n", "v" }, lhs = "[[", rhs = function() end }, + { modes = { "n", "v" }, lhs = "]]", rhs = function() end }, + }) + buffer.set_keymaps(state.floating_buf, default_keymaps) vim.b[state.floating_buf].jj_keymaps_set = true end @@ -410,7 +415,7 @@ function M.run(cmd, keymaps) -- Set base keymaps only if they haven't been set for this buffer yet if not vim.b[state.buf].jj_keymaps_set then buffer.set_keymaps(state.buf, { - { modes = { "n" }, lhs = "g?", rhs = M.keymap_help }, + { modes = { "n" }, lhs = "g?", rhs = M.keymap_help }, -- Disable insert, command and append modes { modes = { "n", "v" }, lhs = "i", rhs = function() end }, { modes = { "n", "v" }, lhs = "c", rhs = function() end }, @@ -421,6 +426,12 @@ function M.run(cmd, keymaps) vim.b[state.buf].jj_keymaps_set = true end + -- Remove prompt keymaps + buffer.remove_keymaps(state.buf, { + { modes = { "n", "v" }, lhs = "[[", rhs = function() end }, + { modes = { "n", "v" }, lhs = "]]", rhs = function() end }, + }) + -- Remove command-specific keymaps from previous runs if vim.b[state.buf].jj_command_keymaps then buffer.remove_keymaps(state.buf, vim.b[state.buf].jj_command_keymaps)