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