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