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