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}