1package selection
2
3import (
4 "fmt"
5 "io"
6 "sort"
7 "strings"
8 "time"
9
10 "github.com/charmbracelet/bubbles/key"
11 "github.com/charmbracelet/bubbles/list"
12 tea "github.com/charmbracelet/bubbletea"
13 "github.com/charmbracelet/lipgloss"
14 "github.com/charmbracelet/soft-serve/pkg/proto"
15 "github.com/charmbracelet/soft-serve/pkg/ui/common"
16 "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.KeyMsg:
139 switch {
140 case key.Matches(msg, d.common.KeyMap.Copy):
141 d.copiedIdx = idx
142 d.common.Output.Copy(item.Command())
143 return m.SetItem(idx, item)
144 }
145 }
146 return nil
147}
148
149// Render implements list.ItemDelegate.
150func (d *ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
151 i := listItem.(Item)
152 s := strings.Builder{}
153 var matchedRunes []int
154
155 // Conditions
156 var (
157 isSelected = index == m.Index()
158 isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied
159 )
160
161 styles := d.common.Styles.RepoSelector.Normal
162 if isSelected {
163 styles = d.common.Styles.RepoSelector.Active
164 }
165
166 title := i.Title()
167 title = common.TruncateString(title, m.Width()-styles.Base.GetHorizontalFrameSize())
168 if i.repo.IsPrivate() {
169 title += " 🔒"
170 }
171 if isSelected {
172 title += " "
173 }
174 var updatedStr string
175 if i.lastUpdate != nil {
176 updatedStr = fmt.Sprintf(" Updated %s", humanize.Time(*i.lastUpdate))
177 }
178 if m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 {
179 updatedStr = ""
180 }
181 updatedStyle := styles.Updated.
182 Align(lipgloss.Right).
183 Width(m.Width() - styles.Base.GetHorizontalFrameSize() - lipgloss.Width(title))
184 updated := updatedStyle.Render(updatedStr)
185
186 if isFiltered && index < len(m.VisibleItems()) {
187 // Get indices of matched characters
188 matchedRunes = m.MatchesForItem(index)
189 }
190
191 if isFiltered {
192 unmatched := styles.Title.Inline(true)
193 matched := unmatched.Underline(true)
194 title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
195 }
196 title = styles.Title.Render(title)
197 desc := i.Description()
198 desc = common.TruncateString(desc, m.Width()-styles.Base.GetHorizontalFrameSize())
199 desc = styles.Desc.Render(desc)
200
201 s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated))
202 s.WriteRune('\n')
203 s.WriteString(desc)
204 s.WriteRune('\n')
205
206 cmd := i.Command()
207 cmdStyler := styles.Command.Render
208 if d.copiedIdx == index {
209 cmd = "(copied to clipboard)"
210 cmdStyler = styles.Desc.Render
211 d.copiedIdx = -1
212 }
213 cmd = common.TruncateString(cmd, m.Width()-styles.Base.GetHorizontalFrameSize())
214 s.WriteString(cmdStyler(cmd))
215 fmt.Fprint(w, //nolint:errcheck
216 d.common.Zone.Mark(i.ID(),
217 styles.Base.Render(s.String()),
218 ),
219 )
220}