From 0763064a749b610892af1e5dc4a8409fa0054b3a Mon Sep 17 00:00:00 2001 From: axadrn Date: Fri, 26 Dec 2025 19:52:47 +0400 Subject: [PATCH 1/3] chore: implement innerHeight on card style, simplify logs card --- internal/tui/ui/page/pod_logs.go | 43 ++++++++++++-------------------- internal/tui/ui/styles/card.go | 8 ++++++ 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/internal/tui/ui/page/pod_logs.go b/internal/tui/ui/page/pod_logs.go index 3bbe6d6..9a1a8b5 100644 --- a/internal/tui/ui/page/pod_logs.go +++ b/internal/tui/ui/page/pod_logs.go @@ -99,6 +99,7 @@ type podLogs struct { keyDeploy key.Binding width int height int + cardProps styles.CardProps } func NewPodLogs(s msg.Store, podID string) podLogs { @@ -119,10 +120,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: logsViewport{}, status: "building", keyBack: key.NewBinding(key.WithKeys("esc", "q"), key.WithHelp("esc/q", "back")), keyDeploy: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "redeploy")), @@ -228,15 +229,11 @@ func (m podLogs) Update(tmsg tea.Msg) (tea.Model, tea.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, Height: m.height, Padding: []int{1, 1}} + + const logsHeaderHeight = 2 // header + empty line + m.viewport.setSize(m.cardProps.InnerWidth(), m.cardProps.InnerHeight()-logsHeaderHeight) m.updateViewport() return m, nil } @@ -263,6 +260,10 @@ func (m *podLogs) updateViewport() { } 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,21 +283,9 @@ 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) @@ -309,7 +298,7 @@ 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) diff --git a/internal/tui/ui/styles/card.go b/internal/tui/ui/styles/card.go index 034c753..dd997d0 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(). From a93c89c6c60c86488b8b1713303c1b3719203fbc Mon Sep 17 00:00:00 2001 From: axadrn Date: Fri, 26 Dec 2025 20:28:38 +0400 Subject: [PATCH 2/3] chore: some fixes --- internal/tui/ui/page/pod_logs.go | 7 ++++++- internal/tui/ui/styles/card.go | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/tui/ui/page/pod_logs.go b/internal/tui/ui/page/pod_logs.go index 9a1a8b5..ba51bd8 100644 --- a/internal/tui/ui/page/pod_logs.go +++ b/internal/tui/ui/page/pod_logs.go @@ -73,6 +73,10 @@ func (v *logsViewport) gotoBottom() { } } +func (v *logsViewport) isAtBottom() bool { + return v.offset >= len(v.lines)-v.height +} + func (v *logsViewport) view() string { if v.height <= 0 { return "" @@ -253,8 +257,9 @@ func (m podLogs) triggerDeploy() tea.Cmd { } func (m *podLogs) updateViewport() { + wasAtBottom := m.viewport.isAtBottom() m.viewport.setLines(m.logs) - if m.status == "building" { + if wasAtBottom { m.viewport.gotoBottom() } } diff --git a/internal/tui/ui/styles/card.go b/internal/tui/ui/styles/card.go index dd997d0..f984d82 100644 --- a/internal/tui/ui/styles/card.go +++ b/internal/tui/ui/styles/card.go @@ -45,7 +45,7 @@ func Card(p CardProps) lipgloss.Style { Background(ColorBackgroundPanel()) if p.Height > 0 { - style = style.Height(p.Height) + style = style.Height(p.Height).MaxHeight(p.Height) } if len(p.Padding) > 0 { From 94ac276e8c4a766cb63f94f851060f7e50b178d0 Mon Sep 17 00:00:00 2001 From: axadrn Date: Fri, 26 Dec 2025 20:58:15 +0400 Subject: [PATCH 3/3] feat(pod logs): replace custoom viewport with bubbles viewport --- internal/tui/ui/page/pod_logs.go | 120 ++++++++----------------------- internal/tui/ui/styles/card.go | 2 +- 2 files changed, 30 insertions(+), 92 deletions(-) diff --git a/internal/tui/ui/page/pod_logs.go b/internal/tui/ui/page/pod_logs.go index ba51bd8..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,15 @@ 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) isAtBottom() bool { - return v.offset >= len(v.lines)-v.height -} - -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 @@ -127,7 +69,7 @@ func NewPodLogs(s msg.Store, podID string) podLogs { store: s, pod: &pod, project: &project, - viewport: logsViewport{}, + 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")), @@ -178,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 @@ -212,32 +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 + m.cardProps = styles.CardProps{Width: m.width, Padding: []int{1, 1}} - m.cardProps = styles.CardProps{Width: m.width, Height: m.height, Padding: []int{1, 1}} - - const logsHeaderHeight = 2 // header + empty line - m.viewport.setSize(m.cardProps.InnerWidth(), m.cardProps.InnerHeight()-logsHeaderHeight) + // 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 } @@ -256,11 +192,14 @@ 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() { - wasAtBottom := m.viewport.isAtBottom() - m.viewport.setLines(m.logs) + wasAtBottom := m.viewport.AtBottom() + m.viewport.SetContent(strings.Join(m.logs, "\n")) if wasAtBottom { - m.viewport.gotoBottom() + m.viewport.GotoBottom() } } @@ -294,8 +233,8 @@ func (m podLogs) View() tea.View { 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, @@ -305,8 +244,7 @@ func (m podLogs) View() tea.View { 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 f984d82..0c952de 100644 --- a/internal/tui/ui/styles/card.go +++ b/internal/tui/ui/styles/card.go @@ -45,7 +45,7 @@ func Card(p CardProps) lipgloss.Style { Background(ColorBackgroundPanel()) if p.Height > 0 { - style = style.Height(p.Height).MaxHeight(p.Height) + style = style.MaxHeight(p.Height) } if len(p.Padding) > 0 {