fix(preview): use bubbletea spinner for deploy polling, fix env loader for preview domains
- Replace plain text polling with bubbletea spinner matching push command UX - Fix isAttachmentContainer heuristic: preview domains like gordon--test.bnema.dev start with "gordon-" but contain dots, distinguishing them from attachment container names which never have dots
- commit
- a3061c7195b009080ac29fca843f114fa551b667
- author
- brice · · 2026-03-18 17:08 UTC
- committer
- brice · · 2026-03-18 22:35 UTC
- parent
- bffa8f6
- signature
- unsigned
2 files · +114 -20
M internal/adapters/in/cli/preview.go +109 -18
| @@ -10,8 +10,11 @@ import ( | |||
| 10 | 10 | "text/tabwriter" | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | + | tea "github.com/charmbracelet/bubbletea" | |
| 13 | 14 | "github.com/spf13/cobra" | |
| 14 | 15 | ||
| 16 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 17 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 15 | 18 | "github.com/bnema/gordon/internal/domain" | |
| 16 | 19 | "github.com/bnema/gordon/internal/usecase/auto/preview" | |
| 17 | 20 | ) | |
| @@ -299,10 +302,110 @@ func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData | |||
| 299 | 302 | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name))) | |
| 300 | 303 | } | |
| 301 | 304 | ||
| 302 | - | if err := cliWriteLine(out, "Waiting for preview deployment..."); err != nil { | |
| 305 | + | result, err := pollPreviewWithSpinner(ctx, client, name) | |
| 306 | + | if err != nil { | |
| 303 | 307 | return err | |
| 304 | 308 | } | |
| 305 | 309 | ||
| 310 | + | switch result.Status { | |
| 311 | + | case domain.PreviewStatusRunning: | |
| 312 | + | scheme := "http" | |
| 313 | + | if result.HTTPS { | |
| 314 | + | scheme = "https" | |
| 315 | + | } | |
| 316 | + | previewURL := fmt.Sprintf("%s://%s", scheme, result.Domain) | |
| 317 | + | if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil { | |
| 318 | + | return err | |
| 319 | + | } | |
| 320 | + | return cliWriteLine(out, cliRenderMeta("URL:", previewURL)) | |
| 321 | + | case domain.PreviewStatusFailed: | |
| 322 | + | return fmt.Errorf("preview deployment failed") | |
| 323 | + | default: | |
| 324 | + | return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote")) | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | type previewPollResult struct { | |
| 329 | + | preview *domain.PreviewRoute | |
| 330 | + | err error | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | type previewPollDoneMsg previewPollResult | |
| 334 | + | ||
| 335 | + | type previewSpinnerModel struct { | |
| 336 | + | spinner components.SpinnerModel | |
| 337 | + | done <-chan previewPollResult | |
| 338 | + | outcome previewPollResult | |
| 339 | + | finished bool | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func newPreviewSpinnerModel(name string, done <-chan previewPollResult) previewSpinnerModel { | |
| 343 | + | return previewSpinnerModel{ | |
| 344 | + | spinner: components.NewSpinner( | |
| 345 | + | components.WithMessage(fmt.Sprintf("Deploying preview %s...", name)), | |
| 346 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |
| 347 | + | ), | |
| 348 | + | done: done, | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func (m previewSpinnerModel) Init() tea.Cmd { | |
| 353 | + | return tea.Batch(m.spinner.Init(), waitForPreviewPollDone(m.done)) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func (m previewSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 357 | + | switch msg := msg.(type) { | |
| 358 | + | case previewPollDoneMsg: | |
| 359 | + | m.outcome = previewPollResult(msg) | |
| 360 | + | m.finished = true | |
| 361 | + | return m, tea.Quit | |
| 362 | + | default: | |
| 363 | + | updated, cmd := m.spinner.Update(msg) | |
| 364 | + | if sm, ok := updated.(components.SpinnerModel); ok { | |
| 365 | + | m.spinner = sm | |
| 366 | + | } | |
| 367 | + | return m, cmd | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func (m previewSpinnerModel) View() string { | |
| 372 | + | return m.spinner.View() | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd { | |
| 376 | + | return func() tea.Msg { | |
| 377 | + | return previewPollDoneMsg(<-done) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func pollPreviewWithSpinner(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) { | |
| 382 | + | done := make(chan previewPollResult, 1) | |
| 383 | + | go func() { | |
| 384 | + | result, err := pollPreviewStatus(ctx, client, name) | |
| 385 | + | done <- previewPollResult{preview: result, err: err} | |
| 386 | + | }() | |
| 387 | + | ||
| 388 | + | if !isInteractiveTerminal() { | |
| 389 | + | fmt.Printf("Waiting for preview %s...\n", name) | |
| 390 | + | r := <-done | |
| 391 | + | return r.preview, r.err | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | model := newPreviewSpinnerModel(name, done) | |
| 395 | + | final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run() | |
| 396 | + | fmt.Print("\r\033[K") | |
| 397 | + | if err != nil { | |
| 398 | + | return nil, err | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | m, ok := final.(previewSpinnerModel) | |
| 402 | + | if !ok || !m.finished { | |
| 403 | + | return nil, fmt.Errorf("preview spinner exited unexpectedly") | |
| 404 | + | } | |
| 405 | + | return m.outcome.preview, m.outcome.err | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | func pollPreviewStatus(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) { | |
| 306 | 409 | ticker := time.NewTicker(2 * time.Second) | |
| 307 | 410 | defer ticker.Stop() | |
| 308 | 411 | timeout := time.After(3 * time.Minute) | |
| @@ -310,29 +413,17 @@ func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData | |||
| 310 | 413 | for { | |
| 311 | 414 | select { | |
| 312 | 415 | case <-ctx.Done(): | |
| 313 | - | return ctx.Err() | |
| 416 | + | return nil, ctx.Err() | |
| 314 | 417 | case <-timeout: | |
| 315 | - | return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote")) | |
| 418 | + | return &domain.PreviewRoute{Status: "timeout"}, nil | |
| 316 | 419 | case <-ticker.C: | |
| 317 | 420 | p, err := client.GetPreview(ctx, name) | |
| 318 | 421 | if err != nil { | |
| 319 | - | continue // not found yet, keep polling | |
| 422 | + | continue | |
| 320 | 423 | } | |
| 321 | - | switch p.Status { | |
| 322 | - | case domain.PreviewStatusRunning: | |
| 323 | - | scheme := "http" | |
| 324 | - | if p.HTTPS { | |
| 325 | - | scheme = "https" | |
| 326 | - | } | |
| 327 | - | previewURL := fmt.Sprintf("%s://%s", scheme, p.Domain) | |
| 328 | - | if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil { | |
| 329 | - | return err | |
| 330 | - | } | |
| 331 | - | return cliWriteLine(out, cliRenderMeta("URL:", previewURL)) | |
| 332 | - | case domain.PreviewStatusFailed: | |
| 333 | - | return fmt.Errorf("preview deployment failed") | |
| 424 | + | if p.Status == domain.PreviewStatusRunning || p.Status == domain.PreviewStatusFailed { | |
| 425 | + | return p, nil | |
| 334 | 426 | } | |
| 335 | - | // status is PreviewStatusDeploying, keep polling | |
| 336 | 427 | } | |
| 337 | 428 | } | |
| 338 | 429 | } | |
| @@ -10,8 +10,11 @@ import ( | |||||
| 10 | "text/tabwriter" | 10 | "text/tabwriter" | ||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 13 | "github.com/spf13/cobra" | 14 | "github.com/spf13/cobra" | ||
| 14 | 15 | ||||
| 16 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 17 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |||
| 15 | "github.com/bnema/gordon/internal/domain" | 18 | "github.com/bnema/gordon/internal/domain" | ||
| 16 | "github.com/bnema/gordon/internal/usecase/auto/preview" | 19 | "github.com/bnema/gordon/internal/usecase/auto/preview" | ||
| 17 | ) | 20 | ) | ||
| @@ -299,10 +302,110 @@ func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData | |||||
| 299 | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name))) | 302 | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name))) | ||
| 300 | } | 303 | } | ||
| 301 | 304 | ||||
| 302 | - | if err := cliWriteLine(out, "Waiting for preview deployment..."); err != nil { | 305 | + | result, err := pollPreviewWithSpinner(ctx, client, name) |
| 306 | + | if err != nil { | |||
| 303 | return err | 307 | return err | ||
| 304 | } | 308 | } | ||
| 305 | 309 | ||||
| 310 | + | switch result.Status { | |||
| 311 | + | case domain.PreviewStatusRunning: | |||
| 312 | + | scheme := "http" | |||
| 313 | + | if result.HTTPS { | |||
| 314 | + | scheme = "https" | |||
| 315 | + | } | |||
| 316 | + | previewURL := fmt.Sprintf("%s://%s", scheme, result.Domain) | |||
| 317 | + | if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil { | |||
| 318 | + | return err | |||
| 319 | + | } | |||
| 320 | + | return cliWriteLine(out, cliRenderMeta("URL:", previewURL)) | |||
| 321 | + | case domain.PreviewStatusFailed: | |||
| 322 | + | return fmt.Errorf("preview deployment failed") | |||
| 323 | + | default: | |||
| 324 | + | return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote")) | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | type previewPollResult struct { | |||
| 329 | + | preview *domain.PreviewRoute | |||
| 330 | + | err error | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | type previewPollDoneMsg previewPollResult | |||
| 334 | + | ||||
| 335 | + | type previewSpinnerModel struct { | |||
| 336 | + | spinner components.SpinnerModel | |||
| 337 | + | done <-chan previewPollResult | |||
| 338 | + | outcome previewPollResult | |||
| 339 | + | finished bool | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func newPreviewSpinnerModel(name string, done <-chan previewPollResult) previewSpinnerModel { | |||
| 343 | + | return previewSpinnerModel{ | |||
| 344 | + | spinner: components.NewSpinner( | |||
| 345 | + | components.WithMessage(fmt.Sprintf("Deploying preview %s...", name)), | |||
| 346 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |||
| 347 | + | ), | |||
| 348 | + | done: done, | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func (m previewSpinnerModel) Init() tea.Cmd { | |||
| 353 | + | return tea.Batch(m.spinner.Init(), waitForPreviewPollDone(m.done)) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func (m previewSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 357 | + | switch msg := msg.(type) { | |||
| 358 | + | case previewPollDoneMsg: | |||
| 359 | + | m.outcome = previewPollResult(msg) | |||
| 360 | + | m.finished = true | |||
| 361 | + | return m, tea.Quit | |||
| 362 | + | default: | |||
| 363 | + | updated, cmd := m.spinner.Update(msg) | |||
| 364 | + | if sm, ok := updated.(components.SpinnerModel); ok { | |||
| 365 | + | m.spinner = sm | |||
| 366 | + | } | |||
| 367 | + | return m, cmd | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | func (m previewSpinnerModel) View() string { | |||
| 372 | + | return m.spinner.View() | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | func waitForPreviewPollDone(done <-chan previewPollResult) tea.Cmd { | |||
| 376 | + | return func() tea.Msg { | |||
| 377 | + | return previewPollDoneMsg(<-done) | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func pollPreviewWithSpinner(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) { | |||
| 382 | + | done := make(chan previewPollResult, 1) | |||
| 383 | + | go func() { | |||
| 384 | + | result, err := pollPreviewStatus(ctx, client, name) | |||
| 385 | + | done <- previewPollResult{preview: result, err: err} | |||
| 386 | + | }() | |||
| 387 | + | ||||
| 388 | + | if !isInteractiveTerminal() { | |||
| 389 | + | fmt.Printf("Waiting for preview %s...\n", name) | |||
| 390 | + | r := <-done | |||
| 391 | + | return r.preview, r.err | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | model := newPreviewSpinnerModel(name, done) | |||
| 395 | + | final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run() | |||
| 396 | + | fmt.Print("\r\033[K") | |||
| 397 | + | if err != nil { | |||
| 398 | + | return nil, err | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | m, ok := final.(previewSpinnerModel) | |||
| 402 | + | if !ok || !m.finished { | |||
| 403 | + | return nil, fmt.Errorf("preview spinner exited unexpectedly") | |||
| 404 | + | } | |||
| 405 | + | return m.outcome.preview, m.outcome.err | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | func pollPreviewStatus(ctx context.Context, client *remote.Client, name string) (*domain.PreviewRoute, error) { | |||
| 306 | ticker := time.NewTicker(2 * time.Second) | 409 | ticker := time.NewTicker(2 * time.Second) | ||
| 307 | defer ticker.Stop() | 410 | defer ticker.Stop() | ||
| 308 | timeout := time.After(3 * time.Minute) | 411 | timeout := time.After(3 * time.Minute) | ||
| @@ -310,29 +413,17 @@ func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData | |||||
| 310 | for { | 413 | for { | ||
| 311 | select { | 414 | select { | ||
| 312 | case <-ctx.Done(): | 415 | case <-ctx.Done(): | ||
| 313 | - | return ctx.Err() | 416 | + | return nil, ctx.Err() |
| 314 | case <-timeout: | 417 | case <-timeout: | ||
| 315 | - | return cliWriteLine(out, cliRenderInfo("Preview is still deploying. Check status with: gordon preview list --remote")) | 418 | + | return &domain.PreviewRoute{Status: "timeout"}, nil |
| 316 | case <-ticker.C: | 419 | case <-ticker.C: | ||
| 317 | p, err := client.GetPreview(ctx, name) | 420 | p, err := client.GetPreview(ctx, name) | ||
| 318 | if err != nil { | 421 | if err != nil { | ||
| 319 | - | continue // not found yet, keep polling | 422 | + | continue |
| 320 | } | 423 | } | ||
| 321 | - | switch p.Status { | 424 | + | if p.Status == domain.PreviewStatusRunning || p.Status == domain.PreviewStatusFailed { |
| 322 | - | case domain.PreviewStatusRunning: | 425 | + | return p, nil |
| 323 | - | scheme := "http" | |||
| 324 | - | if p.HTTPS { | |||
| 325 | - | scheme = "https" | |||
| 326 | - | } | |||
| 327 | - | previewURL := fmt.Sprintf("%s://%s", scheme, p.Domain) | |||
| 328 | - | if err := cliWriteLine(out, cliRenderSuccess("Preview deployed")); err != nil { | |||
| 329 | - | return err | |||
| 330 | - | } | |||
| 331 | - | return cliWriteLine(out, cliRenderMeta("URL:", previewURL)) | |||
| 332 | - | case domain.PreviewStatusFailed: | |||
| 333 | - | return fmt.Errorf("preview deployment failed") | |||
| 334 | } | 426 | } | ||
| 335 | - | // status is PreviewStatusDeploying, keep polling | |||
| 336 | } | 427 | } | ||
| 337 | } | 428 | } | ||
| 338 | } | 429 | } | ||
M internal/adapters/out/envloader/pass_loader.go +5 -2
| @@ -86,8 +86,11 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro | |||
| 86 | 86 | // This is an acceptable trade-off for simplicity, but if such domains become common, | |
| 87 | 87 | // this should be replaced with a more robust detection mechanism (e.g., checking both | |
| 88 | 88 | // GetAll() and GetAllAttachment() and seeing which returns results). | |
| 89 | - | func isAttachmentContainer(domain string) bool { | |
| 90 | - | return strings.HasPrefix(domain, "gordon-") | |
| 89 | + | func isAttachmentContainer(name string) bool { | |
| 90 | + | // Attachment container names start with "gordon-" and never contain dots. | |
| 91 | + | // Domain names (including preview domains like "gordon--test.bnema.dev") | |
| 92 | + | // always contain dots, so checking for dots distinguishes them. | |
| 93 | + | return strings.HasPrefix(name, "gordon-") && !strings.Contains(name, ".") | |
| 91 | 94 | } | |
| 92 | 95 | ||
| 93 | 96 | // CreateEnvFile is a no-op for pass-backed loader. | |
| @@ -86,8 +86,11 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro | |||||
| 86 | // This is an acceptable trade-off for simplicity, but if such domains become common, | 86 | // This is an acceptable trade-off for simplicity, but if such domains become common, | ||
| 87 | // this should be replaced with a more robust detection mechanism (e.g., checking both | 87 | // this should be replaced with a more robust detection mechanism (e.g., checking both | ||
| 88 | // GetAll() and GetAllAttachment() and seeing which returns results). | 88 | // GetAll() and GetAllAttachment() and seeing which returns results). | ||
| 89 | - | func isAttachmentContainer(domain string) bool { | 89 | + | func isAttachmentContainer(name string) bool { |
| 90 | - | return strings.HasPrefix(domain, "gordon-") | 90 | + | // Attachment container names start with "gordon-" and never contain dots. |
| 91 | + | // Domain names (including preview domains like "gordon--test.bnema.dev") | |||
| 92 | + | // always contain dots, so checking for dots distinguishes them. | |||
| 93 | + | return strings.HasPrefix(name, "gordon-") && !strings.Contains(name, ".") | |||
| 91 | } | 94 | } | ||
| 92 | 95 | ||||
| 93 | // CreateEnvFile is a no-op for pass-backed loader. | 96 | // CreateEnvFile is a no-op for pass-backed loader. | ||