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