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
67 changes: 63 additions & 4 deletions internal/ui/rollinglog.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strings"
"sync"
"time"

tea "github.com/charmbracelet/bubbletea"
Expand All @@ -15,6 +16,26 @@ import (
"github.com/gcstr/dockform/internal/logger"
)

// activeProgram tracks the Bubble Tea program currently owning stdout while
// RunWithRollingLog is active. Spinner.SetLabel reads this to forward label
// updates into the rolling log view instead of animating on stdout directly.
var (
activeMu sync.RWMutex
activeProgram *tea.Program
)

func getActiveProgram() *tea.Program {
activeMu.RLock()
defer activeMu.RUnlock()
return activeProgram
}

func setActiveProgram(p *tea.Program) {
activeMu.Lock()
activeProgram = p
activeMu.Unlock()
}

// maxLogLines is the number of rolling log lines displayed in the UI.
const maxLogLines = 5

Expand Down Expand Up @@ -53,6 +74,11 @@ func RunWithRollingLog(ctx context.Context, fn func(ctx context.Context) (string
m := model{state: stateRunning, width: initialWidth, cancelCh: cancelCh}
p := tea.NewProgram(m, tea.WithOutput(os.Stdout))

// Expose the program so Spinner.SetLabel can forward status updates to
// the rolling UI instead of fighting for stdout.
setActiveProgram(p)
defer setActiveProgram(nil)

// Wire up display logger: intercepts structured log events and formats
// lines for the UI via appendLog messages.
displayLog := newDisplayLogger(func(line string) { p.Send(appendLog{line: line}) })
Expand Down Expand Up @@ -138,6 +164,8 @@ var displayNoiseKeys = map[string]bool{
"command": true,
}

var statusSpinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69"))

var (
displayStyleKey = lipgloss.NewStyle().Faint(true)
displayLevelStyles = map[string]lipgloss.Style{
Expand Down Expand Up @@ -236,16 +264,30 @@ const (
type appendLog struct{ line string }
type done struct{ report string }
type interrupted struct{}
type statusUpdate struct{ label string }
type statusTick struct{}

// statusSpinnerFrames mirrors ui.Spinner so the TUI and the standalone
// spinner use the same animation.
var statusSpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}

const statusTickDelay = 100 * time.Millisecond

func statusTickCmd() tea.Cmd {
return tea.Tick(statusTickDelay, func(time.Time) tea.Msg { return statusTick{} })
}

type model struct {
state state
width int
logLines []string // newest last, max maxLogLines
finalReport string
cancelCh chan struct{} // Signal channel for Ctrl+C
statusLabel string // current progress label (e.g., "Applying -> creating volume foo")
statusFrame int // spinner animation frame
}

func (m model) Init() tea.Cmd { return nil }
func (m model) Init() tea.Cmd { return statusTickCmd() }

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
Expand All @@ -270,6 +312,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if len(m.logLines) > maxLogLines {
m.logLines = m.logLines[len(m.logLines)-maxLogLines:]
}
case statusUpdate:
m.statusLabel = msg.label
case statusTick:
if m.state == stateRunning {
m.statusFrame++
return m, statusTickCmd()
}
case done:
m.finalReport = msg.report
m.state = stateFinal
Expand All @@ -292,9 +341,19 @@ func (m model) View() string {
var b strings.Builder
switch m.state {
case stateRunning:
// Leading blank line separates the rolling block from the
// Identifier/Contexts header printed immediately before us.
b.WriteByte('\n')
// Status line: animated frame + current label (e.g., "Applying -> creating volume foo").
// DisplayDaemonInfo prints a trailing blank line, so we don't add our own leading spacer.
// Sits above the rolling log so the current phase is always visible.
if m.statusLabel != "" {
frame := statusSpinnerFrames[m.statusFrame%len(statusSpinnerFrames)]
statusLine := borderPrefix + statusSpinnerStyle.Render(frame) + " " + m.statusLabel
if m.width > 1 {
statusLine = ansi.Truncate(statusLine, m.width-1, "")
}
b.WriteString(statusLine)
b.WriteByte('\n')
b.WriteByte('\n')
}
for _, l := range m.logLines {
// Build the complete line (border + content) first, then truncate
// the WHOLE thing to m.width-1. This guarantees the line never
Expand Down
4 changes: 2 additions & 2 deletions internal/ui/rollinglog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ func TestTruncOneRowANSI(t *testing.T) {
}

func TestModelInitAndRunningView(t *testing.T) {
if cmd := (model{}).Init(); cmd != nil {
t.Fatalf("expected nil init cmd")
if cmd := (model{}).Init(); cmd == nil {
t.Fatalf("expected non-nil init cmd (status tick)")
}
m := model{state: stateRunning, width: 80, logLines: []string{"line"}}
view := m.View()
Expand Down
40 changes: 25 additions & 15 deletions internal/ui/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,6 @@ type Spinner struct {
delay time.Duration
enabled bool

// spacer ensures a blank line above the spinner while running
spacerAdded bool

stopCh chan struct{}
doneCh chan struct{}
mu sync.Mutex
Expand Down Expand Up @@ -70,13 +67,20 @@ func NewSpinner(out io.Writer, label string) *Spinner {
func (s *Spinner) Start() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.enabled || s.stopCh == nil || s.doneCh == nil {
// When the rolling TUI owns stdout, forward the initial label to the
// Bubble Tea program so it can render a status line inside the rolling
// block. The stdout animation stays disabled.
if !s.enabled {
if p := getActiveProgram(); p != nil {
s.labelMu.RLock()
label := s.label
s.labelMu.RUnlock()
p.Send(statusUpdate{label: label})
}
return
}
// Insert a visual spacer line before spinner so it doesn't hug previous output
if !s.spacerAdded {
_, _ = fmt.Fprint(s.out, "\n")
s.spacerAdded = true
if s.stopCh == nil || s.doneCh == nil {
return
}
// If already running, do nothing
select {
Expand Down Expand Up @@ -120,13 +124,25 @@ func (s *Spinner) SetLabel(label string) {
s.labelMu.Lock()
s.label = label
s.labelMu.Unlock()
// Forward label updates to the rolling TUI when it's the active owner
// of stdout, so the status line above the rolling log stays current.
if p := getActiveProgram(); p != nil {
p.Send(statusUpdate{label: label})
}
}

// Stop stops the spinner and clears the line.
func (s *Spinner) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.enabled || s.stopCh == nil || s.doneCh == nil {
// Clear the status line in the rolling TUI if it's active.
if !s.enabled {
if p := getActiveProgram(); p != nil {
p.Send(statusUpdate{label: ""})
}
return
}
if s.stopCh == nil || s.doneCh == nil {
return
}
// Signal stop
Expand All @@ -137,10 +153,4 @@ func (s *Spinner) Stop() {
close(s.stopCh)
<-s.doneCh
}
// After the spinner line is cleared by the goroutine, also remove the spacer line
if s.spacerAdded {
// Move cursor up one line and clear it
_, _ = fmt.Fprint(s.out, "\x1b[1A\x1b[2K")
s.spacerAdded = false
}
}
6 changes: 0 additions & 6 deletions internal/ui/spinner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,9 @@ func TestSpinnerStartStopEnabled(t *testing.T) {
sp.Stop()

out := buf.String()
if !strings.Contains(out, "\n") {
t.Fatalf("expected spinner to add spacer newline, got %q", out)
}
if !strings.Contains(out, "working") {
t.Fatalf("expected spinner label in output, got %q", out)
}
if sp.spacerAdded {
t.Fatalf("expected spacer flag to reset on Stop")
}
}

func TestSpinnerHiddenViaEnv(t *testing.T) {
Expand Down
Loading