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/git"
15 "github.com/charmbracelet/soft-serve/internal/tui/style"
16 "github.com/charmbracelet/soft-serve/tui/common"
17 "github.com/charmbracelet/soft-serve/tui/refs"
18 vp "github.com/charmbracelet/soft-serve/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 if b.ref == nil {
140 ref, err := b.repo.HEAD()
141 if err != nil {
142 return errMsg(err)
143 }
144 b.ref = ref
145 }
146 count, err := b.repo.CountCommits(b.ref)
147 if err != nil {
148 return errMsg(err)
149 }
150 b.count = count
151 b.state = logState
152 b.list.Select(0)
153 cmd := b.updateItems()
154 return cmd
155}
156
157func (b *Bubble) updateItems() tea.Cmd {
158 count := b.count
159 items := make([]list.Item, count)
160 b.list.SetItems(items)
161 page := b.list.Paginator.Page
162 limit := b.list.Paginator.PerPage
163 skip := page * limit
164 cc, err := b.repo.CommitsByPage(b.ref, page+1, limit)
165 if err != nil {
166 return func() tea.Msg { return common.ErrMsg{Err: err} }
167 }
168 for i, c := range cc {
169 idx := i + skip
170 if idx >= int(count) {
171 break
172 }
173 items[idx] = item{c}
174 }
175 cmd := b.list.SetItems(items)
176 b.SetSize(b.width, b.height)
177 return cmd
178}
179
180func (b *Bubble) Help() []common.HelpEntry {
181 return nil
182}
183
184func (b *Bubble) GotoTop() {
185 b.commitViewport.Viewport.GotoTop()
186}
187
188func (b *Bubble) Init() tea.Cmd {
189 return nil
190}
191
192func (b *Bubble) SetSize(width, height int) {
193 b.width = width
194 b.height = height
195 b.commitViewport.Viewport.Width = width - b.widthMargin
196 b.commitViewport.Viewport.Height = height - b.heightMargin
197 b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
198 b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin)
199}
200
201func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
202 cmds := make([]tea.Cmd, 0)
203 switch msg := msg.(type) {
204 case tea.WindowSizeMsg:
205 b.SetSize(msg.Width, msg.Height)
206 cmds = append(cmds, b.updateItems())
207
208 case tea.KeyMsg:
209 switch msg.String() {
210 case "C":
211 return b, b.reset()
212 case "enter", "right", "l":
213 if b.state == logState {
214 cmds = append(cmds, b.loadCommit())
215 }
216 case "esc", "left", "h":
217 if b.state != logState {
218 b.state = logState
219 }
220 }
221 switch b.state {
222 case logState:
223 curPage := b.list.Paginator.Page
224 m, cmd := b.list.Update(msg)
225 b.list = m
226 if m.Paginator.Page != curPage {
227 cmds = append(cmds, b.updateItems())
228 }
229 cmds = append(cmds, cmd)
230 case commitState:
231 rv, cmd := b.commitViewport.Update(msg)
232 b.commitViewport = rv.(*vp.ViewportBubble)
233 cmds = append(cmds, cmd)
234 }
235 return b, tea.Batch(cmds...)
236 case common.ErrMsg:
237 b.error = msg
238 b.state = errorState
239 return b, nil
240 case commitMsg:
241 if b.state == loadingState {
242 cmds = append(cmds, b.spinner.Tick)
243 }
244 case refs.RefMsg:
245 b.ref = msg
246 count, err := b.repo.CountCommits(msg)
247 if err != nil {
248 b.error = common.ErrMsg{Err: err}
249 }
250 b.count = count
251 case spinner.TickMsg:
252 if b.state == loadingState {
253 s, cmd := b.spinner.Update(msg)
254 if cmd != nil {
255 cmds = append(cmds, cmd)
256 }
257 b.spinner = s
258 }
259 }
260
261 return b, tea.Batch(cmds...)
262}
263
264func (b *Bubble) loadPatch(c *git.Commit) error {
265 var patch strings.Builder
266 style := b.style.LogCommit.Copy().Width(b.width - b.widthMargin - b.style.LogCommit.GetHorizontalFrameSize())
267 p, err := b.repo.Diff(c)
268 if err != nil {
269 return err
270 }
271 stats := strings.Split(p.Stats().String(), "\n")
272 for i, l := range stats {
273 ch := strings.Split(l, "|")
274 if len(ch) > 1 {
275 adddel := ch[len(ch)-1]
276 adddel = strings.ReplaceAll(adddel, "+", b.style.LogCommitStatsAdd.Render("+"))
277 adddel = strings.ReplaceAll(adddel, "-", b.style.LogCommitStatsDel.Render("-"))
278 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
279 }
280 }
281 patch.WriteString(b.renderCommit(c))
282 fpl := len(p.Files)
283 if fpl > common.MaxDiffFiles {
284 patch.WriteString("\n" + common.ErrDiffFilesTooLong.Error())
285 } else {
286 patch.WriteString("\n" + strings.Join(stats, "\n"))
287 }
288 if fpl <= common.MaxDiffFiles {
289 ps := ""
290 if len(strings.Split(ps, "\n")) > common.MaxDiffLines {
291 patch.WriteString("\n" + common.ErrDiffTooLong.Error())
292 } else {
293 patch.WriteString("\n" + b.renderDiff(p))
294 }
295 }
296 content := style.Render(patch.String())
297 b.commitViewport.Viewport.SetContent(content)
298 b.GotoTop()
299 return nil
300}
301
302func (b *Bubble) loadCommit() tea.Cmd {
303 var err error
304 done := make(chan struct{}, 1)
305 i := b.list.SelectedItem()
306 if i == nil {
307 return nil
308 }
309 c, ok := i.(item)
310 if !ok {
311 return nil
312 }
313 go func() {
314 err = b.loadPatch(c.Commit)
315 done <- struct{}{}
316 b.state = commitState
317 }()
318 return func() tea.Msg {
319 select {
320 case <-done:
321 case <-time.After(waitBeforeLoading):
322 b.state = loadingState
323 }
324 if err != nil {
325 return common.ErrMsg{Err: err}
326 }
327 return commitMsg(c.Commit)
328 }
329}
330
331func (b *Bubble) renderCommit(c *git.Commit) string {
332 s := strings.Builder{}
333 // FIXME: lipgloss prints empty lines when CRLF is used
334 // sanitize commit message from CRLF
335 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
336 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
337 b.style.LogCommitHash.Render("commit "+c.ID.String()),
338 b.style.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
339 b.style.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
340 b.style.LogCommitBody.Render(msg),
341 ))
342 return s.String()
343}
344
345func (b *Bubble) renderDiff(diff *git.Diff) string {
346 var s strings.Builder
347 var pr strings.Builder
348 diffChroma.Code = diff.Patch()
349 err := diffChroma.Render(&pr, common.RenderCtx)
350 if err != nil {
351 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
352 } else {
353 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
354 }
355 return s.String()
356}
357
358func (b *Bubble) View() string {
359 switch b.state {
360 case logState:
361 return b.list.View()
362 case loadingState:
363 return fmt.Sprintf("%s loading commit…", b.spinner.View())
364 case errorState:
365 return b.error.ViewWithPrefix(b.style, "Error")
366 case commitState:
367 return b.commitViewport.View()
368 default:
369 return ""
370 }
371}