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 ggit "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/ui/common"
15 "github.com/charmbracelet/soft-serve/ui/components/footer"
16 "github.com/charmbracelet/soft-serve/ui/components/selector"
17 "github.com/charmbracelet/soft-serve/ui/components/viewport"
18 "github.com/charmbracelet/soft-serve/ui/git"
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 *ggit.Commit
40
41// LogDiffMsg is a message that contains a git diff.
42type LogDiffMsg *ggit.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 git.GitRepo
51 ref *ggit.Reference
52 count int64
53 nextPage int
54 activeCommit *ggit.Commit
55 selectedCommit *ggit.Commit
56 currentDiff *ggit.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()
81 s.Spinner = spinner.Dot
82 s.Style = 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 = git.GitRepo(msg)
193 cmds = append(cmds, l.Init())
194 case RefMsg:
195 l.ref = msg
196 cmds = append(cmds, l.Init())
197 case LogCountMsg:
198 l.count = int64(msg)
199 case LogItemsMsg:
200 cmds = append(cmds,
201 l.selector.SetItems(msg),
202 // stop loading after receiving items
203 l.stopLoading(),
204 )
205 l.selector.SetPage(l.nextPage)
206 l.SetSize(l.common.Width, l.common.Height)
207 i := l.selector.SelectedItem()
208 if i != nil {
209 l.activeCommit = i.(LogItem).Commit
210 }
211 case tea.KeyMsg, tea.MouseMsg:
212 switch l.activeView {
213 case logViewCommits:
214 switch kmsg := msg.(type) {
215 case tea.KeyMsg:
216 switch {
217 case key.Matches(kmsg, l.common.KeyMap.SelectItem):
218 cmds = append(cmds, l.selector.SelectItem)
219 }
220 }
221 // This is a hack for loading commits on demand based on list.Pagination.
222 curPage := l.selector.Page()
223 s, cmd := l.selector.Update(msg)
224 m := s.(*selector.Selector)
225 l.selector = m
226 if m.Page() != curPage {
227 l.nextPage = m.Page()
228 l.selector.SetPage(curPage)
229 cmds = append(cmds,
230 l.updateCommitsCmd,
231 l.startLoading(),
232 )
233 }
234 cmds = append(cmds, cmd)
235 case logViewDiff:
236 switch kmsg := msg.(type) {
237 case tea.KeyMsg:
238 switch {
239 case key.Matches(kmsg, l.common.KeyMap.BackItem):
240 cmds = append(cmds, backCmd)
241 }
242 }
243 }
244 case BackMsg:
245 if l.activeView == logViewDiff {
246 l.activeView = logViewCommits
247 l.selectedCommit = nil
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 }
303 if l.loading {
304 s, cmd := l.spinner.Update(msg)
305 if cmd != nil {
306 cmds = append(cmds, cmd)
307 }
308 l.spinner = s
309 }
310 switch l.activeView {
311 case logViewDiff:
312 vp, cmd := l.vp.Update(msg)
313 l.vp = vp.(*viewport.Viewport)
314 if cmd != nil {
315 cmds = append(cmds, cmd)
316 }
317 }
318 return l, tea.Batch(cmds...)
319}
320
321// View implements tea.Model.
322func (l *Log) View() string {
323 if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
324 msg := fmt.Sprintf("%s loading commit", l.spinner.View())
325 if l.selectedCommit == nil {
326 msg += "s"
327 }
328 msg += "…"
329 return msg
330 }
331 switch l.activeView {
332 case logViewCommits:
333 return l.selector.View()
334 case logViewDiff:
335 return l.vp.View()
336 default:
337 return ""
338 }
339}
340
341// StatusBarValue returns the status bar value.
342func (l *Log) StatusBarValue() string {
343 if l.loading {
344 return ""
345 }
346 c := l.activeCommit
347 if c == nil {
348 return ""
349 }
350 who := c.Author.Name
351 if email := c.Author.Email; email != "" {
352 who += " <" + email + ">"
353 }
354 value := c.ID.String()
355 if who != "" {
356 value += " by " + who
357 }
358 return value
359}
360
361// StatusBarInfo returns the status bar info.
362func (l *Log) StatusBarInfo() string {
363 switch l.activeView {
364 case logViewCommits:
365 // We're using l.nextPage instead of l.selector.Paginator.Page because
366 // of the paginator hack above.
367 return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
368 case logViewDiff:
369 return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
370 default:
371 return ""
372 }
373}
374
375func (l *Log) countCommitsCmd() tea.Msg {
376 if l.ref == nil {
377 return common.ErrorMsg(errNoRef)
378 }
379 count, err := l.repo.CountCommits(l.ref)
380 if err != nil {
381 return common.ErrorMsg(err)
382 }
383 return LogCountMsg(count)
384}
385
386func (l *Log) updateCommitsCmd() tea.Msg {
387 count := l.count
388 if l.count == 0 {
389 switch msg := l.countCommitsCmd().(type) {
390 case common.ErrorMsg:
391 return msg
392 case LogCountMsg:
393 count = int64(msg)
394 }
395 }
396 if l.ref == nil {
397 return common.ErrorMsg(errNoRef)
398 }
399 items := make([]selector.IdentifiableItem, count)
400 page := l.nextPage
401 limit := l.selector.PerPage()
402 skip := page * limit
403 // CommitsByPage pages start at 1
404 cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
405 if err != nil {
406 return common.ErrorMsg(err)
407 }
408 for i, c := range cc {
409 idx := i + skip
410 if int64(idx) >= count {
411 break
412 }
413 items[idx] = LogItem{Commit: c}
414 }
415 return LogItemsMsg(items)
416}
417
418func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
419 return func() tea.Msg {
420 return LogCommitMsg(commit)
421 }
422}
423
424func (l *Log) loadDiffCmd() tea.Msg {
425 diff, err := l.repo.Diff(l.selectedCommit)
426 if err != nil {
427 return common.ErrorMsg(err)
428 }
429 return LogDiffMsg(diff)
430}
431
432func renderCtx() gansi.RenderContext {
433 return gansi.NewRenderContext(gansi.Options{
434 ColorProfile: termenv.TrueColor,
435 Styles: common.StyleConfig(),
436 })
437}
438
439func (l *Log) renderCommit(c *ggit.Commit) string {
440 s := strings.Builder{}
441 // FIXME: lipgloss prints empty lines when CRLF is used
442 // sanitize commit message from CRLF
443 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
444 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
445 l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()),
446 l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
447 l.common.Styles.Log.CommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
448 l.common.Styles.Log.CommitBody.Render(msg),
449 ))
450 return wrap.String(s.String(), l.common.Width-2)
451}
452
453func (l *Log) renderSummary(diff *ggit.Diff) string {
454 stats := strings.Split(diff.Stats().String(), "\n")
455 for i, line := range stats {
456 ch := strings.Split(line, "|")
457 if len(ch) > 1 {
458 adddel := ch[len(ch)-1]
459 adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.Log.CommitStatsAdd.Render("+"))
460 adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.Log.CommitStatsDel.Render("-"))
461 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
462 }
463 }
464 return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
465}
466
467func (l *Log) renderDiff(diff *ggit.Diff) string {
468 var s strings.Builder
469 var pr strings.Builder
470 diffChroma := &gansi.CodeBlockElement{
471 Code: diff.Patch(),
472 Language: "diff",
473 }
474 err := diffChroma.Render(&pr, renderCtx())
475 if err != nil {
476 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
477 } else {
478 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
479 }
480 return wrap.String(s.String(), l.common.Width)
481}