jujutsu file annotations for neovim (like blame)
4

Configure Feed

Select the types of activity you want to include in your feed.

highlight lines related to cursor's change id

Signed-off-by: Ron Shavit <me@ronshavit.com>

Ron Shavit (May 27, 2026, 1:50 AM +0300) 564b141a 78705f37

+100 -31
+2
lua/jjannotate.lua
··· 14 14 ---@class jjannotate.ParseResult 15 15 ---@field lines jjannotate.Line[] 16 16 ---@field longest_timestamp integer 17 + ---@field row_index jjannotate.Line[] 18 + ---@field change_id_index table<string, jjannotate.Line[]> 17 19 18 20 ---@class jjannotate.Range 19 21 ---@field start integer 0-indexed inclusive
+4 -6
lua/jjannotate/color.lua
··· 11 11 h = bit.bxor(h, string.byte(str, i)) 12 12 h = (h * 16777619) % 2 ^ 32 13 13 end 14 - -- mix some bits so similar strings produce very different hashes 15 - h = bit.bxor(h, bit.rshift(h, 16)) 16 - h = (h * 0x45d9f3b) % 2 ^ 32 17 - h = bit.bxor(h, bit.rshift(h, 16)) 18 14 return h 19 15 end 20 16 ··· 48 44 49 45 --- Returns a stable "golden ratio" color from a given string, in hex format. 50 46 ---@param str string 47 + ---@param sat number 48 + ---@param light number 51 49 ---@return string 52 - function M.stable_from_string(str) 50 + function M.stable_from_string(str, sat, light) 53 51 local hue = ((hash(str) * GOLDEN_RATIO_CONJUGATE) % 1) * 360 54 - return hsl_to_hex(hue, 0.5, 0.7) 52 + return hsl_to_hex(hue, sat, light) 55 53 end 56 54 57 55 return M
+13 -1
lua/jjannotate/jj.lua
··· 16 16 local lines = {} ---@type jjannotate.Line[] 17 17 local longest_timestamp = 0 18 18 local last_change_id ---@type string | nil 19 + local row_index = {} ---@type jjannotate.Line[] 20 + local change_id_index = {} ---@type table<string, jjannotate.Line[]> 19 21 20 22 for i, line in ipairs(vim.split(raw, "\n")) do 21 23 if #line > 0 then ··· 30 32 31 33 if parsed.change_id == last_change_id then 32 34 lines[#lines].range._end = row 35 + row_index[row] = lines[#lines] 33 36 else 34 37 table.insert(lines, parsed) 35 38 longest_timestamp = math.max(longest_timestamp, #parsed.timestamp) 39 + row_index[row] = parsed 40 + local cid = parsed.change_id 41 + if not change_id_index[cid] then change_id_index[cid] = {} end 42 + table.insert(change_id_index[cid], parsed) 36 43 end 37 44 38 45 last_change_id = parsed.change_id 39 46 end 40 47 end 41 48 42 - return { lines = lines, longest_timestamp = longest_timestamp } 49 + return { 50 + lines = lines, 51 + longest_timestamp = longest_timestamp, 52 + row_index = row_index, 53 + change_id_index = change_id_index, 54 + } 43 55 end 44 56 45 57 ---@param path string
+81 -24
lua/jjannotate/view.lua
··· 1 1 local color = require("jjannotate.color") 2 2 3 3 ---@param path string 4 - ---@return jjannotate.BufWin | nil 4 + ---@return { win: integer, buf: integer} | nil 5 5 local function find_visible_path(path) 6 6 local target = vim.fn.fnamemodify(path, ":p") 7 7 for _, win in ipairs(vim.api.nvim_list_wins()) do ··· 13 13 ---@class jjannotate.View 14 14 ---@field win integer? 15 15 ---@field buf integer? 16 - ---@field original_win integer? 16 + ---@field original { win: integer, buf: integer }? 17 17 ---@field original_opts table<string, any> 18 + ---@field parsed jjannotate.ParseResult? 18 19 ---@field ns integer 19 20 20 - ---@class jjannotate.BufWin 21 - ---@field win integer 22 - ---@field buf integer 23 - 24 21 local View = {} 25 22 View.__index = View 26 23 ··· 31 28 wrap = false, 32 29 } 33 30 34 - local hl_cache = {} 31 + local hl_cache = {} ---@type table<string, { full: string, dim: string }> 35 32 36 33 function View:new() 37 34 return setmetatable({ 38 35 win = nil, 39 36 buf = nil, 40 - original_win = nil, 37 + original = nil, 41 38 original_opts = {}, 39 + parsed = nil, 42 40 ns = vim.api.nvim_create_namespace("jjannotate"), 41 + ns_hl = vim.api.nvim_create_namespace("jjannotate.hl"), 42 + augroup = vim.api.nvim_create_augroup("jjannotate", { clear = true }), 43 43 }, self) 44 44 end 45 45 46 + ---@return boolean 46 47 function View:is_open() return self.win and vim.api.nvim_win_is_valid(self.win) end 47 48 48 49 ---@return boolean whether the view closed ··· 54 55 return false 55 56 end 56 57 58 + ---@param path string 59 + ---@param parsed jjannotate.ParseResult 60 + ---@param opts jjannotate.Opts 57 61 function View:open(path, parsed, opts) 62 + self.parsed = parsed 63 + 58 64 -- create buffer 59 65 self.buf = self.buf or vim.api.nvim_create_buf(false, true) 60 66 vim.bo[self.buf].modifiable = true ··· 81 87 local change_id = line.change_id 82 88 83 89 if not hl_cache[change_id] then 84 - local hl_name = "JJAnnotate" .. change_id 85 - vim.api.nvim_set_hl(0, hl_name, { fg = color.stable_from_string(change_id) }) 86 - hl_cache[change_id] = hl_name 90 + local full = "JJAnnotate" .. change_id 91 + local dim = "JJAnnotateDim" .. change_id 92 + vim.api.nvim_set_hl(0, full, { fg = color.stable_from_string(change_id, 0.6, 0.7) }) 93 + vim.api.nvim_set_hl(0, dim, { bg = color.stable_from_string(change_id, 0.3, 0.125) }) 94 + hl_cache[change_id] = { full = full, dim = dim } 87 95 end 88 96 89 - local hl = hl_cache[change_id] 97 + local hl = hl_cache[change_id].full 90 98 for row = line.range.start, line.range._end do 91 99 vim.api.nvim_buf_set_extmark( 92 100 self.buf, ··· 113 121 vim.api.nvim_win_set_width(self.win, longest_line + textoff + 3) 114 122 vim.wo[self.win].winfixwidth = true 115 123 116 - local original = find_visible_path(path) 117 - if original then 118 - self.original_win = original.win 124 + self.original = find_visible_path(path) 125 + if self.original then 119 126 for opt, value in pairs(enabled_opts) do 120 - self.original_opts[opt] = vim.api.nvim_get_option_value(opt, { win = original.win }) 121 - vim.api.nvim_set_option_value(opt, value, { win = original.win }) 127 + self.original_opts[opt] = vim.api.nvim_get_option_value(opt, { win = self.original.win }) 128 + vim.api.nvim_set_option_value(opt, value, { win = self.original.win }) 122 129 vim.api.nvim_set_option_value(opt, value, { win = self.win }) 123 130 end 124 131 end ··· 134 141 135 142 self:_autocmds() 136 143 137 - if original then vim.api.nvim_win_set_cursor(self.win, { vim.api.nvim_win_get_cursor(original.win)[1], 0 }) end 144 + if self.original then 145 + vim.api.nvim_win_set_cursor(self.win, { vim.api.nvim_win_get_cursor(self.original.win)[1], 0 }) 146 + end 138 147 vim.api.nvim_set_current_win(self.win) 139 148 vim.cmd.syncbind() 140 149 end 141 150 142 151 function View:_autocmds() 152 + local function cursor_moved() 153 + local row = vim.api.nvim_win_get_cursor(self.win)[1] - 1 154 + local change_id = self.parsed.row_index[row].change_id 155 + local lines = self.parsed.change_id_index[change_id] 156 + local dim_hl = hl_cache[change_id].dim 157 + 158 + vim.api.nvim_buf_clear_namespace(self.buf, self.ns_hl, 0, -1) 159 + vim.api.nvim_buf_clear_namespace(self.original.buf, self.ns_hl, 0, -1) 160 + 161 + for _, line in ipairs(lines) do 162 + vim.api.nvim_buf_set_extmark(self.buf, self.ns_hl, line.range.start, 0, { 163 + end_row = line.range._end + 1, 164 + hl_group = dim_hl, 165 + hl_eol = true, 166 + strict = false, 167 + }) 168 + vim.api.nvim_buf_set_extmark(self.original.buf, self.ns_hl, line.range.start, 0, { 169 + end_row = line.range._end + 1, 170 + hl_group = dim_hl, 171 + hl_eol = true, 172 + strict = false, 173 + }) 174 + end 175 + end 176 + 177 + vim.api.nvim_create_autocmd("CursorMoved", { 178 + group = self.augroup, 179 + buffer = self.buf, 180 + callback = vim.schedule_wrap(cursor_moved), 181 + }) 182 + 183 + if self.original then 184 + vim.api.nvim_create_autocmd("CursorMoved", { 185 + group = self.augroup, 186 + buffer = self.original.buf, 187 + callback = vim.schedule_wrap(cursor_moved), 188 + }) 189 + end 190 + 143 191 vim.api.nvim_create_autocmd({ "BufHidden", "BufUnload" }, { 192 + group = self.augroup, 144 193 once = true, 145 194 buffer = self.buf, 146 - callback = vim.schedule_wrap(function() 147 - if self.original_win and vim.api.nvim_win_is_valid(self.original_win) then 148 - for opt, value in pairs(self.original_opts) do 149 - vim.api.nvim_set_option_value(opt, value, { win = self.original_win }) 195 + callback = function() 196 + vim.api.nvim_clear_autocmds({ group = self.augroup }) 197 + 198 + vim.api.nvim_buf_clear_namespace(self.buf, self.ns_hl, 0, -1) 199 + 200 + if self.original then 201 + vim.api.nvim_buf_clear_namespace(self.original.buf, self.ns_hl, 0, -1) 202 + if vim.api.nvim_win_is_valid(self.original.win) then 203 + for opt, value in pairs(self.original_opts) do 204 + vim.api.nvim_set_option_value(opt, value, { win = self.original.win }) 205 + end 150 206 end 151 207 end 208 + 152 209 self.win = nil 153 210 self.buf = nil 154 - self.original_win = nil 211 + self.original = nil 155 212 self.original_opts = {} 156 - end), 213 + end, 157 214 }) 158 215 end 159 216