esync

Directory watching and remote syncing
Log | Files | Refs | README | LICENSE

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 }