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