feat(picker): Add file history picker

This commit is contained in:
NicolasGB
2025-07-03 21:46:24 +02:00
parent 5aaf396b70
commit b6def1df67
3 changed files with 245 additions and 1 deletions
+33 -1
View File
@@ -23,7 +23,9 @@ This plugin aims to be something like vim-fugitive but for driving the jj-vcs CL
- `new` - Create a new change
- `edit` - Edit a change
- `squash` - Squash the current diff to it's parent
- Picker `git status` like for [Snacks.nvim](https://github.com/folke/snacks.nvim)
- Picker for for [Snacks.nvim](https://github.com/folke/snacks.nvim)
- `jj status` Displays the current changes diffs
- `jj log` Displays a buffer's history changes and allows to edit it's change
## Installation
@@ -51,6 +53,22 @@ The plugin provides a `:J` command that accepts jj subcommands:
:J <your-alias>
```
## Setup config
```lua
{
-- Setup snacks as a picker
picker = {
-- Here you can pass the options as you would for snacks.
-- It will be used when using the picker
snacks = {
}
}
}
```
## Example config
```lua
@@ -66,9 +84,15 @@ The plugin provides a `:J` command that accepts jj subcommands:
vim.keymap.set("n", "<leader>js", cmd.status, { desc = "JJ status" })
vim.keymap.set("n", "<leader>dj", cmd.diff, { desc = "JJ diff" })
vim.keymap.set("n", "<leader>sj", cmd.squash, { desc = "JJ squash" })
-- Pickers
vim.keymap.set("n", "<leader>gj", function()
require("jj.picker").status()
end, { desc = "JJ Picker status" })
vim.keymap.set("n", "<leader>gl", function()
require("jj.picker").file_history()
end, { desc = "JJ Picker file history" })
-- Some functions like `describe` or `log` can take parameters
@@ -96,6 +120,14 @@ The plugin provides a `:J` command that accepts jj subcommands:
This is an early-stage project. Contributions are welcome, but please be aware that the API and features are likely to change significantly.
## Documentation
Once the plugin is more complete i'll write docs for each of the commands.
## FAQ
- Telescope Suport? Planned but i don't use it, it's already thought of by design, will implement it at some point or if someone submits a PR i'll accept it gladly.
## License
[MIT](License)
+88
View File
@@ -10,6 +10,15 @@ local utils = require("jj.utils")
--- @field change string The type of change in the file
--- @field diff_cmd string The command to get the diff of the file
--- @class jj.picker.log_line
--- @field symbol string The symbol of the log entry
--- @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 commit_id string The commit id of the log entry
--- @field description string The description of the log entry
--- @field diff_cmd string The command to get the diff of the file
local M = {
--- @type jj.picker.config
config = {
@@ -73,4 +82,83 @@ function M.status()
end
end
---Parse jj log oneline
---@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 format =
"jj log %s -r 'all()' -T builtin_log_oneline --config 'template-aliases.\"format_timestamp(timestamp)\"=timestamp'"
local output, ok = utils.execute_command(string.format(format, file_path))
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
-- Skip root line and elided revisions
local not_empty = line:match("%S")
local root = line:match("root%(%)")
local elided = line:match("~%s*%(elided revisions%)%s*$")
if not_empty and not root and not elided then
-- Pattern: [symbol] [rev] [author] [time] [commit_id] [description]
-- Example: @ w ngou0210 10 seconds ago e (no description set)
-- Split at the first space after the symbol
local first_space = line:find("%s")
if not first_space then
goto continue
end
-- Split the line into parts
-- The first part is the symbol, the second part is the revision, the third part is the author,
local symbol = line:sub(1, first_space - 1)
local rest_of_line = line:sub(first_space + 1)
local rev, author, time_part, commit_id, description =
rest_of_line:match("^%s*(%S+)%s+(%S+)%s+(.-)%s+([%w]+)%s+(.*)$")
-- It does not make much sense to show the current commit
if symbol and symbol ~= "@" then
if rev and author and commit_id then
table.insert(file_history, {
symbol = symbol or "",
rev = rev,
author = author,
time = time_part or "",
commit_id = commit_id,
description = description or "",
text = line,
diff_cmd = string.format("jj diff %s -r %s --stat --git", file_path, rev),
})
end
end
end
::continue::
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 M.config.snacks then
require("jj.picker.snacks").file_log_history(M.config, log_lines)
else
return utils.notify("No `Picker` enabled", vim.log.levels.INFO)
end
end
return M
+124
View File
@@ -37,4 +37,128 @@ function M.status(opts, files)
snacks.picker.pick(merged_opts)
end
local function format_jj_log(item, picker)
local a = Snacks.picker.util.align
local ret = {} ---@type snacks.picker.Highlight[]
-- Add symbol (if available) and revision
if item.symbol and item.symbol ~= "" then
ret[#ret + 1] = { a(item.symbol, 1, { truncate = true }), "SnacksPickerGitMsg" }
else
ret[#ret + 1] = { "?", "SnacksPickerGitMsg" }
end
ret[#ret + 1] = { " " }
local rev = item.rev or "unknown"
-- INFO: This highlight is kind of a nice hack to avoid doing my own highlighs for the moment
--- At some point i'll probably do mines
ret[#ret + 1] = { a(rev, 4, { truncate = true }), "SnacksPickerGitBreaking" }
if #rev >= 4 then
ret[#ret + 1] = { " " }
end
if item.author then
ret[#ret + 1] = { a(item.author, 8, { truncate = true }), "SnacksPcikerGitMsg" }
if #item.author >= 8 then
ret[#ret + 1] = { " " }
end
end
if item.time then
local year, month, day = item.time:match("(%d+)-(%d+)-(%d+)")
local formatted = string.format("%s-%s-%s", year, day, month)
ret[#ret + 1] = { a(formatted, 10), "SnacksPickerGitDate" }
if #formatted >= 10 then
ret[#ret + 1] = { " " }
end
end
if item.commit_id then
ret[#ret + 1] = { a(item.commit_id, 4, { truncate = true }), "SnacksPickerGitCommit" }
if #item.commit_id >= 4 then
ret[#ret + 1] = { " " }
end
end
-- This comes from snacks git description formattedr
local desc = item.description or ""
local type, scope, breaking, body = desc:match("^(%S+)%s*(%(.-%))(!?):%s*(.*)$")
if not type then
type, breaking, body = desc:match("^(%S+)(!?):%s*(.*)$")
end
local msg_hl = "SnacksPickerGitMsg"
if type and body then
local dimmed = vim.tbl_contains({ "chore", "bot", "build", "ci", "style", "test" }, type)
msg_hl = dimmed and "SnacksPickerDimmed" or "SnacksPickerGitMsg"
ret[#ret + 1] = {
type,
breaking ~= "" and "SnacksPickerGitBreaking" or dimmed and "SnacksPickerBold" or "SnacksPickerGitType",
}
if scope and scope ~= "" then
ret[#ret + 1] = { scope, "SnacksPickerGitScope" }
end
if breaking ~= "" then
ret[#ret + 1] = { "!", "SnacksPickerGitBreaking" }
end
ret[#ret + 1] = { ":", "SnacksPickerDelim" }
ret[#ret + 1] = { " " }
desc = body
end
ret[#ret + 1] = { desc, msg_hl }
return ret
end
---@param opts jj.picker.config
---@param log_lines jj.picker.log_line[]
function M.file_log_history(opts, log_lines)
if not opts.snacks then
return utils.notify("Snacks picker is `disabled`", vim.log.levels.INFO)
end
local snacks = require("snacks")
local snacks_opts
-- If its true we default to an empty table
if opts.snacks == true then
snacks_opts = {}
else
--- Otherwise we get the table from the config
---@type table
snacks_opts = opts.snacks
end
local merged_opts = vim.tbl_deep_extend("force", snacks_opts, {
source = "jj",
items = log_lines,
title = "JJ Log",
format = format_jj_log,
confirm = function(picker, item)
picker:close()
if not item or not item.rev then
return
end
local _, ok = utils.execute_command(
string.format("jj edit %s --ignore-immutable", item.rev),
string.format("could not edit revision '%s'", item.rev)
)
if ok then
utils.notify(string.format("Editing revision `%s`", item.rev), vim.log.levels.INFO)
end
end,
preview = function(ctx)
if ctx.item.rev and ctx.item.diff_cmd then
snacks.picker.preview.cmd(ctx.item.diff_cmd, ctx, {})
end
end,
})
snacks.picker.pick(merged_opts)
end
return M