Refactor push deploy spinner to reuse Bubble Tea component
- commit
- 3a8691d798f329b1d70aac76d9b700d9f021eb03
- author
- brice · · 2026-02-08 17:00 UTC
- parent
- b8f764f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +69 -26
M internal/adapters/in/cli/push.go +69 -26
| @@ -7,9 +7,8 @@ import ( | |||
| 7 | 7 | "os/exec" | |
| 8 | 8 | "regexp" | |
| 9 | 9 | "strings" | |
| 10 | - | "time" | |
| 11 | 10 | ||
| 12 | - | "github.com/charmbracelet/lipgloss" | |
| 11 | + | tea "github.com/charmbracelet/bubbletea" | |
| 13 | 12 | "github.com/spf13/cobra" | |
| 14 | 13 | ||
| 15 | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| @@ -256,37 +255,81 @@ func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain st | |||
| 256 | 255 | return client.Deploy(ctx, pushDomain) | |
| 257 | 256 | } | |
| 258 | 257 | ||
| 259 | - | type deployOutcome struct { | |
| 260 | - | result *remote.DeployResult | |
| 261 | - | err error | |
| 262 | - | } | |
| 263 | - | ||
| 264 | 258 | done := make(chan deployOutcome, 1) | |
| 265 | 259 | go func() { | |
| 266 | 260 | result, err := client.Deploy(ctx, pushDomain) | |
| 267 | 261 | done <- deployOutcome{result: result, err: err} | |
| 268 | 262 | }() | |
| 269 | 263 | ||
| 270 | - | frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} | |
| 271 | - | frameStyle := lipgloss.NewStyle().Foreground(styles.ColorPrimary) | |
| 272 | - | message := styles.Theme.Muted.Render(fmt.Sprintf("Deploying %s...", pushDomain)) | |
| 273 | - | ||
| 274 | - | ticker := time.NewTicker(90 * time.Millisecond) | |
| 275 | - | defer ticker.Stop() | |
| 276 | - | ||
| 277 | - | frame := 0 | |
| 278 | - | for { | |
| 279 | - | select { | |
| 280 | - | case out := <-done: | |
| 281 | - | fmt.Print("\r\033[K") | |
| 282 | - | return out.result, out.err | |
| 283 | - | case <-ticker.C: | |
| 284 | - | fmt.Printf("\r%s %s", frameStyle.Render(frames[frame%len(frames)]), message) | |
| 285 | - | frame++ | |
| 286 | - | case <-ctx.Done(): | |
| 287 | - | fmt.Print("\r\033[K") | |
| 288 | - | return nil, ctx.Err() | |
| 264 | + | model := newDeploySpinnerModel(pushDomain, done) | |
| 265 | + | final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run() | |
| 266 | + | fmt.Print("\r\033[K") | |
| 267 | + | if err != nil { | |
| 268 | + | return nil, err | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | deployModel, ok := final.(deploySpinnerModel) | |
| 272 | + | if !ok { | |
| 273 | + | return nil, fmt.Errorf("spinner exited with unexpected model type %T", final) | |
| 274 | + | } | |
| 275 | + | if !deployModel.finished { | |
| 276 | + | return nil, fmt.Errorf("deploy spinner exited before deploy result was received") | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | return deployModel.outcome.result, deployModel.outcome.err | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | type deployOutcome struct { | |
| 283 | + | result *remote.DeployResult | |
| 284 | + | err error | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | type deployDoneMsg deployOutcome | |
| 288 | + | ||
| 289 | + | type deploySpinnerModel struct { | |
| 290 | + | spinner components.SpinnerModel | |
| 291 | + | done <-chan deployOutcome | |
| 292 | + | outcome deployOutcome | |
| 293 | + | finished bool | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel { | |
| 297 | + | return deploySpinnerModel{ | |
| 298 | + | spinner: components.NewSpinner( | |
| 299 | + | components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)), | |
| 300 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |
| 301 | + | ), | |
| 302 | + | done: done, | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func (m deploySpinnerModel) Init() tea.Cmd { | |
| 307 | + | return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done)) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 311 | + | switch msg := msg.(type) { | |
| 312 | + | case deployDoneMsg: | |
| 313 | + | m.outcome = deployOutcome(msg) | |
| 314 | + | m.finished = true | |
| 315 | + | return m, tea.Quit | |
| 316 | + | default: | |
| 317 | + | updated, cmd := m.spinner.Update(msg) | |
| 318 | + | spinnerModel, ok := updated.(components.SpinnerModel) | |
| 319 | + | if ok { | |
| 320 | + | m.spinner = spinnerModel | |
| 289 | 321 | } | |
| 322 | + | return m, cmd | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (m deploySpinnerModel) View() string { | |
| 327 | + | return m.spinner.View() | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func waitForDeployDone(done <-chan deployOutcome) tea.Cmd { | |
| 331 | + | return func() tea.Msg { | |
| 332 | + | return deployDoneMsg(<-done) | |
| 290 | 333 | } | |
| 291 | 334 | } | |
| 292 | 335 | ||
| @@ -7,9 +7,8 @@ import ( | |||||
| 7 | "os/exec" | 7 | "os/exec" | ||
| 8 | "regexp" | 8 | "regexp" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | - | "time" | |||
| 11 | 10 | ||||
| 12 | - | "github.com/charmbracelet/lipgloss" | 11 | + | tea "github.com/charmbracelet/bubbletea" |
| 13 | "github.com/spf13/cobra" | 12 | "github.com/spf13/cobra" | ||
| 14 | 13 | ||||
| 15 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| @@ -256,37 +255,81 @@ func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain st | |||||
| 256 | return client.Deploy(ctx, pushDomain) | 255 | return client.Deploy(ctx, pushDomain) | ||
| 257 | } | 256 | } | ||
| 258 | 257 | ||||
| 259 | - | type deployOutcome struct { | |||
| 260 | - | result *remote.DeployResult | |||
| 261 | - | err error | |||
| 262 | - | } | |||
| 263 | - | ||||
| 264 | done := make(chan deployOutcome, 1) | 258 | done := make(chan deployOutcome, 1) | ||
| 265 | go func() { | 259 | go func() { | ||
| 266 | result, err := client.Deploy(ctx, pushDomain) | 260 | result, err := client.Deploy(ctx, pushDomain) | ||
| 267 | done <- deployOutcome{result: result, err: err} | 261 | done <- deployOutcome{result: result, err: err} | ||
| 268 | }() | 262 | }() | ||
| 269 | 263 | ||||
| 270 | - | frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} | 264 | + | model := newDeploySpinnerModel(pushDomain, done) |
| 271 | - | frameStyle := lipgloss.NewStyle().Foreground(styles.ColorPrimary) | 265 | + | final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run() |
| 272 | - | message := styles.Theme.Muted.Render(fmt.Sprintf("Deploying %s...", pushDomain)) | 266 | + | fmt.Print("\r\033[K") |
| 273 | - | 267 | + | if err != nil { | |
| 274 | - | ticker := time.NewTicker(90 * time.Millisecond) | 268 | + | return nil, err |
| 275 | - | defer ticker.Stop() | 269 | + | } |
| 276 | - | 270 | + | ||
| 277 | - | frame := 0 | 271 | + | deployModel, ok := final.(deploySpinnerModel) |
| 278 | - | for { | 272 | + | if !ok { |
| 279 | - | select { | 273 | + | return nil, fmt.Errorf("spinner exited with unexpected model type %T", final) |
| 280 | - | case out := <-done: | 274 | + | } |
| 281 | - | fmt.Print("\r\033[K") | 275 | + | if !deployModel.finished { |
| 282 | - | return out.result, out.err | 276 | + | return nil, fmt.Errorf("deploy spinner exited before deploy result was received") |
| 283 | - | case <-ticker.C: | 277 | + | } |
| 284 | - | fmt.Printf("\r%s %s", frameStyle.Render(frames[frame%len(frames)]), message) | 278 | + | |
| 285 | - | frame++ | 279 | + | return deployModel.outcome.result, deployModel.outcome.err |
| 286 | - | case <-ctx.Done(): | 280 | + | } |
| 287 | - | fmt.Print("\r\033[K") | 281 | + | |
| 288 | - | return nil, ctx.Err() | 282 | + | type deployOutcome struct { |
| 283 | + | result *remote.DeployResult | |||
| 284 | + | err error | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | type deployDoneMsg deployOutcome | |||
| 288 | + | ||||
| 289 | + | type deploySpinnerModel struct { | |||
| 290 | + | spinner components.SpinnerModel | |||
| 291 | + | done <-chan deployOutcome | |||
| 292 | + | outcome deployOutcome | |||
| 293 | + | finished bool | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel { | |||
| 297 | + | return deploySpinnerModel{ | |||
| 298 | + | spinner: components.NewSpinner( | |||
| 299 | + | components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)), | |||
| 300 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |||
| 301 | + | ), | |||
| 302 | + | done: done, | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func (m deploySpinnerModel) Init() tea.Cmd { | |||
| 307 | + | return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done)) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 311 | + | switch msg := msg.(type) { | |||
| 312 | + | case deployDoneMsg: | |||
| 313 | + | m.outcome = deployOutcome(msg) | |||
| 314 | + | m.finished = true | |||
| 315 | + | return m, tea.Quit | |||
| 316 | + | default: | |||
| 317 | + | updated, cmd := m.spinner.Update(msg) | |||
| 318 | + | spinnerModel, ok := updated.(components.SpinnerModel) | |||
| 319 | + | if ok { | |||
| 320 | + | m.spinner = spinnerModel | |||
| 289 | } | 321 | } | ||
| 322 | + | return m, cmd | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (m deploySpinnerModel) View() string { | |||
| 327 | + | return m.spinner.View() | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func waitForDeployDone(done <-chan deployOutcome) tea.Cmd { | |||
| 331 | + | return func() tea.Msg { | |||
| 332 | + | return deployDoneMsg(<-done) | |||
| 290 | } | 333 | } | ||
| 291 | } | 334 | } | ||
| 292 | 335 | ||||