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}