From 851b1ba8cebc620abe4d159609914f189602b526 Mon Sep 17 00:00:00 2001 From: Ivan Dagelic Date: Wed, 24 Apr 2024 01:14:16 +0200 Subject: [PATCH] feat: advanced project configuration in cli Signed-off-by: Ivan Dagelic --- pkg/cmd/workspace/util/creation_data.go | 40 +++-- pkg/views/workspace/create/configuration.go | 66 ++++++++ pkg/views/workspace/create/summary.go | 34 +++- pkg/views/workspace/create/view.go | 49 ------ pkg/views/workspace/create/workspacedata.go | 157 ++++++++++++++++++ .../workspace/selection/projectrequest.go | 71 ++++++++ 6 files changed, 344 insertions(+), 73 deletions(-) create mode 100644 pkg/views/workspace/create/configuration.go create mode 100644 pkg/views/workspace/create/workspacedata.go create mode 100644 pkg/views/workspace/selection/projectrequest.go diff --git a/pkg/cmd/workspace/util/creation_data.go b/pkg/cmd/workspace/util/creation_data.go index a42f18a05b..562bfbdadf 100644 --- a/pkg/cmd/workspace/util/creation_data.go +++ b/pkg/cmd/workspace/util/creation_data.go @@ -18,7 +18,10 @@ func GetCreationDataFromPrompt(workspaceNames []string, userGitProviders []serve var providerRepo serverapiclient.GitRepository var providerRepoUrl string var err error - var confirmCheck bool + var workspaceName string + var primaryContainerImageUrl string + var primaryOsUser string + doneCheck := true if !manual && userGitProviders != nil && len(userGitProviders) > 0 { providerRepo, err = getRepositoryFromWizard(userGitProviders, 0) @@ -69,17 +72,6 @@ func GetCreationDataFromPrompt(workspaceNames []string, userGitProviders []serve projectList = append(projectList, workspaceCreationPromptResponse.SecondaryProjects...) } - suggestedName := create.GetSuggestedWorkspaceName(*workspaceCreationPromptResponse.PrimaryProject.Source.Repository.Url) - - workspaceCreationPromptResponse, err = create.RunWorkspaceNameForm(workspaceCreationPromptResponse, suggestedName, workspaceNames) - if err != nil { - return "", nil, err - } - - if workspaceCreationPromptResponse.WorkspaceName == "" { - return "", nil, errors.New("workspace name is required") - } - for i, project := range projectList { if project.Source == nil || project.Source.Repository == nil || project.Source.Repository.Url == nil { return "", nil, errors.New("repository is required") @@ -88,14 +80,30 @@ func GetCreationDataFromPrompt(workspaceNames []string, userGitProviders []serve projectList[i].Name = projectName } - if len(projectList) > 1 { - create.DisplaySummaryView(workspaceCreationPromptResponse.WorkspaceName, projectList, &confirmCheck) - if !confirmCheck { + suggestedName := create.GetSuggestedWorkspaceName(*workspaceCreationPromptResponse.PrimaryProject.Source.Repository.Url) + + workspaceName, primaryContainerImageUrl, primaryOsUser = create.GetWorkspaceDataFromPrompt(suggestedName, workspaceNames, !multiProject) + + if workspaceName == "" { + return "", nil, errors.New("workspace name is required") + } + + if primaryContainerImageUrl != "" { + projectList[0].Image = &primaryContainerImageUrl + } + + if primaryOsUser != "" { + projectList[0].User = &primaryOsUser + } + + if multiProject { + create.DisplayMultiSubmitForm(workspaceName, &projectList, &doneCheck) + if !doneCheck { return "", nil, errors.New("operation cancelled") } } - return workspaceCreationPromptResponse.WorkspaceName, projectList, nil + return workspaceName, projectList, nil } func GetProjectNameFromRepo(repoUrl string) string { diff --git a/pkg/views/workspace/create/configuration.go b/pkg/views/workspace/create/configuration.go new file mode 100644 index 0000000000..a38369840b --- /dev/null +++ b/pkg/views/workspace/create/configuration.go @@ -0,0 +1,66 @@ +// Copyright 2024 Daytona Platforms Inc. +// SPDX-License-Identifier: Apache-2.0 + +package create + +import ( + "errors" + "log" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + "github.com/daytonaio/daytona/pkg/serverapiclient" + "github.com/daytonaio/daytona/pkg/views" + selection "github.com/daytonaio/daytona/pkg/views/workspace/selection" +) + +var configurationHelpLine = lipgloss.NewStyle().Foreground(views.Gray).Render("enter: next f10: configuration view") + +func ConfigureProjects(projectList []serverapiclient.CreateWorkspaceRequestProject) ([]serverapiclient.CreateWorkspaceRequestProject, error) { + var containerImageUrl string + var osUser string + var doneCheck bool + + projectName := selection.GetProjectRequestFromPrompt(projectList) + if projectName == "" { + return projectList, errors.New("project ID is required") + } + + GetProjectConfigurationGroup(&containerImageUrl, &osUser) + + form := huh.NewForm( + GetProjectConfigurationGroup(&containerImageUrl, &osUser), + GetDoneCheckGroup(&doneCheck), + ).WithTheme(views.GetCustomTheme()) + + err := form.Run() + if err != nil { + log.Fatal(err) + } + + for i := range projectList { + if projectList[i].Name == projectName { + projectList[i].Image = &containerImageUrl + projectList[i].User = &osUser + } + } + + if !doneCheck { + projectList, err = ConfigureProjects(projectList) + if err != nil { + return projectList, err + } + } + + return projectList, nil +} + +func GetDoneCheckGroup(doneCheck *bool) *huh.Group { + group := huh.NewGroup( + huh.NewConfirm(). + Title("Done configuring projects?"). + Value(doneCheck), + ) + + return group +} diff --git a/pkg/views/workspace/create/summary.go b/pkg/views/workspace/create/summary.go index 4009eb07b6..0996b55bb5 100644 --- a/pkg/views/workspace/create/summary.go +++ b/pkg/views/workspace/create/summary.go @@ -26,13 +26,26 @@ type SummaryModel struct { projectList []serverapiclient.CreateWorkspaceRequestProject } -func DisplaySummaryView(workspaceName string, projectList []serverapiclient.CreateWorkspaceRequestProject, confirmCheck *bool) { - m := NewSummaryModel(workspaceName, projectList, confirmCheck) +var configureCheck bool + +func DisplayMultiSubmitForm(workspaceName string, projectList *[]serverapiclient.CreateWorkspaceRequestProject, doneCheck *bool) { + m := NewSummaryModel(workspaceName, *projectList, doneCheck) if _, err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } + + if !configureCheck { + return + } + + configuredProjects, err := ConfigureProjects(*projectList) + if err != nil { + log.Fatal(err) + } + + *projectList = configuredProjects } func RenderSummary(workspaceName string, projectList []serverapiclient.CreateWorkspaceRequestProject) string { @@ -64,7 +77,7 @@ func RenderSummary(workspaceName string, projectList []serverapiclient.CreateWor return output } -func NewSummaryModel(workspaceName string, projectList []serverapiclient.CreateWorkspaceRequestProject, confirmCheck *bool) SummaryModel { +func NewSummaryModel(workspaceName string, projectList []serverapiclient.CreateWorkspaceRequestProject, doneCheck *bool) SummaryModel { m := SummaryModel{width: maxWidth} m.lg = lipgloss.DefaultRenderer() m.styles = NewStyles(m.lg) @@ -76,9 +89,9 @@ func NewSummaryModel(workspaceName string, projectList []serverapiclient.CreateW huh.NewConfirm(). Title("Good to go?"). Negative("Abort"). - Value(confirmCheck), + Value(doneCheck), ), - ).WithTheme(views.GetCustomTheme()) + ).WithShowHelp(false).WithTheme(views.GetCustomTheme()) return m } @@ -94,14 +107,19 @@ func (m SummaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "q", "ctrl+c": m.quitting = true return m, tea.Quit + case "f10": + m.quitting = true + m.form.State = huh.StateCompleted + configureCheck = true + return m, tea.Quit } } var cmds []tea.Cmd // Process the form - activeForm, cmd := m.form.Update(msg) - if f, ok := activeForm.(*huh.Form); ok { + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { m.form = f cmds = append(cmds, cmd) } @@ -120,5 +138,5 @@ func (m SummaryModel) View() string { return "" } - return view_util.GetBorderedMessage(RenderSummary(m.workspaceName, m.projectList)) + "\n" + m.form.View() + return view_util.GetBorderedMessage(RenderSummary(m.workspaceName, m.projectList)) + "\n" + m.form.View() + configurationHelpLine } diff --git a/pkg/views/workspace/create/view.go b/pkg/views/workspace/create/view.go index d7571108f6..a24890239d 100644 --- a/pkg/views/workspace/create/view.go +++ b/pkg/views/workspace/create/view.go @@ -4,7 +4,6 @@ package create import ( - "errors" "fmt" "strings" "unicode" @@ -187,54 +186,6 @@ func RunProjectForm(workspaceCreationPromptResponse WorkspaceCreationPromptRespo return result, nil } -func RunWorkspaceNameForm(workspaceCreationPromptResponse WorkspaceCreationPromptResponse, suggestedName string, workspaceNames []string) (WorkspaceCreationPromptResponse, error) { - m := Model{width: maxWidth, workspaceCreationPromptResponse: workspaceCreationPromptResponse} - m.lg = lipgloss.DefaultRenderer() - m.styles = NewStyles(m.lg) - - workspaceName := suggestedName - - workspaceNamePrompt := - huh.NewInput(). - Title("Workspace name"). - Value(&workspaceName). - Key("workspaceName"). - Validate(func(str string) error { - result, err := util.GetValidatedWorkspaceName(str) - if err != nil { - return err - } - for _, name := range workspaceNames { - if name == result { - return errors.New("workspace name already exists") - } - } - workspaceName = result - return nil - }) - - dTheme := views.GetCustomTheme() - - m.form = huh.NewForm( - huh.NewGroup( - workspaceNamePrompt, - ), - ).WithTheme(dTheme). - WithWidth(maxWidth). - WithShowHelp(false). - WithShowErrors(true) - - err := m.form.Run() - if err != nil { - return WorkspaceCreationPromptResponse{}, err - } - - result := workspaceCreationPromptResponse - result.WorkspaceName = workspaceName - - return result, nil -} - func GetSuggestedWorkspaceName(repo string) string { var result strings.Builder input := repo diff --git a/pkg/views/workspace/create/workspacedata.go b/pkg/views/workspace/create/workspacedata.go new file mode 100644 index 0000000000..dfe1bd6912 --- /dev/null +++ b/pkg/views/workspace/create/workspacedata.go @@ -0,0 +1,157 @@ +// Copyright 2024 Daytona Platforms Inc. +// SPDX-License-Identifier: Apache-2.0 + +package create + +import ( + "errors" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + "github.com/daytonaio/daytona/internal/util" + "github.com/daytonaio/daytona/pkg/views" +) + +var doneCheck bool + +type WorkspaceDataModel struct { + lg *lipgloss.Renderer + styles *Styles + form *huh.Form + basicViewActive bool + width int + quitting bool + showConfigurationOption bool +} + +func GetWorkspaceDataFromPrompt(suggestedName string, workspaceNames []string, showConfigurationOption bool) (string, string, string) { + workspaceName, containerImageUrl, osUser := suggestedName, "", "" + + m := NewWorkspaceDataModel(workspaceNames, &workspaceName, &containerImageUrl, &osUser, showConfigurationOption) + + if _, err := tea.NewProgram(m).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + + if !doneCheck { + return "", "", "" + } + + return workspaceName, containerImageUrl, osUser +} + +func NewWorkspaceDataModel(workspaceNames []string, workspaceName *string, containerImageUrl *string, osUser *string, showConfigurationOption bool) WorkspaceDataModel { + m := WorkspaceDataModel{width: maxWidth, basicViewActive: true, showConfigurationOption: showConfigurationOption} + m.lg = lipgloss.DefaultRenderer() + m.styles = NewStyles(m.lg) + + workspaceNamePrompt := + huh.NewInput(). + Title("Workspace name"). + Value(workspaceName). + Key("workspaceName"). + Validate(func(str string) error { + result, err := util.GetValidatedWorkspaceName(str) + if err != nil { + return err + } + for _, name := range workspaceNames { + if name == result { + return errors.New("workspace name already exists") + } + } + *workspaceName = result + return nil + }) + + dTheme := views.GetCustomTheme() + + m.form = huh.NewForm( + huh.NewGroup( + workspaceNamePrompt, + ), + GetProjectConfigurationGroup(containerImageUrl, osUser), + ).WithTheme(dTheme). + WithWidth(maxWidth). + WithShowErrors(true).WithShowHelp(false) + + return m +} + +func GetProjectConfigurationGroup(imageUrl *string, osUser *string) *huh.Group { + group := huh.NewGroup( + huh.NewInput(). + Title("Custom container image URL"). + Value(imageUrl), + huh.NewInput(). + Title("Operating system username"). + Value(osUser), + ) + + return group +} + +func (m WorkspaceDataModel) Init() tea.Cmd { + return m.form.Init() +} + +func (m WorkspaceDataModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + case "enter": + if m.basicViewActive { + m.form.State = huh.StateCompleted + } + doneCheck = true + case "f10": + if !m.showConfigurationOption { + return m, nil + } + if m.basicViewActive { + m.form.NextGroup() + } else { + m.form.PrevGroup() + } + m.basicViewActive = !m.basicViewActive + } + } + + var cmds []tea.Cmd + + // Process the form + activeForm, cmd := m.form.Update(msg) + if f, ok := activeForm.(*huh.Form); ok { + m.form = f + cmds = append(cmds, cmd) + } + + if m.form.State == huh.StateCompleted { + // Quit when the form is done. + m.quitting = true + cmds = append(cmds, tea.Quit) + } + + return m, tea.Batch(cmds...) +} + +func (m WorkspaceDataModel) View() string { + if m.quitting { + return "" + } + + view := m.form.View() + + if m.showConfigurationOption { + view += configurationHelpLine + } + + return view +} diff --git a/pkg/views/workspace/selection/projectrequest.go b/pkg/views/workspace/selection/projectrequest.go new file mode 100644 index 0000000000..725ee37dff --- /dev/null +++ b/pkg/views/workspace/selection/projectrequest.go @@ -0,0 +1,71 @@ +// Copyright 2024 Daytona Platforms Inc. +// SPDX-License-Identifier: Apache-2.0 + +package selection + +import ( + "fmt" + "os" + + "github.com/daytonaio/daytona/pkg/serverapiclient" + "github.com/daytonaio/daytona/pkg/views" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +func selectProjectRequestPrompt(projects []serverapiclient.CreateWorkspaceRequestProject, choiceChan chan<- string) { + items := []list.Item{} + + for _, project := range projects { + var name string + if project.Name != "" { + name = project.Name + } + var image string + if project.Image != nil { + image = *project.Image + } + var user string + if project.User != nil { + user = *project.User + } + newItem := item[string]{id: name, title: name, choiceProperty: name} + if image != "" { + if user != "" { + newItem.desc = fmt.Sprintf("%s (%s)", image, user) + } else { + newItem.desc = fmt.Sprintf("Image: %s", image) + } + } else if user != "" { + newItem.desc = fmt.Sprintf("User: %s", user) + } else { + newItem.desc = "Default configuration" + } + items = append(items, newItem) + } + + l := views.GetStyledSelectList(items) + m := model[string]{list: l} + m.list.Title = "CHOOSE A PROJECT TO CONFIGURE" + + p, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + + if m, ok := p.(model[string]); ok && m.choice != nil { + choiceChan <- *m.choice + } else { + choiceChan <- "" + } +} + +func GetProjectRequestFromPrompt(projects []serverapiclient.CreateWorkspaceRequestProject) string { + choiceChan := make(chan string) + + go selectProjectRequestPrompt(projects, choiceChan) + + return <-choiceChan +}