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 "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/server/backend"
15 "github.com/charmbracelet/soft-serve/ui/common"
16 "github.com/charmbracelet/soft-serve/ui/components/footer"
17 "github.com/charmbracelet/soft-serve/ui/components/selector"
18 "github.com/charmbracelet/soft-serve/ui/components/viewport"
19 "github.com/muesli/reflow/wrap"
20 "github.com/muesli/termenv"
21)
22
23var waitBeforeLoading = time.Millisecond * 100
24
25type logView int
26
27const (
28 logViewCommits logView = iota
29 logViewDiff
30)
31
32// LogCountMsg is a message that contains the number of commits in a repo.
33type LogCountMsg int64
34
35// LogItemsMsg is a message that contains a slice of LogItem.
36type LogItemsMsg []selector.IdentifiableItem
37
38// LogCommitMsg is a message that contains a git commit.
39type LogCommitMsg *git.Commit
40
41// LogDiffMsg is a message that contains a git diff.
42type LogDiffMsg *git.Diff
43
44// Log is a model that displays a list of commits and their diffs.
45type Log struct {
46 common common.Common
47 selector *selector.Selector
48 vp *viewport.Viewport
49 activeView logView
50 repo backend.Repository
51 ref *git.Reference
52 count int64
53 nextPage int
54 activeCommit *git.Commit
55 selectedCommit *git.Commit
56 currentDiff *git.Diff
57 loadingTime time.Time
58 loading bool
59 spinner spinner.Model
60}
61
62// NewLog creates a new Log model.
63func NewLog(common common.Common) *Log {
64 l := &Log{
65 common: common,
66 vp: viewport.New(common),
67 activeView: logViewCommits,
68 }
69 selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common})
70 selector.SetShowFilter(false)
71 selector.SetShowHelp(false)
72 selector.SetShowPagination(false)
73 selector.SetShowStatusBar(false)
74 selector.SetShowTitle(false)
75 selector.SetFilteringEnabled(false)
76 selector.DisableQuitKeybindings()
77 selector.KeyMap.NextPage = common.KeyMap.NextPage
78 selector.KeyMap.PrevPage = common.KeyMap.PrevPage
79 l.selector = selector
80 s := spinner.New(spinner.WithSpinner(spinner.Dot),
81 spinner.WithStyle(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 = msg
192 case RefMsg:
193 l.ref = msg
194 cmds = append(cmds, l.Init())
195 case LogCountMsg:
196 l.count = int64(msg)
197 case LogItemsMsg:
198 cmds = append(cmds,
199 l.selector.SetItems(msg),
200 // stop loading after receiving items
201 l.stopLoading(),
202 )
203 l.selector.SetPage(l.nextPage)
204 l.SetSize(l.common.Width, l.common.Height)
205 i := l.selector.SelectedItem()
206 if i != nil {
207 l.activeCommit = i.(LogItem).Commit
208 }
209 case tea.KeyMsg, tea.MouseMsg:
210 switch l.activeView {
211 case logViewCommits:
212 switch kmsg := msg.(type) {
213 case tea.KeyMsg:
214 switch {
215 case key.Matches(kmsg, l.common.KeyMap.SelectItem):
216 cmds = append(cmds, l.selector.SelectItem)
217 }
218 }
219 // This is a hack for loading commits on demand based on list.Pagination.
220 curPage := l.selector.Page()
221 s, cmd := l.selector.Update(msg)
222 m := s.(*selector.Selector)
223 l.selector = m
224 if m.Page() != curPage {
225 l.nextPage = m.Page()
226 l.selector.SetPage(curPage)
227 cmds = append(cmds,
228 l.updateCommitsCmd,
229 l.startLoading(),
230 )
231 }
232 cmds = append(cmds, cmd)
233 case logViewDiff:
234 switch kmsg := msg.(type) {
235 case tea.KeyMsg:
236 switch {
237 case key.Matches(kmsg, l.common.KeyMap.BackItem):
238 cmds = append(cmds, backCmd)
239 }
240 }
241 }
242 case BackMsg:
243 if l.activeView == logViewDiff {
244 l.activeView = logViewCommits
245 l.selectedCommit = nil
246 cmds = append(cmds, updateStatusBarCmd)
247 }
248 case selector.ActiveMsg:
249 switch sel := msg.IdentifiableItem.(type) {
250 case LogItem:
251 l.activeCommit = sel.Commit
252 }
253 cmds = append(cmds, updateStatusBarCmd)
254 case selector.SelectMsg:
255 switch sel := msg.IdentifiableItem.(type) {
256 case LogItem:
257 cmds = append(cmds,
258 l.selectCommitCmd(sel.Commit),
259 l.startLoading(),
260 )
261 }
262 case LogCommitMsg:
263 l.selectedCommit = msg
264 cmds = append(cmds, l.loadDiffCmd)
265 case LogDiffMsg:
266 l.currentDiff = msg
267 l.vp.SetContent(
268 lipgloss.JoinVertical(lipgloss.Top,
269 l.renderCommit(l.selectedCommit),
270 l.renderSummary(msg),
271 l.renderDiff(msg),
272 ),
273 )
274 l.vp.GotoTop()
275 l.activeView = logViewDiff
276 cmds = append(cmds,
277 updateStatusBarCmd,
278 // stop loading after setting the viewport content
279 l.stopLoading(),
280 )
281 case footer.ToggleFooterMsg:
282 cmds = append(cmds, l.updateCommitsCmd)
283 case tea.WindowSizeMsg:
284 if l.selectedCommit != nil && l.currentDiff != nil {
285 l.vp.SetContent(
286 lipgloss.JoinVertical(lipgloss.Top,
287 l.renderCommit(l.selectedCommit),
288 l.renderSummary(l.currentDiff),
289 l.renderDiff(l.currentDiff),
290 ),
291 )
292 }
293 if l.repo != nil {
294 cmds = append(cmds,
295 l.updateCommitsCmd,
296 // start loading on resize since the number of commits per page
297 // might change and we'd need to load more commits.
298 l.startLoading(),
299 )
300 }
301 case EmptyRepoMsg:
302 l.ref = nil
303 l.loading = false
304 l.activeView = logViewCommits
305 l.nextPage = 0
306 l.count = 0
307 l.activeCommit = nil
308 l.selectedCommit = nil
309 l.selector.Select(0)
310 cmds = append(cmds, l.setItems([]selector.IdentifiableItem{}))
311 }
312 if l.loading {
313 s, cmd := l.spinner.Update(msg)
314 if cmd != nil {
315 cmds = append(cmds, cmd)
316 }
317 l.spinner = s
318 }
319 switch l.activeView {
320 case logViewDiff:
321 vp, cmd := l.vp.Update(msg)
322 l.vp = vp.(*viewport.Viewport)
323 if cmd != nil {
324 cmds = append(cmds, cmd)
325 }
326 }
327 return l, tea.Batch(cmds...)
328}
329
330// View implements tea.Model.
331func (l *Log) View() string {
332 if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
333 msg := fmt.Sprintf("%s loading commit", l.spinner.View())
334 if l.selectedCommit == nil {
335 msg += "s"
336 }
337 msg += "…"
338 return l.common.Styles.SpinnerContainer.Copy().
339 Height(l.common.Height).
340 Render(msg)
341 }
342 switch l.activeView {
343 case logViewCommits:
344 return l.selector.View()
345 case logViewDiff:
346 return l.vp.View()
347 default:
348 return ""
349 }
350}
351
352// StatusBarValue returns the status bar value.
353func (l *Log) StatusBarValue() string {
354 if l.loading {
355 return ""
356 }
357 c := l.activeCommit
358 if c == nil {
359 return ""
360 }
361 who := c.Author.Name
362 if email := c.Author.Email; email != "" {
363 who += " <" + email + ">"
364 }
365 value := c.ID.String()
366 if who != "" {
367 value += " by " + who
368 }
369 return value
370}
371
372// StatusBarInfo returns the status bar info.
373func (l *Log) StatusBarInfo() string {
374 switch l.activeView {
375 case logViewCommits:
376 // We're using l.nextPage instead of l.selector.Paginator.Page because
377 // of the paginator hack above.
378 return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
379 case logViewDiff:
380 return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
381 default:
382 return ""
383 }
384}
385
386func (l *Log) countCommitsCmd() tea.Msg {
387 if l.ref == nil {
388 logger.Debugf("ui: log: ref is nil")
389 return common.ErrorMsg(errNoRef)
390 }
391 r, err := l.repo.Repository()
392 if err != nil {
393 return common.ErrorMsg(err)
394 }
395 count, err := r.CountCommits(l.ref)
396 if err != nil {
397 logger.Debugf("ui: error counting commits: %v", err)
398 return common.ErrorMsg(err)
399 }
400 return LogCountMsg(count)
401}
402
403func (l *Log) updateCommitsCmd() tea.Msg {
404 count := l.count
405 if l.count == 0 {
406 switch msg := l.countCommitsCmd().(type) {
407 case common.ErrorMsg:
408 return msg
409 case LogCountMsg:
410 count = int64(msg)
411 }
412 }
413 if l.ref == nil {
414 logger.Debugf("ui: log: ref is nil")
415 return common.ErrorMsg(errNoRef)
416 }
417 items := make([]selector.IdentifiableItem, count)
418 page := l.nextPage
419 limit := l.selector.PerPage()
420 skip := page * limit
421 r, err := l.repo.Repository()
422 if err != nil {
423 return common.ErrorMsg(err)
424 }
425 // CommitsByPage pages start at 1
426 cc, err := r.CommitsByPage(l.ref, page+1, limit)
427 if err != nil {
428 logger.Debugf("ui: error loading commits: %v", err)
429 return common.ErrorMsg(err)
430 }
431 for i, c := range cc {
432 idx := i + skip
433 if int64(idx) >= count {
434 break
435 }
436 items[idx] = LogItem{Commit: c}
437 }
438 return LogItemsMsg(items)
439}
440
441func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {
442 return func() tea.Msg {
443 return LogCommitMsg(commit)
444 }
445}
446
447func (l *Log) loadDiffCmd() tea.Msg {
448 r, err := l.repo.Repository()
449 if err != nil {
450 logger.Debugf("ui: error loading diff repository: %v", err)
451 return common.ErrorMsg(err)
452 }
453 diff, err := r.Diff(l.selectedCommit)
454 if err != nil {
455 logger.Debugf("ui: error loading diff: %v", err)
456 return common.ErrorMsg(err)
457 }
458 return LogDiffMsg(diff)
459}
460
461func renderCtx() gansi.RenderContext {
462 return gansi.NewRenderContext(gansi.Options{
463 ColorProfile: termenv.TrueColor,
464 Styles: common.StyleConfig(),
465 })
466}
467
468func (l *Log) renderCommit(c *git.Commit) string {
469 s := strings.Builder{}
470 // FIXME: lipgloss prints empty lines when CRLF is used
471 // sanitize commit message from CRLF
472 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
473 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
474 l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()),
475 l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
476 l.common.Styles.Log.CommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
477 l.common.Styles.Log.CommitBody.Render(msg),
478 ))
479 return wrap.String(s.String(), l.common.Width-2)
480}
481
482func (l *Log) renderSummary(diff *git.Diff) string {
483 stats := strings.Split(diff.Stats().String(), "\n")
484 for i, line := range stats {
485 ch := strings.Split(line, "|")
486 if len(ch) > 1 {
487 adddel := ch[len(ch)-1]
488 adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.Log.CommitStatsAdd.Render("+"))
489 adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.Log.CommitStatsDel.Render("-"))
490 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
491 }
492 }
493 return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
494}
495
496func (l *Log) renderDiff(diff *git.Diff) string {
497 var s strings.Builder
498 var pr strings.Builder
499 diffChroma := &gansi.CodeBlockElement{
500 Code: diff.Patch(),
501 Language: "diff",
502 }
503 err := diffChroma.Render(&pr, renderCtx())
504 if err != nil {
505 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
506 } else {
507 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
508 }
509 return wrap.String(s.String(), l.common.Width)
510}
511
512func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd {
513 return func() tea.Msg {
514 return LogItemsMsg(items)
515 }
516}