commit 9e1d3f4870705aec340b55d7767884ab64a4acf4
parent 3b860653ca0c698629d714f3af996822335debd1
Author: altermo <107814000+altermo@users.noreply.github.com>
Date: Tue, 7 Oct 2025 23:32:22 +0200
feat(runtime): undotree #35627
Problem
No builtin way to visualize and navigate the undo-tree.
Solution
Include an "opt" plugin.
Diffstat:
7 files changed, 584 insertions(+), 1 deletion(-)
diff --git 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -138,7 +138,7 @@ endfunc
func Test_help_completion()
call feedkeys(":help :undo\<C-A>\<C-B>\"\<CR>", '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