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