log.go

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