1//nolint:revive
2package selection
3
4import (
5 "fmt"
6 "io"
7 "sort"
8 "strings"
9 "time"
10
11 "github.com/charmbracelet/bubbles/v2/key"
12 "github.com/charmbracelet/bubbles/v2/list"
13 tea "github.com/charmbracelet/bubbletea/v2"
14 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/soft-serve/pkg/proto"
16 "github.com/charmbracelet/soft-serve/pkg/ui/common"
17 "github.com/dustin/go-humanize"
18)
19
20var _ sort.Interface = Items{}
21
22// Items is a list of Item.
23type Items []Item
24
25// Len implements sort.Interface.
26func (it Items) Len() int {
27 return len(it)
28}
29
30// Less implements sort.Interface.
31func (it Items) Less(i int, j int) bool {
32 if it[i].lastUpdate == nil && it[j].lastUpdate != nil {
33 return false
34 }
35 if it[i].lastUpdate != nil && it[j].lastUpdate == nil {
36 return true
37 }
38 if it[i].lastUpdate == nil && it[j].lastUpdate == nil {
39 return it[i].repo.Name() < it[j].repo.Name()
40 }
41 return it[i].lastUpdate.After(*it[j].lastUpdate)
42}
43
44// Swap implements sort.Interface.
45func (it Items) Swap(i int, j int) {
46 it[i], it[j] = it[j], it[i]
47}
48
49// Item represents a single item in the selector.
50type Item struct {
51 repo proto.Repository
52 lastUpdate *time.Time
53 cmd string
54}
55
56// New creates a new Item.
57func NewItem(c common.Common, repo proto.Repository) (Item, error) {
58 var lastUpdate *time.Time
59 lu := repo.UpdatedAt()
60 if !lu.IsZero() {
61 lastUpdate = &lu
62 }
63 var cmd string
64 if cfg := c.Config(); cfg != nil {
65 cmd = c.CloneCmd(cfg.SSH.PublicURL, repo.Name())
66 }
67 return Item{
68 repo: repo,
69 lastUpdate: lastUpdate,
70 cmd: cmd,
71 }, nil
72}
73
74// ID implements selector.IdentifiableItem.
75func (i Item) ID() string {
76 return i.repo.Name()
77}
78
79// Title returns the item title. Implements list.DefaultItem.
80func (i Item) Title() string {
81 name := i.repo.ProjectName()
82 if name == "" {
83 name = i.repo.Name()
84 }
85
86 return name
87}
88
89// Description returns the item description. Implements list.DefaultItem.
90func (i Item) Description() string { return strings.TrimSpace(i.repo.Description()) }
91
92// FilterValue implements list.Item.
93func (i Item) FilterValue() string { return i.Title() }
94
95// Command returns the item Command view.
96func (i Item) Command() string {
97 return i.cmd
98}
99
100// ItemDelegate is the delegate for the item.
101type ItemDelegate struct {
102 common *common.Common
103 activePane *pane
104 copiedIdx int
105}
106
107// NewItemDelegate creates a new ItemDelegate.
108func NewItemDelegate(common *common.Common, activePane *pane) *ItemDelegate {
109 return &ItemDelegate{
110 common: common,
111 activePane: activePane,
112 copiedIdx: -1,
113 }
114}
115
116// Width returns the item width.
117func (d ItemDelegate) Width() int {
118 width := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth()
119 return width
120}
121
122// Height returns the item height. Implements list.ItemDelegate.
123func (d *ItemDelegate) Height() int {
124 height := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight()
125 return height
126}
127
128// Spacing returns the spacing between items. Implements list.ItemDelegate.
129func (d *ItemDelegate) Spacing() int { return 1 }
130
131// Update implements list.ItemDelegate.
132func (d *ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
133 idx := m.Index()
134 item, ok := m.SelectedItem().(Item)
135 if !ok {
136 return nil
137 }
138 switch msg := msg.(type) {
139 case tea.KeyPressMsg:
140 switch {
141 case key.Matches(msg, d.common.KeyMap.Copy):
142 d.copiedIdx = idx
143 return tea.Batch(
144 tea.SetClipboard(item.Command()),
145 m.SetItem(idx, item),
146 )
147 }
148 }
149 return nil
150}
151
152// Render implements list.ItemDelegate.
153func (d *ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
154 i := listItem.(Item)
155 s := strings.Builder{}
156 var matchedRunes []int
157
158 // Conditions
159 var (
160 isSelected = index == m.Index()
161 isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied
162 )
163
164 styles := d.common.Styles.RepoSelector.Normal
165 if isSelected {
166 styles = d.common.Styles.RepoSelector.Active
167 }
168
169 title := i.Title()
170 title = common.TruncateString(title, m.Width()-styles.Base.GetHorizontalFrameSize())
171 if i.repo.IsPrivate() {
172 title += " 🔒"
173 }
174 if isSelected {
175 title += " "
176 }
177 var updatedStr string
178 if i.lastUpdate != nil {
179 updatedStr = fmt.Sprintf(" Updated %s", humanize.Time(*i.lastUpdate))
180 }
181 if m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 {
182 updatedStr = ""
183 }
184 updatedStyle := styles.Updated.
185 Align(lipgloss.Right).
186 Width(m.Width() - styles.Base.GetHorizontalFrameSize() - lipgloss.Width(title))
187 updated := updatedStyle.Render(updatedStr)
188
189 if isFiltered && index < len(m.VisibleItems()) {
190 // Get indices of matched characters
191 matchedRunes = m.MatchesForItem(index)
192 }
193
194 if isFiltered {
195 unmatched := styles.Title.Inline(true)
196 matched := unmatched.Underline(true)
197 title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
198 }
199 title = styles.Title.Render(title)
200 desc := i.Description()
201 desc = common.TruncateString(desc, m.Width()-styles.Base.GetHorizontalFrameSize())
202 desc = styles.Desc.Render(desc)
203
204 s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated))
205 s.WriteRune('\n')
206 s.WriteString(desc)
207 s.WriteRune('\n')
208
209 cmd := i.Command()
210 cmdStyler := styles.Command.Render
211 if d.copiedIdx == index {
212 cmd = "(copied to clipboard)"
213 cmdStyler = styles.Desc.Render
214 d.copiedIdx = -1
215 }
216 cmd = common.TruncateString(cmd, m.Width()-styles.Base.GetHorizontalFrameSize())
217 s.WriteString(cmdStyler(cmd))
218 fmt.Fprint(w, //nolint:errcheck
219 d.common.Zone.Mark(i.ID(),
220 styles.Base.Render(s.String()),
221 ),
222 )
223}