From 3934cb69a5e53e9352682e0b6c396bab5e0972fa Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 29 Aug 2024 09:18:41 +0000 Subject: [PATCH 01/28] chore: replace survery library with bubbletea --- cli/cliui/select.go | 305 +++++++++++++++++++++++++++++++++++++------- cmd/cliui/main.go | 16 ++- go.mod | 15 ++- go.sum | 41 ++++-- 4 files changed, 313 insertions(+), 64 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 7d190b4bccf3c..04b554c7bfc00 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -1,13 +1,12 @@ package cliui import ( - "errors" "flag" - "io" - "os" + "fmt" + "strings" - "github.com/AlecAivazis/survey/v2" - "github.com/AlecAivazis/survey/v2/terminal" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" "golang.org/x/xerrors" "github.com/coder/coder/v2/codersdk" @@ -66,6 +65,7 @@ func RichSelect(inv *serpent.Invocation, richOptions RichSelectOptions) (*coders // Select displays a list of user options. func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { + // TODO: Check if this is still true for Bubbletea. // The survey library used *always* fails when testing on Windows, // as it requires a live TTY (can't be a conpty). We should fork // this library to add a dummy fallback, that simply reads/writes @@ -75,33 +75,154 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { return opts.Options[0], nil } - var defaultOption interface{} - if opts.Default != "" { - defaultOption = opts.Default + initialModel := selectModel{ + search: textinput.New(), + hideSearch: opts.HideSearch, + options: opts.Options, + height: opts.Size, } + if initialModel.height == 0 { + initialModel.height = 5 // TODO: Pick a default? + } + + initialModel.search.Prompt = "" + initialModel.search.Focus() + + m, err := tea.NewProgram( + initialModel, + tea.WithInput(inv.Stdin), + tea.WithOutput(inv.Stdout), + ).Run() + var value string - err := survey.AskOne(&survey.Select{ - Options: opts.Options, - Default: defaultOption, - PageSize: opts.Size, - Message: opts.Message, - }, &value, survey.WithIcons(func(is *survey.IconSet) { - is.Help.Text = "Type to search" - if opts.HideSearch { - is.Help.Text = "" + if m, ok := m.(selectModel); ok { + if m.canceled { + return value, Canceled } - }), survey.WithStdio(fileReadWriter{ - Reader: inv.Stdin, - }, fileReadWriter{ - Writer: inv.Stdout, - }, inv.Stdout)) - if errors.Is(err, terminal.InterruptErr) { - return value, Canceled + value = m.selected } return value, err } +type selectModel struct { + search textinput.Model + options []string + cursor int + height int + selected string + canceled bool + hideSearch bool +} + +func (selectModel) Init() tea.Cmd { + return textinput.Blink +} + +//nolint:revive The linter complains about modifying 'm' but this is typical practice for bubbletea +func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.Type { + case tea.KeyCtrlC: + m.canceled = true + return m, tea.Quit + + case tea.KeyEnter: + options := m.filteredOptions() + if len(options) != 0 { + m.selected = options[m.cursor] + return m, tea.Quit + } + + case tea.KeyUp: + if m.cursor > 0 { + m.cursor-- + } + + case tea.KeyDown: + options := m.filteredOptions() + if m.cursor < len(options)-1 { + m.cursor++ + } + } + } + + if !m.hideSearch { + oldSearch := m.search.Value() + m.search, cmd = m.search.Update(msg) + + // If the search query has changed then we need to ensure + // the cursor is still pointing at a valid option. + if m.search.Value() != oldSearch { + options := m.filteredOptions() + + if m.cursor > len(options)-1 { + m.cursor = max(0, len(options)-1) + } + } + } + + return m, cmd +} + +func (m selectModel) View() string { + var s string + + if m.hideSearch { + s += "? [Use arrows to move]\n" + } else { + s += fmt.Sprintf("? %s [Use arrows to move, type to filter]\n", m.search.View()) + } + + options, start := m.viewableOptions() + + for i, option := range options { + // Is this the currently selected option? + cursor := " " + if m.cursor == start+i { + cursor = ">" + } + + s += fmt.Sprintf("%s %s\n", cursor, option) + } + + return s +} + +func (m selectModel) viewableOptions() ([]string, int) { + options := m.filteredOptions() + halfHeight := m.height / 2 + bottom := 0 + top := len(options) + + switch { + case m.cursor <= halfHeight: + top = min(top, m.height) + case m.cursor < top-halfHeight: + bottom = m.cursor - halfHeight + top = min(top, m.cursor+halfHeight+1) + default: + bottom = top - m.height + } + + return options[bottom:top], bottom +} + +func (m selectModel) filteredOptions() []string { + options := []string{} + for _, o := range m.options { + prefix := strings.ToLower(m.search.Value()) + option := strings.ToLower(o) + + if strings.HasPrefix(option, prefix) { + options = append(options, o) + } + } + return options +} + type MultiSelectOptions struct { Message string Options []string @@ -114,35 +235,129 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er return opts.Defaults, nil } - prompt := &survey.MultiSelect{ - Options: opts.Options, - Default: opts.Defaults, - Message: opts.Message, + options := make([]multiSelectOption, len(opts.Options)) + for i, option := range opts.Options { + options[i].option = option + } + + initialModel := multiSelectModel{ + search: textinput.New(), + options: options, } - var values []string - err := survey.AskOne(prompt, &values, survey.WithStdio(fileReadWriter{ - Reader: inv.Stdin, - }, fileReadWriter{ - Writer: inv.Stdout, - }, inv.Stdout)) - if errors.Is(err, terminal.InterruptErr) { - return nil, Canceled + initialModel.search.Prompt = "" + initialModel.search.Focus() + + m, err := tea.NewProgram( + initialModel, + tea.WithInput(inv.Stdin), + tea.WithOutput(inv.Stdout), + ).Run() + + values := []string{} + if m, ok := m.(multiSelectModel); ok { + if m.canceled { + return values, Canceled + } + + for _, option := range m.options { + if option.chosen { + values = append(values, option.option) + } + } } return values, err } -type fileReadWriter struct { - io.Reader - io.Writer +type multiSelectOption struct { + option string + chosen bool } -func (f fileReadWriter) Fd() uintptr { - if file, ok := f.Reader.(*os.File); ok { - return file.Fd() +type multiSelectModel struct { + search textinput.Model + options []multiSelectOption + cursor int + canceled bool +} + +func (multiSelectModel) Init() tea.Cmd { + return nil +} + +//nolint:revive For same reason as previous Update definition +func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.Type { + case tea.KeyCtrlC: + m.canceled = true + return m, tea.Quit + + case tea.KeyEnter: + if len(m.options) != 0 { + return m, tea.Quit + } + + case tea.KeySpace: + if len(m.options) != 0 { + m.options[m.cursor].chosen = true + } + + case tea.KeyUp: + if m.cursor > 0 { + m.cursor-- + } + + case tea.KeyDown: + if m.cursor < len(m.options)-1 { + m.cursor++ + } + + case tea.KeyRight: + for i := range m.options { + m.options[i].chosen = true + } + + case tea.KeyLeft: + for i := range m.options { + m.options[i].chosen = false + } + + default: + oldSearch := m.search.Value() + m.search, cmd = m.search.Update(msg) + + // If the search query has changed then we need to ensure + // the cursor is still pointing at a valid option. + if m.search.Value() != oldSearch { + if m.cursor > len(m.options)-1 { + m.cursor = max(0, len(m.options)-1) + } + } + } } - if file, ok := f.Writer.(*os.File); ok { - return file.Fd() + + return m, cmd +} + +func (m multiSelectModel) View() string { + s := fmt.Sprintf("? %s [Use arrows to move, space to select, to all, to none, type to filter]\n", m.search.View()) + + for i, option := range m.options { + cursor := " " + if m.cursor == i { + cursor = ">" + } + + chosen := "[ ]" + if option.chosen { + chosen = "[x]" + } + + s += fmt.Sprintf("%s %s %s\n", cursor, chosen, option.option) } - return 0 + + return s } diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index 161414e4471e2..13ff7bcb683fc 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -79,14 +79,26 @@ func main() { Use: "select", Handler: func(inv *serpent.Invocation) error { value, err := cliui.Select(inv, cliui.SelectOptions{ - Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"}, - Size: 3, + Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"}, + Size: 3, + HideSearch: true, }) _, _ = fmt.Printf("Selected: %q\n", value) return err }, }) + root.Children = append(root.Children, &serpent.Command{ + Use: "multi-select", + Handler: func(inv *serpent.Invocation) error { + values, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ + Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"}, + }) + _, _ = fmt.Printf("Selected: %q\n", values) + return err + }, + }) + root.Children = append(root.Children, &serpent.Command{ Use: "job", Handler: func(inv *serpent.Invocation) error { diff --git a/go.mod b/go.mod index 9266baf6158c7..1d6b7ac28176c 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,6 @@ replace github.com/lib/pq => github.com/coder/pq v1.10.5-0.20240813183442-0c420c require ( cdr.dev/slog v1.6.2-0.20240126064726-20367d4aede6 cloud.google.com/go/compute/metadata v0.5.0 - github.com/AlecAivazis/survey/v2 v2.3.5 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/adrg/xdg v0.5.0 github.com/ammario/tlru v0.4.0 @@ -202,6 +201,8 @@ require go.uber.org/mock v0.4.0 require ( github.com/cespare/xxhash v1.1.0 + github.com/charmbracelet/bubbles v0.19.0 + github.com/charmbracelet/bubbletea v0.27.1 github.com/coder/serpent v0.8.0 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-smtp v0.21.2 @@ -217,19 +218,28 @@ require ( dario.cat/mergo v1.0.0 // indirect github.com/DataDog/go-libddwaf/v3 v3.3.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect github.com/charmbracelet/x/ansi v0.1.4 // indirect + github.com/charmbracelet/x/input v0.1.0 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.0 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/go-viper/mapstructure/v2 v2.0.0 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect ) @@ -279,7 +289,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect // In later at least v0.7.1, lipgloss changes its terminal detection // which breaks most of our CLI golden files tests. - github.com/charmbracelet/lipgloss v0.12.1 // indirect + github.com/charmbracelet/lipgloss v0.13.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect github.com/clbanning/mxj/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect @@ -356,7 +366,6 @@ require ( github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/sdnotify v1.0.0 // indirect github.com/mdlayher/socket v0.5.0 // indirect - github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/miekg/dns v1.1.57 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect diff --git a/go.sum b/go.sum index b15ace650b277..8077f5495b555 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,6 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= -github.com/AlecAivazis/survey/v2 v2.3.5 h1:A8cYupsAZkjaUmhtTYv3sSqc7LO5mp1XDfqe5E/9wRQ= -github.com/AlecAivazis/survey/v2 v2.3.5/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= @@ -47,8 +45,6 @@ github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6Xge github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= @@ -93,6 +89,8 @@ github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloD github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M= github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY= @@ -179,14 +177,24 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0= +github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA= +github.com/charmbracelet/bubbletea v0.27.1 h1:/yhaJKX52pxG4jZVKCNWj/oq0QouPdXycriDRA6m6r8= +github.com/charmbracelet/bubbletea v0.27.1/go.mod h1:xc4gm5yv+7tbniEvQ0naiG9P3fzYhk16cTgDZQQW6YE= github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= -github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= -github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4 h1:6KzMkQeAF56rggw2NZu1L+TH7j9+DM1/2Kmh7KUxg1I= -github.com/charmbracelet/x/exp/golden v0.0.0-20240715153702-9ba8adf781c4/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= +github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= +github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= @@ -245,7 +253,6 @@ github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDh github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/dave/dst v0.27.2 h1:4Y5VFTkhGLC1oddtNwuxxe36pnyLxMFXT51FOzH8Ekc= @@ -301,6 +308,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/evanw/esbuild v0.23.0 h1:PLUwTn2pzQfIBRrMKcD3M0g1ALOKIHMDefdFCk7avwM= github.com/evanw/esbuild v0.23.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -597,7 +606,6 @@ github.com/hdevalence/ed25519consensus v0.1.0 h1:jtBwzzcHuTmFrQN6xQZn6CQEO/V9f7H github.com/hdevalence/ed25519consensus v0.1.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 h1:AgcIVYPa6XJnU3phs104wLj8l5GEththEw6+F79YsIY= github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/hugelgupf/vmtest v0.0.0-20240216064925-0561770280a1 h1:jWoR2Yqg8tzM0v6LAiP7i1bikZJu3gxpgvu3g1Lw+a0= @@ -698,6 +706,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -712,8 +722,6 @@ github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= @@ -752,6 +760,10 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9 github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc= @@ -976,6 +988,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= @@ -1133,9 +1147,9 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1155,7 +1169,6 @@ golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= From 164402bff9c6fa8c704aafcae711cfe9326ca166 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 29 Aug 2024 09:20:06 +0000 Subject: [PATCH 02/28] chore: fix nolint comments --- 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 04b554c7bfc00..2dc1e16573b94 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -119,7 +119,7 @@ func (selectModel) Init() tea.Cmd { return textinput.Blink } -//nolint:revive The linter complains about modifying 'm' but this is typical practice for bubbletea +//nolint:revive // The linter complains about modifying 'm' but this is typical practice for bubbletea func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd @@ -285,7 +285,7 @@ func (multiSelectModel) Init() tea.Cmd { return nil } -//nolint:revive For same reason as previous Update definition +//nolint:revive // For same reason as previous Update definition func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd From 360fb95329dfd81219b5aa8f2acd37ae3f52df94 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 29 Aug 2024 11:12:45 +0000 Subject: [PATCH 03/28] chore: add message and colour to select prompts --- cli/cliui/select.go | 97 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 73 insertions(+), 24 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 2dc1e16573b94..41751c6dfafc4 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -10,6 +10,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" "github.com/coder/serpent" ) @@ -80,6 +81,7 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { hideSearch: opts.HideSearch, options: opts.Options, height: opts.Size, + message: opts.Message, } if initialModel.height == 0 { @@ -110,6 +112,7 @@ type selectModel struct { options []string cursor int height int + message string selected string canceled bool hideSearch bool @@ -170,22 +173,33 @@ func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m selectModel) View() string { var s string - if m.hideSearch { - s += "? [Use arrows to move]\n" - } else { - s += fmt.Sprintf("? %s [Use arrows to move, type to filter]\n", m.search.View()) - } + msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message) - options, start := m.viewableOptions() - - for i, option := range options { - // Is this the currently selected option? - cursor := " " - if m.cursor == start+i { - cursor = ">" + if m.selected == "" { + if m.hideSearch { + s += fmt.Sprintf("%s [Use arrows to move]\n", msg) + } else { + s += fmt.Sprintf("%s %s[Use arrows to move, type to filter]\n", msg, m.search.View()) } - s += fmt.Sprintf("%s %s\n", cursor, option) + options, start := m.viewableOptions() + + for i, option := range options { + // Is this the currently selected option? + style := pretty.Wrap(" ", "") + if m.cursor == start+i { + style = pretty.Style{ + pretty.Wrap("> ", ""), + pretty.FgColor(Green), + } + } + + s += pretty.Sprint(style, option) + s += "\n" + } + } else { + selected := pretty.Sprint(DefaultStyles.Keyword, m.selected) + s += fmt.Sprintf("%s %s\n", msg, selected) } return s @@ -238,11 +252,18 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er options := make([]multiSelectOption, len(opts.Options)) for i, option := range opts.Options { options[i].option = option + + for _, d := range opts.Defaults { + if option == d { + options[i].chosen = true + } + } } initialModel := multiSelectModel{ search: textinput.New(), options: options, + message: opts.Message, } initialModel.search.Prompt = "" @@ -278,7 +299,9 @@ type multiSelectModel struct { search textinput.Model options []multiSelectOption cursor int + message string canceled bool + selected bool } func (multiSelectModel) Init() tea.Cmd { @@ -297,12 +320,13 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEnter: if len(m.options) != 0 { + m.selected = true return m, tea.Quit } case tea.KeySpace: if len(m.options) != 0 { - m.options[m.cursor].chosen = true + m.options[m.cursor].chosen = !m.options[m.cursor].chosen } case tea.KeyUp: @@ -343,21 +367,46 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m multiSelectModel) View() string { - s := fmt.Sprintf("? %s [Use arrows to move, space to select, to all, to none, type to filter]\n", m.search.View()) + var s string - for i, option := range m.options { - cursor := " " - if m.cursor == i { - cursor = ">" - } + msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message) - chosen := "[ ]" - if option.chosen { - chosen = "[x]" + if !m.selected { + s += fmt.Sprintf("%s %s[Use arrows to move, space to select, to all, to none, type to filter]\n", msg, m.search.View()) + + for i, option := range m.options { + cursor := " " + if m.cursor == i { + cursor = pretty.Sprint(pretty.FgColor(Green), "> ") + } + + chosen := "[ ]" + if option.chosen { + chosen = pretty.Sprint(pretty.FgColor(Green), "[x]") + } + + o := option.option + if m.cursor == i { + o = pretty.Sprint(pretty.FgColor(Green), o) + } + + s += fmt.Sprintf("%s%s %s\n", cursor, chosen, o) } + } else { + selected := pretty.Sprint(DefaultStyles.Keyword, strings.Join(m.SelectedOptions(), ", ")) - s += fmt.Sprintf("%s %s %s\n", cursor, chosen, option.option) + s += fmt.Sprintf("%s %s\n", msg, selected) } return s } + +func (m multiSelectModel) SelectedOptions() []string { + selected := []string{} + for _, o := range m.options { + if o.chosen { + selected = append(selected, o.option) + } + } + return selected +} From 7cf35d1be3641a62896324c92eb3278f285c95d7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 29 Aug 2024 11:21:19 +0000 Subject: [PATCH 04/28] chore: make SelectedOptions private --- cli/cliui/select.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 41751c6dfafc4..bc333a2ae88df 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -281,11 +281,7 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er return values, Canceled } - for _, option := range m.options { - if option.chosen { - values = append(values, option.option) - } - } + values = m.selectedOptions() } return values, err } @@ -393,7 +389,7 @@ func (m multiSelectModel) View() string { s += fmt.Sprintf("%s%s %s\n", cursor, chosen, o) } } else { - selected := pretty.Sprint(DefaultStyles.Keyword, strings.Join(m.SelectedOptions(), ", ")) + selected := pretty.Sprint(DefaultStyles.Keyword, strings.Join(m.selectedOptions(), ", ")) s += fmt.Sprintf("%s %s\n", msg, selected) } @@ -401,7 +397,7 @@ func (m multiSelectModel) View() string { return s } -func (m multiSelectModel) SelectedOptions() []string { +func (m multiSelectModel) selectedOptions() []string { selected := []string{} for _, o := range m.options { if o.chosen { From b5092a638bd73c8624f88d6d90bbe145c5eacfda Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 29 Aug 2024 11:46:36 +0000 Subject: [PATCH 05/28] chore: add filtering for multiselect, improve filtering algo --- cli/cliui/select.go | 72 ++++++++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index bc333a2ae88df..552c11cec9589 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -227,10 +227,10 @@ func (m selectModel) viewableOptions() ([]string, int) { func (m selectModel) filteredOptions() []string { options := []string{} for _, o := range m.options { - prefix := strings.ToLower(m.search.Value()) + filter := strings.ToLower(m.search.Value()) option := strings.ToLower(o) - if strings.HasPrefix(option, prefix) { + if strings.Contains(option, filter) { options = append(options, o) } } @@ -249,15 +249,20 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er return opts.Defaults, nil } - options := make([]multiSelectOption, len(opts.Options)) + options := make([]*multiSelectOption, len(opts.Options)) for i, option := range opts.Options { - options[i].option = option - + chosen := false for _, d := range opts.Defaults { if option == d { - options[i].chosen = true + chosen = true } } + + options[i] = &multiSelectOption{ + option: option, + chosen: chosen, + } + } initialModel := multiSelectModel{ @@ -293,7 +298,7 @@ type multiSelectOption struct { type multiSelectModel struct { search textinput.Model - options []multiSelectOption + options []*multiSelectOption cursor int message string canceled bool @@ -321,8 +326,9 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeySpace: - if len(m.options) != 0 { - m.options[m.cursor].chosen = !m.options[m.cursor].chosen + options := m.filteredOptions() + if len(options) != 0 { + options[m.cursor].chosen = !options[m.cursor].chosen } case tea.KeyUp: @@ -336,13 +342,15 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyRight: - for i := range m.options { - m.options[i].chosen = true + options := m.filteredOptions() + for _, option := range options { + option.chosen = false } case tea.KeyLeft: - for i := range m.options { - m.options[i].chosen = false + options := m.filteredOptions() + for _, option := range options { + option.chosen = false } default: @@ -352,8 +360,9 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // If the search query has changed then we need to ensure // the cursor is still pointing at a valid option. if m.search.Value() != oldSearch { - if m.cursor > len(m.options)-1 { - m.cursor = max(0, len(m.options)-1) + options := m.filteredOptions() + if m.cursor > len(options)-1 { + m.cursor = max(0, len(options)-1) } } } @@ -370,23 +379,27 @@ func (m multiSelectModel) View() string { if !m.selected { s += fmt.Sprintf("%s %s[Use arrows to move, space to select, to all, to none, type to filter]\n", msg, m.search.View()) - for i, option := range m.options { + for i, option := range m.filteredOptions() { cursor := " " + chosen := "[ ]" + o := option.option + if m.cursor == i { cursor = pretty.Sprint(pretty.FgColor(Green), "> ") + chosen = pretty.Sprint(pretty.FgColor(Green), "[ ]") + o = pretty.Sprint(pretty.FgColor(Green), o) } - chosen := "[ ]" if option.chosen { chosen = pretty.Sprint(pretty.FgColor(Green), "[x]") } - o := option.option - if m.cursor == i { - o = pretty.Sprint(pretty.FgColor(Green), o) - } - - s += fmt.Sprintf("%s%s %s\n", cursor, chosen, o) + s += fmt.Sprintf( + "%s%s %s\n", + cursor, + chosen, + o, + ) } } else { selected := pretty.Sprint(DefaultStyles.Keyword, strings.Join(m.selectedOptions(), ", ")) @@ -397,6 +410,19 @@ func (m multiSelectModel) View() string { return s } +func (m multiSelectModel) filteredOptions() []*multiSelectOption { + options := []*multiSelectOption{} + for _, o := range m.options { + filter := strings.ToLower(m.search.Value()) + option := strings.ToLower(o.option) + + if strings.Contains(option, filter) { + options = append(options, o) + } + } + return options +} + func (m multiSelectModel) selectedOptions() []string { selected := []string{} for _, o := range m.options { From e4c010e8ccc3216037b88bf4a6bf692249e21137 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 29 Aug 2024 13:24:06 +0000 Subject: [PATCH 06/28] chore: allow vertical wrap on select/multiselect --- cli/cliui/select.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 552c11cec9589..223e8da1f35d3 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -140,14 +140,19 @@ func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyUp: + options := m.filteredOptions() if m.cursor > 0 { m.cursor-- + } else { + m.cursor = len(options) - 1 } case tea.KeyDown: options := m.filteredOptions() if m.cursor < len(options)-1 { m.cursor++ + } else { + m.cursor = 0 } } } @@ -332,13 +337,19 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyUp: + options := m.filteredOptions() if m.cursor > 0 { m.cursor-- + } else { + m.cursor = len(options) - 1 } case tea.KeyDown: - if m.cursor < len(m.options)-1 { + options := m.filteredOptions() + if m.cursor < len(options)-1 { m.cursor++ + } else { + m.cursor = 0 } case tea.KeyRight: From 2b6cd6ee40a9ca6df35e578ea34a3f07b665bfe4 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 29 Aug 2024 13:40:01 +0000 Subject: [PATCH 07/28] chore: fix failing jobs --- cli/cliui/select.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 223e8da1f35d3..b035e0d67b065 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -267,7 +267,6 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er option: option, chosen: chosen, } - } initialModel := multiSelectModel{ From 35fe4618665828a8e7857b0cff5142427792e7e8 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 29 Aug 2024 13:43:56 +0000 Subject: [PATCH 08/28] chore: update charmbracelet/bubbletea to v1.0.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1d6b7ac28176c..6d83a3b94ec81 100644 --- a/go.mod +++ b/go.mod @@ -202,7 +202,7 @@ require go.uber.org/mock v0.4.0 require ( github.com/cespare/xxhash v1.1.0 github.com/charmbracelet/bubbles v0.19.0 - github.com/charmbracelet/bubbletea v0.27.1 + github.com/charmbracelet/bubbletea v1.0.0 github.com/coder/serpent v0.8.0 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-smtp v0.21.2 diff --git a/go.sum b/go.sum index 8077f5495b555..358529616d89d 100644 --- a/go.sum +++ b/go.sum @@ -179,8 +179,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0= github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA= -github.com/charmbracelet/bubbletea v0.27.1 h1:/yhaJKX52pxG4jZVKCNWj/oq0QouPdXycriDRA6m6r8= -github.com/charmbracelet/bubbletea v0.27.1/go.mod h1:xc4gm5yv+7tbniEvQ0naiG9P3fzYhk16cTgDZQQW6YE= +github.com/charmbracelet/bubbletea v1.0.0 h1:BlNvkVed3DADQlV+W79eioNUOrnMUY25EEVdFUoDoGA= +github.com/charmbracelet/bubbletea v1.0.0/go.mod h1:xc4gm5yv+7tbniEvQ0naiG9P3fzYhk16cTgDZQQW6YE= github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= From 59821854317b5760f1255711a742b72cf1c9326e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 29 Aug 2024 15:45:33 +0000 Subject: [PATCH 09/28] chore: remove windows testing workaround for survey library --- cli/cliui/select.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index b035e0d67b065..7b3cbcbfbb747 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -1,7 +1,6 @@ package cliui import ( - "flag" "fmt" "strings" @@ -66,16 +65,6 @@ func RichSelect(inv *serpent.Invocation, richOptions RichSelectOptions) (*coders // Select displays a list of user options. func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { - // TODO: Check if this is still true for Bubbletea. - // The survey library used *always* fails when testing on Windows, - // as it requires a live TTY (can't be a conpty). We should fork - // this library to add a dummy fallback, that simply reads/writes - // to the IO provided. See: - // https://github.com/AlecAivazis/survey/blob/master/terminal/runereader_windows.go#L94 - if flag.Lookup("test.v") != nil { - return opts.Options[0], nil - } - initialModel := selectModel{ search: textinput.New(), hideSearch: opts.HideSearch, @@ -249,11 +238,6 @@ type MultiSelectOptions struct { } func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) { - // Similar hack is applied to Select() - if flag.Lookup("test.v") != nil { - return opts.Defaults, nil - } - options := make([]*multiSelectOption, len(opts.Options)) for i, option := range opts.Options { chosen := false From affbfbeeb4f037b48d804a0a098938963208d28c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 2 Sep 2024 08:12:21 +0000 Subject: [PATCH 10/28] chore: update bubbletea to v1.1.0 --- go.mod | 9 +++------ go.sum | 18 ++++++------------ 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 6d83a3b94ec81..b2b79c8d25880 100644 --- a/go.mod +++ b/go.mod @@ -202,7 +202,7 @@ require go.uber.org/mock v0.4.0 require ( github.com/cespare/xxhash v1.1.0 github.com/charmbracelet/bubbles v0.19.0 - github.com/charmbracelet/bubbletea v1.0.0 + github.com/charmbracelet/bubbletea v1.1.0 github.com/coder/serpent v0.8.0 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-smtp v0.21.2 @@ -220,10 +220,8 @@ require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect - github.com/charmbracelet/x/ansi v0.1.4 // indirect - github.com/charmbracelet/x/input v0.1.0 // indirect - github.com/charmbracelet/x/term v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.1.0 // indirect + github.com/charmbracelet/x/ansi v0.2.3 // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-jose/go-jose/v4 v4.0.2 // indirect @@ -239,7 +237,6 @@ require ( github.com/pion/transport/v2 v2.2.10 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect ) diff --git a/go.sum b/go.sum index 358529616d89d..be83bd8f77f05 100644 --- a/go.sum +++ b/go.sum @@ -179,22 +179,18 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0= github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA= -github.com/charmbracelet/bubbletea v1.0.0 h1:BlNvkVed3DADQlV+W79eioNUOrnMUY25EEVdFUoDoGA= -github.com/charmbracelet/bubbletea v1.0.0/go.mod h1:xc4gm5yv+7tbniEvQ0naiG9P3fzYhk16cTgDZQQW6YE= +github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= +github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= -github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= +github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= -github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= -github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= -github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= -github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= -github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= @@ -988,8 +984,6 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= From 33196cd7b804543398ea8c1a131bc3242f0e31c6 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 2 Sep 2024 08:13:52 +0000 Subject: [PATCH 11/28] chore: reimplement testing workaround --- cli/cliui/select.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 7b3cbcbfbb747..b132c65eceb91 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -1,6 +1,7 @@ package cliui import ( + "flag" "fmt" "strings" @@ -65,6 +66,15 @@ func RichSelect(inv *serpent.Invocation, richOptions RichSelectOptions) (*coders // Select displays a list of user options. func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { + // The survey library used *always* fails when testing on Windows, + // as it requires a live TTY (can't be a conpty). We should fork + // this library to add a dummy fallback, that simply reads/writes + // to the IO provided. See: + // https://github.com/AlecAivazis/survey/blob/master/terminal/runereader_windows.go#L94 + if flag.Lookup("test.v") != nil { + return opts.Options[0], nil + } + initialModel := selectModel{ search: textinput.New(), hideSearch: opts.HideSearch, @@ -238,6 +248,11 @@ type MultiSelectOptions struct { } func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) { + // Similar hack is applied to Select() + if flag.Lookup("test.v") != nil { + return opts.Defaults, nil + } + options := make([]*multiSelectOption, len(opts.Options)) for i, option := range opts.Options { chosen := false From 409cec54a6d4e0050d0f26ca3392c30b4db96967 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 2 Sep 2024 08:56:47 +0000 Subject: [PATCH 12/28] chore: use survey's default page size --- cli/cliui/select.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index b132c65eceb91..0b445be5f1038 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -84,7 +84,7 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { } if initialModel.height == 0 { - initialModel.height = 5 // TODO: Pick a default? + initialModel.height = 7 } initialModel.search.Prompt = "" From 52ed1278479a30536d3513de9bdc3b4be2bb791c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Mon, 2 Sep 2024 11:22:44 +0000 Subject: [PATCH 13/28] chore: ensure no underflow on page bottom --- 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 0b445be5f1038..8afba19dbaa1c 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -219,10 +219,10 @@ func (m selectModel) viewableOptions() ([]string, int) { case m.cursor <= halfHeight: top = min(top, m.height) case m.cursor < top-halfHeight: - bottom = m.cursor - halfHeight + bottom = max(0, m.cursor-halfHeight) top = min(top, m.cursor+halfHeight+1) default: - bottom = top - m.height + bottom = max(0, top-m.height) } return options[bottom:top], bottom From 60c83d0efa2f2585fa3be675925aed3853d7e50c Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 3 Sep 2024 07:39:48 +0000 Subject: [PATCH 14/28] chore: remove multi-select test from cmd/cliui --- cmd/cliui/main.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index 13ff7bcb683fc..206d756553aaa 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -88,17 +88,6 @@ func main() { }, }) - root.Children = append(root.Children, &serpent.Command{ - Use: "multi-select", - Handler: func(inv *serpent.Invocation) error { - values, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ - Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"}, - }) - _, _ = fmt.Printf("Selected: %q\n", values) - return err - }, - }) - root.Children = append(root.Children, &serpent.Command{ Use: "job", Handler: func(inv *serpent.Invocation) error { From 2b621cee8185358c44052689ef36f6be58471ea7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 3 Sep 2024 07:53:11 +0000 Subject: [PATCH 15/28] chore: disable HideSearch from cmd/cliui select --- cmd/cliui/main.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/cliui/main.go b/cmd/cliui/main.go index 206d756553aaa..161414e4471e2 100644 --- a/cmd/cliui/main.go +++ b/cmd/cliui/main.go @@ -79,9 +79,8 @@ func main() { Use: "select", Handler: func(inv *serpent.Invocation) error { value, err := cliui.Select(inv, cliui.SelectOptions{ - Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"}, - Size: 3, - HideSearch: true, + Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"}, + Size: 3, }) _, _ = fmt.Printf("Selected: %q\n", value) return err From 8a3cd264aad7ac01c0ba0c007f583a31d2c53dfe Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 3 Sep 2024 10:02:40 +0000 Subject: [PATCH 16/28] chore: use strings.Builder, fix select all logic, apply nitpicks --- cli/cliui/select.go | 104 ++++++++++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 48 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 8afba19dbaa1c..0dd71cc601ae2 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -179,31 +179,33 @@ func (m selectModel) View() string { msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message) - if m.selected == "" { - if m.hideSearch { - s += fmt.Sprintf("%s [Use arrows to move]\n", msg) - } else { - s += fmt.Sprintf("%s %s[Use arrows to move, type to filter]\n", msg, m.search.View()) - } + if m.selected != "" { + selected := pretty.Sprint(DefaultStyles.Keyword, m.selected) + s += fmt.Sprintf("%s %s\n", msg, selected) - options, start := m.viewableOptions() + return s + } - for i, option := range options { - // Is this the currently selected option? - style := pretty.Wrap(" ", "") - if m.cursor == start+i { - style = pretty.Style{ - pretty.Wrap("> ", ""), - pretty.FgColor(Green), - } - } + if m.hideSearch { + s += fmt.Sprintf("%s [Use arrows to move]\n", msg) + } else { + s += fmt.Sprintf("%s %s[Use arrows to move, type to filter]\n", msg, m.search.View()) + } + + options, start := m.viewableOptions() - s += pretty.Sprint(style, option) - s += "\n" + for i, option := range options { + // Is this the currently selected option? + style := pretty.Wrap(" ", "") + if m.cursor == start+i { + style = pretty.Style{ + pretty.Wrap("> ", ""), + pretty.FgColor(Green), + } } - } else { - selected := pretty.Sprint(DefaultStyles.Keyword, m.selected) - s += fmt.Sprintf("%s %s\n", msg, selected) + + s += pretty.Sprint(style, option) + s += "\n" } return s @@ -259,6 +261,7 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er for _, d := range opts.Defaults { if option == d { chosen = true + break } } @@ -353,7 +356,7 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyRight: options := m.filteredOptions() for _, option := range options { - option.chosen = false + option.chosen = true } case tea.KeyLeft: @@ -381,42 +384,47 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m multiSelectModel) View() string { - var s string + var s strings.Builder msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message) - if !m.selected { - s += fmt.Sprintf("%s %s[Use arrows to move, space to select, to all, to none, type to filter]\n", msg, m.search.View()) - - for i, option := range m.filteredOptions() { - cursor := " " - chosen := "[ ]" - o := option.option + if m.selected { + selected := pretty.Sprint(DefaultStyles.Keyword, strings.Join(m.selectedOptions(), ", ")) + _, _ = s.WriteString(fmt.Sprintf("%s %s\n", msg, selected)) - if m.cursor == i { - cursor = pretty.Sprint(pretty.FgColor(Green), "> ") - chosen = pretty.Sprint(pretty.FgColor(Green), "[ ]") - o = pretty.Sprint(pretty.FgColor(Green), o) - } + return s.String() + } - if option.chosen { - chosen = pretty.Sprint(pretty.FgColor(Green), "[x]") - } + _, _ = s.WriteString(fmt.Sprintf( + "%s %s[Use arrows to move, space to select, to all, to none, type to filter]\n", + msg, + m.search.View(), + )) + + for i, option := range m.filteredOptions() { + cursor := " " + chosen := "[ ]" + o := option.option + + if m.cursor == i { + cursor = pretty.Sprint(pretty.FgColor(Green), "> ") + chosen = pretty.Sprint(pretty.FgColor(Green), "[ ]") + o = pretty.Sprint(pretty.FgColor(Green), o) + } - s += fmt.Sprintf( - "%s%s %s\n", - cursor, - chosen, - o, - ) + if option.chosen { + chosen = pretty.Sprint(pretty.FgColor(Green), "[x]") } - } else { - selected := pretty.Sprint(DefaultStyles.Keyword, strings.Join(m.selectedOptions(), ", ")) - s += fmt.Sprintf("%s %s\n", msg, selected) + _, _ = s.WriteString(fmt.Sprintf( + "%s%s %s\n", + cursor, + chosen, + o, + )) } - return s + return s.String() } func (m multiSelectModel) filteredOptions() []*multiSelectOption { From 6e59ecbce6c2db324ff4dd2da360c27e87778979 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 3 Sep 2024 10:04:34 +0000 Subject: [PATCH 17/28] chore: use strings.Builder --- cli/cliui/select.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 0dd71cc601ae2..31a1f8717d8ec 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -175,21 +175,25 @@ func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m selectModel) View() string { - var s string + var s strings.Builder msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message) if m.selected != "" { selected := pretty.Sprint(DefaultStyles.Keyword, m.selected) - s += fmt.Sprintf("%s %s\n", msg, selected) + _, _ = s.WriteString(fmt.Sprintf("%s %s\n", msg, selected)) - return s + return s.String() } if m.hideSearch { - s += fmt.Sprintf("%s [Use arrows to move]\n", msg) + _, _ = s.WriteString(fmt.Sprintf("%s [Use arrows to move]\n", msg)) } else { - s += fmt.Sprintf("%s %s[Use arrows to move, type to filter]\n", msg, m.search.View()) + _, _ = s.WriteString(fmt.Sprintf( + "%s %s[Use arrows to move, type to filter]\n", + msg, + m.search.View(), + )) } options, start := m.viewableOptions() @@ -204,11 +208,11 @@ func (m selectModel) View() string { } } - s += pretty.Sprint(style, option) - s += "\n" + _, _ = s.WriteString(pretty.Sprint(style, option)) + _, _ = s.WriteString("\n") } - return s + return s.String() } func (m selectModel) viewableOptions() ([]string, int) { From 579c73b9ef90ff27ace61b364751460538a48b7a Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 3 Sep 2024 10:08:36 +0000 Subject: [PATCH 18/28] chore: move m.search.Update out of case --- cli/cliui/select.go | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 31a1f8717d8ec..c69bbcb082442 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -368,19 +368,18 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for _, option := range options { option.chosen = false } + } + } - default: - oldSearch := m.search.Value() - m.search, cmd = m.search.Update(msg) - - // If the search query has changed then we need to ensure - // the cursor is still pointing at a valid option. - if m.search.Value() != oldSearch { - options := m.filteredOptions() - if m.cursor > len(options)-1 { - m.cursor = max(0, len(options)-1) - } - } + oldSearch := m.search.Value() + m.search, cmd = m.search.Update(msg) + + // If the search query has changed then we need to ensure + // the cursor is still pointing at a valid option. + if m.search.Value() != oldSearch { + options := m.filteredOptions() + if m.cursor > len(options)-1 { + m.cursor = max(0, len(options)-1) } } From 907824252b1026397d4d8c8b483e2ca65271f8a2 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 3 Sep 2024 10:28:19 +0000 Subject: [PATCH 19/28] chore: use tea.WithContext(inv.Context()) for tea program --- cli/cliui/select.go | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index c69bbcb082442..d19b8e3a17d30 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -92,18 +92,25 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { m, err := tea.NewProgram( initialModel, + tea.WithContext(inv.Context()), tea.WithInput(inv.Stdin), tea.WithOutput(inv.Stdout), ).Run() - var value string - if m, ok := m.(selectModel); ok { - if m.canceled { - return value, Canceled - } - value = m.selected + if err != nil { + return "", err + } + + model, ok := m.(selectModel) + if !ok { + return "", xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", m, m)) } - return value, err + + if model.canceled { + return "", Canceled + } + + return model.selected, err } type selectModel struct { @@ -286,19 +293,25 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er m, err := tea.NewProgram( initialModel, + tea.WithContext(inv.Context()), tea.WithInput(inv.Stdin), tea.WithOutput(inv.Stdout), ).Run() - values := []string{} - if m, ok := m.(multiSelectModel); ok { - if m.canceled { - return values, Canceled - } + if err != nil { + return nil, err + } - values = m.selectedOptions() + model, ok := m.(multiSelectModel) + if !ok { + return nil, xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", m, m)) } - return values, err + + if model.canceled { + return nil, Canceled + } + + return model.selectedOptions(), err } type multiSelectOption struct { From f2ed4fa10591b45e53e643eb03ad85da24a4ad20 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 3 Sep 2024 10:53:08 +0000 Subject: [PATCH 20/28] chore: return nil as err already handled --- cli/cliui/select.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index d19b8e3a17d30..87f47fefd17a2 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -96,7 +96,6 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { tea.WithInput(inv.Stdin), tea.WithOutput(inv.Stdout), ).Run() - if err != nil { return "", err } @@ -110,7 +109,7 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { return "", Canceled } - return model.selected, err + return model.selected, nil } type selectModel struct { @@ -297,7 +296,6 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er tea.WithInput(inv.Stdin), tea.WithOutput(inv.Stdout), ).Run() - if err != nil { return nil, err } @@ -311,7 +309,7 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er return nil, Canceled } - return model.selectedOptions(), err + return model.selectedOptions(), nil } type multiSelectOption struct { From 778411028cdccbe1ec895ff8959735e05310d5e7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 3 Sep 2024 10:56:13 +0000 Subject: [PATCH 21/28] chore: use const instead of magic number --- cli/cliui/select.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index 87f47fefd17a2..e67f49a633fb5 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -14,6 +14,8 @@ import ( "github.com/coder/serpent" ) +const defaultSelectModelHeight = 7 + type SelectOptions struct { Options []string // Default will be highlighted first if it's a valid option. @@ -84,7 +86,7 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { } if initialModel.height == 0 { - initialModel.height = 7 + initialModel.height = defaultSelectModelHeight } initialModel.search.Prompt = "" From f5988362bfbd89ea492cd658c897739c894c23e2 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Tue, 3 Sep 2024 15:41:46 +0000 Subject: [PATCH 22/28] chore: handle signals ourself instead of bubbletea --- cli/cliui/select.go | 72 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/cli/cliui/select.go b/cli/cliui/select.go index e67f49a633fb5..9c918bad94488 100644 --- a/cli/cliui/select.go +++ b/cli/cliui/select.go @@ -3,7 +3,10 @@ package cliui import ( "flag" "fmt" + "os" + "os/signal" "strings" + "syscall" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -16,6 +19,36 @@ import ( const defaultSelectModelHeight = 7 +type terminateMsg struct{} + +func installSignalHandler(p *tea.Program) func() { + ch := make(chan struct{}) + + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + + defer func() { + signal.Stop(sig) + close(ch) + }() + + for { + select { + case <-ch: + return + + case <-sig: + p.Send(terminateMsg{}) + } + } + }() + + return func() { + ch <- struct{}{} + } +} + type SelectOptions struct { Options []string // Default will be highlighted first if it's a valid option. @@ -92,12 +125,18 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) { initialModel.search.Prompt = "" initialModel.search.Focus() - m, err := tea.NewProgram( + p := tea.NewProgram( initialModel, + tea.WithoutSignalHandler(), tea.WithContext(inv.Context()), tea.WithInput(inv.Stdin), tea.WithOutput(inv.Stdout), - ).Run() + ) + + closeSignalHandler := installSignalHandler(p) + defer closeSignalHandler() + + m, err := p.Run() if err != nil { return "", err } @@ -126,14 +165,19 @@ type selectModel struct { } func (selectModel) Init() tea.Cmd { - return textinput.Blink + return nil } //nolint:revive // The linter complains about modifying 'm' but this is typical practice for bubbletea func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - if msg, ok := msg.(tea.KeyMsg); ok { + switch msg := msg.(type) { + case terminateMsg: + m.canceled = true + return m, tea.Quit + + case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC: m.canceled = true @@ -292,12 +336,18 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er initialModel.search.Prompt = "" initialModel.search.Focus() - m, err := tea.NewProgram( + p := tea.NewProgram( initialModel, + tea.WithoutSignalHandler(), tea.WithContext(inv.Context()), tea.WithInput(inv.Stdin), tea.WithOutput(inv.Stdout), - ).Run() + ) + + closeSignalHandler := installSignalHandler(p) + defer closeSignalHandler() + + m, err := p.Run() if err != nil { return nil, err } @@ -336,7 +386,12 @@ func (multiSelectModel) Init() tea.Cmd { func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - if msg, ok := msg.(tea.KeyMsg); ok { + switch msg := msg.(type) { + case terminateMsg: + m.canceled = true + return m, tea.Quit + + case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC: m.canceled = true @@ -353,6 +408,9 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(options) != 0 { options[m.cursor].chosen = !options[m.cursor].chosen } + // We back out early here otherwise a space will be inserted + // into the search field. + return m, nil case tea.KeyUp: options := m.filteredOptions() From 8a7f1bc24dec67451b4ed83384c246c534ab3302 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 4 Sep 2024 08:23:37 +0000 Subject: [PATCH 23/28] chore: update flake.nix --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 4a034f77164b7..6afb1ae271971 100644 --- a/flake.nix +++ b/flake.nix @@ -117,7 +117,7 @@ name = "coder-${osArch}"; # Updated with ./scripts/update-flake.sh`. # This should be updated whenever go.mod changes! - vendorHash = "sha256-cCJOftz6BF9GeS4lHRY//NnEdLLukO5E+V1CuMlvCHo="; + vendorHash = "sha256-VujUXstiUEH3C/7jtSYwFNUyB0F+QbRcutXCN9y7/DY="; proxyVendor = true; src = ./.; nativeBuildInputs = with pkgs; [ getopt openssl zstd ]; From 77804e0c8849a04b01d712bb14eb6c3b1ae5d400 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 4 Sep 2024 08:50:35 +0000 Subject: [PATCH 24/28] chore: update flake.nix --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 6afb1ae271971..dcbd4ee4edae1 100644 --- a/flake.nix +++ b/flake.nix @@ -117,7 +117,7 @@ name = "coder-${osArch}"; # Updated with ./scripts/update-flake.sh`. # This should be updated whenever go.mod changes! - vendorHash = "sha256-VujUXstiUEH3C/7jtSYwFNUyB0F+QbRcutXCN9y7/DY="; + vendorHash = "sha256-EhClnsmlKB4NDwu3O3yE7XELB/9IcIx6ZZpjHhkj1vc="; proxyVendor = true; src = ./.; nativeBuildInputs = with pkgs; [ getopt openssl zstd ]; From 2904b7c95a84a6a7dec3ece5d54f37464d85fc1d Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 4 Sep 2024 09:05:04 +0000 Subject: [PATCH 25/28] chore: update flake.nix --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index dcbd4ee4edae1..6afb1ae271971 100644 --- a/flake.nix +++ b/flake.nix @@ -117,7 +117,7 @@ name = "coder-${osArch}"; # Updated with ./scripts/update-flake.sh`. # This should be updated whenever go.mod changes! - vendorHash = "sha256-EhClnsmlKB4NDwu3O3yE7XELB/9IcIx6ZZpjHhkj1vc="; + vendorHash = "sha256-VujUXstiUEH3C/7jtSYwFNUyB0F+QbRcutXCN9y7/DY="; proxyVendor = true; src = ./.; nativeBuildInputs = with pkgs; [ getopt openssl zstd ]; From 0b99afe61fae6daf9bf82ff31145b86167d8b2eb Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 4 Sep 2024 09:12:41 +0000 Subject: [PATCH 26/28] chore: update flake.nix --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 6afb1ae271971..dcbd4ee4edae1 100644 --- a/flake.nix +++ b/flake.nix @@ -117,7 +117,7 @@ name = "coder-${osArch}"; # Updated with ./scripts/update-flake.sh`. # This should be updated whenever go.mod changes! - vendorHash = "sha256-VujUXstiUEH3C/7jtSYwFNUyB0F+QbRcutXCN9y7/DY="; + vendorHash = "sha256-EhClnsmlKB4NDwu3O3yE7XELB/9IcIx6ZZpjHhkj1vc="; proxyVendor = true; src = ./.; nativeBuildInputs = with pkgs; [ getopt openssl zstd ]; From b173fb0f48a07b2164c21d131303fe46c2770926 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 4 Sep 2024 09:17:21 +0000 Subject: [PATCH 27/28] chore: update flake.nix --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index dcbd4ee4edae1..ea167ef65f9ac 100644 --- a/flake.nix +++ b/flake.nix @@ -117,7 +117,7 @@ name = "coder-${osArch}"; # Updated with ./scripts/update-flake.sh`. # This should be updated whenever go.mod changes! - vendorHash = "sha256-EhClnsmlKB4NDwu3O3yE7XELB/9IcIx6ZZpjHhkj1vc="; + vendorHash = "sha256-8Fi88vyiaI7HNFkAsAB1RW6+sI4kSNfUxKX3j+DAEmw="; proxyVendor = true; src = ./.; nativeBuildInputs = with pkgs; [ getopt openssl zstd ]; From 966f7725f33dbd794574c45de85fac973ec243e7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 4 Sep 2024 09:30:14 +0000 Subject: [PATCH 28/28] chore: update flake.nix --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index ea167ef65f9ac..27c238fe68d8b 100644 --- a/flake.nix +++ b/flake.nix @@ -117,7 +117,7 @@ name = "coder-${osArch}"; # Updated with ./scripts/update-flake.sh`. # This should be updated whenever go.mod changes! - vendorHash = "sha256-8Fi88vyiaI7HNFkAsAB1RW6+sI4kSNfUxKX3j+DAEmw="; + vendorHash = "sha256-GaqNm/eraGPfFgT/E7MTb0jcjkQ7lPS12Nj1OoXNrCQ="; proxyVendor = true; src = ./.; nativeBuildInputs = with pkgs; [ getopt openssl zstd ];