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