detached 6ce6bc8

feat(ui): add reusable Tree component for terminal tree rendering

commit
6ce6bc88044a085f0a5b822ddaa08bdf7f2ef816
author
brice · · 2026-03-24 05:39 UTC
parent
52e5e4d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +255 -0
A internal/adapters/in/cli/ui/components/tree.go +133 -0
@@ -0,0 +1,133 @@
1+package components
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
7+)
8+
9+// Node represents a tree node with a title, optional subtitle, and children.
10+type Node struct {
11+ Title string
12+ Subtitle string
13+ Children []*Node
14+}
15+
16+// AddChild adds a child node under this node.
17+func (n *Node) AddChild(title, subtitle string) *Node {
18+ child := &Node{Title: title, Subtitle: subtitle}
19+ n.Children = append(n.Children, child)
20+ return child
21+}
22+
23+// Group represents a named group of nodes rendered with a vertical bar connector.
24+type Group struct {
25+ Name string
26+ Nodes []*Node
27+}
28+
29+// AddNode adds a node inside this group.
30+func (g *Group) AddNode(title, subtitle string) *Node {
31+ node := &Node{Title: title, Subtitle: subtitle}
32+ g.Nodes = append(g.Nodes, node)
33+ return node
34+}
35+
36+// treeItem is a union type for ordering nodes and groups.
37+type treeItem struct {
38+ node *Node
39+ group *Group
40+}
41+
42+// Tree is a renderable tree structure for terminal output.
43+type Tree struct {
44+ items []treeItem
45+}
46+
47+// NewTree creates a new empty tree.
48+func NewTree() *Tree {
49+ return &Tree{}
50+}
51+
52+// AddNode adds a top-level node with a pre-styled title and a plain subtitle.
53+func (t *Tree) AddNode(title, subtitle string) *Node {
54+ node := &Node{Title: title, Subtitle: subtitle}
55+ t.items = append(t.items, treeItem{node: node})
56+ return node
57+}
58+
59+// AddGroup adds a named group header.
60+func (t *Tree) AddGroup(name string) *Group {
61+ group := &Group{Name: name}
62+ t.items = append(t.items, treeItem{group: group})
63+ return group
64+}
65+
66+// Render returns the full styled string output.
67+func (t *Tree) Render() string {
68+ if len(t.items) == 0 {
69+ return ""
70+ }
71+
72+ var b strings.Builder
73+ for i, item := range t.items {
74+ if i > 0 {
75+ b.WriteString("\n")
76+ }
77+ if item.group != nil {
78+ t.renderGroup(&b, item.group)
79+ } else {
80+ t.renderNode(&b, item.node, " ")
81+ }
82+ }
83+ return b.String()
84+}
85+
86+func (t *Tree) renderGroup(b *strings.Builder, group *Group) {
87+ // Group header: ◆ group-name
88+ icon := styles.Theme.Highlight.Render(styles.IconGroup)
89+ name := styles.Theme.Bold.Render(group.Name)
90+ b.WriteString(" " + icon + " " + name + "\n")
91+
92+ vertBar := styles.Theme.Muted.Render(styles.IconTreeVert)
93+ b.WriteString(" " + vertBar + "\n")
94+
95+ for _, node := range group.Nodes {
96+ t.renderNode(b, node, " "+vertBar+" ")
97+ }
98+}
99+
100+func (t *Tree) renderNode(b *strings.Builder, node *Node, prefix string) {
101+ // Title line
102+ b.WriteString(prefix + node.Title + "\n")
103+
104+ // Subtitle line (muted)
105+ if node.Subtitle != "" {
106+ subtitle := styles.Theme.Muted.Render(node.Subtitle)
107+ b.WriteString(prefix + " " + subtitle + "\n")
108+ }
109+
110+ // Children
111+ for i, child := range node.Children {
112+ isLast := i == len(node.Children)-1
113+
114+ var branch string
115+ var childPrefix string
116+ if isLast {
117+ branch = styles.Theme.Muted.Render(styles.IconTreeLast + styles.IconTreeLine)
118+ childPrefix = " "
119+ } else {
120+ branch = styles.Theme.Muted.Render(styles.IconTreeBranch + styles.IconTreeLine)
121+ childPrefix = styles.Theme.Muted.Render(styles.IconTreeVert) + " "
122+ }
123+
124+ // Child title
125+ b.WriteString(prefix + " " + branch + " " + child.Title + "\n")
126+
127+ // Child subtitle (indented to align under child name)
128+ if child.Subtitle != "" {
129+ subtitle := styles.Theme.Muted.Render(child.Subtitle)
130+ b.WriteString(prefix + " " + childPrefix + " " + subtitle + "\n")
131+ }
132+ }
133+}
@@ -0,0 +1,133 @@
1+package components
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
7+)
8+
9+// Node represents a tree node with a title, optional subtitle, and children.
10+type Node struct {
11+ Title string
12+ Subtitle string
13+ Children []*Node
14+}
15+
16+// AddChild adds a child node under this node.
17+func (n *Node) AddChild(title, subtitle string) *Node {
18+ child := &Node{Title: title, Subtitle: subtitle}
19+ n.Children = append(n.Children, child)
20+ return child
21+}
22+
23+// Group represents a named group of nodes rendered with a vertical bar connector.
24+type Group struct {
25+ Name string
26+ Nodes []*Node
27+}
28+
29+// AddNode adds a node inside this group.
30+func (g *Group) AddNode(title, subtitle string) *Node {
31+ node := &Node{Title: title, Subtitle: subtitle}
32+ g.Nodes = append(g.Nodes, node)
33+ return node
34+}
35+
36+// treeItem is a union type for ordering nodes and groups.
37+type treeItem struct {
38+ node *Node
39+ group *Group
40+}
41+
42+// Tree is a renderable tree structure for terminal output.
43+type Tree struct {
44+ items []treeItem
45+}
46+
47+// NewTree creates a new empty tree.
48+func NewTree() *Tree {
49+ return &Tree{}
50+}
51+
52+// AddNode adds a top-level node with a pre-styled title and a plain subtitle.
53+func (t *Tree) AddNode(title, subtitle string) *Node {
54+ node := &Node{Title: title, Subtitle: subtitle}
55+ t.items = append(t.items, treeItem{node: node})
56+ return node
57+}
58+
59+// AddGroup adds a named group header.
60+func (t *Tree) AddGroup(name string) *Group {
61+ group := &Group{Name: name}
62+ t.items = append(t.items, treeItem{group: group})
63+ return group
64+}
65+
66+// Render returns the full styled string output.
67+func (t *Tree) Render() string {
68+ if len(t.items) == 0 {
69+ return ""
70+ }
71+
72+ var b strings.Builder
73+ for i, item := range t.items {
74+ if i > 0 {
75+ b.WriteString("\n")
76+ }
77+ if item.group != nil {
78+ t.renderGroup(&b, item.group)
79+ } else {
80+ t.renderNode(&b, item.node, " ")
81+ }
82+ }
83+ return b.String()
84+}
85+
86+func (t *Tree) renderGroup(b *strings.Builder, group *Group) {
87+ // Group header: ◆ group-name
88+ icon := styles.Theme.Highlight.Render(styles.IconGroup)
89+ name := styles.Theme.Bold.Render(group.Name)
90+ b.WriteString(" " + icon + " " + name + "\n")
91+
92+ vertBar := styles.Theme.Muted.Render(styles.IconTreeVert)
93+ b.WriteString(" " + vertBar + "\n")
94+
95+ for _, node := range group.Nodes {
96+ t.renderNode(b, node, " "+vertBar+" ")
97+ }
98+}
99+
100+func (t *Tree) renderNode(b *strings.Builder, node *Node, prefix string) {
101+ // Title line
102+ b.WriteString(prefix + node.Title + "\n")
103+
104+ // Subtitle line (muted)
105+ if node.Subtitle != "" {
106+ subtitle := styles.Theme.Muted.Render(node.Subtitle)
107+ b.WriteString(prefix + " " + subtitle + "\n")
108+ }
109+
110+ // Children
111+ for i, child := range node.Children {
112+ isLast := i == len(node.Children)-1
113+
114+ var branch string
115+ var childPrefix string
116+ if isLast {
117+ branch = styles.Theme.Muted.Render(styles.IconTreeLast + styles.IconTreeLine)
118+ childPrefix = " "
119+ } else {
120+ branch = styles.Theme.Muted.Render(styles.IconTreeBranch + styles.IconTreeLine)
121+ childPrefix = styles.Theme.Muted.Render(styles.IconTreeVert) + " "
122+ }
123+
124+ // Child title
125+ b.WriteString(prefix + " " + branch + " " + child.Title + "\n")
126+
127+ // Child subtitle (indented to align under child name)
128+ if child.Subtitle != "" {
129+ subtitle := styles.Theme.Muted.Render(child.Subtitle)
130+ b.WriteString(prefix + " " + childPrefix + " " + subtitle + "\n")
131+ }
132+ }
133+}
A internal/adapters/in/cli/ui/components/tree_test.go +122 -0
@@ -0,0 +1,122 @@
1+package components
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// NOTE: stripANSI is already defined in table_test.go (same package).
12+// Use it directly — do NOT redefine it here.
13+
14+func TestTree_SingleNodeNoChildren(t *testing.T) {
15+ tree := NewTree()
16+ tree.AddNode("my-app.dev", "myapp:latest")
17+
18+ rendered := stripANSI(tree.Render())
19+
20+ assert.Contains(t, rendered, "my-app.dev")
21+ assert.Contains(t, rendered, "myapp:latest")
22+}
23+
24+func TestTree_NodeWithOneChild(t *testing.T) {
25+ tree := NewTree()
26+ node := tree.AddNode("my-app.dev", "myapp:latest")
27+ node.AddChild("my-postgres", "postgres:16")
28+
29+ rendered := stripANSI(tree.Render())
30+
31+ assert.Contains(t, rendered, "my-app.dev")
32+ assert.Contains(t, rendered, "myapp:latest")
33+ assert.Contains(t, rendered, "└─")
34+ assert.Contains(t, rendered, "my-postgres")
35+ assert.Contains(t, rendered, "postgres:16")
36+}
37+
38+func TestTree_NodeWithMultipleChildren(t *testing.T) {
39+ tree := NewTree()
40+ node := tree.AddNode("my-app.dev", "myapp:latest")
41+ node.AddChild("my-postgres", "postgres:16")
42+ node.AddChild("my-redis", "redis:7")
43+
44+ rendered := stripANSI(tree.Render())
45+
46+ assert.Contains(t, rendered, "├─")
47+ assert.Contains(t, rendered, "└─")
48+ assert.Contains(t, rendered, "my-postgres")
49+ assert.Contains(t, rendered, "my-redis")
50+}
51+
52+func TestTree_GroupWithNodes(t *testing.T) {
53+ tree := NewTree()
54+ group := tree.AddGroup("shared-services")
55+ group.AddNode("app-a.dev", "app-a:latest")
56+ group.AddNode("app-b.dev", "app-b:latest")
57+
58+ rendered := stripANSI(tree.Render())
59+
60+ assert.Contains(t, rendered, "shared-services")
61+ assert.Contains(t, rendered, "◆")
62+ assert.Contains(t, rendered, "│")
63+ assert.Contains(t, rendered, "app-a.dev")
64+ assert.Contains(t, rendered, "app-b.dev")
65+}
66+
67+func TestTree_MixedNodesAndGroups(t *testing.T) {
68+ tree := NewTree()
69+ tree.AddNode("alpha.dev", "alpha:latest")
70+
71+ group := tree.AddGroup("my-group")
72+ group.AddNode("beta.dev", "beta:latest")
73+ group.AddNode("gamma.dev", "gamma:latest")
74+
75+ tree.AddNode("delta.dev", "delta:latest")
76+
77+ rendered := stripANSI(tree.Render())
78+
79+ assert.Contains(t, rendered, "alpha.dev")
80+ assert.Contains(t, rendered, "my-group")
81+ assert.Contains(t, rendered, "beta.dev")
82+ assert.Contains(t, rendered, "gamma.dev")
83+ assert.Contains(t, rendered, "delta.dev")
84+}
85+
86+func TestTree_EmptyTreeRendersEmpty(t *testing.T) {
87+ tree := NewTree()
88+ rendered := tree.Render()
89+ assert.Empty(t, rendered)
90+}
91+
92+func TestTree_NodeWithChildSubtitleIndented(t *testing.T) {
93+ tree := NewTree()
94+ node := tree.AddNode("my-app.dev", "myapp:latest")
95+ node.AddChild("my-postgres", "postgres:16")
96+
97+ rendered := stripANSI(tree.Render())
98+ lines := strings.Split(rendered, "\n")
99+
100+ var childSubtitleLine string
101+ for _, line := range lines {
102+ if strings.Contains(line, "postgres:16") {
103+ childSubtitleLine = line
104+ break
105+ }
106+ }
107+ require.NotEmpty(t, childSubtitleLine, "child subtitle line should exist")
108+}
109+
110+func TestTree_GroupNodeWithChild(t *testing.T) {
111+ tree := NewTree()
112+ group := tree.AddGroup("shared")
113+ node := group.AddNode("app.dev", "app:latest")
114+ node.AddChild("app-db", "postgres:16")
115+
116+ rendered := stripANSI(tree.Render())
117+
118+ assert.Contains(t, rendered, "shared")
119+ assert.Contains(t, rendered, "app.dev")
120+ assert.Contains(t, rendered, "app-db")
121+ assert.Contains(t, rendered, "postgres:16")
122+}
@@ -0,0 +1,122 @@
1+package components
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// NOTE: stripANSI is already defined in table_test.go (same package).
12+// Use it directly — do NOT redefine it here.
13+
14+func TestTree_SingleNodeNoChildren(t *testing.T) {
15+ tree := NewTree()
16+ tree.AddNode("my-app.dev", "myapp:latest")
17+
18+ rendered := stripANSI(tree.Render())
19+
20+ assert.Contains(t, rendered, "my-app.dev")
21+ assert.Contains(t, rendered, "myapp:latest")
22+}
23+
24+func TestTree_NodeWithOneChild(t *testing.T) {
25+ tree := NewTree()
26+ node := tree.AddNode("my-app.dev", "myapp:latest")
27+ node.AddChild("my-postgres", "postgres:16")
28+
29+ rendered := stripANSI(tree.Render())
30+
31+ assert.Contains(t, rendered, "my-app.dev")
32+ assert.Contains(t, rendered, "myapp:latest")
33+ assert.Contains(t, rendered, "└─")
34+ assert.Contains(t, rendered, "my-postgres")
35+ assert.Contains(t, rendered, "postgres:16")
36+}
37+
38+func TestTree_NodeWithMultipleChildren(t *testing.T) {
39+ tree := NewTree()
40+ node := tree.AddNode("my-app.dev", "myapp:latest")
41+ node.AddChild("my-postgres", "postgres:16")
42+ node.AddChild("my-redis", "redis:7")
43+
44+ rendered := stripANSI(tree.Render())
45+
46+ assert.Contains(t, rendered, "├─")
47+ assert.Contains(t, rendered, "└─")
48+ assert.Contains(t, rendered, "my-postgres")
49+ assert.Contains(t, rendered, "my-redis")
50+}
51+
52+func TestTree_GroupWithNodes(t *testing.T) {
53+ tree := NewTree()
54+ group := tree.AddGroup("shared-services")
55+ group.AddNode("app-a.dev", "app-a:latest")
56+ group.AddNode("app-b.dev", "app-b:latest")
57+
58+ rendered := stripANSI(tree.Render())
59+
60+ assert.Contains(t, rendered, "shared-services")
61+ assert.Contains(t, rendered, "◆")
62+ assert.Contains(t, rendered, "│")
63+ assert.Contains(t, rendered, "app-a.dev")
64+ assert.Contains(t, rendered, "app-b.dev")
65+}
66+
67+func TestTree_MixedNodesAndGroups(t *testing.T) {
68+ tree := NewTree()
69+ tree.AddNode("alpha.dev", "alpha:latest")
70+
71+ group := tree.AddGroup("my-group")
72+ group.AddNode("beta.dev", "beta:latest")
73+ group.AddNode("gamma.dev", "gamma:latest")
74+
75+ tree.AddNode("delta.dev", "delta:latest")
76+
77+ rendered := stripANSI(tree.Render())
78+
79+ assert.Contains(t, rendered, "alpha.dev")
80+ assert.Contains(t, rendered, "my-group")
81+ assert.Contains(t, rendered, "beta.dev")
82+ assert.Contains(t, rendered, "gamma.dev")
83+ assert.Contains(t, rendered, "delta.dev")
84+}
85+
86+func TestTree_EmptyTreeRendersEmpty(t *testing.T) {
87+ tree := NewTree()
88+ rendered := tree.Render()
89+ assert.Empty(t, rendered)
90+}
91+
92+func TestTree_NodeWithChildSubtitleIndented(t *testing.T) {
93+ tree := NewTree()
94+ node := tree.AddNode("my-app.dev", "myapp:latest")
95+ node.AddChild("my-postgres", "postgres:16")
96+
97+ rendered := stripANSI(tree.Render())
98+ lines := strings.Split(rendered, "\n")
99+
100+ var childSubtitleLine string
101+ for _, line := range lines {
102+ if strings.Contains(line, "postgres:16") {
103+ childSubtitleLine = line
104+ break
105+ }
106+ }
107+ require.NotEmpty(t, childSubtitleLine, "child subtitle line should exist")
108+}
109+
110+func TestTree_GroupNodeWithChild(t *testing.T) {
111+ tree := NewTree()
112+ group := tree.AddGroup("shared")
113+ node := group.AddNode("app.dev", "app:latest")
114+ node.AddChild("app-db", "postgres:16")
115+
116+ rendered := stripANSI(tree.Render())
117+
118+ assert.Contains(t, rendered, "shared")
119+ assert.Contains(t, rendered, "app.dev")
120+ assert.Contains(t, rendered, "app-db")
121+ assert.Contains(t, rendered, "postgres:16")
122+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close