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