bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
81 lines
2.3 KiB
Go
81 lines
2.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package output
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// spinnerFrames are braille spinner glyphs cycled to animate progress.
|
|
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
|
|
|
const (
|
|
spinnerInterval = 80 * time.Millisecond
|
|
spinnerHideCursor = "\x1b[?25l"
|
|
spinnerShowCursor = "\x1b[?25h"
|
|
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
|
|
)
|
|
|
|
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
|
|
// until the returned stop() is called, e.g.:
|
|
//
|
|
// ⠹ Publishing dev → main... 3s
|
|
//
|
|
// It is meant for slow operations (long polls, first-time provisioning) so the
|
|
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
|
|
// animation never pollutes stdout — the JSON/pretty result stays clean.
|
|
//
|
|
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
|
|
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
|
|
// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the
|
|
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
|
|
//
|
|
// stop() clears the spinner line, restores the cursor, and blocks until the
|
|
// render goroutine has finished — so callers can safely write the result to
|
|
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
|
|
// it is safe to call more than once (e.g. an explicit call plus a defer).
|
|
func StartSpinner(w io.Writer, enabled bool, label string) func() {
|
|
if !enabled || w == nil {
|
|
return func() {}
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
finished := make(chan struct{})
|
|
start := time.Now()
|
|
|
|
go func() {
|
|
defer close(finished)
|
|
frame := 0
|
|
fmt.Fprint(w, spinnerHideCursor)
|
|
render := func() {
|
|
elapsed := int(time.Since(start).Seconds())
|
|
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
|
|
frame = (frame + 1) % len(spinnerFrames)
|
|
}
|
|
render()
|
|
ticker := time.NewTicker(spinnerInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
|
|
return
|
|
case <-ticker.C:
|
|
render()
|
|
}
|
|
}
|
|
}()
|
|
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
close(done)
|
|
<-finished // wait for the line to be cleared before returning
|
|
})
|
|
}
|
|
}
|