item.go

  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}