Feat: Annotation by file and line (#55)

This commit is contained in:
Nicolas GB
2025-12-29 11:46:35 -08:00
committed by GitHub
parent 176456b498
commit 6eb41f631e
6 changed files with 555 additions and 11 deletions
+371
View File
@@ -0,0 +1,371 @@
local M = {}
local utils = require("jj.utils")
local runner = require("jj.core.runner")
local buffer = require("jj.core.buffer")
local parser = require("jj.core.parser")
--TODO: Maybe if annotating on the file is slow we could cache the annotations.
-- Track the last annotation tooltip buffer
local last_tooltip_buf = nil
--- Sets the highlights for the blame bufer
--- @param buf integer
--- @param annotations string[]
local function setup_blame_highlighting(buf, annotations)
local ns = vim.api.nvim_create_namespace("jj_annotate")
local seen = {}
-- Define base highlight groups (link to standard groups)
vim.api.nvim_set_hl(0, "JJAnnotateDelimiter", { link = "Delimiter" })
vim.api.nvim_set_hl(0, "JJAnnotateName", { link = "String" })
vim.api.nvim_set_hl(0, "JJAnnotateDate", { link = "PreProc" })
for i, line in ipairs(annotations) do
-- Parse: "wmkslu | NicolasGB | 2025-11-23"
local id_start, id_end, change_id = line:find("^(%S+)")
local name_start, _ = line:find("|%s*(. -)%s*|")
local date_start, date_end = line:find("%d%d%d%d%-%d%d%-%d%d%s%d%d:%d%d:%d%d%s[%+%-]%d%d:%d%d")
if change_id then
-- Dynamic color for change_id
if not seen[change_id] then
seen[change_id] = true
local hash = vim.fn.sha256(change_id):sub(1, 6)
local hl_group = "JJAnnotateId" .. change_id
vim.api.nvim_set_hl(0, hl_group, { fg = "#" .. hash })
end
-- Highlight change ID
vim.api.nvim_buf_set_extmark(buf, ns, i - 1, id_start - 1, {
end_col = id_end,
hl_group = "JJAnnotateId" .. change_id,
})
end
-- Highlight delimiters
for delim_start in line:gmatch("()| ") do
vim.api.nvim_buf_set_extmark(buf, ns, i - 1, delim_start - 1, {
end_col = delim_start,
hl_group = "JJAnnotateDelimiter",
})
end
-- Highlight name (between first and second |)
if name_start then
local actual_name_start = line:find("|") + 2
local actual_name_end = line:find("|", actual_name_start) - 2
vim.api.nvim_buf_set_extmark(buf, ns, i - 1, actual_name_start - 1, {
end_col = actual_name_end + 1,
hl_group = "JJAnnotateName",
})
end
-- Highlight date
if date_start then
vim.api.nvim_buf_set_extmark(buf, ns, i - 1, date_start - 1, {
end_col = date_end,
hl_group = "JJAnnotateDate",
})
end
end
end
--- Pads correctly the annotations so that all are the same length
--- @param lines string[] The lines to format
--- @return string[]
local function align_annotations(lines)
local parsed = {}
local max_id, max_name, max_date = 0, 0, 0
for i, line in ipairs(lines) do
if line ~= "" then
local parsed_line = parser.parse_annotation_line(line)
if parsed_line then
local rev = parsed_line.rev.value
local name = parsed_line.name.value
local date = parsed_line.date.value
parsed[i] = { rev = rev, name = name, date = date }
max_id = math.max(max_id, #rev)
max_name = math.max(max_name, #name)
max_date = math.max(max_date, #date)
else
parsed[i] = { raw = line }
end
else
parsed[i] = { raw = "" }
end
end
-- Now that it has been parsed align with max values each line
local result = {}
for i, p in ipairs(parsed) do
if p.rev then
result[i] = string.format(
"%-" .. max_id .. "s | %-" .. max_name .. "s | %-" .. max_date .. "s",
p.rev,
p.name,
p.date
)
else
result[i] = p.raw
end
end
return result
end
local function handle_enter()
-- Parse the current line to extract the revset
local line = vim.api.nvim_get_current_line()
local parts = parser.parse_annotation_line(line)
if not parts or parts.rev.value == "" then
return
end
-- Get the local name
local filename = vim.b[0].jj_annotation_file
local cmd = string.format("jj diff --git -r %s %s", parts.rev.value, filename)
-- Run the command
local output, success = runner.execute_command(cmd, "Could not run diff from annotation")
if not success or not output or output == "" then
return
end
-- Create a new buffer with the filetype gitdiff and the output
local buf, win = buffer.create({
name = "jj-diff://" .. vim.fn.fnamemodify(filename, ":t") .. "//" .. parts.rev.value,
split = "tab",
filetype = "gitdiff",
bufhidden = "wipe",
})
-- Set the lines
local lines = vim.split(output, "\n", { trimempty = true })
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
buffer.set_modified(buf, false)
-- Autoclose the tab when leaving it to another buffer
vim.api.nvim_create_autocmd("BufLeave", {
buffer = buf,
callback = function()
-- Only close if actually leaving to a different buffer
if vim.api.nvim_get_current_buf() ~= buf then
if vim.api.nvim_win_is_valid(win) then
vim.cmd("tabclose")
end
end
end,
})
end
--- Annotates the current file
function M.file()
if not utils.ensure_jj() then
return
end
local template =
'join(" | ", commit.change_id().short(6), commit.author().name(), commit.author().timestamp().format("%Y-%m-%d %H:%M:%S %Z")) ++ "\n"'
local filename = vim.api.nvim_buf_get_name(0)
if filename == "" then
utils.notify("Could extract file from buffer", vim.log.levels.ERROR)
return
end
local raw_output, success = runner.execute_command(
string.format("jj file annotate %s -T '%s'", filename, template),
"Failed to annotate file"
)
if not success or not raw_output then
return
end
local annotations = vim.split(raw_output, "\n", { trimempty = true })
local size = 0
for _, line in ipairs(annotations) do
if line ~= "" then
size = math.max(size, vim.fn.strdisplaywidth(line))
end
end
-- Capture source window/buffer state BEFORE creating split
local source_win = vim.api.nvim_get_current_win()
local source_buf = vim.api.nvim_get_current_buf()
local source_topline = vim.fn.line("w0")
local source_had_scrollbind = vim.wo[source_win].scrollbind
local source_winbar = vim.wo[source_win].winbar
local buf = buffer.create({
split = "vertical",
direction = "left",
bufhidden = "wipe",
size = size + 2,
win_options = {
wrap = true,
number = false,
relativenumber = false,
cursorline = false,
signcolumn = "no",
scrollbind = true,
winbar = source_winbar,
},
})
-- Set local variables to the buffer
vim.b[buf].jj_annotation_file = filename
-- Align annotations and set the text into the buffer
annotations = align_annotations(annotations)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, annotations)
setup_blame_highlighting(buf, annotations)
buffer.set_modifiable(buf, false)
-- Get annotation window (current after buffer. create)
local annotation_win = vim.api.nvim_get_current_win()
-- Set annotation window to match source scroll position
vim.api.nvim_win_set_cursor(annotation_win, { source_topline, 0 })
vim.cmd("normal! zt")
-- Enable scrollbind on source window
vim.wo[source_win].scrollbind = true
-- Sync them
vim.cmd("syncbind")
-- Create an augroup for this annotation session so we can clean up all autocmds together
local augroup = vim.api.nvim_create_augroup("JJAnnotate" .. buf, { clear = true })
-- When source buffer leaves its window (fuzzy finder, : e, etc.)
vim.api.nvim_create_autocmd("BufLeave", {
group = augroup,
buffer = source_buf,
callback = function()
if vim.api.nvim_win_is_valid(source_win) then
vim.wo[source_win].scrollbind = false
end
end,
})
-- When source buffer comes back to a window
vim.api.nvim_create_autocmd("BufEnter", {
group = augroup,
buffer = source_buf,
callback = function()
-- Only re-enable if annotation window still exists
if vim.api.nvim_win_is_valid(annotation_win) and vim.api.nvim_buf_is_valid(buf) then
vim.wo[source_win].scrollbind = true
vim.cmd("syncbind")
end
end,
})
-- Clean up everything when annotation buffer is destroyed
vim.api.nvim_create_autocmd("BufWipeout", {
group = augroup,
buffer = buf,
once = true,
callback = function()
-- Restore original scrollbind state
if vim.api.nvim_win_is_valid(source_win) and not source_had_scrollbind then
vim.wo[source_win].scrollbind = false
end
-- Clear the augroup (removes all autocmds in it)
vim.api.nvim_del_augroup_by_id(augroup)
end,
})
-- Set the keymap enter
buffer.set_keymaps(buf, {
{ modes = { "n", "v" }, lhs = "<CR>", rhs = handle_enter, { desc = "Show diff" } },
})
end
--- Annotates the current line
function M.line()
if not utils.ensure_jj() then
return
end
-- If tooltip exists and is valid, just focus it
if last_tooltip_buf and vim.api.nvim_buf_is_valid(last_tooltip_buf) then
local win = vim.fn.bufwinid(last_tooltip_buf)
if win ~= -1 then
vim.api.nvim_set_current_win(win)
return
end
end
local template =
'join(" | ", commit.change_id().short(6), commit.author().name(), commit.author().timestamp().format("%Y-%m-%d %H:%M:%S %Z")) ++ "\n"'
local filename = vim.api.nvim_buf_get_name(0)
if filename == "" then
utils.notify("Could not extract file from buffer", vim.log.levels.ERROR)
return
end
local line_num = vim.fn.line(".")
local raw_output, success = runner.execute_command(
string.format("jj file annotate %s -T '%s'", filename, template),
"Failed to annotate line"
)
if not success or not raw_output then
return
end
local all_annotations = vim.split(raw_output, "\n", { trimempty = true })
local annotation_line = all_annotations[line_num]
if not annotation_line or annotation_line == "" then
utils.notify("Could not get annotation for line", vim.log.levels.WARN)
return
end
-- Get the description of the current rev
local parsed_line = parser.parse_annotation_line(annotation_line)
if not parsed_line then
utils.notify("Could not extract revset from annotation line", vim.log.levels.ERROR)
return
end
local desc, ok = runner.execute_command(
string.format("jj log -r %s -T 'self.description()' --no-graph", parsed_line.rev.value),
"Failed getting description"
)
if not ok or not desc then
return
end
local text = annotation_line
if desc ~= "" then
text = vim.trim(annotation_line .. ":" .. "\n" .. desc)
end
local buf, _ = buffer.create_tooltip({
text = text,
})
setup_blame_highlighting(buf, { text })
-- Store the buffer to enter it if re-executed
last_tooltip_buf = buf
-- Add autcmd to clean the cache when the buffer gets closed
vim.api.nvim_create_autocmd("BufWipeout", {
buffer = buf,
once = true,
callback = function()
last_tooltip_buf = nil
end,
})
end
return M
+9
View File
@@ -5,6 +5,7 @@ local utils = require("jj.utils")
local runner = require("jj.core.runner")
local parser = require("jj.core.parser")
local terminal = require("jj.ui.terminal")
local diff = require("jj.diff")
local log_module = require("jj.cmd.log")
local describe_module = require("jj.cmd.describe")
@@ -738,6 +739,12 @@ function M.j(args)
terminal.run(cmd, M.terminal_keymaps())
end
end,
annotate = function()
require("jj.annotate").file()
end,
annotate_line = function()
require("jj.annotate").line()
end,
}
if handlers[subcommand] then
@@ -782,6 +789,8 @@ function M.register_command()
"status",
"undo",
"open_pr",
"annotate",
"annotate_line",
}
local matches = {}
for _, cmd in ipairs(subcommands) do
+105 -9
View File
@@ -4,12 +4,15 @@ local M = {}
--- @class jj.core.buffer.opts
--- @field name? string Buffer name
--- @field split? "horizontal"|"vertical"|"tab"|"current" Split type (default: "horizontal")
--- @field direction? "left"|"right"|"top"|"bottom" Split direction (left/right for vertical, top/bottom for horizontal)
--- @field size? number Split size in lines/columns
--- @field modifiable? boolean Whether buffer is modifiable (default: true)
--- @field filetype? string Filetype to set
--- @field buftype? string Buffer type (e.g., "nofile", "acwrite", etc. - optional, defaults to scratch buffer)
--- @field bufhidden? string Buffer hidden behavior (default: "hide")
--- @field on_exit? fun(buf: number) Callback when buffer is closed
--- @field keymaps? jj.core.buffer.keymap[] Keymaps to set on the buffer
--- @field win_options? table Window-specific options to set
--- @class jj.core.buffer.keymap
--- @field modes? string|string[] Modes for the keymap (default: "n")
@@ -47,26 +50,39 @@ function M.create(opts)
local win = nil
-- Handle window/split creation
local buf
if opts.split == "vertical" then
local direction = opts.direction or "right"
local width = opts.size or math.floor(vim.o.columns / 2)
vim.cmd(string.format("vsplit | vertical resize %d", width))
if direction == "left" then
vim.cmd("leftabove vsplit")
else
vim.cmd("vsplit")
end
vim.cmd(string.format("vertical resize %d", width))
win = vim.api.nvim_get_current_win()
buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(win, buf)
elseif opts.split == "tab" then
vim.cmd("tabnew")
win = vim.api.nvim_get_current_win()
-- Use the buffer that tabnew created
buf = vim.api.nvim_get_current_buf()
elseif opts.split == "current" then
win = vim.api.nvim_get_current_win()
buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(win, buf)
else -- horizontal (default)
local direction = opts.direction or "bottom"
local height = opts.size or math.floor(vim.o.lines / 2)
vim.cmd(string.format("split | resize %d", height))
if direction == "top" then
vim.cmd("topleft split")
else
vim.cmd("split")
end
vim.cmd(string.format("horizontal resize %d", height))
win = vim.api.nvim_get_current_win()
end
-- Create buffer
local buf = vim.api.nvim_create_buf(false, true)
-- Set buffer in window if we created/got a window
if win then
buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_win_set_buf(win, buf)
end
@@ -83,6 +99,10 @@ function M.create(opts)
vim.bo[buf].swapfile = false
vim.bo[buf].buflisted = false
if opts.bufhidden then
vim.bo[buf].bufhidden = opts.bufhidden
end
-- Set filetype if provided
if opts.filetype then
vim.bo[buf].filetype = opts.filetype
@@ -93,6 +113,13 @@ function M.create(opts)
M.set_keymaps(buf, opts.keymaps)
end
-- Set window options
if opts.win_options then
for option, value in pairs(opts.win_options) do
vim.wo[win][option] = value
end
end
-- Set up cleanup autocmd if on_exit callback provided
if opts.on_exit then
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
@@ -209,6 +236,65 @@ function M.create_float(opts)
return buf, win
end
--- Creates a tooltip floating buffer
--- @param opts {text: string, timeout?: number, title?: string} Tooltip options
--- @return number buf Buffer handle
--- @return number win Window handle
function M.create_tooltip(opts)
opts = opts or {}
-- Calculate size based on text
local text = opts.text or ""
local lines = vim.split(text, "\n", { trimempty = false })
local max_width = 0
for _, line in ipairs(lines) do
max_width = math.max(max_width, vim.fn.strdisplaywidth(line))
end
-- Create float with tooltip defaults
local buf, win = M.create_float({
width = math.min(max_width + 2, vim.o.columns - 4),
height = #lines,
relative = "cursor",
row = 1,
col = 0,
style = "minimal",
border = "rounded",
title = opts.title,
modifiable = false,
buftype = "nofile",
bufhidden = "wipe",
})
-- Set the text
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modified = false
-- Close when cursor moves in other windows
vim.api.nvim_create_autocmd("CursorMoved", {
callback = function()
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_get_current_win() ~= win then
M.close(buf, true)
end
end,
})
-- Add Esc/q keymap to close tooltip
vim.keymap.set("n", "<Esc>", function()
if vim.api.nvim_buf_is_valid(buf) then
M.close(buf, true)
end
end, { buffer = buf, silent = true })
vim.keymap.set("n", "q", function()
if vim.api.nvim_buf_is_valid(buf) then
M.close(buf, true)
end
end, { buffer = buf, silent = true })
return buf, win
end
--- Close/wipe a buffer safely
--- @param buf number Buffer handle
--- @param force? boolean Force close (default: true)
@@ -272,6 +358,16 @@ function M.set_modifiable(buf, modifiable)
vim.bo[buf].modifiable = modifiable
end
--- Set buffer as modified or not
--- @param buf number Buffer handle
--- @param modified boolean Whether buffer should be modifiable
function M.set_modified(buf, modified)
if not vim.api.nvim_buf_is_valid(buf) then
return
end
vim.bo[buf].modified = modified
end
--- Stop insert mode if in the given buffer if the cursor is currently in that buffer
--- @param buf number Buffer handle
function M.stop_insert(buf)
+32 -1
View File
@@ -52,7 +52,7 @@ end
--- Parse the current line in the jj status buffer to extract file information.
--- Handles renamed files and regular status lines.
--- @return table|nil A table with {old_path = string, new_path = string, is_rename = boolean}, or nil if parsing fails
--- @return {old_path : string, new_path : string, is_rename : boolean}|nil A table with , or nil if parsing fails
function M.parse_file_info_from_status_line(line)
-- Handle renamed files: "R path/{old_name => new_name}" or "R old_path => new_path"
local rename_pattern_curly = "^R (.*)/{(.*) => ([^}]+)}"
@@ -147,4 +147,35 @@ function M.get_rev_from_log_line(line)
return revset
end
--- Given an annotation line, parses and returns its components with positions
--- @param line string The annotation line to parse
--- @return table A table with {rev = {value = string|nil, pos = {start, end}|nil}, name = {value = string|nil, pos = {start, end}|nil}, date = {value = string|nil, pos = {start, end}|nil}}
function M.parse_annotation_line(line)
local rev, name, date = line:match("^(%S+)%s*|%s*(.-)%s*|%s*(.+)$")
local result = {
rev = { value = rev },
name = { value = name },
date = { value = date },
}
if rev then
local id_start, id_end = line:find("^(%S+)")
result.rev.pos = { id_start, id_end }
end
if name then
local name_start = line:find("|") + 2
local name_end = line:find("|", name_start) - 2 -- Unsure about this one since it can have N whitespaces but we'll see
result.name.pos = { name_start, name_end + 1 }
end
if date then
local date_start, date_end = line:find("%d%d%d%d%-%d%d%-%d%d%s%d%d:%d%d:%d%d%s[%+%-]%d%d:%d%d")
result.date.pos = { date_start, date_end }
end
return result
end
return M
+1 -1
View File
@@ -80,4 +80,4 @@ function M.execute_command_async(cmd, on_success, error_prefix, input, silent)
end
end
return M
return M