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:
Chris Davies
2026-01-28 20:13:09 -05:00
commit 8f9c66ba46
12 changed files with 1658 additions and 0 deletions

280
viewer/model.go Normal file
View File

@@ -0,0 +1,280 @@
package viewer
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
)
// ThemeConfig holds theme settings for rendering
type ThemeConfig struct {
ChromaStyle string
GlamourStyle string
LineNumColor string
TextColor string
}
// Model represents the content viewer
type Model struct {
viewport viewport.Model
FilePath string
FileName string
content string
contentType string
ready bool
width int
height int
theme ThemeConfig
}
// New creates a new viewer model
func New() Model {
return Model{
theme: ThemeConfig{
ChromaStyle: "github",
GlamourStyle: "light",
LineNumColor: "#888888",
TextColor: "#1a1a1a",
},
}
}
// SetTheme updates the viewer's theme settings
func (m *Model) SetTheme(config ThemeConfig) {
m.theme = config
// Re-render if we have content
if m.FilePath != "" {
m.renderContent()
}
}
// SetSize sets the viewer dimensions
func (m *Model) SetSize(width, height int) {
m.width = width
m.height = height
if !m.ready {
m.viewport = viewport.New(width, height)
m.ready = true
} else {
m.viewport.Width = width
m.viewport.Height = height
}
// Re-render content if we have a file
if m.FilePath != "" {
m.renderContent()
}
}
// LoadFile loads and renders a file
func (m *Model) LoadFile(path string) error {
content, err := os.ReadFile(path)
if err != nil {
return err
}
m.FilePath = path
m.FileName = filepath.Base(path)
m.content = string(content)
m.contentType = detectContentType(path)
m.renderContent()
m.viewport.GotoTop()
return nil
}
// renderContent renders the content based on file type
func (m *Model) renderContent() {
if m.width <= 0 || m.height <= 0 {
return
}
var rendered string
switch m.contentType {
case "markdown":
rendered = m.renderMarkdown()
case "code":
rendered = m.renderCode()
default:
rendered = m.renderPlainText()
}
m.viewport.SetContent(rendered)
}
func (m *Model) renderMarkdown() string {
r, err := glamour.NewTermRenderer(
glamour.WithStylePath(m.theme.GlamourStyle),
glamour.WithWordWrap(m.width-4),
)
if err != nil {
return m.content
}
out, err := r.Render(m.content)
if err != nil {
return m.content
}
return out
}
func (m *Model) renderCode() string {
lexer := lexers.Match(m.FilePath)
if lexer == nil {
lexer = lexers.Fallback
}
lexer = chroma.Coalesce(lexer)
style := styles.Get(m.theme.ChromaStyle)
if style == nil {
style = styles.Fallback
}
formatter := formatters.Get("terminal256")
if formatter == nil {
formatter = formatters.Fallback
}
iterator, err := lexer.Tokenise(nil, m.content)
if err != nil {
return m.renderPlainText()
}
var buf strings.Builder
err = formatter.Format(&buf, style, iterator)
if err != nil {
return m.renderPlainText()
}
// Add line numbers
lines := strings.Split(buf.String(), "\n")
lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.theme.LineNumColor))
var result strings.Builder
for i, line := range lines {
lineNum := fmt.Sprintf("%4d │ ", i+1)
result.WriteString(lineNumStyle.Render(lineNum))
result.WriteString(line)
if i < len(lines)-1 {
result.WriteString("\n")
}
}
return result.String()
}
func (m *Model) renderPlainText() string {
lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.theme.LineNumColor))
textStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(m.theme.TextColor))
lines := strings.Split(m.content, "\n")
var result strings.Builder
for i, line := range lines {
lineNum := fmt.Sprintf("%4d │ ", i+1)
result.WriteString(lineNumStyle.Render(lineNum))
result.WriteString(textStyle.Render(line))
if i < len(lines)-1 {
result.WriteString("\n")
}
}
return result.String()
}
func detectContentType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
markdownExts := map[string]bool{
".md": true,
".markdown": true,
".mdown": true,
".mkd": true,
}
codeExts := 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, ".sh": true, ".bash": true,
".zsh": true, ".json": true, ".yaml": true, ".yml": true,
".toml": true, ".xml": true, ".html": true, ".css": true,
".scss": true, ".sass": true, ".sql": true, ".lua": true,
".vim": true, ".swift": true, ".kt": true, ".kts": true,
".zig": true, ".nim": true, ".ex": true, ".exs": true,
".erl": true, ".hrl": true, ".clj": true, ".scala": true,
}
if markdownExts[ext] {
return "markdown"
}
if codeExts[ext] {
return "code"
}
return "text"
}
// View renders the viewer
func (m Model) View() string {
if !m.ready {
return ""
}
return m.viewport.View()
}
// Viewport returns the viewport for external control
func (m *Model) Viewport() *viewport.Model {
return &m.viewport
}
// ScrollPercent returns the current scroll percentage
func (m Model) ScrollPercent() float64 {
return m.viewport.ScrollPercent()
}
// LineDown scrolls down one line
func (m *Model) LineDown() {
m.viewport.LineDown(1)
}
// LineUp scrolls up one line
func (m *Model) LineUp() {
m.viewport.LineUp(1)
}
// PageDown scrolls a full page down
func (m *Model) PageDown() {
m.viewport.ViewDown()
}
// PageUp scrolls a full page up
func (m *Model) PageUp() {
m.viewport.ViewUp()
}
// GotoTop scrolls to the top
func (m *Model) GotoTop() {
m.viewport.GotoTop()
}
// GotoBottom scrolls to the bottom
func (m *Model) GotoBottom() {
m.viewport.GotoBottom()
}
// Empty returns true if no file is loaded
func (m Model) Empty() bool {
return m.FilePath == ""
}