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/backend"
 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/uiutils"
 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	logViewCommits logView = iota
 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           backend.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	loading        bool
 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// SetSize implements common.Component.
 88func (l *Log) SetSize(width, height int) {
 89	l.common.SetSize(width, height)
 90	l.selector.SetSize(width, height)
 91	l.vp.SetSize(width, height)
 92}
 93
 94// ShortHelp implements help.KeyMap.
 95func (l *Log) ShortHelp() []key.Binding {
 96	switch l.activeView {
 97	case logViewCommits:
 98		copyKey := l.common.KeyMap.Copy
 99		copyKey.SetHelp("c", "copy hash")
100		return []key.Binding{
101			l.common.KeyMap.UpDown,
102			l.common.KeyMap.SelectItem,
103			copyKey,
104		}
105	case logViewDiff:
106		return []key.Binding{
107			l.common.KeyMap.UpDown,
108			l.common.KeyMap.BackItem,
109			l.common.KeyMap.GotoTop,
110			l.common.KeyMap.GotoBottom,
111		}
112	default:
113		return []key.Binding{}
114	}
115}
116
117// FullHelp implements help.KeyMap.
118func (l *Log) FullHelp() [][]key.Binding {
119	k := l.selector.KeyMap
120	b := make([][]key.Binding, 0)
121	switch l.activeView {
122	case logViewCommits:
123		copyKey := l.common.KeyMap.Copy
124		copyKey.SetHelp("c", "copy hash")
125		b = append(b, []key.Binding{
126			l.common.KeyMap.SelectItem,
127			l.common.KeyMap.BackItem,
128		})
129		b = append(b, [][]key.Binding{
130			{
131				copyKey,
132				k.CursorUp,
133				k.CursorDown,
134			},
135			{
136				k.NextPage,
137				k.PrevPage,
138				k.GoToStart,
139				k.GoToEnd,
140			},
141		}...)
142	case logViewDiff:
143		k := l.vp.KeyMap
144		b = append(b, []key.Binding{
145			l.common.KeyMap.BackItem,
146		})
147		b = append(b, [][]key.Binding{
148			{
149				k.PageDown,
150				k.PageUp,
151				k.HalfPageDown,
152				k.HalfPageUp,
153			},
154			{
155				k.Down,
156				k.Up,
157				l.common.KeyMap.GotoTop,
158				l.common.KeyMap.GotoBottom,
159			},
160		}...)
161	}
162	return b
163}
164
165func (l *Log) startLoading() tea.Cmd {
166	l.loadingTime = time.Now()
167	l.loading = true
168	return l.spinner.Tick
169}
170
171func (l *Log) stopLoading() tea.Cmd {
172	l.loading = false
173	return updateStatusBarCmd
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	l.selector.Select(0)
184	return tea.Batch(
185		l.updateCommitsCmd,
186		// start loading on init
187		l.startLoading(),
188	)
189}
190
191// Update implements tea.Model.
192func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
193	cmds := make([]tea.Cmd, 0)
194	switch msg := msg.(type) {
195	case RepoMsg:
196		l.repo = msg
197	case RefMsg:
198		l.ref = msg
199		cmds = append(cmds, l.Init())
200	case LogCountMsg:
201		l.count = int64(msg)
202	case LogItemsMsg:
203		cmds = append(cmds,
204			l.selector.SetItems(msg),
205			// stop loading after receiving items
206			l.stopLoading(),
207		)
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.SelectItem)
222				}
223			}
224			// This is a hack for loading commits on demand based on list.Pagination.
225			curPage := l.selector.Page()
226			s, cmd := l.selector.Update(msg)
227			m := s.(*selector.Selector)
228			l.selector = m
229			if m.Page() != curPage {
230				l.nextPage = m.Page()
231				l.selector.SetPage(curPage)
232				cmds = append(cmds,
233					l.updateCommitsCmd,
234					l.startLoading(),
235				)
236			}
237			cmds = append(cmds, cmd)
238		case logViewDiff:
239			switch kmsg := msg.(type) {
240			case tea.KeyMsg:
241				switch {
242				case key.Matches(kmsg, l.common.KeyMap.BackItem):
243					cmds = append(cmds, backCmd)
244				}
245			}
246		}
247	case BackMsg:
248		if l.activeView == logViewDiff {
249			l.activeView = logViewCommits
250			l.selectedCommit = nil
251			cmds = append(cmds, updateStatusBarCmd)
252		}
253	case selector.ActiveMsg:
254		switch sel := msg.IdentifiableItem.(type) {
255		case LogItem:
256			l.activeCommit = sel.Commit
257		}
258		cmds = append(cmds, updateStatusBarCmd)
259	case selector.SelectMsg:
260		switch sel := msg.IdentifiableItem.(type) {
261		case LogItem:
262			cmds = append(cmds,
263				l.selectCommitCmd(sel.Commit),
264				l.startLoading(),
265			)
266		}
267	case LogCommitMsg:
268		l.selectedCommit = msg
269		cmds = append(cmds, l.loadDiffCmd)
270	case LogDiffMsg:
271		l.currentDiff = msg
272		l.vp.SetContent(
273			lipgloss.JoinVertical(lipgloss.Top,
274				l.renderCommit(l.selectedCommit),
275				l.renderSummary(msg),
276				l.renderDiff(msg),
277			),
278		)
279		l.vp.GotoTop()
280		l.activeView = logViewDiff
281		cmds = append(cmds,
282			updateStatusBarCmd,
283			// stop loading after setting the viewport content
284			l.stopLoading(),
285		)
286	case footer.ToggleFooterMsg:
287		cmds = append(cmds, l.updateCommitsCmd)
288	case tea.WindowSizeMsg:
289		if l.selectedCommit != nil && l.currentDiff != nil {
290			l.vp.SetContent(
291				lipgloss.JoinVertical(lipgloss.Top,
292					l.renderCommit(l.selectedCommit),
293					l.renderSummary(l.currentDiff),
294					l.renderDiff(l.currentDiff),
295				),
296			)
297		}
298		if l.repo != nil && l.ref != nil {
299			cmds = append(cmds,
300				l.updateCommitsCmd,
301				// start loading on resize since the number of commits per page
302				// might change and we'd need to load more commits.
303				l.startLoading(),
304			)
305		}
306	case EmptyRepoMsg:
307		l.ref = nil
308		l.loading = false
309		l.activeView = logViewCommits
310		l.nextPage = 0
311		l.count = 0
312		l.activeCommit = nil
313		l.selectedCommit = nil
314		l.selector.Select(0)
315		cmds = append(cmds, l.setItems([]selector.IdentifiableItem{}))
316	}
317	if l.loading {
318		s, cmd := l.spinner.Update(msg)
319		if cmd != nil {
320			cmds = append(cmds, cmd)
321		}
322		l.spinner = s
323	}
324	switch l.activeView {
325	case logViewDiff:
326		vp, cmd := l.vp.Update(msg)
327		l.vp = vp.(*viewport.Viewport)
328		if cmd != nil {
329			cmds = append(cmds, cmd)
330		}
331	}
332	return l, tea.Batch(cmds...)
333}
334
335// View implements tea.Model.
336func (l *Log) View() string {
337	if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
338		msg := fmt.Sprintf("%s loading commit", l.spinner.View())
339		if l.selectedCommit == nil {
340			msg += "s"
341		}
342		msg += "…"
343		return l.common.Styles.SpinnerContainer.Copy().
344			Height(l.common.Height).
345			Render(msg)
346	}
347	switch l.activeView {
348	case logViewCommits:
349		return l.selector.View()
350	case logViewDiff:
351		return l.vp.View()
352	default:
353		return ""
354	}
355}
356
357// StatusBarValue returns the status bar value.
358func (l *Log) StatusBarValue() string {
359	if l.loading {
360		return ""
361	}
362	c := l.activeCommit
363	if c == nil {
364		return ""
365	}
366	who := c.Author.Name
367	if email := c.Author.Email; email != "" {
368		who += " <" + email + ">"
369	}
370	value := c.ID.String()[:7]
371	if who != "" {
372		value += " by " + who
373	}
374	return value
375}
376
377// StatusBarInfo returns the status bar info.
378func (l *Log) StatusBarInfo() string {
379	switch l.activeView {
380	case logViewCommits:
381		// We're using l.nextPage instead of l.selector.Paginator.Page because
382		// of the paginator hack above.
383		return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
384	case logViewDiff:
385		return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
386	default:
387		return ""
388	}
389}
390
391func (l *Log) countCommitsCmd() tea.Msg {
392	if l.ref == nil {
393		return nil
394	}
395	r, err := l.repo.Open()
396	if err != nil {
397		return common.ErrorMsg(err)
398	}
399	count, err := r.CountCommits(l.ref)
400	if err != nil {
401		l.common.Logger.Debugf("ui: error counting commits: %v", err)
402		return common.ErrorMsg(err)
403	}
404	return LogCountMsg(count)
405}
406
407func (l *Log) updateCommitsCmd() tea.Msg {
408	count := l.count
409	if l.count == 0 {
410		switch msg := l.countCommitsCmd().(type) {
411		case common.ErrorMsg:
412			return msg
413		case LogCountMsg:
414			count = int64(msg)
415		}
416	}
417	if l.ref == nil {
418		return nil
419	}
420	items := make([]selector.IdentifiableItem, count)
421	page := l.nextPage
422	limit := l.selector.PerPage()
423	skip := page * limit
424	r, err := l.repo.Open()
425	if err != nil {
426		return common.ErrorMsg(err)
427	}
428	// CommitsByPage pages start at 1
429	cc, err := r.CommitsByPage(l.ref, page+1, limit)
430	if err != nil {
431		l.common.Logger.Debugf("ui: error loading commits: %v", err)
432		return common.ErrorMsg(err)
433	}
434	for i, c := range cc {
435		idx := i + skip
436		if int64(idx) >= count {
437			break
438		}
439		items[idx] = LogItem{Commit: c}
440	}
441	return LogItemsMsg(items)
442}
443
444func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {
445	return func() tea.Msg {
446		return LogCommitMsg(commit)
447	}
448}
449
450func (l *Log) loadDiffCmd() tea.Msg {
451	r, err := l.repo.Open()
452	if err != nil {
453		l.common.Logger.Debugf("ui: error loading diff repository: %v", err)
454		return common.ErrorMsg(err)
455	}
456	diff, err := r.Diff(l.selectedCommit)
457	if err != nil {
458		l.common.Logger.Debugf("ui: error loading diff: %v", err)
459		return common.ErrorMsg(err)
460	}
461	return LogDiffMsg(diff)
462}
463
464func renderCtx() gansi.RenderContext {
465	return gansi.NewRenderContext(gansi.Options{
466		ColorProfile: termenv.TrueColor,
467		Styles:       uiutils.StyleConfig(),
468	})
469}
470
471func (l *Log) renderCommit(c *git.Commit) string {
472	s := strings.Builder{}
473	// FIXME: lipgloss prints empty lines when CRLF is used
474	// sanitize commit message from CRLF
475	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
476	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
477		l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()),
478		l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
479		l.common.Styles.Log.CommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
480		l.common.Styles.Log.CommitBody.Render(msg),
481	))
482	return wrap.String(s.String(), l.common.Width-2)
483}
484
485func (l *Log) renderSummary(diff *git.Diff) string {
486	stats := strings.Split(diff.Stats().String(), "\n")
487	for i, line := range stats {
488		ch := strings.Split(line, "|")
489		if len(ch) > 1 {
490			adddel := ch[len(ch)-1]
491			adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.Log.CommitStatsAdd.Render("+"))
492			adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.Log.CommitStatsDel.Render("-"))
493			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
494		}
495	}
496	return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
497}
498
499func (l *Log) renderDiff(diff *git.Diff) string {
500	var s strings.Builder
501	var pr strings.Builder
502	diffChroma := &gansi.CodeBlockElement{
503		Code:     diff.Patch(),
504		Language: "diff",
505	}
506	err := diffChroma.Render(&pr, renderCtx())
507	if err != nil {
508		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
509	} else {
510		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
511	}
512	return wrap.String(s.String(), l.common.Width)
513}
514
515func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd {
516	return func() tea.Msg {
517		return LogItemsMsg(items)
518	}
519}