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