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