1package log
2
3import (
4 "fmt"
5 "io"
6 "strings"
7 "time"
8
9 "github.com/charmbracelet/bubbles/list"
10 "github.com/charmbracelet/bubbles/spinner"
11 "github.com/charmbracelet/bubbles/viewport"
12 tea "github.com/charmbracelet/bubbletea"
13 gansi "github.com/charmbracelet/glamour/ansi"
14 "github.com/charmbracelet/soft-serve/internal/tui/style"
15 "github.com/charmbracelet/soft-serve/pkg/git"
16 "github.com/charmbracelet/soft-serve/pkg/tui/common"
17 "github.com/charmbracelet/soft-serve/pkg/tui/refs"
18 vp "github.com/charmbracelet/soft-serve/pkg/tui/viewport"
19)
20
21var (
22 diffChroma = &gansi.CodeBlockElement{
23 Code: "",
24 Language: "diff",
25 }
26 waitBeforeLoading = time.Millisecond * 300
27)
28
29type commitMsg *git.Commit
30
31type sessionState int
32
33const (
34 logState sessionState = iota
35 commitState
36 loadingState
37 errorState
38)
39
40type item struct {
41 *git.Commit
42}
43
44func (i item) Title() string {
45 if i.Commit != nil {
46 return strings.Split(i.Commit.Message, "\n")[0]
47 }
48 return ""
49}
50
51func (i item) FilterValue() string { return i.Title() }
52
53type itemDelegate struct {
54 style *style.Styles
55}
56
57func (d itemDelegate) Height() int { return 1 }
58func (d itemDelegate) Spacing() int { return 0 }
59func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
60func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
61 i, ok := listItem.(item)
62 if !ok {
63 return
64 }
65 if i.Commit == nil {
66 return
67 }
68
69 hash := i.ID.String()
70 leftMargin := d.style.LogItemSelector.GetMarginLeft() +
71 d.style.LogItemSelector.GetWidth() +
72 d.style.LogItemHash.GetMarginLeft() +
73 d.style.LogItemHash.GetWidth() +
74 d.style.LogItemInactive.GetMarginLeft()
75 title := common.TruncateString(i.Title(), m.Width()-leftMargin, "…")
76 if index == m.Index() {
77 fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
78 d.style.LogItemHash.Bold(true).Render(hash[:7])+
79 d.style.LogItemActive.Render(title))
80 } else {
81 fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
82 d.style.LogItemHash.Render(hash[:7])+
83 d.style.LogItemInactive.Render(title))
84 }
85}
86
87type Bubble struct {
88 repo common.GitRepo
89 count int64
90 list list.Model
91 state sessionState
92 commitViewport *vp.ViewportBubble
93 ref *git.Reference
94 style *style.Styles
95 width int
96 widthMargin int
97 height int
98 heightMargin int
99 error common.ErrMsg
100 spinner spinner.Model
101}
102
103func NewBubble(repo common.GitRepo, 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 = common.NextPage
113 l.KeyMap.PrevPage = common.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 spinner: s,
130 }
131 b.SetSize(width, height)
132 return b
133}
134
135func (b *Bubble) reset() tea.Cmd {
136 errMsg := func(err error) tea.Cmd {
137 return func() tea.Msg { return common.ErrMsg{Err: err} }
138 }
139 ref, err := b.repo.HEAD()
140 if err != nil {
141 return errMsg(err)
142 }
143 b.ref = ref
144 count, err := b.repo.CountCommits(ref)
145 if err != nil {
146 return errMsg(err)
147 }
148 b.count = count
149 b.state = logState
150 b.list.Select(0)
151 b.SetSize(b.width, b.height)
152 cmd := b.updateItems()
153 return cmd
154}
155
156func (b *Bubble) updateItems() tea.Cmd {
157 count := b.count
158 items := make([]list.Item, count)
159 b.list.SetItems(items)
160 page := b.list.Paginator.Page
161 limit := b.list.Paginator.PerPage
162 skip := page * limit
163 cc, err := b.repo.CommitsByPage(b.ref, page+1, limit)
164 if err != nil {
165 return func() tea.Msg { return common.ErrMsg{Err: err} }
166 }
167 for i, c := range cc {
168 idx := i + skip
169 if idx >= int(count) {
170 break
171 }
172 items[idx] = item{c}
173 }
174 cmd := b.list.SetItems(items)
175 return cmd
176}
177
178func (b *Bubble) Help() []common.HelpEntry {
179 return nil
180}
181
182func (b *Bubble) GotoTop() {
183 b.commitViewport.Viewport.GotoTop()
184}
185
186func (b *Bubble) Init() tea.Cmd {
187 return nil
188}
189
190func (b *Bubble) SetSize(width, height int) {
191 b.width = width
192 b.height = height
193 b.commitViewport.Viewport.Width = width - b.widthMargin
194 b.commitViewport.Viewport.Height = height - b.heightMargin
195 b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
196 b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin)
197}
198
199func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
200 cmds := make([]tea.Cmd, 0)
201 switch msg := msg.(type) {
202 case tea.WindowSizeMsg:
203 b.SetSize(msg.Width, msg.Height)
204 cmds = append(cmds, b.updateItems())
205
206 case tea.KeyMsg:
207 switch msg.String() {
208 case "C":
209 return b, b.reset()
210 case "enter", "right", "l":
211 if b.state == logState {
212 cmds = append(cmds, b.loadCommit())
213 }
214 case "esc", "left", "h":
215 if b.state != logState {
216 b.state = logState
217 }
218 }
219 switch b.state {
220 case logState:
221 curPage := b.list.Paginator.Page
222 m, cmd := b.list.Update(msg)
223 b.list = m
224 if m.Paginator.Page != curPage {
225 cmds = append(cmds, b.updateItems())
226 }
227 cmds = append(cmds, cmd)
228 case commitState:
229 rv, cmd := b.commitViewport.Update(msg)
230 b.commitViewport = rv.(*vp.ViewportBubble)
231 cmds = append(cmds, cmd)
232 }
233 return b, tea.Batch(cmds...)
234 case common.ErrMsg:
235 b.error = msg
236 b.state = errorState
237 return b, nil
238 case commitMsg:
239 if b.state == loadingState {
240 cmds = append(cmds, b.spinner.Tick)
241 }
242 case refs.RefMsg:
243 b.ref = msg
244 count, err := b.repo.CountCommits(msg)
245 if err != nil {
246 b.error = common.ErrMsg{Err: err}
247 }
248 b.count = count
249 case spinner.TickMsg:
250 if b.state == loadingState {
251 s, cmd := b.spinner.Update(msg)
252 if cmd != nil {
253 cmds = append(cmds, cmd)
254 }
255 b.spinner = s
256 }
257 }
258
259 return b, tea.Batch(cmds...)
260}
261
262func (b *Bubble) loadPatch(c *git.Commit) error {
263 var patch strings.Builder
264 style := b.style.LogCommit.Copy().Width(b.width - b.widthMargin - b.style.LogCommit.GetHorizontalFrameSize())
265 p, err := b.repo.Diff(c)
266 if err != nil {
267 return err
268 }
269 stats := strings.Split(p.Stats().String(), "\n")
270 for i, l := range stats {
271 ch := strings.Split(l, "|")
272 if len(ch) > 1 {
273 adddel := ch[len(ch)-1]
274 adddel = strings.ReplaceAll(adddel, "+", b.style.LogCommitStatsAdd.Render("+"))
275 adddel = strings.ReplaceAll(adddel, "-", b.style.LogCommitStatsDel.Render("-"))
276 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
277 }
278 }
279 patch.WriteString(b.renderCommit(c))
280 fpl := len(p.Files)
281 if fpl > common.MaxDiffFiles {
282 patch.WriteString("\n" + common.ErrDiffFilesTooLong.Error())
283 } else {
284 patch.WriteString("\n" + strings.Join(stats, "\n"))
285 }
286 if fpl <= common.MaxDiffFiles {
287 ps := ""
288 if len(strings.Split(ps, "\n")) > common.MaxDiffLines {
289 patch.WriteString("\n" + common.ErrDiffTooLong.Error())
290 } else {
291 patch.WriteString("\n" + b.renderDiff(p))
292 }
293 }
294 content := style.Render(patch.String())
295 b.commitViewport.Viewport.SetContent(content)
296 b.GotoTop()
297 return nil
298}
299
300func (b *Bubble) loadCommit() tea.Cmd {
301 var err error
302 done := make(chan struct{}, 1)
303 i := b.list.SelectedItem()
304 if i == nil {
305 return nil
306 }
307 c, ok := i.(item)
308 if !ok {
309 return nil
310 }
311 go func() {
312 err = b.loadPatch(c.Commit)
313 done <- struct{}{}
314 b.state = commitState
315 }()
316 return func() tea.Msg {
317 select {
318 case <-done:
319 case <-time.After(waitBeforeLoading):
320 b.state = loadingState
321 }
322 if err != nil {
323 return common.ErrMsg{Err: err}
324 }
325 return commitMsg(c.Commit)
326 }
327}
328
329func (b *Bubble) renderCommit(c *git.Commit) string {
330 s := strings.Builder{}
331 // FIXME: lipgloss prints empty lines when CRLF is used
332 // sanitize commit message from CRLF
333 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
334 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
335 b.style.LogCommitHash.Render("commit "+c.ID.String()),
336 b.style.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
337 b.style.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
338 b.style.LogCommitBody.Render(msg),
339 ))
340 return s.String()
341}
342
343func (b *Bubble) renderDiff(diff *git.Diff) string {
344 var s strings.Builder
345 var pr strings.Builder
346 diffChroma.Code = diff.Patch()
347 err := diffChroma.Render(&pr, common.RenderCtx)
348 if err != nil {
349 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
350 } else {
351 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
352 }
353 return s.String()
354}
355
356func (b *Bubble) View() string {
357 switch b.state {
358 case logState:
359 return b.list.View()
360 case loadingState:
361 return fmt.Sprintf("%s loading commit…", b.spinner.View())
362 case errorState:
363 return b.error.ViewWithPrefix(b.style, "Error")
364 case commitState:
365 return b.commitViewport.View()
366 default:
367 return ""
368 }
369}