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