1package repo
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"time"
  7
  8	"github.com/charmbracelet/bubbles/key"
  9	"github.com/charmbracelet/bubbles/spinner"
 10	tea "github.com/charmbracelet/bubbletea"
 11	gansi "github.com/charmbracelet/glamour/ansi"
 12	"github.com/charmbracelet/lipgloss"
 13	ggit "github.com/charmbracelet/soft-serve/git"
 14	"github.com/charmbracelet/soft-serve/ui/common"
 15	"github.com/charmbracelet/soft-serve/ui/components/selector"
 16	"github.com/charmbracelet/soft-serve/ui/components/viewport"
 17	"github.com/charmbracelet/soft-serve/ui/git"
 18	"github.com/muesli/reflow/wrap"
 19	"github.com/muesli/termenv"
 20)
 21
 22var waitBeforeLoading = time.Millisecond * 100
 23
 24type logView int
 25
 26const (
 27	logViewCommits logView = iota
 28	logViewDiff
 29)
 30
 31// LogCountMsg is a message that contains the number of commits in a repo.
 32type LogCountMsg int64
 33
 34// LogItemsMsg is a message that contains a slice of LogItem.
 35type LogItemsMsg []selector.IdentifiableItem
 36
 37// LogCommitMsg is a message that contains a git commit.
 38type LogCommitMsg *ggit.Commit
 39
 40// LogDiffMsg is a message that contains a git diff.
 41type LogDiffMsg *ggit.Diff
 42
 43// Log is a model that displays a list of commits and their diffs.
 44type Log struct {
 45	common         common.Common
 46	selector       *selector.Selector
 47	vp             *viewport.Viewport
 48	activeView     logView
 49	repo           git.GitRepo
 50	ref            *ggit.Reference
 51	count          int64
 52	nextPage       int
 53	activeCommit   *ggit.Commit
 54	selectedCommit *ggit.Commit
 55	currentDiff    *ggit.Diff
 56	loadingTime    time.Time
 57	loading        bool
 58	spinner        spinner.Model
 59}
 60
 61// NewLog creates a new Log model.
 62func NewLog(common common.Common) *Log {
 63	l := &Log{
 64		common:     common,
 65		vp:         viewport.New(common),
 66		activeView: logViewCommits,
 67	}
 68	selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common})
 69	selector.SetShowFilter(false)
 70	selector.SetShowHelp(false)
 71	selector.SetShowPagination(false)
 72	selector.SetShowStatusBar(false)
 73	selector.SetShowTitle(false)
 74	selector.SetFilteringEnabled(false)
 75	selector.DisableQuitKeybindings()
 76	selector.KeyMap.NextPage = common.KeyMap.NextPage
 77	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
 78	l.selector = selector
 79	s := spinner.New()
 80	s.Spinner = spinner.Dot
 81	s.Style = common.Styles.Spinner
 82	l.spinner = s
 83	return l
 84}
 85
 86// SetSize implements common.Component.
 87func (l *Log) SetSize(width, height int) {
 88	l.common.SetSize(width, height)
 89	l.selector.SetSize(width, height)
 90	l.vp.SetSize(width, height)
 91}
 92
 93// ShortHelp implements help.KeyMap.
 94func (l *Log) ShortHelp() []key.Binding {
 95	switch l.activeView {
 96	case logViewCommits:
 97		copyKey := l.common.KeyMap.Copy
 98		copyKey.SetHelp("c", "copy hash")
 99		return []key.Binding{
100			l.common.KeyMap.UpDown,
101			l.common.KeyMap.SelectItem,
102			copyKey,
103		}
104	case logViewDiff:
105		return []key.Binding{
106			l.common.KeyMap.UpDown,
107			l.common.KeyMap.BackItem,
108		}
109	default:
110		return []key.Binding{}
111	}
112}
113
114// FullHelp implements help.KeyMap.
115func (l *Log) FullHelp() [][]key.Binding {
116	k := l.selector.KeyMap
117	b := make([][]key.Binding, 0)
118	switch l.activeView {
119	case logViewCommits:
120		copyKey := l.common.KeyMap.Copy
121		copyKey.SetHelp("c", "copy hash")
122		b = append(b, []key.Binding{
123			l.common.KeyMap.SelectItem,
124			l.common.KeyMap.BackItem,
125		})
126		b = append(b, [][]key.Binding{
127			{
128				copyKey,
129				k.CursorUp,
130				k.CursorDown,
131			},
132			{
133				k.NextPage,
134				k.PrevPage,
135				k.GoToStart,
136				k.GoToEnd,
137			},
138		}...)
139	case logViewDiff:
140		k := l.vp.KeyMap
141		b = append(b, []key.Binding{
142			l.common.KeyMap.BackItem,
143		})
144		b = append(b, [][]key.Binding{
145			{
146				k.PageDown,
147				k.PageUp,
148				k.HalfPageDown,
149				k.HalfPageUp,
150			},
151			{
152				k.Down,
153				k.Up,
154			},
155		}...)
156	}
157	return b
158}
159
160func (l *Log) startLoading() tea.Cmd {
161	l.loadingTime = time.Now()
162	l.loading = true
163	return l.spinner.Tick
164}
165
166func (l *Log) stopLoading() tea.Cmd {
167	l.loading = false
168	return updateStatusBarCmd
169}
170
171// Init implements tea.Model.
172func (l *Log) Init() tea.Cmd {
173	l.activeView = logViewCommits
174	l.nextPage = 0
175	l.count = 0
176	l.activeCommit = nil
177	l.selectedCommit = nil
178	l.selector.Select(0)
179	return tea.Batch(
180		l.updateCommitsCmd,
181		// start loading on init
182		l.startLoading(),
183	)
184}
185
186// Update implements tea.Model.
187func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
188	cmds := make([]tea.Cmd, 0)
189	switch msg := msg.(type) {
190	case RepoMsg:
191		l.repo = git.GitRepo(msg)
192		cmds = append(cmds, l.Init())
193	case RefMsg:
194		l.ref = msg
195		cmds = append(cmds, l.Init())
196	case LogCountMsg:
197		l.count = int64(msg)
198	case LogItemsMsg:
199		cmds = append(cmds,
200			l.selector.SetItems(msg),
201			// stop loading after receiving items
202			l.stopLoading(),
203		)
204		l.selector.SetPage(l.nextPage)
205		l.SetSize(l.common.Width, l.common.Height)
206		i := l.selector.SelectedItem()
207		if i != nil {
208			l.activeCommit = i.(LogItem).Commit
209		}
210	case tea.KeyMsg, tea.MouseMsg:
211		switch l.activeView {
212		case logViewCommits:
213			switch key := msg.(type) {
214			case tea.KeyMsg:
215				switch key.String() {
216				case "l", "right":
217					cmds = append(cmds, l.selector.SelectItem)
218				}
219			}
220			// This is a hack for loading commits on demand based on list.Pagination.
221			curPage := l.selector.Page()
222			s, cmd := l.selector.Update(msg)
223			m := s.(*selector.Selector)
224			l.selector = m
225			if m.Page() != curPage {
226				l.nextPage = m.Page()
227				l.selector.SetPage(curPage)
228				cmds = append(cmds,
229					l.updateCommitsCmd,
230					l.startLoading(),
231				)
232			}
233			cmds = append(cmds, cmd)
234		case logViewDiff:
235			switch key := msg.(type) {
236			case tea.KeyMsg:
237				switch key.String() {
238				case "h", "left":
239					l.activeView = logViewCommits
240					l.selectedCommit = nil
241				}
242			}
243		}
244	case selector.ActiveMsg:
245		switch sel := msg.IdentifiableItem.(type) {
246		case LogItem:
247			l.activeCommit = sel.Commit
248		}
249		cmds = append(cmds, updateStatusBarCmd)
250	case selector.SelectMsg:
251		switch sel := msg.IdentifiableItem.(type) {
252		case LogItem:
253			cmds = append(cmds,
254				l.selectCommitCmd(sel.Commit),
255				l.startLoading(),
256			)
257		}
258	case LogCommitMsg:
259		l.selectedCommit = msg
260		cmds = append(cmds, l.loadDiffCmd)
261	case LogDiffMsg:
262		l.currentDiff = msg
263		l.vp.SetContent(
264			lipgloss.JoinVertical(lipgloss.Top,
265				l.renderCommit(l.selectedCommit),
266				l.renderSummary(msg),
267				l.renderDiff(msg),
268			),
269		)
270		l.vp.GotoTop()
271		l.activeView = logViewDiff
272		cmds = append(cmds,
273			updateStatusBarCmd,
274			// stop loading after setting the viewport content
275			l.stopLoading(),
276		)
277	case tea.WindowSizeMsg:
278		if l.selectedCommit != nil && l.currentDiff != nil {
279			l.vp.SetContent(
280				lipgloss.JoinVertical(lipgloss.Top,
281					l.renderCommit(l.selectedCommit),
282					l.renderSummary(l.currentDiff),
283					l.renderDiff(l.currentDiff),
284				),
285			)
286		}
287		if l.repo != nil {
288			cmds = append(cmds,
289				l.updateCommitsCmd,
290				// start loading on resize since the number of commits per page
291				// might change and we'd need to load more commits.
292				l.startLoading(),
293			)
294		}
295	}
296	if l.loading {
297		s, cmd := l.spinner.Update(msg)
298		if cmd != nil {
299			cmds = append(cmds, cmd)
300		}
301		l.spinner = s
302	}
303	switch l.activeView {
304	case logViewDiff:
305		vp, cmd := l.vp.Update(msg)
306		l.vp = vp.(*viewport.Viewport)
307		if cmd != nil {
308			cmds = append(cmds, cmd)
309		}
310	}
311	return l, tea.Batch(cmds...)
312}
313
314// View implements tea.Model.
315func (l *Log) View() string {
316	if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
317		msg := fmt.Sprintf("%s loading commit", l.spinner.View())
318		if l.selectedCommit == nil {
319			msg += "s"
320		}
321		msg += "…"
322		return msg
323	}
324	switch l.activeView {
325	case logViewCommits:
326		return l.selector.View()
327	case logViewDiff:
328		return l.vp.View()
329	default:
330		return ""
331	}
332}
333
334// StatusBarValue returns the status bar value.
335func (l *Log) StatusBarValue() string {
336	if l.loading {
337		return ""
338	}
339	c := l.activeCommit
340	if c == nil {
341		return ""
342	}
343	who := c.Author.Name
344	if email := c.Author.Email; email != "" {
345		who += " <" + email + ">"
346	}
347	value := c.ID.String()
348	if who != "" {
349		value += " by " + who
350	}
351	return value
352}
353
354// StatusBarInfo returns the status bar info.
355func (l *Log) StatusBarInfo() string {
356	switch l.activeView {
357	case logViewCommits:
358		// We're using l.nextPage instead of l.selector.Paginator.Page because
359		// of the paginator hack above.
360		return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
361	case logViewDiff:
362		return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
363	default:
364		return ""
365	}
366}
367
368func (l *Log) countCommitsCmd() tea.Msg {
369	if l.ref == nil {
370		return common.ErrorMsg(errNoRef)
371	}
372	count, err := l.repo.CountCommits(l.ref)
373	if err != nil {
374		return common.ErrorMsg(err)
375	}
376	return LogCountMsg(count)
377}
378
379func (l *Log) updateCommitsCmd() tea.Msg {
380	count := l.count
381	if l.count == 0 {
382		switch msg := l.countCommitsCmd().(type) {
383		case common.ErrorMsg:
384			return msg
385		case LogCountMsg:
386			count = int64(msg)
387		}
388	}
389	if l.ref == nil {
390		return common.ErrorMsg(errNoRef)
391	}
392	items := make([]selector.IdentifiableItem, count)
393	page := l.nextPage
394	limit := l.selector.PerPage()
395	skip := page * limit
396	// CommitsByPage pages start at 1
397	cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
398	if err != nil {
399		return common.ErrorMsg(err)
400	}
401	for i, c := range cc {
402		idx := i + skip
403		if int64(idx) >= count {
404			break
405		}
406		items[idx] = LogItem{Commit: c}
407	}
408	return LogItemsMsg(items)
409}
410
411func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
412	return func() tea.Msg {
413		return LogCommitMsg(commit)
414	}
415}
416
417func (l *Log) loadDiffCmd() tea.Msg {
418	diff, err := l.repo.Diff(l.selectedCommit)
419	if err != nil {
420		return common.ErrorMsg(err)
421	}
422	return LogDiffMsg(diff)
423}
424
425func renderCtx() gansi.RenderContext {
426	return gansi.NewRenderContext(gansi.Options{
427		ColorProfile: termenv.TrueColor,
428		Styles:       common.StyleConfig(),
429	})
430}
431
432func (l *Log) renderCommit(c *ggit.Commit) string {
433	s := strings.Builder{}
434	// FIXME: lipgloss prints empty lines when CRLF is used
435	// sanitize commit message from CRLF
436	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
437	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
438		l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()),
439		l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
440		l.common.Styles.Log.CommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
441		l.common.Styles.Log.CommitBody.Render(msg),
442	))
443	return wrap.String(s.String(), l.common.Width-2)
444}
445
446func (l *Log) renderSummary(diff *ggit.Diff) string {
447	stats := strings.Split(diff.Stats().String(), "\n")
448	for i, line := range stats {
449		ch := strings.Split(line, "|")
450		if len(ch) > 1 {
451			adddel := ch[len(ch)-1]
452			adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.Log.CommitStatsAdd.Render("+"))
453			adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.Log.CommitStatsDel.Render("-"))
454			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
455		}
456	}
457	return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
458}
459
460func (l *Log) renderDiff(diff *ggit.Diff) string {
461	var s strings.Builder
462	var pr strings.Builder
463	diffChroma := &gansi.CodeBlockElement{
464		Code:     diff.Patch(),
465		Language: "diff",
466	}
467	err := diffChroma.Render(&pr, renderCtx())
468	if err != nil {
469		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
470	} else {
471		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
472	}
473	return wrap.String(s.String(), l.common.Width)
474}