diff --git a/README.md b/README.md index 52d15eb..0507b72 100644 --- a/README.md +++ b/README.md @@ -435,6 +435,43 @@ diff.open_hsplit() -- Horizontal split diff diff.open_hsplit({ rev = "@-2" }) -- Horizontal split against @-2 ``` +### Annotations + +View file blame and line history using the annotate module. Can be invoked via command or Lua API. + +**Via `:J` command:** + +```sh +:J annotate " Show blame/annotations for entire file in vertical split +:J annotate_line " Show annotation for current line in floating buffer +``` + +**Via Lua API:** + +```lua +local annotate = require("jj.annotate") +annotate.file() -- Show blame/annotations for entire file in vertical split +annotate.line() -- Show annotation for current line in a tooltip +``` + +The file annotation displays a vertical split showing: + +- Change ID (colored uniquely per commit) +- Author name +- Timestamp + +Press `` on any annotation line to view the diff for that change. + +The line annotation displays a floating tooltip with the current line's annotation and the commit description. + +Example keymaps: + +```lua +local annotate = require("jj.annotate") +vim.keymap.set("n", "ja", annotate.file, { desc = "JJ annotate file" }) +vim.keymap.set("n", "jA", annotate.line, { desc = "JJ annotate line" }) +``` + ## Example config ```lua diff --git a/lua/jj/annotate.lua b/lua/jj/annotate.lua new file mode 100644 index 0000000..afed814 --- /dev/null +++ b/lua/jj/annotate.lua @@ -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 = "", 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 diff --git a/lua/jj/cmd/init.lua b/lua/jj/cmd/init.lua index a35c9e4..90c809b 100644 --- a/lua/jj/cmd/init.lua +++ b/lua/jj/cmd/init.lua @@ -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 diff --git a/lua/jj/core/buffer.lua b/lua/jj/core/buffer.lua index 59b1dec..828cb0b 100644 --- a/lua/jj/core/buffer.lua +++ b/lua/jj/core/buffer.lua @@ -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", "", 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) diff --git a/lua/jj/core/parser.lua b/lua/jj/core/parser.lua index a38d3be..25fa3e9 100644 --- a/lua/jj/core/parser.lua +++ b/lua/jj/core/parser.lua @@ -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 diff --git a/lua/jj/core/runner.lua b/lua/jj/core/runner.lua index a9d7f94..3c170d6 100644 --- a/lua/jj/core/runner.lua +++ b/lua/jj/core/runner.lua @@ -80,4 +80,4 @@ function M.execute_command_async(cmd, on_success, error_prefix, input, silent) end end -return M \ No newline at end of file +return M