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})
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 copyKey := l.common.KeyMap.Copy
89 copyKey.SetHelp("c", "copy hash")
90 return []key.Binding{
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 },
120 {
121 k.CursorUp,
122 k.CursorDown,
123 },
124 {
125 k.NextPage,
126 k.PrevPage,
127 },
128 {
129 k.GoToStart,
130 k.GoToEnd,
131 },
132 }...)
133 case logViewDiff:
134 k := l.vp.KeyMap
135 b = append(b, []key.Binding{
136 l.common.KeyMap.BackItem,
137 })
138 b = append(b, [][]key.Binding{
139 {
140 k.PageDown,
141 k.PageUp,
142 },
143 {
144 k.HalfPageDown,
145 k.HalfPageUp,
146 },
147 {
148 k.Down,
149 k.Up,
150 },
151 }...)
152 }
153 return b
154}
155
156// Init implements tea.Model.
157func (l *Log) Init() tea.Cmd {
158 l.activeView = logViewCommits
159 l.nextPage = 0
160 l.count = 0
161 l.activeCommit = nil
162 l.selector.Select(0)
163 return l.updateCommitsCmd
164}
165
166// Update implements tea.Model.
167func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
168 cmds := make([]tea.Cmd, 0)
169 switch msg := msg.(type) {
170 case RepoMsg:
171 l.repo = git.GitRepo(msg)
172 cmds = append(cmds, l.Init())
173 case RefMsg:
174 l.ref = msg
175 cmds = append(cmds, l.Init())
176 case LogCountMsg:
177 l.count = int64(msg)
178 case LogItemsMsg:
179 cmds = append(cmds, l.selector.SetItems(msg))
180 l.selector.SetPage(l.nextPage)
181 l.SetSize(l.common.Width, l.common.Height)
182 i := l.selector.SelectedItem()
183 if i != nil {
184 l.activeCommit = i.(LogItem).Commit
185 }
186 case tea.KeyMsg, tea.MouseMsg:
187 switch l.activeView {
188 case logViewCommits:
189 switch key := msg.(type) {
190 case tea.KeyMsg:
191 switch key.String() {
192 case "l", "right":
193 cmds = append(cmds, l.selector.SelectItem)
194 }
195 }
196 // This is a hack for loading commits on demand based on list.Pagination.
197 curPage := l.selector.Page()
198 s, cmd := l.selector.Update(msg)
199 m := s.(*selector.Selector)
200 l.selector = m
201 if m.Page() != curPage {
202 l.nextPage = m.Page()
203 l.selector.SetPage(curPage)
204 cmds = append(cmds, l.updateCommitsCmd)
205 }
206 cmds = append(cmds, cmd)
207 case logViewDiff:
208 switch key := msg.(type) {
209 case tea.KeyMsg:
210 switch key.String() {
211 case "h", "left":
212 l.activeView = logViewCommits
213 }
214 }
215 }
216 case selector.ActiveMsg:
217 switch sel := msg.IdentifiableItem.(type) {
218 case LogItem:
219 l.activeCommit = sel.Commit
220 }
221 cmds = append(cmds, updateStatusBarCmd)
222 case selector.SelectMsg:
223 switch sel := msg.IdentifiableItem.(type) {
224 case LogItem:
225 cmds = append(cmds, l.selectCommitCmd(sel.Commit))
226 }
227 case LogCommitMsg:
228 l.selectedCommit = msg
229 cmds = append(cmds, l.loadDiffCmd)
230 case LogDiffMsg:
231 l.currentDiff = msg
232 l.vp.SetContent(
233 lipgloss.JoinVertical(lipgloss.Top,
234 l.renderCommit(l.selectedCommit),
235 l.renderSummary(msg),
236 l.renderDiff(msg),
237 ),
238 )
239 l.vp.GotoTop()
240 l.activeView = logViewDiff
241 cmds = append(cmds, updateStatusBarCmd)
242 case tea.WindowSizeMsg:
243 if l.selectedCommit != nil && l.currentDiff != nil {
244 l.vp.SetContent(
245 lipgloss.JoinVertical(lipgloss.Top,
246 l.renderCommit(l.selectedCommit),
247 l.renderSummary(l.currentDiff),
248 l.renderDiff(l.currentDiff),
249 ),
250 )
251 }
252 if l.repo != nil {
253 cmds = append(cmds, l.updateCommitsCmd)
254 }
255 }
256 switch l.activeView {
257 case logViewDiff:
258 vp, cmd := l.vp.Update(msg)
259 l.vp = vp.(*viewport.Viewport)
260 if cmd != nil {
261 cmds = append(cmds, cmd)
262 }
263 }
264 return l, tea.Batch(cmds...)
265}
266
267// View implements tea.Model.
268func (l *Log) View() string {
269 switch l.activeView {
270 case logViewCommits:
271 return l.selector.View()
272 case logViewDiff:
273 return l.vp.View()
274 default:
275 return ""
276 }
277}
278
279// StatusBarValue returns the status bar value.
280func (l *Log) StatusBarValue() string {
281 c := l.activeCommit
282 if c == nil {
283 return ""
284 }
285 return fmt.Sprintf("%s by %s",
286 c.ID.String(),
287 fmt.Sprintf("%s <%s>", c.Author.Name, c.Author.Email),
288 )
289}
290
291// StatusBarInfo returns the status bar info.
292func (l *Log) StatusBarInfo() string {
293 switch l.activeView {
294 case logViewCommits:
295 // We're using l.nextPage instead of l.selector.Paginator.Page because
296 // of the paginator hack above.
297 return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
298 case logViewDiff:
299 return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
300 default:
301 return ""
302 }
303}
304
305func (l *Log) countCommitsCmd() tea.Msg {
306 count, err := l.repo.CountCommits(l.ref)
307 if err != nil {
308 return common.ErrorMsg(err)
309 }
310 return LogCountMsg(count)
311}
312
313func (l *Log) updateCommitsCmd() tea.Msg {
314 count := l.count
315 if l.count == 0 {
316 switch msg := l.countCommitsCmd().(type) {
317 case common.ErrorMsg:
318 return msg
319 case LogCountMsg:
320 count = int64(msg)
321 }
322 }
323 items := make([]selector.IdentifiableItem, count)
324 page := l.nextPage
325 limit := l.selector.PerPage()
326 skip := page * limit
327 // CommitsByPage pages start at 1
328 cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
329 if err != nil {
330 return common.ErrorMsg(err)
331 }
332 for i, c := range cc {
333 idx := i + skip
334 if int64(idx) >= count {
335 break
336 }
337 items[idx] = LogItem{Commit: c}
338 }
339 return LogItemsMsg(items)
340}
341
342func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
343 return func() tea.Msg {
344 return LogCommitMsg(commit)
345 }
346}
347
348func (l *Log) loadDiffCmd() tea.Msg {
349 diff, err := l.repo.Diff(l.selectedCommit)
350 if err != nil {
351 return common.ErrorMsg(err)
352 }
353 return LogDiffMsg(diff)
354}
355
356func styleConfig() gansi.StyleConfig {
357 noColor := ""
358 s := glamour.DarkStyleConfig
359 s.Document.StylePrimitive.Color = &noColor
360 s.CodeBlock.Chroma.Text.Color = &noColor
361 s.CodeBlock.Chroma.Name.Color = &noColor
362 return s
363}
364
365func renderCtx() gansi.RenderContext {
366 return gansi.NewRenderContext(gansi.Options{
367 ColorProfile: termenv.TrueColor,
368 Styles: styleConfig(),
369 })
370}
371
372func (l *Log) renderCommit(c *ggit.Commit) string {
373 s := strings.Builder{}
374 // FIXME: lipgloss prints empty lines when CRLF is used
375 // sanitize commit message from CRLF
376 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
377 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
378 l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()),
379 l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
380 l.common.Styles.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
381 l.common.Styles.LogCommitBody.Render(msg),
382 ))
383 return wrap.String(s.String(), l.common.Width-2)
384}
385
386func (l *Log) renderSummary(diff *ggit.Diff) string {
387 stats := strings.Split(diff.Stats().String(), "\n")
388 for i, line := range stats {
389 ch := strings.Split(line, "|")
390 if len(ch) > 1 {
391 adddel := ch[len(ch)-1]
392 adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.LogCommitStatsAdd.Render("+"))
393 adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-"))
394 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
395 }
396 }
397 return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
398}
399
400func (l *Log) renderDiff(diff *ggit.Diff) string {
401 var s strings.Builder
402 var pr strings.Builder
403 diffChroma := &gansi.CodeBlockElement{
404 Code: diff.Patch(),
405 Language: "diff",
406 }
407 err := diffChroma.Render(&pr, renderCtx())
408 if err != nil {
409 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
410 } else {
411 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
412 }
413 return wrap.String(s.String(), l.common.Width-2)
414}