logview.go (5579B)
1 package tui 2 3 import ( 4 "fmt" 5 "strings" 6 "time" 7 8 tea "github.com/charmbracelet/bubbletea" 9 ) 10 11 // --------------------------------------------------------------------------- 12 // Messages 13 // --------------------------------------------------------------------------- 14 15 // LogEntryMsg carries a log entry into the TUI. 16 type LogEntryMsg LogEntry 17 18 // --------------------------------------------------------------------------- 19 // Types 20 // --------------------------------------------------------------------------- 21 22 // LogEntry represents a single log line. 23 type LogEntry struct { 24 Time time.Time 25 Level string // "INF", "WRN", "ERR" 26 Message string 27 } 28 29 // LogViewModel is a scrollable log view. It is not a standalone tea.Model; 30 // its Update and View methods are called by AppModel. 31 type LogViewModel struct { 32 entries []LogEntry 33 offset int 34 width int 35 height int 36 filter string 37 filtering bool 38 follow bool // tail mode: auto-scroll to bottom on new entries 39 } 40 41 // --------------------------------------------------------------------------- 42 // Constructor 43 // --------------------------------------------------------------------------- 44 45 // NewLogView returns an empty LogViewModel with follow mode enabled. 46 func NewLogView() LogViewModel { 47 return LogViewModel{follow: true} 48 } 49 50 // --------------------------------------------------------------------------- 51 // Update / View (not tea.Model — managed by AppModel) 52 // --------------------------------------------------------------------------- 53 54 // Update handles messages for the log view. 55 func (m LogViewModel) Update(msg tea.Msg) (LogViewModel, tea.Cmd) { 56 switch msg := msg.(type) { 57 58 case tea.KeyMsg: 59 if m.filtering { 60 return m.updateFiltering(msg) 61 } 62 return m.updateNormal(msg) 63 64 case LogEntryMsg: 65 entry := LogEntry(msg) 66 m.entries = append(m.entries, entry) 67 if len(m.entries) > 1000 { 68 m.entries = m.entries[len(m.entries)-1000:] 69 } 70 if m.follow { 71 filtered := m.filteredEntries() 72 m.offset = max(0, len(filtered)-m.viewHeight()) 73 } 74 return m, nil 75 76 case tea.WindowSizeMsg: 77 m.width = msg.Width 78 m.height = msg.Height 79 return m, nil 80 } 81 82 return m, nil 83 } 84 85 // updateNormal handles keys when NOT in filtering mode. 86 func (m LogViewModel) updateNormal(msg tea.KeyMsg) (LogViewModel, tea.Cmd) { 87 filtered := m.filteredEntries() 88 maxOffset := max(0, len(filtered)-m.viewHeight()) 89 switch msg.String() { 90 case "up", "k": 91 m.follow = false 92 if m.offset > 0 { 93 m.offset-- 94 } 95 case "down", "j": 96 if m.offset < maxOffset { 97 m.offset++ 98 } 99 case "pgup": 100 m.follow = false 101 m.offset = max(0, m.offset-m.viewHeight()) 102 case "pgdown": 103 m.offset = min(maxOffset, m.offset+m.viewHeight()) 104 case "g": 105 m.follow = false 106 m.offset = 0 107 case "G": 108 m.offset = maxOffset 109 case "f": 110 m.follow = !m.follow 111 if m.follow { 112 m.offset = maxOffset 113 } 114 case "/": 115 m.filtering = true 116 m.filter = "" 117 m.offset = 0 118 } 119 return m, nil 120 } 121 122 // updateFiltering handles keys when in filtering mode. 123 func (m LogViewModel) updateFiltering(msg tea.KeyMsg) (LogViewModel, tea.Cmd) { 124 switch msg.Type { 125 case tea.KeyEnter: 126 m.filtering = false 127 case tea.KeyEscape: 128 m.filter = "" 129 m.filtering = false 130 case tea.KeyBackspace: 131 if len(m.filter) > 0 { 132 m.filter = m.filter[:len(m.filter)-1] 133 } 134 default: 135 if len(msg.String()) == 1 { 136 m.filter += msg.String() 137 } 138 } 139 m.offset = 0 140 return m, nil 141 } 142 143 // View renders the log view. 144 func (m LogViewModel) View() string { 145 var b strings.Builder 146 147 // Header 148 header := titleStyle.Render(" esync ─ logs ") + dimStyle.Render(strings.Repeat("─", max(0, m.width-15))) 149 b.WriteString(header + "\n") 150 151 // Log lines 152 filtered := m.filteredEntries() 153 vh := m.viewHeight() 154 start := m.offset 155 end := min(start+vh, len(filtered)) 156 157 for i := start; i < end; i++ { 158 entry := filtered[i] 159 ts := entry.Time.Format("15:04:05") 160 lvl := m.styledLevel(entry.Level) 161 b.WriteString(fmt.Sprintf(" %s %s %s\n", dimStyle.Render(ts), lvl, entry.Message)) 162 } 163 164 // Pad remaining lines 165 rendered := end - start 166 for i := rendered; i < vh; i++ { 167 b.WriteString("\n") 168 } 169 170 // Help / filter 171 if m.filtering { 172 b.WriteString(helpStyle.Render(fmt.Sprintf(" filter: %s█ (enter apply esc clear)", m.filter))) 173 } else { 174 help := " ↑↓/pgup/pgdn scroll g top G end f follow / filter l back q quit" 175 if m.follow { 176 help += " [FOLLOW]" 177 } 178 if m.filter != "" { 179 help += fmt.Sprintf(" [filter: %s]", m.filter) 180 } 181 b.WriteString(helpStyle.Render(help)) 182 } 183 b.WriteString("\n") 184 185 return b.String() 186 } 187 188 // --------------------------------------------------------------------------- 189 // Helpers 190 // --------------------------------------------------------------------------- 191 192 // viewHeight returns the number of log lines visible (total height minus 193 // header and help bar). 194 func (m LogViewModel) viewHeight() int { 195 return max(1, m.height-3) 196 } 197 198 // styledLevel returns the level string with appropriate color. 199 func (m LogViewModel) styledLevel(level string) string { 200 switch level { 201 case "INF": 202 return statusSynced.Render("INF") 203 case "WRN": 204 return statusSyncing.Render("WRN") 205 case "ERR": 206 return statusError.Render("ERR") 207 default: 208 return dimStyle.Render(level) 209 } 210 } 211 212 // filteredEntries returns log entries matching the current filter 213 // (case-insensitive match on Message). 214 func (m LogViewModel) filteredEntries() []LogEntry { 215 if m.filter == "" { 216 return m.entries 217 } 218 lf := strings.ToLower(m.filter) 219 var out []LogEntry 220 for _, e := range m.entries { 221 if strings.Contains(strings.ToLower(e.Message), lf) { 222 out = append(out, e) 223 } 224 } 225 return out 226 }