mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
641 lines
14 KiB
Go
641 lines
14 KiB
Go
package cliui
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/pretty"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
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.
|
|
Default string
|
|
Message string
|
|
Size int
|
|
HideSearch bool
|
|
}
|
|
|
|
type RichSelectOptions struct {
|
|
Options []codersdk.TemplateVersionParameterOption
|
|
Default string
|
|
Size int
|
|
HideSearch bool
|
|
}
|
|
|
|
// RichSelect displays a list of user options including name and description.
|
|
func RichSelect(inv *serpent.Invocation, richOptions RichSelectOptions) (*codersdk.TemplateVersionParameterOption, error) {
|
|
opts := make([]string, len(richOptions.Options))
|
|
var defaultOpt string
|
|
for i, option := range richOptions.Options {
|
|
line := option.Name
|
|
if len(option.Description) > 0 {
|
|
line += ": " + option.Description
|
|
}
|
|
opts[i] = line
|
|
|
|
if option.Value == richOptions.Default {
|
|
defaultOpt = line
|
|
}
|
|
}
|
|
|
|
selected, err := Select(inv, SelectOptions{
|
|
Options: opts,
|
|
Default: defaultOpt,
|
|
Size: richOptions.Size,
|
|
HideSearch: richOptions.HideSearch,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i, option := range opts {
|
|
if option == selected {
|
|
return &richOptions.Options[i], nil
|
|
}
|
|
}
|
|
return nil, xerrors.Errorf("unknown option selected: %s", selected)
|
|
}
|
|
|
|
// 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,
|
|
options: opts.Options,
|
|
height: opts.Size,
|
|
message: opts.Message,
|
|
}
|
|
|
|
if initialModel.height == 0 {
|
|
initialModel.height = defaultSelectModelHeight
|
|
}
|
|
|
|
initialModel.search.Prompt = ""
|
|
initialModel.search.Focus()
|
|
|
|
p := tea.NewProgram(
|
|
initialModel,
|
|
tea.WithoutSignalHandler(),
|
|
tea.WithContext(inv.Context()),
|
|
tea.WithInput(inv.Stdin),
|
|
tea.WithOutput(inv.Stdout),
|
|
)
|
|
|
|
closeSignalHandler := installSignalHandler(p)
|
|
defer closeSignalHandler()
|
|
|
|
m, err := p.Run()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
model, ok := m.(selectModel)
|
|
if !ok {
|
|
return "", xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", m, m))
|
|
}
|
|
|
|
if model.canceled {
|
|
return "", Canceled
|
|
}
|
|
|
|
return model.selected, nil
|
|
}
|
|
|
|
type selectModel struct {
|
|
search textinput.Model
|
|
options []string
|
|
cursor int
|
|
height int
|
|
message string
|
|
selected string
|
|
canceled bool
|
|
hideSearch bool
|
|
}
|
|
|
|
func (selectModel) Init() tea.Cmd {
|
|
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
|
|
|
|
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
|
|
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:
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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 strings.Builder
|
|
|
|
msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message)
|
|
|
|
if m.selected != "" {
|
|
selected := pretty.Sprint(DefaultStyles.Keyword, m.selected)
|
|
_, _ = s.WriteString(fmt.Sprintf("%s %s\n", msg, selected))
|
|
|
|
return s.String()
|
|
}
|
|
|
|
if m.hideSearch {
|
|
_, _ = s.WriteString(fmt.Sprintf("%s [Use arrows to move]\n", msg))
|
|
} else {
|
|
_, _ = s.WriteString(fmt.Sprintf(
|
|
"%s %s[Use arrows to move, type to filter]\n",
|
|
msg,
|
|
m.search.View(),
|
|
))
|
|
}
|
|
|
|
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("> ", ""),
|
|
DefaultStyles.Keyword,
|
|
}
|
|
}
|
|
|
|
_, _ = s.WriteString(pretty.Sprint(style, option))
|
|
_, _ = s.WriteString("\n")
|
|
}
|
|
|
|
return s.String()
|
|
}
|
|
|
|
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 = max(0, m.cursor-halfHeight)
|
|
top = min(top, m.cursor+halfHeight+1)
|
|
default:
|
|
bottom = max(0, top-m.height)
|
|
}
|
|
|
|
return options[bottom:top], bottom
|
|
}
|
|
|
|
func (m selectModel) filteredOptions() []string {
|
|
options := []string{}
|
|
for _, o := range m.options {
|
|
filter := strings.ToLower(m.search.Value())
|
|
option := strings.ToLower(o)
|
|
|
|
if strings.Contains(option, filter) {
|
|
options = append(options, o)
|
|
}
|
|
}
|
|
return options
|
|
}
|
|
|
|
type MultiSelectOptions struct {
|
|
Message string
|
|
Options []string
|
|
Defaults []string
|
|
EnableCustomInput bool
|
|
}
|
|
|
|
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
|
|
for _, d := range opts.Defaults {
|
|
if option == d {
|
|
chosen = true
|
|
break
|
|
}
|
|
}
|
|
|
|
options[i] = &multiSelectOption{
|
|
option: option,
|
|
chosen: chosen,
|
|
}
|
|
}
|
|
|
|
initialModel := multiSelectModel{
|
|
search: textinput.New(),
|
|
options: options,
|
|
message: opts.Message,
|
|
enableCustomInput: opts.EnableCustomInput,
|
|
}
|
|
|
|
initialModel.search.Prompt = ""
|
|
initialModel.search.Focus()
|
|
|
|
p := tea.NewProgram(
|
|
initialModel,
|
|
tea.WithoutSignalHandler(),
|
|
tea.WithContext(inv.Context()),
|
|
tea.WithInput(inv.Stdin),
|
|
tea.WithOutput(inv.Stdout),
|
|
)
|
|
|
|
closeSignalHandler := installSignalHandler(p)
|
|
defer closeSignalHandler()
|
|
|
|
m, err := p.Run()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
model, ok := m.(multiSelectModel)
|
|
if !ok {
|
|
return nil, xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", m, m))
|
|
}
|
|
|
|
if model.canceled {
|
|
return nil, Canceled
|
|
}
|
|
|
|
return model.selectedOptions(), nil
|
|
}
|
|
|
|
type multiSelectOption struct {
|
|
option string
|
|
chosen bool
|
|
}
|
|
|
|
type multiSelectModel struct {
|
|
search textinput.Model
|
|
options []*multiSelectOption
|
|
cursor int
|
|
message string
|
|
canceled bool
|
|
selected bool
|
|
isCustomInputMode bool // track if we're adding a custom option
|
|
customInput string // store custom input
|
|
enableCustomInput bool // control whether custom input is allowed
|
|
}
|
|
|
|
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 m.isCustomInputMode {
|
|
return m.handleCustomInputMode(msg)
|
|
}
|
|
|
|
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
|
|
return m, tea.Quit
|
|
|
|
case tea.KeyEnter:
|
|
// Switch to custom input mode if we're on the "+ Add custom value:" option
|
|
if m.enableCustomInput && m.cursor == len(m.filteredOptions()) {
|
|
m.isCustomInputMode = true
|
|
return m, nil
|
|
}
|
|
if len(m.options) != 0 {
|
|
m.selected = true
|
|
return m, tea.Quit
|
|
}
|
|
|
|
case tea.KeySpace:
|
|
options := m.filteredOptions()
|
|
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:
|
|
maxIndex := m.getMaxIndex()
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
} else {
|
|
m.cursor = maxIndex
|
|
}
|
|
|
|
case tea.KeyDown:
|
|
maxIndex := m.getMaxIndex()
|
|
if m.cursor < maxIndex {
|
|
m.cursor++
|
|
} else {
|
|
m.cursor = 0
|
|
}
|
|
|
|
case tea.KeyRight:
|
|
options := m.filteredOptions()
|
|
for _, option := range options {
|
|
option.chosen = true
|
|
}
|
|
|
|
case tea.KeyLeft:
|
|
options := m.filteredOptions()
|
|
for _, option := range options {
|
|
option.chosen = false
|
|
}
|
|
}
|
|
}
|
|
|
|
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 multiSelectModel) getMaxIndex() int {
|
|
options := m.filteredOptions()
|
|
if m.enableCustomInput {
|
|
// Include the "+ Add custom value" entry
|
|
return len(options)
|
|
}
|
|
// Includes only the actual options
|
|
return len(options) - 1
|
|
}
|
|
|
|
// handleCustomInputMode manages keyboard interactions when in custom input mode
|
|
func (m *multiSelectModel) handleCustomInputMode(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
keyMsg, ok := msg.(tea.KeyMsg)
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
|
|
switch keyMsg.Type {
|
|
case tea.KeyEnter:
|
|
return m.handleCustomInputSubmission()
|
|
|
|
case tea.KeyCtrlC:
|
|
m.canceled = true
|
|
return m, tea.Quit
|
|
|
|
case tea.KeyBackspace:
|
|
return m.handleCustomInputBackspace()
|
|
|
|
default:
|
|
m.customInput += keyMsg.String()
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
// handleCustomInputSubmission processes the submission of custom input
|
|
func (m *multiSelectModel) handleCustomInputSubmission() (tea.Model, tea.Cmd) {
|
|
if m.customInput == "" {
|
|
m.isCustomInputMode = false
|
|
return m, nil
|
|
}
|
|
|
|
// Clear search to ensure option is visible and cursor points to the new option
|
|
m.search.SetValue("")
|
|
|
|
// Check for duplicates
|
|
for i, opt := range m.options {
|
|
if opt.option == m.customInput {
|
|
// If the option exists but isn't chosen, select it
|
|
if !opt.chosen {
|
|
opt.chosen = true
|
|
}
|
|
|
|
// Point cursor to the new option
|
|
m.cursor = i
|
|
|
|
// Reset custom input mode to disabled
|
|
m.isCustomInputMode = false
|
|
m.customInput = ""
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
// Add new unique option
|
|
m.options = append(m.options, &multiSelectOption{
|
|
option: m.customInput,
|
|
chosen: true,
|
|
})
|
|
|
|
// Point cursor to the newly added option
|
|
m.cursor = len(m.options) - 1
|
|
|
|
// Reset custom input mode to disabled
|
|
m.customInput = ""
|
|
m.isCustomInputMode = false
|
|
return m, nil
|
|
}
|
|
|
|
// handleCustomInputBackspace handles backspace in custom input mode
|
|
func (m *multiSelectModel) handleCustomInputBackspace() (tea.Model, tea.Cmd) {
|
|
if len(m.customInput) > 0 {
|
|
m.customInput = m.customInput[:len(m.customInput)-1]
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m multiSelectModel) View() string {
|
|
var s strings.Builder
|
|
|
|
msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message)
|
|
|
|
if m.selected {
|
|
selected := pretty.Sprint(DefaultStyles.Keyword, strings.Join(m.selectedOptions(), ", "))
|
|
_, _ = s.WriteString(fmt.Sprintf("%s %s\n", msg, selected))
|
|
|
|
return s.String()
|
|
}
|
|
|
|
if m.isCustomInputMode {
|
|
_, _ = s.WriteString(fmt.Sprintf("%s\nEnter custom value: %s\n", msg, m.customInput))
|
|
return s.String()
|
|
}
|
|
|
|
_, _ = s.WriteString(fmt.Sprintf(
|
|
"%s %s[Use arrows to move, space to select, <right> to all, <left> to none, type to filter]\n",
|
|
msg,
|
|
m.search.View(),
|
|
))
|
|
|
|
options := m.filteredOptions()
|
|
for i, option := range options {
|
|
cursor := " "
|
|
chosen := "[ ]"
|
|
o := option.option
|
|
|
|
if m.cursor == i {
|
|
cursor = pretty.Sprint(DefaultStyles.Keyword, "> ")
|
|
chosen = pretty.Sprint(DefaultStyles.Keyword, "[ ]")
|
|
o = pretty.Sprint(DefaultStyles.Keyword, o)
|
|
}
|
|
|
|
if option.chosen {
|
|
chosen = pretty.Sprint(DefaultStyles.Keyword, "[x]")
|
|
}
|
|
|
|
_, _ = s.WriteString(fmt.Sprintf(
|
|
"%s%s %s\n",
|
|
cursor,
|
|
chosen,
|
|
o,
|
|
))
|
|
}
|
|
|
|
if m.enableCustomInput {
|
|
// Add the "+ Add custom value" option at the bottom
|
|
cursor := " "
|
|
text := " + Add custom value"
|
|
if m.cursor == len(options) {
|
|
cursor = pretty.Sprint(DefaultStyles.Keyword, "> ")
|
|
text = pretty.Sprint(DefaultStyles.Keyword, text)
|
|
}
|
|
_, _ = s.WriteString(fmt.Sprintf("%s%s\n", cursor, text))
|
|
}
|
|
return s.String()
|
|
}
|
|
|
|
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 {
|
|
if o.chosen {
|
|
selected = append(selected, o.option)
|
|
}
|
|
}
|
|
return selected
|
|
}
|