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