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 return nil
144}
145
146func (b *Bubble) GotoTop() {
147 b.commitViewport.Viewport.GotoTop()
148}
149
150func (b *Bubble) Init() tea.Cmd {
151 return b.updateItems()
152}
153
154func (b *Bubble) SetSize(width, height int) {
155 b.width = width
156 b.height = height
157 b.commitViewport.Viewport.Width = width - b.widthMargin
158 b.commitViewport.Viewport.Height = height - b.heightMargin
159 b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
160}
161
162func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163 cmds := make([]tea.Cmd, 0)
164 switch msg := msg.(type) {
165 case tea.WindowSizeMsg:
166 b.SetSize(msg.Width, msg.Height)
167
168 case tea.KeyMsg:
169 switch msg.String() {
170 case "C":
171 b.state = logState
172 b.list.Select(0)
173 cmds = append(cmds, b.updateItems())
174 case "enter", "right", "l":
175 if b.state == logState {
176 cmds = append(cmds, b.loadCommit())
177 }
178 case "esc", "left", "h":
179 if b.state != logState {
180 b.state = logState
181 }
182 }
183 case types.ErrMsg:
184 b.error = msg
185 b.state = errorState
186 return b, nil
187 case commitMsg:
188 content := b.renderCommit(msg)
189 b.state = commitState
190 b.commitViewport.Viewport.SetContent(content)
191 b.GotoTop()
192 }
193
194 switch b.state {
195 case commitState:
196 rv, cmd := b.commitViewport.Update(msg)
197 b.commitViewport = rv.(*vp.ViewportBubble)
198 cmds = append(cmds, cmd)
199 case logState:
200 l, cmd := b.list.Update(msg)
201 b.list = l
202 cmds = append(cmds, cmd)
203 }
204
205 return b, tea.Batch(cmds...)
206}
207
208func (b *Bubble) loadCommit() tea.Cmd {
209 return func() tea.Msg {
210 i := b.list.SelectedItem()
211 if i == nil {
212 return nil
213 }
214 c, ok := i.(item)
215 if !ok {
216 return nil
217 }
218 // Using commit trees fixes the issue when generating diff for the first commit
219 // https://github.com/go-git/go-git/issues/281
220 tree, err := c.Tree()
221 if err != nil {
222 return types.ErrMsg{err}
223 }
224 var parent *object.Commit
225 parentTree := &object.Tree{}
226 if c.NumParents() > 0 {
227 parent, err = c.Parents().Next()
228 if err != nil {
229 return types.ErrMsg{err}
230 }
231 parentTree, err = parent.Tree()
232 if err != nil {
233 return types.ErrMsg{err}
234 }
235 }
236 ctx, cancel := context.WithTimeout(context.TODO(), types.MaxPatchWait)
237 defer cancel()
238 patch, err := parentTree.PatchContext(ctx, tree)
239 if err != nil {
240 return types.ErrMsg{err}
241 }
242 return commitMsg{
243 commit: c.Commit.Commit,
244 tree: tree,
245 parent: parent,
246 parentTree: parentTree,
247 patch: patch,
248 }
249 }
250}
251
252func (b *Bubble) renderCommit(m commitMsg) string {
253 s := strings.Builder{}
254 st := b.style
255 c := m.commit
256 // FIXME: lipgloss prints empty lines when CRLF is used
257 // sanitize commit message from CRLF
258 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
259 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
260 st.LogCommitHash.Render("commit "+c.Hash.String()),
261 st.LogCommitAuthor.Render("Author: "+c.Author.String()),
262 st.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
263 st.LogCommitBody.Render(msg),
264 ))
265 stats := m.patch.Stats()
266 if len(stats) > types.MaxDiffFiles {
267 s.WriteString("\n" + types.ErrDiffFilesTooLong.Error())
268 } else {
269 s.WriteString("\n" + b.renderStats(stats))
270 }
271 ps := m.patch.String()
272 if len(strings.Split(ps, "\n")) > types.MaxDiffLines {
273 s.WriteString("\n" + types.ErrDiffTooLong.Error())
274 } else {
275 p := strings.Builder{}
276 diffChroma.Code = ps
277 err := diffChroma.Render(&p, types.RenderCtx)
278 if err != nil {
279 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
280 } else {
281 s.WriteString(fmt.Sprintf("\n%s", p.String()))
282 }
283 }
284 return st.LogCommit.Copy().Width(b.width - b.widthMargin - st.LogCommit.GetHorizontalFrameSize()).Render(s.String())
285}
286
287func (b *Bubble) renderStats(fileStats object.FileStats) string {
288 padLength := float64(len(" "))
289 newlineLength := float64(len("\n"))
290 separatorLength := float64(len("|"))
291 // Soft line length limit. The text length calculation below excludes
292 // length of the change number. Adding that would take it closer to 80,
293 // but probably not more than 80, until it's a huge number.
294 lineLength := 72.0
295
296 // Get the longest filename and longest total change.
297 var longestLength float64
298 var longestTotalChange float64
299 for _, fs := range fileStats {
300 if int(longestLength) < len(fs.Name) {
301 longestLength = float64(len(fs.Name))
302 }
303 totalChange := fs.Addition + fs.Deletion
304 if int(longestTotalChange) < totalChange {
305 longestTotalChange = float64(totalChange)
306 }
307 }
308
309 // Parts of the output:
310 // <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
311 // example: " main.go | 10 +++++++--- "
312
313 // <pad><filename><pad>
314 leftTextLength := padLength + longestLength + padLength
315
316 // <pad><number><pad><+++++/-----><newline>
317 // Excluding number length here.
318 rightTextLength := padLength + padLength + newlineLength
319
320 totalTextArea := leftTextLength + separatorLength + rightTextLength
321 heightOfHistogram := lineLength - totalTextArea
322
323 // Scale the histogram.
324 var scaleFactor float64
325 if longestTotalChange > heightOfHistogram {
326 // Scale down to heightOfHistogram.
327 scaleFactor = longestTotalChange / heightOfHistogram
328 } else {
329 scaleFactor = 1.0
330 }
331
332 taddc := 0
333 tdelc := 0
334 output := strings.Builder{}
335 for _, fs := range fileStats {
336 taddc += fs.Addition
337 tdelc += fs.Deletion
338 addn := float64(fs.Addition)
339 deln := float64(fs.Deletion)
340 addc := int(math.Floor(addn / scaleFactor))
341 delc := int(math.Floor(deln / scaleFactor))
342 if addc < 0 {
343 addc = 0
344 }
345 if delc < 0 {
346 delc = 0
347 }
348 adds := strings.Repeat("+", addc)
349 dels := strings.Repeat("-", delc)
350 diffLines := fmt.Sprint(fs.Addition + fs.Deletion)
351 totalDiffLines := fmt.Sprint(int(longestTotalChange))
352 fmt.Fprintf(&output, "%s | %s %s%s\n",
353 fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)),
354 strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines,
355 b.style.LogCommitStatsAdd.Render(adds),
356 b.style.LogCommitStatsDel.Render(dels))
357 }
358 files := len(fileStats)
359 fc := fmt.Sprintf("%s changed", english.Plural(files, "file", ""))
360 ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", ""))
361 dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", ""))
362 fmt.Fprint(&output, fc)
363 if taddc > 0 {
364 fmt.Fprintf(&output, ", %s", ins)
365 }
366 if tdelc > 0 {
367 fmt.Fprintf(&output, ", %s", dels)
368 }
369 fmt.Fprint(&output, "\n")
370
371 return output.String()
372}
373
374func (b *Bubble) View() string {
375 switch b.state {
376 case logState:
377 return b.list.View()
378 case errorState:
379 return b.error.ViewWithPrefix(b.style, "Error")
380 case commitState:
381 return b.commitViewport.View()
382 default:
383 return ""
384 }
385}