Files
reader/filetree/model.go
Chris Davies 8f9c66ba46 Initial commit: terminal file browser & markdown viewer
A beautiful TUI for browsing files and reading markdown/code:
- Two-pane layout with file tree and content viewer
- Markdown rendering via Glamour
- Syntax highlighting via Chroma
- Auto-detects light/dark terminal theme
- Arrow key navigation, Esc to go back
- Page Up/Down, Home/End for scrolling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 20:13:09 -05:00

190 lines
3.4 KiB
Go

package filetree
import (
"os"
"path/filepath"
"sort"
"strings"
)
// Node represents a file or directory in the tree
type Node struct {
Name string
Path string
IsDir bool
IsOpen bool
Level int
Parent *Node
Children []*Node
}
// Model represents the file tree state
type Model struct {
Root *Node
Nodes []*Node // Flattened visible nodes for navigation
Cursor int
RootPath string
}
// New creates a new file tree model
func New(rootPath string) Model {
absPath, err := filepath.Abs(rootPath)
if err != nil {
absPath = rootPath
}
root := &Node{
Name: filepath.Base(absPath),
Path: absPath,
IsDir: true,
IsOpen: true,
Level: 0,
}
m := Model{
Root: root,
RootPath: absPath,
Cursor: 0,
}
m.loadChildren(root)
m.updateVisibleNodes()
return m
}
// loadChildren loads the children of a directory node
func (m *Model) loadChildren(node *Node) {
if !node.IsDir {
return
}
entries, err := os.ReadDir(node.Path)
if err != nil {
return
}
// Sort: directories first, then alphabetically
sort.Slice(entries, func(i, j int) bool {
iDir := entries[i].IsDir()
jDir := entries[j].IsDir()
if iDir != jDir {
return iDir
}
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
})
node.Children = make([]*Node, 0, len(entries))
for _, entry := range entries {
// Skip hidden files
if strings.HasPrefix(entry.Name(), ".") {
continue
}
child := &Node{
Name: entry.Name(),
Path: filepath.Join(node.Path, entry.Name()),
IsDir: entry.IsDir(),
Level: node.Level + 1,
Parent: node,
}
node.Children = append(node.Children, child)
}
}
// updateVisibleNodes updates the flattened list of visible nodes
func (m *Model) updateVisibleNodes() {
m.Nodes = make([]*Node, 0)
m.flattenNode(m.Root)
}
func (m *Model) flattenNode(node *Node) {
m.Nodes = append(m.Nodes, node)
if node.IsDir && node.IsOpen {
for _, child := range node.Children {
m.flattenNode(child)
}
}
}
// SelectedNode returns the currently selected node
func (m *Model) SelectedNode() *Node {
if m.Cursor < 0 || m.Cursor >= len(m.Nodes) {
return nil
}
return m.Nodes[m.Cursor]
}
// Toggle expands or collapses the selected directory
func (m *Model) Toggle() {
node := m.SelectedNode()
if node == nil || !node.IsDir {
return
}
node.IsOpen = !node.IsOpen
if node.IsOpen && len(node.Children) == 0 {
m.loadChildren(node)
}
m.updateVisibleNodes()
}
// MoveUp moves the cursor up
func (m *Model) MoveUp() {
if m.Cursor > 0 {
m.Cursor--
}
}
// MoveDown moves the cursor down
func (m *Model) MoveDown() {
if m.Cursor < len(m.Nodes)-1 {
m.Cursor++
}
}
// Collapse collapses the current directory or moves to parent
func (m *Model) Collapse() {
node := m.SelectedNode()
if node == nil {
return
}
if node.IsDir && node.IsOpen {
node.IsOpen = false
m.updateVisibleNodes()
} else if node.Parent != nil {
// Find parent in visible nodes
for i, n := range m.Nodes {
if n == node.Parent {
m.Cursor = i
break
}
}
}
}
// Expand expands the current directory
func (m *Model) Expand() {
node := m.SelectedNode()
if node == nil || !node.IsDir {
return
}
if !node.IsOpen {
node.IsOpen = true
if len(node.Children) == 0 {
m.loadChildren(node)
}
m.updateVisibleNodes()
}
}
// Ext returns the file extension of a node
func (n *Node) Ext() string {
if n.IsDir {
return ""
}
return strings.ToLower(filepath.Ext(n.Name))
}