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