From f217b16699e714e32df5c7616c0f71b70d49e11f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Steinbrink?= Date: Tue, 23 Jun 2026 09:59:42 +0200 Subject: [PATCH] feat(picker): Add picker to navigate conflict sections (#131) --- README.md | 17 ++++++++++ lua/jj/core/parser.lua | 45 ++++++++++++++++++++++++++ lua/jj/picker.lua | 69 ++++++++++++++++++++++++++++++++++++++++ lua/jj/picker/snacks.lua | 44 +++++++++++++++++++++++++ tests/run_tests.lua | 67 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 242 insertions(+) diff --git a/README.md b/README.md index c567470..81572ef 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ - `picker.status()` displays the current changed files with live diff preview (or falls back to `vim.ui.select()`) - `picker.file_history()` displays the current buffer's revision history and lets you edit the selected change (or falls back to `vim.ui.select()`) - `picker.conflict()` lists conflicted revisions, previews their changes, and launches conflict resolution (with Snacks, or `vim.ui.select()` as a fallback) + - `picker.conflict_sections()` lists each individual conflict section in the current revision and navigates to it in the current window, a split or a tab (with Snacks, or `vim.ui.select()` as a fallback) ## Enhanced integrations @@ -423,6 +424,21 @@ The fallback `vim.ui.select()` version supports selecting a conflicted revision This makes it easy to keep a dedicated “show me all conflicts” picker bound to a keymap, especially when using the Snacks picker UI. +### Conflict sections picker (Snacks.nvim) + +`picker.conflict_sections()` lists the individual conflict sections in the current revision (`@`) so you can jump straight to a conflict and resolve it in your own editor (e.g. Neovim) instead of launching an external merge tool. + +It uses the `conflicted_files()` template to find the conflicted files, then scans each file for conflict opening markers (lines starting with `<<<<<<<`) to produce one entry per conflict section, since the template does not report line numbers. + +When Snacks is enabled, it uses a standard file picker, so the usual bindings apply: + +- `` - Open the conflict in the current window +- `` - Open the conflict in a horizontal split +- `` - Open the conflict in a vertical split +- `` - Open the conflict in a new tab + +If Snacks is not enabled, it falls back to a plain `vim.ui.select()` picker that opens the selected conflict in the current window. + ## Installation Using [lazy.nvim](https://github.com/folke/lazy.nvim): @@ -1288,6 +1304,7 @@ vim.keymap.set("n", "jA", annotate.line, { desc = "JJ annotate line" }) 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" }) + vim.keymap.set("n", "jgs", function() picker.conflict_sections() end, { desc = "JJ Picker conflict sections" }) -- Some functions like `log` can take parameters vim.keymap.set("n", "jL", function() diff --git a/lua/jj/core/parser.lua b/lua/jj/core/parser.lua index 41b8c7b..e6cd3dd 100644 --- a/lua/jj/core/parser.lua +++ b/lua/jj/core/parser.lua @@ -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 `:` argument as used by file commands. --- With no colon, the whole input is treated as a revision with no file. --- A trailing colon (`:`) is accepted and treated as no file path. diff --git a/lua/jj/picker.lua b/lua/jj/picker.lua index 1c57033..f5e3737 100644 --- a/lua/jj/picker.lua +++ b/lua/jj/picker.lua @@ -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 diff --git a/lua/jj/picker/snacks.lua b/lua/jj/picker/snacks.lua index 285a13b..1473317 100644 --- a/lua/jj/picker/snacks.lua +++ b/lua/jj/picker/snacks.lua @@ -249,4 +249,48 @@ function M.conflict(opts, conflicts) snacks.picker.pick(merged_opts) end +--- Format a conflict-section picker entry as `:`, +--- 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: `` opens in the current window, `` in a split, +--- `` in a vertical split and `` 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 diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 77249fc..3777518 100755 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -364,6 +364,73 @@ run_test("parses divergent change at end of line", function() assert_equals("bcd890/1", parser.get_revset(line)) end) +print("\n=== Running parse_conflicted_files tests ===\n") + +run_test("parse_conflicted_files: single file", function() + assert_table_equals( + { { rel_path = "src/foo.rs", abs_path = "/repo/src/foo.rs" } }, + parser.parse_conflicted_files("src/foo.rs\0/repo/src/foo.rs\0") + ) +end) + +run_test("parse_conflicted_files: multiple files", function() + assert_table_equals({ + { rel_path = "a.txt", abs_path = "/repo/a.txt" }, + { rel_path = "b.txt", abs_path = "/repo/b.txt" }, + }, parser.parse_conflicted_files("a.txt\0/repo/a.txt\0b.txt\0/repo/b.txt\0")) +end) + +run_test("parse_conflicted_files: paths with tabs and newlines", function() + assert_table_equals( + { { rel_path = "weird\tname\n.txt", abs_path = "/repo/weird\tname\n.txt" } }, + parser.parse_conflicted_files("weird\tname\n.txt\0/repo/weird\tname\n.txt\0") + ) +end) + +run_test("parse_conflicted_files: empty output yields empty list", function() + assert_table_equals({}, parser.parse_conflicted_files("")) +end) + +run_test("parse_conflicted_files: nil output yields empty list", function() + assert_table_equals({}, parser.parse_conflicted_files(nil)) +end) + +print("\n=== Running scan_conflict_sections tests ===\n") + +run_test("scan_conflict_sections: one entry per opening marker", function() + local lines = { + "line 1", + "<<<<<<< Conflict 1 of 2", + "%%%%%%%", + ">>>>>>>", + "between", + "<<<<<<< Conflict 2 of 2", + ">>>>>>>", + } + assert_table_equals({ + { + file = "/abs/a.txt", + rel_path = "a.txt", + pos = { 2, 0 }, + text = "a.txt:2", + }, + { + file = "/abs/a.txt", + rel_path = "a.txt", + pos = { 6, 0 }, + text = "a.txt:6", + }, + }, parser.scan_conflict_sections("a.txt", "/abs/a.txt", lines)) +end) + +run_test("scan_conflict_sections: no markers yields empty list", function() + assert_table_equals({}, parser.scan_conflict_sections("a.txt", "/abs/a.txt", { "no", "markers", "here" })) +end) + +run_test("scan_conflict_sections: empty file yields empty list", function() + assert_table_equals({}, parser.scan_conflict_sections("a.txt", "/abs/a.txt", {})) +end) + print("\n=== Running parse_default_cmd tests ===\n") run_test("parse_default_cmd: parses config list array output", function()