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