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/* diff --git a/cmd/harbor/root/robot/create.go b/cmd/harbor/root/robot/create.go index 7d4bd5500..52dec96da 100644 --- a/cmd/harbor/root/robot/create.go +++ b/cmd/harbor/root/robot/create.go @@ -19,7 +19,6 @@ import ( "os" "github.com/atotto/clipboard" - "github.com/charmbracelet/huh" "github.com/goharbor/go-client/pkg/sdk/v2.0/models" "github.com/goharbor/harbor-cli/pkg/api" config "github.com/goharbor/harbor-cli/pkg/config/robot" @@ -209,7 +208,7 @@ func getSystemPermissions(all bool, permissions *[]models.Permission) error { } func getProjectPermissions(opts *create.CreateView, projectPermissionsMap map[string][]models.Permission) error { - permissionMode, err := promptPermissionMode() + permissionMode, err := prompt.PromptPermissionMode() if err != nil { return fmt.Errorf("error selecting permission mode: %v", err) } @@ -228,7 +227,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 +262,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) } @@ -379,74 +378,3 @@ 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( - huh.NewGroup( - huh.NewNote(). - Title("Permission Mode"). - Description("Select how you want to assign permissions to projects:"), - huh.NewSelect[string](). - Title("Permission Mode"). - Description("Choose 'List' to select multiple projects with common permissions, or 'Per Project' for individual project permissions."). - Options( - huh.NewOption("No project permissions (system-level only)", "none"), - huh.NewOption("Per Project", "per_project"), - huh.NewOption("List", "list"), - ). - Value(&permissionMode), - ), - ).WithTheme(huh.ThemeCharm()).WithWidth(60).WithHeight(10).Run() - - return permissionMode, err -} 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..c7a912a53 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -18,7 +18,9 @@ import ( "fmt" "strconv" + "github.com/charmbracelet/huh" "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/base/selection" list "github.com/goharbor/harbor-cli/pkg/views/context/switch" "github.com/goharbor/go-client/pkg/sdk/v2.0/models" @@ -30,6 +32,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 +95,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 +168,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) @@ -424,3 +520,18 @@ func GetRoleIDFromUser() int64 { return <-roleID } + +func PromptPermissionMode() (string, error) { + options := []string{ + "none", + "per_project", + "list", + } + permissionMode, err := selection.SelectFromOptionsListString( + options, + "Permission Mode", "Choose 'list' to select multiple projects with common \npermissions, or 'per_project' for individual project \npermissions or 'none' for no project permissions (system-level only).") + if err != nil { + return "", err + } + return permissionMode, err +} diff --git a/pkg/views/base/listselect/model.go b/pkg/views/base/listselect/model.go new file mode 100644 index 000000000..eaada5643 --- /dev/null +++ b/pkg/views/base/listselect/model.go @@ -0,0 +1,158 @@ +// 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/key" + "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(true) + l.SetFilteringEnabled(true) + l.SetShowHelp(true) + l.Styles.Title = views.TitleStyle + l.Styles.PaginationStyle = views.PaginationStyle + l.Styles.HelpStyle = views.HelpStyle + + toggleKey := key.NewBinding( + key.WithKeys(" "), + key.WithHelp("space", "toggle"), + ) + confirmKey := key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ) + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{toggleKey, confirmKey} + } + l.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{toggleKey, confirmKey} + } + + 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": + 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)) + } + } + return m, tea.Quit + case "ctrl+c", "esc": + m.Aborted = true + return m, tea.Quit + case "up", "k": + m.List.CursorUp() + return m, nil + case "down", "j": + m.List.CursorDown() + return m, nil + } + } + + 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 deleted file mode 100644 index 412e6840c..000000000 --- a/pkg/views/base/multiselect/model.go +++ /dev/null @@ -1,207 +0,0 @@ -// 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 multiselect - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/goharbor/go-client/pkg/sdk/v2.0/models" -) - -const useHighPerformanceRenderer = false - -var ( - titleStyle = func() lipgloss.Style { - b := lipgloss.RoundedBorder() - b.Right = "├" - return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) - }() - - selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("43")) - itemStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("46")) - blockStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("81")). - Foreground(lipgloss.Color("#000000")). - Bold(true). - Padding(0, 1, 0) - - infoStyle = func() lipgloss.Style { - b := lipgloss.RoundedBorder() - b.Left = "┤" - return titleStyle.BorderStyle(b) - }() -) - -type Model struct { - ready bool - viewport viewport.Model - choices []models.Permission - cursor int - selected map[int]struct{} - selects *[]models.Permission -} - -func (m Model) Init() tea.Cmd { - return nil -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var ( - cmd tea.Cmd - cmds []tea.Cmd - ) - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "q", "esc": - return m, tea.Quit - case "y": - m.GetSelectedPermissions() - return m, tea.Quit - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - case "down", "j": - if m.cursor < len(m.choices)-1 { - m.cursor++ - } - case "enter", " ": - _, ok := m.selected[m.cursor] - if ok { - delete(m.selected, m.cursor) - } else { - m.selected[m.cursor] = struct{}{} - } - } - - case tea.WindowSizeMsg: - headerHeight := lipgloss.Height(m.headerView()) - footerHeight := lipgloss.Height(m.footerView()) - verticalMarginHeight := headerHeight + footerHeight - - 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()) - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m Model) View() string { - if !m.ready { - return "\n Initializing..." - } - return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView()) -} - -func (m Model) headerView() string { - title := titleStyle.Render("Select Permissions for Robot Account") - line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) - return lipgloss.JoinHorizontal(lipgloss.Center, title, line) -} - -func (m Model) footerView() string { - help := lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Render( - fmt.Sprint( - " up/down: navigate • ", "enter: select permissions • ", "q: quit • ", " y: confirm\t", - ), - ) - info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) - line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info)-lipgloss.Width(help))) - return lipgloss.JoinHorizontal(lipgloss.Center, help, line, info) -} - -func (m Model) listView() string { - s := "Select Robot Permissions\n\n" - var prev string - for i, choice := range m.choices { - // Render the row ith appropriate action message - choiceRes := choice.Resource - choiceAct := choice.Action - now := choice.Resource - if prev != now { - prev = now - s += blockStyle.Render(prev) - s += "\n\n" - } - cursor := " " // no cursor - if m.cursor == i { - choiceRes = itemStyle.Render(choice.Resource) - choiceAct = itemStyle.Render(choice.Action) - cursor = ">" // cursor! - } - checked := " " // not selected - if _, ok := m.selected[i]; ok { - choiceRes = selectedStyle.Render(choice.Resource) - choiceAct = selectedStyle.Render(choice.Action) - checked = "x" // selected! - } - s += fmt.Sprintf( - "%s [%s] %s %s\n\n", - cursor, - checked, - choiceAct, - choiceRes, - ) - } - s += "\nPress q to quit.\n" - - return s -} - -func (m Model) GetSelectedPermissions() *[]models.Permission { - selectedPermissions := make([]models.Permission, 0, len(m.selected)) - for index := range m.selected { - selectedPermissions = append(selectedPermissions, m.choices[index]) - } - *m.selects = selectedPermissions - 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, - selected: make(map[int]struct{}), - selects: selects, - } -} diff --git a/pkg/views/base/selection/model.go b/pkg/views/base/selection/model.go index bdf978895..c581d585e 100644 --- a/pkg/views/base/selection/model.go +++ b/pkg/views/base/selection/model.go @@ -20,6 +20,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/goharbor/harbor-cli/pkg/views" ) @@ -58,7 +59,7 @@ type Model struct { Aborted bool } -func NewModel(items []list.Item, construct string) Model { +func NewModel(items []list.Item, construct string, description ...string) Model { const defaultWidth = 20 l := list.New(items, ItemDelegate{}, defaultWidth, listHeight) l.Title = "Select a " + construct @@ -67,7 +68,10 @@ func NewModel(items []list.Item, construct string) Model { l.Styles.Title = views.TitleStyle l.Styles.PaginationStyle = views.PaginationStyle l.Styles.HelpStyle = views.HelpStyle - + if len(description) > 0 { + subtitle := lipgloss.NewStyle().Faint(true).Render(strings.Join(description, " ")) + l.Title = l.Title + "\n" + subtitle + } return Model{List: l} } diff --git a/pkg/views/base/selection/view.go b/pkg/views/base/selection/view.go new file mode 100644 index 000000000..77acf7fb7 --- /dev/null +++ b/pkg/views/base/selection/view.go @@ -0,0 +1,42 @@ +// 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 selection + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +func SelectFromOptionsListString(options []string, selectionType string, description ...string) (string, error) { + items := make([]list.Item, len(options)) + + for i, p := range options { + items[i] = Item(p) + } + + m := NewModel(items, selectionType, description...) + + p, err := tea.NewProgram(m).Run() + if err != nil { + return "", fmt.Errorf("error running program: %v", err) + } + + if p, ok := p.(Model); ok { + return p.Choice, nil + } else { + return "", fmt.Errorf("failed to get selection") + } +} diff --git a/pkg/views/base/tablegrid/model.go b/pkg/views/base/tablegrid/model.go index baa126190..b1088e802 100644 --- a/pkg/views/base/tablegrid/model.go +++ b/pkg/views/base/tablegrid/model.go @@ -16,11 +16,66 @@ package tablegrid import ( "fmt" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) +// KeyMap defines all key bindings for TableGrid and implements help.KeyMap. +type KeyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Toggle key.Binding + RowOn key.Binding + RowOff key.Binding + ColOn key.Binding + ColOff key.Binding + TableOn key.Binding + TableOff key.Binding + Submit key.Binding + Quit key.Binding + Help key.Binding +} + +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Left, k.Right, + k.Toggle, k.TableOn, + k.Submit, k.Quit, k.Help} +} + +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Left, k.Right}, + {k.Toggle, k.RowOn, k.RowOff}, + {k.ColOn, k.ColOff, k.TableOn, k.TableOff}, + {k.Submit, k.Quit}, + } +} + +// DefaultKeyMap returns the default key bindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "move up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "move down")), + Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "move left")), + Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "move right")), + Toggle: key.NewBinding(key.WithKeys(" ", "enter"), key.WithHelp("space/enter", "toggle cell")), + RowOn: key.NewBinding(key.WithKeys("ctrl+j"), key.WithHelp("^J", "row on")), + RowOff: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("^K", "row off")), + ColOn: key.NewBinding(key.WithKeys("ctrl+h"), key.WithHelp("^H", "col on")), + ColOff: key.NewBinding(key.WithKeys("ctrl+l"), key.WithHelp("^L", "col off")), + TableOn: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("^A", "all on")), + TableOff: key.NewBinding(key.WithKeys("ctrl+d"), key.WithHelp("^D", "all off")), + Submit: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("^S", "submit")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "See For How to Toggle")), + } +} + // CellStatus represents a cell's toggle state type CellStatus bool @@ -35,6 +90,8 @@ type TableGrid struct { Styles Styles // Custom styles Icons Icons // Custom icons Footer string // Custom footer text + Help help.Model + Keys KeyMap } // Styles contains customizable styles for the table grid @@ -154,6 +211,8 @@ func New(config Config) *TableGrid { Styles: styles, Icons: icons, Footer: config.Footer, + Help: help.New(), + Keys: DefaultKeyMap(), } } @@ -375,6 +434,9 @@ func (m *TableGrid) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshTable(rowIdx, colIdx) return m, nil + case "?": + m.Help.ShowAll = !m.Help.ShowAll + return m, nil case "q", "ctrl+c": return m, tea.Quit } @@ -396,10 +458,7 @@ func (m *TableGrid) View() string { m.refreshTable(cursor, m.SelectedCol) out := m.Table.View() - footer := "\n ↑/↓ move row • ⌃J toggle row on • ⌃H toggle col on • ^A toggle table on • space/enter to toggle\n" + - " ←/→ move col • ⌃K toggle row off • ⌃L toggle col off • ^D toggle table off • ^S submit • q to cancel \n" - - return out + footer + return out + "\n" + m.Help.View(m.Keys) } // GetData returns the current selection state diff --git a/pkg/views/project/listselect/view.go b/pkg/views/project/listselect/view.go new file mode 100644 index 000000000..53e88975c --- /dev/null +++ b/pkg/views/project/listselect/view.go @@ -0,0 +1,86 @@ +// 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" + + "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, 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") + } + 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") +}