Something went wrong. Try again.
jujutsu file annotations for neovim (like blame)
Something went wrong. Try again.
21 kB · 725 lines
Lua
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726local backend = require("jjannotate.backend")local cache = require("jjannotate.cache")local color = require("jjannotate.color")local util = require("jjannotate.util")
---@type jjannotate.Configlocal defaults = { view = { collapse_repeating_lines = true, highlight_changed_lines = true, highlight_current_change = "bold", palette = "auto", loading_delay = 100, }, mappings = { close = "q", show = "<CR>", follow = "f", prev_change = "[[", next_change = "]]", },}
local ns = vim.api.nvim_create_namespace("jjannotate")local ns_hl = vim.api.nvim_create_namespace("jjannotate.hl")
local win_opts = { scrollbind = true, cursorbind = true, cursorline = true, wrap = false }local win_opts_neg = vim.tbl_map(function(v) return not v end, win_opts)
local function set_win_opts(wins, opts) for _, win in ipairs(wins) do if vim.api.nvim_win_is_valid(win) then for opt, value in pairs(opts) do vim.api.nvim_set_option_value(opt, value, { win = win }) end end endend
---@class jjannotate.CachedHl---@field fg string---@field fg_bold string---@field bg string
local hl_cache = {} ---@type table<string, jjannotate.CachedHl?>
local change_id_hls = { fg = "JjAnnotateChangeId", fg_bold = "JjAnnotateChangeIdBold" }local function update_change_id_hls() local cmt = vim.api.nvim_get_hl(0, { name = "Comment" }) vim.api.nvim_set_hl(0, "JjAnnotateChangeId", { fg = cmt.fg, italic = cmt.italic, default = true }) vim.api.nvim_set_hl( 0, "JjAnnotateChangeIdBold", { fg = cmt.fg, italic = cmt.italic, default = true, bold = true } )endupdate_change_id_hls()
---@param change_id string---@param palette jjannotate.Config.ResolvedPalette---@return jjannotate.CachedHllocal function change_id_hl(change_id, palette) if hl_cache[change_id] then return hl_cache[change_id] end
local fg = "JjAnnotateFg-" .. change_id vim.api.nvim_set_hl( 0, fg, { fg = color.stable_from_string(change_id, palette.fg.sat, palette.fg.light) } )
local fg_bold = "JjAnnotateFgBold-" .. change_id vim.api.nvim_set_hl(0, fg_bold, { fg = color.stable_from_string(change_id, palette.fg_bold.sat, palette.fg_bold.light), bold = true, })
local bg = "JjAnnotateBg-" .. change_id vim.api.nvim_set_hl( 0, bg, { bg = color.stable_from_string(change_id, palette.bg.sat, palette.bg.light) } )
hl_cache[change_id] = { fg = fg, fg_bold = fg_bold, bg = bg }
return hl_cache[change_id]end
---@class jjannotate.RevisionRef---@field repo string repo root---@field path string path within `repo`---@field change_id string
---@class jjannotate.Revision : jjannotate.RevisionRef---@field backend jjannotate.Backend
---@class jjannotate.Window---@field id integer---@field augroup integer---@field revision jjannotate.Revision---@field annotate { win: integer, buf: integer }---@field file { win: integer, buf: integer }---@field file_opts table<string, any>---@field result jjannotate.AnnotateResult?---@field opts jjannotate.Configlocal Window = {}Window.__index = Window
---@param win integer?---@return integerlocal function cursor_row(win) return vim.api.nvim_win_get_cursor(win or 0)[1] end
---@param revision jjannotate.Revision---@param focus boolean?function Window.open(revision, focus) local opts = vim.tbl_deep_extend("force", defaults, vim.g.jjannotate_opts or {}) local w ---@type jjannotate.Window? local done = false
---@param result jjannotate.AnnotateResult? local function apply(result) if done then return end done = true
if w then w:_set_result(result) elseif result then w = Window.open_result(revision, result, opts, focus) end end
local timer if opts.view.loading_delay then timer = vim.defer_fn(function() timer = nil if done then return end w = Window.open_result(revision, nil, opts, focus) if not w then done = true end end, opts.view.loading_delay) end
revision.backend.annotate(revision.repo, revision.path, revision.change_id, function(result) vim.schedule(function() if timer then timer:stop() timer:close() timer = nil end apply(result) end) end)end
---@param revision jjannotate.Revision---@return integerlocal function get_or_create_buf(revision) local bufname = util.revision_uri("annotate", revision) local buf = vim.fn.bufnr(bufname) if buf ~= -1 then return buf end
local new_buf = vim.api.nvim_create_buf(false, true) vim.b[new_buf].jjannotate = true vim.api.nvim_buf_set_name(new_buf, bufname) return new_bufend
---@param buf integer---@return integerlocal function get_or_create_win(buf) local win = vim.fn.win_findbuf(buf)[1] if win then return win end
return vim.api.nvim_open_win(buf, false, { split = "left" })end
---@param tab integer---@param revision jjannotate.Revision---@return { buf: integer, win: integer }?local function find_file(tab, revision) local uri = util.revision_uri("file", revision) local wins = vim.api.nvim_tabpage_list_wins(tab) return vim .iter(wins) :map(function(win) local buf = vim.api.nvim_win_get_buf(win) local name = vim.api.nvim_buf_get_name(buf)
if name == uri then return { buf = buf, win = win } end if name:match("^jjannotate://") then return nil end
local info = backend.buf_info(buf) if info and info.repo == revision.repo and info.path == revision.path then return { buf = buf, win = win } end end) :find(function(x) return x ~= nil end)end
---@param revision jjannotate.Revision---@param result jjannotate.AnnotateResult?---@param opts jjannotate.Config---@param focus boolean?---@return jjannotate.Window?function Window.open_result(revision, result, opts, focus) if focus == nil then focus = true end
local buf = get_or_create_buf(revision) vim.b[buf].jjannotate = true local win = get_or_create_win(buf)
local file = find_file(vim.api.nvim_win_get_tabpage(win), revision) if not file then util.notify( string.format( "Could not find file window for `%s` at change id %s", vim.fs.joinpath(revision.repo, revision.path), revision.change_id ), vim.log.levels.ERROR ) return end
--- @type jjannotate.Window local w = { id = win, augroup = vim.api.nvim_create_augroup("jjannotate_" .. win, { clear = true }), revision = revision, annotate = { win = win, buf = buf }, file = file, file_opts = {}, result = result, opts = opts, } w = setmetatable(w, Window) cache:set_win(revision, w)
vim.bo[w.annotate.buf].filetype = "jjannotate" vim.bo[w.annotate.buf].buftype = "nofile" vim.bo[w.annotate.buf].bufhidden = "wipe" vim.bo[w.annotate.buf].swapfile = false vim.bo[w.annotate.buf].buflisted = false
set_win_opts({ w.annotate.win }, { list = false, spell = false, signcolumn = "no", foldcolumn = "0", number = false, relativenumber = false, sidescrolloff = 0, winfixwidth = true, })
w:_render_lines() w:_setup_autocmds() w:_setup_keymaps() w:_save_file_opts() set_win_opts({ w.annotate.win, w.file.win }, win_opts)
if focus then vim.api.nvim_set_current_win(w.annotate.win) else w:_highlight_changed_lines() end
vim.api.nvim_exec_autocmds("User", { pattern = "JjAnnotateOpen:" .. w.annotate.win, data = { id = w.id }, })
return wend
---@param result jjannotate.AnnotateResult?function Window:_set_result(result) if not self:is_open() then return end
self.result = result if result then self:_render_lines() self:_highlight_changed_lines() else self:_set_lines({ "No annotate" }) endend
function Window:update() self.revision.backend.annotate( self.revision.repo, self.revision.path, self.revision.change_id, function(result) vim.schedule(function() if not vim.api.nvim_win_is_valid(self.annotate.win) or not vim.api.nvim_win_is_valid(self.file.win) then return end
if not result then return end
set_win_opts({ self.annotate.win, self.file.win }, win_opts_neg)
self.result = result self:_render_lines() self:_highlight_changed_lines()
set_win_opts({ self.annotate.win, self.file.win }, win_opts)
vim.api.nvim_exec_autocmds("User", { pattern = "JjAnnotateUpdate:" .. self.annotate.win, data = { id = self.id }, }) end) end )end
function Window:close() if not self:is_open() then return false end vim.api.nvim_win_call(self.annotate.win, function() vim.cmd.quit() end) return trueend
function Window:_cleanup_file() if vim.api.nvim_buf_is_valid(self.file.buf) then vim.api.nvim_buf_clear_namespace(self.file.buf, ns, 0, -1) vim.api.nvim_buf_clear_namespace(self.file.buf, ns_hl, 0, -1) end
set_win_opts({ self.file.win }, self.file_opts)end
function Window:_cleanup() vim.api.nvim_del_augroup_by_id(self.augroup)
if vim.api.nvim_buf_is_valid(self.annotate.buf) then vim.api.nvim_buf_clear_namespace(self.annotate.buf, ns, 0, -1) vim.api.nvim_buf_clear_namespace(self.annotate.buf, ns_hl, 0, -1) end
self:_cleanup_file()
vim.api.nvim_exec_autocmds("User", { pattern = "JjAnnotateClose:" .. self.annotate.win, data = { id = self.id }, })
cache:del_win(self.id)end
---@return booleanfunction Window:is_open() return vim.api.nvim_win_is_valid(self.annotate.win) end
---@param longest_line integerfunction Window:_update_win(longest_line) if not vim.api.nvim_win_is_valid(self.annotate.win) then return end
-- update win width local textoff = vim.fn.getwininfo(self.annotate.win)[1].textoff vim.api.nvim_win_set_width(self.annotate.win, longest_line + textoff + 3)
if not vim.api.nvim_win_is_valid(self.file.win) then return end
-- don't sync cursor when annotate buffer is shorter than file's cursor row local row = cursor_row(self.file.win) if row > vim.api.nvim_buf_line_count(self.annotate.buf) then return end
local topline = vim.fn.line("w0", self.file.win) vim.api.nvim_win_call(self.annotate.win, function() vim.fn.winrestview({ topline = topline }) end) vim.api.nvim_win_set_cursor(self.annotate.win, { row, 0 })end
function Window:_save_file_opts() for opt, _ in pairs(win_opts) do self.file_opts[opt] = vim.api.nvim_get_option_value(opt, { win = self.file.win }) endend
---@param file { buf: integer, win: integer }function Window:_set_file(file) local info = backend.buf_info(file.buf) if not info then self:_set_lines({ "No annotate" }) return end
self:_cleanup_file() self.file = file self:_save_file_opts() self:_setup_autocmds()
info.backend.resolve_revision(info.repo, nil, function(change_id) if not change_id then self:_set_lines({ "No annotate" }) return end cache:del_win(self.id) self.revision = { repo = info.repo, path = info.path, change_id = change_id, backend = info.backend } cache:set_win(self.revision, self) self:update() end)end
-- Highlight lines in both annotate and file windows that were-- changed in the change id under the cursor.function Window:_highlight_changed_lines() if not self.result or not self.opts.view.highlight_changed_lines or not vim.api.nvim_buf_is_valid(self.annotate.buf) or not vim.api.nvim_buf_is_valid(self.file.buf) then return end
local change_id = self:_change_id_at_cursor() local lines = self.result.change_id_index[change_id] if not lines then return end local bg_hl = change_id_hl(change_id, color.resolve_palette(self.opts.view.palette)).bg
vim.api.nvim_buf_clear_namespace(self.annotate.buf, ns_hl, 0, -1) vim.api.nvim_buf_clear_namespace(self.file.buf, ns_hl, 0, -1)
for _, line in ipairs(lines) do vim.api.nvim_buf_set_extmark(self.annotate.buf, ns_hl, line.range.start, 0, { end_row = line.range._end + 1, hl_group = bg_hl, hl_eol = true, strict = false, }) vim.api.nvim_buf_set_extmark(self.file.buf, ns_hl, line.range.start, 0, { end_row = line.range._end + 1, hl_group = bg_hl, hl_eol = true, strict = false, }) endend
---@param lines string[]function Window:_set_lines(lines) vim.bo[self.annotate.buf].modifiable = true vim.bo[self.annotate.buf].readonly = false vim.api.nvim_buf_set_lines(self.annotate.buf, 0, -1, false, lines) vim.bo[self.annotate.buf].modifiable = false vim.bo[self.annotate.buf].readonly = trueend
function Window:_render_lines() if not vim.api.nvim_buf_is_valid(self.annotate.buf) then return end
if not self.result then self:_set_lines({ "Loading..." }) self:_update_win(vim.fn.strdisplaywidth("Loading...")) return end
local lines = {} local longest_line = 0 for _, line in ipairs(self.result.lines) do local rendered = string.format( "%s %-" .. self.result.longest_timestamp .. "s %s", line.change_id, line.timestamp, line.author ) table.insert(lines, rendered) longest_line = math.max(longest_line, vim.fn.strdisplaywidth(rendered)) local filler = self.opts.view.collapse_repeating_lines and "" or rendered if line.range._end > line.range.start then for _ = 1, line.range._end - line.range.start do table.insert(lines, filler) end end end
self:_set_lines(lines) self:_apply_highlights()
self:_update_win(longest_line)end
function Window:_apply_highlights() local resolved = color.resolve_palette(self.opts.view.palette) vim.api.nvim_buf_clear_namespace(self.annotate.buf, ns, 0, -1)
local current_change_id_hl_group = change_id_hls.fg if self.opts.view.highlight_current_change == "bold" then current_change_id_hl_group = change_id_hls.fg_bold end
for _, line in ipairs(self.result.lines) do local change_id = line.change_id local is_current = change_id == self.revision.change_id local hl = change_id_hl(change_id, resolved)
for row = line.range.start, line.range._end do vim.api.nvim_buf_set_extmark(self.annotate.buf, ns, row, 0, { end_col = #change_id, hl_group = is_current and current_change_id_hl_group or change_id_hls.fg, strict = false, })
local hl_group = hl.fg if is_current and self.opts.view.highlight_current_change == "bold" then hl_group = hl.fg_bold end
vim.api.nvim_buf_set_extmark( self.annotate.buf, ns, row, #change_id, { end_col = -1, hl_group = hl_group, strict = false } ) end endend
function Window:_setup_autocmds() vim.api.nvim_clear_autocmds({ group = self.augroup })
vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, { desc = "Update jjannotate when the file buffer changes", group = self.augroup, buffer = self.file.buf, callback = function() self:update() end, })
vim.api.nvim_create_autocmd("BufWinEnter", { desc = "Update jjannotate when file window's buffer changes", group = self.augroup, pattern = "*", callback = function(args) local win = vim.api.nvim_get_current_win() if win ~= self.file.win then return end self:_set_file({ buf = args.buf, win = win }) end, })
vim.api.nvim_create_autocmd("WinClosed", { desc = "Close or re-attach jj annotate when its file window is closed", group = self.augroup, pattern = tostring(self.file.win), callback = function() local file = find_file(vim.api.nvim_get_current_tabpage(), self.revision) if file then self:_set_file(file) else self:close() end end, })
vim.api.nvim_create_autocmd({ "BufHidden", "BufUnload" }, { desc = "Cleanup jjannotate", group = self.augroup, once = true, buffer = self.annotate.buf, callback = function() self:_cleanup() end, })
if self.opts.view.highlight_changed_lines then vim.api.nvim_create_autocmd("CursorMoved", { desc = "Highlight changed lines in jjannotate buffer", group = self.augroup, callback = function(args) if args.buf == self.annotate.buf or args.buf == self.file.buf then self:_highlight_changed_lines() end end, }) end
if self.opts.view.palette == "auto" or self.opts.view.palette.auto then local function refresh_highlights() hl_cache = {} update_change_id_hls() self:_render_lines() self:_highlight_changed_lines() end vim.api.nvim_create_autocmd("OptionSet", { desc = "Refresh jjannotate palette on background change", group = self.augroup, pattern = "background", callback = refresh_highlights, }) vim.api.nvim_create_autocmd("ColorScheme", { desc = "Refresh jjannotate palette on colorscheme change", group = self.augroup, callback = refresh_highlights, }) endend
function Window:_action_show() local change_id = self:_change_id_at_cursor() if not change_id then return end self.revision.backend.show(self.revision.repo, change_id)endfunction Window:_action_follow() local change_id = self:_change_id_at_cursor() if not change_id then return end
if change_id == self.revision.change_id then util.notify("Already on this change", vim.log.levels.INFO) self:update() return end
---@type jjannotate.Revision local revision = vim.tbl_extend("force", {}, self.revision, { change_id = change_id })
-- if there is already an annotate tab for this change, switch to it local existing = cache:get_by_revision(revision) if existing and existing:is_open() then local tab = vim.api.nvim_win_get_tabpage(existing.file.win) vim.api.nvim_set_current_tabpage(tab) vim.api.nvim_set_current_win(existing.annotate.win) return end
self.revision.backend.file_contents(revision.repo, revision.path, change_id, function(contents) vim.schedule(function() if contents == nil then return end
vim.cmd.tabnew() local tab = vim.api.nvim_get_current_tabpage()
local buf = vim.api.nvim_get_current_buf() vim.bo[buf].filetype = vim.filetype.match({ buf = buf, filename = revision.path }) or "" -- vim.bo[buf].buftype = "nofile" vim.bo[buf].bufhidden = "wipe" vim.bo[buf].buflisted = false vim.bo[buf].swapfile = false
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(contents, "\n")) vim.api.nvim_buf_set_name(buf, util.revision_uri("file", revision))
vim.bo[buf].modified = false vim.bo[buf].modifiable = false vim.bo[buf].readonly = true
local function tabclose() if vim.api.nvim_tabpage_is_valid(tab) then vim.cmd.tabclose(vim.api.nvim_tabpage_get_number(tab)) end end
vim.keymap.set("n", "q", tabclose, { buffer = buf })
vim.api.nvim_create_autocmd("User", { pattern = "JjAnnotateOpen:*", once = true, callback = function(args) local w = cache:get_by_id(args.data.id) if not w then return end vim.api.nvim_create_autocmd("WinClosed", { pattern = tostring(w.annotate.win), once = true, callback = tabclose, }) end, }) Window.open(revision) end) end)end
---@type table<string, { fn: fun(w: jjannotate.Window), desc: string }>local mapping_data = { close = { fn = Window.close, desc = "Close" }, show = { fn = Window._action_show, desc = "Show revision changes" }, follow = { fn = Window._action_follow, desc = "Follow revision (re-annotate)" }, prev_change = { fn = function(w) w:_move_change("prev") end, desc = "Previous change" }, next_change = { fn = function(w) w:_move_change("next") end, desc = "Next change" },}
function Window:_setup_keymaps() for name, data in pairs(mapping_data) do local mapping = self.opts.mappings[name] --[[@as string | boolean]] if type(mapping) == "string" and mapping ~= "" then vim.keymap.set("n", mapping, function() data.fn(self) end, { buffer = self.annotate.buf, desc = data.desc, }) end endend
---@param dir "prev" | "next"function Window:_move_change(dir) if not self.result then return end local row = cursor_row(self.annotate.win) - 1 local lines = self.result.lines local target --[[@type jjannotate.Line?]] if dir == "next" then target = vim.iter(lines):find(function(line) return line.range.start > row end) else target = vim.iter(lines):filter(function(line) return line.range._end < row end):last() end if target then local line = target.range.start + 1 vim.api.nvim_win_set_cursor(self.annotate.win, { line, 0 }) if vim.api.nvim_win_is_valid(self.file.win) then vim.api.nvim_win_set_cursor(self.file.win, { line, 0 }) end endend
---@return string?function Window:_change_id_at_cursor() if not self.result then return nil end local win = vim.api.nvim_get_current_win() if win ~= self.annotate.win and win ~= self.file.win then win = self.annotate.win end local line = self.result.row_index[cursor_row(win) - 1] return line and line.change_id or nilend
return Window