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