Directory Tree
What We're Building
A keyboard-driven file browser that reads a real directory from disk and displays it as a foldable tree with Unicode box-drawing characters. You can navigate with vim-style keys, expand and collapse directories, and walk upward past the root to re-root the tree at a parent directory.
Concepts used:
- State (Guide 05): reactive
State[T]for cursor position, expanded directories, and scroll offset - Components (Guide 06): a struct component with constructor and render method
- Events (Guide 07):
KeyMapfor navigation bindings - Refs (Guide 08):
*tui.Reffor the scrollable container - Scrolling (Guide 10): state-driven scroll with
scrollOffsetandViewportSize - Watchers (Guide 09):
OnChangeto auto-scroll when the cursor moves - Styling (Guide 03): conditional classes for cursor highlight, ancestor path, and directory names
Most of this project is plain Go code that manages state variables on the component struct. go-tui handles rendering and UI updates, so the code you write is just the tree logic.
Project Setup
Create a new directory and initialize the module:
mkdir directory-tree && cd directory-tree
go mod init directory-tree
go get github.com/grindlemire/go-tui
You'll create two files:
tree.gsx- the component and all its logicmain.go- the entry point
Data Model
The tree is built from two types. Node is the raw filesystem data: a name plus optional children. visibleNode is a flattened row ready for rendering carrying the depth, logical path, and enough information to draw the correct tree prefix characters.
// Node represents a file or directory in the tree.
type Node struct {
Name string
Children []Node
}
// visibleNode is a flattened node for rendering.
type visibleNode struct {
node Node
depth int
path string
isDir bool
isLast bool
ancestors []bool
onPath bool
}
The Children field doubles as a directory indicator: nil means it's a file, while a non-nil slice (even an empty one) means it's a directory. This distinction matters because an empty directory should still show the expand arrow.
The ancestors slice on visibleNode tracks whether each ancestor was the last child at its depth. buildPrefix uses this to decide between │ (more siblings below) and blank space (last child, no continuation line). The onPath field is set during flattening to mark nodes that are ancestors of the currently selected node, used for teal path highlighting in the render template.
Reading the Filesystem
readDir reads a single directory level, skipping hidden files and sorting results with directories first. Subdirectories get an empty Children slice to mark them as directories, but their contents aren't read yet. Since only the top level is read at startup, opening a huge directory like /usr is fast:
func readDir(dirPath string) []Node {
entries, err := os.ReadDir(dirPath)
if err != nil {
return nil
}
var children []Node
for _, entry := range entries {
if entry.Name()[0] == '.' {
continue
}
node := Node{Name: entry.Name()}
if entry.IsDir() {
node.Children = []Node{}
}
children = append(children, node)
}
sortChildren(children)
return children
}
When os.ReadDir fails (permissions, broken symlinks), the function returns nil and the parent directory silently omits that subtree.
Children are loaded on demand when the user first expands a directory. loadChildren walks the tree to find the node by its logical path, then calls readDir to populate its children:
func (d *directoryTree) loadChildren(nodePath string) {
parts := strings.Split(nodePath, "/")
node := &d.tree[0]
fsPath := d.rootPath
for _, part := range parts[1:] {
found := false
for i := range node.Children {
if node.Children[i].Name == part {
node = &node.Children[i]
fsPath = filepath.Join(fsPath, part)
found = true
break
}
}
if !found {
return
}
}
if len(node.Children) == 0 {
node.Children = readDir(fsPath)
if node.Children == nil {
node.Children = []Node{}
}
}
}
The len(node.Children) == 0 check means children are only read once per directory. After collapsing and re-expanding, the previously loaded children are still in the tree.
The sort puts directories before files, then alphabetical within each group:
func sortChildren(children []Node) {
sort.Slice(children, func(i, j int) bool {
iDir := children[i].Children != nil
jDir := children[j].Children != nil
if iDir != jDir {
return iDir
}
return children[i].Name < children[j].Name
})
}
Component State
The component holds five pieces of state: the filesystem root path, the node tree, a cursor position, a set of expanded directory paths, and a scroll offset. A ref tracks the scrollable container so the component can query its viewport height.
type directoryTree struct {
rootPath string
tree []Node
cursor *tui.State[int]
expanded *tui.State[map[string]bool]
scrollY *tui.State[int]
scrollContainer *tui.Ref
}
The expanded state is a map[string]bool keyed by logical path (like myproject/internal/config). When the user expands a directory, its path gets added to the map. When they collapse it, the path gets removed.
The constructor reads the filesystem and starts with the root directory expanded:
func DirectoryTree(root string) *directoryTree {
rootNode := buildRootNode(root)
return &directoryTree{
rootPath: root,
tree: []Node{rootNode},
cursor: tui.NewState(0),
expanded: tui.NewState(map[string]bool{rootNode.Name: true}),
scrollY: tui.NewState(0),
scrollContainer: tui.NewRef(),
}
}
Flattening the Tree
The render method needs a flat list of all rows from expanded directories. visibleNodes walks the tree and only recurses into directories that appear in the expanded map. After flattening, it marks which nodes are on the path from root to the cursor, so the render template can apply teal highlighting without recomputing it per row:
func (d *directoryTree) visibleNodes() []visibleNode {
var result []visibleNode
expanded := d.expanded.Get()
for i, node := range d.tree {
flattenNode(node, 0, node.Name, i == len(d.tree)-1, nil, expanded, &result)
}
cur := d.cursor.Get()
if cur < len(result) {
sel := result[cur].path
for i := range result {
result[i].onPath = result[i].path == sel || strings.HasPrefix(sel, result[i].path+"/")
}
}
return result
}
flattenNode is recursive. It appends the current node and, if the node is an expanded directory, recurses into its children. Each level builds up the ancestors slice so the prefix renderer knows which columns need continuation lines:
func flattenNode(n Node, depth int, nodePath string, isLast bool, ancestors []bool, expanded map[string]bool, result *[]visibleNode) {
isDir := n.Children != nil
*result = append(*result, visibleNode{
node: n,
depth: depth,
path: nodePath,
isDir: isDir,
isLast: isLast,
ancestors: ancestors,
})
if isDir && expanded[nodePath] {
newAncestors := make([]bool, len(ancestors)+1)
copy(newAncestors, ancestors)
newAncestors[len(ancestors)] = isLast
for i, child := range n.Children {
childPath := nodePath + "/" + child.Name
flattenNode(child, depth+1, childPath, i == len(n.Children)-1, newAncestors, expanded, result)
}
}
}
Drawing Tree Lines
The buildPrefix function turns the depth and ancestor information into Unicode box-drawing characters. For each ancestor level, it draws either │ (more siblings follow) or a blank space (last child). The final segment is ├── or └── depending on whether the node itself is the last sibling:
func buildPrefix(vn visibleNode) string {
if vn.depth == 0 {
return ""
}
var b strings.Builder
for i := 0; i < vn.depth-1; i++ {
if vn.ancestors[i+1] {
b.WriteString(" ")
} else {
b.WriteString("│ ")
}
}
if vn.isLast {
b.WriteString("└── ")
} else {
b.WriteString("├── ")
}
return b.String()
}
Directories get an expand/collapse indicator prepended to their name:
func nodeLabel(vn visibleNode, expanded map[string]bool) string {
if vn.isDir {
if expanded[vn.path] {
return "▼ " + vn.node.Name
}
return "▶ " + vn.node.Name
}
return vn.node.Name
}
Keyboard Navigation
The component implements KeyListener with vim-style bindings. j/k and arrow keys move the cursor. Enter/l/Right expand or collapse directories. h/Left collapses the current directory or jumps to its parent:
func (d *directoryTree) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune('q'), func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.KeyUp, func(ke tui.KeyEvent) { d.moveUp() }),
tui.On(tui.Rune('k'), func(ke tui.KeyEvent) { d.moveUp() }),
tui.On(tui.KeyDown, func(ke tui.KeyEvent) { d.moveDown() }),
tui.On(tui.Rune('j'), func(ke tui.KeyEvent) { d.moveDown() }),
tui.On(tui.KeyEnter, func(ke tui.KeyEvent) { d.toggle() }),
tui.On(tui.KeyRight, func(ke tui.KeyEvent) { d.toggle() }),
tui.On(tui.Rune('l'), func(ke tui.KeyEvent) { d.toggle() }),
tui.On(tui.KeyLeft, func(ke tui.KeyEvent) { d.collapseOrParent() }),
tui.On(tui.Rune('h'), func(ke tui.KeyEvent) { d.collapseOrParent() }),
}
}
moveUp and moveDown use State.Update to atomically adjust the cursor within bounds:
func (d *directoryTree) moveUp() {
d.cursor.Update(func(v int) int {
if v > 0 {
return v - 1
}
return v
})
}
func (d *directoryTree) moveDown() {
visible := d.visibleNodes()
d.cursor.Update(func(v int) int {
if v < len(visible)-1 {
return v + 1
}
return v
})
}
Note that neither moveUp nor moveDown handles scrolling. Instead they update the cursor and there is a watcher on the cursor to ensure the scroll state shows that cursor on the screen (more on that below).
Toggling Directories
toggle flips a directory's expanded state. When expanding, it first calls loadChildren to read the directory contents from disk if they haven't been loaded yet. Because State[T] holds a map, you can't mutate it in place. The cloneExpandedWith helper copies the map and applies the change:
func (d *directoryTree) toggle() {
visible := d.visibleNodes()
cur := d.cursor.Get()
if cur >= len(visible) {
return
}
vn := visible[cur]
if !vn.isDir {
return
}
expanding := !d.expanded.Get()[vn.path]
if expanding {
d.loadChildren(vn.path)
}
d.expanded.Update(func(m map[string]bool) map[string]bool {
return cloneExpandedWith(m, vn.path, expanding)
})
}
Collapsing and Walking Up
collapseOrParent has three behaviors depending on context. On an expanded directory, it collapses it. On a file or collapsed directory, it jumps the cursor to the parent. On the root node, it re-roots the entire tree at the parent directory:
func (d *directoryTree) collapseOrParent() {
visible := d.visibleNodes()
cur := d.cursor.Get()
if cur >= len(visible) {
return
}
vn := visible[cur]
// If on an expanded directory, collapse it
expanded := d.expanded.Get()
if vn.isDir && expanded[vn.path] {
d.expanded.Update(func(m map[string]bool) map[string]bool {
return cloneExpandedWith(m, vn.path, false)
})
return
}
// At root, navigate to parent directory
if vn.depth == 0 {
d.navigateUp()
return
}
// Jump to parent node
parentPath := path.Dir(vn.path)
for i := cur - 1; i >= 0; i-- {
if visible[i].path == parentPath {
d.cursor.Set(i)
return
}
}
}
navigateUp re-reads the filesystem at the parent path and resets all state. The root stops moving when filepath.Dir returns the same path (the filesystem root):
func (d *directoryTree) navigateUp() {
parent := filepath.Dir(d.rootPath)
if parent == d.rootPath {
return
}
d.rootPath = parent
rootNode := buildRootNode(parent)
d.tree = []Node{rootNode}
d.cursor.Set(0)
d.expanded.Set(map[string]bool{rootNode.Name: true})
d.scrollY.Set(0)
}
Scrolling with OnChange
The tree can be taller than the terminal. The scrollable container clips content and draws a scrollbar automatically. The remaining problem is keeping the cursor visible as it moves.
Rather than calling a scroll function after every cursor movement, the component uses an OnChange watcher. Whenever the cursor state changes, the watcher fires and adjusts scrollY to keep the cursor row inside the viewport:
func (d *directoryTree) Watchers() []tui.Watcher {
return []tui.Watcher{
tui.OnChange(d.cursor, func(int) { d.scrollToCursor() }),
}
}
func (d *directoryTree) scrollToCursor() {
el := d.scrollContainer.El()
if el == nil {
return
}
cur := d.cursor.Get()
_, vpH := el.ViewportSize()
y := d.scrollY.Get()
if cur < y {
d.scrollY.Set(cur)
} else if cur >= y+vpH {
d.scrollY.Set(cur - vpH + 1)
}
}
This approach keeps scroll logic out of the key handlers entirely. Any code that moves the cursor (keyboard navigation, collapsing, re-rooting) gets automatic scroll adjustment for free. This is equivalent to React's effects API.
Conditional Row Styling
Each row in the tree gets one of four visual treatments: teal text on a dark background for the cursor row, teal bold for ancestor nodes on the path to the selection, bold for other directories, and plain for files.
The onPath field was already computed during flattening in visibleNodes(), so the render template just checks the boolean. This avoids recomputing path ancestry per row, which would otherwise turn the render loop into O(N²) work.
The render template uses chained if/else if to apply the right class to each row:
for i, vn := range d.visibleNodes() {
if i == d.cursor.Get() {
<span class="bg-bright-black text-cyan font-bold">{buildPrefix(vn) + nodeLabel(vn, d.expanded.Get())}</span>
} else if vn.onPath {
<span class="text-cyan font-bold">{buildPrefix(vn) + nodeLabel(vn, d.expanded.Get())}</span>
} else if vn.isDir {
<span class="font-bold">{buildPrefix(vn) + nodeLabel(vn, d.expanded.Get())}</span>
} else {
<span>{buildPrefix(vn) + nodeLabel(vn, d.expanded.Get())}</span>
}
}
The selected row gets the same teal color as the ancestor path, plus a dark background to mark the cursor. This keeps the path visually continuous from root to selection. The ancestor highlighting helps you orient in deep trees. As you navigate, the teal path updates from the root down to your current position.
Complete Example
Here's the full tree.gsx:
package main
import (
"os"
"path"
"path/filepath"
"sort"
"strings"
tui "github.com/grindlemire/go-tui"
)
// Node represents a file or directory in the tree.
type Node struct {
Name string
Children []Node
}
// visibleNode is a flattened node for rendering.
type visibleNode struct {
node Node
depth int
path string
isDir bool
isLast bool
ancestors []bool
onPath bool
}
// directoryTree is a foldable directory tree component.
type directoryTree struct {
rootPath string
tree []Node
cursor *tui.State[int]
expanded *tui.State[map[string]bool]
scrollY *tui.State[int]
scrollContainer *tui.Ref
}
// DirectoryTree creates a new directory tree component rooted at the given path.
func DirectoryTree(root string) *directoryTree {
rootNode := buildRootNode(root)
return &directoryTree{
rootPath: root,
tree: []Node{rootNode},
cursor: tui.NewState(0),
expanded: tui.NewState(map[string]bool{rootNode.Name: true}),
scrollY: tui.NewState(0),
scrollContainer: tui.NewRef(),
}
}
// navigateUp re-roots the tree at the parent of the current root.
func (d *directoryTree) navigateUp() {
parent := filepath.Dir(d.rootPath)
if parent == d.rootPath {
return
}
d.rootPath = parent
rootNode := buildRootNode(parent)
d.tree = []Node{rootNode}
d.cursor.Set(0)
d.expanded.Set(map[string]bool{rootNode.Name: true})
d.scrollY.Set(0)
}
// visibleSelectedPath returns the path of the currently selected node for display.
func (d *directoryTree) visibleSelectedPath() string {
visible := d.visibleNodes()
cur := d.cursor.Get()
if cur >= len(visible) {
return ""
}
return visible[cur].path
}
// loadChildren reads one level of children for the directory at the given logical path.
func (d *directoryTree) loadChildren(nodePath string) {
parts := strings.Split(nodePath, "/")
node := &d.tree[0]
fsPath := d.rootPath
for _, part := range parts[1:] {
found := false
for i := range node.Children {
if node.Children[i].Name == part {
node = &node.Children[i]
fsPath = filepath.Join(fsPath, part)
found = true
break
}
}
if !found {
return
}
}
if len(node.Children) == 0 {
node.Children = readDir(fsPath)
if node.Children == nil {
node.Children = []Node{}
}
}
}
// scrollToCursor adjusts scrollY state so the cursor row is visible.
func (d *directoryTree) scrollToCursor() {
el := d.scrollContainer.El()
if el == nil {
return
}
cur := d.cursor.Get()
_, vpH := el.ViewportSize()
y := d.scrollY.Get()
if cur < y {
d.scrollY.Set(cur)
} else if cur >= y+vpH {
d.scrollY.Set(cur - vpH + 1)
}
}
func (d *directoryTree) visibleNodes() []visibleNode {
var result []visibleNode
expanded := d.expanded.Get()
for i, node := range d.tree {
flattenNode(node, 0, node.Name, i == len(d.tree)-1, nil, expanded, &result)
}
cur := d.cursor.Get()
if cur < len(result) {
sel := result[cur].path
for i := range result {
result[i].onPath = result[i].path == sel || strings.HasPrefix(sel, result[i].path+"/")
}
}
return result
}
func (d *directoryTree) Watchers() []tui.Watcher {
return []tui.Watcher{
tui.OnChange(d.cursor, func(int) { d.scrollToCursor() }),
}
}
func (d *directoryTree) KeyMap() tui.KeyMap {
return tui.KeyMap{
tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.Rune('q'), func(ke tui.KeyEvent) { ke.App().Stop() }),
tui.On(tui.KeyUp, func(ke tui.KeyEvent) { d.moveUp() }),
tui.On(tui.Rune('k'), func(ke tui.KeyEvent) { d.moveUp() }),
tui.On(tui.KeyDown, func(ke tui.KeyEvent) { d.moveDown() }),
tui.On(tui.Rune('j'), func(ke tui.KeyEvent) { d.moveDown() }),
tui.On(tui.KeyEnter, func(ke tui.KeyEvent) { d.toggle() }),
tui.On(tui.KeyRight, func(ke tui.KeyEvent) { d.toggle() }),
tui.On(tui.Rune('l'), func(ke tui.KeyEvent) { d.toggle() }),
tui.On(tui.KeyLeft, func(ke tui.KeyEvent) { d.collapseOrParent() }),
tui.On(tui.Rune('h'), func(ke tui.KeyEvent) { d.collapseOrParent() }),
}
}
func (d *directoryTree) moveUp() {
d.cursor.Update(func(v int) int {
if v > 0 {
return v - 1
}
return v
})
}
func (d *directoryTree) moveDown() {
visible := d.visibleNodes()
d.cursor.Update(func(v int) int {
if v < len(visible)-1 {
return v + 1
}
return v
})
}
func (d *directoryTree) toggle() {
visible := d.visibleNodes()
cur := d.cursor.Get()
if cur >= len(visible) {
return
}
vn := visible[cur]
if !vn.isDir {
return
}
expanding := !d.expanded.Get()[vn.path]
if expanding {
d.loadChildren(vn.path)
}
d.expanded.Update(func(m map[string]bool) map[string]bool {
return cloneExpandedWith(m, vn.path, expanding)
})
}
func (d *directoryTree) collapseOrParent() {
visible := d.visibleNodes()
cur := d.cursor.Get()
if cur >= len(visible) {
return
}
vn := visible[cur]
// If on an expanded directory, collapse it
expanded := d.expanded.Get()
if vn.isDir && expanded[vn.path] {
d.expanded.Update(func(m map[string]bool) map[string]bool {
return cloneExpandedWith(m, vn.path, false)
})
return
}
// At root, navigate to parent directory
if vn.depth == 0 {
d.navigateUp()
return
}
// Jump to parent node
parentPath := path.Dir(vn.path)
for i := cur - 1; i >= 0; i-- {
if visible[i].path == parentPath {
d.cursor.Set(i)
return
}
}
}
templ (d *directoryTree) Render() {
<div class="flex-col w-full h-full border-rounded border-cyan">
<div class="flex-col p-1">
<span class="text-gradient-cyan-magenta font-bold">Directory Tree</span>
<span class="text-cyan font-dim">{d.visibleSelectedPath()}</span>
</div>
<hr class="border-single" />
<div
ref={d.scrollContainer}
class="flex-col grow overflow-y-scroll scrollbar-cyan scrollbar-thumb-bright-cyan"
scrollOffset={0, d.scrollY.Get()}>
for i, vn := range d.visibleNodes() {
if i == d.cursor.Get() {
<span class="bg-bright-black text-cyan font-bold">{buildPrefix(vn) + nodeLabel(vn, d.expanded.Get())}</span>
} else if vn.onPath {
<span class="text-cyan font-bold">{buildPrefix(vn) + nodeLabel(vn, d.expanded.Get())}</span>
} else if vn.isDir {
<span class="font-bold">{buildPrefix(vn) + nodeLabel(vn, d.expanded.Get())}</span>
} else {
<span>{buildPrefix(vn) + nodeLabel(vn, d.expanded.Get())}</span>
}
}
</div>
<hr class="border-single" />
<div class="flex justify-center p-1">
<span class="font-dim">j/k: navigate | enter/l: expand | h: collapse | q: quit</span>
</div>
</div>
}
// readDir reads one level of a directory and returns its children as Nodes, sorted dirs-first then alphabetically.
// Subdirectory children are not read until the user expands them (lazy loading).
func readDir(dirPath string) []Node {
entries, err := os.ReadDir(dirPath)
if err != nil {
return nil
}
var children []Node
for _, entry := range entries {
if entry.Name()[0] == '.' {
continue
}
node := Node{Name: entry.Name()}
if entry.IsDir() {
node.Children = []Node{}
}
children = append(children, node)
}
sortChildren(children)
return children
}
func sortChildren(children []Node) {
sort.Slice(children, func(i, j int) bool {
iDir := children[i].Children != nil
jDir := children[j].Children != nil
if iDir != jDir {
return iDir
}
return children[i].Name < children[j].Name
})
}
func buildRootNode(absPath string) Node {
root := Node{
Name: filepath.Base(absPath),
Children: readDir(absPath),
}
if root.Children == nil {
root.Children = []Node{}
}
return root
}
func cloneExpandedWith(m map[string]bool, key string, val bool) map[string]bool {
out := make(map[string]bool, len(m))
for k, v := range m {
out[k] = v
}
if val {
out[key] = true
} else {
delete(out, key)
}
return out
}
func flattenNode(n Node, depth int, nodePath string, isLast bool, ancestors []bool, expanded map[string]bool, result *[]visibleNode) {
isDir := n.Children != nil
*result = append(*result, visibleNode{
node: n,
depth: depth,
path: nodePath,
isDir: isDir,
isLast: isLast,
ancestors: ancestors,
})
if isDir && expanded[nodePath] {
newAncestors := make([]bool, len(ancestors)+1)
copy(newAncestors, ancestors)
newAncestors[len(ancestors)] = isLast
for i, child := range n.Children {
childPath := nodePath + "/" + child.Name
flattenNode(child, depth+1, childPath, i == len(n.Children)-1, newAncestors, expanded, result)
}
}
}
func buildPrefix(vn visibleNode) string {
if vn.depth == 0 {
return ""
}
var b strings.Builder
for i := 0; i < vn.depth-1; i++ {
if vn.ancestors[i+1] {
b.WriteString(" ")
} else {
b.WriteString("│ ")
}
}
if vn.isLast {
b.WriteString("└── ")
} else {
b.WriteString("├── ")
}
return b.String()
}
func nodeLabel(vn visibleNode, expanded map[string]bool) string {
if vn.isDir {
if expanded[vn.path] {
return "▼ " + vn.node.Name
}
return "▶ " + vn.node.Name
}
return vn.node.Name
}
With main.go:
package main
import (
"fmt"
"os"
"path/filepath"
tui "github.com/grindlemire/go-tui"
)
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
absRoot, err := filepath.Abs(root)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
app, err := tui.NewApp(
tui.WithRootComponent(DirectoryTree(absRoot)),
)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer app.Close()
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
Generate and run:
tui generate ./...
go run .
Pass a path to browse a specific directory:
go run . /usr/local
The directory tree browser should look like this:

Next Steps
- Scrolling -- Scrollable containers, keyboard and mouse scroll control
- Timers, Watchers, and Channels -- Background operations and state change watchers
- Building a Dashboard -- A larger example combining multiple go-tui features