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/ui/common"
 16	"github.com/charmbracelet/soft-serve/ui/components/footer"
 17	"github.com/charmbracelet/soft-serve/ui/components/selector"
 18	"github.com/charmbracelet/soft-serve/ui/components/viewport"
 19	"github.com/muesli/reflow/wrap"
 20	"github.com/muesli/termenv"
 21)
 22
 23var waitBeforeLoading = time.Millisecond * 100
 24
 25type logView int
 26
 27const (
 28	logViewCommits logView = iota
 29	logViewDiff
 30)
 31
 32// LogCountMsg is a message that contains the number of commits in a repo.
 33type LogCountMsg int64
 34
 35// LogItemsMsg is a message that contains a slice of LogItem.
 36type LogItemsMsg []selector.IdentifiableItem
 37
 38// LogCommitMsg is a message that contains a git commit.
 39type LogCommitMsg *git.Commit
 40
 41// LogDiffMsg is a message that contains a git diff.
 42type LogDiffMsg *git.Diff
 43
 44// Log is a model that displays a list of commits and their diffs.
 45type Log struct {
 46	common         common.Common
 47	selector       *selector.Selector
 48	vp             *viewport.Viewport
 49	activeView     logView
 50	repo           backend.Repository
 51	ref            *git.Reference
 52	count          int64
 53	nextPage       int
 54	activeCommit   *git.Commit
 55	selectedCommit *git.Commit
 56	currentDiff    *git.Diff
 57	loadingTime    time.Time
 58	loading        bool
 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// 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 = msg
192	case RefMsg:
193		l.ref = msg
194		cmds = append(cmds, l.Init())
195	case LogCountMsg:
196		l.count = int64(msg)
197	case LogItemsMsg:
198		cmds = append(cmds,
199			l.selector.SetItems(msg),
200			// stop loading after receiving items
201			l.stopLoading(),
202		)
203		l.selector.SetPage(l.nextPage)
204		l.SetSize(l.common.Width, l.common.Height)
205		i := l.selector.SelectedItem()
206		if i != nil {
207			l.activeCommit = i.(LogItem).Commit
208		}
209	case tea.KeyMsg, tea.MouseMsg:
210		switch l.activeView {
211		case logViewCommits:
212			switch kmsg := msg.(type) {
213			case tea.KeyMsg:
214				switch {
215				case key.Matches(kmsg, l.common.KeyMap.SelectItem):
216					cmds = append(cmds, l.selector.SelectItem)
217				}
218			}
219			// This is a hack for loading commits on demand based on list.Pagination.
220			curPage := l.selector.Page()
221			s, cmd := l.selector.Update(msg)
222			m := s.(*selector.Selector)
223			l.selector = m
224			if m.Page() != curPage {
225				l.nextPage = m.Page()
226				l.selector.SetPage(curPage)
227				cmds = append(cmds,
228					l.updateCommitsCmd,
229					l.startLoading(),
230				)
231			}
232			cmds = append(cmds, cmd)
233		case logViewDiff:
234			switch kmsg := msg.(type) {
235			case tea.KeyMsg:
236				switch {
237				case key.Matches(kmsg, l.common.KeyMap.BackItem):
238					cmds = append(cmds, backCmd)
239				}
240			}
241		}
242	case BackMsg:
243		if l.activeView == logViewDiff {
244			l.activeView = logViewCommits
245			l.selectedCommit = nil
246			cmds = append(cmds, updateStatusBarCmd)
247		}
248	case selector.ActiveMsg:
249		switch sel := msg.IdentifiableItem.(type) {
250		case LogItem:
251			l.activeCommit = sel.Commit
252		}
253		cmds = append(cmds, updateStatusBarCmd)
254	case selector.SelectMsg:
255		switch sel := msg.IdentifiableItem.(type) {
256		case LogItem:
257			cmds = append(cmds,
258				l.selectCommitCmd(sel.Commit),
259				l.startLoading(),
260			)
261		}
262	case LogCommitMsg:
263		l.selectedCommit = msg
264		cmds = append(cmds, l.loadDiffCmd)
265	case LogDiffMsg:
266		l.currentDiff = msg
267		l.vp.SetContent(
268			lipgloss.JoinVertical(lipgloss.Top,
269				l.renderCommit(l.selectedCommit),
270				l.renderSummary(msg),
271				l.renderDiff(msg),
272			),
273		)
274		l.vp.GotoTop()
275		l.activeView = logViewDiff
276		cmds = append(cmds,
277			updateStatusBarCmd,
278			// stop loading after setting the viewport content
279			l.stopLoading(),
280		)
281	case footer.ToggleFooterMsg:
282		cmds = append(cmds, l.updateCommitsCmd)
283	case tea.WindowSizeMsg:
284		if l.selectedCommit != nil && l.currentDiff != nil {
285			l.vp.SetContent(
286				lipgloss.JoinVertical(lipgloss.Top,
287					l.renderCommit(l.selectedCommit),
288					l.renderSummary(l.currentDiff),
289					l.renderDiff(l.currentDiff),
290				),
291			)
292		}
293		if l.repo != nil && l.ref != nil {
294			cmds = append(cmds,
295				l.updateCommitsCmd,
296				// start loading on resize since the number of commits per page
297				// might change and we'd need to load more commits.
298				l.startLoading(),
299			)
300		}
301	case EmptyRepoMsg:
302		l.ref = nil
303		l.loading = false
304		l.activeView = logViewCommits
305		l.nextPage = 0
306		l.count = 0
307		l.activeCommit = nil
308		l.selectedCommit = nil
309		l.selector.Select(0)
310		cmds = append(cmds, l.setItems([]selector.IdentifiableItem{}))
311	}
312	if l.loading {
313		s, cmd := l.spinner.Update(msg)
314		if cmd != nil {
315			cmds = append(cmds, cmd)
316		}
317		l.spinner = s
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	if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
333		msg := fmt.Sprintf("%s loading commit", l.spinner.View())
334		if l.selectedCommit == nil {
335			msg += "s"
336		}
337		msg += "…"
338		return l.common.Styles.SpinnerContainer.Copy().
339			Height(l.common.Height).
340			Render(msg)
341	}
342	switch l.activeView {
343	case logViewCommits:
344		return l.selector.View()
345	case logViewDiff:
346		return l.vp.View()
347	default:
348		return ""
349	}
350}
351
352// StatusBarValue returns the status bar value.
353func (l *Log) StatusBarValue() string {
354	if l.loading {
355		return ""
356	}
357	c := l.activeCommit
358	if c == nil {
359		return ""
360	}
361	who := c.Author.Name
362	if email := c.Author.Email; email != "" {
363		who += " <" + email + ">"
364	}
365	value := c.ID.String()
366	if who != "" {
367		value += " by " + who
368	}
369	return value
370}
371
372// StatusBarInfo returns the status bar info.
373func (l *Log) StatusBarInfo() string {
374	switch l.activeView {
375	case logViewCommits:
376		// We're using l.nextPage instead of l.selector.Paginator.Page because
377		// of the paginator hack above.
378		return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
379	case logViewDiff:
380		return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
381	default:
382		return ""
383	}
384}
385
386func (l *Log) countCommitsCmd() tea.Msg {
387	if l.ref == nil {
388		return nil
389	}
390	r, err := l.repo.Open()
391	if err != nil {
392		return common.ErrorMsg(err)
393	}
394	count, err := r.CountCommits(l.ref)
395	if err != nil {
396		logger.Debugf("ui: error counting commits: %v", err)
397		return common.ErrorMsg(err)
398	}
399	return LogCountMsg(count)
400}
401
402func (l *Log) updateCommitsCmd() tea.Msg {
403	count := l.count
404	if l.count == 0 {
405		switch msg := l.countCommitsCmd().(type) {
406		case common.ErrorMsg:
407			return msg
408		case LogCountMsg:
409			count = int64(msg)
410		}
411	}
412	if l.ref == nil {
413		return nil
414	}
415	items := make([]selector.IdentifiableItem, count)
416	page := l.nextPage
417	limit := l.selector.PerPage()
418	skip := page * limit
419	r, err := l.repo.Open()
420	if err != nil {
421		return common.ErrorMsg(err)
422	}
423	// CommitsByPage pages start at 1
424	cc, err := r.CommitsByPage(l.ref, page+1, limit)
425	if err != nil {
426		logger.Debugf("ui: error loading commits: %v", err)
427		return common.ErrorMsg(err)
428	}
429	for i, c := range cc {
430		idx := i + skip
431		if int64(idx) >= count {
432			break
433		}
434		items[idx] = LogItem{Commit: c}
435	}
436	return LogItemsMsg(items)
437}
438
439func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {
440	return func() tea.Msg {
441		return LogCommitMsg(commit)
442	}
443}
444
445func (l *Log) loadDiffCmd() tea.Msg {
446	r, err := l.repo.Open()
447	if err != nil {
448		logger.Debugf("ui: error loading diff repository: %v", err)
449		return common.ErrorMsg(err)
450	}
451	diff, err := r.Diff(l.selectedCommit)
452	if err != nil {
453		logger.Debugf("ui: error loading diff: %v", err)
454		return common.ErrorMsg(err)
455	}
456	return LogDiffMsg(diff)
457}
458
459func renderCtx() gansi.RenderContext {
460	return gansi.NewRenderContext(gansi.Options{
461		ColorProfile: termenv.TrueColor,
462		Styles:       common.StyleConfig(),
463	})
464}
465
466func (l *Log) renderCommit(c *git.Commit) string {
467	s := strings.Builder{}
468	// FIXME: lipgloss prints empty lines when CRLF is used
469	// sanitize commit message from CRLF
470	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
471	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
472		l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()),
473		l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
474		l.common.Styles.Log.CommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
475		l.common.Styles.Log.CommitBody.Render(msg),
476	))
477	return wrap.String(s.String(), l.common.Width-2)
478}
479
480func (l *Log) renderSummary(diff *git.Diff) string {
481	stats := strings.Split(diff.Stats().String(), "\n")
482	for i, line := range stats {
483		ch := strings.Split(line, "|")
484		if len(ch) > 1 {
485			adddel := ch[len(ch)-1]
486			adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.Log.CommitStatsAdd.Render("+"))
487			adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.Log.CommitStatsDel.Render("-"))
488			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
489		}
490	}
491	return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
492}
493
494func (l *Log) renderDiff(diff *git.Diff) string {
495	var s strings.Builder
496	var pr strings.Builder
497	diffChroma := &gansi.CodeBlockElement{
498		Code:     diff.Patch(),
499		Language: "diff",
500	}
501	err := diffChroma.Render(&pr, renderCtx())
502	if err != nil {
503		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
504	} else {
505		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
506	}
507	return wrap.String(s.String(), l.common.Width)
508}
509
510func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd {
511	return func() tea.Msg {
512		return LogItemsMsg(items)
513	}
514}