Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 41 additions & 109 deletions internal/tui/ui/page/pod_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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")),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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))
Expand All @@ -282,37 +227,24 @@ 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,
"",
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)
}
Expand Down
10 changes: 9 additions & 1 deletion internal/tui/ui/styles/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,22 @@ 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().
Width(p.Width).
Background(ColorBackgroundPanel())

if p.Height > 0 {
style = style.Height(p.Height)
style = style.MaxHeight(p.Height)
}

if len(p.Padding) > 0 {
Expand Down