bubble.go

  1package log
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"math"
  8	"strings"
  9	"time"
 10
 11	"github.com/charmbracelet/bubbles/list"
 12	"github.com/charmbracelet/bubbles/viewport"
 13	tea "github.com/charmbracelet/bubbletea"
 14	gansi "github.com/charmbracelet/glamour/ansi"
 15	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 16	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
 17	"github.com/charmbracelet/soft-serve/internal/tui/style"
 18	"github.com/dustin/go-humanize/english"
 19	"github.com/go-git/go-git/v5/plumbing/object"
 20)
 21
 22var (
 23	diffChroma = &gansi.CodeBlockElement{
 24		Code:     "",
 25		Language: "diff",
 26	}
 27)
 28
 29type commitMsg struct {
 30	commit     *object.Commit
 31	parent     *object.Commit
 32	tree       *object.Tree
 33	parentTree *object.Tree
 34	patch      *object.Patch
 35}
 36
 37type sessionState int
 38
 39const (
 40	logState sessionState = iota
 41	commitState
 42	errorState
 43)
 44
 45type item struct {
 46	*types.Commit
 47}
 48
 49func (i item) Title() string {
 50	lines := strings.Split(i.Message, "\n")
 51	if len(lines) > 0 {
 52		return lines[0]
 53	}
 54	return ""
 55}
 56
 57func (i item) FilterValue() string { return i.Title() }
 58
 59type itemDelegate struct {
 60	style *style.Styles
 61}
 62
 63func (d itemDelegate) Height() int                               { return 1 }
 64func (d itemDelegate) Spacing() int                              { return 0 }
 65func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
 66func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 67	i, ok := listItem.(item)
 68	if !ok {
 69		return
 70	}
 71
 72	leftMargin := d.style.LogItemSelector.GetMarginLeft() +
 73		d.style.LogItemSelector.GetWidth() +
 74		d.style.LogItemHash.GetMarginLeft() +
 75		d.style.LogItemHash.GetWidth() +
 76		d.style.LogItemInactive.GetMarginLeft()
 77	title := types.TruncateString(i.Title(), m.Width()-leftMargin, "…")
 78	if index == m.Index() {
 79		fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
 80			d.style.LogItemHash.Bold(true).Render(i.Hash.String()[:7])+
 81			d.style.LogItemActive.Render(title))
 82	} else {
 83		fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
 84			d.style.LogItemHash.Render(i.Hash.String()[:7])+
 85			d.style.LogItemInactive.Render(title))
 86	}
 87}
 88
 89type Bubble struct {
 90	repo           types.Repo
 91	list           list.Model
 92	state          sessionState
 93	commitViewport *vp.ViewportBubble
 94	style          *style.Styles
 95	width          int
 96	widthMargin    int
 97	height         int
 98	heightMargin   int
 99	error          types.ErrMsg
100}
101
102func NewBubble(repo types.Repo, style *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
103	l := list.New([]list.Item{}, itemDelegate{style}, width-widthMargin, height-heightMargin)
104	l.SetShowFilter(false)
105	l.SetShowHelp(false)
106	l.SetShowPagination(false)
107	l.SetShowStatusBar(false)
108	l.SetShowTitle(false)
109	l.SetFilteringEnabled(false)
110	l.DisableQuitKeybindings()
111	l.KeyMap.NextPage = types.NextPage
112	l.KeyMap.PrevPage = types.PrevPage
113	b := &Bubble{
114		commitViewport: &vp.ViewportBubble{
115			Viewport: &viewport.Model{},
116		},
117		repo:         repo,
118		style:        style,
119		state:        logState,
120		width:        width,
121		widthMargin:  widthMargin,
122		height:       height,
123		heightMargin: heightMargin,
124		list:         l,
125	}
126	b.SetSize(width, height)
127	return b
128}
129
130func (b *Bubble) updateItems() tea.Cmd {
131	items := make([]list.Item, 0)
132	cc, err := b.repo.GetCommits(0)
133	if err != nil {
134		return func() tea.Msg { return types.ErrMsg{err} }
135	}
136	for _, c := range cc {
137		items = append(items, item{c})
138	}
139	return b.list.SetItems(items)
140}
141
142func (b *Bubble) Help() []types.HelpEntry {
143	switch b.state {
144	case logState:
145		return []types.HelpEntry{
146			{"enter", "select"},
147		}
148	default:
149		return []types.HelpEntry{
150			{"esc", "back"},
151		}
152	}
153}
154
155func (b *Bubble) GotoTop() {
156	b.commitViewport.Viewport.GotoTop()
157}
158
159func (b *Bubble) Init() tea.Cmd {
160	return b.updateItems()
161}
162
163func (b *Bubble) SetSize(width, height int) {
164	b.width = width
165	b.height = height
166	b.commitViewport.Viewport.Width = width - b.widthMargin
167	b.commitViewport.Viewport.Height = height - b.heightMargin
168	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
169}
170
171func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172	cmds := make([]tea.Cmd, 0)
173	switch msg := msg.(type) {
174	case tea.WindowSizeMsg:
175		b.SetSize(msg.Width, msg.Height)
176
177	case tea.KeyMsg:
178		switch msg.String() {
179		case "L":
180			b.state = logState
181			b.list.Select(0)
182			cmds = append(cmds, b.updateItems())
183		case "enter", "right", "l":
184			if b.state == logState {
185				cmds = append(cmds, b.loadCommit())
186			}
187		case "esc", "left", "h":
188			if b.state != logState {
189				b.state = logState
190			}
191		}
192	case types.ErrMsg:
193		b.error = msg
194		b.state = errorState
195		return b, nil
196	case commitMsg:
197		content := b.renderCommit(msg)
198		b.state = commitState
199		b.commitViewport.Viewport.SetContent(content)
200		b.GotoTop()
201	}
202
203	switch b.state {
204	case commitState:
205		rv, cmd := b.commitViewport.Update(msg)
206		b.commitViewport = rv.(*vp.ViewportBubble)
207		cmds = append(cmds, cmd)
208	case logState:
209		l, cmd := b.list.Update(msg)
210		b.list = l
211		cmds = append(cmds, cmd)
212	}
213
214	return b, tea.Batch(cmds...)
215}
216
217func (b *Bubble) loadCommit() tea.Cmd {
218	return func() tea.Msg {
219		i := b.list.SelectedItem()
220		if i == nil {
221			return nil
222		}
223		c, ok := i.(item)
224		if !ok {
225			return nil
226		}
227		// Using commit trees fixes the issue when generating diff for the first commit
228		// https://github.com/go-git/go-git/issues/281
229		tree, err := c.Tree()
230		if err != nil {
231			return types.ErrMsg{err}
232		}
233		var parent *object.Commit
234		parentTree := &object.Tree{}
235		if c.NumParents() > 0 {
236			parent, err = c.Parents().Next()
237			if err != nil {
238				return types.ErrMsg{err}
239			}
240			parentTree, err = parent.Tree()
241			if err != nil {
242				return types.ErrMsg{err}
243			}
244		}
245		ctx, cancel := context.WithTimeout(context.TODO(), types.MaxPatchWait)
246		defer cancel()
247		patch, err := parentTree.PatchContext(ctx, tree)
248		if err != nil {
249			return types.ErrMsg{err}
250		}
251		return commitMsg{
252			commit:     c.Commit.Commit,
253			tree:       tree,
254			parent:     parent,
255			parentTree: parentTree,
256			patch:      patch,
257		}
258	}
259}
260
261func (b *Bubble) renderCommit(m commitMsg) string {
262	s := strings.Builder{}
263	st := b.style
264	c := m.commit
265	// FIXME: lipgloss prints empty lines when CRLF is used
266	// sanitize commit message from CRLF
267	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
268	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
269		st.LogCommitHash.Render("commit "+c.Hash.String()),
270		st.LogCommitAuthor.Render("Author: "+c.Author.String()),
271		st.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
272		st.LogCommitBody.Render(msg),
273	))
274	stats := m.patch.Stats()
275	if len(stats) > types.MaxDiffFiles {
276		s.WriteString("\n" + types.ErrDiffFilesTooLong.Error())
277	} else {
278		s.WriteString("\n" + b.renderStats(stats))
279	}
280	ps := m.patch.String()
281	if len(strings.Split(ps, "\n")) > types.MaxDiffLines {
282		s.WriteString("\n" + types.ErrDiffTooLong.Error())
283	} else {
284		p := strings.Builder{}
285		diffChroma.Code = ps
286		err := diffChroma.Render(&p, types.RenderCtx)
287		if err != nil {
288			s.WriteString(fmt.Sprintf("\n%s", err.Error()))
289		} else {
290			s.WriteString(fmt.Sprintf("\n%s", p.String()))
291		}
292	}
293	return st.LogCommit.Copy().Width(b.width - b.widthMargin - st.LogCommit.GetHorizontalFrameSize()).Render(s.String())
294}
295
296func (b *Bubble) renderStats(fileStats object.FileStats) string {
297	padLength := float64(len(" "))
298	newlineLength := float64(len("\n"))
299	separatorLength := float64(len("|"))
300	// Soft line length limit. The text length calculation below excludes
301	// length of the change number. Adding that would take it closer to 80,
302	// but probably not more than 80, until it's a huge number.
303	lineLength := 72.0
304
305	// Get the longest filename and longest total change.
306	var longestLength float64
307	var longestTotalChange float64
308	for _, fs := range fileStats {
309		if int(longestLength) < len(fs.Name) {
310			longestLength = float64(len(fs.Name))
311		}
312		totalChange := fs.Addition + fs.Deletion
313		if int(longestTotalChange) < totalChange {
314			longestTotalChange = float64(totalChange)
315		}
316	}
317
318	// Parts of the output:
319	// <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
320	// example: " main.go | 10 +++++++--- "
321
322	// <pad><filename><pad>
323	leftTextLength := padLength + longestLength + padLength
324
325	// <pad><number><pad><+++++/-----><newline>
326	// Excluding number length here.
327	rightTextLength := padLength + padLength + newlineLength
328
329	totalTextArea := leftTextLength + separatorLength + rightTextLength
330	heightOfHistogram := lineLength - totalTextArea
331
332	// Scale the histogram.
333	var scaleFactor float64
334	if longestTotalChange > heightOfHistogram {
335		// Scale down to heightOfHistogram.
336		scaleFactor = longestTotalChange / heightOfHistogram
337	} else {
338		scaleFactor = 1.0
339	}
340
341	taddc := 0
342	tdelc := 0
343	output := strings.Builder{}
344	for _, fs := range fileStats {
345		taddc += fs.Addition
346		tdelc += fs.Deletion
347		addn := float64(fs.Addition)
348		deln := float64(fs.Deletion)
349		addc := int(math.Floor(addn / scaleFactor))
350		delc := int(math.Floor(deln / scaleFactor))
351		if addc < 0 {
352			addc = 0
353		}
354		if delc < 0 {
355			delc = 0
356		}
357		adds := strings.Repeat("+", addc)
358		dels := strings.Repeat("-", delc)
359		diffLines := fmt.Sprint(fs.Addition + fs.Deletion)
360		totalDiffLines := fmt.Sprint(int(longestTotalChange))
361		fmt.Fprintf(&output, "%s | %s %s%s\n",
362			fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)),
363			strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines,
364			b.style.LogCommitStatsAdd.Render(adds),
365			b.style.LogCommitStatsDel.Render(dels))
366	}
367	files := len(fileStats)
368	fc := fmt.Sprintf("%s changed", english.Plural(files, "file", ""))
369	ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", ""))
370	dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", ""))
371	fmt.Fprint(&output, fc)
372	if taddc > 0 {
373		fmt.Fprintf(&output, ", %s", ins)
374	}
375	if tdelc > 0 {
376		fmt.Fprintf(&output, ", %s", dels)
377	}
378	fmt.Fprint(&output, "\n")
379
380	return output.String()
381}
382
383func (b *Bubble) View() string {
384	switch b.state {
385	case logState:
386		return b.list.View()
387	case errorState:
388		return b.error.ViewWithPrefix(b.style, "Error")
389	case commitState:
390		return b.commitViewport.View()
391	default:
392		return ""
393	}
394}