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

384
model.go Normal file
View File

@@ -0,0 +1,384 @@
package main
import (
"fmt"
"reader/filetree"
"reader/viewer"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Pane represents which pane is focused
type Pane int
const (
TreePane Pane = iota
ViewerPane
)
// Model is the main application model
type Model struct {
tree filetree.Model
viewer viewer.Model
focused Pane
showHelp bool
width int
height int
treeWidth int
ready bool
}
// NewModel creates a new application model
func NewModel(rootPath string) Model {
// Initialize file tree styles from current theme
filetree.DirectoryStyle = directoryStyle
filetree.FileStyle = fileStyle
filetree.SelectedStyle = selectedStyle
// Create viewer with theme config
v := viewer.New()
v.SetTheme(viewer.ThemeConfig{
ChromaStyle: CurrentTheme.ChromaStyle,
GlamourStyle: CurrentTheme.GlamourStyle,
LineNumColor: CurrentTheme.LineNumColor,
TextColor: string(CurrentTheme.Text),
})
return Model{
tree: filetree.New(rootPath),
viewer: v,
focused: TreePane,
treeWidth: 30,
}
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// Handle help toggle
if keyHelp.Matches(msg) {
m.showHelp = !m.showHelp
return m, nil
}
// If help is shown, any key closes it
if m.showHelp {
m.showHelp = false
return m, nil
}
// Handle quit
if keyQuit.Matches(msg) {
return m, tea.Quit
}
// Handle escape to go back to file tree
if keyEscape.Matches(msg) {
m.focused = TreePane
return m, nil
}
// Handle pane-specific keys
if m.focused == TreePane {
return m.updateTree(msg)
} else {
return m.updateViewer(msg)
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.ready = true
// Calculate pane widths
m.treeWidth = m.width / 3
if m.treeWidth < 25 {
m.treeWidth = 25
}
if m.treeWidth > 50 {
m.treeWidth = 50
}
viewerWidth := m.width - m.treeWidth - 4 // Account for borders
viewerHeight := m.height - 4 // Account for status bar and borders
m.viewer.SetSize(viewerWidth, viewerHeight)
return m, nil
}
return m, nil
}
func (m Model) updateTree(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case keyUp.Matches(msg):
m.tree.MoveUp()
case keyDown.Matches(msg):
m.tree.MoveDown()
case keyLeft.Matches(msg):
m.tree.Collapse()
case keyRight.Matches(msg):
node := m.tree.SelectedNode()
if node != nil && !node.IsDir {
m.openSelectedFile()
} else {
m.tree.Expand()
}
case keyEnter.Matches(msg):
node := m.tree.SelectedNode()
if node != nil {
if node.IsDir {
m.tree.Toggle()
} else {
m.openSelectedFile()
}
}
}
return m, nil
}
func (m *Model) openSelectedFile() {
node := m.tree.SelectedNode()
if node == nil || node.IsDir {
return
}
m.viewer.LoadFile(node.Path)
m.focused = ViewerPane // Automatically switch to viewer
}
func (m Model) updateViewer(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case keyUp.Matches(msg):
m.viewer.LineUp()
case keyDown.Matches(msg):
m.viewer.LineDown()
case keyPageDown.Matches(msg):
m.viewer.PageDown()
case keyPageUp.Matches(msg):
m.viewer.PageUp()
case keyHome.Matches(msg):
m.viewer.GotoTop()
case keyEnd.Matches(msg):
m.viewer.GotoBottom()
}
return m, nil
}
func (m Model) View() string {
if !m.ready {
return "Loading..."
}
// Render file tree pane
treeContent := m.tree.View(m.treeWidth-2, m.height-4, m.focused == TreePane)
var treePane string
if m.focused == TreePane {
treePane = activeBorderStyle.
Width(m.treeWidth - 2).
Height(m.height - 4).
Render(treeContent)
} else {
treePane = inactiveBorderStyle.
Width(m.treeWidth - 2).
Height(m.height - 4).
Render(treeContent)
}
// Render viewer pane
viewerWidth := m.width - m.treeWidth - 4
viewerContent := m.renderViewer(viewerWidth, m.height-4)
var viewerPane string
if m.focused == ViewerPane {
viewerPane = activeBorderStyle.
Width(viewerWidth).
Height(m.height - 4).
Render(viewerContent)
} else {
viewerPane = inactiveBorderStyle.
Width(viewerWidth).
Height(m.height - 4).
Render(viewerContent)
}
// Join panes horizontally
content := lipgloss.JoinHorizontal(lipgloss.Top, treePane, viewerPane)
// Add status bar
statusBar := m.renderStatusBar()
content = lipgloss.JoinVertical(lipgloss.Left, content, statusBar)
// Overlay help if shown
if m.showHelp {
content = m.renderHelpOverlay(content)
}
return content
}
func (m Model) renderViewer(width, height int) string {
if m.viewer.Empty() {
// Show welcome message
welcome := lipgloss.NewStyle().
Foreground(CurrentTheme.Subtext).
Italic(true).
Render("Select a file to view its contents\n\nPress ? for help")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, welcome)
}
return m.viewer.View()
}
func (m Model) renderStatusBar() string {
// Left side: current file or directory
var left string
if m.viewer.Empty() {
left = statusBarStyle.Render(fmt.Sprintf(" %s", m.tree.RootPath))
} else {
left = statusBarStyle.Render(fmt.Sprintf(" %s", m.viewer.FileName))
}
// Right side: scroll position and help hint
var right string
if !m.viewer.Empty() {
percent := int(m.viewer.ScrollPercent() * 100)
right = statusBarStyle.Render(fmt.Sprintf("%d%% ", percent))
}
right += statusKeyStyle.Render("? help")
// Calculate gap
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return left + strings.Repeat(" ", gap) + right
}
func (m Model) renderHelpOverlay(base string) string {
help := m.buildHelpContent()
// Center the help overlay
helpRendered := helpStyle.Render(help)
// Calculate position
helpWidth := lipgloss.Width(helpRendered)
helpHeight := lipgloss.Height(helpRendered)
x := (m.width - helpWidth) / 2
y := (m.height - helpHeight) / 2
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
// Overlay help on base content
return placeOverlay(x, y, helpRendered, base)
}
func (m Model) buildHelpContent() string {
title := helpTitleStyle.Render("Keyboard Shortcuts")
sections := []struct {
header string
keys []struct{ key, desc string }
}{
{
header: "File Browser",
keys: []struct{ key, desc string }{
{"↑/↓", "Move up/down"},
{"Enter", "Open file/folder"},
{"←", "Collapse/go back"},
{"→", "Expand/open"},
},
},
{
header: "Reading a File",
keys: []struct{ key, desc string }{
{"↑/↓", "Scroll up/down"},
{"PgUp/PgDn", "Page up/down"},
{"Home/End", "Top/bottom"},
{"Esc", "Back to files"},
},
},
{
header: "General",
keys: []struct{ key, desc string }{
{"?", "Toggle help"},
{"q", "Quit"},
},
},
}
var content strings.Builder
content.WriteString(title)
content.WriteString("\n\n")
for i, section := range sections {
header := lipgloss.NewStyle().Foreground(CurrentTheme.Teal).Bold(true).Render(section.header)
content.WriteString(header)
content.WriteString("\n")
for _, binding := range section.keys {
key := helpKeyStyle.Render(binding.key)
desc := helpDescStyle.Render(binding.desc)
content.WriteString(fmt.Sprintf(" %s %s\n", key, desc))
}
if i < len(sections)-1 {
content.WriteString("\n")
}
}
return content.String()
}
// placeOverlay places overlay on top of background at position x, y
func placeOverlay(x, y int, overlay, background string) string {
bgLines := strings.Split(background, "\n")
overlayLines := strings.Split(overlay, "\n")
for i, overlayLine := range overlayLines {
bgY := y + i
if bgY < 0 || bgY >= len(bgLines) {
continue
}
bgLine := bgLines[bgY]
bgRunes := []rune(bgLine)
// Build new line
var newLine strings.Builder
// Add background before overlay
for j := 0; j < x && j < len(bgRunes); j++ {
newLine.WriteRune(bgRunes[j])
}
// Pad if needed
for j := len(bgRunes); j < x; j++ {
newLine.WriteRune(' ')
}
// Add overlay
newLine.WriteString(overlayLine)
// Add background after overlay
overlayWidth := lipgloss.Width(overlayLine)
afterStart := x + overlayWidth
for j := afterStart; j < len(bgRunes); j++ {
newLine.WriteRune(bgRunes[j])
}
bgLines[bgY] = newLine.String()
}
return strings.Join(bgLines, "\n")
}