1package repo
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/charmbracelet/bubbles/key"
9 "github.com/charmbracelet/bubbles/spinner"
10 tea "github.com/charmbracelet/bubbletea"
11 gansi "github.com/charmbracelet/glamour/ansi"
12 "github.com/charmbracelet/lipgloss"
13 ggit "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/ui/common"
15 "github.com/charmbracelet/soft-serve/ui/components/selector"
16 "github.com/charmbracelet/soft-serve/ui/components/viewport"
17 "github.com/charmbracelet/soft-serve/ui/git"
18 "github.com/muesli/reflow/wrap"
19 "github.com/muesli/termenv"
20)
21
22var waitBeforeLoading = time.Millisecond * 100
23
24type logView int
25
26const (
27 logViewCommits logView = iota
28 logViewDiff
29)
30
31// LogCountMsg is a message that contains the number of commits in a repo.
32type LogCountMsg int64
33
34// LogItemsMsg is a message that contains a slice of LogItem.
35type LogItemsMsg []selector.IdentifiableItem
36
37// LogCommitMsg is a message that contains a git commit.
38type LogCommitMsg *ggit.Commit
39
40// LogDiffMsg is a message that contains a git diff.
41type LogDiffMsg *ggit.Diff
42
43// Log is a model that displays a list of commits and their diffs.
44type Log struct {
45 common common.Common
46 selector *selector.Selector
47 vp *viewport.Viewport
48 activeView logView
49 repo git.GitRepo
50 ref *ggit.Reference
51 count int64
52 nextPage int
53 activeCommit *ggit.Commit
54 selectedCommit *ggit.Commit
55 currentDiff *ggit.Diff
56 loadingTime time.Time
57 loading bool
58 spinner spinner.Model
59}
60
61// NewLog creates a new Log model.
62func NewLog(common common.Common) *Log {
63 l := &Log{
64 common: common,
65 vp: viewport.New(common),
66 activeView: logViewCommits,
67 }
68 selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common})
69 selector.SetShowFilter(false)
70 selector.SetShowHelp(false)
71 selector.SetShowPagination(false)
72 selector.SetShowStatusBar(false)
73 selector.SetShowTitle(false)
74 selector.SetFilteringEnabled(false)
75 selector.DisableQuitKeybindings()
76 selector.KeyMap.NextPage = common.KeyMap.NextPage
77 selector.KeyMap.PrevPage = common.KeyMap.PrevPage
78 l.selector = selector
79 s := spinner.New()
80 s.Spinner = spinner.Dot
81 s.Style = common.Styles.Spinner
82 l.spinner = s
83 return l
84}
85
86// SetSize implements common.Component.
87func (l *Log) SetSize(width, height int) {
88 l.common.SetSize(width, height)
89 l.selector.SetSize(width, height)
90 l.vp.SetSize(width, height)
91}
92
93// ShortHelp implements help.KeyMap.
94func (l *Log) ShortHelp() []key.Binding {
95 switch l.activeView {
96 case logViewCommits:
97 copyKey := l.common.KeyMap.Copy
98 copyKey.SetHelp("c", "copy hash")
99 return []key.Binding{
100 l.common.KeyMap.UpDown,
101 l.common.KeyMap.SelectItem,
102 copyKey,
103 }
104 case logViewDiff:
105 return []key.Binding{
106 l.common.KeyMap.UpDown,
107 l.common.KeyMap.BackItem,
108 }
109 default:
110 return []key.Binding{}
111 }
112}
113
114// FullHelp implements help.KeyMap.
115func (l *Log) FullHelp() [][]key.Binding {
116 k := l.selector.KeyMap
117 b := make([][]key.Binding, 0)
118 switch l.activeView {
119 case logViewCommits:
120 copyKey := l.common.KeyMap.Copy
121 copyKey.SetHelp("c", "copy hash")
122 b = append(b, []key.Binding{
123 l.common.KeyMap.SelectItem,
124 l.common.KeyMap.BackItem,
125 })
126 b = append(b, [][]key.Binding{
127 {
128 copyKey,
129 k.CursorUp,
130 k.CursorDown,
131 },
132 {
133 k.NextPage,
134 k.PrevPage,
135 k.GoToStart,
136 k.GoToEnd,
137 },
138 }...)
139 case logViewDiff:
140 k := l.vp.KeyMap
141 b = append(b, []key.Binding{
142 l.common.KeyMap.BackItem,
143 })
144 b = append(b, [][]key.Binding{
145 {
146 k.PageDown,
147 k.PageUp,
148 k.HalfPageDown,
149 k.HalfPageUp,
150 },
151 {
152 k.Down,
153 k.Up,
154 },
155 }...)
156 }
157 return b
158}
159
160func (l *Log) startLoading() tea.Cmd {
161 l.loadingTime = time.Now()
162 l.loading = true
163 return l.spinner.Tick
164}
165
166func (l *Log) stopLoading() tea.Cmd {
167 l.loading = false
168 return updateStatusBarCmd
169}
170
171// Init implements tea.Model.
172func (l *Log) Init() tea.Cmd {
173 l.activeView = logViewCommits
174 l.nextPage = 0
175 l.count = 0
176 l.activeCommit = nil
177 l.selectedCommit = nil
178 l.selector.Select(0)
179 return tea.Batch(
180 l.updateCommitsCmd,
181 // start loading on init
182 l.startLoading(),
183 )
184}
185
186// Update implements tea.Model.
187func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
188 cmds := make([]tea.Cmd, 0)
189 switch msg := msg.(type) {
190 case RepoMsg:
191 l.repo = git.GitRepo(msg)
192 cmds = append(cmds, l.Init())
193 case RefMsg:
194 l.ref = msg
195 cmds = append(cmds, l.Init())
196 case LogCountMsg:
197 l.count = int64(msg)
198 case LogItemsMsg:
199 cmds = append(cmds,
200 l.selector.SetItems(msg),
201 // stop loading after receiving items
202 l.stopLoading(),
203 )
204 l.selector.SetPage(l.nextPage)
205 l.SetSize(l.common.Width, l.common.Height)
206 i := l.selector.SelectedItem()
207 if i != nil {
208 l.activeCommit = i.(LogItem).Commit
209 }
210 case tea.KeyMsg, tea.MouseMsg:
211 switch l.activeView {
212 case logViewCommits:
213 switch key := msg.(type) {
214 case tea.KeyMsg:
215 switch key.String() {
216 case "l", "right":
217 cmds = append(cmds, l.selector.SelectItem)
218 }
219 }
220 // This is a hack for loading commits on demand based on list.Pagination.
221 curPage := l.selector.Page()
222 s, cmd := l.selector.Update(msg)
223 m := s.(*selector.Selector)
224 l.selector = m
225 if m.Page() != curPage {
226 l.nextPage = m.Page()
227 l.selector.SetPage(curPage)
228 cmds = append(cmds,
229 l.updateCommitsCmd,
230 l.startLoading(),
231 )
232 }
233 cmds = append(cmds, cmd)
234 case logViewDiff:
235 switch key := msg.(type) {
236 case tea.KeyMsg:
237 switch key.String() {
238 case "h", "left":
239 l.activeView = logViewCommits
240 l.selectedCommit = nil
241 }
242 }
243 }
244 case selector.ActiveMsg:
245 switch sel := msg.IdentifiableItem.(type) {
246 case LogItem:
247 l.activeCommit = sel.Commit
248 }
249 cmds = append(cmds, updateStatusBarCmd)
250 case selector.SelectMsg:
251 switch sel := msg.IdentifiableItem.(type) {
252 case LogItem:
253 cmds = append(cmds,
254 l.selectCommitCmd(sel.Commit),
255 l.startLoading(),
256 )
257 }
258 case LogCommitMsg:
259 l.selectedCommit = msg
260 cmds = append(cmds, l.loadDiffCmd)
261 case LogDiffMsg:
262 l.currentDiff = msg
263 l.vp.SetContent(
264 lipgloss.JoinVertical(lipgloss.Top,
265 l.renderCommit(l.selectedCommit),
266 l.renderSummary(msg),
267 l.renderDiff(msg),
268 ),
269 )
270 l.vp.GotoTop()
271 l.activeView = logViewDiff
272 cmds = append(cmds,
273 updateStatusBarCmd,
274 // stop loading after setting the viewport content
275 l.stopLoading(),
276 )
277 case tea.WindowSizeMsg:
278 if l.selectedCommit != nil && l.currentDiff != nil {
279 l.vp.SetContent(
280 lipgloss.JoinVertical(lipgloss.Top,
281 l.renderCommit(l.selectedCommit),
282 l.renderSummary(l.currentDiff),
283 l.renderDiff(l.currentDiff),
284 ),
285 )
286 }
287 if l.repo != nil {
288 cmds = append(cmds,
289 l.updateCommitsCmd,
290 // start loading on resize since the number of commits per page
291 // might change and we'd need to load more commits.
292 l.startLoading(),
293 )
294 }
295 }
296 if l.loading {
297 s, cmd := l.spinner.Update(msg)
298 if cmd != nil {
299 cmds = append(cmds, cmd)
300 }
301 l.spinner = s
302 }
303 switch l.activeView {
304 case logViewDiff:
305 vp, cmd := l.vp.Update(msg)
306 l.vp = vp.(*viewport.Viewport)
307 if cmd != nil {
308 cmds = append(cmds, cmd)
309 }
310 }
311 return l, tea.Batch(cmds...)
312}
313
314// View implements tea.Model.
315func (l *Log) View() string {
316 if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
317 msg := fmt.Sprintf("%s loading commit", l.spinner.View())
318 if l.selectedCommit == nil {
319 msg += "s"
320 }
321 msg += "…"
322 return msg
323 }
324 switch l.activeView {
325 case logViewCommits:
326 return l.selector.View()
327 case logViewDiff:
328 return l.vp.View()
329 default:
330 return ""
331 }
332}
333
334// StatusBarValue returns the status bar value.
335func (l *Log) StatusBarValue() string {
336 if l.loading {
337 return ""
338 }
339 c := l.activeCommit
340 if c == nil {
341 return ""
342 }
343 who := c.Author.Name
344 if email := c.Author.Email; email != "" {
345 who += " <" + email + ">"
346 }
347 value := c.ID.String()
348 if who != "" {
349 value += " by " + who
350 }
351 return value
352}
353
354// StatusBarInfo returns the status bar info.
355func (l *Log) StatusBarInfo() string {
356 switch l.activeView {
357 case logViewCommits:
358 // We're using l.nextPage instead of l.selector.Paginator.Page because
359 // of the paginator hack above.
360 return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
361 case logViewDiff:
362 return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
363 default:
364 return ""
365 }
366}
367
368func (l *Log) countCommitsCmd() tea.Msg {
369 if l.ref == nil {
370 return common.ErrorMsg(errNoRef)
371 }
372 count, err := l.repo.CountCommits(l.ref)
373 if err != nil {
374 return common.ErrorMsg(err)
375 }
376 return LogCountMsg(count)
377}
378
379func (l *Log) updateCommitsCmd() tea.Msg {
380 count := l.count
381 if l.count == 0 {
382 switch msg := l.countCommitsCmd().(type) {
383 case common.ErrorMsg:
384 return msg
385 case LogCountMsg:
386 count = int64(msg)
387 }
388 }
389 if l.ref == nil {
390 return common.ErrorMsg(errNoRef)
391 }
392 items := make([]selector.IdentifiableItem, count)
393 page := l.nextPage
394 limit := l.selector.PerPage()
395 skip := page * limit
396 // CommitsByPage pages start at 1
397 cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
398 if err != nil {
399 return common.ErrorMsg(err)
400 }
401 for i, c := range cc {
402 idx := i + skip
403 if int64(idx) >= count {
404 break
405 }
406 items[idx] = LogItem{Commit: c}
407 }
408 return LogItemsMsg(items)
409}
410
411func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
412 return func() tea.Msg {
413 return LogCommitMsg(commit)
414 }
415}
416
417func (l *Log) loadDiffCmd() tea.Msg {
418 diff, err := l.repo.Diff(l.selectedCommit)
419 if err != nil {
420 return common.ErrorMsg(err)
421 }
422 return LogDiffMsg(diff)
423}
424
425func renderCtx() gansi.RenderContext {
426 return gansi.NewRenderContext(gansi.Options{
427 ColorProfile: termenv.TrueColor,
428 Styles: common.StyleConfig(),
429 })
430}
431
432func (l *Log) renderCommit(c *ggit.Commit) string {
433 s := strings.Builder{}
434 // FIXME: lipgloss prints empty lines when CRLF is used
435 // sanitize commit message from CRLF
436 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
437 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
438 l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()),
439 l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
440 l.common.Styles.Log.CommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
441 l.common.Styles.Log.CommitBody.Render(msg),
442 ))
443 return wrap.String(s.String(), l.common.Width-2)
444}
445
446func (l *Log) renderSummary(diff *ggit.Diff) string {
447 stats := strings.Split(diff.Stats().String(), "\n")
448 for i, line := range stats {
449 ch := strings.Split(line, "|")
450 if len(ch) > 1 {
451 adddel := ch[len(ch)-1]
452 adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.Log.CommitStatsAdd.Render("+"))
453 adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.Log.CommitStatsDel.Render("-"))
454 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
455 }
456 }
457 return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
458}
459
460func (l *Log) renderDiff(diff *ggit.Diff) string {
461 var s strings.Builder
462 var pr strings.Builder
463 diffChroma := &gansi.CodeBlockElement{
464 Code: diff.Patch(),
465 Language: "diff",
466 }
467 err := diffChroma.Render(&pr, renderCtx())
468 if err != nil {
469 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
470 } else {
471 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
472 }
473 return wrap.String(s.String(), l.common.Width)
474}