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>
190 lines
3.4 KiB
Go
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))
|
|
}
|