@ 261105.2

260929.1 · 9e2d65cf972730470051c39973b2029ddca5882d

feat(undotree): add tree_style option with :Undotree graph layout

- Add opts.tree_style = "tree" | "graph" (default "tree") in defaults.lua.

- Keep the existing box-drawing tree as the default.

- Port the :Undotree graph-line algorithm for tree_style="graph",

including connector rows between branches.

- Use Unicode box-drawing glyphs (│ ● ╲ ╱) instead of ASCII so the

graph stays upright even when the highlight group is italicized.

- Skip connector rows (which contain no sequence number) in actions.undo

and both undotree previewers.

- Strip ANSI color codes before matching digits in actions.undo and both

undotree previewers so escape-code digits cannot be mistaken for the

undo sequence.

- Pass the fzf layout-derived reverse flag into the graph renderer and swap

diagonal glyphs when the default (reversed) layout is active.

- Avoid goto/::continue:: in draw_graph for PUC Lua compatibility.

- Add tests covering tree and graph rendering, connector rows, nested

branches, and grandchild ordering.

PR ready notes:

- make test glob=undotree passes all 13 cases.

- make emmylua-check reports no warnings from the modified files.

- docs/superpowers markdown files were omitted from this commit.

Fix #2806

reading declarations

Changes to tests/undotree_spec.lua. All files

tests/undotree_spec.lua

