Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:47:33 +08:00
commit 5edac65f28
21 changed files with 6893 additions and 0 deletions

567
references/common_issues.md Normal file
View File

@@ -0,0 +1,567 @@
# Common Bubble Tea Issues and Solutions
Reference guide for diagnosing and fixing common problems in Bubble Tea applications.
## Performance Issues
### Issue: Slow/Laggy UI
**Symptoms:**
- UI freezes when typing
- Delayed response to key presses
- Stuttering animations
**Common Causes:**
1. **Blocking Operations in Update()**
```go
// ❌ BAD
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
data := http.Get("https://api.example.com") // BLOCKS!
m.data = data
}
return m, nil
}
// ✅ GOOD
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m, fetchDataCmd // Non-blocking
case dataFetchedMsg:
m.data = msg.data
}
return m, nil
}
func fetchDataCmd() tea.Msg {
data := http.Get("https://api.example.com") // Runs in goroutine
return dataFetchedMsg{data: data}
}
```
2. **Heavy Processing in View()**
```go
// ❌ BAD
func (m model) View() string {
content, _ := os.ReadFile("large_file.txt") // EVERY RENDER!
return string(content)
}
// ✅ GOOD
type model struct {
cachedContent string
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case fileLoadedMsg:
m.cachedContent = msg.content // Cache it
}
return m, nil
}
func (m model) View() string {
return m.cachedContent // Just return cached data
}
```
3. **String Concatenation with +**
```go
// ❌ BAD - Allocates many temp strings
func (m model) View() string {
s := ""
for _, line := range m.lines {
s += line + "\\n" // Expensive!
}
return s
}
// ✅ GOOD - Single allocation
func (m model) View() string {
var b strings.Builder
for _, line := range m.lines {
b.WriteString(line)
b.WriteString("\\n")
}
return b.String()
}
```
**Performance Target:** Update() should complete in <16ms (60 FPS)
---
## Layout Issues
### Issue: Content Overflows Terminal
**Symptoms:**
- Text wraps unexpectedly
- Content gets clipped
- Layout breaks on different terminal sizes
**Common Causes:**
1. **Hardcoded Dimensions**
```go
// ❌ BAD
content := lipgloss.NewStyle().
Width(80). // What if terminal is 120 wide?
Height(24). // What if terminal is 40 tall?
Render(text)
// ✅ GOOD
type model struct {
termWidth int
termHeight int
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.termWidth = msg.Width
m.termHeight = msg.Height
}
return m, nil
}
func (m model) View() string {
content := lipgloss.NewStyle().
Width(m.termWidth).
Height(m.termHeight - 2). // Leave room for status bar
Render(text)
return content
}
```
2. **Not Accounting for Padding/Borders**
```go
// ❌ BAD
style := lipgloss.NewStyle().
Padding(2).
Border(lipgloss.RoundedBorder()).
Width(80)
content := style.Render(text)
// Text area is 76 (80 - 2*2 padding), NOT 80!
// ✅ GOOD
style := lipgloss.NewStyle().
Padding(2).
Border(lipgloss.RoundedBorder())
contentWidth := 80 - style.GetHorizontalPadding() - style.GetHorizontalBorderSize()
innerContent := lipgloss.NewStyle().Width(contentWidth).Render(text)
result := style.Width(80).Render(innerContent)
```
3. **Manual Height Calculations**
```go
// ❌ BAD - Magic numbers
availableHeight := 24 - 3 // Where did 3 come from?
// ✅ GOOD - Calculated
headerHeight := lipgloss.Height(m.renderHeader())
footerHeight := lipgloss.Height(m.renderFooter())
availableHeight := m.termHeight - headerHeight - footerHeight
```
---
## Message Handling Issues
### Issue: Messages Arrive Out of Order
**Symptoms:**
- State becomes inconsistent
- Operations complete in wrong order
- Race conditions
**Cause:** Concurrent tea.Cmd messages aren't guaranteed to arrive in order
**Solution: Use State Tracking**
```go
// ❌ BAD - Assumes order
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "r" {
return m, tea.Batch(
fetchUsersCmd, // Might complete second
fetchPostsCmd, // Might complete first
)
}
case usersLoadedMsg:
m.users = msg.users
case postsLoadedMsg:
m.posts = msg.posts
// Assumes users are loaded! May not be!
}
return m, nil
}
// ✅ GOOD - Track operations
type model struct {
operations map[string]bool
users []User
posts []Post
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "r" {
m.operations["users"] = true
m.operations["posts"] = true
return m, tea.Batch(fetchUsersCmd, fetchPostsCmd)
}
case usersLoadedMsg:
m.users = msg.users
delete(m.operations, "users")
return m, m.checkAllLoaded()
case postsLoadedMsg:
m.posts = msg.posts
delete(m.operations, "posts")
return m, m.checkAllLoaded()
}
return m, nil
}
func (m model) checkAllLoaded() tea.Cmd {
if len(m.operations) == 0 {
// All operations complete, can proceed
return m.processData
}
return nil
}
```
---
## Terminal Recovery Issues
### Issue: Terminal Gets Messed Up After Crash
**Symptoms:**
- Cursor disappears
- Mouse mode still active
- Terminal looks corrupted
**Solution: Add Panic Recovery**
```go
func main() {
defer func() {
if r := recover(); r != nil {
// Restore terminal state
tea.DisableMouseAllMotion()
tea.ShowCursor()
fmt.Printf("Panic: %v\\n", r)
debug.PrintStack()
os.Exit(1)
}
}()
p := tea.NewProgram(initialModel())
if err := p.Start(); err != nil {
fmt.Printf("Error: %v\\n", err)
os.Exit(1)
}
}
```
---
## Architecture Issues
### Issue: Model Too Complex
**Symptoms:**
- Model struct has 20+ fields
- Update() is hundreds of lines
- Hard to maintain
**Solution: Use Model Tree Pattern**
```go
// ❌ BAD - Flat model
type model struct {
// List view fields
listItems []string
listCursor int
listFilter string
// Detail view fields
detailItem string
detailHTML string
detailScroll int
// Search view fields
searchQuery string
searchResults []string
searchCursor int
// ... 15 more fields
}
// ✅ GOOD - Model tree
type appModel struct {
activeView int
listView listViewModel
detailView detailViewModel
searchView searchViewModel
}
type listViewModel struct {
items []string
cursor int
filter string
}
func (m listViewModel) Update(msg tea.Msg) (listViewModel, tea.Cmd) {
// Only handles list-specific messages
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "up":
m.cursor--
case "down":
m.cursor++
case "enter":
return m, func() tea.Msg {
return itemSelectedMsg{itemID: m.items[m.cursor]}
}
}
}
return m, nil
}
// Parent routes messages
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle global messages
switch msg := msg.(type) {
case itemSelectedMsg:
m.detailView.LoadItem(msg.itemID)
m.activeView = 1 // Switch to detail
return m, nil
}
// Route to active child
var cmd tea.Cmd
switch m.activeView {
case 0:
m.listView, cmd = m.listView.Update(msg)
case 1:
m.detailView, cmd = m.detailView.Update(msg)
case 2:
m.searchView, cmd = m.searchView.Update(msg)
}
return m, cmd
}
```
---
## Memory Issues
### Issue: Memory Leak / Growing Memory Usage
**Symptoms:**
- Memory usage increases over time
- Never gets garbage collected
**Common Causes:**
1. **Goroutine Leaks**
```go
// ❌ BAD - Goroutines never stop
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "s" {
return m, func() tea.Msg {
go func() {
for { // INFINITE LOOP!
time.Sleep(time.Second)
// Do something
}
}()
return nil
}
}
}
return m, nil
}
// ✅ GOOD - Use context for cancellation
type model struct {
ctx context.Context
cancel context.CancelFunc
}
func initialModel() model {
ctx, cancel := context.WithCancel(context.Background())
return model{ctx: ctx, cancel: cancel}
}
func worker(ctx context.Context) tea.Msg {
for {
select {
case <-ctx.Done():
return nil // Stop gracefully
case <-time.After(time.Second):
// Do work
}
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" {
m.cancel() // Stop all workers
return m, tea.Quit
}
}
return m, nil
}
```
2. **Unreleased Resources**
```go
// ❌ BAD
func loadFile() tea.Msg {
file, _ := os.Open("data.txt")
// Never closed!
data, _ := io.ReadAll(file)
return dataMsg{data: data}
}
// ✅ GOOD
func loadFile() tea.Msg {
file, err := os.Open("data.txt")
if err != nil {
return errorMsg{err: err}
}
defer file.Close() // Always close
data, err := io.ReadAll(file)
return dataMsg{data: data, err: err}
}
```
---
## Testing Issues
### Issue: Hard to Test TUI
**Solution: Use teatest**
```go
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbletea/teatest"
)
func TestNavigation(t *testing.T) {
m := initialModel()
// Create test program
tm := teatest.NewTestModel(t, m)
// Send key presses
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
tm.Send(tea.KeyMsg{Type: tea.KeyDown})
// Wait for program to process
teatest.WaitFor(
t, tm.Output(),
func(bts []byte) bool {
return bytes.Contains(bts, []byte("Item 2"))
},
teatest.WithCheckInterval(time.Millisecond*100),
teatest.WithDuration(time.Second*3),
)
// Verify state
finalModel := tm.FinalModel(t).(model)
if finalModel.cursor != 2 {
t.Errorf("Expected cursor at 2, got %d", finalModel.cursor)
}
}
```
---
## Debugging Tips
### Enable Message Dumping
```go
import "github.com/davecgh/go-spew/spew"
type model struct {
dump io.Writer
}
func main() {
// Create debug file
f, _ := os.Create("debug.log")
defer f.Close()
m := model{dump: f}
p := tea.NewProgram(m)
p.Start()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Dump every message
if m.dump != nil {
spew.Fdump(m.dump, msg)
}
// ... rest of Update()
return m, nil
}
```
### Live Reload with Air
`.air.toml`:
```toml
[build]
cmd = "go build -o ./tmp/main ."
bin = "tmp/main"
include_ext = ["go"]
exclude_dir = ["tmp"]
delay = 1000
```
Run: `air`
---
## Quick Checklist
Before deploying your Bubble Tea app:
- [ ] No blocking operations in Update() or View()
- [ ] Terminal resize handled (tea.WindowSizeMsg)
- [ ] Panic recovery with terminal cleanup
- [ ] Dynamic layout (no hardcoded dimensions)
- [ ] Lipgloss padding/borders accounted for
- [ ] String operations use strings.Builder
- [ ] Goroutines have cancellation (context)
- [ ] Resources properly closed (defer)
- [ ] State machine handles message ordering
- [ ] Tests with teatest for key interactions
---
**Generated for Bubble Tea Maintenance Agent v1.0.0**