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