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/viewport"
13 tea "github.com/charmbracelet/bubbletea"
14 gansi "github.com/charmbracelet/glamour/ansi"
15 "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
16 vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
17 "github.com/charmbracelet/soft-serve/internal/tui/style"
18 "github.com/dustin/go-humanize/english"
19 "github.com/go-git/go-git/v5/plumbing/object"
20)
21
22var (
23 diffChroma = &gansi.CodeBlockElement{
24 Code: "",
25 Language: "diff",
26 }
27)
28
29type commitMsg struct {
30 commit *object.Commit
31 parent *object.Commit
32 tree *object.Tree
33 parentTree *object.Tree
34 patch *object.Patch
35}
36
37type sessionState int
38
39const (
40 logState sessionState = iota
41 commitState
42 errorState
43)
44
45type item struct {
46 *types.Commit
47}
48
49func (i item) Title() string {
50 lines := strings.Split(i.Message, "\n")
51 if len(lines) > 0 {
52 return lines[0]
53 }
54 return ""
55}
56
57func (i item) FilterValue() string { return i.Title() }
58
59type itemDelegate struct {
60 style *style.Styles
61}
62
63func (d itemDelegate) Height() int { return 1 }
64func (d itemDelegate) Spacing() int { return 0 }
65func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
66func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
67 i, ok := listItem.(item)
68 if !ok {
69 return
70 }
71
72 leftMargin := d.style.LogItemSelector.GetMarginLeft() +
73 d.style.LogItemSelector.GetWidth() +
74 d.style.LogItemHash.GetMarginLeft() +
75 d.style.LogItemHash.GetWidth() +
76 d.style.LogItemInactive.GetMarginLeft()
77 title := types.TruncateString(i.Title(), m.Width()-leftMargin, "…")
78 if index == m.Index() {
79 fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
80 d.style.LogItemHash.Bold(true).Render(i.Hash.String()[:7])+
81 d.style.LogItemActive.Render(title))
82 } else {
83 fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
84 d.style.LogItemHash.Render(i.Hash.String()[:7])+
85 d.style.LogItemInactive.Render(title))
86 }
87}
88
89type Bubble struct {
90 repo types.Repo
91 list list.Model
92 state sessionState
93 commitViewport *vp.ViewportBubble
94 style *style.Styles
95 width int
96 widthMargin int
97 height int
98 heightMargin int
99 error types.ErrMsg
100}
101
102func NewBubble(repo types.Repo, style *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
103 l := list.New([]list.Item{}, itemDelegate{style}, width-widthMargin, height-heightMargin)
104 l.SetShowFilter(false)
105 l.SetShowHelp(false)
106 l.SetShowPagination(false)
107 l.SetShowStatusBar(false)
108 l.SetShowTitle(false)
109 l.SetFilteringEnabled(false)
110 l.DisableQuitKeybindings()
111 l.KeyMap.NextPage = types.NextPage
112 l.KeyMap.PrevPage = types.PrevPage
113 b := &Bubble{
114 commitViewport: &vp.ViewportBubble{
115 Viewport: &viewport.Model{},
116 },
117 repo: repo,
118 style: style,
119 state: logState,
120 width: width,
121 widthMargin: widthMargin,
122 height: height,
123 heightMargin: heightMargin,
124 list: l,
125 }
126 b.SetSize(width, height)
127 return b
128}
129
130func (b *Bubble) updateItems() tea.Cmd {
131 items := make([]list.Item, 0)
132 cc, err := b.repo.GetCommits(0)
133 if err != nil {
134 return func() tea.Msg { return types.ErrMsg{err} }
135 }
136 for _, c := range cc {
137 items = append(items, item{c})
138 }
139 return b.list.SetItems(items)
140}
141
142func (b *Bubble) Help() []types.HelpEntry {
143 switch b.state {
144 case logState:
145 return []types.HelpEntry{
146 {"enter", "select"},
147 }
148 default:
149 return []types.HelpEntry{
150 {"esc", "back"},
151 }
152 }
153}
154
155func (b *Bubble) GotoTop() {
156 b.commitViewport.Viewport.GotoTop()
157}
158
159func (b *Bubble) Init() tea.Cmd {
160 return b.updateItems()
161}
162
163func (b *Bubble) SetSize(width, height int) {
164 b.width = width
165 b.height = height
166 b.commitViewport.Viewport.Width = width - b.widthMargin
167 b.commitViewport.Viewport.Height = height - b.heightMargin
168 b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
169}
170
171func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172 cmds := make([]tea.Cmd, 0)
173 switch msg := msg.(type) {
174 case tea.WindowSizeMsg:
175 b.SetSize(msg.Width, msg.Height)
176
177 case tea.KeyMsg:
178 switch msg.String() {
179 case "L":
180 b.state = logState
181 b.list.Select(0)
182 cmds = append(cmds, b.updateItems())
183 case "enter", "right", "l":
184 if b.state == logState {
185 cmds = append(cmds, b.loadCommit())
186 }
187 case "esc", "left", "h":
188 if b.state != logState {
189 b.state = logState
190 }
191 }
192 case types.ErrMsg:
193 b.error = msg
194 b.state = errorState
195 return b, nil
196 case commitMsg:
197 content := b.renderCommit(msg)
198 b.state = commitState
199 b.commitViewport.Viewport.SetContent(content)
200 b.GotoTop()
201 }
202
203 switch b.state {
204 case commitState:
205 rv, cmd := b.commitViewport.Update(msg)
206 b.commitViewport = rv.(*vp.ViewportBubble)
207 cmds = append(cmds, cmd)
208 case logState:
209 l, cmd := b.list.Update(msg)
210 b.list = l
211 cmds = append(cmds, cmd)
212 }
213
214 return b, tea.Batch(cmds...)
215}
216
217func (b *Bubble) loadCommit() tea.Cmd {
218 return func() tea.Msg {
219 i := b.list.SelectedItem()
220 if i == nil {
221 return nil
222 }
223 c, ok := i.(item)
224 if !ok {
225 return nil
226 }
227 // Using commit trees fixes the issue when generating diff for the first commit
228 // https://github.com/go-git/go-git/issues/281
229 tree, err := c.Tree()
230 if err != nil {
231 return types.ErrMsg{err}
232 }
233 var parent *object.Commit
234 parentTree := &object.Tree{}
235 if c.NumParents() > 0 {
236 parent, err = c.Parents().Next()
237 if err != nil {
238 return types.ErrMsg{err}
239 }
240 parentTree, err = parent.Tree()
241 if err != nil {
242 return types.ErrMsg{err}
243 }
244 }
245 ctx, cancel := context.WithTimeout(context.TODO(), types.MaxPatchWait)
246 defer cancel()
247 patch, err := parentTree.PatchContext(ctx, tree)
248 if err != nil {
249 return types.ErrMsg{err}
250 }
251 return commitMsg{
252 commit: c.Commit.Commit,
253 tree: tree,
254 parent: parent,
255 parentTree: parentTree,
256 patch: patch,
257 }
258 }
259}
260
261func (b *Bubble) renderCommit(m commitMsg) string {
262 s := strings.Builder{}
263 st := b.style
264 c := m.commit
265 // FIXME: lipgloss prints empty lines when CRLF is used
266 // sanitize commit message from CRLF
267 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
268 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
269 st.LogCommitHash.Render("commit "+c.Hash.String()),
270 st.LogCommitAuthor.Render("Author: "+c.Author.String()),
271 st.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
272 st.LogCommitBody.Render(msg),
273 ))
274 stats := m.patch.Stats()
275 if len(stats) > types.MaxDiffFiles {
276 s.WriteString("\n" + types.ErrDiffFilesTooLong.Error())
277 } else {
278 s.WriteString("\n" + b.renderStats(stats))
279 }
280 ps := m.patch.String()
281 if len(strings.Split(ps, "\n")) > types.MaxDiffLines {
282 s.WriteString("\n" + types.ErrDiffTooLong.Error())
283 } else {
284 p := strings.Builder{}
285 diffChroma.Code = ps
286 err := diffChroma.Render(&p, types.RenderCtx)
287 if err != nil {
288 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
289 } else {
290 s.WriteString(fmt.Sprintf("\n%s", p.String()))
291 }
292 }
293 return st.LogCommit.Copy().Width(b.width - b.widthMargin - st.LogCommit.GetHorizontalFrameSize()).Render(s.String())
294}
295
296func (b *Bubble) renderStats(fileStats object.FileStats) string {
297 padLength := float64(len(" "))
298 newlineLength := float64(len("\n"))
299 separatorLength := float64(len("|"))
300 // Soft line length limit. The text length calculation below excludes
301 // length of the change number. Adding that would take it closer to 80,
302 // but probably not more than 80, until it's a huge number.
303 lineLength := 72.0
304
305 // Get the longest filename and longest total change.
306 var longestLength float64
307 var longestTotalChange float64
308 for _, fs := range fileStats {
309 if int(longestLength) < len(fs.Name) {
310 longestLength = float64(len(fs.Name))
311 }
312 totalChange := fs.Addition + fs.Deletion
313 if int(longestTotalChange) < totalChange {
314 longestTotalChange = float64(totalChange)
315 }
316 }
317
318 // Parts of the output:
319 // <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
320 // example: " main.go | 10 +++++++--- "
321
322 // <pad><filename><pad>
323 leftTextLength := padLength + longestLength + padLength
324
325 // <pad><number><pad><+++++/-----><newline>
326 // Excluding number length here.
327 rightTextLength := padLength + padLength + newlineLength
328
329 totalTextArea := leftTextLength + separatorLength + rightTextLength
330 heightOfHistogram := lineLength - totalTextArea
331
332 // Scale the histogram.
333 var scaleFactor float64
334 if longestTotalChange > heightOfHistogram {
335 // Scale down to heightOfHistogram.
336 scaleFactor = longestTotalChange / heightOfHistogram
337 } else {
338 scaleFactor = 1.0
339 }
340
341 taddc := 0
342 tdelc := 0
343 output := strings.Builder{}
344 for _, fs := range fileStats {
345 taddc += fs.Addition
346 tdelc += fs.Deletion
347 addn := float64(fs.Addition)
348 deln := float64(fs.Deletion)
349 addc := int(math.Floor(addn / scaleFactor))
350 delc := int(math.Floor(deln / scaleFactor))
351 if addc < 0 {
352 addc = 0
353 }
354 if delc < 0 {
355 delc = 0
356 }
357 adds := strings.Repeat("+", addc)
358 dels := strings.Repeat("-", delc)
359 diffLines := fmt.Sprint(fs.Addition + fs.Deletion)
360 totalDiffLines := fmt.Sprint(int(longestTotalChange))
361 fmt.Fprintf(&output, "%s | %s %s%s\n",
362 fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)),
363 strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines,
364 b.style.LogCommitStatsAdd.Render(adds),
365 b.style.LogCommitStatsDel.Render(dels))
366 }
367 files := len(fileStats)
368 fc := fmt.Sprintf("%s changed", english.Plural(files, "file", ""))
369 ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", ""))
370 dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", ""))
371 fmt.Fprint(&output, fc)
372 if taddc > 0 {
373 fmt.Fprintf(&output, ", %s", ins)
374 }
375 if tdelc > 0 {
376 fmt.Fprintf(&output, ", %s", dels)
377 }
378 fmt.Fprint(&output, "\n")
379
380 return output.String()
381}
382
383func (b *Bubble) View() string {
384 switch b.state {
385 case logState:
386 return b.list.View()
387 case errorState:
388 return b.error.ViewWithPrefix(b.style, "Error")
389 case commitState:
390 return b.commitViewport.View()
391 default:
392 return ""
393 }
394}