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