log.go

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