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