Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ go.work

/harbor
dist/
demo/
/dagger.gen.go
/internal/*
53 changes: 2 additions & 51 deletions cmd/harbor/root/robot/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions cmd/harbor/root/robot/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
95 changes: 95 additions & 0 deletions pkg/prompt/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
131 changes: 131 additions & 0 deletions pkg/views/base/listselect/model.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Comment on lines +110 to +114
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iterating over a map in Go produces non-deterministic order. This means the items in m.Choices will be added in random order, which could lead to inconsistent behavior across runs. Consider sorting the indices before iterating, or maintain the selection order in a separate slice to preserve the order in which users selected items.

Copilot uses AI. Check for mistakes.
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()
}
14 changes: 0 additions & 14 deletions pkg/views/base/multiselect/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -99,18 +97,13 @@ 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
} else {
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())
Expand Down Expand Up @@ -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,
Expand Down
Loading