From 05ef593209ded5cfe4d4d7e237c56e3b86e54e7c Mon Sep 17 00:00:00 2001 From: Patrick Eschenbach Date: Mon, 16 Feb 2026 19:55:45 +0100 Subject: [PATCH 1/3] refactor(robot): introduce listselect model for project prompts - add prompts for project names and IDs - move functions to prompt package - make list select reusable - refactor system robot create and update to use new model Signed-off-by: Patrick Eschenbach --- cmd/harbor/root/robot/create.go | 53 +---------- cmd/harbor/root/robot/update.go | 4 +- pkg/prompt/prompt.go | 95 +++++++++++++++++++ pkg/views/base/listselect/model.go | 131 +++++++++++++++++++++++++++ pkg/views/base/multiselect/model.go | 14 --- pkg/views/project/listselect/view.go | 87 ++++++++++++++++++ 6 files changed, 317 insertions(+), 67 deletions(-) create mode 100644 pkg/views/base/listselect/model.go create mode 100644 pkg/views/project/listselect/view.go diff --git a/cmd/harbor/root/robot/create.go b/cmd/harbor/root/robot/create.go index 7d4bd5500..b6762712e 100644 --- a/cmd/harbor/root/robot/create.go +++ b/cmd/harbor/root/robot/create.go @@ -228,7 +228,7 @@ func getProjectPermissions(opts *create.CreateView, projectPermissionsMap map[st } func handleMultipleProjectsPermissions(projectPermissionsMap map[string][]models.Permission) error { - selectedProjects, err := getMultipleProjectsFromUser() + selectedProjects, err := prompt.GetProjectNamesFromUser() if err != nil { return fmt.Errorf("error selecting projects: %v", err) } @@ -263,7 +263,7 @@ func handlePerProjectPermissions(opts *create.CreateView, projectPermissionsMap return fmt.Errorf("failed to get permissions: %v", utils.ParseHarborErrorMsg(err)) } - moreProjects, err := promptMoreProjects() + moreProjects, err := prompt.PromptForMoreProjects() if err != nil { return fmt.Errorf("error asking for more projects: %v", err) } @@ -380,55 +380,6 @@ func exportSecretToFile(name, secret, creationTime string, expiresAt int64) { fmt.Printf("Secret saved to %s\n", filename) } -func getMultipleProjectsFromUser() ([]string, error) { - allProjects, err := api.ListAllProjects() - if err != nil { - return nil, fmt.Errorf("failed to list projects: %v", err) - } - - var selectedProjects []string - var projectOptions []huh.Option[string] - - for _, p := range allProjects.Payload { - projectOptions = append(projectOptions, huh.NewOption(p.Name, p.Name)) - } - - err = huh.NewForm( - huh.NewGroup( - huh.NewNote(). - Title("Multiple Project Selection"). - Description("Select the projects to assign the same permissions to this robot account."), - huh.NewMultiSelect[string](). - Title("Select projects"). - Options(projectOptions...). - Value(&selectedProjects), - ), - ).WithTheme(huh.ThemeCharm()).WithWidth(80).Run() - - return selectedProjects, err -} - -func promptMoreProjects() (bool, error) { - var addMore bool - err := huh.NewForm( - huh.NewGroup( - huh.NewNote(). - Title("Project Selection"). - Description("You can add permissions for multiple projects to this robot account."), - huh.NewSelect[bool](). - Title("Do you want to select (more) projects?"). - Description("Select 'Yes' to add (another) project, 'No' to continue with current selection."). - Options( - huh.NewOption("No", false), - huh.NewOption("Yes", true), - ). - Value(&addMore), - ), - ).WithTheme(huh.ThemeCharm()).WithWidth(60).WithHeight(10).Run() - - return addMore, err -} - func promptPermissionMode() (string, error) { var permissionMode string err := huh.NewForm( diff --git a/cmd/harbor/root/robot/update.go b/cmd/harbor/root/robot/update.go index 5d2345f11..f55876e50 100644 --- a/cmd/harbor/root/robot/update.go +++ b/cmd/harbor/root/robot/update.go @@ -373,7 +373,7 @@ func handleMultipleProjectsPermissionsForUpdate(projectPermissionsMap map[string } } - selectedProjects, err := getMultipleProjectsFromUser() + selectedProjects, err := prompt.GetProjectNamesFromUser() if err != nil { return fmt.Errorf("error selecting projects: %v", err) } @@ -496,7 +496,7 @@ func handlePerProjectPermissionsForUpdate(projectPermissionsMap map[string][]mod projectPermissionsMap[projectName] = validProjectPerms - moreProjects, err := promptMoreProjects() + moreProjects, err := prompt.PromptForMoreProjects() if err != nil { return fmt.Errorf("error asking for more projects: %v", err) } diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go index 7b3e20b46..603624878 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -18,6 +18,7 @@ import ( "fmt" "strconv" + "github.com/charmbracelet/huh" "github.com/goharbor/harbor-cli/pkg/utils" list "github.com/goharbor/harbor-cli/pkg/views/context/switch" @@ -30,6 +31,7 @@ import ( instview "github.com/goharbor/harbor-cli/pkg/views/instance/select" lview "github.com/goharbor/harbor-cli/pkg/views/label/select" mview "github.com/goharbor/harbor-cli/pkg/views/member/select" + plistselect "github.com/goharbor/harbor-cli/pkg/views/project/listselect" pview "github.com/goharbor/harbor-cli/pkg/views/project/select" qview "github.com/goharbor/harbor-cli/pkg/views/quota/select" rview "github.com/goharbor/harbor-cli/pkg/views/registry/select" @@ -92,6 +94,43 @@ func GetProjectIDFromUser() (int64, error) { return res.id, res.err } +func GetProjectIDsFromUser() ([]int64, error) { + type result struct { + ids []int64 + err error + } + resultChan := make(chan result) + + go func() { + response, err := api.ListAllProjects() + if err != nil { + resultChan <- result{nil, err} + return + } + + if len(response.Payload) == 0 { + resultChan <- result{nil, errors.New("no projects found")} + return + } + + ids, err := plistselect.ProjectsListWithId(response.Payload) + if err != nil { + if err == plistselect.ErrUserAborted { + resultChan <- result{nil, errors.New("user aborted project selection")} + } else { + resultChan <- result{nil, fmt.Errorf("error during project selection: %w", err)} + } + return + } + + resultChan <- result{ids, nil} + }() + + res := <-resultChan + + return res.ids, res.err +} + func GetProjectNameFromUser() (string, error) { type result struct { name string @@ -128,6 +167,62 @@ func GetProjectNameFromUser() (string, error) { return res.name, res.err } +func GetProjectNamesFromUser() ([]string, error) { + type result struct { + names []string + err error + } + resultChan := make(chan result) + + go func() { + response, err := api.ListAllProjects() + if err != nil { + resultChan <- result{nil, err} + return + } + + if len(response.Payload) == 0 { + resultChan <- result{nil, errors.New("no projects found")} + return + } + + names, err := plistselect.ProjectsList(response.Payload) + if err != nil { + if err == plistselect.ErrUserAborted { + resultChan <- result{nil, errors.New("user aborted project selection")} + } else { + resultChan <- result{nil, fmt.Errorf("error during project selection: %w", err)} + } + return + } + + resultChan <- result{names, nil} + }() + + res := <-resultChan + return res.names, res.err +} + +func PromptForMoreProjects() (bool, error) { + var addMore bool + err := huh.NewForm( + huh.NewGroup( + huh.NewNote(). + Title("Project Selection"). + Description("You can add permissions for multiple projects to this robot account."), + huh.NewSelect[bool](). + Title("Would you like to add another project?"). + Description("Select 'Yes' to add a project, 'No' to continue with your current selection."). + Options( + huh.NewOption("No", false), + huh.NewOption("Yes", true), + ). + Value(&addMore), + ), + ).WithTheme(huh.ThemeCharm()).WithWidth(60).WithHeight(10).Run() + return addMore, err +} + // GetRoleNameFromUser prompts the user to select a role and returns it. func GetRoleNameFromUser() int64 { roleChan := make(chan int64) diff --git a/pkg/views/base/listselect/model.go b/pkg/views/base/listselect/model.go new file mode 100644 index 000000000..7eba91897 --- /dev/null +++ b/pkg/views/base/listselect/model.go @@ -0,0 +1,131 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package listselect + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/harbor-cli/pkg/views" +) + +const listHeight = 14 + +type Item string + +func (i Item) FilterValue() string { return string(i) } + +type ItemDelegate struct { + Selected *map[int]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 + } + + checked := " " + if d.Selected != nil { + if _, ok := (*d.Selected)[index]; ok { + checked = "✓" + } + } + + str := fmt.Sprintf("[%s] %d. %s", checked, index+1, i) + + fn := views.ItemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return views.SelectedItemStyle.Render("> " + strings.Join(s, " ")) + } + } + + fmt.Fprint(w, fn(str)) +} + +type Model struct { + List list.Model + Choices []string + Selected map[int]struct{} + Aborted bool +} + +func NewModel(items []list.Item, construct string) Model { + const defaultWidth = 20 + selected := make(map[int]struct{}) + l := list.New(items, ItemDelegate{Selected: &selected}, defaultWidth, listHeight) + l.Title = "Select one or more " + construct + " (space to toggle, enter to confirm)" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.Styles.Title = views.TitleStyle + l.Styles.PaginationStyle = views.PaginationStyle + l.Styles.HelpStyle = views.HelpStyle + + return Model{List: l, Selected: selected} +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) 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 keypress := msg.String(); keypress { + case " ": + idx := m.List.Index() + if _, ok := m.Selected[idx]; ok { + delete(m.Selected, idx) + } else { + m.Selected[idx] = struct{}{} + } + return m, nil + case "enter": + for idx := range m.Selected { + if i, ok := m.List.Items()[idx].(Item); ok { + m.Choices = append(m.Choices, string(i)) + } + } + return m, tea.Quit + case "ctrl+c", "esc": + m.Aborted = true + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.List, cmd = m.List.Update(msg) + return m, cmd +} + +func (m Model) View() string { + if m.Aborted { + return "" + } + if len(m.Choices) > 0 { + return fmt.Sprintf("Selected: %s\n", strings.Join(m.Choices, ", ")) + } + return "\n" + m.List.View() +} diff --git a/pkg/views/base/multiselect/model.go b/pkg/views/base/multiselect/model.go index 412e6840c..811d133a2 100644 --- a/pkg/views/base/multiselect/model.go +++ b/pkg/views/base/multiselect/model.go @@ -23,8 +23,6 @@ import ( "github.com/goharbor/go-client/pkg/sdk/v2.0/models" ) -const useHighPerformanceRenderer = false - var ( titleStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() @@ -99,7 +97,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.ready { m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) m.viewport.YPosition = headerHeight - m.viewport.HighPerformanceRendering = useHighPerformanceRenderer m.viewport.SetContent(m.listView()) m.ready = true m.viewport.YPosition = headerHeight - 1 @@ -107,10 +104,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.Width = msg.Width m.viewport.Height = msg.Height - verticalMarginHeight - 1 } - - if useHighPerformanceRenderer { - cmds = append(cmds, viewport.Sync(m.viewport)) - } } m.viewport.SetContent(m.listView()) @@ -191,13 +184,6 @@ func (m Model) GetSelectedPermissions() *[]models.Permission { return m.selects } -func max(a, b int) int { - if a > b { - return a - } - return b -} - func NewModel(choices []models.Permission, selects *[]models.Permission) Model { return Model{ choices: choices, diff --git a/pkg/views/project/listselect/view.go b/pkg/views/project/listselect/view.go new file mode 100644 index 000000000..9fa2b8fb1 --- /dev/null +++ b/pkg/views/project/listselect/view.go @@ -0,0 +1,87 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package project + +import ( + "errors" + "fmt" + "os" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/views/base/listselect" +) + +var ErrUserAborted = errors.New("user aborted selection") + +func ProjectsList(projects []*models.Project) ([]string, error) { + items := make([]list.Item, len(projects)) + for i, p := range projects { + items[i] = listselect.Item(p.Name) + } + + m := listselect.NewModel(items, "Project") + + p, err := tea.NewProgram(m).Run() + if err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + + if model, ok := p.(listselect.Model); ok { + if model.Aborted { + return nil, ErrUserAborted + } + if len(model.Choices) == 0 { + return nil, errors.New("no project selected") + } + return model.Choices, nil + } + + return nil, errors.New("unexpected program result") +} + +func ProjectsListWithId(projects []*models.Project) ([]int64, error) { + items := make([]list.Item, len(projects)) + itemsMap := make(map[string]int64) + + for i, p := range projects { + items[i] = listselect.Item(p.Name) + itemsMap[p.Name] = int64(p.ProjectID) + } + + m := listselect.NewModel(items, "Project") + + p, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + return nil, fmt.Errorf("error running selection program: %w", err) + } + + if model, ok := p.(listselect.Model); ok { + if model.Aborted { + return nil, ErrUserAborted + } + if len(model.Choices) == 0 { + return nil, errors.New("no project selected") + } + var selectedIDs []int64 + for _, choice := range model.Choices { + selectedIDs = append(selectedIDs, itemsMap[choice]) + } + return selectedIDs, nil + } + + return nil, errors.New("unexpected program result") +} From a7228650a4052b1867f72734e2612d5255f5c414 Mon Sep 17 00:00:00 2001 From: Patrick Eschenbach Date: Mon, 16 Feb 2026 20:06:45 +0100 Subject: [PATCH 2/3] fix(demo): add demo dir to gitignore Signed-off-by: Patrick Eschenbach --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4e00192ec..67d7851a6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,6 @@ go.work /harbor dist/ +demo/ /dagger.gen.go /internal/* From 577d468109440e65622784f3576ed1b23be9ed87 Mon Sep 17 00:00:00 2001 From: Patrick Eschenbach Date: Tue, 17 Feb 2026 19:10:47 +0100 Subject: [PATCH 3/3] chore: apply copilot suggestions - run tea with altscreen - return error instead of os.Exit - print warning if list selection is empty Signed-off-by: Patrick Eschenbach --- pkg/views/base/listselect/model.go | 6 +++++- pkg/views/project/listselect/view.go | 7 +++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pkg/views/base/listselect/model.go b/pkg/views/base/listselect/model.go index 7eba91897..2dc899562 100644 --- a/pkg/views/base/listselect/model.go +++ b/pkg/views/base/listselect/model.go @@ -73,7 +73,7 @@ func NewModel(items []list.Item, construct string) Model { selected := make(map[int]struct{}) l := list.New(items, ItemDelegate{Selected: &selected}, defaultWidth, listHeight) l.Title = "Select one or more " + construct + " (space to toggle, enter to confirm)" - l.SetShowStatusBar(false) + l.SetShowStatusBar(true) l.SetFilteringEnabled(true) l.Styles.Title = views.TitleStyle l.Styles.PaginationStyle = views.PaginationStyle @@ -103,6 +103,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case "enter": + if len(m.Selected) == 0 { + cmd := m.List.NewStatusMessage("!! Please select at least one item !!") + return m, cmd + } for idx := range m.Selected { if i, ok := m.List.Items()[idx].(Item); ok { m.Choices = append(m.Choices, string(i)) diff --git a/pkg/views/project/listselect/view.go b/pkg/views/project/listselect/view.go index 9fa2b8fb1..53e88975c 100644 --- a/pkg/views/project/listselect/view.go +++ b/pkg/views/project/listselect/view.go @@ -16,7 +16,6 @@ package project import ( "errors" "fmt" - "os" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -34,10 +33,10 @@ func ProjectsList(projects []*models.Project) ([]string, error) { m := listselect.NewModel(items, "Project") - p, err := tea.NewProgram(m).Run() + p, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { - fmt.Println("Error running program:", err) - os.Exit(1) + return nil, fmt.Errorf("error running selection program: %w", err) } if model, ok := p.(listselect.Model); ok {