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