feat(picker): Add picker to navigate conflict sections (#131)

This commit is contained in:
Björn Steinbrink
2026-06-23 09:59:42 +02:00
committed by GitHub
parent ca6d751de6
commit f217b16699
5 changed files with 242 additions and 0 deletions
+45
View File
@@ -242,6 +242,51 @@ function M.parse_diff_range(range_str)
return nil
end
--- Parse the conflicted-file records emitted by the `conflicted_files()`
--- template. Each file is rendered as two NUL-terminated fields: its display
--- path (relative to the working directory) followed by its absolute path. NUL
--- is used as the separator because it is the only byte that cannot occur in a
--- path, so paths containing tabs or newlines are handled correctly.
--- @param output string|nil Raw template output
--- @return {rel_path: string, abs_path: string}[]
function M.parse_conflicted_files(output)
local entries = {}
if not output then
return entries
end
local fields = vim.split(output, "\0", { trimempty = true })
for i = 1, #fields - 1, 2 do
entries[#entries + 1] = { rel_path = fields[i], abs_path = fields[i + 1] }
end
return entries
end
--- Build conflict-section entries by scanning a conflicted file's lines for
--- opening conflict markers (lines starting with `<<<<<<<`), one entry per
--- marker.
--- @param rel_path string Path of the file as reported by jj
--- @param abs_path string Absolute path of the file
--- @param file_lines string[] The lines of the file
--- @return jj.picker.conflict_section[]
function M.scan_conflict_sections(rel_path, abs_path, file_lines)
local sections = {}
for lnum, file_line in ipairs(file_lines) do
if file_line:match("^<<<<<<<") then
table.insert(sections, {
file = abs_path,
rel_path = rel_path,
pos = { lnum, 0 },
text = string.format("%s:%d", rel_path, lnum),
})
end
end
return sections
end
--- Parse a `<rev>:<path>` argument as used by file commands.
--- With no colon, the whole input is treated as a revision with no file.
--- A trailing colon (`<rev>:`) is accepted and treated as no file path.
+69
View File
@@ -24,6 +24,12 @@ local parser = require("jj.core.parser")
--- @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_section
--- @field text string The text to display in the picker
--- @field file string Absolute path of the conflicted file
--- @field rel_path string Path of the conflicted file as reported by jj
--- @field pos integer[] {lnum, col} of the conflict opening marker
--- @class jj.picker.conflict
--- @field text string The text to display in the picker
--- @field symbol string The symbol of the conflict entry
@@ -311,4 +317,67 @@ function M.conflict()
end
end
--- Lists the individual conflict sections present in the current revision (`@`).
---
--- The `conflicted_files()` template reports the conflicted files but not the
--- location of the conflicts inside them, so each file is scanned for opening
--- conflict markers (lines starting with `<<<<<<<`) to build one entry per
--- conflict section. Each file is emitted as a NUL-terminated display path
--- followed by a NUL-terminated absolute path; NUL is used because it is the
--- only byte that cannot occur in a path, so the raw output is read to preserve
--- it.
--- @return jj.picker.conflict_section[]|nil A list of conflict sections or nil if not in a jj repo
local function get_conflict_sections()
local output, ok = runner.execute_command_raw(
[[jj log --no-graph --quiet -r @ -T 'self.conflicted_files().map(|e| e.path().display() ++ "\0" ++ e.path().absolute() ++ "\0")']]
)
if not ok then
return
end
if type(output) ~= "string" then
return utils.notify("Could not get conflict list output", vim.log.levels.ERROR)
end
local sections = {}
for _, entry in ipairs(parser.parse_conflicted_files(output)) do
local file_lines = vim.fn.filereadable(entry.abs_path) == 1 and vim.fn.readfile(entry.abs_path) or {}
vim.list_extend(sections, parser.scan_conflict_sections(entry.rel_path, entry.abs_path, file_lines))
end
return sections
end
--- Displays a picker with each individual conflict section in the current revision (`@`).
function M.conflict_sections()
-- Ensure jj is installed
if not utils.ensure_jj() then
return
end
local sections = get_conflict_sections()
if not sections or #sections == 0 then
return utils.notify("`Picker`: No conflicts found in the current revision", vim.log.levels.INFO)
end
if M.config.snacks then
require("jj.picker.snacks").conflict_sections(M.config, sections)
else
-- Otherwise, use the default vim.ui.select to navigate to a conflict
vim.ui.select(sections, {
prompt = "Select conflict to navigate to",
format_item = function(item)
return item.text
end,
}, function(item)
if not item then
return
end
vim.cmd("edit " .. vim.fn.fnameescape(item.file))
pcall(vim.api.nvim_win_set_cursor, 0, item.pos)
end)
end
end
return M
+44
View File
@@ -249,4 +249,48 @@ function M.conflict(opts, conflicts)
snacks.picker.pick(merged_opts)
end
--- Format a conflict-section picker entry as `<relative file path>:<line>`,
--- the line being where the conflict's opening marker is.
---
--- @param item jj.picker.conflict_section|nil
--- @return snacks.picker.Highlight[]
local function format_conflict_section_item(item)
if not item then
return {}
end
local ret = {} ---@type snacks.picker.Highlight[]
ret[#ret + 1] = { item.rel_path or item.file or "", "SnacksPickerFile" }
ret[#ret + 1] = { ":" .. tostring(item.pos and item.pos[1] or 0), "SnacksPickerRow" }
return ret
end
--- Picker to navigate to each individual conflict section in the current revision.
---
--- The items are a standard file source (`file` + `pos`), so the default Snacks
--- bindings apply: `<CR>` opens in the current window, `<C-s>` in a split,
--- `<C-v>` in a vertical split and `<C-t>` in a new tab, with a live file
--- preview positioned on the conflict marker.
---@param opts jj.picker.config
---@param sections jj.picker.conflict_section[]
function M.conflict_sections(opts, sections)
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 merged_opts = vim.tbl_deep_extend("force", snacks_opts, {
source = "jj",
items = sections,
title = "JJ Conflicts",
format = format_conflict_section_item,
})
snacks.picker.pick(merged_opts)
end
return M