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 key := msg.(type) {
215 case tea.KeyMsg:
216 switch key.String() {
217 case "l", "right":
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 key := msg.(type) {
237 case tea.KeyMsg:
238 switch key.String() {
239 case "h", "left":
240 l.activeView = logViewCommits
241 l.selectedCommit = nil
242 }
243 }
244 }
245 case selector.ActiveMsg:
246 switch sel := msg.IdentifiableItem.(type) {
247 case LogItem:
248 l.activeCommit = sel.Commit
249 }
250 cmds = append(cmds, updateStatusBarCmd)
251 case selector.SelectMsg:
252 switch sel := msg.IdentifiableItem.(type) {
253 case LogItem:
254 cmds = append(cmds,
255 l.selectCommitCmd(sel.Commit),
256 l.startLoading(),
257 )
258 }
259 case LogCommitMsg:
260 l.selectedCommit = msg
261 cmds = append(cmds, l.loadDiffCmd)
262 case LogDiffMsg:
263 l.currentDiff = msg
264 l.vp.SetContent(
265 lipgloss.JoinVertical(lipgloss.Top,
266 l.renderCommit(l.selectedCommit),
267 l.renderSummary(msg),
268 l.renderDiff(msg),
269 ),
270 )
271 l.vp.GotoTop()
272 l.activeView = logViewDiff
273 cmds = append(cmds,
274 updateStatusBarCmd,
275 // stop loading after setting the viewport content
276 l.stopLoading(),
277 )
278 case footer.ToggleFooterMsg:
279 cmds = append(cmds, l.updateCommitsCmd)
280 case tea.WindowSizeMsg:
281 if l.selectedCommit != nil && l.currentDiff != nil {
282 l.vp.SetContent(
283 lipgloss.JoinVertical(lipgloss.Top,
284 l.renderCommit(l.selectedCommit),
285 l.renderSummary(l.currentDiff),
286 l.renderDiff(l.currentDiff),
287 ),
288 )
289 }
290 if l.repo != nil {
291 cmds = append(cmds,
292 l.updateCommitsCmd,
293 // start loading on resize since the number of commits per page
294 // might change and we'd need to load more commits.
295 l.startLoading(),
296 )
297 }
298 }
299 if l.loading {
300 s, cmd := l.spinner.Update(msg)
301 if cmd != nil {
302 cmds = append(cmds, cmd)
303 }
304 l.spinner = s
305 }
306 switch l.activeView {
307 case logViewDiff:
308 vp, cmd := l.vp.Update(msg)
309 l.vp = vp.(*viewport.Viewport)
310 if cmd != nil {
311 cmds = append(cmds, cmd)
312 }
313 }
314 return l, tea.Batch(cmds...)
315}
316
317// View implements tea.Model.
318func (l *Log) View() string {
319 if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
320 msg := fmt.Sprintf("%s loading commit", l.spinner.View())
321 if l.selectedCommit == nil {
322 msg += "s"
323 }
324 msg += "…"
325 return msg
326 }
327 switch l.activeView {
328 case logViewCommits:
329 return l.selector.View()
330 case logViewDiff:
331 return l.vp.View()
332 default:
333 return ""
334 }
335}
336
337// StatusBarValue returns the status bar value.
338func (l *Log) StatusBarValue() string {
339 if l.loading {
340 return ""
341 }
342 c := l.activeCommit
343 if c == nil {
344 return ""
345 }
346 who := c.Author.Name
347 if email := c.Author.Email; email != "" {
348 who += " <" + email + ">"
349 }
350 value := c.ID.String()
351 if who != "" {
352 value += " by " + who
353 }
354 return value
355}
356
357// StatusBarInfo returns the status bar info.
358func (l *Log) StatusBarInfo() string {
359 switch l.activeView {
360 case logViewCommits:
361 // We're using l.nextPage instead of l.selector.Paginator.Page because
362 // of the paginator hack above.
363 return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
364 case logViewDiff:
365 return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
366 default:
367 return ""
368 }
369}
370
371func (l *Log) countCommitsCmd() tea.Msg {
372 if l.ref == nil {
373 return common.ErrorMsg(errNoRef)
374 }
375 count, err := l.repo.CountCommits(l.ref)
376 if err != nil {
377 return common.ErrorMsg(err)
378 }
379 return LogCountMsg(count)
380}
381
382func (l *Log) updateCommitsCmd() tea.Msg {
383 count := l.count
384 if l.count == 0 {
385 switch msg := l.countCommitsCmd().(type) {
386 case common.ErrorMsg:
387 return msg
388 case LogCountMsg:
389 count = int64(msg)
390 }
391 }
392 if l.ref == nil {
393 return common.ErrorMsg(errNoRef)
394 }
395 items := make([]selector.IdentifiableItem, count)
396 page := l.nextPage
397 limit := l.selector.PerPage()
398 skip := page * limit
399 // CommitsByPage pages start at 1
400 cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
401 if err != nil {
402 return common.ErrorMsg(err)
403 }
404 for i, c := range cc {
405 idx := i + skip
406 if int64(idx) >= count {
407 break
408 }
409 items[idx] = LogItem{Commit: c}
410 }
411 return LogItemsMsg(items)
412}
413
414func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
415 return func() tea.Msg {
416 return LogCommitMsg(commit)
417 }
418}
419
420func (l *Log) loadDiffCmd() tea.Msg {
421 diff, err := l.repo.Diff(l.selectedCommit)
422 if err != nil {
423 return common.ErrorMsg(err)
424 }
425 return LogDiffMsg(diff)
426}
427
428func renderCtx() gansi.RenderContext {
429 return gansi.NewRenderContext(gansi.Options{
430 ColorProfile: termenv.TrueColor,
431 Styles: common.StyleConfig(),
432 })
433}
434
435func (l *Log) renderCommit(c *ggit.Commit) string {
436 s := strings.Builder{}
437 // FIXME: lipgloss prints empty lines when CRLF is used
438 // sanitize commit message from CRLF
439 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
440 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
441 l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()),
442 l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
443 l.common.Styles.Log.CommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
444 l.common.Styles.Log.CommitBody.Render(msg),
445 ))
446 return wrap.String(s.String(), l.common.Width-2)
447}
448
449func (l *Log) renderSummary(diff *ggit.Diff) string {
450 stats := strings.Split(diff.Stats().String(), "\n")
451 for i, line := range stats {
452 ch := strings.Split(line, "|")
453 if len(ch) > 1 {
454 adddel := ch[len(ch)-1]
455 adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.Log.CommitStatsAdd.Render("+"))
456 adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.Log.CommitStatsDel.Render("-"))
457 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
458 }
459 }
460 return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
461}
462
463func (l *Log) renderDiff(diff *ggit.Diff) string {
464 var s strings.Builder
465 var pr strings.Builder
466 diffChroma := &gansi.CodeBlockElement{
467 Code: diff.Patch(),
468 Language: "diff",
469 }
470 err := diffChroma.Render(&pr, renderCtx())
471 if err != nil {
472 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
473 } else {
474 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
475 }
476 return wrap.String(s.String(), l.common.Width)
477}