new file mode 100644@@ -0,0 +1,269 @@+local helpers = require("fzf-lua.test.helpers")+local assert = helpers.assert+local eq = assert.are.same++local undotree = require("fzf-lua.providers.undotree")+local utils = require("fzf-lua.utils")++local FROZEN_TIME = 1700000000++local function freeze_time()+  local orig = os.time+  _G.os.time = function() return FROZEN_TIME end+  return orig+end++local function setup_ansi_identity()+  local orig = {}+  for _, hl in ipairs({ "Directory", "Number", "Comment" }) do+    orig[hl] = utils.ansi_codes[hl]+    utils.ansi_codes[hl] = function(s) return s end+  end+  return orig+end++local function restore_ansi_identity(orig)+  for hl, fn in pairs(orig) do+    utils.ansi_codes[hl] = fn+  end+end++local opts = {+  hls = {+    dir_part = "Directory",+    buf_name = "Number",+    path_linenr = "Comment",+  },+}++local function collect_tree(tree, reverse)+  local rows = {}+  local cb = function(seq, line)+    table.insert(rows, { seq = seq, line = utils.strip_ansi_coloring(line) })+  end+  undotree._draw_tree(opts, cb, tree, { 0 }, reverse or false)+  return rows+end++local function collect_graph(tree)+  local rows = {}+  local cb = function(seq, line)+    table.insert(rows, { seq = seq, line = utils.strip_ansi_coloring(line) })+  end+  undotree._draw_graph(opts, cb, tree, 0, false)+  return rows+end++local function origin()+  return { [0] = { child = {}, time = -1 } }+end++local function linear_chain()+  return {+    [0] = { child = { 1 }, time = -1 },+    [1] = { child = { 2 }, time = FROZEN_TIME },+    [2] = { child = {}, time = FROZEN_TIME + 10 },+  }+end++local function simple_branch()+  return {+    [0] = { child = { 1 }, time = -1 },+    [1] = { child = { 2, 3 }, time = FROZEN_TIME },+    [2] = { child = {}, time = FROZEN_TIME + 5 },+    [3] = { child = {}, time = FROZEN_TIME + 10 },+  }+end++local function triple_branch()+  return {+    [0] = { child = { 1 }, time = -1 },+    [1] = { child = { 2, 3, 4 }, time = FROZEN_TIME },+    [2] = { child = {}, time = FROZEN_TIME + 5 },+    [3] = { child = {}, time = FROZEN_TIME + 10 },+    [4] = { child = {}, time = FROZEN_TIME + 15 },+  }+end++local function nested_branches()+  return {+    [0] = { child = { 1, 2 }, time = -1 },+    [1] = { child = { 3, 4 }, time = FROZEN_TIME },+    [2] = { child = {}, time = FROZEN_TIME + 5 },+    [3] = { child = {}, time = FROZEN_TIME + 10 },+    [4] = { child = {}, time = FROZEN_TIME + 15 },+  }+end++local function grandchild_branch()+  return {+    [0] = { child = { 1 }, time = -1 },+    [1] = { child = { 2, 3, 4 }, time = FROZEN_TIME },+    [2] = { child = {}, time = FROZEN_TIME + 5 },+    [3] = { child = { 5 }, time = FROZEN_TIME + 10 },+    [4] = { child = {}, time = FROZEN_TIME + 15 },+    [5] = { child = {}, time = FROZEN_TIME + 20 },+  }+end++describe("undotree draw_tree", function()+  local orig_time, orig_ansi++  before_each(function()+    orig_time = freeze_time()+    orig_ansi = setup_ansi_identity()+  end)++  after_each(function()+    _G.os.time = orig_time+    restore_ansi_identity(orig_ansi)+  end)++  it("renders a single origin node", function()+    local rows = collect_tree(origin())+    eq({+      { seq = 0, line = "0\t\torigin" },+    }, rows)+  end)++  it("renders a linear chain without branch glyphs", function()+    local rows = collect_tree(linear_chain())+    eq({+      { seq = 0, line = "0\t\torigin" },+      { seq = 1, line = "1\t\tjust now" },+      { seq = 2, line = "2\t\tjust now" },+    }, rows)+  end)++  it("renders branch children with box-drawing glyphs", function()+    local rows = collect_tree(simple_branch())+    eq({+      { seq = 0, line = "0\t\torigin" },+      { seq = 1, line = "1\t\tjust now" },+      { seq = 2, line = "├── 2\t\tjust now" },+      { seq = 3, line = "└── 3\t\tjust now" },+    }, rows)+  end)++  it("uses reversed leaf glyphs when reverse is true", function()+    local rows = collect_tree(simple_branch(), true)+    eq({+      { seq = 0, line = "0\t\torigin" },+      { seq = 1, line = "1\t\tjust now" },+      { seq = 2, line = "├── 2\t\tjust now" },+      { seq = 3, line = "┌── 3\t\tjust now" },+    }, rows)+  end)++  it("handles three siblings", function()+    local rows = collect_tree(triple_branch())+    eq({+      { seq = 0, line = "0\t\torigin" },+      { seq = 1, line = "1\t\tjust now" },+      { seq = 2, line = "├── 2\t\tjust now" },+      { seq = 3, line = "├── 3\t\tjust now" },+      { seq = 4, line = "└── 4\t\tjust now" },+    }, rows)+  end)++  it("renders nested branches in pre-order", function()+    local rows = collect_tree(nested_branches())+    eq({+      { seq = 0, line = "0\t\torigin" },+      { seq = 1, line = "├── 1\t\tjust now" },+      { seq = 3, line = "│   ├── 3\t\tjust now" },+      { seq = 4, line = "│   └── 4\t\tjust now" },+      { seq = 2, line = "└── 2\t\tjust now" },+    }, rows)+  end)++  it("renders a grandchild in pre-order", function()+    local rows = collect_tree(grandchild_branch())+    eq({+      { seq = 0, line = "0\t\torigin" },+      { seq = 1, line = "1\t\tjust now" },+      { seq = 2, line = "├── 2\t\tjust now" },+      { seq = 3, line = "├── 3\t\tjust now" },+      { seq = 5, line = "│   5\t\tjust now" },+      { seq = 4, line = "└── 4\t\tjust now" },+    }, rows)+  end)+end)++describe("undotree draw_graph", function()+  local orig_time, orig_ansi++  before_each(function()+    orig_time = freeze_time()+    orig_ansi = setup_ansi_identity()+  end)++  after_each(function()+    _G.os.time = orig_time+    restore_ansi_identity(orig_ansi)+  end)++  it("renders a single origin node", function()+    local rows = collect_graph(origin())+    eq({+      { seq = 0, line = "●    0    (origin)" },+    }, rows)+  end)++  it("renders a linear chain", function()+    local rows = collect_graph(linear_chain())+    eq({+      { seq = 0, line = "●    0    (origin)" },+      { seq = 1, line = "●    1    (just now)" },+      { seq = 2, line = "●    2    (just now)" },+    }, rows)+  end)++  it("renders a branch with connector and :Undotree-style columns", function()+    local rows = collect_graph(simple_branch())+    eq({+      { seq = 0, line = "●    0    (origin)" },+      { seq = 1, line = "●    1    (just now)" },+      { seq = nil, line = "│╲" },+      { seq = 2, line = "│ ●    2    (just now)" },+      { seq = 3, line = "●    3    (just now)" },+    }, rows)+  end)++  it("renders three siblings", function()+    local rows = collect_graph(triple_branch())+    eq({+      { seq = 0, line = "●    0    (origin)" },+      { seq = 1, line = "●    1    (just now)" },+      { seq = nil, line = "│╲" },+      { seq = 2, line = "│ ●    2    (just now)" },+      { seq = nil, line = "│╲" },+      { seq = 3, line = "│ ●    3    (just now)" },+      { seq = 4, line = "●    4    (just now)" },+    }, rows)+  end)++  it("renders nested branches with remove/branch connectors", function()+    local rows = collect_graph(nested_branches())+    eq({+      { seq = 0, line = "●    0    (origin)" },+      { seq = nil, line = "│╲" },+      { seq = 1, line = "│ ●    1    (just now)" },+      { seq = 2, line = "● │    2    (just now)" },+      { seq = nil, line = " ╱│" },+      { seq = 3, line = "│ ●    3    (just now)" },+      { seq = 4, line = "●    4    (just now)" },+    }, rows)+  end)++  it("emits connector rows with no digits and node rows with digits", function()+    local rows = collect_graph(simple_branch())+    for _, r in ipairs(rows) do+      if r.seq == nil then+        eq(nil, r.line:match("%d"))+      else+        eq(true, r.line:match("%d+") ~= nil)+      end+    end+  end)+end)