1package selection23import (4 "fmt"5 "io"6 "sort"7 "strings"8 "time"910 "charm.land/bubbles/v2/key"11 "charm.land/bubbles/v2/list"12 tea "charm.land/bubbletea/v2"13 "charm.land/lipgloss/v2"14 "github.com/charmbracelet/soft-serve/pkg/proto"15 "github.com/charmbracelet/soft-serve/pkg/ui/common"16 "github.com/dustin/go-humanize"17)1819var _ sort.Interface = Items{}2021// Items is a list of Item.22type Items []Item2324// Len implements sort.Interface.25func (it Items) Len() int {26 return len(it)27}2829// Less implements sort.Interface.30func (it Items) Less(i int, j int) bool {31 if it[i].lastUpdate == nil && it[j].lastUpdate != nil {32 return false33 }34 if it[i].lastUpdate != nil && it[j].lastUpdate == nil {35 return true36 }37 if it[i].lastUpdate == nil && it[j].lastUpdate == nil {38 return it[i].repo.Name() < it[j].repo.Name()39 }40 return it[i].lastUpdate.After(*it[j].lastUpdate)41}4243// Swap implements sort.Interface.44func (it Items) Swap(i int, j int) {45 it[i], it[j] = it[j], it[i]46}4748// Item represents a single item in the selector.49type Item struct {50 repo proto.Repository51 lastUpdate *time.Time52 cmd string53}5455// New creates a new Item.56func NewItem(c common.Common, repo proto.Repository) (Item, error) {57 var lastUpdate *time.Time58 lu := repo.UpdatedAt()59 if !lu.IsZero() {60 lastUpdate = &lu61 }62 var cmd string63 if cfg := c.Config(); cfg != nil {64 cmd = c.CloneCmd(cfg.SSH.PublicURL, repo.Name())65 }66 return Item{67 repo: repo,68 lastUpdate: lastUpdate,69 cmd: cmd,70 }, nil71}7273// ID implements selector.IdentifiableItem.74func (i Item) ID() string {75 return i.repo.Name()76}7778// Title returns the item title. Implements list.DefaultItem.79func (i Item) Title() string {80 name := i.repo.ProjectName()81 if name == "" {82 name = i.repo.Name()83 }8485 return name86}8788// Description returns the item description. Implements list.DefaultItem.89func (i Item) Description() string { return strings.TrimSpace(i.repo.Description()) }9091// FilterValue implements list.Item.92func (i Item) FilterValue() string { return i.Title() }9394// Command returns the item Command view.95func (i Item) Command() string {96 return i.cmd97}9899// ItemDelegate is the delegate for the item.100type ItemDelegate struct {101 common *common.Common102 activePane *pane103 copiedIdx int104}105106// NewItemDelegate creates a new ItemDelegate.107func NewItemDelegate(common *common.Common, activePane *pane) *ItemDelegate {108 return &ItemDelegate{109 common: common,110 activePane: activePane,111 copiedIdx: -1,112 }113}114115// Width returns the item width.116func (d ItemDelegate) Width() int {117 width := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth()118 return width119}120121// Height returns the item height. Implements list.ItemDelegate.122func (d *ItemDelegate) Height() int {123 height := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight()124 return height125}126127// Spacing returns the spacing between items. Implements list.ItemDelegate.128func (d *ItemDelegate) Spacing() int { return 1 }129130// Update implements list.ItemDelegate.131func (d *ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {132 idx := m.Index()133 item, ok := m.SelectedItem().(Item)134 if !ok {135 return nil136 }137 switch msg := msg.(type) {138 case tea.KeyPressMsg:139 switch {140 case key.Matches(msg, d.common.KeyMap.Copy):141 d.copiedIdx = idx142 return tea.Batch(143 tea.SetClipboard(item.Command()),144 m.SetItem(idx, item),145 )146 }147 }148 return nil149}150151// Render implements list.ItemDelegate.152func (d *ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {153 i := listItem.(Item)154 s := strings.Builder{}155 var matchedRunes []int156157 // Conditions158 var (159 isSelected = index == m.Index()160 isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied161 )162163 styles := d.common.Styles.RepoSelector.Normal164 if isSelected {165 styles = d.common.Styles.RepoSelector.Active166 }167168 title := i.Title()169 title = common.TruncateString(title, m.Width()-styles.Base.GetHorizontalFrameSize())170 if i.repo.IsPrivate() {171 title += " 🔒"172 }173 if isSelected {174 title += " "175 }176 var updatedStr string177 if i.lastUpdate != nil {178 updatedStr = fmt.Sprintf(" Updated %s", humanize.Time(*i.lastUpdate))179 }180 if m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 {181 updatedStr = ""182 }183 updatedStyle := styles.Updated.184 Align(lipgloss.Right).185 Width(m.Width() - styles.Base.GetHorizontalFrameSize() - lipgloss.Width(title))186 updated := updatedStyle.Render(updatedStr)187188 if isFiltered && index < len(m.VisibleItems()) {189 // Get indices of matched characters190 matchedRunes = m.MatchesForItem(index)191 }192193 if isFiltered {194 unmatched := styles.Title.Inline(true)195 matched := unmatched.Underline(true)196 title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)197 }198 title = styles.Title.Render(title)199 desc := i.Description()200 desc = common.TruncateString(desc, m.Width()-styles.Base.GetHorizontalFrameSize())201 desc = styles.Desc.Render(desc)202203 s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated))204 s.WriteRune('\n')205 s.WriteString(desc)206 s.WriteRune('\n')207208 cmd := i.Command()209 cmdStyler := styles.Command.Render210 if d.copiedIdx == index {211 cmd = "(copied to clipboard)"212 cmdStyler = styles.Desc.Render213 d.copiedIdx = -1214 }215 cmd = common.TruncateString(cmd, m.Width()-styles.Base.GetHorizontalFrameSize())216 s.WriteString(cmdStyler(cmd))217 fmt.Fprint(w, //nolint:errcheck218 d.common.Zone.Mark(i.ID(),219 styles.Base.Render(s.String()),220 ),221 )222}