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>
This commit is contained in:
189
filetree/model.go
Normal file
189
filetree/model.go
Normal file
@@ -0,0 +1,189 @@
|
||||
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))
|
||||
}
|
||||
206
filetree/view.go
Normal file
206
filetree/view.go
Normal file
@@ -0,0 +1,206 @@
|
||||
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 "…"
|
||||
}
|
||||
Reference in New Issue
Block a user