From ed9b556f140523ddef6c8e84b819d7894dc682f6 Mon Sep 17 00:00:00 2001 From: zychen5186 Date: Sat, 27 Apr 2024 16:35:43 -0700 Subject: [PATCH] Add Bubbletea list for command selecting Signed-off-by: zychen5186 --- cmd/root.go | 3 + go.mod | 2 + go.sum | 4 + pkg/bubbletea/bubbletea_list.go | 142 +++++++++++++++++++ pkg/bubbletea/bubbletea_list_util.go | 205 +++++++++++++++++++++++++++ pkg/bubbletea/bubbletea_util.go | 40 ++++++ pkg/bubbletea/command_flag_map.go | 77 ++++++++++ 7 files changed, 473 insertions(+) create mode 100644 pkg/bubbletea/bubbletea_list.go create mode 100644 pkg/bubbletea/bubbletea_list_util.go create mode 100644 pkg/bubbletea/bubbletea_util.go create mode 100644 pkg/bubbletea/command_flag_map.go diff --git a/cmd/root.go b/cmd/root.go index b1a97bbb..83716348 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,6 +20,7 @@ import ( "github.com/flyteorg/flytectl/cmd/update" "github.com/flyteorg/flytectl/cmd/upgrade" "github.com/flyteorg/flytectl/cmd/version" + "github.com/flyteorg/flytectl/pkg/bubbletea" f "github.com/flyteorg/flytectl/pkg/filesystemutils" "github.com/flyteorg/flytectl/pkg/printer" @@ -102,6 +103,8 @@ Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} `) + bubbletea.ShowCmdList(rootCmd) + return rootCmd } diff --git a/go.mod b/go.mod index 9e492aa0..bca762c7 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,7 @@ require ( github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/Microsoft/go-winio v0.5.0 // 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 @@ -142,6 +143,7 @@ require ( github.com/prometheus/procfs v0.7.3 // 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 diff --git a/go.sum b/go.sum index 0d631841..bb342e34 100644 --- a/go.sum +++ b/go.sum @@ -130,6 +130,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= 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= @@ -824,6 +826,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD 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/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4= +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/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= diff --git a/pkg/bubbletea/bubbletea_list.go b/pkg/bubbletea/bubbletea_list.go new file mode 100644 index 00000000..4ee94be2 --- /dev/null +++ b/pkg/bubbletea/bubbletea_list.go @@ -0,0 +1,142 @@ +package bubbletea + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/charmbracelet/bubbles/list" + 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) +) + +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 { + list list.Model + quitting bool +} + +func (m listModel) Init() tea.Cmd { + return nil +} + +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 "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + + case "enter": + item, _ := m.list.SelectedItem().(item) + m, err := genListModel(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) View() string { + if m.quitting { + return quitTextStyle.Render("") + } + return "\n" + m.list.View() +} + +func genList(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) error { + rootCmd = _rootCmd + + currentCmd, run, err := ifRunBubbleTea(*rootCmd) + if err != nil { + return err + } + if !run { + return nil + } + + InitCommandFlagMap() + + items := generateSubCmdItems(currentCmd) + + l := genList(items, "") + m := listModel{list: l} + + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + + rootCmd.SetArgs(newArgs) + + return nil +} diff --git a/pkg/bubbletea/bubbletea_list_util.go b/pkg/bubbletea/bubbletea_list_util.go new file mode 100644 index 00000000..0b0c1fc0 --- /dev/null +++ b/pkg/bubbletea/bubbletea_list_util.go @@ -0,0 +1,205 @@ +package bubbletea + +import ( + "context" + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/flyteorg/flyte/flyteidl/clients/go/admin" + "github.com/flyteorg/flytectl/cmd/config/subcommand/project" + cmdcore "github.com/flyteorg/flytectl/cmd/core" + "github.com/flyteorg/flytectl/pkg/pkce" + "github.com/spf13/cobra" +) + +type Command struct { + Cmd *cobra.Command + Name string + Short string +} + +var ( + rootCmd *cobra.Command + newArgs []string + flags []string +) +var ( + DOMAIN_NAME = [3]string{"development", "staging", "production"} + isCommand = true + nameToCommand = map[string]Command{} +) + +// Generate a []list.Item of cmd's subcommands +func generateSubCmdItems(cmd *cobra.Command) []list.Item { + items := []list.Item{} + + for _, subcmd := range cmd.Commands() { + subCmdName := strings.Fields(subcmd.Use)[0] + nameToCommand[subCmdName] = Command{ + Cmd: subcmd, + Name: subCmdName, + Short: subcmd.Short, + } + items = append(items, item(subCmdName)) + } + + return items +} + +// Generate list.Model for domain names +func genDomainListModel(m listModel) (listModel, error) { + items := []list.Item{} + for _, domain := range DOMAIN_NAME { + items = append(items, item(domain)) + } + + m.list = genList(items, "Please choose one of the domains") + return m, nil +} + +// Get the "get" "project" cobra.Command item +func extractGetProjectCmd() *cobra.Command { + var getProjectCmd *cobra.Command + + for _, cmd := range rootCmd.Commands() { + if cmd.Use == "get" { + getProjectCmd = cmd + break + } + } + for _, cmd := range getProjectCmd.Commands() { + if cmd.Use == "project" { + getProjectCmd = cmd + break + } + } + return getProjectCmd +} + +// Get all the project names from the configured endpoint +func getProjects(getProjectCmd *cobra.Command) ([]string, error) { + ctx := context.Background() + rootCmd.PersistentPreRunE(rootCmd, []string{}) + adminCfg := admin.GetConfig(ctx) + + clientSet, err := admin.ClientSetBuilder().WithConfig(admin.GetConfig(ctx)). + WithTokenCache(pkce.TokenCacheKeyringProvider{ + ServiceUser: fmt.Sprintf("%s:%s", adminCfg.Endpoint.String(), pkce.KeyRingServiceUser), + ServiceName: pkce.KeyRingServiceName, + }).Build(ctx) + if err != nil { + return nil, err + } + cmdCtx := cmdcore.NewCommandContext(clientSet, getProjectCmd.OutOrStdout()) + + projects, err := cmdCtx.AdminFetcherExt().ListProjects(ctx, project.DefaultConfig.Filter) + if err != nil { + return nil, err + } + + projectNames := []string{} + for _, p := range projects.Projects { + projectNames = append(projectNames, p.Id) + } + + return projectNames, nil +} + +// Generate list.Model for project names from the configured endpoint +func genProjectListModel(m listModel) (listModel, error) { + getProjectCmd := extractGetProjectCmd() + projects, err := getProjects(getProjectCmd) + if err != nil { + return m, err + } + + items := []list.Item{} + for _, project := range projects { + items = append(items, item(project)) + } + + m.list = genList(items, "Please choose one of the projects") + + return m, nil +} + +// Generate list.Model of options for different flags +func genFlagListModel(m listModel, f string) (listModel, error) { + var err error + + switch f { + case "-p": + m, err = genProjectListModel(m) + case "-d": + m, err = genDomainListModel(m) + } + + return m, err +} + +// Generate list.Model of subcommands from a given command +func genCmdListModel(m listModel, c string) listModel { + if len(nameToCommand[c].Cmd.Commands()) == 0 { + return m + } + + items := generateSubCmdItems(nameToCommand[c].Cmd) + l := genList(items, "") + m.list = l + + return m +} + +// Generate list.Model after user chose one of the item +func genListModel(m listModel, item string) (listModel, error) { + newArgs = append(newArgs, item) + + if isCommand { + m = genCmdListModel(m, item) + var ok bool + if flags, ok = commandFlagMap[sliceToString(newArgs)]; ok { // If found in commandFlagMap means last command + isCommand = false + } else { + return m, nil + } + } + // TODO check if some flags are already input as arguments by user + if len(flags) > 0 { + nextFlag := flags[0] + flags = flags[1:] + newArgs = append(newArgs, nextFlag) + var err error + m, err = genFlagListModel(m, nextFlag) + if err != nil { + return m, err + } + } else { + m.quitting = true + return m, nil + } + return m, nil +} + +// func isValidCommand(curArg string, cmd *cobra.Command) (*cobra.Command, bool) { +// for _, subCmd := range cmd.Commands() { +// if subCmd.Use == curArg { +// return subCmd, true +// } +// } +// return nil, false +// } + +// func findSubCmdItems(cmd *cobra.Command, inputArgs []string) ([]list.Item, error) { +// if len(inputArgs) == 0 { +// return generateSubCmdItems(cmd), nil +// } + +// curArg := inputArgs[0] +// subCmd, isValid := isValidCommand(curArg, cmd) +// if !isValid { +// return nil, fmt.Errorf("not a valid argument: %v", curArg) +// } + +// return findSubCmdItems(subCmd, inputArgs[1:]) +// } diff --git a/pkg/bubbletea/bubbletea_util.go b/pkg/bubbletea/bubbletea_util.go new file mode 100644 index 00000000..ca7fb60e --- /dev/null +++ b/pkg/bubbletea/bubbletea_util.go @@ -0,0 +1,40 @@ +package bubbletea + +import ( + "os" + + "github.com/spf13/cobra" +) + +// Check if -f bubbletea is in args +func ifRunBubbleTea(_rootCmd cobra.Command) (*cobra.Command, bool, error) { + cmd, flags, err := _rootCmd.Find(os.Args[1:]) + if err != nil { + return cmd, false, err + } + + tempCmd := cmd + for tempCmd.HasParent() { + newArgs = append([]string{tempCmd.Use}, newArgs...) + tempCmd = tempCmd.Parent() + } + + for _, flag := range flags { + if flag == "-i" || flag == "--interactive" { + return cmd, true, nil + } + } + + return cmd, false, nil + // err = _rootCmd.ParseFlags(flags) + // if err != nil { + // return nil, false, err + // } + + // format, err := _rootCmd.Flags().GetString("format") + // if format != "bubbletea" || err != nil { + // return nil, false, err + // } else { + // return cmd, true, err + // } +} diff --git a/pkg/bubbletea/command_flag_map.go b/pkg/bubbletea/command_flag_map.go new file mode 100644 index 00000000..fd30169a --- /dev/null +++ b/pkg/bubbletea/command_flag_map.go @@ -0,0 +1,77 @@ +package bubbletea + +import ( + "strings" +) + +var commandFlagMap = make(map[string][]string) + +func InitCommandFlagMap() { + // compile + commandFlagMap[sliceToString([]string{"compile"})] = []string{"--file"} + // completion + commandFlagMap[sliceToString([]string{"completion"})] = []string{"--shell"} + // config + commandFlagMap[sliceToString([]string{"config", "discover"})] = []string{""} + commandFlagMap[sliceToString([]string{"config", "docs"})] = []string{""} + commandFlagMap[sliceToString([]string{"config", "init"})] = []string{""} + commandFlagMap[sliceToString([]string{"config", "validate"})] = []string{""} + // create + commandFlagMap[sliceToString([]string{"create", "execution"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"create", "project"})] = []string{"--pid"} //? + // delete + commandFlagMap[sliceToString([]string{"delete", "cluster-resource-attribute"})] = []string{""} //? + commandFlagMap[sliceToString([]string{"delete", "execution"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"delete", "execution-cluster-label"})] = []string{""} //? + commandFlagMap[sliceToString([]string{"delete", "execution-queue-attribute"})] = []string{""} //? + commandFlagMap[sliceToString([]string{"delete", "plugin-override"})] = []string{""} //? + commandFlagMap[sliceToString([]string{"delete", "task-resource-attribute"})] = []string{""} //? + commandFlagMap[sliceToString([]string{"delete", "workflow-execution-config"})] = []string{""} //? + // demo + commandFlagMap[sliceToString([]string{"demo", "exec"})] = []string{""} //? + commandFlagMap[sliceToString([]string{"demo", "reload"})] = []string{""} + commandFlagMap[sliceToString([]string{"demo", "start"})] = []string{""} + commandFlagMap[sliceToString([]string{"demo", "status"})] = []string{""} + commandFlagMap[sliceToString([]string{"demo", "teardown"})] = []string{""} + // get + commandFlagMap[sliceToString([]string{"get", "cluster-resource-attribute"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"get", "execution"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"get", "execution-cluster-label"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"get", "launchplan"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"get", "plugin-override"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"get", "project"})] = []string{} + commandFlagMap[sliceToString([]string{"get", "task"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"get", "task-resource-attribute"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"get", "workflow"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"get", "workflow-execution-config"})] = []string{} + // register + commandFlagMap[sliceToString([]string{"register", "examples"})] = []string{} + commandFlagMap[sliceToString([]string{"register", "files"})] = []string{} + // sandbox + commandFlagMap[sliceToString([]string{"sandbox", "exec"})] = []string{} //? + commandFlagMap[sliceToString([]string{"sandbox", "start"})] = []string{} + commandFlagMap[sliceToString([]string{"sandbox", "status"})] = []string{} + commandFlagMap[sliceToString([]string{"sandbox", "teardown"})] = []string{} + // update + commandFlagMap[sliceToString([]string{"update", "cluster-resource-attribute"})] = []string{"--attrFile"} //? + commandFlagMap[sliceToString([]string{"update", "execution"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"update", "execution-cluster-label"})] = []string{"--attrFile"} //? + commandFlagMap[sliceToString([]string{"update", "execution-queue-attribute"})] = []string{"--attrFile"} //? + commandFlagMap[sliceToString([]string{"update", "launchplan"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"update", "launchplan-meta"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"update", "plugin-override"})] = []string{"--attrFile"} //? + commandFlagMap[sliceToString([]string{"update", "project"})] = []string{"--pid"} + commandFlagMap[sliceToString([]string{"update", "task-meta"})] = []string{"-p", "-d"} + commandFlagMap[sliceToString([]string{"update", "task-resource-attribute"})] = []string{"--attrFile"} + commandFlagMap[sliceToString([]string{"update", "workflow-execution-config"})] = []string{"--attrFile"} + commandFlagMap[sliceToString([]string{"update", "workflow-meta"})] = []string{"-p", "-d"} + // upgrade + commandFlagMap[sliceToString([]string{"upgrade"})] = []string{} + // version + commandFlagMap[sliceToString([]string{"version"})] = []string{} +} + +// sliceToString converts a slice of strings to a string representation +func sliceToString(slice []string) string { + return strings.Join(slice, "|") +}