feat(runtime): undotree #35627

Problem
No builtin way to visualize and navigate the undo-tree.

Solution
Include an "opt" plugin.
This commit is contained in:
altermo
2025-10-07 23:32:22 +02:00
committed by GitHub
parent 3b860653ca
commit 9e1d3f4870
7 changed files with 584 additions and 1 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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, {})

View File

@@ -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

View File

@@ -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)

View File

@@ -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