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