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 copied time.Time
55}
56
57// New creates a new Item.
58func NewItem(repo backend.Repository, cfg *config.Config) (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.RepoURL(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 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}
102
103// Width returns the item width.
104func (d ItemDelegate) Width() int {
105 width := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth()
106 return width
107}
108
109// Height returns the item height. Implements list.ItemDelegate.
110func (d ItemDelegate) Height() int {
111 height := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight()
112 return height
113}
114
115// Spacing returns the spacing between items. Implements list.ItemDelegate.
116func (d ItemDelegate) Spacing() int { return 1 }
117
118// Update implements list.ItemDelegate.
119func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
120 idx := m.Index()
121 item, ok := m.SelectedItem().(Item)
122 if !ok {
123 return nil
124 }
125 switch msg := msg.(type) {
126 case tea.KeyMsg:
127 switch {
128 case key.Matches(msg, d.common.KeyMap.Copy):
129 item.copied = time.Now()
130 d.common.Copy.Copy(item.Command())
131 return m.SetItem(idx, item)
132 }
133 }
134 return nil
135}
136
137// Render implements list.ItemDelegate.
138func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
139 i := listItem.(Item)
140 s := strings.Builder{}
141 var matchedRunes []int
142
143 // Conditions
144 var (
145 isSelected = index == m.Index()
146 isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied
147 )
148
149 styles := d.common.Styles.RepoSelector.Normal
150 if isSelected {
151 styles = d.common.Styles.RepoSelector.Active
152 }
153
154 title := i.Title()
155 title = common.TruncateString(title, m.Width()-styles.Base.GetHorizontalFrameSize())
156 if i.repo.IsPrivate() {
157 title += " 🔒"
158 }
159 if isSelected {
160 title += " "
161 }
162 var updatedStr string
163 if i.lastUpdate != nil {
164 updatedStr = fmt.Sprintf(" Updated %s", humanize.Time(*i.lastUpdate))
165 }
166 if m.Width()-styles.Base.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 {
167 updatedStr = ""
168 }
169 updatedStyle := styles.Updated.Copy().
170 Align(lipgloss.Right).
171 Width(m.Width() - styles.Base.GetHorizontalFrameSize() - lipgloss.Width(title))
172 updated := updatedStyle.Render(updatedStr)
173
174 if isFiltered && index < len(m.VisibleItems()) {
175 // Get indices of matched characters
176 matchedRunes = m.MatchesForItem(index)
177 }
178
179 if isFiltered {
180 unmatched := styles.Title.Copy().Inline(true)
181 matched := unmatched.Copy().Underline(true)
182 title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
183 }
184 title = styles.Title.Render(title)
185 desc := i.Description()
186 desc = common.TruncateString(desc, m.Width()-styles.Base.GetHorizontalFrameSize())
187 desc = styles.Desc.Render(desc)
188
189 s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated))
190 s.WriteRune('\n')
191 s.WriteString(desc)
192 s.WriteRune('\n')
193 cmd := common.TruncateString(i.Command(), m.Width()-styles.Base.GetHorizontalFrameSize())
194 cmd = styles.Command.Render(cmd)
195 if !i.copied.IsZero() && i.copied.Add(time.Second).After(time.Now()) {
196 cmd = styles.Command.Render("Copied!")
197 }
198 s.WriteString(cmd)
199 fmt.Fprint(w,
200 d.common.Zone.Mark(i.ID(),
201 styles.Base.Render(s.String()),
202 ),
203 )
204}