mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-05 02:36:07 +00:00
feat(picker): Add picker to navigate conflict sections (#131)
This commit is contained in:
@@ -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:
|
||||
|
||||
- `<Enter>` - Open the conflict in the current window
|
||||
- `<C-s>` - Open the conflict in a horizontal split
|
||||
- `<C-v>` - Open the conflict in a vertical split
|
||||
- `<C-t>` - 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", "<leader>jA", annotate.line, { desc = "JJ annotate line" })
|
||||
vim.keymap.set("n", "<leader>gj", function() picker.status() end, { desc = "JJ Picker status" })
|
||||
vim.keymap.set("n", "<leader>jgh", function() picker.file_history() end, { desc = "JJ Picker history" })
|
||||
vim.keymap.set("n", "<leader>jgc", function() picker.conflict() end, { desc = "JJ Picker conflicts" })
|
||||
vim.keymap.set("n", "<leader>jgs", function() picker.conflict_sections() end, { desc = "JJ Picker conflict sections" })
|
||||
|
||||
-- Some functions like `log` can take parameters
|
||||
vim.keymap.set("n", "<leader>jL", function()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user