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