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