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
 30type LogCountMsg int64
 31
 32type LogItemsMsg []list.Item
 33
 34type LogCommitMsg *ggit.Commit
 35
 36type LogDiffMsg *ggit.Diff
 37
 38type Log struct {
 39	common         common.Common
 40	selector       *selector.Selector
 41	vp             *viewport.Viewport
 42	activeView     view
 43	repo           git.GitRepo
 44	ref            *ggit.Reference
 45	count          int64
 46	nextPage       int
 47	selectedCommit *ggit.Commit
 48	currentDiff    *ggit.Diff
 49}
 50
 51func NewLog(common common.Common) *Log {
 52	l := &Log{
 53		common:     common,
 54		vp:         viewport.New(),
 55		activeView: logView,
 56	}
 57	selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{common.Styles})
 58	selector.SetShowFilter(false)
 59	selector.SetShowHelp(false)
 60	selector.SetShowPagination(false)
 61	selector.SetShowStatusBar(false)
 62	selector.SetShowTitle(false)
 63	selector.SetFilteringEnabled(false)
 64	selector.DisableQuitKeybindings()
 65	selector.KeyMap.NextPage = common.KeyMap.NextPage
 66	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
 67	l.selector = selector
 68	return l
 69}
 70
 71func (l *Log) SetSize(width, height int) {
 72	l.common.SetSize(width, height)
 73	l.selector.SetSize(width, height)
 74	l.vp.SetSize(width, height)
 75}
 76
 77func (l *Log) ShortHelp() []key.Binding {
 78	switch l.activeView {
 79	case logView:
 80		return []key.Binding{
 81			key.NewBinding(
 82				key.WithKeys(
 83					"l",
 84					"right",
 85				),
 86				key.WithHelp(
 87					"→",
 88					"select",
 89				),
 90			),
 91		}
 92	case commitView:
 93		return []key.Binding{
 94			l.common.KeyMap.UpDown,
 95			key.NewBinding(
 96				key.WithKeys(
 97					"h",
 98					"left",
 99				),
100				key.WithHelp(
101					"←",
102					"back",
103				),
104			),
105		}
106	default:
107		return []key.Binding{}
108	}
109}
110
111func (l *Log) Init() tea.Cmd {
112	cmds := make([]tea.Cmd, 0)
113	cmds = append(cmds, l.updateCommitsCmd)
114	return tea.Batch(cmds...)
115}
116
117func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118	cmds := make([]tea.Cmd, 0)
119	switch msg := msg.(type) {
120	case RepoMsg:
121		l.count = 0
122		l.selector.Select(0)
123		l.nextPage = 0
124		l.activeView = 0
125		l.repo = git.GitRepo(msg)
126	case RefMsg:
127		l.ref = msg
128		l.count = 0
129		cmds = append(cmds, l.countCommitsCmd)
130	case LogCountMsg:
131		l.count = int64(msg)
132	case LogItemsMsg:
133		cmds = append(cmds, l.selector.SetItems(msg))
134		l.selector.SetPage(l.nextPage)
135		l.SetSize(l.common.Width, l.common.Height)
136	case tea.KeyMsg, tea.MouseMsg:
137		switch l.activeView {
138		case logView:
139			switch key := msg.(type) {
140			case tea.KeyMsg:
141				switch key.String() {
142				case "l", "right":
143					cmds = append(cmds, l.selector.SelectItem)
144				}
145			}
146			// This is a hack for loading commits on demand based on list.Pagination.
147			curPage := l.selector.Page()
148			s, cmd := l.selector.Update(msg)
149			m := s.(*selector.Selector)
150			l.selector = m
151			if m.Page() != curPage {
152				l.nextPage = m.Page()
153				l.selector.SetPage(curPage)
154				cmds = append(cmds, l.updateCommitsCmd)
155			}
156			cmds = append(cmds, cmd)
157		case commitView:
158			switch key := msg.(type) {
159			case tea.KeyMsg:
160				switch key.String() {
161				case "h", "left":
162					l.activeView = logView
163				}
164			}
165		}
166	case selector.SelectMsg:
167		switch sel := msg.IdentifiableItem.(type) {
168		case LogItem:
169			cmds = append(cmds, l.selectCommitCmd(sel.Commit))
170		}
171	case LogCommitMsg:
172		l.selectedCommit = msg
173		cmds = append(cmds, l.loadDiffCmd)
174	case LogDiffMsg:
175		l.currentDiff = msg
176		l.vp.SetContent(
177			lipgloss.JoinVertical(lipgloss.Top,
178				l.renderCommit(l.selectedCommit),
179				l.renderSummary(msg),
180				l.renderDiff(msg),
181			),
182		)
183		l.vp.GotoTop()
184		l.activeView = commitView
185		cmds = append(cmds, updateStatusBarCmd)
186	case tea.WindowSizeMsg:
187		if l.selectedCommit != nil && l.currentDiff != nil {
188			l.vp.SetContent(
189				lipgloss.JoinVertical(lipgloss.Top,
190					l.renderCommit(l.selectedCommit),
191					l.renderSummary(l.currentDiff),
192					l.renderDiff(l.currentDiff),
193				),
194			)
195		}
196		if l.repo != nil {
197			cmds = append(cmds, l.updateCommitsCmd)
198		}
199	}
200	switch l.activeView {
201	case commitView:
202		vp, cmd := l.vp.Update(msg)
203		l.vp = vp.(*viewport.Viewport)
204		if cmd != nil {
205			cmds = append(cmds, cmd)
206		}
207	}
208	return l, tea.Batch(cmds...)
209}
210
211func (l *Log) View() string {
212	switch l.activeView {
213	case logView:
214		return l.selector.View()
215	case commitView:
216		return l.vp.View()
217	default:
218		return ""
219	}
220}
221
222func (l *Log) StatusBarInfo() string {
223	switch l.activeView {
224	case logView:
225		// We're using l.nextPage instead of l.selector.Paginator.Page because
226		// of the paginator hack above.
227		return fmt.Sprintf("%d/%d", l.nextPage+1, l.selector.TotalPages())
228	case commitView:
229		return fmt.Sprintf("%.f%%", l.vp.ScrollPercent()*100)
230	default:
231		return ""
232	}
233}
234
235func (l *Log) countCommitsCmd() tea.Msg {
236	count, err := l.repo.CountCommits(l.ref)
237	if err != nil {
238		return common.ErrorMsg(err)
239	}
240	return LogCountMsg(count)
241}
242
243func (l *Log) updateCommitsCmd() tea.Msg {
244	count := l.count
245	if l.count == 0 {
246		switch msg := l.countCommitsCmd().(type) {
247		case common.ErrorMsg:
248			return msg
249		case LogCountMsg:
250			count = int64(msg)
251		}
252	}
253	items := make([]list.Item, count)
254	page := l.nextPage
255	limit := l.selector.PerPage()
256	skip := page * limit
257	// CommitsByPage pages start at 1
258	cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
259	if err != nil {
260		return common.ErrorMsg(err)
261	}
262	for i, c := range cc {
263		idx := i + skip
264		if int64(idx) >= count {
265			break
266		}
267		items[idx] = LogItem{c}
268	}
269	return LogItemsMsg(items)
270}
271
272func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
273	return func() tea.Msg {
274		return LogCommitMsg(commit)
275	}
276}
277
278func (l *Log) loadDiffCmd() tea.Msg {
279	diff, err := l.repo.Diff(l.selectedCommit)
280	if err != nil {
281		return common.ErrorMsg(err)
282	}
283	return LogDiffMsg(diff)
284}
285
286func styleConfig() gansi.StyleConfig {
287	noColor := ""
288	s := glamour.DarkStyleConfig
289	s.Document.StylePrimitive.Color = &noColor
290	s.CodeBlock.Chroma.Text.Color = &noColor
291	s.CodeBlock.Chroma.Name.Color = &noColor
292	return s
293}
294
295func renderCtx() gansi.RenderContext {
296	return gansi.NewRenderContext(gansi.Options{
297		ColorProfile: termenv.TrueColor,
298		Styles:       styleConfig(),
299	})
300}
301
302func (l *Log) renderCommit(c *ggit.Commit) string {
303	s := strings.Builder{}
304	// FIXME: lipgloss prints empty lines when CRLF is used
305	// sanitize commit message from CRLF
306	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
307	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
308		l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()),
309		l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
310		l.common.Styles.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
311		l.common.Styles.LogCommitBody.Render(msg),
312	))
313	return wrap.String(s.String(), l.common.Width-2)
314}
315
316func (l *Log) renderSummary(diff *ggit.Diff) string {
317	stats := strings.Split(diff.Stats().String(), "\n")
318	for i, line := range stats {
319		ch := strings.Split(line, "|")
320		if len(ch) > 1 {
321			adddel := ch[len(ch)-1]
322			adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.LogCommitStatsAdd.Render("+"))
323			adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-"))
324			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
325		}
326	}
327	return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
328}
329
330func (l *Log) renderDiff(diff *ggit.Diff) string {
331	var s strings.Builder
332	var pr strings.Builder
333	diffChroma := &gansi.CodeBlockElement{
334		Code:     diff.Patch(),
335		Language: "diff",
336	}
337	err := diffChroma.Render(&pr, renderCtx())
338	if err != nil {
339		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
340	} else {
341		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
342	}
343	return wrap.String(s.String(), l.common.Width-2)
344}