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