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	return nil
144}
145
146func (b *Bubble) GotoTop() {
147	b.commitViewport.Viewport.GotoTop()
148}
149
150func (b *Bubble) Init() tea.Cmd {
151	return b.updateItems()
152}
153
154func (b *Bubble) SetSize(width, height int) {
155	b.width = width
156	b.height = height
157	b.commitViewport.Viewport.Width = width - b.widthMargin
158	b.commitViewport.Viewport.Height = height - b.heightMargin
159	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
160}
161
162func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163	cmds := make([]tea.Cmd, 0)
164	switch msg := msg.(type) {
165	case tea.WindowSizeMsg:
166		b.SetSize(msg.Width, msg.Height)
167
168	case tea.KeyMsg:
169		switch msg.String() {
170		case "C":
171			b.state = logState
172			b.list.Select(0)
173			cmds = append(cmds, b.updateItems())
174		case "enter", "right", "l":
175			if b.state == logState {
176				cmds = append(cmds, b.loadCommit())
177			}
178		case "esc", "left", "h":
179			if b.state != logState {
180				b.state = logState
181			}
182		}
183	case types.ErrMsg:
184		b.error = msg
185		b.state = errorState
186		return b, nil
187	case commitMsg:
188		content := b.renderCommit(msg)
189		b.state = commitState
190		b.commitViewport.Viewport.SetContent(content)
191		b.GotoTop()
192	}
193
194	switch b.state {
195	case commitState:
196		rv, cmd := b.commitViewport.Update(msg)
197		b.commitViewport = rv.(*vp.ViewportBubble)
198		cmds = append(cmds, cmd)
199	case logState:
200		l, cmd := b.list.Update(msg)
201		b.list = l
202		cmds = append(cmds, cmd)
203	}
204
205	return b, tea.Batch(cmds...)
206}
207
208func (b *Bubble) loadCommit() tea.Cmd {
209	return func() tea.Msg {
210		i := b.list.SelectedItem()
211		if i == nil {
212			return nil
213		}
214		c, ok := i.(item)
215		if !ok {
216			return nil
217		}
218		// Using commit trees fixes the issue when generating diff for the first commit
219		// https://github.com/go-git/go-git/issues/281
220		tree, err := c.Tree()
221		if err != nil {
222			return types.ErrMsg{err}
223		}
224		var parent *object.Commit
225		parentTree := &object.Tree{}
226		if c.NumParents() > 0 {
227			parent, err = c.Parents().Next()
228			if err != nil {
229				return types.ErrMsg{err}
230			}
231			parentTree, err = parent.Tree()
232			if err != nil {
233				return types.ErrMsg{err}
234			}
235		}
236		ctx, cancel := context.WithTimeout(context.TODO(), types.MaxPatchWait)
237		defer cancel()
238		patch, err := parentTree.PatchContext(ctx, tree)
239		if err != nil {
240			return types.ErrMsg{err}
241		}
242		return commitMsg{
243			commit:     c.Commit.Commit,
244			tree:       tree,
245			parent:     parent,
246			parentTree: parentTree,
247			patch:      patch,
248		}
249	}
250}
251
252func (b *Bubble) renderCommit(m commitMsg) string {
253	s := strings.Builder{}
254	st := b.style
255	c := m.commit
256	// FIXME: lipgloss prints empty lines when CRLF is used
257	// sanitize commit message from CRLF
258	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
259	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
260		st.LogCommitHash.Render("commit "+c.Hash.String()),
261		st.LogCommitAuthor.Render("Author: "+c.Author.String()),
262		st.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
263		st.LogCommitBody.Render(msg),
264	))
265	stats := m.patch.Stats()
266	if len(stats) > types.MaxDiffFiles {
267		s.WriteString("\n" + types.ErrDiffFilesTooLong.Error())
268	} else {
269		s.WriteString("\n" + b.renderStats(stats))
270	}
271	ps := m.patch.String()
272	if len(strings.Split(ps, "\n")) > types.MaxDiffLines {
273		s.WriteString("\n" + types.ErrDiffTooLong.Error())
274	} else {
275		p := strings.Builder{}
276		diffChroma.Code = ps
277		err := diffChroma.Render(&p, types.RenderCtx)
278		if err != nil {
279			s.WriteString(fmt.Sprintf("\n%s", err.Error()))
280		} else {
281			s.WriteString(fmt.Sprintf("\n%s", p.String()))
282		}
283	}
284	return st.LogCommit.Copy().Width(b.width - b.widthMargin - st.LogCommit.GetHorizontalFrameSize()).Render(s.String())
285}
286
287func (b *Bubble) renderStats(fileStats object.FileStats) string {
288	padLength := float64(len(" "))
289	newlineLength := float64(len("\n"))
290	separatorLength := float64(len("|"))
291	// Soft line length limit. The text length calculation below excludes
292	// length of the change number. Adding that would take it closer to 80,
293	// but probably not more than 80, until it's a huge number.
294	lineLength := 72.0
295
296	// Get the longest filename and longest total change.
297	var longestLength float64
298	var longestTotalChange float64
299	for _, fs := range fileStats {
300		if int(longestLength) < len(fs.Name) {
301			longestLength = float64(len(fs.Name))
302		}
303		totalChange := fs.Addition + fs.Deletion
304		if int(longestTotalChange) < totalChange {
305			longestTotalChange = float64(totalChange)
306		}
307	}
308
309	// Parts of the output:
310	// <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
311	// example: " main.go | 10 +++++++--- "
312
313	// <pad><filename><pad>
314	leftTextLength := padLength + longestLength + padLength
315
316	// <pad><number><pad><+++++/-----><newline>
317	// Excluding number length here.
318	rightTextLength := padLength + padLength + newlineLength
319
320	totalTextArea := leftTextLength + separatorLength + rightTextLength
321	heightOfHistogram := lineLength - totalTextArea
322
323	// Scale the histogram.
324	var scaleFactor float64
325	if longestTotalChange > heightOfHistogram {
326		// Scale down to heightOfHistogram.
327		scaleFactor = longestTotalChange / heightOfHistogram
328	} else {
329		scaleFactor = 1.0
330	}
331
332	taddc := 0
333	tdelc := 0
334	output := strings.Builder{}
335	for _, fs := range fileStats {
336		taddc += fs.Addition
337		tdelc += fs.Deletion
338		addn := float64(fs.Addition)
339		deln := float64(fs.Deletion)
340		addc := int(math.Floor(addn / scaleFactor))
341		delc := int(math.Floor(deln / scaleFactor))
342		if addc < 0 {
343			addc = 0
344		}
345		if delc < 0 {
346			delc = 0
347		}
348		adds := strings.Repeat("+", addc)
349		dels := strings.Repeat("-", delc)
350		diffLines := fmt.Sprint(fs.Addition + fs.Deletion)
351		totalDiffLines := fmt.Sprint(int(longestTotalChange))
352		fmt.Fprintf(&output, "%s | %s %s%s\n",
353			fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)),
354			strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines,
355			b.style.LogCommitStatsAdd.Render(adds),
356			b.style.LogCommitStatsDel.Render(dels))
357	}
358	files := len(fileStats)
359	fc := fmt.Sprintf("%s changed", english.Plural(files, "file", ""))
360	ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", ""))
361	dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", ""))
362	fmt.Fprint(&output, fc)
363	if taddc > 0 {
364		fmt.Fprintf(&output, ", %s", ins)
365	}
366	if tdelc > 0 {
367		fmt.Fprintf(&output, ", %s", dels)
368	}
369	fmt.Fprint(&output, "\n")
370
371	return output.String()
372}
373
374func (b *Bubble) View() string {
375	switch b.state {
376	case logState:
377		return b.list.View()
378	case errorState:
379		return b.error.ViewWithPrefix(b.style, "Error")
380	case commitState:
381		return b.commitViewport.View()
382	default:
383		return ""
384	}
385}