detached a3061c7

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 (
1010 "text/tabwriter"
1111 "time"
1212
13+ tea "github.com/charmbracelet/bubbletea"
1314 "github.com/spf13/cobra"
1415
16+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
17+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1518 "github.com/bnema/gordon/internal/domain"
1619 "github.com/bnema/gordon/internal/usecase/auto/preview"
1720 )
@@ -299,10 +302,110 @@ func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData
299302 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Push complete. Preview %q will be created by the server (use --remote to poll status).", name)))
300303 }
301304
302- if err := cliWriteLine(out, "Waiting for preview deployment..."); err != nil {
305+ result, err := pollPreviewWithSpinner(ctx, client, name)
306+ if err != nil {
303307 return err
304308 }
305309
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) {
306409 ticker := time.NewTicker(2 * time.Second)
307410 defer ticker.Stop()
308411 timeout := time.After(3 * time.Minute)
@@ -310,29 +413,17 @@ func waitForPreview(ctx context.Context, out io.Writer, name, ttl string, noData
310413 for {
311414 select {
312415 case <-ctx.Done():
313- return ctx.Err()
416+ return nil, ctx.Err()
314417 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
316419 case <-ticker.C:
317420 p, err := client.GetPreview(ctx, name)
318421 if err != nil {
319- continue // not found yet, keep polling
422+ continue
320423 }
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
334426 }
335- // status is PreviewStatusDeploying, keep polling
336427 }
337428 }
338429 }
@@ -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 err307 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 polling422+ 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
8686 // This is an acceptable trade-off for simplicity, but if such domains become common,
8787 // this should be replaced with a more robust detection mechanism (e.g., checking both
8888 // 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, ".")
9194 }
9295
9396 // 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 both87 // 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close