mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-05 18:56:13 +00:00
Add first-class conflict resolution support to jj.nvim.
- add `jj.cmd.resolve` to run `jj resolve` in a floating terminal or via an
external tool
- add `:J resolve` with support for `-r/--revision`, `--tool`,
`--external/--ext`, and positional filesets
- integrate resolve into the log buffer with the `gr` mapping and optional
`resolve_strategies` picker
- add `utils.is_change_conflicted()` and avoid opening resolve when the
selected revision has no conflicts
- document resolve usage, config, and keymaps in the README
- add tests for resolve arg parsing and conflict detection helpers
1732 lines
51 KiB
Lua
1732 lines
51 KiB
Lua
--- @class jj.cmd.log
|
|
local M = {}
|
|
|
|
local utils = require("jj.utils")
|
|
local runner = require("jj.core.runner")
|
|
local parser = require("jj.core.parser")
|
|
local terminal = require("jj.ui.terminal")
|
|
local buffer = require("jj.core.buffer")
|
|
local diff = require("jj.diff")
|
|
|
|
local log_selected_hl_group = "JJLogSelectedHlGroup"
|
|
local log_selected_ns_id = vim.api.nvim_create_namespace(log_selected_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
|
|
|
|
--- @class jj.cmd.log_opts
|
|
--- @field summary? boolean
|
|
--- @field reversed? boolean
|
|
--- @field no_graph? boolean
|
|
--- @field limit? uinteger
|
|
--- @field revisions? string
|
|
--- @field raw_flags? string
|
|
|
|
---@type jj.cmd.log_opts
|
|
local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = nil, raw_flags = nil }
|
|
|
|
--- @param modes string|string[]
|
|
--- @return string[]
|
|
local function as_mode_list(modes)
|
|
if type(modes) == "table" then
|
|
return modes
|
|
end
|
|
return { modes }
|
|
end
|
|
|
|
--- @param lhs string
|
|
--- @param lhs_list string[]
|
|
--- @return boolean
|
|
local function has_longer_prefix(lhs, lhs_list)
|
|
for _, other in ipairs(lhs_list) do
|
|
if lhs ~= other and other:sub(1, #lhs) == lhs then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
--- @param keymaps jj.core.buffer.keymap[]
|
|
--- @return jj.core.buffer.keymap[]
|
|
local function split_keymaps_by_mode(keymaps)
|
|
local split_keymaps = {}
|
|
|
|
for _, keymap in ipairs(keymaps) do
|
|
local modes = keymap.modes or keymap.mode or "n"
|
|
for _, mode in ipairs(as_mode_list(modes)) do
|
|
local mode_keymap = vim.deepcopy(keymap)
|
|
mode_keymap.modes = mode
|
|
mode_keymap.mode = nil
|
|
table.insert(split_keymaps, mode_keymap)
|
|
end
|
|
end
|
|
|
|
return split_keymaps
|
|
end
|
|
|
|
--- Set nowait=true for per-mode keymaps that are not a prefix of another keymap in the same mode.
|
|
--- @param keymaps jj.core.buffer.keymap[]
|
|
--- @return jj.core.buffer.keymap[]
|
|
local function apply_nowait_to_unique_keymaps(keymaps)
|
|
local split_keymaps = split_keymaps_by_mode(keymaps)
|
|
local lhs_by_mode = {}
|
|
|
|
for _, keymap in ipairs(split_keymaps) do
|
|
local lhs = keymap.lhs
|
|
local mode = keymap.modes
|
|
lhs_by_mode[mode] = lhs_by_mode[mode] or {}
|
|
table.insert(lhs_by_mode[mode], lhs)
|
|
end
|
|
|
|
for _, keymap in ipairs(split_keymaps) do
|
|
local lhs = keymap.lhs
|
|
local mode = keymap.modes
|
|
if not has_longer_prefix(lhs, lhs_by_mode[mode]) then
|
|
keymap.opts = vim.tbl_extend("force", keymap.opts or {}, { nowait = true })
|
|
end
|
|
end
|
|
|
|
return split_keymaps
|
|
end
|
|
|
|
--- Init log highlight groups
|
|
function M.init_log_highlights()
|
|
local cfg = require("jj").config.highlights.log
|
|
if not cfg then
|
|
return
|
|
end
|
|
|
|
vim.api.nvim_set_hl(0, log_selected_hl_group, cfg.selected)
|
|
vim.api.nvim_set_hl(0, log_special_mode_target_hl_group, cfg.targeted)
|
|
end
|
|
|
|
--- Find the revision line under cursor, handling description lines
|
|
local function get_revset_line()
|
|
local buf = terminal.state.buf or vim.api.nvim_get_current_buf()
|
|
local current_line_num = vim.api.nvim_win_get_cursor(0)[1] - 1
|
|
local line = vim.api.nvim_get_current_line()
|
|
local revset_line = current_line_num
|
|
|
|
if not parser.get_revset(line) and current_line_num > 0 then
|
|
revset_line = current_line_num - 1
|
|
line = vim.api.nvim_buf_get_lines(buf, revset_line, revset_line + 1, false)[1]
|
|
end
|
|
|
|
return revset_line, parser.get_revset(line)
|
|
end
|
|
|
|
--- Gets revset from current line or parent in case we're on a description.
|
|
--- @return string|nil The revset if found in either current line or previous line, nil otherwise
|
|
local function get_revset()
|
|
local _, rev = get_revset_line()
|
|
return rev
|
|
end
|
|
|
|
--- If a line is selected in visual mode, get the all the needed marks
|
|
--- from selected lines to highlight them during operations like rebase.
|
|
--- Otherwise, get the mark from the current line.
|
|
--- @return {line: uinteger, col: uinteger, end_line: uinteger, end_col: uinteger}[]|nil List of marks
|
|
local function get_highlight_marks()
|
|
local marks = {}
|
|
local mode = vim.fn.mode()
|
|
local buf = terminal.state.buf
|
|
if not buf then
|
|
utils.notify("No open log buffer", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
if mode == "v" or mode == "V" then
|
|
-- Visual mode: get selected lines
|
|
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)
|
|
for i, line in ipairs(lines) do
|
|
-- If the current line has a revset highlight it with the following one (which is the description)
|
|
if parser.get_revset(line) then
|
|
-- Compute the actual line number
|
|
local mark_lstart = start_line + i - 2 -- marks expect 0 api since, start-line and ipars are both 1 indexed we need to remove 2 to transform to 0-index (For future self)
|
|
local mark_lend = start_line + i - 1 -- We highlight start + description so we actually want to stop at the next line included
|
|
local next_line = lines[i + 1] -- Get the next line contents
|
|
if not next_line then
|
|
-- Only fetch if it's the last selected line and has a revset
|
|
local next_line_num = start_line + i
|
|
next_line = vim.api.nvim_buf_get_lines(buf, next_line_num - 1, next_line_num, false)[1] or ""
|
|
end
|
|
|
|
table.insert(marks, {
|
|
line = mark_lstart,
|
|
col = 0, -- Maybe at some point will make the parser say where the data starts but one thing at the time
|
|
end_line = mark_lend,
|
|
end_col = #next_line,
|
|
})
|
|
end
|
|
end
|
|
else
|
|
--
|
|
-- Normal mode: current or previous line
|
|
local revset_line, rev = get_revset_line()
|
|
if rev then
|
|
-- Get the next line (description)
|
|
local next_line = vim.api.nvim_buf_get_lines(buf, revset_line + 1, revset_line + 2, false)[1] or ""
|
|
table.insert(marks, {
|
|
line = revset_line,
|
|
col = 0,
|
|
end_line = revset_line + 1,
|
|
end_col = #next_line,
|
|
})
|
|
end
|
|
end
|
|
|
|
return marks
|
|
end
|
|
|
|
--- Apply highlight to target revision
|
|
local function apply_target_highlight(buf, revset_line, hl_group)
|
|
vim.api.nvim_buf_set_extmark(
|
|
buf,
|
|
log_special_mode_target_ns_id,
|
|
revset_line,
|
|
0,
|
|
{ end_line = revset_line + HIGHLIGHT_RANGE, end_col = 0, hl_group = hl_group }
|
|
)
|
|
last_rebase_target_line = revset_line
|
|
end
|
|
|
|
--- Clear previous target highlight
|
|
local function clear_target_highlight(buf)
|
|
if last_rebase_target_line ~= nil then
|
|
vim.api.nvim_buf_clear_namespace(
|
|
buf,
|
|
log_special_mode_target_ns_id,
|
|
last_rebase_target_line,
|
|
last_rebase_target_line + HIGHLIGHT_RANGE
|
|
)
|
|
end
|
|
last_rebase_target_line = nil
|
|
end
|
|
|
|
--- Update rebase target highlight on cursor movement
|
|
local function update_special_mode_target_highlight()
|
|
local buf = terminal.state.buf
|
|
if not buf then
|
|
return
|
|
end
|
|
|
|
clear_target_highlight(buf)
|
|
|
|
local revset_line, rev = get_revset_line()
|
|
if not rev then
|
|
return
|
|
end
|
|
|
|
-- 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_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("<Esc>", 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
|
|
|
|
--- Build the jj log command string from options
|
|
--- @param opts? jj.cmd.log_opts Optional command options
|
|
--- @return string The full jj log command
|
|
function M.build_log_cmd(opts)
|
|
local jj_cmd = "jj log --no-pager"
|
|
local merged_opts = vim.tbl_extend("force", default_log_opts, opts or {})
|
|
|
|
if merged_opts.raw_flags then
|
|
-- Strip --no-pager from raw_flags since it's already in the base command
|
|
local flags = vim.trim(merged_opts.raw_flags:gsub("%-%-no%-pager", ""):gsub("%s+", " "))
|
|
if flags ~= "" then
|
|
return string.format("%s %s", jj_cmd, flags)
|
|
end
|
|
return jj_cmd
|
|
end
|
|
|
|
for key, value in pairs(merged_opts) do
|
|
key = key:gsub("_", "-")
|
|
if key == "limit" and value then
|
|
jj_cmd = string.format("%s --%s %d", jj_cmd, key, value)
|
|
elseif key == "revisions" and value then
|
|
jj_cmd = string.format("%s --%s %s", jj_cmd, key, value)
|
|
elseif value then
|
|
jj_cmd = string.format("%s --%s", jj_cmd, key)
|
|
end
|
|
end
|
|
|
|
return jj_cmd
|
|
end
|
|
|
|
--- Jujutsu log
|
|
--- @param opts? jj.cmd.log_opts Optional command options
|
|
function M.log(opts)
|
|
if not utils.ensure_jj() then
|
|
return
|
|
end
|
|
|
|
-- If a log was already being displayed before this command we will want to maintain the cursor position
|
|
if terminal.is_log_buffer_open() then
|
|
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_special_mode_target_ns_id, 0, -1)
|
|
end
|
|
|
|
terminal.run(M.build_log_cmd(opts), M.log_keymaps())
|
|
end
|
|
|
|
---
|
|
--- Create a new change relative to the revision under the cursor in a jj log buffer.
|
|
--- Behavior:
|
|
--- flag == nil -> branch off the current revision
|
|
--- flag == "after" -> create a new change after the current revision (-A)
|
|
--- If ignore_immut is true, adds --ignore-immutable to the command.
|
|
--- Silently returns if no revision is found or the jj command fails.
|
|
--- On success, notifies and refreshes the log buffer.
|
|
--- @param flag? 'after' Position relative to the current revision; nil to branch off.
|
|
--- @param ignore_immut? boolean Pass --ignore-immutable to jj when true.
|
|
function M.handle_log_new(flag, ignore_immut)
|
|
local revsets = extract_revsets_from_terminal_buffer()
|
|
if not revsets then
|
|
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
|
|
|
|
-- Mapping for flag-specific options and messages.
|
|
local flag_map = {
|
|
after = {
|
|
opt = "-A",
|
|
err = "Error creating new change after: `%s`",
|
|
ok = is_multiple and "Successfully created merge change after: `%s`"
|
|
or "Successfully created change after: `%s`",
|
|
},
|
|
default = {
|
|
opt = "",
|
|
err = "Error creating new change branching off `%s`",
|
|
ok = is_multiple and "Successfully created merge change from: `%s`"
|
|
or "Successfully created change branching off `%s`",
|
|
},
|
|
}
|
|
|
|
local cfg = flag_map[flag] or flag_map.default
|
|
|
|
-- Build command parts
|
|
local cmd_parts = { "jj", "new" }
|
|
if cfg.opt ~= "" then
|
|
-- For -A flag, each revset needs its own -A prefix
|
|
for rev in revsets:gmatch("%S+") do
|
|
table.insert(cmd_parts, cfg.opt)
|
|
table.insert(cmd_parts, rev)
|
|
end
|
|
else
|
|
table.insert(cmd_parts, revsets)
|
|
end
|
|
if ignore_immut then
|
|
table.insert(cmd_parts, "--ignore-immutable")
|
|
end
|
|
|
|
local cmd = table.concat(cmd_parts, " ")
|
|
runner.execute_command_async(cmd, function()
|
|
utils.notify(string.format(cfg.ok, revsets), vim.log.levels.INFO)
|
|
-- Refresh the log buffer after creating the change.
|
|
require("jj.cmd").log()
|
|
end, string.format(cfg.err, revsets))
|
|
end
|
|
|
|
--- Handle diffing a log line
|
|
function M.handle_log_diff()
|
|
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 not is_multiple then
|
|
diff.show_revision({ rev = revsets })
|
|
else
|
|
-- Get the first and the last revset to diff
|
|
local list = vim.split(revsets, "%s+", { trimempty = true })
|
|
diff.diff_revisions({ left = list[1], right = list[#list] })
|
|
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
|
|
terminal.store_cursor_position()
|
|
local revset = get_revset()
|
|
if revset then
|
|
require("jj.cmd").describe(nil, revset, nil, function()
|
|
-- Execute log
|
|
M.log()
|
|
-- Restore previous cursor pos
|
|
terminal.restore_cursor_position()
|
|
end)
|
|
else
|
|
utils.notify("No valid revision found in the log line", vim.log.levels.ERROR)
|
|
end
|
|
end
|
|
|
|
--- Handle keypress edit on `jj log` buffer to edit a revision.
|
|
--- If ignore_immut is true, adds --ignore-immutable to the command.
|
|
--- Silently returns if no revision is found or the jj command fails.
|
|
--- On success, notifies and refreshes the log buffer.
|
|
--- @param ignore_immut? boolean Pass --ignore-immutable to jj edit when true.
|
|
--- @param close_on_exit? boolean Close the log buffer after editing when true.
|
|
function M.handle_log_edit(ignore_immut, close_on_exit)
|
|
local revset = get_revset()
|
|
if not revset or revset == "" then
|
|
return
|
|
end
|
|
|
|
-- If we found a revision, edit it.
|
|
|
|
-- Build command parts.
|
|
local cmd_parts = { "jj", "edit" }
|
|
if ignore_immut then
|
|
table.insert(cmd_parts, "--ignore-immutable")
|
|
end
|
|
|
|
table.insert(cmd_parts, revset)
|
|
|
|
-- Build cmd string
|
|
local cmd = table.concat(cmd_parts, " ")
|
|
|
|
-- Try to execute cmd
|
|
runner.execute_command_async(cmd, function()
|
|
utils.reload_changed_file_buffers()
|
|
|
|
-- Close the terminal buffer
|
|
if close_on_exit then
|
|
utils.notify(string.format("Editing change: `%s`", revset), vim.log.levels.INFO)
|
|
terminal.close_terminal_buffer()
|
|
else
|
|
M.log({})
|
|
end
|
|
end, "Error editing change")
|
|
end
|
|
|
|
--- Handle abandon `jj log` buffer.
|
|
--- If ignore_immut is true, adds --ignore-immutable to the command.
|
|
--- Silently returns if no revision is found or the jj command fails.
|
|
--- On success, notifies and refreshes the log buffer.
|
|
--- @param ignore_immut? boolean Pass --ignore-immutable to jj abandon when true.
|
|
function M.handle_log_abandon(ignore_immut)
|
|
local revsets = extract_revsets_from_terminal_buffer()
|
|
if not revsets then
|
|
return
|
|
end
|
|
|
|
-- Delete the pipes from the revsets string since jj abandon expects space separated revsets
|
|
revsets = revsets:gsub("| ", "")
|
|
|
|
-- If we found revision(s), abandon it.
|
|
|
|
-- Build command parts.
|
|
local cmd_parts = { "jj", "abandon" }
|
|
if ignore_immut then
|
|
table.insert(cmd_parts, "--ignore-immutable")
|
|
end
|
|
|
|
table.insert(cmd_parts, revsets)
|
|
|
|
-- Build cmd string
|
|
local cmd = table.concat(cmd_parts, " ")
|
|
|
|
-- Try to execute cmd
|
|
runner.execute_command_async(cmd, function()
|
|
local text = "Abandoned change: `%s`"
|
|
if revsets:find(" ", 1) then
|
|
text = "Abandoned changes: `%s`"
|
|
end
|
|
utils.notify(string.format(text, revsets), vim.log.levels.INFO)
|
|
M.log({})
|
|
end, "Error abandoning change")
|
|
end
|
|
|
|
--- Handle fetching from `jj log` buffer.
|
|
function M.handle_log_fetch()
|
|
local remotes = utils.get_remotes()
|
|
if not remotes or #remotes == 0 then
|
|
utils.notify("No git remotes found to fetch from", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
if #remotes > 1 then
|
|
-- Prompt to select a remote
|
|
vim.ui.select(remotes, {
|
|
prompt = "Select remote to fetch from: ",
|
|
format_item = function(item)
|
|
return string.format("%s (%s)", item.name, item.url)
|
|
end,
|
|
}, function(choice)
|
|
if choice then
|
|
local cmd = string.format("jj git fetch --remote %s", choice.name)
|
|
runner.execute_command_async(cmd, function()
|
|
utils.notify(string.format("Fetching from %s...", choice), vim.log.levels.INFO)
|
|
M.log({})
|
|
end, "Error fetching from remote")
|
|
end
|
|
end)
|
|
else
|
|
-- Only one remote, fetch from it directly
|
|
local cmd = "jj git fetch"
|
|
utils.notify("Fetching from remote...", vim.log.levels.INFO)
|
|
runner.execute_command_async(cmd, function()
|
|
utils.notify("Successfully fetched from remote", vim.log.levels.INFO)
|
|
M.log({})
|
|
end, "Error fetching from remote")
|
|
end
|
|
end
|
|
|
|
--- Handle pushing from `jj log` buffer.
|
|
--- Ask the user which local bookmark to push.
|
|
function M.handle_log_push_from_all()
|
|
local bookmarks = utils.get_all_bookmarks_with_status()
|
|
local cmd = "jj git push"
|
|
if not bookmarks or #bookmarks == 0 then
|
|
utils.notify("No bookmarks found to push", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
vim.ui.select(bookmarks, {
|
|
prompt = "Select bookmark to push: ",
|
|
format_item = function(item)
|
|
if item.is_deleted then
|
|
return item.name .. " (deleted)"
|
|
end
|
|
return item.name
|
|
end,
|
|
}, function(choice)
|
|
if choice then
|
|
local push_cmd = string.format("%s -b %s", cmd, choice.name)
|
|
utils.notify(string.format("Pushing bookmark `%s`...", choice.name), vim.log.levels.INFO)
|
|
runner.execute_command_async(push_cmd, function(output)
|
|
if output and string.find(output, "Nothing changed%.") then
|
|
utils.notify("Nothing changed.", vim.log.levels.INFO)
|
|
else
|
|
utils.notify(string.format("Successfully pushed bookmark `%s`", choice.name), vim.log.levels.INFO)
|
|
M.log({})
|
|
end
|
|
end, string.format("Error pushing bookmark `%s`", choice.name))
|
|
else
|
|
return
|
|
end
|
|
end)
|
|
end
|
|
|
|
--- Handle log pushing bookmark from current line in `jj log` buffer.
|
|
function M.handle_log_push_bookmark()
|
|
local revset = get_revset()
|
|
if not revset or revset == "" then
|
|
return
|
|
end
|
|
|
|
local bookmarks = utils.get_bookmarks_for_rev(revset)
|
|
if not bookmarks or #bookmarks == 0 then
|
|
utils.notify("No bookmark found for revision", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
-- Function to push the bookmark, takes the full command as argument
|
|
local function push(cmd)
|
|
runner.execute_command_async(cmd, function(output)
|
|
if output and string.find(output, "Nothing changed%.") then
|
|
utils.notify("Nothing changed.", vim.log.levels.INFO)
|
|
else
|
|
utils.notify(string.format("Successfully pushed bookmark for `%s`", revset), vim.log.levels.INFO)
|
|
M.log({})
|
|
end
|
|
end, string.format("Error pushing bookmark for `%s`", revset))
|
|
end
|
|
|
|
-- If there are multiple bookmarks user must choose
|
|
if #bookmarks > 1 then
|
|
table.insert(bookmarks, { name = "[All]", is_deleted = false })
|
|
|
|
vim.ui.select(bookmarks, {
|
|
prompt = "Which bookmark do you want to push?",
|
|
format_item = function(item)
|
|
if item.is_deleted then
|
|
return item.name .. " (deleted)"
|
|
end
|
|
return item.name
|
|
end,
|
|
}, function(choice)
|
|
if choice then
|
|
local cmd = "jj git push"
|
|
if choice.name == "[All]" then
|
|
-- Push all bookmarks
|
|
cmd = string.format("%s --all", cmd)
|
|
utils.notify("Pushing `ALL` bookmarks", vim.log.levels.INFO)
|
|
else
|
|
utils.notify(string.format("Pushing bookmark `%s`...", choice.name), vim.log.levels.INFO)
|
|
cmd = string.format("%s -b %s", cmd, choice.name)
|
|
end
|
|
push(cmd)
|
|
else
|
|
return
|
|
end
|
|
end)
|
|
else
|
|
-- If there's only one bookmark simply push it
|
|
local b = bookmarks[1].name
|
|
local cmd = string.format("jj git push -b %s", b)
|
|
utils.notify(string.format("Pushing bookmark `%s`...", b), vim.log.levels.INFO)
|
|
push(cmd)
|
|
end
|
|
end
|
|
|
|
--- Handle seting a tag from `jj log` buffer for the revision under cursor
|
|
--- @param revset? string Revset to set the tag on, if not provided it will do nothing.
|
|
function M.handle_log_tag_set(revset)
|
|
if not revset or revset == "" then
|
|
revset = revset or get_revset()
|
|
if not revset or revset == "" then
|
|
return
|
|
end
|
|
end
|
|
|
|
require("jj.cmd").tag_set(revset)
|
|
end
|
|
|
|
--- Handle log change revset
|
|
function M.handle_log_change_revset()
|
|
vim.ui.input({ prompt = "Enter new revset: " }, function(input)
|
|
if input and input ~= "" then
|
|
M.log({ revisions = input })
|
|
end
|
|
end)
|
|
end
|
|
|
|
--- Handle opening a PR/MR from `jj log` buffer for the revision under cursor
|
|
--- @param list_bookmarks? boolean If true, prompt to select from all bookmarks instead of using current revision
|
|
function M.handle_log_open_pr(list_bookmarks)
|
|
if list_bookmarks then
|
|
-- Get all bookmarks
|
|
local bookmarks = utils.get_all_bookmarks()
|
|
|
|
if #bookmarks == 0 then
|
|
utils.notify("No bookmarks found", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
-- Prompt to select a bookmark
|
|
vim.ui.select(bookmarks, {
|
|
prompt = "Select bookmark to open PR for: ",
|
|
}, function(choice)
|
|
if choice then
|
|
utils.open_pr_for_bookmark(choice)
|
|
end
|
|
end)
|
|
-- Return early
|
|
return
|
|
end
|
|
|
|
-- Default behavior: parse revision and open PR
|
|
local revset = get_revset()
|
|
if not revset or revset == "" then
|
|
return
|
|
end
|
|
|
|
-- Get the bookmark for this revision
|
|
local bookmarks = utils.get_bookmarks_for_rev(revset)
|
|
if not bookmarks or #bookmarks == 0 then
|
|
utils.notify("[OPEN PR] No bookmark found for revision", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
if #bookmarks > 1 then
|
|
vim.ui.select(bookmarks, {
|
|
prompt = "Which bookmark do you want to open PR for?",
|
|
format_item = function(item)
|
|
if item.is_deleted then
|
|
return item.name .. " (deleted)"
|
|
end
|
|
return item.name
|
|
end,
|
|
}, function(choice)
|
|
if choice then
|
|
utils.open_pr_for_bookmark(choice.name)
|
|
end
|
|
end)
|
|
else
|
|
-- Open the PR using the utility function
|
|
utils.open_pr_for_bookmark(bookmarks[1].name)
|
|
end
|
|
end
|
|
|
|
function M.handle_log_bookmark_del()
|
|
local revset = get_revset()
|
|
if not revset or revset == "" then
|
|
return
|
|
end
|
|
|
|
local curr_bookmarks = utils.get_bookmarks_for_rev(revset)
|
|
if not curr_bookmarks or #curr_bookmarks == 0 then
|
|
utils.notify("No bookmark found for revision", vim.log.levels.ERROR)
|
|
return
|
|
end
|
|
|
|
---@param b_names string[] Bookmark names to delete
|
|
local function delete_bookmarks(b_names)
|
|
local escaped_list = {}
|
|
for _, b in ipairs(b_names) do
|
|
table.insert(escaped_list, vim.fn.shellescape(b))
|
|
end
|
|
local escaped_bookmarks = table.concat(escaped_list, " ")
|
|
local cmd = string.format("jj bookmark delete %s", escaped_bookmarks)
|
|
local b_str = table.concat(b_names, " ")
|
|
runner.execute_command_async(cmd, function()
|
|
utils.notify(string.format("Deleted bookmark `%s`", b_str), vim.log.levels.INFO)
|
|
M.log({})
|
|
end, string.format("Error deleting bookmark `%s`", b_str))
|
|
end
|
|
|
|
table.insert(curr_bookmarks, { name = "[All]", is_deleted = false })
|
|
|
|
vim.ui.select(curr_bookmarks, {
|
|
prompt = "Which bookmark do you want to delete?",
|
|
format_item = function(item)
|
|
if item.is_deleted then
|
|
return item.name .. " (deleted)"
|
|
end
|
|
return item.name
|
|
end,
|
|
}, function(choice)
|
|
if choice then
|
|
if choice.name == "[All]" then
|
|
local all_names = vim.iter(curr_bookmarks)
|
|
:filter(function(b)
|
|
return b.name ~= "[All]"
|
|
end)
|
|
:map(function(b)
|
|
return b.name
|
|
end)
|
|
:totable()
|
|
delete_bookmarks(all_names)
|
|
else
|
|
delete_bookmarks({ choice.name })
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
-- Create or move bookmark at revision under cursor in `jj log` buffer
|
|
function M.handle_log_bookmark()
|
|
local revset = get_revset()
|
|
if not revset or revset == "" then
|
|
return
|
|
end
|
|
|
|
-- Get all bookmarks
|
|
local bookmarks = utils.get_all_bookmarks()
|
|
table.insert(bookmarks, 1, "[Create new]")
|
|
-- Prompt to select or create a bookmark
|
|
vim.ui.select(bookmarks, {
|
|
prompt = "Select a bookmark to move or create a new: ",
|
|
}, function(choice)
|
|
if choice then
|
|
if choice == "[Create new]" then
|
|
local prefix = require("jj.cmd").config.bookmark.prefix or ""
|
|
-- Prompt for new bookmark name
|
|
vim.ui.input({ prompt = "Enter new bookmark name: ", default = prefix }, function(input)
|
|
if input and input ~= "" then
|
|
local cmd = string.format("jj bookmark create %s -r %s", input, revset)
|
|
runner.execute_command_async(cmd, function()
|
|
utils.notify(
|
|
string.format("Created bookmark `%s` at `%s`", input, revset),
|
|
vim.log.levels.INFO
|
|
)
|
|
M.log({})
|
|
end, "Error creating bookmark")
|
|
end
|
|
end)
|
|
else
|
|
-- Move existing bookmark to the revision
|
|
local cmd = string.format("jj bookmark move %s --to %s -B", choice, revset)
|
|
runner.execute_command_async(cmd, function()
|
|
utils.notify(string.format("Moved bookmark `%s` to `%s`", choice, revset), vim.log.levels.INFO)
|
|
M.log({})
|
|
end, "Error moving bookmark")
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
--- Rebase bookmark(s)
|
|
function M.handle_log_rebase()
|
|
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
|
|
|
|
-- Set highlights
|
|
setup_selected_highlights()
|
|
|
|
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_squash_revsets = revsets_str
|
|
-- Set highlights
|
|
setup_selected_highlights()
|
|
|
|
M.transition_mode("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()
|
|
if not revset or revset == "" then
|
|
return
|
|
end
|
|
|
|
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
|
|
|
|
--- Handle log split
|
|
function M.handle_log_split()
|
|
local revset = get_revset()
|
|
if not revset or revset == "" then
|
|
return
|
|
end
|
|
|
|
require("jj.cmd").split({
|
|
rev = revset,
|
|
on_exit = function(exit_code)
|
|
if exit_code == 0 then
|
|
utils.notify(string.format("Successfully split `%s`", revset), vim.log.levels.INFO)
|
|
vim.schedule(function()
|
|
M.log({})
|
|
end)
|
|
else
|
|
utils.notify(string.format("Cancelled splitting `%s`", revset), vim.log.levels.WARN)
|
|
-- Since we previously replaced the floating with the split 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,
|
|
})
|
|
end
|
|
|
|
--- Handle log resolve conflict
|
|
function M.handle_log_resolve()
|
|
local revset = get_revset()
|
|
if not revset or revset == "" then
|
|
return
|
|
end
|
|
|
|
if not utils.is_change_conflicted(revset) then
|
|
utils.notify(string.format("Revision `%s` has no conflicts to resolve", revset), vim.log.levels.INFO)
|
|
return
|
|
end
|
|
|
|
local exit_func = function(exit_code)
|
|
if exit_code == 0 then
|
|
utils.notify(string.format("Successfully resolved `%s`", revset), vim.log.levels.INFO)
|
|
vim.schedule(function()
|
|
M.log({})
|
|
end)
|
|
else
|
|
-- Since we previously replaced the floating with the resolve 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
|
|
|
|
local strategies = require("jj.cmd").config.resolve_strategies
|
|
if strategies and #strategies > 1 then
|
|
vim.ui.select(strategies, {
|
|
prompt = "Select a resolve strategy: ",
|
|
format_item = function(item)
|
|
return item.name
|
|
end,
|
|
}, function(choice)
|
|
if choice then
|
|
require("jj.cmd").resolve({
|
|
rev = revset,
|
|
args = choice.args,
|
|
external = choice.external,
|
|
on_exit = exit_func,
|
|
})
|
|
end
|
|
end)
|
|
elseif strategies and #strategies == 1 then
|
|
local choice = strategies[1]
|
|
require("jj.cmd").resolve({ rev = revset, args = choice.args, external = choice.external, on_exit = exit_func })
|
|
else
|
|
-- Simply resolve with the default
|
|
require("jj.cmd").resolve({ rev = revset, on_exit = exit_func })
|
|
end
|
|
end
|
|
|
|
--- Handle diff action in summary tooltip
|
|
--- Diffs the file at revset against its parent (revset-)
|
|
--- Opens a floating diff, and returns focus to tooltip when closed
|
|
--- @param revset string The revision being viewed
|
|
function M.handle_summary_diff(revset)
|
|
local line = vim.api.nvim_get_current_line()
|
|
local filepath = parser.parse_file_info_from_status_line(line)
|
|
if not filepath then
|
|
utils.notify("No file found on this line", vim.log.levels.WARN)
|
|
return
|
|
end
|
|
|
|
-- Keep the tooltip open
|
|
if terminal.state.tooltip_buf and vim.api.nvim_buf_is_valid(terminal.state.tooltip_buf) then
|
|
terminal.keep_tooltip_open(true)
|
|
end
|
|
|
|
-- Use the diff module to show what changed in this revision for this file
|
|
diff.show_revision({
|
|
rev = revset,
|
|
path = filepath.new_path,
|
|
})
|
|
end
|
|
|
|
--- Handle edit action in summary tooltip (opens file after jj edit)
|
|
--- Closes tooltip and log buffer, edits the revision, and opens the file
|
|
--- @param revset string The revision to edit
|
|
--- @param ignore_immut boolean Whether to ignore immutability
|
|
function M.handle_summary_edit(revset, ignore_immut)
|
|
-- Before anything check if the revset is immutable and the ignore_immut flag is set
|
|
if utils.is_change_immutable(revset) and not ignore_immut then
|
|
utils.notify(
|
|
string.format("The change `%s` is immutable, use the `edit_immutable` shortcut.", revset),
|
|
vim.log.levels.WARN
|
|
)
|
|
return
|
|
end
|
|
|
|
local line = vim.api.nvim_get_current_line()
|
|
local filepath = parser.parse_file_info_from_status_line(line)
|
|
if not filepath then
|
|
utils.notify("No file found on this line", vim.log.levels.WARN)
|
|
return
|
|
end
|
|
|
|
-- Close the tooltip first
|
|
if terminal.state.tooltip_buf and vim.api.nvim_buf_is_valid(terminal.state.tooltip_buf) then
|
|
terminal.close_tooltip()
|
|
end
|
|
|
|
-- Close the log buffer
|
|
terminal.close_terminal_buffer()
|
|
|
|
local cmd = string.format("jj edit %s", revset)
|
|
if ignore_immut then
|
|
cmd = cmd .. " --ignore-immutable"
|
|
end
|
|
|
|
runner.execute_command_async(cmd, function()
|
|
utils.reload_changed_file_buffers()
|
|
utils.notify(string.format("Editing revset: `%s`", revset), vim.log.levels.INFO)
|
|
-- Open the file in the current window
|
|
vim.cmd("edit " .. vim.fn.fnameescape(filepath.new_path))
|
|
end, string.format("Error editing revset: `%s`", revset))
|
|
end
|
|
|
|
--- Handle edit action in summary tooltip but only for the file (like Jedit)
|
|
--- Closes tooltip, edits the revision, and opens the file
|
|
--- @param revset string The revision to edit
|
|
function M.handle_summary_file_edit(revset)
|
|
local line = vim.api.nvim_get_current_line()
|
|
local filepath = parser.parse_file_info_from_status_line(line)
|
|
if not filepath then
|
|
utils.notify("No file found on this line", vim.log.levels.WARN)
|
|
return
|
|
end
|
|
|
|
-- Close the tooltip first
|
|
if terminal.state.tooltip_buf and vim.api.nvim_buf_is_valid(terminal.state.tooltip_buf) then
|
|
terminal.close_tooltip()
|
|
end
|
|
|
|
require("jj.file").open_target({ path = filepath.new_path, rev = revset, split = "tab" })
|
|
end
|
|
|
|
--- Get keymaps for summary tooltip
|
|
--- @param revset string The revision being viewed
|
|
--- @return jj.core.buffer.keymap[]
|
|
function M.summary_keymaps(revset)
|
|
local cmd = require("jj.cmd")
|
|
local keymaps = cmd.config.keymaps.log.summary_tooltip or {}
|
|
local specs = {
|
|
diff = {
|
|
modes = { "n" },
|
|
handler = M.handle_summary_diff,
|
|
args = { revset },
|
|
desc = "Diff file at this revision",
|
|
},
|
|
edit = {
|
|
modes = { "n" },
|
|
handler = M.handle_summary_edit,
|
|
args = { revset, false },
|
|
desc = "Edit revision and open file",
|
|
},
|
|
edit_immutable = {
|
|
modes = { "n" },
|
|
handler = M.handle_summary_edit,
|
|
args = { revset, true },
|
|
opts = { desc = "Edit revision (ignore immutability) and open file" },
|
|
},
|
|
edit_file = {
|
|
modes = { "n" },
|
|
handler = M.handle_summary_file_edit,
|
|
args = { revset, true },
|
|
opts = { desc = "Read revision in buffer like `Jedit`" },
|
|
},
|
|
}
|
|
return cmd.resolve_keymaps_from_specs(keymaps, specs)
|
|
end
|
|
|
|
--- Build the summary command for a revset
|
|
--- @param revset string The revision to show summary for
|
|
--- @return string The jj log command
|
|
local function build_summary_cmd(revset)
|
|
local template = '"Commit ID: " ++ commit_id ++ "\\n" '
|
|
.. '++ "Change ID: " ++ change_id ++ "\\n" '
|
|
.. '++ "Author: " ++ author ++ " (" ++ author.timestamp() ++ ")\\n" '
|
|
.. '++ "Committer: " ++ committer ++ " (" ++ committer.timestamp() ++ ")\\n\\n" '
|
|
.. "++ description "
|
|
.. '++ "\\n" ++ self.diff().summary()'
|
|
|
|
return string.format("jj log -r %s --no-graph -T '%s'", revset, template)
|
|
end
|
|
|
|
--- Handle showing summary tooltip for revision under cursor
|
|
--- First K shows tooltip without entering, second K enters the tooltip
|
|
function M.handle_log_summary()
|
|
-- If tooltip exists and is valid, enter it on second K
|
|
if terminal.state.tooltip_buf and vim.api.nvim_buf_is_valid(terminal.state.tooltip_buf) then
|
|
local revset = vim.b[terminal.state.tooltip_buf].jj_summary_revset
|
|
if revset and terminal.state.tooltip_buf and vim.api.nvim_win_is_valid(terminal.state.tooltip_win) then
|
|
-- Store the cursor position
|
|
terminal.store_cursor_position()
|
|
-- Enter the tooltip window and set up keymaps
|
|
vim.api.nvim_set_current_win(terminal.state.tooltip_win)
|
|
buffer.set_keymaps(terminal.state.tooltip_buf, M.summary_keymaps(revset))
|
|
return
|
|
end
|
|
end
|
|
|
|
local revset = get_revset()
|
|
if not revset or revset == "" then
|
|
return
|
|
end
|
|
|
|
local cmd = build_summary_cmd(revset)
|
|
|
|
-- Run command synchronously first to calculate dimensions
|
|
local output, success = runner.execute_command(cmd, nil, nil, false)
|
|
if not success or not output then
|
|
return
|
|
end
|
|
|
|
-- Calculate dimensions based on output
|
|
local lines = vim.split(output, "\n", { trimempty = false })
|
|
local max_width = 0
|
|
for _, line in ipairs(lines) do
|
|
max_width = math.max(max_width, vim.fn.strdisplaywidth(line))
|
|
end
|
|
local width = math.min(max_width + 2, vim.o.columns - 4)
|
|
local height = #lines
|
|
|
|
local buf, _ = terminal.run_tooltip(cmd, {
|
|
title = string.format(" Summary: %s ", revset),
|
|
enter = false,
|
|
width = width,
|
|
height = height,
|
|
})
|
|
|
|
if buf then
|
|
vim.b[terminal.state.tooltip_buf].jj_summary_revset = revset
|
|
end
|
|
end
|
|
|
|
--- Move cursor to the next or previous revision line in the log buffer.
|
|
--- @param direction integer 1 for next, -1 for previous
|
|
local function select_revision(direction)
|
|
local buf = terminal.state.buf or vim.api.nvim_get_current_buf()
|
|
local total_lines = vim.api.nvim_buf_line_count(buf)
|
|
|
|
-- Start scanning from the line after/before the current revision line
|
|
local revset_line, _ = get_revset_line()
|
|
local start = revset_line + 1 + direction -- revset_line is 0-indexed; convert to 1-indexed then step
|
|
|
|
local line = start
|
|
while line >= 1 and line <= total_lines do
|
|
local content = vim.api.nvim_buf_get_lines(buf, line - 1, line, false)[1]
|
|
if parser.get_revset(content) then
|
|
buffer.set_cursor(buf, { line, 0 })
|
|
return
|
|
end
|
|
line = line + direction
|
|
end
|
|
end
|
|
|
|
--- Move cursor to the next revision in the log buffer
|
|
function M.handle_log_select_next_revision()
|
|
select_revision(1)
|
|
end
|
|
|
|
--- Move cursor to the previous revision in the log buffer
|
|
function M.handle_log_select_prev_revision()
|
|
select_revision(-1)
|
|
end
|
|
|
|
--- Resolve log keymaps from config, filtering out nil values
|
|
--- @return jj.core.buffer.keymap[]
|
|
function M.log_keymaps()
|
|
local cmd = require("jj.cmd")
|
|
|
|
-- Reduce repetition by declaring a specification table.
|
|
-- Each entry maps the config key name to:
|
|
-- desc: Keybind description
|
|
-- handler: function to call
|
|
-- args: optional list of arguments passed to handler
|
|
local keymaps = cmd.config.keymaps.log or {}
|
|
local close_on_edit = cmd.config.log.close_on_edit or false
|
|
|
|
--- @type jj.cmd.keymap_specs
|
|
local specs = {
|
|
edit = {
|
|
desc = "Checkout revision under cursor",
|
|
handler = M.handle_log_edit,
|
|
args = { false, close_on_edit },
|
|
modes = { "n" },
|
|
},
|
|
edit_immutable = {
|
|
desc = "Checkout revision under cursor (ignores immutability)",
|
|
handler = M.handle_log_edit,
|
|
args = { true, close_on_edit },
|
|
modes = { "n" },
|
|
},
|
|
describe = {
|
|
desc = "Describe revision under cursor",
|
|
handler = M.handle_log_describe,
|
|
modes = { "n" },
|
|
},
|
|
diff = {
|
|
desc = "Diff revision under cursor",
|
|
handler = M.handle_log_diff,
|
|
modes = { "n", "v" },
|
|
},
|
|
new = {
|
|
desc = "Create new change branching off revision under cursor",
|
|
handler = M.handle_log_new,
|
|
args = { nil, false },
|
|
modes = { "n", "v" },
|
|
},
|
|
new_after = {
|
|
desc = "Create new change after revision under cursor",
|
|
handler = M.handle_log_new,
|
|
args = { "after", false },
|
|
modes = { "n", "v" },
|
|
},
|
|
new_after_immutable = {
|
|
desc = "Create new change after revision under cursor (ignores immutability)",
|
|
handler = M.handle_log_new,
|
|
args = { "after", true },
|
|
modes = { "n", "v" },
|
|
},
|
|
undo = {
|
|
desc = "Undo last change",
|
|
handler = cmd.undo,
|
|
modes = { "n" },
|
|
},
|
|
redo = {
|
|
desc = "Redo last undone change",
|
|
handler = cmd.redo,
|
|
modes = { "n" },
|
|
},
|
|
abandon = {
|
|
desc = "Abandon revision under cursor",
|
|
handler = M.handle_log_abandon,
|
|
-- As of now i'm only exposing the non ignore-immutable version of abandon in the keymaps
|
|
-- Maybe in the future we can add another keymap for that, if people request it
|
|
args = { false },
|
|
modes = { "n", "v" },
|
|
},
|
|
fetch = {
|
|
desc = "Fetch from remote",
|
|
handler = M.handle_log_fetch,
|
|
modes = { "n" },
|
|
},
|
|
push_all = {
|
|
desc = "Push one of the local bookmarks to remote",
|
|
handler = M.handle_log_push_from_all,
|
|
modes = { "n" },
|
|
},
|
|
push = {
|
|
desc = "Push bookmark of revision under cursor to remote",
|
|
handler = M.handle_log_push_bookmark,
|
|
modes = { "n" },
|
|
},
|
|
open_pr = {
|
|
desc = "Open PR/MR for revision under cursor",
|
|
handler = M.handle_log_open_pr,
|
|
modes = { "n" },
|
|
},
|
|
open_pr_list = {
|
|
desc = "Open PR/MR by selecting from all bookmarks",
|
|
handler = M.handle_log_open_pr,
|
|
args = { true },
|
|
modes = { "n" },
|
|
},
|
|
bookmark = {
|
|
desc = "Create or move bookmark at revision under cursor",
|
|
handler = M.handle_log_bookmark,
|
|
modes = { "n" },
|
|
},
|
|
bookmark_del = {
|
|
desc = "Delete bookmark at revision under cursor",
|
|
handler = M.handle_log_bookmark_del,
|
|
modes = { "n" },
|
|
},
|
|
rebase = {
|
|
desc = "Rebase bookmark(s)",
|
|
handler = M.handle_log_rebase,
|
|
modes = { "n", "v" },
|
|
},
|
|
squash = {
|
|
desc = "Squash bookmark(s)",
|
|
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,
|
|
modes = { "n" },
|
|
},
|
|
summary = {
|
|
desc = "Show summary tooltip for revision under cursor",
|
|
handler = M.handle_log_summary,
|
|
modes = { "n" },
|
|
},
|
|
split = {
|
|
desc = "Split the revision under cursor",
|
|
handler = M.handle_log_split,
|
|
modes = { "n" },
|
|
},
|
|
tag_set = {
|
|
desc = "Set a tag under the current revset",
|
|
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" },
|
|
},
|
|
change_revset = {
|
|
desc = "Change the revset(s) being viewed",
|
|
handler = M.handle_log_change_revset,
|
|
modes = { "n", "v" },
|
|
},
|
|
select_next_revision = {
|
|
desc = "Move cursor to the next revision",
|
|
handler = M.handle_log_select_next_revision,
|
|
modes = { "n", "v" },
|
|
},
|
|
select_prev_revision = {
|
|
desc = "Move cursor to the previous revision",
|
|
handler = M.handle_log_select_prev_revision,
|
|
modes = { "n", "v" },
|
|
},
|
|
resolve = {
|
|
desc = "Resolve conflicts for revision under cursor",
|
|
handler = M.handle_log_resolve,
|
|
modes = { "n" },
|
|
},
|
|
}
|
|
|
|
local merged_keymaps = cmd.merge_keymaps(cmd.resolve_keymaps_from_specs(keymaps, specs), cmd.terminal_keymaps())
|
|
return apply_nowait_to_unique_keymaps(merged_keymaps)
|
|
end
|
|
|
|
--- Rebase mode keymaps
|
|
--- @return jj.core.buffer.keymap[]
|
|
function M.rebase_keymaps()
|
|
local cmd = require("jj.cmd")
|
|
local keymaps = cmd.config.keymaps.log.rebase_mode or {}
|
|
|
|
--- @type jj.cmd.keymap_specs
|
|
local spec = {
|
|
onto = {
|
|
desc = "Rebase onto (-O) the revision under cursor",
|
|
handler = M.handle_rebase_execute,
|
|
args = { "onto" },
|
|
modes = { "n" },
|
|
},
|
|
after = {
|
|
desc = "Rebase revset(s) after (-A) the revision under cursor",
|
|
handler = M.handle_rebase_execute,
|
|
args = { "after" },
|
|
modes = { "n" },
|
|
},
|
|
before = {
|
|
desc = "Rebase revset(s) before (-B) the revision under cursor",
|
|
handler = M.handle_rebase_execute,
|
|
args = { "before" },
|
|
modes = { "n" },
|
|
},
|
|
onto_immutable = {
|
|
desc = "Rebase onto (-O) the revision under cursor (ignores immutability)",
|
|
handler = M.handle_rebase_execute,
|
|
args = { "onto", true },
|
|
modes = { "n" },
|
|
},
|
|
after_immutable = {
|
|
desc = "Rebase revset(s) after (-A) the revision under cursor (ignores immutability)",
|
|
handler = M.handle_rebase_execute,
|
|
args = { "after", true },
|
|
modes = { "n" },
|
|
},
|
|
before_immutable = {
|
|
desc = "Rebase revset(s) before (-B) the revision under cursor (ignores immutability)",
|
|
handler = M.handle_rebase_execute,
|
|
args = { "before", true },
|
|
modes = { "n" },
|
|
},
|
|
exit_mode = {
|
|
desc = "Exit rebase to normal mode",
|
|
handler = M.handle_special_mode_exit,
|
|
args = { "Rebase" },
|
|
modes = { "n" },
|
|
},
|
|
}
|
|
|
|
return apply_nowait_to_unique_keymaps(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,
|
|
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" },
|
|
},
|
|
}
|
|
|
|
return apply_nowait_to_unique_keymaps(cmd.resolve_keymaps_from_specs(keymaps, spec))
|
|
end
|
|
|
|
--- Get keymaps for a specific mode
|
|
--- @param mode string Mode name
|
|
--- @return jj.core.buffer.keymap[]
|
|
function M.get_keymaps_for_mode(mode)
|
|
if mode == "normal" then
|
|
return M.log_keymaps()
|
|
elseif mode == "rebase" then
|
|
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"|"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
|
|
return
|
|
end
|
|
|
|
-- Get new keymaps for target mode
|
|
local new_keymaps = M.get_keymaps_for_mode(target_mode)
|
|
terminal.replace_terminal_keymaps(new_keymaps)
|
|
|
|
-- Set up or tear down rebase mode autocmd
|
|
if target_mode ~= "normal" then
|
|
rebase_mode_autocmd_id = vim.api.nvim_create_autocmd("CursorMoved", {
|
|
buffer = terminal.state.buf,
|
|
callback = update_special_mode_target_highlight,
|
|
})
|
|
-- Highlight initial position
|
|
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_special_mode_target_ns_id, 0, -1)
|
|
last_rebase_target_line = nil
|
|
end
|
|
|
|
-- Update buffer mode state
|
|
vim.b.jj_mode = target_mode
|
|
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)
|
|
-- 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
|
|
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)
|
|
|
|
utils.notify(string.format("%s `canceled`", mode), vim.log.levels.INFO, 500)
|
|
end
|
|
|
|
--- Handle rebase execution with mode
|
|
--- @param mode "onto" | "after" | "before" Rebase mode
|
|
--- @param ignore_immut boolean? Wether 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
|
|
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("Rebasing...", revsets, mode, destination_revset), vim.log.levels.INFO, 500)
|
|
local cmd = string.format("jj rebase -r '%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("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")
|
|
-- Refresh log
|
|
M.log({})
|
|
end, "Error during rebase.")
|
|
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 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
|