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