item.go

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