diff --git a/internal/tui/ui/page/pod_logs.go b/internal/tui/ui/page/pod_logs.go index 3bbe6d6..3848c9c 100644 --- a/internal/tui/ui/page/pod_logs.go +++ b/internal/tui/ui/page/pod_logs.go @@ -8,6 +8,7 @@ import ( "time" "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" lipgloss "charm.land/lipgloss/v2" "github.com/deeploy-sh/deeploy/internal/shared/model" @@ -31,74 +32,20 @@ type logsUpdated struct { status string } -// logsViewport is a simple scrollable viewport for log lines -type logsViewport struct { - lines []string - width int - height int - offset int -} - -func (v *logsViewport) setSize(w, h int) { - v.width = w - v.height = h -} - -func (v *logsViewport) setLines(lines []string) { - v.lines = lines -} - -func (v *logsViewport) scrollUp(n int) { - v.offset -= n - if v.offset < 0 { - v.offset = 0 - } -} - -func (v *logsViewport) scrollDown(n int) { - v.offset += n - max := len(v.lines) - v.height - if max < 0 { - max = 0 - } - if v.offset > max { - v.offset = max - } -} - -func (v *logsViewport) gotoBottom() { - v.offset = len(v.lines) - v.height - if v.offset < 0 { - v.offset = 0 - } -} - -func (v *logsViewport) view() string { - if v.height <= 0 { - return "" - } - - lines := make([]string, v.height) - for i := 0; i < v.height; i++ { - idx := v.offset + i - if idx < len(v.lines) { - lines[i] = v.lines[idx] - } - } - return strings.Join(lines, "\n") -} - +// podLogs displays streaming build/container logs with auto-scroll. +// Uses bubbles/viewport for proper scrolling and dimension constraints. type podLogs struct { store msg.Store pod *model.Pod project *model.Project - viewport logsViewport - logs []string - status string + viewport viewport.Model // handles scrolling, truncation, rendering + logs []string // raw log lines from API + status string // building, running, failed keyBack key.Binding keyDeploy key.Binding width int height int + cardProps styles.CardProps } func NewPodLogs(s msg.Store, podID string) podLogs { @@ -119,10 +66,10 @@ func NewPodLogs(s msg.Store, podID string) podLogs { } return podLogs{ - store: s, - pod: &pod, - project: &project, - viewport: logsViewport{}, + store: s, + pod: &pod, + project: &project, + viewport: viewport.New(), status: "building", keyBack: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc/q", "back")), keyDeploy: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "redeploy")), @@ -173,6 +120,8 @@ func (m podLogs) fetchLogs() tea.Cmd { } func (m podLogs) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch tmsg := tmsg.(type) { case pollLogsMsg: // Keep polling while building @@ -207,36 +156,24 @@ func (m podLogs) Update(tmsg tea.Msg) (tea.Model, tea.Cmd) { ) } - // Keyboard scroll - switch tmsg.String() { - case "up", "k": - m.viewport.scrollUp(1) - case "down", "j": - m.viewport.scrollDown(1) - } - return m, nil + // viewport handles up/down/pgup/pgdown/home/end natively + m.viewport, cmd = m.viewport.Update(tmsg) + return m, cmd case tea.MouseWheelMsg: - // Mouse scroll (3 lines per event) - if tmsg.Button == tea.MouseWheelUp { - m.viewport.scrollUp(3) - } else if tmsg.Button == tea.MouseWheelDown { - m.viewport.scrollDown(3) - } - return m, nil + // viewport handles mouse scroll natively + m.viewport, cmd = m.viewport.Update(tmsg) + return m, cmd case tea.WindowSizeMsg: m.width = tmsg.Width m.height = tmsg.Height - // Card width: responsive, max 120 - cardWidth := m.width - 8 - if cardWidth > 120 { - cardWidth = 120 - } - cardProps := styles.CardProps{Width: cardWidth, Padding: []int{1, 2}, Accent: true} - // Height: total minus card padding (1 top, 1 bottom), header, help, spacing - innerHeight := m.height - 10 - m.viewport.setSize(cardProps.InnerWidth(), innerHeight) + m.cardProps = styles.CardProps{Width: m.width, Padding: []int{1, 1}} + + // viewport height = available - card padding (2) - header area (2) + viewportHeight := m.height - 4 + m.viewport.SetWidth(m.cardProps.InnerWidth()) + m.viewport.SetHeight(viewportHeight) m.updateViewport() return m, nil } @@ -255,14 +192,22 @@ func (m podLogs) triggerDeploy() tea.Cmd { } } +// updateViewport syncs logs to viewport with "follow mode": +// - If user was at bottom, stay at bottom (follow new logs) +// - If user scrolled up, stay there (let them read) func (m *podLogs) updateViewport() { - m.viewport.setLines(m.logs) - if m.status == "building" { - m.viewport.gotoBottom() + wasAtBottom := m.viewport.AtBottom() + m.viewport.SetContent(strings.Join(m.logs, "\n")) + if wasAtBottom { + m.viewport.GotoBottom() } } func (m podLogs) View() tea.View { + if m.height == 0 { + return tea.NewView("Loading...") + } + bg := styles.ColorBackgroundPanel() titleStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.ColorPrimary()).Background(bg) header := titleStyle.Render(fmt.Sprintf("Build Logs: %s", m.pod.Title)) @@ -282,26 +227,14 @@ func (m podLogs) View() tea.View { spacer := lipgloss.NewStyle().Background(bg).Render(" ") headerText := header + spacer + statusText - // Card width: responsive, max 120 - cardWidth := m.width - 8 - if cardWidth > 120 { - cardWidth = 120 - } - - cardProps := styles.CardProps{ - Width: cardWidth, - Padding: []int{1, 2}, - Accent: true, - } - // Extend header to full width with background headerLine := lipgloss.NewStyle(). - Width(cardProps.InnerWidth()). + Width(m.cardProps.InnerWidth()). Background(bg). Render(headerText) - // Viewport content - just the lines, lipgloss handles overflow - logsContent := m.viewport.view() + // Viewport content + logsContent := m.viewport.View() content := lipgloss.JoinVertical(lipgloss.Left, headerLine, @@ -309,10 +242,9 @@ func (m podLogs) View() tea.View { logsContent, ) - card := styles.Card(cardProps).Render(content) + card := styles.Card(m.cardProps).Render(content) - centered := lipgloss.Place(m.width, m.height, - lipgloss.Center, lipgloss.Center, card) + centered := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, card) return tea.NewView(centered) } diff --git a/internal/tui/ui/styles/card.go b/internal/tui/ui/styles/card.go index 034c753..0c952de 100644 --- a/internal/tui/ui/styles/card.go +++ b/internal/tui/ui/styles/card.go @@ -30,6 +30,14 @@ func (p CardProps) InnerWidth() int { return inner } +func (p CardProps) InnerHeight() int { + inner := p.Height + if len(p.Padding) > 1 { + inner -= p.Padding[0] * 2 // vertical padding (top + bottom) + } + return inner +} + // Card creates a card style with panel background and optional left accent border. func Card(p CardProps) lipgloss.Style { style := lipgloss.NewStyle(). @@ -37,7 +45,7 @@ func Card(p CardProps) lipgloss.Style { Background(ColorBackgroundPanel()) if p.Height > 0 { - style = style.Height(p.Height) + style = style.MaxHeight(p.Height) } if len(p.Padding) > 0 {