mirror of
https://github.com/zoriya/jj.nvim.git
synced 2026-08-05 02:36:07 +00:00
Migrate command execution to structured argv lists across the plugin, replacing shell-string command construction with `vim.system` argv execution. The runner API now exposes argv-based execution as the default path:
- `runner.execute_command` -> `runner.execute`
- `runner.execute_command_async` -> `runner.execute_async`
- `runner.execute_command_raw` -> `runner.execute_raw`
- `runner.execute_command_raw_async` -> `runner.execute_raw_async`
- `runner.execute_command_sync` was removed; use `runner.execute` and handle the result directly
BREAKING CHANGES:
- `cmd.new({ args = ... })` now requires `args` as `string[]` argv tokens, not a single string.
- `cmd.log({ ... })` now uses `raw_flags` as `string[]` for raw passthrough flags; `raw = "..."` is no longer supported.
- Direct users of `jj.core.runner` must migrate from the old `execute_command*` string helpers to the new argv-based `execute*` helpers.
415 lines
12 KiB
Lua
415 lines
12 KiB
Lua
local utils = require("jj.utils")
|
|
local runner = require("jj.core.runner")
|
|
local parser = require("jj.core.parser")
|
|
local jj_args = require("jj.core.args")
|
|
|
|
--- @class jj.picker
|
|
|
|
--- @class jj.picker.config
|
|
--- @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 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 description string The description of the log entry
|
|
--- @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
|
|
--- @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
|
|
config = {
|
|
snacks = {},
|
|
},
|
|
}
|
|
|
|
--- 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)
|
|
M.config = vim.tbl_deep_extend("force", M.config, opts or {})
|
|
end
|
|
|
|
--- Gets the files in the current jj repository
|
|
--- @return jj.picker.file[]|nil A list of files with their changes or nil if not in a jj repo
|
|
local function get_files()
|
|
local diff_ouptut, ok = runner.execute({ "jj", "--no-pager", "diff", "--summary", "--quiet" })
|
|
if not ok then
|
|
return
|
|
end
|
|
|
|
if type(diff_ouptut) ~= "string" then
|
|
return utils.notify("Could not get diff output", vim.log.levels.ERROR)
|
|
end
|
|
|
|
local files = {}
|
|
|
|
-- Split the output into lines
|
|
local lines = vim.split(diff_ouptut, "\n", { trimempty = true })
|
|
|
|
for _, line in ipairs(lines) do
|
|
local change = line:match("^(%a)%s")
|
|
local file_info = parser.parse_file_info_from_status_line(line)
|
|
|
|
if change and file_info and file_info.new_path then
|
|
local file_path = file_info.new_path
|
|
local item = {
|
|
text = line:sub(3),
|
|
file = file_path,
|
|
status = change .. " ",
|
|
diff_cmd = { "jj", "--no-pager", "diff", jj_args.fileset(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
|
|
item.rename = file_info.old_path
|
|
end
|
|
|
|
table.insert(files, item)
|
|
end
|
|
end
|
|
|
|
return files
|
|
end
|
|
|
|
--- Displays in the configurated picker the status of the files
|
|
function M.status()
|
|
-- Ensure jj is installed
|
|
if not utils.ensure_jj() then
|
|
return
|
|
end
|
|
|
|
local files = get_files()
|
|
if not files or #files == 0 then
|
|
return utils.notify("`Picker`: No diffs found", vim.log.levels.INFO)
|
|
end
|
|
|
|
if M.config.snacks then
|
|
require("jj.picker.snacks").status(M.config, files)
|
|
else
|
|
vim.ui.select(files, {
|
|
prompt = "Select changed file",
|
|
format_item = function(item)
|
|
return string.format("%s %s", item.status or "", item.text or item.file or "")
|
|
end,
|
|
}, function(item)
|
|
if item and item.file then
|
|
vim.cmd("edit " .. vim.fn.fnameescape(item.file))
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
--- Parse file history with a stable no-graph template.
|
|
---@param file_path string The path of the file to log
|
|
---@return jj.picker.log_line[]|nil A list of log lines or nil if not in a jj repo
|
|
local function log_history(file_path)
|
|
local cmd = {
|
|
"jj",
|
|
"log",
|
|
"--no-pager",
|
|
"-r",
|
|
"all() ~ @",
|
|
"--no-graph",
|
|
"-T",
|
|
[[change_id.shortest() ++ "\t" ++ coalesce(author.name(), "(no author)") ++ "\t" ++ committer.timestamp() ++ "\t" ++ coalesce(description.first_line(), "(no description)") ++ "\n"]],
|
|
jj_args.fileset(file_path),
|
|
}
|
|
local output, ok = runner.execute(cmd)
|
|
if not ok then
|
|
return
|
|
end
|
|
|
|
if type(output) ~= "string" then
|
|
return utils.notify(string.format("Could not get log output for file %s", file_path), vim.log.levels.ERROR)
|
|
end
|
|
|
|
local file_history = {}
|
|
local lines = vim.split(output, "\n", { trimempty = true })
|
|
|
|
for _, line in ipairs(lines) do
|
|
local parts = vim.split(line, "\t", { plain = true })
|
|
if #parts >= 4 then
|
|
local rev = parts[1]
|
|
local author = parts[2]
|
|
local time_part = parts[3]
|
|
local description = table.concat(vim.list_slice(parts, 4), "\t")
|
|
local short_time = time_part:match("^%d%d%d%d%-%d%d%-%d%d") or time_part
|
|
|
|
table.insert(file_history, {
|
|
rev = rev,
|
|
author = author,
|
|
time = time_part,
|
|
description = description,
|
|
text = string.format("%s %s %s %s", rev, author, short_time, description),
|
|
preview_cmd = {
|
|
"jj",
|
|
"--no-pager",
|
|
"diff",
|
|
jj_args.fileset(file_path),
|
|
"-r",
|
|
rev,
|
|
"--stat",
|
|
"--git",
|
|
},
|
|
confirm_action = "edit_revision",
|
|
})
|
|
end
|
|
end
|
|
|
|
return file_history
|
|
end
|
|
|
|
function M.file_history()
|
|
-- Ensure jj is installed
|
|
if not utils.ensure_jj() then
|
|
return
|
|
end
|
|
|
|
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)
|
|
else
|
|
vim.ui.select(log_lines, {
|
|
prompt = "Select revision to edit",
|
|
format_item = function(item)
|
|
return item.text or string.format("%s %s %s", item.rev or "", item.author or "", item.description or "")
|
|
end,
|
|
}, function(item)
|
|
if not item or not item.rev then
|
|
return
|
|
end
|
|
|
|
local _, ok = runner.execute(
|
|
{ "jj", "edit", item.rev, "--ignore-immutable" },
|
|
string.format("could not edit revision '%s'", item.rev)
|
|
)
|
|
|
|
if ok then
|
|
utils.reload_changed_file_buffers()
|
|
utils.notify(string.format("Editing revision `%s`", item.rev), vim.log.levels.INFO)
|
|
end
|
|
end)
|
|
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(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
|
|
|
|
--- 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 cmd = {
|
|
"jj",
|
|
"log",
|
|
"--no-graph",
|
|
"--quiet",
|
|
"-r",
|
|
"@",
|
|
"-T",
|
|
[[self.conflicted_files().map(|e| e.path().display() ++ "\0" ++ e.path().absolute() ++ "\0")]],
|
|
}
|
|
local output, ok, _ = runner.execute_raw(cmd)
|
|
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
|