From 9e1d3f4870705aec340b55d7767884ab64a4acf4 Mon Sep 17 00:00:00 2001 From: altermo <107814000+altermo@users.noreply.github.com> Date: Tue, 7 Oct 2025 23:32:22 +0200 Subject: [PATCH] feat(runtime): undotree #35627 Problem No builtin way to visualize and navigate the undo-tree. Solution Include an "opt" plugin. --- runtime/doc/news.txt | 1 + runtime/doc/plugins.txt | 30 ++ .../dist/opt/nvim.undotree/lua/undotree.lua | 406 ++++++++++++++++++ .../opt/nvim.undotree/plugin/undotree.lua | 8 + src/gen/gen_vimdoc.lua | 2 + test/functional/plugin/undotree_spec.lua | 136 ++++++ test/old/testdir/test_help.vim | 2 +- 7 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua create mode 100644 runtime/pack/dist/opt/nvim.undotree/plugin/undotree.lua create mode 100644 test/functional/plugin/undotree_spec.lua diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index e21ceaa949..71c8ee0239 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -196,6 +196,7 @@ EDITOR "(v)iew" then run `:trust`. • |gx| in help buffers opens the online documentation for the tag under the cursor. +• |:Undotree| for visually navigating the |undo-tree| EVENTS diff --git a/runtime/doc/plugins.txt b/runtime/doc/plugins.txt index 78cbf91d41..ad10a6d461 100644 --- a/runtime/doc/plugins.txt +++ b/runtime/doc/plugins.txt @@ -38,6 +38,7 @@ Help-link Loaded Short description ~ |pi_zip.txt| Yes Zip archive explorer |spellfile.vim| Yes Install spellfile if missing |tohtml| Yes Convert buffer to html, syntax included +|undotree| No Interactive textual undotree ============================================================================== Builtin plugin: editorconfig *editorconfig* @@ -157,4 +158,33 @@ tohtml({winid}, {opt}) *tohtml.tohtml()* (`string[]`) +============================================================================== +Builtin plugin: undotree *undotree* + +open({opts}) *undotree.open()* + Open a window that displays a textual representation of the undotree. + + While in the window, moving the cursor changes the undo. + + Load the plugin with this command: > + packadd nvim.undotree +< + + Can also be shown with `:Undotree`. *:Undotree* + + Parameters: ~ + • {opts} (`table?`) A table with the following fields: + • {bufnr} (`integer?`) Buffer to draw the tree into. If + omitted, a new buffer is created. + • {winid} (`integer?`) Window id to display the tree buffer + in. If omitted, a new window is created with {command}. + • {command} (`string?`) Vimscript command to create the + window. Default value is "30vnew". Only used when {winid} is + nil. + • {title} (`string|fun(bufnr:integer):string?`) Title of the + window. If a function, it accepts the buffer number of the + source buffer as its only argument and should return a + string. + + vim:tw=78:ts=8:sw=4:sts=4:et:ft=help:norl: diff --git a/runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua b/runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua new file mode 100644 index 0000000000..3edae8d4e4 --- /dev/null +++ b/runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua @@ -0,0 +1,406 @@ +--- @class (private) vim.undotree.tree.entry +--- @field child integer[] +--- @field time integer + +--- @alias vim.undotree.tree {[integer]: vim.undotree.tree.entry} + +local M = {} + +local ns = vim.api.nvim_create_namespace('nvim.undotree') + +--- @param buf integer +--- @return vim.fn.undotree.entry[] +--- @return integer +local function get_undotree_entries(buf) + local undotree = vim.fn.undotree(buf) + local entries = undotree.entries + + --Maybe: `:undo 0` and then `undotree` to get seq 0 time + table.insert(entries, 1, { seq = 0, time = -1 }) + + return entries, undotree.seq_cur +end + +--- @param ent vim.fn.undotree.entry[] +--- @param _tree vim.undotree.tree? +--- @param _last integer? +--- @return vim.undotree.tree +local function treefy(ent, _tree, _last) + local tree = _tree or {} + local last = _last or nil + + for idx, v in ipairs(ent) do + local seq = v.seq + + if last then + table.insert(tree[last].child, seq) + else + assert(idx == 1 and not _tree) + end + + tree[seq] = { child = {}, time = v.time } + if v.alt then + assert(last) + treefy(v.alt, tree, last) + end + last = seq + end + + return tree +end + +--- @class (private) vim.undotree.graph_line +--- @field kind 'node'|'remove'|'branch'|'remove+branch'|'nochange_remove' +--- @field index integer +--- @field node_count integer +--- @field node integer|integer[] +--- @field index2 integer? -- for branch-index in `remove+branch` + +--- @param tree vim.undotree.tree +--- @return vim.undotree.graph_line[] +local function tree_to_graph_lines(tree) + --- @type vim.undotree.graph_line[] + local graph_lines = {} + + assert(tree[0], "tree doesn't have 0-th node") + --- @type (integer[]|integer)[] + local nodes = { 0 } + + while #nodes > 0 do + local minseq = math.huge + --- @type integer + local index + --- @type integer + local node_index + + for k, v in ipairs(nodes) do + if type(v) == 'table' then + for i, j in ipairs(v) do + if j < minseq then + minseq = j + index = k + node_index = i + end + end + elseif v < minseq then + assert(type(v) == 'number') + minseq = v + index = k + end + end + + local node = nodes[index] + + --- @param kind 'node'|'remove'|'branch'|'nochange_remove' + local function add_graph_line(kind) + table.insert(graph_lines, { kind = kind, index = index, node_count = #nodes, node = node }) + end + + if type(node) == 'number' then + add_graph_line('node') + + local child = tree[node].child + if #child == 0 then + if index ~= #nodes then + add_graph_line('remove') + else + add_graph_line('nochange_remove') + end + + table.remove(nodes, index) + elseif #child == 1 then + nodes[index] = child[1] + else + nodes[index] = child + end + else + assert(type(node) == 'table') + + add_graph_line('branch') + + table.remove(nodes, index) + if #node == 2 then + table.insert(nodes, index, math.min(unpack(node))) + table.insert(nodes, index, math.max(unpack(node))) + elseif #node > 2 then + table.insert(nodes, index, node[node_index]) + table.insert(nodes, index, node) + table.remove(node, node_index) + end + end + end + + for k, v in ipairs(graph_lines) do + if v.kind == 'remove' and (graph_lines[k + 1] or {}).kind == 'branch' then + v.kind = 'remove+branch' + v.index2 = graph_lines[k + 1].index + table.remove(graph_lines, k + 1) + end + end + + return graph_lines +end + +--- @param time integer +--- @return string +local function undo_fmt_time(time) + if time == -1 then + return 'origin' + end + + local diff = os.time() - time + + if diff >= 100 then + if diff < (60 * 60 * 12) then + return os.date('%H:%M:%S', time) --[[@as string]] + else + return os.date('%Y/%m/%d %H:%M:%S', time) --[[@as string]] + end + else + return ('%d second%s ago'):format(diff, diff == 1 and '' or 's') + end +end + +--- @param tree vim.undotree.tree +--- @param graph_lines vim.undotree.graph_line[] +--- @param buf integer +--- @param meta {[integer]:integer} +--- @param find_seq? integer +--- @return integer? +local function buf_apply_graph_lines(tree, graph_lines, buf, meta, find_seq) + -- As in io-buffer, not vim-buffer + local line_buffer = {} + local extmark_buffer = {} + + --- @type integer? + local found_seq + + for k, v in ipairs(graph_lines) do + local is_last = k == #graph_lines + + --- @type string? + local line + if v.kind == 'node' then + line = ('| '):rep(v.index - 1) + .. '*' + .. (' |'):rep(v.node_count - v.index) + .. ' ' + .. v.node + .. ' (' + .. undo_fmt_time(tree[v.node].time) + .. ')' + elseif v.kind == 'remove' then + line = ('| '):rep(v.index - 1) .. (' /'):rep(v.node_count - v.index) + elseif v.kind == 'branch' then + line = ('| '):rep(v.index - 1) .. '|\\' .. (' \\'):rep(v.node_count - v.index) + elseif v.kind == 'remove+branch' then + if v.index2 < v.index then + line = ('| '):rep(v.index2 - 1) + .. '|\\' + .. (' \\'):rep(v.index - v.index2 - 1) + .. ' ' + .. (' |'):rep(v.node_count - v.index) + else + line = ('| '):rep(v.index - 1) + .. (' /'):rep(v.index2 - v.index) + .. ' /|' + .. (' |'):rep(v.node_count - v.index2 - 1) + end + elseif v.kind == 'nochange_remove' then + line = nil + else + error 'unreachable' + end + + if v.kind == 'node' then + table.insert(line_buffer, line) + table.insert(meta, v.node) + + if v.node == find_seq then + found_seq = #meta + end + elseif line then + table.insert(extmark_buffer, { { line, 'Comment' } }) + end + + if next(extmark_buffer) and (v.kind == 'node' or is_last) then + local row = vim.api.nvim_buf_line_count(buf) + vim.api.nvim_buf_set_extmark(buf, ns, row - 1, 0, { virt_lines = extmark_buffer }) + extmark_buffer = {} + end + + if next(line_buffer) and (v.kind ~= 'node' or is_last) then + vim.api.nvim_buf_set_lines(buf, -1, -1, true, line_buffer) + + if #line_buffer > 3 then + local end_ = vim.api.nvim_buf_line_count(buf) - 1 + local start = end_ - #line_buffer + 3 + vim.api.nvim_buf_call(buf, function() + vim.cmd.fold { range = { start, end_ } } + end) + end + + line_buffer = {} + end + end + + vim.api.nvim_buf_set_lines(buf, 0, 1, true, {}) + + return found_seq +end + +---@param inbuf integer +---@param outbuf integer +---@return {[integer]:integer} +local function draw(inbuf, outbuf) + local entries, curseq = get_undotree_entries(inbuf) + local tree = treefy(entries) + local graph_lines = tree_to_graph_lines(tree) + + local meta = {} + vim.bo[outbuf].modifiable = true + vim.api.nvim_buf_set_lines(outbuf, 0, -1, true, {}) + vim.api.nvim_buf_clear_namespace(outbuf, ns, 0, -1) + local curseq_line = buf_apply_graph_lines(tree, graph_lines, outbuf, meta, curseq) + vim.bo[outbuf].modifiable = false + + if vim.api.nvim_win_is_valid(vim.b[outbuf].nvim_is_undotree) then + vim.api.nvim_win_set_cursor(vim.b[outbuf].nvim_is_undotree, { curseq_line, 0 }) + end + + return meta +end + +--- @class vim.undotree.opts +--- @inlinedoc +--- +--- Buffer to draw the tree into. If omitted, a new buffer is created. +--- @field bufnr integer? +--- +--- Window id to display the tree buffer in. If omitted, a new window is +--- created with {command}. +--- @field winid integer? +--- +--- Vimscript command to create the window. Default value is "30vnew". +--- Only used when {winid} is nil. +--- @field command string? +--- +--- Title of the window. If a function, it accepts the buffer number of the +--- source buffer as its only argument and should return a string. +--- @field title (string|fun(bufnr:integer):string|nil) + +--- Open a window that displays a textual representation of the undotree. +--- +--- While in the window, moving the cursor changes the undo. +--- +--- Load the plugin with this command: +--- ``` +--- packadd nvim.undotree +--- ``` +--- +--- Can also be shown with `:Undotree`. [:Undotree]() +--- +--- @param opts vim.undotree.opts? +function M.open(opts) + -- The following lines of code was copied from + -- `vim.treesitter.dev.inspect_tree` and then modified to fit + + vim.validate('opts', opts, 'table', true) + + opts = opts or {} + + local buf = vim.api.nvim_get_current_buf() + + if vim.b[buf].nvim_undotree then + local w = vim.b[buf].nvim_undotree + if vim.api.nvim_win_is_valid(w) then + vim.api.nvim_win_close(w, true) + return true + end + elseif vim.b[buf].nvim_is_undotree then + local w = vim.b[buf].nvim_is_undotree + if vim.api.nvim_win_is_valid(w) then + vim.api.nvim_win_close(w, true) + return true + end + end + + local w = opts.winid + if not w then + vim.cmd(opts.command or '30vnew') + w = vim.api.nvim_get_current_win() + end + + local b = opts.bufnr + if b then + vim.api.nvim_win_set_buf(w, b) + else + b = vim.api.nvim_win_get_buf(w) + end + + vim.b[buf].nvim_undotree = w + vim.b[b].nvim_is_undotree = w + + local title --- @type string? + local opts_title = opts.title + if not opts_title then + local bufname = vim.api.nvim_buf_get_name(buf) + title = string.format('Undo tree for %s', vim.fn.fnamemodify(bufname, ':.')) + elseif type(opts_title) == 'function' then + title = opts_title(buf) + end + + assert(type(title) == 'string', 'Window title must be a string') + vim.api.nvim_buf_set_name(b, title) + + vim.wo[w][0].scrolloff = 5 + vim.wo[w][0].wrap = false + vim.wo[w][0].foldmethod = 'manual' + vim.wo[w][0].foldenable = true + vim.wo[w][0].cursorline = true + vim.bo[b].buflisted = false + vim.bo[b].buftype = 'nofile' + vim.bo[b].bufhidden = 'wipe' + vim.bo[b].swapfile = false + + local meta = draw(buf, b) + + vim.api.nvim_win_set_cursor(w, { vim.api.nvim_buf_line_count(b), 0 }) + + local group = vim.api.nvim_create_augroup('nvim.undotree', { clear = false }) + vim.api.nvim_clear_autocmds({ buffer = b }) + vim.api.nvim_clear_autocmds({ buffer = buf }) + + vim.api.nvim_win_call(w, function() + vim.cmd.syntax('region Comment start="(" end=")"') + end) + + vim.api.nvim_create_autocmd('CursorMoved', { + group = group, + buffer = b, + callback = function() + local row = vim.fn.line('.') + vim.api.nvim_buf_call(buf, function() + vim.cmd.undo { meta[row], mods = { silent = true } } + end) + end, + }) + + vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertLeave' }, { + group = group, + buffer = buf, + callback = function() + if not vim.api.nvim_buf_is_valid(b) then + return true + end + + meta = draw(buf, b) + + if vim.api.nvim_win_is_valid(w) then + vim.wo[w][0].foldlevel = 99 + end + end, + }) +end + +return M diff --git a/runtime/pack/dist/opt/nvim.undotree/plugin/undotree.lua b/runtime/pack/dist/opt/nvim.undotree/plugin/undotree.lua new file mode 100644 index 0000000000..621ad3d444 --- /dev/null +++ b/runtime/pack/dist/opt/nvim.undotree/plugin/undotree.lua @@ -0,0 +1,8 @@ +if vim.g.loaded_undotree_plugin ~= nil then + return +end +vim.g.loaded_undotree_plugin = true + +vim.api.nvim_create_user_command('Undotree', function() + require 'undotree'.open() +end, {}) diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua index baa9d2f69c..cadf5254f7 100755 --- a/src/gen/gen_vimdoc.lua +++ b/src/gen/gen_vimdoc.lua @@ -415,10 +415,12 @@ local config = { section_order = { 'editorconfig.lua', 'tohtml.lua', + 'undotree.lua', }, files = { 'runtime/lua/editorconfig.lua', 'runtime/lua/tohtml.lua', + 'runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua', }, fn_xform = function(fun) if fun.module == 'editorconfig' then diff --git a/test/functional/plugin/undotree_spec.lua b/test/functional/plugin/undotree_spec.lua new file mode 100644 index 0000000000..4d7a5142ac --- /dev/null +++ b/test/functional/plugin/undotree_spec.lua @@ -0,0 +1,136 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() + +local clear = n.clear +local eq = t.eq +local exec = n.exec +local api = n.api +local dedent = t.dedent + +---@param reverse_tree {[integer]:integer} +local function generate_undo_tree_from_rev(reverse_tree) + for k, v in ipairs(reverse_tree) do + exec('undo ' .. v) + api.nvim_buf_set_lines(0, 0, -1, true, { tostring(k) }) + end +end +---@param buf integer +---@return string +local function buf_get_lines_and_extmark(buf) + local lines = api.nvim_buf_get_lines(buf, 0, -1, true) + local ns = api.nvim_create_namespace('nvim.undotree') + local extmarks = api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) + for i = #extmarks, 1, -1 do + local extmark = extmarks[i] + ---@type nil,integer,nil,vim.api.keyset.extmark_details + local _, row, _, opts = unpack(extmark) + local virt_lines = assert(opts.virt_lines) + for _, v in ipairs(virt_lines) do + local virt_line = v[1][1] + table.insert(lines, row + 2, virt_line) + end + end + return table.concat(lines, '\n') +end + +local function strip_time(text) + return text:gsub('%s-%(.-%)', '') +end + +describe(':Undotree', function() + before_each(function() + clear({ args = { '--clean' } }) + exec 'packadd nvim.undotree' + end) + + it('works', function() + api.nvim_set_current_line('foo') + exec 'Undotree' + local buf = api.nvim_get_current_buf() + local win = api.nvim_get_current_win() + eq( + dedent [[ + * 0 + * 1]], + strip_time(buf_get_lines_and_extmark(buf)) + ) + eq(2, api.nvim_win_get_cursor(win)[1]) + exec 'wincmd w' + + -- Doing changes moves cursor in undotree + exec 'undo' + eq(1, api.nvim_win_get_cursor(win)[1]) + api.nvim_set_current_line('bar') + eq(3, api.nvim_win_get_cursor(win)[1]) + + eq( + dedent [[ + * 0 + |\ + | * 1 + * 2]], + strip_time(buf_get_lines_and_extmark(buf)) + ) + + -- Moving the cursor in undotree changes the buffer + eq('bar', api.nvim_get_current_line()) + exec 'wincmd w' + exec '2' + exec 'wincmd w' + eq('foo', api.nvim_get_current_line()) + end) + + describe('branch+remove is correctly graphed', function() + it('when branching left', function() + generate_undo_tree_from_rev({ 0, 1, 2, 3, 1, 3, 4, 3, 2, 0 }) + exec 'Undotree' + eq( + dedent([[ + * 0 + |\ + | * 1 + | |\ + | | * 2 + | | |\ + | | | * 3 + | | | |\ + | | | | * 4 + | * | | | 5 + | / /| |]] --[[This is the line being tested, e.g. remove&branch left]] .. '\n' .. [[ + | | | * | 6 + | | | / + | | | * 7 + | | * 8 + | * 9 + * 10]]), + strip_time(buf_get_lines_and_extmark(0)) + ) + end) + + it('when branching right', function() + generate_undo_tree_from_rev({ 0, 1, 2, 3, 3, 1, 4, 2, 1, 0 }) + exec 'Undotree' + eq( + dedent([[ + * 0 + |\ + | * 1 + | |\ + | | * 2 + | | |\ + | | | * 3 + | | | |\ + | | | | * 4 + | | | * | 5 + | |\ \ |]] --[[This is the line being tested, e.g. remove&branch right]] .. '\n' .. [[ + | | * | | 6 + | | / / + | | | * 7 + | | * 8 + | * 9 + * 10]]), + strip_time(buf_get_lines_and_extmark(0)) + ) + end) + end) +end) diff --git a/test/old/testdir/test_help.vim b/test/old/testdir/test_help.vim index d2943e1b96..36ae408da0 100644 --- a/test/old/testdir/test_help.vim +++ b/test/old/testdir/test_help.vim @@ -138,7 +138,7 @@ endfunc func Test_help_completion() call feedkeys(":help :undo\\\"\", 'tx') - call assert_equal('"help :undo :undoj :undol :undojoin :undolist', @:) + call assert_equal('"help :undo :undoj :undol :undojoin :undolist :Undotree', @:) endfunc " Test for the :helptags command