bubble.go

  1package log
  2
  3import (
  4	"fmt"
  5	"io"
  6	"strings"
  7	"time"
  8
  9	"github.com/charmbracelet/bubbles/list"
 10	"github.com/charmbracelet/bubbles/spinner"
 11	"github.com/charmbracelet/bubbles/viewport"
 12	tea "github.com/charmbracelet/bubbletea"
 13	gansi "github.com/charmbracelet/glamour/ansi"
 14	"github.com/charmbracelet/soft-serve/git"
 15	"github.com/charmbracelet/soft-serve/internal/tui/style"
 16	"github.com/charmbracelet/soft-serve/tui/common"
 17	"github.com/charmbracelet/soft-serve/tui/refs"
 18	vp "github.com/charmbracelet/soft-serve/tui/viewport"
 19)
 20
 21var (
 22	diffChroma = &gansi.CodeBlockElement{
 23		Code:     "",
 24		Language: "diff",
 25	}
 26	waitBeforeLoading = time.Millisecond * 300
 27)
 28
 29type commitMsg *git.Commit
 30
 31type sessionState int
 32
 33const (
 34	logState sessionState = iota
 35	commitState
 36	loadingState
 37	errorState
 38)
 39
 40type item struct {
 41	*git.Commit
 42}
 43
 44func (i item) Title() string {
 45	if i.Commit != nil {
 46		return strings.Split(i.Commit.Message, "\n")[0]
 47	}
 48	return ""
 49}
 50
 51func (i item) FilterValue() string { return i.Title() }
 52
 53type itemDelegate struct {
 54	style *style.Styles
 55}
 56
 57func (d itemDelegate) Height() int                               { return 1 }
 58func (d itemDelegate) Spacing() int                              { return 0 }
 59func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
 60func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 61	i, ok := listItem.(item)
 62	if !ok {
 63		return
 64	}
 65	if i.Commit == nil {
 66		return
 67	}
 68
 69	hash := i.ID.String()
 70	leftMargin := d.style.LogItemSelector.GetMarginLeft() +
 71		d.style.LogItemSelector.GetWidth() +
 72		d.style.LogItemHash.GetMarginLeft() +
 73		d.style.LogItemHash.GetWidth() +
 74		d.style.LogItemInactive.GetMarginLeft()
 75	title := common.TruncateString(i.Title(), m.Width()-leftMargin, "…")
 76	if index == m.Index() {
 77		fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
 78			d.style.LogItemHash.Bold(true).Render(hash[:7])+
 79			d.style.LogItemActive.Render(title))
 80	} else {
 81		fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
 82			d.style.LogItemHash.Render(hash[:7])+
 83			d.style.LogItemInactive.Render(title))
 84	}
 85}
 86
 87type Bubble struct {
 88	repo           common.GitRepo
 89	count          int64
 90	list           list.Model
 91	state          sessionState
 92	commitViewport *vp.ViewportBubble
 93	ref            *git.Reference
 94	style          *style.Styles
 95	width          int
 96	widthMargin    int
 97	height         int
 98	heightMargin   int
 99	error          common.ErrMsg
100	spinner        spinner.Model
101}
102
103func NewBubble(repo common.GitRepo, 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 = common.NextPage
113	l.KeyMap.PrevPage = common.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		spinner:      s,
130	}
131	b.SetSize(width, height)
132	return b
133}
134
135func (b *Bubble) reset() tea.Cmd {
136	errMsg := func(err error) tea.Cmd {
137		return func() tea.Msg { return common.ErrMsg{Err: err} }
138	}
139	if b.ref == nil {
140		ref, err := b.repo.HEAD()
141		if err != nil {
142			return errMsg(err)
143		}
144		b.ref = ref
145	}
146	count, err := b.repo.CountCommits(b.ref)
147	if err != nil {
148		return errMsg(err)
149	}
150	b.count = count
151	b.state = logState
152	b.list.Select(0)
153	cmd := b.updateItems()
154	return cmd
155}
156
157func (b *Bubble) updateItems() tea.Cmd {
158	count := b.count
159	items := make([]list.Item, count)
160	b.list.SetItems(items)
161	page := b.list.Paginator.Page
162	limit := b.list.Paginator.PerPage
163	skip := page * limit
164	cc, err := b.repo.CommitsByPage(b.ref, page+1, limit)
165	if err != nil {
166		return func() tea.Msg { return common.ErrMsg{Err: err} }
167	}
168	for i, c := range cc {
169		idx := i + skip
170		if idx >= int(count) {
171			break
172		}
173		items[idx] = item{c}
174	}
175	cmd := b.list.SetItems(items)
176	b.SetSize(b.width, b.height)
177	return cmd
178}
179
180func (b *Bubble) Help() []common.HelpEntry {
181	return nil
182}
183
184func (b *Bubble) GotoTop() {
185	b.commitViewport.Viewport.GotoTop()
186}
187
188func (b *Bubble) Init() tea.Cmd {
189	return nil
190}
191
192func (b *Bubble) SetSize(width, height int) {
193	b.width = width
194	b.height = height
195	b.commitViewport.Viewport.Width = width - b.widthMargin
196	b.commitViewport.Viewport.Height = height - b.heightMargin
197	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
198	b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin)
199}
200
201func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
202	cmds := make([]tea.Cmd, 0)
203	switch msg := msg.(type) {
204	case tea.WindowSizeMsg:
205		b.SetSize(msg.Width, msg.Height)
206		cmds = append(cmds, b.updateItems())
207
208	case tea.KeyMsg:
209		switch msg.String() {
210		case "C":
211			return b, b.reset()
212		case "enter", "right", "l":
213			if b.state == logState {
214				cmds = append(cmds, b.loadCommit())
215			}
216		case "esc", "left", "h":
217			if b.state != logState {
218				b.state = logState
219			}
220		}
221		switch b.state {
222		case logState:
223			curPage := b.list.Paginator.Page
224			m, cmd := b.list.Update(msg)
225			b.list = m
226			if m.Paginator.Page != curPage {
227				cmds = append(cmds, b.updateItems())
228			}
229			cmds = append(cmds, cmd)
230		case commitState:
231			rv, cmd := b.commitViewport.Update(msg)
232			b.commitViewport = rv.(*vp.ViewportBubble)
233			cmds = append(cmds, cmd)
234		}
235		return b, tea.Batch(cmds...)
236	case common.ErrMsg:
237		b.error = msg
238		b.state = errorState
239		return b, nil
240	case commitMsg:
241		if b.state == loadingState {
242			cmds = append(cmds, b.spinner.Tick)
243		}
244	case refs.RefMsg:
245		b.ref = msg
246		count, err := b.repo.CountCommits(msg)
247		if err != nil {
248			b.error = common.ErrMsg{Err: err}
249		}
250		b.count = count
251	case spinner.TickMsg:
252		if b.state == loadingState {
253			s, cmd := b.spinner.Update(msg)
254			if cmd != nil {
255				cmds = append(cmds, cmd)
256			}
257			b.spinner = s
258		}
259	}
260
261	return b, tea.Batch(cmds...)
262}
263
264func (b *Bubble) loadPatch(c *git.Commit) error {
265	var patch strings.Builder
266	style := b.style.LogCommit.Copy().Width(b.width - b.widthMargin - b.style.LogCommit.GetHorizontalFrameSize())
267	p, err := b.repo.Diff(c)
268	if err != nil {
269		return err
270	}
271	stats := strings.Split(p.Stats().String(), "\n")
272	for i, l := range stats {
273		ch := strings.Split(l, "|")
274		if len(ch) > 1 {
275			adddel := ch[len(ch)-1]
276			adddel = strings.ReplaceAll(adddel, "+", b.style.LogCommitStatsAdd.Render("+"))
277			adddel = strings.ReplaceAll(adddel, "-", b.style.LogCommitStatsDel.Render("-"))
278			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
279		}
280	}
281	patch.WriteString(b.renderCommit(c))
282	fpl := len(p.Files)
283	if fpl > common.MaxDiffFiles {
284		patch.WriteString("\n" + common.ErrDiffFilesTooLong.Error())
285	} else {
286		patch.WriteString("\n" + strings.Join(stats, "\n"))
287	}
288	if fpl <= common.MaxDiffFiles {
289		ps := ""
290		if len(strings.Split(ps, "\n")) > common.MaxDiffLines {
291			patch.WriteString("\n" + common.ErrDiffTooLong.Error())
292		} else {
293			patch.WriteString("\n" + b.renderDiff(p))
294		}
295	}
296	content := style.Render(patch.String())
297	b.commitViewport.Viewport.SetContent(content)
298	b.GotoTop()
299	return nil
300}
301
302func (b *Bubble) loadCommit() tea.Cmd {
303	var err error
304	done := make(chan struct{}, 1)
305	i := b.list.SelectedItem()
306	if i == nil {
307		return nil
308	}
309	c, ok := i.(item)
310	if !ok {
311		return nil
312	}
313	go func() {
314		err = b.loadPatch(c.Commit)
315		done <- struct{}{}
316		b.state = commitState
317	}()
318	return func() tea.Msg {
319		select {
320		case <-done:
321		case <-time.After(waitBeforeLoading):
322			b.state = loadingState
323		}
324		if err != nil {
325			return common.ErrMsg{Err: err}
326		}
327		return commitMsg(c.Commit)
328	}
329}
330
331func (b *Bubble) renderCommit(c *git.Commit) string {
332	s := strings.Builder{}
333	// FIXME: lipgloss prints empty lines when CRLF is used
334	// sanitize commit message from CRLF
335	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
336	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
337		b.style.LogCommitHash.Render("commit "+c.ID.String()),
338		b.style.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
339		b.style.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
340		b.style.LogCommitBody.Render(msg),
341	))
342	return s.String()
343}
344
345func (b *Bubble) renderDiff(diff *git.Diff) string {
346	var s strings.Builder
347	var pr strings.Builder
348	diffChroma.Code = diff.Patch()
349	err := diffChroma.Render(&pr, common.RenderCtx)
350	if err != nil {
351		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
352	} else {
353		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
354	}
355	return s.String()
356}
357
358func (b *Bubble) View() string {
359	switch b.state {
360	case logState:
361		return b.list.View()
362	case loadingState:
363		return fmt.Sprintf("%s loading commit…", b.spinner.View())
364	case errorState:
365		return b.error.ViewWithPrefix(b.style, "Error")
366	case commitState:
367		return b.commitViewport.View()
368	default:
369		return ""
370	}
371}