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