local backend = require("jjannotate.backend") local cache = require("jjannotate.cache") local color = require("jjannotate.color") local util = require("jjannotate.util") ---@type jjannotate.Config local defaults = { view = { collapse_repeating_lines = true, highlight_changed_lines = true, highlight_current_change = "bold", palette = "auto", loading_delay = 100, }, mappings = { close = "q", show = "", 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 end end ---@class jjannotate.CachedHl ---@field fg string ---@field fg_bold string ---@field bg string local hl_cache = {} ---@type table 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 } ) end update_change_id_hls() ---@param change_id string ---@param palette jjannotate.Config.ResolvedPalette ---@return jjannotate.CachedHl local 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 ---@field result jjannotate.AnnotateResult? ---@field opts jjannotate.Config local Window = {} Window.__index = Window ---@param win integer? ---@return integer local 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 integer local 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_buf end ---@param buf integer ---@return integer local 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 w end ---@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" }) end end 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 true end 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 boolean function Window:is_open() return vim.api.nvim_win_is_valid(self.annotate.win) end ---@param longest_line integer function 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 }) end end ---@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, }) end end ---@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 = true end 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 end end 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, }) end end 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) end function 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 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 end end ---@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 end end ---@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 nil end return Window