log.go

  1package repo
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"time"
  7
  8	"github.com/charmbracelet/bubbles/key"
  9	"github.com/charmbracelet/bubbles/list"
 10	tea "github.com/charmbracelet/bubbletea"
 11	"github.com/charmbracelet/glamour"
 12	gansi "github.com/charmbracelet/glamour/ansi"
 13	"github.com/charmbracelet/lipgloss"
 14	ggit "github.com/charmbracelet/soft-serve/git"
 15	"github.com/charmbracelet/soft-serve/ui/common"
 16	"github.com/charmbracelet/soft-serve/ui/components/selector"
 17	"github.com/charmbracelet/soft-serve/ui/components/viewport"
 18	"github.com/charmbracelet/soft-serve/ui/git"
 19	"github.com/muesli/reflow/wrap"
 20	"github.com/muesli/termenv"
 21)
 22
 23type view int
 24
 25const (
 26	logView view = iota
 27	commitView
 28)
 29
 30// LogCountMsg is a message that contains the number of commits in a repo.
 31type LogCountMsg int64
 32
 33// LogItemsMsg is a message that contains a slice of LogItem.
 34type LogItemsMsg []list.Item
 35
 36// LogCommitMsg is a message that contains a git commit.
 37type LogCommitMsg *ggit.Commit
 38
 39// LogDiffMsg is a message that contains a git diff.
 40type LogDiffMsg *ggit.Diff
 41
 42// Log is a model that displays a list of commits and their diffs.
 43type Log struct {
 44	common         common.Common
 45	selector       *selector.Selector
 46	vp             *viewport.Viewport
 47	activeView     view
 48	repo           git.GitRepo
 49	ref            *ggit.Reference
 50	count          int64
 51	nextPage       int
 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: logView,
 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 logView:
 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 commitView:
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
119// Init implements tea.Model.
120func (l *Log) Init() tea.Cmd {
121	cmds := make([]tea.Cmd, 0)
122	cmds = append(cmds, l.updateCommitsCmd)
123	return tea.Batch(cmds...)
124}
125
126// Update implements tea.Model.
127func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
128	cmds := make([]tea.Cmd, 0)
129	switch msg := msg.(type) {
130	case RepoMsg:
131		l.count = 0
132		l.selector.Select(0)
133		l.nextPage = 0
134		l.activeView = 0
135		l.repo = git.GitRepo(msg)
136	case RefMsg:
137		l.ref = msg
138		l.count = 0
139		cmds = append(cmds, l.countCommitsCmd)
140	case LogCountMsg:
141		l.count = int64(msg)
142	case LogItemsMsg:
143		cmds = append(cmds, l.selector.SetItems(msg))
144		l.selector.SetPage(l.nextPage)
145		l.SetSize(l.common.Width, l.common.Height)
146	case tea.KeyMsg, tea.MouseMsg:
147		switch l.activeView {
148		case logView:
149			switch key := msg.(type) {
150			case tea.KeyMsg:
151				switch key.String() {
152				case "l", "right":
153					cmds = append(cmds, l.selector.SelectItem)
154				}
155			}
156			// This is a hack for loading commits on demand based on list.Pagination.
157			curPage := l.selector.Page()
158			s, cmd := l.selector.Update(msg)
159			m := s.(*selector.Selector)
160			l.selector = m
161			if m.Page() != curPage {
162				l.nextPage = m.Page()
163				l.selector.SetPage(curPage)
164				cmds = append(cmds, l.updateCommitsCmd)
165			}
166			cmds = append(cmds, cmd)
167		case commitView:
168			switch key := msg.(type) {
169			case tea.KeyMsg:
170				switch key.String() {
171				case "h", "left":
172					l.activeView = logView
173				}
174			}
175		}
176	case selector.SelectMsg:
177		switch sel := msg.IdentifiableItem.(type) {
178		case LogItem:
179			cmds = append(cmds, l.selectCommitCmd(sel.Commit))
180		}
181	case LogCommitMsg:
182		l.selectedCommit = msg
183		cmds = append(cmds, l.loadDiffCmd)
184	case LogDiffMsg:
185		l.currentDiff = msg
186		l.vp.SetContent(
187			lipgloss.JoinVertical(lipgloss.Top,
188				l.renderCommit(l.selectedCommit),
189				l.renderSummary(msg),
190				l.renderDiff(msg),
191			),
192		)
193		l.vp.GotoTop()
194		l.activeView = commitView
195		cmds = append(cmds, updateStatusBarCmd)
196	case tea.WindowSizeMsg:
197		if l.selectedCommit != nil && l.currentDiff != nil {
198			l.vp.SetContent(
199				lipgloss.JoinVertical(lipgloss.Top,
200					l.renderCommit(l.selectedCommit),
201					l.renderSummary(l.currentDiff),
202					l.renderDiff(l.currentDiff),
203				),
204			)
205		}
206		if l.repo != nil {
207			cmds = append(cmds, l.updateCommitsCmd)
208		}
209	}
210	switch l.activeView {
211	case commitView:
212		vp, cmd := l.vp.Update(msg)
213		l.vp = vp.(*viewport.Viewport)
214		if cmd != nil {
215			cmds = append(cmds, cmd)
216		}
217	}
218	return l, tea.Batch(cmds...)
219}
220
221// View implements tea.Model.
222func (l *Log) View() string {
223	switch l.activeView {
224	case logView:
225		return l.selector.View()
226	case commitView:
227		return l.vp.View()
228	default:
229		return ""
230	}
231}
232
233// StatusBarInfo returns the status bar info.
234func (l *Log) StatusBarInfo() string {
235	switch l.activeView {
236	case logView:
237		// We're using l.nextPage instead of l.selector.Paginator.Page because
238		// of the paginator hack above.
239		return fmt.Sprintf("%d/%d", l.nextPage+1, l.selector.TotalPages())
240	case commitView:
241		return fmt.Sprintf("%.f%%", l.vp.ScrollPercent()*100)
242	default:
243		return ""
244	}
245}
246
247func (l *Log) countCommitsCmd() tea.Msg {
248	count, err := l.repo.CountCommits(l.ref)
249	if err != nil {
250		return common.ErrorMsg(err)
251	}
252	return LogCountMsg(count)
253}
254
255func (l *Log) updateCommitsCmd() tea.Msg {
256	count := l.count
257	if l.count == 0 {
258		switch msg := l.countCommitsCmd().(type) {
259		case common.ErrorMsg:
260			return msg
261		case LogCountMsg:
262			count = int64(msg)
263		}
264	}
265	items := make([]list.Item, count)
266	page := l.nextPage
267	limit := l.selector.PerPage()
268	skip := page * limit
269	// CommitsByPage pages start at 1
270	cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
271	if err != nil {
272		return common.ErrorMsg(err)
273	}
274	for i, c := range cc {
275		idx := i + skip
276		if int64(idx) >= count {
277			break
278		}
279		items[idx] = LogItem{c}
280	}
281	return LogItemsMsg(items)
282}
283
284func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
285	return func() tea.Msg {
286		return LogCommitMsg(commit)
287	}
288}
289
290func (l *Log) loadDiffCmd() tea.Msg {
291	diff, err := l.repo.Diff(l.selectedCommit)
292	if err != nil {
293		return common.ErrorMsg(err)
294	}
295	return LogDiffMsg(diff)
296}
297
298func styleConfig() gansi.StyleConfig {
299	noColor := ""
300	s := glamour.DarkStyleConfig
301	s.Document.StylePrimitive.Color = &noColor
302	s.CodeBlock.Chroma.Text.Color = &noColor
303	s.CodeBlock.Chroma.Name.Color = &noColor
304	return s
305}
306
307func renderCtx() gansi.RenderContext {
308	return gansi.NewRenderContext(gansi.Options{
309		ColorProfile: termenv.TrueColor,
310		Styles:       styleConfig(),
311	})
312}
313
314func (l *Log) renderCommit(c *ggit.Commit) string {
315	s := strings.Builder{}
316	// FIXME: lipgloss prints empty lines when CRLF is used
317	// sanitize commit message from CRLF
318	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
319	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
320		l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()),
321		l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
322		l.common.Styles.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
323		l.common.Styles.LogCommitBody.Render(msg),
324	))
325	return wrap.String(s.String(), l.common.Width-2)
326}
327
328func (l *Log) renderSummary(diff *ggit.Diff) string {
329	stats := strings.Split(diff.Stats().String(), "\n")
330	for i, line := range stats {
331		ch := strings.Split(line, "|")
332		if len(ch) > 1 {
333			adddel := ch[len(ch)-1]
334			adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.LogCommitStatsAdd.Render("+"))
335			adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-"))
336			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
337		}
338	}
339	return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
340}
341
342func (l *Log) renderDiff(diff *ggit.Diff) string {
343	var s strings.Builder
344	var pr strings.Builder
345	diffChroma := &gansi.CodeBlockElement{
346		Code:     diff.Patch(),
347		Language: "diff",
348	}
349	err := diffChroma.Render(&pr, renderCtx())
350	if err != nil {
351		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
352	} else {
353		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
354	}
355	return wrap.String(s.String(), l.common.Width-2)
356}