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