mirror of https://github.com/databricks/cli.git
290 lines
6.7 KiB
Go
290 lines
6.7 KiB
Go
package cmdio
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/briandowns/spinner"
|
|
"github.com/databricks/cli/libs/flags"
|
|
"github.com/manifoldco/promptui"
|
|
"github.com/mattn/go-isatty"
|
|
)
|
|
|
|
// cmdIO is the private instance, that is not supposed to be accessed
|
|
// outside of `cmdio` package. Use the public package-level functions
|
|
// to access the inner state.
|
|
type cmdIO struct {
|
|
// states if we are in the interactive mode
|
|
// e.g. if stdout is a terminal
|
|
interactive bool
|
|
outputFormat flags.Output
|
|
template string
|
|
in io.Reader
|
|
out io.Writer
|
|
err io.Writer
|
|
}
|
|
|
|
func NewIO(outputFormat flags.Output, in io.Reader, out io.Writer, err io.Writer, template string) *cmdIO {
|
|
// The check below is similar to color.NoColor but uses the specified err writer.
|
|
dumb := os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb"
|
|
if f, ok := err.(*os.File); ok && !dumb {
|
|
dumb = !isatty.IsTerminal(f.Fd()) && !isatty.IsCygwinTerminal(f.Fd())
|
|
}
|
|
return &cmdIO{
|
|
interactive: !dumb,
|
|
outputFormat: outputFormat,
|
|
template: template,
|
|
in: in,
|
|
out: out,
|
|
err: err,
|
|
}
|
|
}
|
|
|
|
func IsInteractive(ctx context.Context) bool {
|
|
c := fromContext(ctx)
|
|
return c.interactive
|
|
}
|
|
|
|
// IsTTY detects if io.Writer is a terminal.
|
|
func IsTTY(w any) bool {
|
|
f, ok := w.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
fd := f.Fd()
|
|
return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)
|
|
}
|
|
|
|
// IsInTTY detects if the input reader is a terminal.
|
|
func IsInTTY(ctx context.Context) bool {
|
|
c := fromContext(ctx)
|
|
return IsTTY(c.in)
|
|
}
|
|
|
|
// IsOutTTY detects if the output writer is a terminal.
|
|
func IsOutTTY(ctx context.Context) bool {
|
|
c := fromContext(ctx)
|
|
return IsTTY(c.out)
|
|
}
|
|
|
|
// IsErrTTY detects if the error writer is a terminal.
|
|
func IsErrTTY(ctx context.Context) bool {
|
|
c := fromContext(ctx)
|
|
return IsTTY(c.err)
|
|
}
|
|
|
|
// IsTTY detects if stdout is a terminal. It assumes that stderr is terminal as well
|
|
func (c *cmdIO) IsTTY() bool {
|
|
f, ok := c.out.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
fd := f.Fd()
|
|
return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)
|
|
}
|
|
|
|
func Render(ctx context.Context, v any) error {
|
|
c := fromContext(ctx)
|
|
return RenderWithTemplate(ctx, v, c.template)
|
|
}
|
|
|
|
func RenderWithTemplate(ctx context.Context, v any, template string) error {
|
|
// TODO: add terminal width & white/dark theme detection
|
|
c := fromContext(ctx)
|
|
switch c.outputFormat {
|
|
case flags.OutputJSON:
|
|
return renderJson(c.out, v)
|
|
case flags.OutputText:
|
|
if template != "" {
|
|
return renderTemplate(c.out, template, v)
|
|
}
|
|
return renderJson(c.out, v)
|
|
default:
|
|
return fmt.Errorf("invalid output format: %s", c.outputFormat)
|
|
}
|
|
}
|
|
|
|
func RenderJson(ctx context.Context, v any) error {
|
|
c := fromContext(ctx)
|
|
if c.outputFormat == flags.OutputJSON {
|
|
return renderJson(c.out, v)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func RenderReader(ctx context.Context, r io.Reader) error {
|
|
c := fromContext(ctx)
|
|
switch c.outputFormat {
|
|
case flags.OutputJSON:
|
|
return fmt.Errorf("json output not supported")
|
|
case flags.OutputText:
|
|
_, err := io.Copy(c.out, r)
|
|
return err
|
|
default:
|
|
return fmt.Errorf("invalid output format: %s", c.outputFormat)
|
|
}
|
|
}
|
|
|
|
type tuple struct{ Name, Id string }
|
|
|
|
func (c *cmdIO) Select(names map[string]string, label string) (id string, err error) {
|
|
if !c.interactive {
|
|
return "", fmt.Errorf("expected to have %s", label)
|
|
}
|
|
var items []tuple
|
|
for k, v := range names {
|
|
items = append(items, tuple{k, v})
|
|
}
|
|
slices.SortFunc(items, func(a, b tuple) int {
|
|
return strings.Compare(a.Name, b.Name)
|
|
})
|
|
idx, _, err := (&promptui.Select{
|
|
Label: label,
|
|
Items: items,
|
|
HideSelected: true,
|
|
StartInSearchMode: true,
|
|
Searcher: func(input string, idx int) bool {
|
|
lower := strings.ToLower(items[idx].Name)
|
|
return strings.Contains(lower, input)
|
|
},
|
|
Templates: &promptui.SelectTemplates{
|
|
Active: `{{.Name | bold}} ({{.Id|faint}})`,
|
|
Inactive: `{{.Name}}`,
|
|
},
|
|
Stdin: io.NopCloser(c.in),
|
|
}).Run()
|
|
if err != nil {
|
|
return
|
|
}
|
|
id = items[idx].Id
|
|
return
|
|
}
|
|
|
|
func Select[V any](ctx context.Context, names map[string]V, label string) (id string, err error) {
|
|
c := fromContext(ctx)
|
|
stringNames := map[string]string{}
|
|
for k, v := range names {
|
|
stringNames[k] = fmt.Sprint(v)
|
|
}
|
|
return c.Select(stringNames, label)
|
|
}
|
|
|
|
func (c *cmdIO) Secret(label string) (value string, err error) {
|
|
prompt := (promptui.Prompt{
|
|
Label: label,
|
|
Mask: '*',
|
|
HideEntered: true,
|
|
})
|
|
|
|
return prompt.Run()
|
|
}
|
|
|
|
func Secret(ctx context.Context, label string) (value string, err error) {
|
|
c := fromContext(ctx)
|
|
return c.Secret(label)
|
|
}
|
|
|
|
type nopWriteCloser struct {
|
|
io.Writer
|
|
}
|
|
|
|
func (nopWriteCloser) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func Prompt(ctx context.Context) *promptui.Prompt {
|
|
c := fromContext(ctx)
|
|
return &promptui.Prompt{
|
|
Stdin: io.NopCloser(c.in),
|
|
Stdout: nopWriteCloser{c.out},
|
|
}
|
|
}
|
|
|
|
func (c *cmdIO) simplePrompt(label string) *promptui.Prompt {
|
|
return &promptui.Prompt{
|
|
Label: label,
|
|
Stdin: io.NopCloser(c.in),
|
|
Stdout: nopWriteCloser{c.out},
|
|
}
|
|
}
|
|
|
|
func (c *cmdIO) SimplePrompt(label string) (value string, err error) {
|
|
return c.simplePrompt(label).Run()
|
|
}
|
|
|
|
func SimplePrompt(ctx context.Context, label string) (value string, err error) {
|
|
c := fromContext(ctx)
|
|
return c.SimplePrompt(label)
|
|
}
|
|
|
|
func (c *cmdIO) DefaultPrompt(label, defaultValue string) (value string, err error) {
|
|
prompt := c.simplePrompt(label)
|
|
prompt.Default = defaultValue
|
|
prompt.AllowEdit = true
|
|
return prompt.Run()
|
|
}
|
|
|
|
func DefaultPrompt(ctx context.Context, label, defaultValue string) (value string, err error) {
|
|
c := fromContext(ctx)
|
|
return c.DefaultPrompt(label, defaultValue)
|
|
}
|
|
|
|
func (c *cmdIO) Spinner(ctx context.Context) chan string {
|
|
var sp *spinner.Spinner
|
|
if c.interactive {
|
|
charset := spinner.CharSets[11]
|
|
sp = spinner.New(charset, 200*time.Millisecond,
|
|
spinner.WithWriter(c.err),
|
|
spinner.WithColor("green"))
|
|
sp.Start()
|
|
}
|
|
updates := make(chan string)
|
|
go func() {
|
|
if c.interactive {
|
|
defer sp.Stop()
|
|
}
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case x, hasMore := <-updates:
|
|
if c.interactive {
|
|
// `sp`` access is isolated to this method,
|
|
// so it's safe to update it from this goroutine.
|
|
sp.Suffix = " " + x
|
|
}
|
|
if !hasMore {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return updates
|
|
}
|
|
|
|
func Spinner(ctx context.Context) chan string {
|
|
c := fromContext(ctx)
|
|
return c.Spinner(ctx)
|
|
}
|
|
|
|
type cmdIOType int
|
|
|
|
var cmdIOKey cmdIOType
|
|
|
|
func InContext(ctx context.Context, io *cmdIO) context.Context {
|
|
return context.WithValue(ctx, cmdIOKey, io)
|
|
}
|
|
|
|
func fromContext(ctx context.Context) *cmdIO {
|
|
io, ok := ctx.Value(cmdIOKey).(*cmdIO)
|
|
if !ok {
|
|
panic("no cmdIO found in the context. Please report it as an issue")
|
|
}
|
|
return io
|
|
}
|