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