From 824724b82221be6ae35bf14de18bf3bacc56d22d Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 6 Aug 2025 15:18:26 +0200 Subject: [PATCH 1/4] feat: implement rich multi-selector --- cli/cliui/parameter.go | 11 +-- cli/cliui/select.go | 52 +++++++++++++ cli/cliui/select_test.go | 157 +++++++++++++++++++++++++-------------- 3 files changed, 161 insertions(+), 59 deletions(-) diff --git a/cli/cliui/parameter.go b/cli/cliui/parameter.go index 2e639f8dfa425..d972e346bf196 100644 --- a/cli/cliui/parameter.go +++ b/cli/cliui/parameter.go @@ -38,15 +38,16 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te // Move the cursor up a single line for nicer display! _, _ = fmt.Fprint(inv.Stdout, "\033[1A") - var options []string - err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &options) + var defaults []string + err = json.Unmarshal([]byte(templateVersionParameter.DefaultValue), &defaults) if err != nil { return "", err } - values, err := MultiSelect(inv, MultiSelectOptions{ - Options: options, - Defaults: options, + values, err := RichMultiSelect(inv, RichMultiSelectOptions{ + Options: templateVersionParameter.Options, + Defaults: defaults, + EnableCustomInput: templateVersionParameter.FormType == "tag-select", }) if err == nil { v, err := json.Marshal(&values) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 40f63d92e279d..1375fbb00410c 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "slices" "strings" "syscall" @@ -299,6 +300,57 @@ func (m selectModel) filteredOptions() []string { return options } +type RichMultiSelectOptions struct { + Message string + Options []codersdk.TemplateVersionParameterOption + Defaults []string + EnableCustomInput bool +} + +func RichMultiSelect(inv *serpent.Invocation, richOptions RichMultiSelectOptions) ([]string, error) { + opts := make([]string, len(richOptions.Options)) + var defaultOpts []string + + for i, option := range richOptions.Options { + line := option.Name + if len(option.Description) > 0 { + line += ": " + option.Description + } + opts[i] = line + + if slices.Contains(richOptions.Defaults, option.Value) { + defaultOpts = append(defaultOpts, line) + } + } + + selected, err := MultiSelect(inv, MultiSelectOptions{ + Message: richOptions.Message, + Options: opts, + Defaults: defaultOpts, + EnableCustomInput: richOptions.EnableCustomInput, + }) + if err != nil { + return nil, err + } + + var results []string + for _, sel := range selected { + custom := true + for i, option := range opts { + if option == sel { + results = append(results, richOptions.Options[i].Value) + custom = false + break + } + } + + if custom { + results = append(results, sel) + } + } + return results, nil +} + type MultiSelectOptions struct { Message string Options []string diff --git a/cli/cliui/select_test.go b/cli/cliui/select_test.go index c7630ac4f2460..21fc4cb03c398 100644 --- a/cli/cliui/select_test.go +++ b/cli/cliui/select_test.go @@ -52,15 +52,8 @@ func TestRichSelect(t *testing.T) { go func() { resp, err := newRichSelect(ptty, cliui.RichSelectOptions{ Options: []codersdk.TemplateVersionParameterOption{ - { - Name: "A-Name", - Value: "A-Value", - Description: "A-Description.", - }, { - Name: "B-Name", - Value: "B-Value", - Description: "B-Description.", - }, + {Name: "A-Name", Value: "A-Value", Description: "A-Description."}, + {Name: "B-Name", Value: "B-Value", Description: "B-Description."}, }, }) assert.NoError(t, err) @@ -86,63 +79,119 @@ func newRichSelect(ptty *ptytest.PTY, opts cliui.RichSelectOptions) (string, err return value, inv.Run() } -func TestMultiSelect(t *testing.T) { +func TestRichMultiSelect(t *testing.T) { t.Parallel() - t.Run("MultiSelect", func(t *testing.T) { - items := []string{"aaa", "bbb", "ccc"} - t.Parallel() - ptty := ptytest.New(t) - msgChan := make(chan []string) - go func() { - resp, err := newMultiSelect(ptty, items) - assert.NoError(t, err) - msgChan <- resp - }() - require.Equal(t, items, <-msgChan) - }) + tests := []struct { + name string + options []codersdk.TemplateVersionParameterOption + defaults []string + allowCustom bool + want []string + }{ + { + name: "Predefined", + options: []codersdk.TemplateVersionParameterOption{ + {Name: "AAA", Description: "This is AAA", Value: "aaa"}, + {Name: "BBB", Description: "This is BBB", Value: "bbb"}, + {Name: "CCC", Description: "This is CCC", Value: "ccc"}, + }, + defaults: []string{"bbb", "ccc"}, + allowCustom: false, + want: []string{"bbb", "ccc"}, + }, + { + name: "Custom", + options: []codersdk.TemplateVersionParameterOption{ + {Name: "AAA", Description: "This is AAA", Value: "aaa"}, + {Name: "BBB", Description: "This is BBB", Value: "bbb"}, + {Name: "CCC", Description: "This is CCC", Value: "ccc"}, + }, + defaults: []string{"aaa", "bbb"}, + allowCustom: true, + want: []string{"aaa", "bbb"}, + }, + } - t.Run("MultiSelectWithCustomInput", func(t *testing.T) { - t.Parallel() - items := []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"} - ptty := ptytest.New(t) - msgChan := make(chan []string) - go func() { - resp, err := newMultiSelectWithCustomInput(ptty, items) - assert.NoError(t, err) - msgChan <- resp - }() - require.Equal(t, items, <-msgChan) - }) -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() -func newMultiSelectWithCustomInput(ptty *ptytest.PTY, items []string) ([]string, error) { - var values []string - cmd := &serpent.Command{ - Handler: func(inv *serpent.Invocation) error { - selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ - Options: items, - Defaults: items, - EnableCustomInput: true, - }) - if err == nil { - values = selectedItems + var selectedItems []string + var err error + cmd := &serpent.Command{ + Handler: func(inv *serpent.Invocation) error { + selectedItems, err = cliui.RichMultiSelect(inv, cliui.RichMultiSelectOptions{ + Options: tt.options, + Defaults: tt.defaults, + EnableCustomInput: tt.allowCustom, + }) + return err + }, } - return err + + doneChan := make(chan struct{}) + go func() { + defer close(doneChan) + err := cmd.Invoke().Run() + assert.NoError(t, err) + }() + <-doneChan + + require.Equal(t, tt.want, selectedItems) + }) + } +} + +func TestMultiSelect(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + items []string + allowCustom bool + want []string + }{ + { + name: "MultiSelect", + items: []string{"aaa", "bbb", "ccc"}, + allowCustom: false, + want: []string{"aaa", "bbb", "ccc"}, + }, + { + name: "MultiSelectWithCustomInput", + items: []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"}, + allowCustom: true, + want: []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"}, }, } - inv := cmd.Invoke() - ptty.Attach(inv) - return values, inv.Run() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ptty := ptytest.New(t) + msgChan := make(chan []string) + + go func() { + resp, err := newMultiSelect(ptty, tt.items, tt.allowCustom) + assert.NoError(t, err) + msgChan <- resp + }() + + require.Equal(t, tt.want, <-msgChan) + }) + } } -func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { +func newMultiSelect(pty *ptytest.PTY, items []string, custom bool) ([]string, error) { var values []string cmd := &serpent.Command{ Handler: func(inv *serpent.Invocation) error { selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ - Options: items, - Defaults: items, + Options: items, + Defaults: items, + EnableCustomInput: custom, }) if err == nil { values = selectedItems @@ -151,6 +200,6 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { }, } inv := cmd.Invoke() - ptty.Attach(inv) + pty.Attach(inv) return values, inv.Run() } From e71c7f389da6e1695c461b871e96886a586b10df Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 6 Aug 2025 18:11:57 +0200 Subject: [PATCH 2/4] Fixes --- cli/cliui/select.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 1375fbb00410c..c5bf881b4d07e 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -311,15 +311,30 @@ func RichMultiSelect(inv *serpent.Invocation, richOptions RichMultiSelectOptions opts := make([]string, len(richOptions.Options)) var defaultOpts []string - for i, option := range richOptions.Options { + asLine := func(option codersdk.TemplateVersionParameterOption) string { line := option.Name if len(option.Description) > 0 { line += ": " + option.Description } - opts[i] = line + return line + } + + var predefinedOpts []string + for i, option := range richOptions.Options { + opts[i] = asLine(option) // Some options may have description defined. + // Check if option is selected by default if slices.Contains(richOptions.Defaults, option.Value) { - defaultOpts = append(defaultOpts, line) + defaultOpts = append(defaultOpts, opts[i]) + predefinedOpts = append(predefinedOpts, option.Value) + } + } + + // Check if "defaults" contains extra/custom options, user could select them. + for _, def := range richOptions.Defaults { + if !slices.Contains(predefinedOpts, def) { + opts = append(opts, def) + defaultOpts = append(defaultOpts, def) } } @@ -333,11 +348,12 @@ func RichMultiSelect(inv *serpent.Invocation, richOptions RichMultiSelectOptions return nil, err } + // Check selected option, convert descriptions (line) to values var results []string for _, sel := range selected { custom := true - for i, option := range opts { - if option == sel { + for i, option := range richOptions.Options { + if asLine(option) == sel { results = append(results, richOptions.Options[i].Value) custom = false break From 17abc4491c8d6afb723691afa3527f103686877f Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 6 Aug 2025 18:31:26 +0200 Subject: [PATCH 3/4] Fix --- cli/cliui/select.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index c5bf881b4d07e..b3222cbbf3a71 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -308,7 +308,7 @@ type RichMultiSelectOptions struct { } func RichMultiSelect(inv *serpent.Invocation, richOptions RichMultiSelectOptions) ([]string, error) { - opts := make([]string, len(richOptions.Options)) + var opts []string var defaultOpts []string asLine := func(option codersdk.TemplateVersionParameterOption) string { @@ -321,7 +321,7 @@ func RichMultiSelect(inv *serpent.Invocation, richOptions RichMultiSelectOptions var predefinedOpts []string for i, option := range richOptions.Options { - opts[i] = asLine(option) // Some options may have description defined. + opts = append(opts, asLine(option)) // Some options may have description defined. // Check if option is selected by default if slices.Contains(richOptions.Defaults, option.Value) { From 90d689988e76d6a6ca97ee0e9a651e79d63c2b04 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 6 Aug 2025 20:28:52 +0200 Subject: [PATCH 4/4] exp --- cli/exp_prompts.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cli/exp_prompts.go b/cli/exp_prompts.go index 225685a0c375a..ef51a1ce04398 100644 --- a/cli/exp_prompts.go +++ b/cli/exp_prompts.go @@ -174,6 +174,20 @@ func (RootCmd) promptExample() *serpent.Command { _, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", ")) return multiSelectError }, useThingsOption, enableCustomInputOption), + promptCmd("rich-multi-select", func(inv *serpent.Invocation) error { + if len(multiSelectValues) == 0 { + multiSelectValues, multiSelectError = cliui.MultiSelect(inv, cliui.MultiSelectOptions{ + Message: "Select some things:", + Options: []string{ + "Apples", "Plums", "Grapes", "Oranges", "Bananas", + }, + Defaults: []string{"Grapes", "Plums"}, + EnableCustomInput: enableCustomInput, + }) + } + _, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", ")) + return multiSelectError + }, useThingsOption, enableCustomInputOption), promptCmd("rich-parameter", func(inv *serpent.Invocation) error { value, err := cliui.RichSelect(inv, cliui.RichSelectOptions{ Options: []codersdk.TemplateVersionParameterOption{ pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy