feat(picker): Add a conflict picker (#119)

This commit is contained in:
Nicolas GB
2026-06-18 20:03:27 +02:00
committed by GitHub
parent aa3e98af26
commit af14ce8eda
4 changed files with 374 additions and 57 deletions
+147 -52
View File
@@ -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 = {
["<C-e>"] = { "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,
})