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>
207 lines
4.2 KiB
Go
207 lines
4.2 KiB
Go
package filetree
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// Styles (will be set from parent)
|
|
var (
|
|
DirectoryStyle = lipgloss.NewStyle()
|
|
FileStyle = lipgloss.NewStyle()
|
|
SelectedStyle = lipgloss.NewStyle()
|
|
)
|
|
|
|
// View renders the file tree
|
|
func (m Model) View(width, height int, focused bool) string {
|
|
var b strings.Builder
|
|
|
|
// Calculate visible range for scrolling
|
|
visibleHeight := height - 2 // Account for potential padding
|
|
if visibleHeight < 1 {
|
|
visibleHeight = 1
|
|
}
|
|
|
|
startIdx := 0
|
|
if m.Cursor >= visibleHeight {
|
|
startIdx = m.Cursor - visibleHeight + 1
|
|
}
|
|
|
|
endIdx := startIdx + visibleHeight
|
|
if endIdx > len(m.Nodes) {
|
|
endIdx = len(m.Nodes)
|
|
}
|
|
|
|
for i := startIdx; i < endIdx; i++ {
|
|
node := m.Nodes[i]
|
|
line := m.renderNode(node, i == m.Cursor, focused)
|
|
|
|
// Truncate line if too long
|
|
if lipgloss.Width(line) > width-2 {
|
|
line = truncateString(line, width-2)
|
|
}
|
|
|
|
// Pad to width
|
|
padding := width - 2 - lipgloss.Width(line)
|
|
if padding > 0 {
|
|
line += strings.Repeat(" ", padding)
|
|
}
|
|
|
|
b.WriteString(line)
|
|
if i < endIdx-1 {
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (m Model) renderNode(node *Node, selected, focused bool) string {
|
|
// Build the prefix with tree characters
|
|
prefix := m.buildPrefix(node)
|
|
|
|
// Get icon
|
|
icon := getIcon(node)
|
|
|
|
// Format name
|
|
name := node.Name
|
|
if node.IsDir {
|
|
name += "/"
|
|
}
|
|
|
|
line := fmt.Sprintf("%s%s %s", prefix, icon, name)
|
|
|
|
// Apply style
|
|
if selected && focused {
|
|
return SelectedStyle.Render(line)
|
|
} else if selected {
|
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render(line)
|
|
} else if node.IsDir {
|
|
return DirectoryStyle.Render(line)
|
|
}
|
|
return FileStyle.Render(line)
|
|
}
|
|
|
|
func (m Model) buildPrefix(node *Node) string {
|
|
if node.Level == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Build prefix by traversing parents
|
|
prefixParts := make([]string, node.Level)
|
|
|
|
current := node
|
|
for i := node.Level - 1; i >= 0; i-- {
|
|
parent := current.Parent
|
|
if parent == nil {
|
|
prefixParts[i] = " "
|
|
continue
|
|
}
|
|
|
|
isLast := true
|
|
for j, sibling := range parent.Children {
|
|
if sibling == current {
|
|
isLast = j == len(parent.Children)-1
|
|
break
|
|
}
|
|
}
|
|
|
|
if i == node.Level-1 {
|
|
if isLast {
|
|
prefixParts[i] = "└─"
|
|
} else {
|
|
prefixParts[i] = "├─"
|
|
}
|
|
} else {
|
|
if isLast {
|
|
prefixParts[i] = " "
|
|
} else {
|
|
prefixParts[i] = "│ "
|
|
}
|
|
}
|
|
|
|
current = parent
|
|
}
|
|
|
|
return strings.Join(prefixParts, "")
|
|
}
|
|
|
|
func getIcon(node *Node) string {
|
|
if node.IsDir {
|
|
if node.IsOpen {
|
|
return "▼"
|
|
}
|
|
return "▶"
|
|
}
|
|
|
|
// Minimal icons using Unicode block/geometric characters
|
|
// Grouped by file type category
|
|
ext := node.Ext()
|
|
|
|
// Documents
|
|
docs := map[string]bool{".md": true, ".markdown": true, ".txt": true, ".pdf": true, ".doc": true, ".docx": true}
|
|
if docs[ext] {
|
|
return "◔"
|
|
}
|
|
|
|
// Code files
|
|
code := map[string]bool{
|
|
".go": true, ".py": true, ".js": true, ".ts": true, ".jsx": true, ".tsx": true,
|
|
".rs": true, ".c": true, ".cpp": true, ".h": true, ".hpp": true,
|
|
".java": true, ".rb": true, ".php": true, ".swift": true, ".kt": true,
|
|
".scala": true, ".ex": true, ".exs": true, ".clj": true, ".zig": true,
|
|
".nim": true, ".lua": true, ".vim": true, ".el": true,
|
|
}
|
|
if code[ext] {
|
|
return "◇"
|
|
}
|
|
|
|
// Shell/scripts
|
|
shell := map[string]bool{".sh": true, ".bash": true, ".zsh": true, ".fish": true}
|
|
if shell[ext] {
|
|
return "▷"
|
|
}
|
|
|
|
// Data/config
|
|
data := map[string]bool{
|
|
".json": true, ".yaml": true, ".yml": true, ".toml": true,
|
|
".xml": true, ".csv": true, ".sql": true, ".env": true,
|
|
}
|
|
if data[ext] {
|
|
return "◫"
|
|
}
|
|
|
|
// Web
|
|
web := map[string]bool{".html": true, ".css": true, ".scss": true, ".sass": true, ".less": true}
|
|
if web[ext] {
|
|
return "◈"
|
|
}
|
|
|
|
// Images
|
|
images := map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true}
|
|
if images[ext] {
|
|
return "◐"
|
|
}
|
|
|
|
// Default
|
|
return "○"
|
|
}
|
|
|
|
func truncateString(s string, maxWidth int) string {
|
|
if lipgloss.Width(s) <= maxWidth {
|
|
return s
|
|
}
|
|
|
|
// Simple truncation - could be improved for unicode
|
|
runes := []rune(s)
|
|
for i := len(runes) - 1; i >= 0; i-- {
|
|
truncated := string(runes[:i]) + "…"
|
|
if lipgloss.Width(truncated) <= maxWidth {
|
|
return truncated
|
|
}
|
|
}
|
|
return "…"
|
|
}
|