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