1package repo
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 gansi "github.com/charmbracelet/glamour/v2/ansi"
12 lipgloss "github.com/charmbracelet/lipgloss/v2"
13 "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/pkg/proto"
15 "github.com/charmbracelet/soft-serve/pkg/ui/common"
16 "github.com/charmbracelet/soft-serve/pkg/ui/components/footer"
17 "github.com/charmbracelet/soft-serve/pkg/ui/components/selector"
18 "github.com/charmbracelet/soft-serve/pkg/ui/components/viewport"
19 "github.com/charmbracelet/soft-serve/pkg/ui/styles"
20 "github.com/muesli/reflow/wrap"
21)
22
23var waitBeforeLoading = time.Millisecond * 100
24
25type logView int
26
27const (
28 logViewLoading logView = iota
29 logViewCommits
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 proto.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 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(spinner.WithSpinner(spinner.Dot),
81 spinner.WithStyle(common.Styles.Spinner))
82 l.spinner = s
83 return l
84}
85
86// Path implements common.TabComponent.
87func (l *Log) Path() string {
88 switch l.activeView {
89 case logViewCommits:
90 return ""
91 case logViewLoading, logViewDiff:
92 return "diff" // XXX: this is a place holder and doesn't mean anything
93 default:
94 return "diff"
95 }
96}
97
98// TabName returns the name of the tab.
99func (l *Log) TabName() string {
100 return "Commits"
101}
102
103// SetSize implements common.Component.
104func (l *Log) SetSize(width, height int) {
105 l.common.SetSize(width, height)
106 l.selector.SetSize(width, height)
107 l.vp.SetSize(width, height)
108}
109
110// ShortHelp implements help.KeyMap.
111func (l *Log) ShortHelp() []key.Binding {
112 switch l.activeView {
113 case logViewCommits:
114 copyKey := l.common.KeyMap.Copy
115 copyKey.SetHelp("c", "copy hash")
116 return []key.Binding{
117 l.common.KeyMap.UpDown,
118 l.common.KeyMap.SelectItem,
119 copyKey,
120 }
121 case logViewDiff:
122 copyKey := l.common.KeyMap.Copy
123 copyKey.SetHelp("c", "copy diff")
124 return []key.Binding{
125 l.common.KeyMap.UpDown,
126 l.common.KeyMap.BackItem,
127 copyKey,
128 l.common.KeyMap.GotoTop,
129 l.common.KeyMap.GotoBottom,
130 }
131 case logViewLoading:
132 return []key.Binding{}
133 default:
134 return []key.Binding{}
135 }
136}
137
138// FullHelp implements help.KeyMap.
139func (l *Log) FullHelp() [][]key.Binding {
140 k := l.selector.KeyMap
141 b := make([][]key.Binding, 0)
142 switch l.activeView {
143 case logViewCommits:
144 copyKey := l.common.KeyMap.Copy
145 copyKey.SetHelp("c", "copy hash")
146 b = append(b, []key.Binding{
147 l.common.KeyMap.SelectItem,
148 l.common.KeyMap.BackItem,
149 })
150 b = append(b, [][]key.Binding{
151 {
152 copyKey,
153 k.CursorUp,
154 k.CursorDown,
155 },
156 {
157 k.NextPage,
158 k.PrevPage,
159 k.GoToStart,
160 k.GoToEnd,
161 },
162 }...)
163 case logViewDiff:
164 copyKey := l.common.KeyMap.Copy
165 copyKey.SetHelp("c", "copy diff")
166 k := l.vp.KeyMap
167 b = append(b, []key.Binding{
168 l.common.KeyMap.BackItem,
169 copyKey,
170 })
171 b = append(b, [][]key.Binding{
172 {
173 k.PageDown,
174 k.PageUp,
175 k.HalfPageDown,
176 k.HalfPageUp,
177 },
178 {
179 k.Down,
180 k.Up,
181 l.common.KeyMap.GotoTop,
182 l.common.KeyMap.GotoBottom,
183 },
184 }...)
185 case logViewLoading:
186 // No key bindings while loading
187 }
188 return b
189}
190
191func (l *Log) startLoading() tea.Cmd {
192 l.loadingTime = time.Now()
193 l.activeView = logViewLoading
194 return l.spinner.Tick
195}
196
197// Init implements tea.Model.
198func (l *Log) Init() tea.Cmd {
199 l.activeView = logViewCommits
200 l.nextPage = 0
201 l.count = 0
202 l.activeCommit = nil
203 l.selectedCommit = nil
204 return tea.Batch(
205 l.countCommitsCmd,
206 // start loading on init
207 l.startLoading(),
208 )
209}
210
211// Update implements tea.Model.
212func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
213 cmds := make([]tea.Cmd, 0)
214 switch msg := msg.(type) {
215 case RepoMsg:
216 l.repo = msg
217 case RefMsg:
218 l.ref = msg
219 l.selector.Select(0)
220 cmds = append(cmds, l.Init())
221 case LogCountMsg:
222 l.count = int64(msg)
223 l.selector.SetTotalPages(int(msg))
224 l.selector.SetItems(make([]selector.IdentifiableItem, l.count))
225 cmds = append(cmds, l.updateCommitsCmd)
226 case LogItemsMsg:
227 // stop loading after receiving items
228 l.activeView = logViewCommits
229 cmds = append(cmds, l.selector.SetItems(msg))
230 l.selector.SetPage(l.nextPage)
231 l.SetSize(l.common.Width, l.common.Height)
232 i := l.selector.SelectedItem()
233 if i != nil {
234 l.activeCommit = i.(LogItem).Commit
235 }
236 case tea.KeyPressMsg, tea.MouseClickMsg:
237 switch l.activeView {
238 case logViewCommits:
239 switch kmsg := msg.(type) {
240 case tea.KeyPressMsg:
241 switch {
242 case key.Matches(kmsg, l.common.KeyMap.SelectItem):
243 cmds = append(cmds, l.selector.SelectItemCmd)
244 }
245 }
246 // XXX: This is a hack for loading commits on demand based on
247 // list.Pagination.
248 curPage := l.selector.Page()
249 s, cmd := l.selector.Update(msg)
250 m := s.(*selector.Selector)
251 l.selector = m
252 if m.Page() != curPage {
253 l.nextPage = m.Page()
254 l.selector.SetPage(curPage)
255 cmds = append(cmds,
256 l.updateCommitsCmd,
257 l.startLoading(),
258 )
259 }
260 cmds = append(cmds, cmd)
261 case logViewDiff:
262 switch kmsg := msg.(type) {
263 case tea.KeyPressMsg:
264 switch {
265 case key.Matches(kmsg, l.common.KeyMap.BackItem):
266 l.goBack()
267 case key.Matches(kmsg, l.common.KeyMap.Copy):
268 if l.currentDiff != nil {
269 cmds = append(cmds, copyCmd(l.currentDiff.Patch(), "Commit diff copied to clipboard"))
270 }
271 }
272 }
273 case logViewLoading:
274 // No key handling while loading
275 }
276 case GoBackMsg:
277 l.goBack()
278 case selector.ActiveMsg:
279 switch sel := msg.IdentifiableItem.(type) {
280 case LogItem:
281 l.activeCommit = sel.Commit
282 }
283 case selector.SelectMsg:
284 switch sel := msg.IdentifiableItem.(type) {
285 case LogItem:
286 cmds = append(cmds,
287 l.selectCommitCmd(sel.Commit),
288 l.startLoading(),
289 )
290 }
291 case LogCommitMsg:
292 l.selectedCommit = msg
293 cmds = append(cmds, l.loadDiffCmd)
294 case LogDiffMsg:
295 l.currentDiff = msg
296 l.vp.SetContent(
297 lipgloss.JoinVertical(lipgloss.Left,
298 l.renderCommit(l.selectedCommit),
299 renderSummary(msg, l.common.Styles, l.common.Width),
300 renderDiff(msg, l.common.Width),
301 ),
302 )
303 l.vp.GotoTop()
304 l.activeView = logViewDiff
305 case footer.ToggleFooterMsg:
306 cmds = append(cmds, l.updateCommitsCmd)
307 case tea.WindowSizeMsg:
308 l.SetSize(msg.Width, msg.Height)
309 if l.selectedCommit != nil && l.currentDiff != nil {
310 l.vp.SetContent(
311 lipgloss.JoinVertical(lipgloss.Left,
312 l.renderCommit(l.selectedCommit),
313 renderSummary(l.currentDiff, l.common.Styles, l.common.Width),
314 renderDiff(l.currentDiff, l.common.Width),
315 ),
316 )
317 }
318 if l.repo != nil && l.ref != nil {
319 cmds = append(cmds,
320 l.updateCommitsCmd,
321 // start loading on resize since the number of commits per page
322 // might change and we'd need to load more commits.
323 l.startLoading(),
324 )
325 }
326 case EmptyRepoMsg:
327 l.ref = nil
328 l.activeView = logViewCommits
329 l.nextPage = 0
330 l.count = 0
331 l.activeCommit = nil
332 l.selectedCommit = nil
333 l.selector.Select(0)
334 cmds = append(cmds,
335 l.setItems([]selector.IdentifiableItem{}),
336 )
337 case spinner.TickMsg:
338 if l.activeView == logViewLoading && l.spinner.ID() == msg.ID {
339 s, cmd := l.spinner.Update(msg)
340 if cmd != nil {
341 cmds = append(cmds, cmd)
342 }
343 l.spinner = s
344 }
345 }
346 switch l.activeView {
347 case logViewDiff:
348 vp, cmd := l.vp.Update(msg)
349 l.vp = vp.(*viewport.Viewport)
350 if cmd != nil {
351 cmds = append(cmds, cmd)
352 }
353 case logViewLoading, logViewCommits:
354 // No additional viewport updates needed
355 }
356 return l, tea.Batch(cmds...)
357}
358
359// View implements tea.Model.
360func (l *Log) View() string {
361 switch l.activeView {
362 case logViewLoading:
363 if l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
364 msg := fmt.Sprintf("%s loading commit", l.spinner.View())
365 if l.selectedCommit == nil {
366 msg += "s"
367 }
368 msg += "…"
369 return l.common.Styles.SpinnerContainer.
370 Height(l.common.Height).
371 Render(msg)
372 }
373 fallthrough
374 case logViewCommits:
375 return l.selector.View()
376 case logViewDiff:
377 return l.vp.View()
378 default:
379 return ""
380 }
381}
382
383// SpinnerID implements common.TabComponent.
384func (l *Log) SpinnerID() int {
385 return l.spinner.ID()
386}
387
388// StatusBarValue returns the status bar value.
389func (l *Log) StatusBarValue() string {
390 if l.activeView == logViewLoading {
391 return ""
392 }
393 c := l.activeCommit
394 if c == nil {
395 return ""
396 }
397 who := c.Author.Name
398 if email := c.Author.Email; email != "" {
399 who += " <" + email + ">"
400 }
401 value := c.ID.String()[:7]
402 if who != "" {
403 value += " by " + who
404 }
405 return value
406}
407
408// StatusBarInfo returns the status bar info.
409func (l *Log) StatusBarInfo() string {
410 switch l.activeView {
411 case logViewLoading:
412 if l.count == 0 {
413 return ""
414 }
415 fallthrough
416 case logViewCommits:
417 // We're using l.nextPage instead of l.selector.Paginator.Page because
418 // of the paginator hack above.
419 return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
420 case logViewDiff:
421 return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
422 default:
423 return ""
424 }
425}
426
427func (l *Log) goBack() {
428 if l.activeView == logViewDiff {
429 l.activeView = logViewCommits
430 l.selectedCommit = nil
431 }
432}
433
434func (l *Log) countCommitsCmd() tea.Msg {
435 if l.ref == nil {
436 return nil
437 }
438 r, err := l.repo.Open()
439 if err != nil {
440 return common.ErrorMsg(err)
441 }
442 count, err := r.CountCommits(l.ref)
443 if err != nil {
444 l.common.Logger.Debugf("ui: error counting commits: %v", err)
445 return common.ErrorMsg(err)
446 }
447 return LogCountMsg(count)
448}
449
450func (l *Log) updateCommitsCmd() tea.Msg {
451 if l.ref == nil {
452 return nil
453 }
454 r, err := l.repo.Open()
455 if err != nil {
456 return common.ErrorMsg(err)
457 }
458
459 count := l.count
460 if count == 0 {
461 return LogItemsMsg([]selector.IdentifiableItem{})
462 }
463
464 page := l.nextPage
465 limit := l.selector.PerPage()
466 skip := page * limit
467 ref := l.ref
468 items := make([]selector.IdentifiableItem, count)
469 // CommitsByPage pages start at 1
470 cc, err := r.CommitsByPage(ref, page+1, limit)
471 if err != nil {
472 l.common.Logger.Debugf("ui: error loading commits: %v", err)
473 return common.ErrorMsg(err)
474 }
475 for i, c := range cc {
476 idx := i + skip
477 if int64(idx) >= count {
478 break
479 }
480 items[idx] = LogItem{Commit: c}
481 }
482 return LogItemsMsg(items)
483}
484
485func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {
486 return func() tea.Msg {
487 return LogCommitMsg(commit)
488 }
489}
490
491func (l *Log) loadDiffCmd() tea.Msg {
492 if l.selectedCommit == nil {
493 return nil
494 }
495 r, err := l.repo.Open()
496 if err != nil {
497 l.common.Logger.Debugf("ui: error loading diff repository: %v", err)
498 return common.ErrorMsg(err)
499 }
500 diff, err := r.Diff(l.selectedCommit)
501 if err != nil {
502 l.common.Logger.Debugf("ui: error loading diff: %v", err)
503 return common.ErrorMsg(err)
504 }
505 return LogDiffMsg(diff)
506}
507
508func (l *Log) renderCommit(c *git.Commit) string {
509 s := strings.Builder{}
510 // FIXME: lipgloss prints empty lines when CRLF is used
511 // sanitize commit message from CRLF
512 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
513 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
514 l.common.Styles.Log.CommitHash.Render("commit "+c.ID.String()),
515 l.common.Styles.Log.CommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
516 l.common.Styles.Log.CommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
517 l.common.Styles.Log.CommitBody.Render(msg),
518 ))
519 return wrap.String(s.String(), l.common.Width-2)
520}
521
522func renderSummary(diff *git.Diff, styles *styles.Styles, width int) string {
523 stats := strings.Split(diff.Stats().String(), "\n")
524 for i, line := range stats {
525 ch := strings.Split(line, "|")
526 if len(ch) > 1 {
527 adddel := ch[len(ch)-1]
528 adddel = strings.ReplaceAll(adddel, "+", styles.Log.CommitStatsAdd.Render("+"))
529 adddel = strings.ReplaceAll(adddel, "-", styles.Log.CommitStatsDel.Render("-"))
530 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
531 }
532 }
533 return wrap.String(strings.Join(stats, "\n"), width-2)
534}
535
536func renderDiff(diff *git.Diff, width int) string {
537 var s strings.Builder
538 var pr strings.Builder
539 diffChroma := &gansi.CodeBlockElement{
540 Code: diff.Patch(),
541 Language: "diff",
542 }
543 err := diffChroma.Render(&pr, common.StyleRenderer())
544 if err != nil {
545 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
546 } else {
547 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
548 }
549 return wrap.String(s.String(), width)
550}
551
552func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd {
553 return func() tea.Msg {
554 return LogItemsMsg(items)
555 }
556}