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