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