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