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