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