Add jj stage picker

This commit is contained in:
2026-07-31 16:55:14 +02:00
parent 7c2c6e017d
commit f00daa25eb
5 changed files with 200 additions and 12 deletions
+189 -10
View File
@@ -11,6 +11,19 @@ local function jj_cwd(from, opts, ctx)
return cwd
end
local STATUS_HL = {
["A"] = "SnacksPickerGitStatusAdded",
["M"] = "SnacksPickerGitStatusModified",
["D"] = "SnacksPickerGitStatusDeleted",
["R"] = "SnacksPickerGitStatusRenamed",
["C"] = "SnacksPickerGitStatusCopied",
["?"] = "SnacksPickerGitStatusUntracked",
}
local function status_hl(status)
return STATUS_HL[status] or "SnacksPickerGitStatus"
end
Snacks.picker.jj_log = function(file)
local title = "jj log"
if file then
@@ -171,6 +184,164 @@ Snacks.picker.jj_log_file = function()
Snacks.picker.jj_log(vim.fn.expand("%:p"))
end
-- Stage-area style picker: show the combined diff of `@` and `@-`, with a two
-- character status column (`@` first, `@-` second) à la git's staged/unstaged
-- columns, and let <tab> move a file's change between `@` and `@-`.
Snacks.picker.jj_stage = function()
local from = vim.api.nvim_buf_get_name(0)
if from == "" then
from = nil
end
local title = "jj stage @ / @-"
local root = vim.fs.root(vim.fs.normalize(from or vim.uv.cwd() or "."), ".jj")
if root then
local function desc(rev)
local out = vim.system({
"jj", "log", "--no-graph", "--color=never", "-r", rev, "-T",
"if(description, description.first_line(), description_placeholder)",
}, { cwd = root, text = true }):wait()
return vim.trim(out.stdout or "")
end
title = string.format("@ %s │ @- %s", desc("@"), desc("@-"))
end
Snacks.picker.pick({
title = title,
supports_live = false,
finder = function(opts, ctx)
local cwd = jj_cwd(from, opts, ctx)
if not cwd then
return {}
end
-- Parse `jj diff --summary` for a revision into { [file] = entry }.
local function summary(rev)
local out = vim.system(
{ "jj", "diff", "--summary", "--color=never", "-r", rev },
{ cwd = cwd, text = true }
):wait()
local files = {}
for line in vim.gsplit(out.stdout or "", "\n", { trimempty = true }) do
local status, file = line:match("^(.) (.+)$")
if status then
local entry = { status = status, file = file }
local base, prev, next, suffix = file:match("^(.*){(.*) => (.*)}(.*)$")
if base ~= nil then
entry.rename = base .. prev .. suffix
entry.file = base .. next .. suffix
end
files[entry.file] = entry
end
end
return files
end
local at = summary("@")
local parent = summary("@-")
local order, seen = {}, {}
for _, map in ipairs({ at, parent }) do
for k in pairs(map) do
if not seen[k] then
seen[k] = true
order[#order + 1] = k
end
end
end
table.sort(order)
local items = {}
for _, k in ipairs(order) do
local a, p = at[k], parent[k]
local at_status = a and a.status or " "
local parent_status = p and p.status or " "
items[#items + 1] = {
text = k,
cwd = cwd,
file = k,
rename = (a and a.rename) or (p and p.rename),
-- Two status columns, `@` first then `@-`: `MM`, `M `, ` M`, ...
at_status = at_status,
parent_status = parent_status,
in_at = a ~= nil,
}
end
return items
end,
format = function(item, picker)
local ret = {} ---@type snacks.picker.Highlight[]
table.insert(ret, { item.at_status, status_hl(item.at_status) })
table.insert(ret, { item.parent_status, status_hl(item.parent_status) })
table.insert(ret, { " " })
if item.rename then
local file = item.file
item.file = item.rename
item._path = nil
vim.list_extend(ret, Snacks.picker.format.filename(item, picker))
item.file = file
item._path = nil
ret[#ret + 1] = { "-> ", "SnacksPickerDelim" }
ret[#ret + 1] = { " " }
end
vim.list_extend(ret, Snacks.picker.format.filename(item, picker))
return ret
end,
preview = function(ctx)
local cmd = { "jj", "diff", "--git", "--no-pager", "--from", "@--", "--to", "@" }
table.insert(cmd, string.format("file:'%s'", ctx.item.file))
Snacks.picker.preview.cmd(cmd, ctx, { ft = "diff" })
end,
sort = { fields = { 'score:desc', 'idx' } },
win = {
input = {
keys = {
["<tab>"] = { "jj_toggle", mode = { "i", "n" } },
["<c-s>"] = { "jj_gsplit", mode = { "i" } },
["<c-v>"] = { "jj_gvsplit", mode = { "i" } },
},
},
},
actions = {
-- Move a file's change between `@` and `@-`. When it lives in `@`
-- (`M ` or `MM`) squash it down into `@-`; when it only lives in `@-`
-- (` M`) move it back up into `@`.
jj_toggle = function(picker)
local items = picker:selected({ fallback = true })
local done = 0
for _, item in ipairs(items) do
local paths = { string.format("file:'%s'", item.file) }
if item.rename then
table.insert(paths, string.format("file:'%s'", item.rename))
end
local cmd
if item.in_at then
cmd = { "jj", "squash", "--into", "@-" }
else
cmd = { "jj", "squash", "--from", "@-", "--into", "@" }
end
vim.list_extend(cmd, paths)
Snacks.picker.util.cmd(cmd, function()
done = done + 1
if done == #items then
picker:refresh()
end
end, { cwd = item.cwd })
end
end,
jj_gsplit = function(picker, item)
picker:close()
vim.cmd({ cmd = "Jsplit", args = { "@:" .. item.file } })
end,
jj_gvsplit = function(picker, item)
picker:close()
vim.cmd({ cmd = "Jvsplit", args = { "@:" .. item.file } })
end,
},
})
end
Snacks.picker.jj_show = function(spec)
if type(spec) == "string" then
spec = { ref = spec }
@@ -242,16 +413,7 @@ Snacks.picker.jj_show = function(spec)
format = function(item, picker)
local ret = {} ---@type snacks.picker.Highlight[]
local hls = {
["A"] = "SnacksPickerGitStatusAdded",
["M"] = "SnacksPickerGitStatusModified",
["D"] = "SnacksPickerGitStatusDeleted",
["R"] = "SnacksPickerGitStatusRenamed",
["C"] = "SnacksPickerGitStatusCopied",
["?"] = "SnacksPickerGitStatusUntracked",
}
local hl = hls[item.status] or "SnacksPickerGitStatus"
table.insert(ret, { item.status, hl })
table.insert(ret, { item.status, status_hl(item.status) })
table.insert(ret, { " " })
if item.rename then
local file = item.file
@@ -281,6 +443,7 @@ Snacks.picker.jj_show = function(spec)
input = {
keys = {
["<a-s>"] = { "jj_split", mode = { "i" } },
["<c-s-s>"] = { "jj_squash", mode = { "i" } },
["<c-s>"] = { "jj_gsplit", mode = { "i" } },
["<c-v>"] = { "jj_gvsplit", mode = { "i" } },
},
@@ -297,6 +460,22 @@ Snacks.picker.jj_show = function(spec)
table.concat(items, "' | '")
))
end,
jj_squash = function(picker)
local items = picker:selected({ fallback = true })
if #items == 0 then
return
end
local cmd = { "jj", "squash", "-r", show_ref }
for _, item in ipairs(items) do
table.insert(cmd, string.format("file:'%s'", item.file))
if item.rename then
table.insert(cmd, string.format("file:'%s'", item.rename))
end
end
Snacks.picker.util.cmd(cmd, function()
picker:refresh()
end, { cwd = items[1].cwd })
end,
jj_gsplit = function(picker, item)
picker:close()
vim.cmd({ cmd = "Jsplit", args = { show_ref .. ":" .. item.file } })