diff --git a/README.md b/README.md index 4d053a4..7391762 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - [Current Features](#current-features) - [Enhanced Integrations](#enhanced-integrations) - - [View change summary from the log buffer](#view-change-summary-from-the-log-buffer) + - [View change summary from the log buffer](view-change-summary-from-the-log-buffer) - [Diff any change](#diff-any-change) - [Diff revision history](#diff-revision-history) - [Change the log revset from the log buffer](#change-the-log-revset-from-the-log-buffer) @@ -86,8 +86,9 @@ - `:Jdiff [revision]` - Vertical split diff against a jj revision - `:Jhdiff [revision]` - Horizontal split diff - Picker for [Snacks.nvim](https://github.com/folke/snacks.nvim) - - `jj status` Displays the current changes diffs - - `jj file_history` Displays a buffer's history changes and allows to edit its change (including immutable changes) + - `picker.status()` displays the current changed files with live diff preview + - `picker.file_history()` displays the current buffer's revision history and lets you edit the selected change + - `picker.conflict()` lists conflicted revisions, previews their changes, and launches conflict resolution (with Snacks, or `vim.ui.select()` as a fallback) ## Enhanced integrations @@ -383,6 +384,35 @@ When using the [Snacks.nvim](https://github.com/folke/snacks.nvim) picker integr - `` - Open the selected file - `` - Open the selected file and run `Jdiff` +### Conflict picker (Snacks.nvim) + +`picker.conflict()` opens a picker for `jj log -r 'conflicts()'`, so you can resolve conflicted revisions without first navigating to them in the log buffer. + +When Snacks is enabled, it uses the Snacks picker UI. Otherwise, it falls back to a plain `vim.ui.select()` picker with the same conflicted revision list. + +Available actions in the Snacks picker: + +- `` - Resolve the selected conflicted revision +- `` - Run `jj edit` on the selected conflicted revision and refresh changed buffers + +What it shows: + +- The conflicted revision/change id +- The author +- The first line of the description +- A live `jj show --stat --git` preview for the selected conflict + +What happens on confirm: + +- `` resolves the selected conflicted revision +- If `cmd.resolve_strategies` contains multiple entries, `jj.nvim` prompts you to choose one +- The selected strategy's `args` and `external` options are forwarded to `cmd.resolve(...)` +- If no strategies are configured, it falls back to the default interactive `jj resolve` + +The fallback `vim.ui.select()` version supports selecting a conflicted revision to resolve, but does not provide the extra Snacks-only `` edit action. + +This makes it easy to keep a dedicated “show me all conflicts” picker bound to a keymap, especially when using the Snacks picker UI. + ## Installation Using [lazy.nvim](https://github.com/folke/lazy.nvim): @@ -1247,6 +1277,7 @@ vim.keymap.set("n", "jA", annotate.line, { desc = "JJ annotate line" }) local picker = require("jj.picker") vim.keymap.set("n", "gj", function() picker.status() end, { desc = "JJ Picker status" }) vim.keymap.set("n", "jgh", function() picker.file_history() end, { desc = "JJ Picker history" }) + vim.keymap.set("n", "jgc", function() picker.conflict() end, { desc = "JJ Picker conflicts" }) -- Some functions like `log` can take parameters vim.keymap.set("n", "jL", function() diff --git a/lua/jj/picker.lua b/lua/jj/picker.lua index dffb052..0fdefd6 100644 --- a/lua/jj/picker.lua +++ b/lua/jj/picker.lua @@ -8,19 +8,32 @@ local parser = require("jj.core.parser") --- @field snacks table|boolean The snacks config --- @class jj.picker.file +--- @field text string The text to display in the picker --- @field file string The current path of the file --- @field status string JJ-style status code (e.g. "M ", "R ") for picker formatting --- @field rename? string Previous path when this item is a rename --- @field diff_cmd string The command to get the diff of the file +--- @field confirm_action string The default picker action for the item --- @class jj.picker.log_line +--- @field text string The text to display in the picker --- @field symbol string The symbol of the log entry --- @field rev string The revision of the log entry --- @field author string The author of the log entry --- @field time string The time of the log entry --- @field commit_id string The commit id of the log entry --- @field description string The description of the log entry ---- @field diff_cmd string The command to get the diff of the file +--- @field preview_cmd string[] The command used to preview the item +--- @field confirm_action string The default picker action for the item + +--- @class jj.picker.conflict +--- @field text string The text to display in the picker +--- @field symbol string The symbol of the conflict entry +--- @field rev string The revision of the conflict entry +--- @field author string The author of the conflict entry +--- @field description string The description of the conflict entry +--- @field preview_cmd string[] The command used to preview the item +--- @field confirm_action string The default picker action for the item local M = { --- @type jj.picker.config @@ -29,6 +42,48 @@ local M = { }, } +--- Resolve a conflicted revision using configured strategies. +--- @param item jj.picker.conflict|nil +--- @param on_exit? fun(exit_code: number) +local function resolve_conflict(item, on_exit) + if not item or not item.rev then + return + end + + local strategies = require("jj.cmd").config.resolve_strategies or nil + if strategies and #strategies > 1 then + vim.ui.select(strategies, { + prompt = "Select a strategy to resolve the conflict", + format_item = function(choice) + return choice.name + end, + }, function(choice) + if not choice then + return + end + + require("jj.cmd").resolve({ + rev = item.rev, + args = choice.args, + external = choice.external, + on_exit = on_exit, + }) + end) + elseif strategies and #strategies == 1 then + local choice = strategies[1] + require("jj.cmd").resolve({ + rev = item.rev, + args = choice.args, + external = choice.external, + on_exit = on_exit, + }) + else + require("jj.cmd.resolve").resolve({ rev = item.rev, on_exit = on_exit }) + end +end + +M.resolve_conflict = resolve_conflict + --- Initializes the picker --- @param opts jj.picker.config function M.setup(opts) @@ -63,6 +118,7 @@ local function get_files() file = file_path, status = change .. " ", diff_cmd = string.format("jj --no-pager diff %s", vim.fn.shellescape(file_path)), + confirm_action = "open_and_diff", } if change == "R" and file_info.old_path and file_info.old_path ~= file_info.new_path then @@ -146,7 +202,8 @@ local function log_history(file_path) commit_id = commit_id, description = description or "", text = line, - diff_cmd = string.format("jj --no-pager diff %s -r %s --stat --git", file_path, rev), + preview_cmd = { "jj", "--no-pager", "diff", file_path, "-r", rev, "--stat", "--git" }, + confirm_action = "edit_revision", }) end end @@ -166,6 +223,9 @@ function M.file_history() local file = vim.fn.expand("%:p") local log_lines = log_history(file) + if not log_lines or #log_lines == 0 then + return utils.notify("`Picker`: No file history found", vim.log.levels.INFO) + end if M.config.snacks then require("jj.picker.snacks").file_log_history(M.config, log_lines) @@ -174,4 +234,72 @@ function M.file_history() end end +--- Gets the list of conflicted revisions +--- @return jj.picker.conflict[]|nil A list of conflicted revisions or nil if not in a jj repo +local function get_conflicts() + local cmd = + [[jj log -r 'conflicts()' --no-graph -T 'change_id.shortest() ++ "\t" ++ coalesce(author.name(), "(no author)") ++ "\t" ++ coalesce(description.first_line(), "(no description)") ++ "\n"']] + local output, ok = runner.execute_command(cmd) + if not ok then + return + end + + if type(output) ~= "string" then + return utils.notify("Could not get conflicts output", vim.log.levels.ERROR) + end + + local conflicts = {} + local lines = vim.split(output, "\n", { trimempty = true }) + + for _, line in ipairs(lines) do + local rev, author, description = line:match("^(.-)\t(.-)\t(.*)$") + if rev and rev ~= "" then + local item_author = author or "(no author)" + local item_description = description or "(no description)" + table.insert(conflicts, { + symbol = "!", + rev = rev, + author = item_author, + description = item_description, + text = string.format("%s %s %s", rev, item_author, item_description), + preview_cmd = { "jj", "--no-pager", "show", "-r", rev, "--stat", "--git" }, + confirm_action = "resolve_conflict", + }) + end + end + + return conflicts +end + +--- Displays in the configurated picker the list of conflicted revisions +function M.conflict() + -- Ensure jj is installed + if not utils.ensure_jj() then + return + end + + local conflicts = get_conflicts() + if not conflicts or #conflicts == 0 then + return utils.notify("`Picker`: No conflicts found", vim.log.levels.INFO) + end + + if M.config.snacks then + require("jj.picker.snacks").conflict(M.config, conflicts) + else + -- Otherwise, use the default vim.ui.select to choose a conflicted revision + vim.ui.select(conflicts, { + prompt = "Select conflicted revision", + format_item = function(item) + return string.format("%s %s %s", item.rev or "", item.author or "", item.description or "") + end, + }, function(item) + resolve_conflict(item, function(exit_code) + if exit_code == 0 and item and item.rev then + utils.notify(string.format("Successfully resolved `%s`", item.rev), vim.log.levels.INFO) + end + end) + end) + end +end + return M diff --git a/lua/jj/picker/snacks.lua b/lua/jj/picker/snacks.lua index 6435e1b..def94c4 100644 --- a/lua/jj/picker/snacks.lua +++ b/lua/jj/picker/snacks.lua @@ -4,6 +4,79 @@ local runner = require("jj.core.runner") --- @class jj.picker.snacks local M = {} +local function get_snacks_opts(opts) + if opts.snacks == true then + return {} + end + return opts.snacks --[[@as table]] +end + +--- Append git-style highlighted description segments to a Snacks highlight array. +--- +--- If the description matches a conventional-commit-like shape such as +--- `feat(scope)!: body`, this splits and highlights the type/scope/breaking +--- marker separately, then appends the remaining body with `default_hl`. +--- Otherwise the whole description is appended as a single segment. +--- +--- @param ret snacks.picker.Highlight[] +--- @param desc string|nil +--- @param default_hl? string Highlight group used for the description body/fallback text +local function append_description_hl(ret, desc, default_hl) + desc = desc or "" + + local type, scope, breaking, body = desc:match("^(%S+)%s*(%(.-%))(!?):%s*(.*)$") + if not type then + type, breaking, body = desc:match("^(%S+)(!?):%s*(.*)$") + end + + local msg_hl = default_hl or "SnacksPickerGitMsg" + if type and body then + local dimmed = vim.tbl_contains({ "chore", "bot", "build", "ci", "style", "test" }, type) + msg_hl = dimmed and "SnacksPickerDimmed" or (default_hl or "SnacksPickerGitMsg") + ret[#ret + 1] = { + type, + breaking ~= "" and "SnacksPickerGitBreaking" or dimmed and "SnacksPickerBold" or "SnacksPickerGitType", + } + if scope and scope ~= "" then + ret[#ret + 1] = { scope, "SnacksPickerGitScope" } + end + if breaking ~= "" then + ret[#ret + 1] = { "!", "SnacksPickerGitBreaking" } + end + ret[#ret + 1] = { ":", "SnacksPickerDelim" } + ret[#ret + 1] = { " " } + desc = body + end + + ret[#ret + 1] = { desc, msg_hl } +end + +--- Format a conflict picker entry with git-like colored segments. +--- +--- Layout: +--- - change/revision id +--- - author +--- - first-line description +--- +--- @param item jj.picker.conflict|nil +--- @return snacks.picker.Highlight[] +local function format_conflict_item(item) + if not item then + return {} + end + + local a = Snacks.picker.util.align + local ret = {} ---@type snacks.picker.Highlight[] + + ret[#ret + 1] = { a(item.rev or "unknown", 12, { truncate = true }), "Constant" } + ret[#ret + 1] = { " " } + ret[#ret + 1] = { a(item.author or "(no author)", 16, { truncate = true }), "Identifier" } + ret[#ret + 1] = { " " } + append_description_hl(ret, item.description, "Comment") + + return ret +end + --- Displays the status files in a snacks picker ---@param opts jj.picker.config ---@param files jj.picker.file[] @@ -13,16 +86,7 @@ function M.status(opts, files) end local snacks = require("snacks") - - local snacks_opts - -- If its true we default to an empty table - if opts.snacks == true then - snacks_opts = {} - else - --- Otherwise we get the table from the config - ---@type table - snacks_opts = opts.snacks - end + local snacks_opts = get_snacks_opts(opts) local merged_opts = vim.tbl_deep_extend("force", snacks_opts, { source = "jj", @@ -48,7 +112,7 @@ function M.status(opts, files) }, }, preview = function(ctx) - if ctx.item.file then + if ctx.item and ctx.item.diff_cmd then snacks.picker.preview.cmd(ctx.item.diff_cmd, ctx, {}) end end, @@ -57,7 +121,7 @@ function M.status(opts, files) snacks.picker.pick(merged_opts) end -local function format_jj_log(item, picker) +local function format_jj_log(item) local a = Snacks.picker.util.align local ret = {} ---@type snacks.picker.Highlight[] @@ -79,7 +143,7 @@ local function format_jj_log(item, picker) end if item.author then - ret[#ret + 1] = { a(item.author, 8, { truncate = true }), "SnacksPcikerGitMsg" } + ret[#ret + 1] = { a(item.author, 8, { truncate = true }), "Identifier" } if #item.author >= 8 then ret[#ret + 1] = { " " } end @@ -101,33 +165,7 @@ local function format_jj_log(item, picker) end end - -- This comes from snacks git description formattedr - local desc = item.description or "" - local type, scope, breaking, body = desc:match("^(%S+)%s*(%(.-%))(!?):%s*(.*)$") - - if not type then - type, breaking, body = desc:match("^(%S+)(!?):%s*(.*)$") - end - local msg_hl = "SnacksPickerGitMsg" - if type and body then - local dimmed = vim.tbl_contains({ "chore", "bot", "build", "ci", "style", "test" }, type) - msg_hl = dimmed and "SnacksPickerDimmed" or "SnacksPickerGitMsg" - ret[#ret + 1] = { - type, - breaking ~= "" and "SnacksPickerGitBreaking" or dimmed and "SnacksPickerBold" or "SnacksPickerGitType", - } - if scope and scope ~= "" then - ret[#ret + 1] = { scope, "SnacksPickerGitScope" } - end - if breaking ~= "" then - ret[#ret + 1] = { "!", "SnacksPickerGitBreaking" } - end - ret[#ret + 1] = { ":", "SnacksPickerDelim" } - ret[#ret + 1] = { " " } - desc = body - end - - ret[#ret + 1] = { desc, msg_hl } + append_description_hl(ret, item.description) return ret end @@ -139,16 +177,7 @@ function M.file_log_history(opts, log_lines) end local snacks = require("snacks") - - local snacks_opts - -- If its true we default to an empty table - if opts.snacks == true then - snacks_opts = {} - else - --- Otherwise we get the table from the config - ---@type table - snacks_opts = opts.snacks - end + local snacks_opts = get_snacks_opts(opts) local merged_opts = vim.tbl_deep_extend("force", snacks_opts, { source = "jj", @@ -173,8 +202,74 @@ function M.file_log_history(opts, log_lines) end end, preview = function(ctx) - if ctx.item.rev and ctx.item.diff_cmd then - snacks.picker.preview.cmd(ctx.item.diff_cmd, ctx, {}) + if ctx.item and ctx.item.preview_cmd then + snacks.picker.preview.cmd(ctx.item.preview_cmd, ctx, { ft = "git" }) + end + end, + }) + + snacks.picker.pick(merged_opts) +end + +--- Picker to chose conlicted revisions and resolve them +---@param opts jj.picker.config +---@param conflicts jj.picker.conflict[] +function M.conflict(opts, conflicts) + if not opts.snacks then + return utils.notify("Snacks picker is `disabled`", vim.log.levels.INFO) + end + + local snacks = require("snacks") + local snacks_opts = get_snacks_opts(opts) + + local picker = require("jj.picker") + + local function exit_func(rev) + return function(exit_code) + if exit_code == 0 then + utils.notify(string.format("Successfully resolved `%s`", rev), vim.log.levels.INFO) + end + end + end + + local merged_opts = vim.tbl_deep_extend("force", snacks_opts, { + source = "jj", + items = conflicts, + title = "JJ Conflicts", + format = format_conflict_item, + actions = { + edit_revision = function(snacks_picker, item) + snacks_picker:close() + + if not item or not item.rev then + return + end + + local _, ok = runner.execute_command( + string.format("jj edit %s", item.rev), + string.format("could not edit revision '%s'", item.rev) + ) + + if ok then + utils.reload_changed_file_buffers() + utils.notify(string.format("Editing conflicted revision `%s`", item.rev), vim.log.levels.INFO) + end + end, + }, + win = { + input = { + keys = { + [""] = { "edit_revision", mode = { "i", "n" } }, + }, + }, + }, + confirm = function(snacks_picker, item) + snacks_picker:close() + picker.resolve_conflict(item, exit_func(item and item.rev or "")) + end, + preview = function(ctx) + if ctx.item and ctx.item.preview_cmd then + snacks.picker.preview.cmd(ctx.item.preview_cmd, ctx, { ft = "git" }) end end, }) diff --git a/lua/jj/utils.lua b/lua/jj/utils.lua index 945cee5..0829a4e 100644 --- a/lua/jj/utils.lua +++ b/lua/jj/utils.lua @@ -919,4 +919,67 @@ function M.list_github_prs(opts) return open_prs end +-- Open first conflicted file, runs edit on the conflicted revision and opens the first conflicted file in a buffer. +--- @param revset string The revset to check for conflicts +function M.open_first_conflicted_file(revset) + if not revset or revset == "" then + M.notify("No revset provided to open conflicted file", vim.log.levels.ERROR) + return + elseif not M.is_change_conflicted(revset) then + return + end + + local repo_root = M.get_jj_root() + local quoted_revset = vim.fn.shellescape(revset) + + -- Resolve the first conflicted path before editing so we can still open it + -- reliably when the current working directory is outside the repo root. + local list_output, list_ok = runner.execute_command( + string.format("jj resolve -r %s --list", quoted_revset), + string.format("could not list conflicted files for '%s'", revset), + nil, + true + ) + + local first_conflicted_path + if list_ok and type(list_output) == "string" and list_output ~= "" then + for _, line in ipairs(vim.split(list_output, "\n", { trimempty = true })) do + local rel_path = vim.trim(line:gsub("^[-*]%s+", "")) + if rel_path ~= "" then + first_conflicted_path = rel_path + break + end + end + end + + local _, ok = runner.execute_command( + string.format("jj edit %s --ignore-immutable", quoted_revset), + string.format("could not edit revision '%s'", revset) + ) + + if not ok then + return + end + + M.reload_changed_file_buffers() + M.notify(string.format("Editing conflicted revision `%s`", revset), vim.log.levels.INFO) + + if first_conflicted_path then + local first_conflicted_file = repo_root and vim.fs.joinpath(repo_root, first_conflicted_path) + or first_conflicted_path + local escaped = vim.fn.fnameescape(first_conflicted_file) + local existing_buf = vim.fn.bufnr(first_conflicted_file) + + if existing_buf ~= -1 and vim.api.nvim_buf_is_loaded(existing_buf) then + local modified = vim.bo[existing_buf].modified + if not modified then + vim.cmd("bdelete! " .. existing_buf) + end + end + + vim.cmd("edit! " .. escaped) + vim.cmd("checktime " .. escaped) + end +end + return M