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