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/internal/tui/style"
 15	"github.com/charmbracelet/soft-serve/pkg/git"
 16	"github.com/charmbracelet/soft-serve/pkg/tui/common"
 17	"github.com/charmbracelet/soft-serve/pkg/tui/refs"
 18	vp "github.com/charmbracelet/soft-serve/pkg/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	ref, err := b.repo.HEAD()
140	if err != nil {
141		return errMsg(err)
142	}
143	b.ref = ref
144	count, err := b.repo.CountCommits(ref)
145	if err != nil {
146		return errMsg(err)
147	}
148	b.count = count
149	b.state = logState
150	b.list.Select(0)
151	cmd := b.updateItems()
152	return cmd
153}
154
155func (b *Bubble) updateItems() tea.Cmd {
156	count := b.count
157	items := make([]list.Item, count)
158	b.list.SetItems(items)
159	page := b.list.Paginator.Page
160	limit := b.list.Paginator.PerPage
161	skip := page * limit
162	cc, err := b.repo.CommitsByPage(b.ref, page+1, limit)
163	if err != nil {
164		return func() tea.Msg { return common.ErrMsg{Err: err} }
165	}
166	for i, c := range cc {
167		idx := i + skip
168		if idx >= int(count) {
169			break
170		}
171		items[idx] = item{c}
172	}
173	cmd := b.list.SetItems(items)
174	b.SetSize(b.width, b.height)
175	return cmd
176}
177
178func (b *Bubble) Help() []common.HelpEntry {
179	return nil
180}
181
182func (b *Bubble) GotoTop() {
183	b.commitViewport.Viewport.GotoTop()
184}
185
186func (b *Bubble) Init() tea.Cmd {
187	return nil
188}
189
190func (b *Bubble) SetSize(width, height int) {
191	b.width = width
192	b.height = height
193	b.commitViewport.Viewport.Width = width - b.widthMargin
194	b.commitViewport.Viewport.Height = height - b.heightMargin
195	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
196	b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin)
197}
198
199func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
200	cmds := make([]tea.Cmd, 0)
201	switch msg := msg.(type) {
202	case tea.WindowSizeMsg:
203		b.SetSize(msg.Width, msg.Height)
204		cmds = append(cmds, b.updateItems())
205
206	case tea.KeyMsg:
207		switch msg.String() {
208		case "C":
209			return b, b.reset()
210		case "enter", "right", "l":
211			if b.state == logState {
212				cmds = append(cmds, b.loadCommit())
213			}
214		case "esc", "left", "h":
215			if b.state != logState {
216				b.state = logState
217			}
218		}
219		switch b.state {
220		case logState:
221			curPage := b.list.Paginator.Page
222			m, cmd := b.list.Update(msg)
223			b.list = m
224			if m.Paginator.Page != curPage {
225				cmds = append(cmds, b.updateItems())
226			}
227			cmds = append(cmds, cmd)
228		case commitState:
229			rv, cmd := b.commitViewport.Update(msg)
230			b.commitViewport = rv.(*vp.ViewportBubble)
231			cmds = append(cmds, cmd)
232		}
233		return b, tea.Batch(cmds...)
234	case common.ErrMsg:
235		b.error = msg
236		b.state = errorState
237		return b, nil
238	case commitMsg:
239		if b.state == loadingState {
240			cmds = append(cmds, b.spinner.Tick)
241		}
242	case refs.RefMsg:
243		b.ref = msg
244		count, err := b.repo.CountCommits(msg)
245		if err != nil {
246			b.error = common.ErrMsg{Err: err}
247		}
248		b.count = count
249	case spinner.TickMsg:
250		if b.state == loadingState {
251			s, cmd := b.spinner.Update(msg)
252			if cmd != nil {
253				cmds = append(cmds, cmd)
254			}
255			b.spinner = s
256		}
257	}
258
259	return b, tea.Batch(cmds...)
260}
261
262func (b *Bubble) loadPatch(c *git.Commit) error {
263	var patch strings.Builder
264	style := b.style.LogCommit.Copy().Width(b.width - b.widthMargin - b.style.LogCommit.GetHorizontalFrameSize())
265	p, err := b.repo.Diff(c)
266	if err != nil {
267		return err
268	}
269	stats := strings.Split(p.Stats().String(), "\n")
270	for i, l := range stats {
271		ch := strings.Split(l, "|")
272		if len(ch) > 1 {
273			adddel := ch[len(ch)-1]
274			adddel = strings.ReplaceAll(adddel, "+", b.style.LogCommitStatsAdd.Render("+"))
275			adddel = strings.ReplaceAll(adddel, "-", b.style.LogCommitStatsDel.Render("-"))
276			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
277		}
278	}
279	patch.WriteString(b.renderCommit(c))
280	fpl := len(p.Files)
281	if fpl > common.MaxDiffFiles {
282		patch.WriteString("\n" + common.ErrDiffFilesTooLong.Error())
283	} else {
284		patch.WriteString("\n" + strings.Join(stats, "\n"))
285	}
286	if fpl <= common.MaxDiffFiles {
287		ps := ""
288		if len(strings.Split(ps, "\n")) > common.MaxDiffLines {
289			patch.WriteString("\n" + common.ErrDiffTooLong.Error())
290		} else {
291			patch.WriteString("\n" + b.renderDiff(p))
292		}
293	}
294	content := style.Render(patch.String())
295	b.commitViewport.Viewport.SetContent(content)
296	b.GotoTop()
297	return nil
298}
299
300func (b *Bubble) loadCommit() tea.Cmd {
301	var err error
302	done := make(chan struct{}, 1)
303	i := b.list.SelectedItem()
304	if i == nil {
305		return nil
306	}
307	c, ok := i.(item)
308	if !ok {
309		return nil
310	}
311	go func() {
312		err = b.loadPatch(c.Commit)
313		done <- struct{}{}
314		b.state = commitState
315	}()
316	return func() tea.Msg {
317		select {
318		case <-done:
319		case <-time.After(waitBeforeLoading):
320			b.state = loadingState
321		}
322		if err != nil {
323			return common.ErrMsg{Err: err}
324		}
325		return commitMsg(c.Commit)
326	}
327}
328
329func (b *Bubble) renderCommit(c *git.Commit) string {
330	s := strings.Builder{}
331	// FIXME: lipgloss prints empty lines when CRLF is used
332	// sanitize commit message from CRLF
333	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
334	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
335		b.style.LogCommitHash.Render("commit "+c.ID.String()),
336		b.style.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
337		b.style.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
338		b.style.LogCommitBody.Render(msg),
339	))
340	return s.String()
341}
342
343func (b *Bubble) renderDiff(diff *git.Diff) string {
344	var s strings.Builder
345	var pr strings.Builder
346	diffChroma.Code = diff.Patch()
347	err := diffChroma.Render(&pr, common.RenderCtx)
348	if err != nil {
349		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
350	} else {
351		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
352	}
353	return s.String()
354}
355
356func (b *Bubble) View() string {
357	switch b.state {
358	case logState:
359		return b.list.View()
360	case loadingState:
361		return fmt.Sprintf("%s loading commit…", b.spinner.View())
362	case errorState:
363		return b.error.ViewWithPrefix(b.style, "Error")
364	case commitState:
365		return b.commitViewport.View()
366	default:
367		return ""
368	}
369}