log.go

  1package repo
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"time"
  7
  8	"github.com/charmbracelet/bubbles/key"
  9	tea "github.com/charmbracelet/bubbletea"
 10	"github.com/charmbracelet/glamour"
 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/selector"
 16	"github.com/charmbracelet/soft-serve/ui/components/viewport"
 17	"github.com/charmbracelet/soft-serve/ui/git"
 18	"github.com/muesli/reflow/wrap"
 19	"github.com/muesli/termenv"
 20)
 21
 22type logView int
 23
 24const (
 25	logViewCommits logView = iota
 26	logViewDiff
 27)
 28
 29// LogCountMsg is a message that contains the number of commits in a repo.
 30type LogCountMsg int64
 31
 32// LogItemsMsg is a message that contains a slice of LogItem.
 33type LogItemsMsg []selector.IdentifiableItem
 34
 35// LogCommitMsg is a message that contains a git commit.
 36type LogCommitMsg *ggit.Commit
 37
 38// LogDiffMsg is a message that contains a git diff.
 39type LogDiffMsg *ggit.Diff
 40
 41// Log is a model that displays a list of commits and their diffs.
 42type Log struct {
 43	common         common.Common
 44	selector       *selector.Selector
 45	vp             *viewport.Viewport
 46	activeView     logView
 47	repo           git.GitRepo
 48	ref            *ggit.Reference
 49	count          int64
 50	nextPage       int
 51	activeCommit   *ggit.Commit
 52	selectedCommit *ggit.Commit
 53	currentDiff    *ggit.Diff
 54}
 55
 56// NewLog creates a new Log model.
 57func NewLog(common common.Common) *Log {
 58	l := &Log{
 59		common:     common,
 60		vp:         viewport.New(common),
 61		activeView: logViewCommits,
 62	}
 63	selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common})
 64	selector.SetShowFilter(false)
 65	selector.SetShowHelp(false)
 66	selector.SetShowPagination(false)
 67	selector.SetShowStatusBar(false)
 68	selector.SetShowTitle(false)
 69	selector.SetFilteringEnabled(false)
 70	selector.DisableQuitKeybindings()
 71	selector.KeyMap.NextPage = common.KeyMap.NextPage
 72	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
 73	l.selector = selector
 74	return l
 75}
 76
 77// SetSize implements common.Component.
 78func (l *Log) SetSize(width, height int) {
 79	l.common.SetSize(width, height)
 80	l.selector.SetSize(width, height)
 81	l.vp.SetSize(width, height)
 82}
 83
 84// ShortHelp implements help.KeyMap.
 85func (l *Log) ShortHelp() []key.Binding {
 86	switch l.activeView {
 87	case logViewCommits:
 88		copyKey := l.common.KeyMap.Copy
 89		copyKey.SetHelp("c", "copy hash")
 90		return []key.Binding{
 91			l.common.KeyMap.UpDown,
 92			l.common.KeyMap.SelectItem,
 93			copyKey,
 94		}
 95	case logViewDiff:
 96		return []key.Binding{
 97			l.common.KeyMap.UpDown,
 98			l.common.KeyMap.BackItem,
 99		}
100	default:
101		return []key.Binding{}
102	}
103}
104
105// FullHelp implements help.KeyMap.
106func (l *Log) FullHelp() [][]key.Binding {
107	k := l.selector.KeyMap
108	b := make([][]key.Binding, 0)
109	switch l.activeView {
110	case logViewCommits:
111		copyKey := l.common.KeyMap.Copy
112		copyKey.SetHelp("c", "copy hash")
113		b = append(b, []key.Binding{
114			l.common.KeyMap.SelectItem,
115			l.common.KeyMap.BackItem,
116		})
117		b = append(b, [][]key.Binding{
118			{
119				copyKey,
120				k.CursorUp,
121				k.CursorDown,
122			},
123			{
124				k.NextPage,
125				k.PrevPage,
126				k.GoToStart,
127				k.GoToEnd,
128			},
129		}...)
130	case logViewDiff:
131		k := l.vp.KeyMap
132		b = append(b, []key.Binding{
133			l.common.KeyMap.BackItem,
134		})
135		b = append(b, [][]key.Binding{
136			{
137				k.PageDown,
138				k.PageUp,
139				k.HalfPageDown,
140				k.HalfPageUp,
141			},
142			{
143				k.Down,
144				k.Up,
145			},
146		}...)
147	}
148	return b
149}
150
151// Init implements tea.Model.
152func (l *Log) Init() tea.Cmd {
153	l.activeView = logViewCommits
154	l.nextPage = 0
155	l.count = 0
156	l.activeCommit = nil
157	l.selector.Select(0)
158	return l.updateCommitsCmd
159}
160
161// Update implements tea.Model.
162func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163	cmds := make([]tea.Cmd, 0)
164	switch msg := msg.(type) {
165	case RepoMsg:
166		l.repo = git.GitRepo(msg)
167		cmds = append(cmds, l.Init())
168	case RefMsg:
169		l.ref = msg
170		cmds = append(cmds, l.Init())
171	case LogCountMsg:
172		l.count = int64(msg)
173	case LogItemsMsg:
174		cmds = append(cmds, l.selector.SetItems(msg))
175		l.selector.SetPage(l.nextPage)
176		l.SetSize(l.common.Width, l.common.Height)
177		i := l.selector.SelectedItem()
178		if i != nil {
179			l.activeCommit = i.(LogItem).Commit
180		}
181	case tea.KeyMsg, tea.MouseMsg:
182		switch l.activeView {
183		case logViewCommits:
184			switch key := msg.(type) {
185			case tea.KeyMsg:
186				switch key.String() {
187				case "l", "right":
188					cmds = append(cmds, l.selector.SelectItem)
189				}
190			}
191			// This is a hack for loading commits on demand based on list.Pagination.
192			curPage := l.selector.Page()
193			s, cmd := l.selector.Update(msg)
194			m := s.(*selector.Selector)
195			l.selector = m
196			if m.Page() != curPage {
197				l.nextPage = m.Page()
198				l.selector.SetPage(curPage)
199				cmds = append(cmds, l.updateCommitsCmd)
200			}
201			cmds = append(cmds, cmd)
202		case logViewDiff:
203			switch key := msg.(type) {
204			case tea.KeyMsg:
205				switch key.String() {
206				case "h", "left":
207					l.activeView = logViewCommits
208				}
209			}
210		}
211	case selector.ActiveMsg:
212		switch sel := msg.IdentifiableItem.(type) {
213		case LogItem:
214			l.activeCommit = sel.Commit
215		}
216		cmds = append(cmds, updateStatusBarCmd)
217	case selector.SelectMsg:
218		switch sel := msg.IdentifiableItem.(type) {
219		case LogItem:
220			cmds = append(cmds, l.selectCommitCmd(sel.Commit))
221		}
222	case LogCommitMsg:
223		l.selectedCommit = msg
224		cmds = append(cmds, l.loadDiffCmd)
225	case LogDiffMsg:
226		l.currentDiff = msg
227		l.vp.SetContent(
228			lipgloss.JoinVertical(lipgloss.Top,
229				l.renderCommit(l.selectedCommit),
230				l.renderSummary(msg),
231				l.renderDiff(msg),
232			),
233		)
234		l.vp.GotoTop()
235		l.activeView = logViewDiff
236		cmds = append(cmds, updateStatusBarCmd)
237	case tea.WindowSizeMsg:
238		if l.selectedCommit != nil && l.currentDiff != nil {
239			l.vp.SetContent(
240				lipgloss.JoinVertical(lipgloss.Top,
241					l.renderCommit(l.selectedCommit),
242					l.renderSummary(l.currentDiff),
243					l.renderDiff(l.currentDiff),
244				),
245			)
246		}
247		if l.repo != nil {
248			cmds = append(cmds, l.updateCommitsCmd)
249		}
250	}
251	switch l.activeView {
252	case logViewDiff:
253		vp, cmd := l.vp.Update(msg)
254		l.vp = vp.(*viewport.Viewport)
255		if cmd != nil {
256			cmds = append(cmds, cmd)
257		}
258	}
259	return l, tea.Batch(cmds...)
260}
261
262// View implements tea.Model.
263func (l *Log) View() string {
264	switch l.activeView {
265	case logViewCommits:
266		return l.selector.View()
267	case logViewDiff:
268		return l.vp.View()
269	default:
270		return ""
271	}
272}
273
274// StatusBarValue returns the status bar value.
275func (l *Log) StatusBarValue() string {
276	c := l.activeCommit
277	if c == nil {
278		return ""
279	}
280	return fmt.Sprintf("%s by %s",
281		c.ID.String(),
282		fmt.Sprintf("%s <%s>", c.Author.Name, c.Author.Email),
283	)
284}
285
286// StatusBarInfo returns the status bar info.
287func (l *Log) StatusBarInfo() string {
288	switch l.activeView {
289	case logViewCommits:
290		// We're using l.nextPage instead of l.selector.Paginator.Page because
291		// of the paginator hack above.
292		return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
293	case logViewDiff:
294		return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
295	default:
296		return ""
297	}
298}
299
300func (l *Log) countCommitsCmd() tea.Msg {
301	count, err := l.repo.CountCommits(l.ref)
302	if err != nil {
303		return common.ErrorMsg(err)
304	}
305	return LogCountMsg(count)
306}
307
308func (l *Log) updateCommitsCmd() tea.Msg {
309	count := l.count
310	if l.count == 0 {
311		switch msg := l.countCommitsCmd().(type) {
312		case common.ErrorMsg:
313			return msg
314		case LogCountMsg:
315			count = int64(msg)
316		}
317	}
318	items := make([]selector.IdentifiableItem, count)
319	page := l.nextPage
320	limit := l.selector.PerPage()
321	skip := page * limit
322	// CommitsByPage pages start at 1
323	cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
324	if err != nil {
325		return common.ErrorMsg(err)
326	}
327	for i, c := range cc {
328		idx := i + skip
329		if int64(idx) >= count {
330			break
331		}
332		items[idx] = LogItem{Commit: c}
333	}
334	return LogItemsMsg(items)
335}
336
337func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
338	return func() tea.Msg {
339		return LogCommitMsg(commit)
340	}
341}
342
343func (l *Log) loadDiffCmd() tea.Msg {
344	diff, err := l.repo.Diff(l.selectedCommit)
345	if err != nil {
346		return common.ErrorMsg(err)
347	}
348	return LogDiffMsg(diff)
349}
350
351func styleConfig() gansi.StyleConfig {
352	noColor := ""
353	s := glamour.DarkStyleConfig
354	s.Document.StylePrimitive.Color = &noColor
355	s.CodeBlock.Chroma.Text.Color = &noColor
356	s.CodeBlock.Chroma.Name.Color = &noColor
357	return s
358}
359
360func renderCtx() gansi.RenderContext {
361	return gansi.NewRenderContext(gansi.Options{
362		ColorProfile: termenv.TrueColor,
363		Styles:       styleConfig(),
364	})
365}
366
367func (l *Log) renderCommit(c *ggit.Commit) string {
368	s := strings.Builder{}
369	// FIXME: lipgloss prints empty lines when CRLF is used
370	// sanitize commit message from CRLF
371	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
372	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
373		l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()),
374		l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
375		l.common.Styles.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
376		l.common.Styles.LogCommitBody.Render(msg),
377	))
378	return wrap.String(s.String(), l.common.Width-2)
379}
380
381func (l *Log) renderSummary(diff *ggit.Diff) string {
382	stats := strings.Split(diff.Stats().String(), "\n")
383	for i, line := range stats {
384		ch := strings.Split(line, "|")
385		if len(ch) > 1 {
386			adddel := ch[len(ch)-1]
387			adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.LogCommitStatsAdd.Render("+"))
388			adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-"))
389			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
390		}
391	}
392	return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
393}
394
395func (l *Log) renderDiff(diff *ggit.Diff) string {
396	var s strings.Builder
397	var pr strings.Builder
398	diffChroma := &gansi.CodeBlockElement{
399		Code:     diff.Patch(),
400		Language: "diff",
401	}
402	err := diffChroma.Render(&pr, renderCtx())
403	if err != nil {
404		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
405	} else {
406		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
407	}
408	return wrap.String(s.String(), l.common.Width-2)
409}