diff --git a/temporalcloudcli/async/poller.go b/temporalcloudcli/async/poller.go index d73f629..f2e71ec 100644 --- a/temporalcloudcli/async/poller.go +++ b/temporalcloudcli/async/poller.go @@ -156,6 +156,8 @@ func (p *poller) waitForAsyncOperation( ) (*operation.AsyncOperation, error) { ticker := time.NewTicker(p.pollInterval) defer ticker.Stop() + status := p.printer.NewStatusLine() + defer status.Clear() for { select { case <-ctx.Done(): @@ -182,10 +184,10 @@ func (p *poller) waitForAsyncOperation( ) switch asyncOp.State { case operation.AsyncOperation_STATE_PENDING: - outString = "Operation pending..." + outString = "Operation pending" continuePolling = true case operation.AsyncOperation_STATE_IN_PROGRESS: - outString = "Operation in progress..." + outString = "Operation in progress" continuePolling = true case operation.AsyncOperation_STATE_FULFILLED: outString = "Operation completed successfully" @@ -200,16 +202,19 @@ func (p *poller) waitForAsyncOperation( outErr = true default: // This should never happen, but if we get an unknown state, print it and continue polling - outString = fmt.Sprintf("Unknown operation state: %s, trying again...", asyncOp.State.String()) + outString = fmt.Sprintf("Unknown operation state: %s, trying again", asyncOp.State.String()) continuePolling = true } - p.printer.Print(fmt.Sprintf("[%s] %s\n", time.Now().Format("15:04:05"), outString)) - if !continuePolling { - if outErr { - return nil, errors.New(outString) - } - return asyncOp, nil + line := fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), outString) + if continuePolling { + status.Progress(line) + continue } + status.Done(line) + if outErr { + return nil, errors.New(outString) + } + return asyncOp, nil } } } diff --git a/temporalcloudcli/common.go b/temporalcloudcli/common.go index d98109d..84f3f5f 100644 --- a/temporalcloudcli/common.go +++ b/temporalcloudcli/common.go @@ -323,6 +323,10 @@ func (p *AsyncOperationPoller) PollAsyncOperation( ) error { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() + // Covers the early returns. Terminal states clear explicitly, because a deferred Clear would + // run only after their result has already printed on the status line. + status := cctx.Printer.NewStatusLine() + defer status.Clear() for { select { @@ -342,21 +346,19 @@ func (p *AsyncOperationPoller) PollAsyncOperation( return fmt.Errorf("async operation not found") } - // Print current state - var progressString string switch asyncOp.State { case operation.AsyncOperation_STATE_PENDING: - progressString = fmt.Sprintf("[%s] Operation pending...\n", time.Now().Format("15:04:05")) + status.Progress(fmt.Sprintf("[%s] Operation pending", time.Now().Format("15:04:05"))) case operation.AsyncOperation_STATE_IN_PROGRESS: - progressString = fmt.Sprintf("[%s] Operation in progress...\n", time.Now().Format("15:04:05")) + status.Progress(fmt.Sprintf("[%s] Operation in progress", time.Now().Format("15:04:05"))) case operation.AsyncOperation_STATE_FULFILLED: - progressString = fmt.Sprintf("[%s] Operation completed successfully\n", time.Now().Format("15:04:05")) + status.Clear() return cctx.Printer.PrintStructured(MutationResult{ ID: id, AsyncOp: asyncOp, }, printer.StructuredOptions{}) case operation.AsyncOperation_STATE_FAILED: - progressString = fmt.Sprintf("[%s] Operation failed: %s\n", time.Now().Format("15:04:05"), asyncOp.FailureReason) + status.Clear() // Print the structured output first, then return error for proper exit code if err := cctx.Printer.PrintStructured(MutationResult{ ID: id, @@ -366,7 +368,7 @@ func (p *AsyncOperationPoller) PollAsyncOperation( } return fmt.Errorf("async operation failed: %s", asyncOp.FailureReason) case operation.AsyncOperation_STATE_CANCELLED: - progressString = fmt.Sprintf("[%s] Operation cancelled\n", time.Now().Format("15:04:05")) + status.Clear() // Print the structured output first, then return error for proper exit code if err := cctx.Printer.PrintStructured(MutationResult{ ID: id, @@ -376,7 +378,7 @@ func (p *AsyncOperationPoller) PollAsyncOperation( } return fmt.Errorf("async operation cancelled") case operation.AsyncOperation_STATE_REJECTED: - progressString = fmt.Sprintf("[%s] Operation rejected\n", time.Now().Format("15:04:05")) + status.Clear() // Print the structured output first, then return error for proper exit code if err := cctx.Printer.PrintStructured(MutationResult{ ID: id, @@ -386,10 +388,7 @@ func (p *AsyncOperationPoller) PollAsyncOperation( } return fmt.Errorf("async operation rejected") default: - progressString = fmt.Sprintf("[%s] Operation pending...\n", time.Now().Format("15:04:05")) - } - if !cctx.JSONOutput { - cctx.Printer.Print(progressString) + status.Progress(fmt.Sprintf("[%s] Operation pending", time.Now().Format("15:04:05"))) } } } diff --git a/temporalcloudcli/internal/printer/status_line.go b/temporalcloudcli/internal/printer/status_line.go new file mode 100644 index 0000000..cb58246 --- /dev/null +++ b/temporalcloudcli/internal/printer/status_line.go @@ -0,0 +1,61 @@ +package printer + +import ( + "io" + "os" + + "golang.org/x/term" +) + +// clearLine erases the current terminal line and returns the cursor to its start. +const clearLine = "\033[2K\r" + +var ellipsisFrames = []string{"", ".", "..", "..."} + +// StatusLine reports the progress of a long-running operation. On a terminal it rewrites one line +// in place with an animated ellipsis. Anywhere else it writes one line per update, so piped and +// captured output is unaffected. Like Print, it writes nothing in JSON mode. +type StatusLine struct { + printer *Printer + terminal bool + frame int + open bool +} + +// NewStatusLine returns a StatusLine that writes through p. +func (p *Printer) NewStatusLine() *StatusLine { + return &StatusLine{printer: p, terminal: isTerminal(p.Output)} +} + +// Progress reports an intermediate state. msg must not end in an ellipsis; one is added. +func (s *StatusLine) Progress(msg string) { + if !s.terminal { + s.printer.Print(msg + "...\n") + return + } + s.printer.Print(clearLine + msg + ellipsisFrames[s.frame]) + s.frame = (s.frame + 1) % len(ellipsisFrames) + s.open = true +} + +// Done reports the final state and leaves it on its own line. +func (s *StatusLine) Done(msg string) { + if s.open { + msg = clearLine + msg + } + s.printer.Print(msg + "\n") + s.open = false +} + +// Clear erases an in-progress line so that whatever prints next starts on a clean line. +func (s *StatusLine) Clear() { + if s.open { + s.printer.Print(clearLine) + s.open = false + } +} + +func isTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + return ok && term.IsTerminal(int(f.Fd())) +} diff --git a/temporalcloudcli/internal/printer/status_line_test.go b/temporalcloudcli/internal/printer/status_line_test.go new file mode 100644 index 0000000..46ec301 --- /dev/null +++ b/temporalcloudcli/internal/printer/status_line_test.go @@ -0,0 +1,75 @@ +package printer + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestStatusLine_NotTerminal(t *testing.T) { + var buf bytes.Buffer + s := (&Printer{Output: &buf}).NewStatusLine() + + s.Progress("[10:00:00] Operation pending") + s.Progress("[10:00:01] Operation in progress") + s.Done("[10:00:02] Operation completed successfully") + s.Clear() + + require.Equal(t, + "[10:00:00] Operation pending...\n"+ + "[10:00:01] Operation in progress...\n"+ + "[10:00:02] Operation completed successfully\n", + buf.String()) +} + +func TestStatusLine_TerminalRewritesOneLine(t *testing.T) { + var buf bytes.Buffer + s := &StatusLine{printer: &Printer{Output: &buf}, terminal: true} + + for range 5 { + s.Progress("working") + } + s.Done("finished") + + require.Equal(t, + clearLine+"working"+ + clearLine+"working."+ + clearLine+"working.."+ + clearLine+"working..."+ + clearLine+"working"+ + clearLine+"finished\n", + buf.String()) +} + +func TestStatusLine_TerminalDoneWithoutProgress(t *testing.T) { + var buf bytes.Buffer + s := &StatusLine{printer: &Printer{Output: &buf}, terminal: true} + + s.Done("finished") + + require.Equal(t, "finished\n", buf.String()) +} + +func TestStatusLine_TerminalClearOnlyErasesAnOpenLine(t *testing.T) { + var buf bytes.Buffer + s := &StatusLine{printer: &Printer{Output: &buf}, terminal: true} + + s.Clear() + s.Progress("working") + s.Clear() + s.Clear() + + require.Equal(t, clearLine+"working"+clearLine, buf.String()) +} + +func TestStatusLine_JSONWritesNothing(t *testing.T) { + var buf bytes.Buffer + s := &StatusLine{printer: &Printer{Output: &buf, JSON: true}, terminal: true} + + s.Progress("working") + s.Done("finished") + s.Clear() + + require.Empty(t, buf.String()) +}