diff --git a/commands/charm/list/list.go b/commands/charm/list/list.go index 40fbd8f7d..4c3ff02f1 100644 --- a/commands/charm/list/list.go +++ b/commands/charm/list/list.go @@ -131,7 +131,7 @@ func (l *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return l, cmd } -// Update implements bubbletea.Model. +// View implements bubbletea.Model. func (l *listModel) View() string { return l.style.Lipgloss().Render(l.model.View()) } diff --git a/commands/charm/spinner/spinner.go b/commands/charm/spinner/spinner.go new file mode 100644 index 000000000..42679b5f4 --- /dev/null +++ b/commands/charm/spinner/spinner.go @@ -0,0 +1,95 @@ +package spinner + +import ( + "fmt" + "os" + + s "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +type SpinningLoader struct { + model s.Model + prog *tea.Program + cancel bool + message string +} + +type Option func(*SpinningLoader) + +// New creates a new spinning loader. +func New(message string, opts ...Option) SpinningLoader { + sm := s.New() + sm.Spinner = s.Dot + + l := SpinningLoader{ + model: sm, + message: message, + } + + for _, opt := range opts { + opt(&l) + } + return l +} + +// New creates a new spinner. +func (sl *SpinningLoader) Start() error { + p := tea.NewProgram((*SpinningLoader)(sl)) + sl.prog = p + + if err := p.Start(); err != nil { + return err + } + + if sl.cancel { + os.Exit(1) + } + return nil +} + +func (sl *SpinningLoader) Stop() { + if sl.prog != nil { + sl.prog.Kill() + } +} + +// Init implements bubbletea.Model. +func (sl *SpinningLoader) Init() tea.Cmd { + return sl.model.Tick +} + +// Update implements bubbletea.Model. +func (sl *SpinningLoader) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch keypress := msg.String(); keypress { + case tea.KeyCtrlC.String(): + sl.cancel = true + return sl, tea.Quit + } + + case s.TickMsg: + var cmd tea.Cmd + sl.model, cmd = sl.model.Update(msg) + return sl, cmd + } + + return sl, nil +} + +// View implements bubbletea.Model. +func (sl *SpinningLoader) View() string { + return fmt.Sprintf("%s %s", sl.model.View(), sl.message) +} + +// Model returns the underlying SpinningLoader.model +func (sl SpinningLoader) Model() *s.Model { + return &sl.model +} + +func WithSpinner(s s.Spinner) Option { + return func(sl *SpinningLoader) { + sl.model.Spinner = s + } +} diff --git a/commands/doit.go b/commands/doit.go index cfcdce787..36a742124 100644 --- a/commands/doit.go +++ b/commands/doit.go @@ -307,5 +307,8 @@ func cmdNS(cmd *cobra.Command) string { } func isTerminal(f *os.File) bool { + if os.Getenv("TERM") == "dumb" { + return false + } return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } diff --git a/commands/namespaces.go b/commands/namespaces.go index a496e7796..41b2a8977 100644 --- a/commands/namespaces.go +++ b/commands/namespaces.go @@ -20,6 +20,8 @@ import ( "strings" "github.com/digitalocean/doctl" + + "github.com/digitalocean/doctl/commands/charm/spinner" "github.com/digitalocean/doctl/commands/displayers" "github.com/digitalocean/doctl/do" "github.com/spf13/cobra" @@ -226,14 +228,27 @@ func getValidRegion(value string) string { // get the Namespaces that match a pattern, where the "pattern" has no wildcards but can be a // prefix, infix, or suffix match to a namespace ID or label. func getMatchingNamespaces(ctx context.Context, ss do.ServerlessService, pattern string) ([]do.OutputNamespace, error) { + var loader spinner.SpinningLoader + if Interactive { + loader = spinner.New("Loading namespaces ...") + go loader.Start() + } + ans := []do.OutputNamespace{} list, err := ss.ListNamespaces(ctx) + + if Interactive { + loader.Stop() + } + if err != nil { return ans, err } + if pattern == "" { return list.Namespaces, nil } + for _, ns := range list.Namespaces { if strings.Contains(ns.Namespace, pattern) || strings.Contains(ns.Label, pattern) { ans = append(ans, ns) diff --git a/commands/serverless.go b/commands/serverless.go index 866a0b2a6..6e25cea79 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -14,16 +14,15 @@ limitations under the License. package commands import ( - "bufio" "context" "errors" "fmt" "io" "os" - "strconv" "strings" "github.com/digitalocean/doctl" + "github.com/digitalocean/doctl/commands/charm/list" "github.com/digitalocean/doctl/commands/charm/template" "github.com/digitalocean/doctl/do" "github.com/spf13/cobra" @@ -264,49 +263,40 @@ func RunServerlessConnect(c *CmdConfig) error { // connectFromList connects a namespace based on a non-empty list of namespaces. If the list is // singular that determines the namespace that will be connected. Otherwise, this is determined // via a prompt. -func connectFromList(ctx context.Context, sls do.ServerlessService, list []do.OutputNamespace, out io.Writer) error { - var ns do.OutputNamespace - if len(list) == 1 { - ns = list[0] - } else { - ns = chooseFromList(list, out) - if ns.Namespace == "" { - return nil +func connectFromList(ctx context.Context, sls do.ServerlessService, l []do.OutputNamespace, out io.Writer) error { + if len(l) == 1 { + creds, err := sls.GetNamespace(ctx, l[0].Namespace) + if err != nil { + return err } + return finishConnecting(sls, creds, l[0].Label, out) } - creds, err := sls.GetNamespace(ctx, ns.Namespace) - if err != nil { - return err + + if !Interactive { + return errors.New("Namespace is required when running non-interactively") } - return finishConnecting(sls, creds, ns.Label, out) -} -// connectChoiceReader is the bufio.Reader for reading the user's response to the prompt to choose -// a namespace. It can be replaced for testing. -var connectChoiceReader *bufio.Reader = bufio.NewReader(os.Stdin) + var nsItems []list.Item -// chooseFromList displays a list of namespaces (label, region, id) assigning each one a number. -// The user can than respond to a prompt that chooses from the list by number. The response 'x' is -// also accepted and exits the command. -func chooseFromList(list []do.OutputNamespace, out io.Writer) do.OutputNamespace { - for i, ns := range list { - fmt.Fprintf(out, "%d: %s in %s, label=%s\n", i, ns.Namespace, ns.Region, ns.Label) + for _, ns := range l { + nsItems = append(nsItems, nsListItem{ns: ns}) } - for { - fmt.Fprintln(out, "Choose a namespace by number or 'x' to exit") - choice, err := connectChoiceReader.ReadString('\n') - if err != nil { - continue - } - choice = strings.TrimSpace(choice) - if choice == "x" { - return do.OutputNamespace{} - } - i, err := strconv.Atoi(choice) - if err == nil && i >= 0 && i < len(list) { - return list[i] - } + + listItems := list.New(nsItems) + listItems.Model().Title = "select a namespace" + listItems.Model().SetStatusBarItemName("namespace", "namespaces") + + selected, err := listItems.Select() + if err != nil { + return err + } + + selectedNs := selected.(nsListItem).ns + creds, err := sls.GetNamespace(ctx, selectedNs.Namespace) + if err != nil { + return err } + return finishConnecting(sls, creds, selectedNs.Label, out) } // finishConnecting performs the final steps of 'doctl serverless connect'. diff --git a/commands/serverless_charm.go b/commands/serverless_charm.go new file mode 100644 index 000000000..c86f11638 --- /dev/null +++ b/commands/serverless_charm.go @@ -0,0 +1,21 @@ +package commands + +import ( + "github.com/digitalocean/doctl/do" +) + +type nsListItem struct { + ns do.OutputNamespace +} + +func (i nsListItem) Title() string { + return i.ns.Label + " (" + i.ns.Region + ")" +} + +func (i nsListItem) Description() string { + return i.ns.Namespace +} + +func (i nsListItem) FilterValue() string { + return i.ns.Label +} diff --git a/commands/serverless_test.go b/commands/serverless_test.go index 6ba0fdd34..6fea1c3df 100644 --- a/commands/serverless_test.go +++ b/commands/serverless_test.go @@ -14,7 +14,6 @@ limitations under the License. package commands import ( - "bufio" "bytes" "context" "errors" @@ -68,7 +67,7 @@ func TestServerlessConnect(t *testing.T) { Label: "another", }, }, - expectedOutput: "0: ns1 in nyc1, label=something\n1: ns2 in lon1, label=another\nChoose a namespace by number or 'x' to exit\nConnected to functions namespace 'ns1' on API host 'https://api.example.com' (label=something)\n\n", + expectedError: errors.New("Namespace is required when running non-interactively"), }, { name: "use argument", @@ -96,7 +95,6 @@ func TestServerlessConnect(t *testing.T) { if tt.doctlArg != "" { config.Args = append(config.Args, tt.doctlArg) } - connectChoiceReader = bufio.NewReader(strings.NewReader("0\n")) nsResponse := do.NamespaceListResponse{Namespaces: tt.namespaceList} creds := do.ServerlessCredentials{Namespace: "ns1", APIHost: "https://api.example.com"}