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