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