Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrate Bubbletea list CLI for command selecting #5357

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions flytectl/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/flyteorg/flyte/flytectl/cmd/update"
"github.com/flyteorg/flyte/flytectl/cmd/upgrade"
"github.com/flyteorg/flyte/flytectl/cmd/version"
"github.com/flyteorg/flyte/flytectl/pkg/bubbletea"
f "github.com/flyteorg/flyte/flytectl/pkg/filesystemutils"
"github.com/flyteorg/flyte/flytectl/pkg/printer"
stdConfig "github.com/flyteorg/flyte/flytestdlib/config"
Expand Down Expand Up @@ -101,6 +102,7 @@ Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}

Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
`)
bubbletea.ShowCmdList(rootCmd)

return rootCmd
}
Expand Down
2 changes: 2 additions & 0 deletions flytectl/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ require (
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0 // indirect
github.com/Microsoft/go-winio v0.5.2 // indirect
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go v1.44.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
Expand Down Expand Up @@ -139,6 +140,7 @@ require (
github.com/prometheus/procfs v0.10.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
github.com/spf13/afero v1.9.2 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions flytectl/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ github.com/apoorvam/goterminal v0.0.0-20180523175556-614d345c47e5 h1:VYqcjykqpcq
github.com/apoorvam/goterminal v0.0.0-20180523175556-614d345c47e5/go.mod h1:E7x8aDc3AQzDKjEoIZCt+XYheHk2OkP+p2UgeNjecH8=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E=
Expand Down Expand Up @@ -429,6 +431,8 @@ github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDN
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
Expand Down
312 changes: 312 additions & 0 deletions flytectl/pkg/bubbletea/bubbletea_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
package bubbletea

import (
"fmt"
"io"
"os"
"strings"

"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)

const (
listHeight = 17
defaultWidth = 40
)

var (
titleStyle = lipgloss.NewStyle().MarginLeft(2)
itemStyle = lipgloss.NewStyle().PaddingLeft(4)
selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
quitTextStyle = lipgloss.NewStyle().Margin(0, 0, 0, 0)

noStyle = lipgloss.NewStyle()
focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
focusedButton = focusedStyle.Copy().Render("[ Submit ]")
blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit"))
)

type item string

func (i item) FilterValue() string { return "" }

type itemDelegate struct{}

func (d itemDelegate) Height() int { return 1 }
func (d itemDelegate) Spacing() int { return 0 }
func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
i, ok := listItem.(item)
if !ok {
return
}

str := string(i)

fn := itemStyle.Render

if index == m.Index() {
fn = func(s ...string) string {
return selectedItemStyle.Render("> " + strings.Join(s, " "))
}
}

fmt.Fprint(w, fn(str))
}

type listModel struct {
quitting bool
curView viewType
list list.Model
textInputs []textinput.Model
pendingInputFlags []string
focusIndex int
textInputTitle string
}

type viewType int

const (
listView viewType = iota
inputView
)

func makeTextInputModel(flagList []string) []textinput.Model {
inputs := make([]textinput.Model, len(flagList))

var t textinput.Model
for i := range inputs {
t = textinput.New()
t.CharLimit = 32

t.Placeholder = flagList[i]
if i == 0 {
t.Focus()
}
t.PromptStyle = focusedStyle
t.TextStyle = focusedStyle
inputs[i] = t
}

return inputs
}

func initListModel() listModel {

return listModel{
curView: listView,
}
}

func (m listModel) Init() tea.Cmd {
return textinput.Blink
}

func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetWidth(msg.Width)
return m, nil

case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
m.quitting = true
return m, tea.Quit
}
}

var cmd tea.Cmd
var updatedModel tea.Model
if m.curView == inputView {
updatedModel, cmd = m.textInputUpdate(msg)
} else if m.curView == listView {
updatedModel, cmd = m.listUpdate(msg)
}
m = updatedModel.(listModel)

return m, cmd
}

func (m listModel) listUpdate(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
item, _ := m.list.SelectedItem().(item)
args = append(args, string(item))
err := makeListModel(&m, string(item))
if err != nil || m.quitting {
return m, tea.Quit
}
return m, nil
}
}

var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}

func (m listModel) textInputUpdate(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.Type {
// Set focus to next input
case tea.KeyEnter, tea.KeyUp, tea.KeyDown:
s := msg.String()

// Did the user press enter while the submit button was focused?
// If so, exit. //TODO save to args
if s == "enter" && m.focusIndex == len(m.textInputs) {
m.curView = listView
for i := range m.pendingInputFlags {
args = append(args, m.pendingInputFlags[i])
args = append(args, m.textInputs[i].Value())
}
err := makeListModel(&m, "")
if err != nil || m.quitting {
return m, tea.Quit
}
return m, nil
}

// Cycle indexes
if s == "up" {
m.focusIndex--
} else {
m.focusIndex++
}

if m.focusIndex > len(m.textInputs) {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = len(m.textInputs)
}

cmds := make([]tea.Cmd, len(m.textInputs))
for i := 0; i <= len(m.textInputs)-1; i++ {
if i == m.focusIndex {
// Set focused state
cmds[i] = m.textInputs[i].Focus()
m.textInputs[i].PromptStyle = focusedStyle
m.textInputs[i].TextStyle = focusedStyle
continue
}
// Remove focused state
m.textInputs[i].Blur()
m.textInputs[i].PromptStyle = noStyle
m.textInputs[i].TextStyle = noStyle
}

return m, tea.Batch(cmds...)
}
}

// Handle character input and blinking
cmd := m.updateInputs(msg)
return m, cmd
}

func (m *listModel) updateInputs(msg tea.Msg) tea.Cmd {
cmds := make([]tea.Cmd, len(m.textInputs))

// Only text inputs with Focus() set will respond, so it's safe to simply
// update all of them here without any further logic.
for i := range m.textInputs {
m.textInputs[i], cmds[i] = m.textInputs[i].Update(msg)
}

return tea.Batch(cmds...)
}

func (m listModel) View() string {
if m.quitting {
return quitTextStyle.Render("")
}

if m.curView == inputView {
var b strings.Builder
b.WriteString(m.textInputTitle + "\n\n")
for i := range m.textInputs {
b.WriteString(m.textInputs[i].View())
if i < len(m.textInputs)-1 {
b.WriteRune('\n')
}
}

button := &blurredButton
if m.focusIndex == len(m.textInputs) {
button = &focusedButton
}
fmt.Fprintf(&b, "\n\n%s\n\n", *button)

return b.String()
}

return "\n" + m.list.View()
}

func makeList(items []list.Item, title string) list.Model {
l := list.New(items, itemDelegate{}, defaultWidth, listHeight)

l.SetShowTitle(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
if title != "" {
l.Title = title
l.SetShowTitle(true)
l.Styles.Title = titleStyle
}
l.Styles.PaginationStyle = paginationStyle
l.Styles.HelpStyle = helpStyle

return l
}

func ShowCmdList(_rootCmd *cobra.Command) {
rootCmd = _rootCmd

currentCmd, run, err := ifRunBubbleTea()
if err != nil || !run {
return
}

InitCommandFlagMap()

cmdName := strings.Fields(currentCmd.Use)[0]
nameToCommand[cmdName] = Command{
Cmd: currentCmd,
Name: cmdName,
Short: currentCmd.Short,
}

m := initListModel()
if err := makeListModel(&m, cmdName); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}

if !m.quitting {
if _, err := tea.NewProgram(m).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}

if listErrMsg != nil {
fmt.Println(listErrMsg)
os.Exit(1)
}

fmt.Println(append(args, existingFlags...))
// Originally existed flags need to be append at last, so if any user input is wrong, it can be caught in the main logic
rootCmd.SetArgs(append(args, existingFlags...))
}
Loading
Loading