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