Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions temporalcloudcli/async/poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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"
Expand All @@ -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
}
}
}
Expand Down
23 changes: 11 additions & 12 deletions temporalcloudcli/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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")))
}
}
}
Expand Down
61 changes: 61 additions & 0 deletions temporalcloudcli/internal/printer/status_line.go
Original file line number Diff line number Diff line change
@@ -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()))
}
75 changes: 75 additions & 0 deletions temporalcloudcli/internal/printer/status_line_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading