package logview import ( "fmt" "strings" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // LogMessage is a message containing a line from a service's log. type LogMessage struct { ServiceName string Line string Color string } type model struct { services []string logChan <-chan LogMessage logs map[string][]string // Key: service name, Value: log lines allLogs []string viewports map[string]viewport.Model activeTab int ready bool width int height int } var ( tabStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).Padding(0, 1) activeTabStyle = tabStyle.Copy().Border(lipgloss.ThickBorder(), false, false, true, false).Foreground(lipgloss.Color("62")) inactiveTabStyle = tabStyle.Copy() windowStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Padding(1, 0) ) func NewModel(logChan <-chan LogMessage, services []string) model { m := model{ services: append([]string{"All"}, services...), logChan: logChan, logs: make(map[string][]string), viewports: make(map[string]viewport.Model), activeTab: 0, } for _, s := range m.services { m.logs[s] = []string{} } return m } func (m model) Init() tea.Cmd { return m.waitForLogs() } // waitForLogs waits for the next log message from the channel. func (m model) waitForLogs() tea.Cmd { return func() tea.Msg { return <-m.logChan } } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( cmd tea.Cmd cmds []tea.Cmd ) switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "right", "l": m.activeTab = (m.activeTab + 1) % len(m.services) m.updateViewportContent() case "left", "h": m.activeTab-- if m.activeTab < 0 { m.activeTab = len(m.services) - 1 } m.updateViewportContent() } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height headerHeight := lipgloss.Height(m.renderHeader()) vpHeight := m.height - headerHeight - windowStyle.GetVerticalFrameSize() if !m.ready { for _, serviceName := range m.services { m.viewports[serviceName] = viewport.New(m.width-windowStyle.GetHorizontalFrameSize(), vpHeight) } m.ready = true } else { for _, serviceName := range m.services { vp := m.viewports[serviceName] vp.Width = m.width - windowStyle.GetHorizontalFrameSize() vp.Height = vpHeight m.viewports[serviceName] = vp } } m.updateViewportContent() case LogMessage: logLine := fmt.Sprintf("%s%s%s", msg.Color, msg.Line, "\033[0m") allLogLine := fmt.Sprintf("%s[%-10s]%s %s", msg.Color, msg.ServiceName, "\033[0m", msg.Line) m.logs[msg.ServiceName] = append(m.logs[msg.ServiceName], logLine) m.allLogs = append(m.allLogs, allLogLine) m.updateViewportContent() cmds = append(cmds, m.waitForLogs()) } activeViewport := m.viewports[m.services[m.activeTab]] activeViewport, cmd = activeViewport.Update(msg) m.viewports[m.services[m.activeTab]] = activeViewport cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m *model) updateViewportContent() { if !m.ready { return } activeServiceName := m.services[m.activeTab] var content string if activeServiceName == "All" { content = strings.Join(m.allLogs, "\n") } else { content = strings.Join(m.logs[activeServiceName], "\n") } vp := m.viewports[activeServiceName] vp.SetContent(content) vp.GotoBottom() m.viewports[activeServiceName] = vp } func (m model) View() string { if !m.ready { return "Initializing..." } header := m.renderHeader() activeService := m.services[m.activeTab] viewport := m.viewports[activeService] return fmt.Sprintf("%s\n%s", header, windowStyle.Render(viewport.View())) } func (m model) renderHeader() string { var tabs []string for i, service := range m.services { if i == m.activeTab { tabs = append(tabs, activeTabStyle.Render(service)) } else { tabs = append(tabs, inactiveTabStyle.Render(service)) } } return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) } // Start runs the log viewer program. func Start(logChan <-chan LogMessage, services []string) error { m := NewModel(logChan, services) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) return p.Start() }