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