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/selector"
16 "github.com/charmbracelet/soft-serve/ui/components/viewport"
17 "github.com/charmbracelet/soft-serve/ui/git"
18 "github.com/muesli/reflow/wrap"
19 "github.com/muesli/termenv"
20)
21
22var (
23 waitBeforeLoading = time.Millisecond * 100
24)
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.GitRepo
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()
82 s.Spinner = spinner.Dot
83 s.Style = common.Styles.Spinner
84 l.spinner = s
85 return l
86}
87
88// SetSize implements common.Component.
89func (l *Log) SetSize(width, height int) {
90 l.common.SetSize(width, height)
91 l.selector.SetSize(width, height)
92 l.vp.SetSize(width, height)
93}
94
95// ShortHelp implements help.KeyMap.
96func (l *Log) ShortHelp() []key.Binding {
97 switch l.activeView {
98 case logViewCommits:
99 copyKey := l.common.KeyMap.Copy
100 copyKey.SetHelp("c", "copy hash")
101 return []key.Binding{
102 l.common.KeyMap.UpDown,
103 l.common.KeyMap.SelectItem,
104 copyKey,
105 }
106 case logViewDiff:
107 return []key.Binding{
108 l.common.KeyMap.UpDown,
109 l.common.KeyMap.BackItem,
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 },
157 }...)
158 }
159 return b
160}
161
162func (l *Log) startLoading() tea.Cmd {
163 l.loadingTime = time.Now()
164 l.loading = true
165 return l.spinner.Tick
166}
167
168func (l *Log) stopLoading() tea.Cmd {
169 l.loading = false
170 return updateStatusBarCmd
171}
172
173// Init implements tea.Model.
174func (l *Log) Init() tea.Cmd {
175 l.activeView = logViewCommits
176 l.nextPage = 0
177 l.count = 0
178 l.activeCommit = nil
179 l.selectedCommit = nil
180 l.selector.Select(0)
181 return tea.Batch(
182 l.updateCommitsCmd,
183 // start loading on init
184 l.startLoading(),
185 )
186}
187
188// Update implements tea.Model.
189func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
190 cmds := make([]tea.Cmd, 0)
191 switch msg := msg.(type) {
192 case RepoMsg:
193 l.repo = git.GitRepo(msg)
194 cmds = append(cmds, l.Init())
195 case RefMsg:
196 l.ref = msg
197 cmds = append(cmds, l.Init())
198 case LogCountMsg:
199 l.count = int64(msg)
200 case LogItemsMsg:
201 cmds = append(cmds,
202 l.selector.SetItems(msg),
203 // stop loading after receiving items
204 l.stopLoading(),
205 )
206 l.selector.SetPage(l.nextPage)
207 l.SetSize(l.common.Width, l.common.Height)
208 i := l.selector.SelectedItem()
209 if i != nil {
210 l.activeCommit = i.(LogItem).Commit
211 }
212 case tea.KeyMsg, tea.MouseMsg:
213 switch l.activeView {
214 case logViewCommits:
215 switch key := msg.(type) {
216 case tea.KeyMsg:
217 switch key.String() {
218 case "l", "right":
219 cmds = append(cmds, l.selector.SelectItem)
220 }
221 }
222 // This is a hack for loading commits on demand based on list.Pagination.
223 curPage := l.selector.Page()
224 s, cmd := l.selector.Update(msg)
225 m := s.(*selector.Selector)
226 l.selector = m
227 if m.Page() != curPage {
228 l.nextPage = m.Page()
229 l.selector.SetPage(curPage)
230 cmds = append(cmds,
231 l.updateCommitsCmd,
232 l.startLoading(),
233 )
234 }
235 cmds = append(cmds, cmd)
236 case logViewDiff:
237 switch key := msg.(type) {
238 case tea.KeyMsg:
239 switch key.String() {
240 case "h", "left":
241 l.activeView = logViewCommits
242 l.selectedCommit = nil
243 }
244 }
245 }
246 case selector.ActiveMsg:
247 switch sel := msg.IdentifiableItem.(type) {
248 case LogItem:
249 l.activeCommit = sel.Commit
250 }
251 cmds = append(cmds, updateStatusBarCmd)
252 case selector.SelectMsg:
253 switch sel := msg.IdentifiableItem.(type) {
254 case LogItem:
255 cmds = append(cmds,
256 l.selectCommitCmd(sel.Commit),
257 l.startLoading(),
258 )
259 }
260 case LogCommitMsg:
261 l.selectedCommit = msg
262 cmds = append(cmds, l.loadDiffCmd)
263 case LogDiffMsg:
264 l.currentDiff = msg
265 l.vp.SetContent(
266 lipgloss.JoinVertical(lipgloss.Top,
267 l.renderCommit(l.selectedCommit),
268 l.renderSummary(msg),
269 l.renderDiff(msg),
270 ),
271 )
272 l.vp.GotoTop()
273 l.activeView = logViewDiff
274 cmds = append(cmds,
275 updateStatusBarCmd,
276 // stop loading after setting the viewport content
277 l.stopLoading(),
278 )
279 case tea.WindowSizeMsg:
280 if l.selectedCommit != nil && l.currentDiff != nil {
281 l.vp.SetContent(
282 lipgloss.JoinVertical(lipgloss.Top,
283 l.renderCommit(l.selectedCommit),
284 l.renderSummary(l.currentDiff),
285 l.renderDiff(l.currentDiff),
286 ),
287 )
288 }
289 if l.repo != nil {
290 cmds = append(cmds,
291 l.updateCommitsCmd,
292 // start loading on resize since the number of commits per page
293 // might change and we'd need to load more commits.
294 l.startLoading(),
295 )
296 }
297 }
298 if l.loading {
299 s, cmd := l.spinner.Update(msg)
300 if cmd != nil {
301 cmds = append(cmds, cmd)
302 }
303 l.spinner = s
304 }
305 switch l.activeView {
306 case logViewDiff:
307 vp, cmd := l.vp.Update(msg)
308 l.vp = vp.(*viewport.Viewport)
309 if cmd != nil {
310 cmds = append(cmds, cmd)
311 }
312 }
313 return l, tea.Batch(cmds...)
314}
315
316// View implements tea.Model.
317func (l *Log) View() string {
318 if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
319 msg := fmt.Sprintf("%s loading commit", l.spinner.View())
320 if l.selectedCommit == nil {
321 msg += "s"
322 }
323 msg += "…"
324 return msg
325 }
326 switch l.activeView {
327 case logViewCommits:
328 return l.selector.View()
329 case logViewDiff:
330 return l.vp.View()
331 default:
332 return ""
333 }
334}
335
336// StatusBarValue returns the status bar value.
337func (l *Log) StatusBarValue() string {
338 if l.loading {
339 return ""
340 }
341 c := l.activeCommit
342 if c == nil {
343 return ""
344 }
345 who := c.Author.Name
346 if email := c.Author.Email; email != "" {
347 who += " <" + email + ">"
348 }
349 value := c.ID.String()
350 if who != "" {
351 value += " by " + who
352 }
353 return value
354}
355
356// StatusBarInfo returns the status bar info.
357func (l *Log) StatusBarInfo() string {
358 switch l.activeView {
359 case logViewCommits:
360 // We're using l.nextPage instead of l.selector.Paginator.Page because
361 // of the paginator hack above.
362 return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
363 case logViewDiff:
364 return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
365 default:
366 return ""
367 }
368}
369
370func (l *Log) countCommitsCmd() tea.Msg {
371 if l.ref == nil {
372 return common.ErrorMsg(errNoRef)
373 }
374 count, err := l.repo.CountCommits(l.ref)
375 if err != nil {
376 return common.ErrorMsg(err)
377 }
378 return LogCountMsg(count)
379}
380
381func (l *Log) updateCommitsCmd() tea.Msg {
382 count := l.count
383 if l.count == 0 {
384 switch msg := l.countCommitsCmd().(type) {
385 case common.ErrorMsg:
386 return msg
387 case LogCountMsg:
388 count = int64(msg)
389 }
390 }
391 if l.ref == nil {
392 return common.ErrorMsg(errNoRef)
393 }
394 items := make([]selector.IdentifiableItem, count)
395 page := l.nextPage
396 limit := l.selector.PerPage()
397 skip := page * limit
398 // CommitsByPage pages start at 1
399 cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
400 if err != nil {
401 return common.ErrorMsg(err)
402 }
403 for i, c := range cc {
404 idx := i + skip
405 if int64(idx) >= count {
406 break
407 }
408 items[idx] = LogItem{Commit: c}
409 }
410 return LogItemsMsg(items)
411}
412
413func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
414 return func() tea.Msg {
415 return LogCommitMsg(commit)
416 }
417}
418
419func (l *Log) loadDiffCmd() tea.Msg {
420 diff, err := l.repo.Diff(l.selectedCommit)
421 if err != nil {
422 return common.ErrorMsg(err)
423 }
424 return LogDiffMsg(diff)
425}
426
427func renderCtx() gansi.RenderContext {
428 return gansi.NewRenderContext(gansi.Options{
429 ColorProfile: termenv.TrueColor,
430 Styles: common.StyleConfig(),
431 })
432}
433
434func (l *Log) renderCommit(c *ggit.Commit) string {
435 s := strings.Builder{}
436 // FIXME: lipgloss prints empty lines when CRLF is used
437 // sanitize commit message from CRLF
438 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
439 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
440 l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()),
441 l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
442 l.common.Styles.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
443 l.common.Styles.LogCommitBody.Render(msg),
444 ))
445 return wrap.String(s.String(), l.common.Width-2)
446}
447
448func (l *Log) renderSummary(diff *ggit.Diff) string {
449 stats := strings.Split(diff.Stats().String(), "\n")
450 for i, line := range stats {
451 ch := strings.Split(line, "|")
452 if len(ch) > 1 {
453 adddel := ch[len(ch)-1]
454 adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.LogCommitStatsAdd.Render("+"))
455 adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-"))
456 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
457 }
458 }
459 return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
460}
461
462func (l *Log) renderDiff(diff *ggit.Diff) string {
463 var s strings.Builder
464 var pr strings.Builder
465 diffChroma := &gansi.CodeBlockElement{
466 Code: diff.Patch(),
467 Language: "diff",
468 }
469 err := diffChroma.Render(&pr, renderCtx())
470 if err != nil {
471 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
472 } else {
473 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
474 }
475 return wrap.String(s.String(), l.common.Width)
476}