1package selection
2
3import (
4 "fmt"
5 "io"
6 "sort"
7 "strings"
8 "time"
9
10 "github.com/charmbracelet/bubbles/v2/key"
11 "github.com/charmbracelet/bubbles/v2/list"
12 tea "github.com/charmbracelet/bubbletea/v2"
13 lipgloss "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/soft-serve/pkg/proto"
15 "github.com/charmbracelet/soft-serve/pkg/ui/common"
16 humanize "github.com/dustin/go-humanize"
17)
18
19var _ sort.Interface = Items{}
20
21// Items is a list of Item.
22type Items []Item
23
24// Len implements sort.Interface.
25func (it Items) Len() int {
26 return len(it)
27}
28
29// 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 false
33 }
34 if it[i].lastUpdate != nil && it[j].lastUpdate == nil {
35 return true
36 }
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}
42
43// Swap implements sort.Interface.
44func (it Items) Swap(i int, j int) {
45 it[i], it[j] = it[j], it[i]
46}
47
48// Item represents a single item in the selector.
49type Item struct {
50 repo proto.Repository
51 lastUpdate *time.Time
52 cmd string
53}
54
55// New creates a new Item.
56func NewItem(c common.Common, repo proto.Repository) (Item, error) {
57 var lastUpdate *time.Time
58 lu := repo.UpdatedAt()
59 if !lu.IsZero() {
60 lastUpdate = &lu
61 }
62 var cmd string
63 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 }, nil
71}
72
73// ID implements selector.IdentifiableItem.
74func (i Item) ID() string {
75 return i.repo.Name()
76}
77
78// 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 }
84
85 return name
86}
87
88// Description returns the item description. Implements list.DefaultItem.
89func (i Item) Description() string { return strings.TrimSpace(i.repo.Description()) }
90
91// FilterValue implements list.Item.
92func (i Item) FilterValue() string { return i.Title() }
93
94// Command returns the item Command view.
95func (i Item) Command() string {
96 return i.cmd
97}
98
99// ItemDelegate is the delegate for the item.
100type ItemDelegate struct {
101 common *common.Common
102 activePane *pane
103 copiedIdx int
104}
105
106// 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}
114
115// Width returns the item width.
116func (d ItemDelegate) Width() int {
117 width := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth()
118 return width
119}
120
121// 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 height
125}
126
127// Spacing returns the spacing between items. Implements list.ItemDelegate.
128func (d *ItemDelegate) Spacing() int { return 1 }
129
130// 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 nil
136 }
137 switch msg := msg.(type) {
138 case tea.KeyPressMsg:
139 switch {
140 case key.Matches(msg, d.common.KeyMap.Copy):
141 d.copiedIdx = idx
142 return tea.Batch(
143 tea.SetClipboard(item.Command()),
144 m.SetItem(idx, item),
145 )
146 }
147 }
148 return nil
149}
150
151// 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 []int
156
157 // Conditions
158 var (
159 isSelected = index == m.Index()
160 isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied
161 )
162
163 styles := d.common.Styles.RepoSelector.Normal
164 if isSelected {
165 styles = d.common.Styles.RepoSelector.Active
166 }
167
168 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 string
177 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)
187
188 if isFiltered && index < len(m.VisibleItems()) {
189 // Get indices of matched characters
190 matchedRunes = m.MatchesForItem(index)
191 }
192
193 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)
202
203 s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated))
204 s.WriteRune('\n')
205 s.WriteString(desc)
206 s.WriteRune('\n')
207
208 cmd := i.Command()
209 cmdStyler := styles.Command.Render
210 if d.copiedIdx == index {
211 cmd = "(copied to clipboard)"
212 cmdStyler = styles.Desc.Render
213 d.copiedIdx = -1
214 }
215 cmd = common.TruncateString(cmd, m.Width()-styles.Base.GetHorizontalFrameSize())
216 s.WriteString(cmdStyler(cmd))
217 fmt.Fprint(w,
218 d.common.Zone.Mark(i.ID(),
219 styles.Base.Render(s.String()),
220 ),
221 )
222}