1package repo
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/charmbracelet/bubbles/key"
9 tea "github.com/charmbracelet/bubbletea"
10 gansi "github.com/charmbracelet/glamour/ansi"
11 "github.com/charmbracelet/lipgloss"
12 ggit "github.com/charmbracelet/soft-serve/git"
13 "github.com/charmbracelet/soft-serve/ui/common"
14 "github.com/charmbracelet/soft-serve/ui/components/selector"
15 "github.com/charmbracelet/soft-serve/ui/components/viewport"
16 "github.com/charmbracelet/soft-serve/ui/git"
17 "github.com/muesli/reflow/wrap"
18 "github.com/muesli/termenv"
19)
20
21type logView int
22
23const (
24 logViewCommits logView = iota
25 logViewDiff
26)
27
28// LogCountMsg is a message that contains the number of commits in a repo.
29type LogCountMsg int64
30
31// LogItemsMsg is a message that contains a slice of LogItem.
32type LogItemsMsg []selector.IdentifiableItem
33
34// LogCommitMsg is a message that contains a git commit.
35type LogCommitMsg *ggit.Commit
36
37// LogDiffMsg is a message that contains a git diff.
38type LogDiffMsg *ggit.Diff
39
40// Log is a model that displays a list of commits and their diffs.
41type Log struct {
42 common common.Common
43 selector *selector.Selector
44 vp *viewport.Viewport
45 activeView logView
46 repo git.GitRepo
47 ref *ggit.Reference
48 count int64
49 nextPage int
50 activeCommit *ggit.Commit
51 selectedCommit *ggit.Commit
52 currentDiff *ggit.Diff
53}
54
55// NewLog creates a new Log model.
56func NewLog(common common.Common) *Log {
57 l := &Log{
58 common: common,
59 vp: viewport.New(common),
60 activeView: logViewCommits,
61 }
62 selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common})
63 selector.SetShowFilter(false)
64 selector.SetShowHelp(false)
65 selector.SetShowPagination(false)
66 selector.SetShowStatusBar(false)
67 selector.SetShowTitle(false)
68 selector.SetFilteringEnabled(false)
69 selector.DisableQuitKeybindings()
70 selector.KeyMap.NextPage = common.KeyMap.NextPage
71 selector.KeyMap.PrevPage = common.KeyMap.PrevPage
72 l.selector = selector
73 return l
74}
75
76// SetSize implements common.Component.
77func (l *Log) SetSize(width, height int) {
78 l.common.SetSize(width, height)
79 l.selector.SetSize(width, height)
80 l.vp.SetSize(width, height)
81}
82
83// ShortHelp implements help.KeyMap.
84func (l *Log) ShortHelp() []key.Binding {
85 switch l.activeView {
86 case logViewCommits:
87 copyKey := l.common.KeyMap.Copy
88 copyKey.SetHelp("c", "copy hash")
89 return []key.Binding{
90 l.common.KeyMap.UpDown,
91 l.common.KeyMap.SelectItem,
92 copyKey,
93 }
94 case logViewDiff:
95 return []key.Binding{
96 l.common.KeyMap.UpDown,
97 l.common.KeyMap.BackItem,
98 }
99 default:
100 return []key.Binding{}
101 }
102}
103
104// FullHelp implements help.KeyMap.
105func (l *Log) FullHelp() [][]key.Binding {
106 k := l.selector.KeyMap
107 b := make([][]key.Binding, 0)
108 switch l.activeView {
109 case logViewCommits:
110 copyKey := l.common.KeyMap.Copy
111 copyKey.SetHelp("c", "copy hash")
112 b = append(b, []key.Binding{
113 l.common.KeyMap.SelectItem,
114 l.common.KeyMap.BackItem,
115 })
116 b = append(b, [][]key.Binding{
117 {
118 copyKey,
119 k.CursorUp,
120 k.CursorDown,
121 },
122 {
123 k.NextPage,
124 k.PrevPage,
125 k.GoToStart,
126 k.GoToEnd,
127 },
128 }...)
129 case logViewDiff:
130 k := l.vp.KeyMap
131 b = append(b, []key.Binding{
132 l.common.KeyMap.BackItem,
133 })
134 b = append(b, [][]key.Binding{
135 {
136 k.PageDown,
137 k.PageUp,
138 k.HalfPageDown,
139 k.HalfPageUp,
140 },
141 {
142 k.Down,
143 k.Up,
144 },
145 }...)
146 }
147 return b
148}
149
150// Init implements tea.Model.
151func (l *Log) Init() tea.Cmd {
152 l.activeView = logViewCommits
153 l.nextPage = 0
154 l.count = 0
155 l.activeCommit = nil
156 l.selector.Select(0)
157 return l.updateCommitsCmd
158}
159
160// Update implements tea.Model.
161func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
162 cmds := make([]tea.Cmd, 0)
163 switch msg := msg.(type) {
164 case RepoMsg:
165 l.repo = git.GitRepo(msg)
166 cmds = append(cmds, l.Init())
167 case RefMsg:
168 l.ref = msg
169 cmds = append(cmds, l.Init())
170 case LogCountMsg:
171 l.count = int64(msg)
172 case LogItemsMsg:
173 cmds = append(cmds, l.selector.SetItems(msg))
174 l.selector.SetPage(l.nextPage)
175 l.SetSize(l.common.Width, l.common.Height)
176 i := l.selector.SelectedItem()
177 if i != nil {
178 l.activeCommit = i.(LogItem).Commit
179 }
180 case tea.KeyMsg, tea.MouseMsg:
181 switch l.activeView {
182 case logViewCommits:
183 switch key := msg.(type) {
184 case tea.KeyMsg:
185 switch key.String() {
186 case "l", "right":
187 cmds = append(cmds, l.selector.SelectItem)
188 }
189 }
190 // This is a hack for loading commits on demand based on list.Pagination.
191 curPage := l.selector.Page()
192 s, cmd := l.selector.Update(msg)
193 m := s.(*selector.Selector)
194 l.selector = m
195 if m.Page() != curPage {
196 l.nextPage = m.Page()
197 l.selector.SetPage(curPage)
198 cmds = append(cmds, l.updateCommitsCmd)
199 }
200 cmds = append(cmds, cmd)
201 case logViewDiff:
202 switch key := msg.(type) {
203 case tea.KeyMsg:
204 switch key.String() {
205 case "h", "left":
206 l.activeView = logViewCommits
207 }
208 }
209 }
210 case selector.ActiveMsg:
211 switch sel := msg.IdentifiableItem.(type) {
212 case LogItem:
213 l.activeCommit = sel.Commit
214 }
215 cmds = append(cmds, updateStatusBarCmd)
216 case selector.SelectMsg:
217 switch sel := msg.IdentifiableItem.(type) {
218 case LogItem:
219 cmds = append(cmds, l.selectCommitCmd(sel.Commit))
220 }
221 case LogCommitMsg:
222 l.selectedCommit = msg
223 cmds = append(cmds, l.loadDiffCmd)
224 case LogDiffMsg:
225 l.currentDiff = msg
226 l.vp.SetContent(
227 lipgloss.JoinVertical(lipgloss.Top,
228 l.renderCommit(l.selectedCommit),
229 l.renderSummary(msg),
230 l.renderDiff(msg),
231 ),
232 )
233 l.vp.GotoTop()
234 l.activeView = logViewDiff
235 cmds = append(cmds, updateStatusBarCmd)
236 case tea.WindowSizeMsg:
237 if l.selectedCommit != nil && l.currentDiff != nil {
238 l.vp.SetContent(
239 lipgloss.JoinVertical(lipgloss.Top,
240 l.renderCommit(l.selectedCommit),
241 l.renderSummary(l.currentDiff),
242 l.renderDiff(l.currentDiff),
243 ),
244 )
245 }
246 if l.repo != nil {
247 cmds = append(cmds, l.updateCommitsCmd)
248 }
249 }
250 switch l.activeView {
251 case logViewDiff:
252 vp, cmd := l.vp.Update(msg)
253 l.vp = vp.(*viewport.Viewport)
254 if cmd != nil {
255 cmds = append(cmds, cmd)
256 }
257 }
258 return l, tea.Batch(cmds...)
259}
260
261// View implements tea.Model.
262func (l *Log) View() string {
263 switch l.activeView {
264 case logViewCommits:
265 return l.selector.View()
266 case logViewDiff:
267 return l.vp.View()
268 default:
269 return ""
270 }
271}
272
273// StatusBarValue returns the status bar value.
274func (l *Log) StatusBarValue() string {
275 c := l.activeCommit
276 if c == nil {
277 return ""
278 }
279 who := c.Author.Name
280 if email := c.Author.Email; email != "" {
281 who += " <" + email + ">"
282 }
283 value := c.ID.String()
284 if who != "" {
285 value += " by " + who
286 }
287 return value
288}
289
290// StatusBarInfo returns the status bar info.
291func (l *Log) StatusBarInfo() string {
292 switch l.activeView {
293 case logViewCommits:
294 // We're using l.nextPage instead of l.selector.Paginator.Page because
295 // of the paginator hack above.
296 return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
297 case logViewDiff:
298 return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
299 default:
300 return ""
301 }
302}
303
304func (l *Log) countCommitsCmd() tea.Msg {
305 if l.ref == nil {
306 return common.ErrorMsg(errNoRef)
307 }
308 count, err := l.repo.CountCommits(l.ref)
309 if err != nil {
310 return common.ErrorMsg(err)
311 }
312 return LogCountMsg(count)
313}
314
315func (l *Log) updateCommitsCmd() tea.Msg {
316 count := l.count
317 if l.count == 0 {
318 switch msg := l.countCommitsCmd().(type) {
319 case common.ErrorMsg:
320 return msg
321 case LogCountMsg:
322 count = int64(msg)
323 }
324 }
325 if l.ref == nil {
326 return common.ErrorMsg(errNoRef)
327 }
328 items := make([]selector.IdentifiableItem, count)
329 page := l.nextPage
330 limit := l.selector.PerPage()
331 skip := page * limit
332 // CommitsByPage pages start at 1
333 cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
334 if err != nil {
335 return common.ErrorMsg(err)
336 }
337 for i, c := range cc {
338 idx := i + skip
339 if int64(idx) >= count {
340 break
341 }
342 items[idx] = LogItem{Commit: c}
343 }
344 return LogItemsMsg(items)
345}
346
347func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
348 return func() tea.Msg {
349 return LogCommitMsg(commit)
350 }
351}
352
353func (l *Log) loadDiffCmd() tea.Msg {
354 diff, err := l.repo.Diff(l.selectedCommit)
355 if err != nil {
356 return common.ErrorMsg(err)
357 }
358 return LogDiffMsg(diff)
359}
360
361func renderCtx() gansi.RenderContext {
362 return gansi.NewRenderContext(gansi.Options{
363 ColorProfile: termenv.TrueColor,
364 Styles: common.StyleConfig(),
365 })
366}
367
368func (l *Log) renderCommit(c *ggit.Commit) string {
369 s := strings.Builder{}
370 // FIXME: lipgloss prints empty lines when CRLF is used
371 // sanitize commit message from CRLF
372 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
373 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
374 l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()),
375 l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
376 l.common.Styles.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
377 l.common.Styles.LogCommitBody.Render(msg),
378 ))
379 return wrap.String(s.String(), l.common.Width-2)
380}
381
382func (l *Log) renderSummary(diff *ggit.Diff) string {
383 stats := strings.Split(diff.Stats().String(), "\n")
384 for i, line := range stats {
385 ch := strings.Split(line, "|")
386 if len(ch) > 1 {
387 adddel := ch[len(ch)-1]
388 adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.LogCommitStatsAdd.Render("+"))
389 adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-"))
390 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
391 }
392 }
393 return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
394}
395
396func (l *Log) renderDiff(diff *ggit.Diff) string {
397 var s strings.Builder
398 var pr strings.Builder
399 diffChroma := &gansi.CodeBlockElement{
400 Code: diff.Patch(),
401 Language: "diff",
402 }
403 err := diffChroma.Render(&pr, renderCtx())
404 if err != nil {
405 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
406 } else {
407 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
408 }
409 return wrap.String(s.String(), l.common.Width-2)
410}