1package repo
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/charmbracelet/bubbles/key"
9 "github.com/charmbracelet/bubbles/list"
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/glamour"
12 gansi "github.com/charmbracelet/glamour/ansi"
13 "github.com/charmbracelet/lipgloss"
14 ggit "github.com/charmbracelet/soft-serve/git"
15 "github.com/charmbracelet/soft-serve/ui/common"
16 "github.com/charmbracelet/soft-serve/ui/components/selector"
17 "github.com/charmbracelet/soft-serve/ui/components/viewport"
18 "github.com/charmbracelet/soft-serve/ui/git"
19 "github.com/muesli/reflow/wrap"
20 "github.com/muesli/termenv"
21)
22
23type view int
24
25const (
26 logView view = iota
27 commitView
28)
29
30// LogCountMsg is a message that contains the number of commits in a repo.
31type LogCountMsg int64
32
33// LogItemsMsg is a message that contains a slice of LogItem.
34type LogItemsMsg []list.Item
35
36// LogCommitMsg is a message that contains a git commit.
37type LogCommitMsg *ggit.Commit
38
39// LogDiffMsg is a message that contains a git diff.
40type LogDiffMsg *ggit.Diff
41
42// Log is a model that displays a list of commits and their diffs.
43type Log struct {
44 common common.Common
45 selector *selector.Selector
46 vp *viewport.Viewport
47 activeView view
48 repo git.GitRepo
49 ref *ggit.Reference
50 count int64
51 nextPage int
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: logView,
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 logView:
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 commitView:
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
119// Init implements tea.Model.
120func (l *Log) Init() tea.Cmd {
121 cmds := make([]tea.Cmd, 0)
122 cmds = append(cmds, l.updateCommitsCmd)
123 return tea.Batch(cmds...)
124}
125
126// Update implements tea.Model.
127func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
128 cmds := make([]tea.Cmd, 0)
129 switch msg := msg.(type) {
130 case RepoMsg:
131 l.count = 0
132 l.selector.Select(0)
133 l.nextPage = 0
134 l.activeView = 0
135 l.repo = git.GitRepo(msg)
136 case RefMsg:
137 l.ref = msg
138 l.count = 0
139 cmds = append(cmds, l.countCommitsCmd)
140 case LogCountMsg:
141 l.count = int64(msg)
142 case LogItemsMsg:
143 cmds = append(cmds, l.selector.SetItems(msg))
144 l.selector.SetPage(l.nextPage)
145 l.SetSize(l.common.Width, l.common.Height)
146 case tea.KeyMsg, tea.MouseMsg:
147 switch l.activeView {
148 case logView:
149 switch key := msg.(type) {
150 case tea.KeyMsg:
151 switch key.String() {
152 case "l", "right":
153 cmds = append(cmds, l.selector.SelectItem)
154 }
155 }
156 // This is a hack for loading commits on demand based on list.Pagination.
157 curPage := l.selector.Page()
158 s, cmd := l.selector.Update(msg)
159 m := s.(*selector.Selector)
160 l.selector = m
161 if m.Page() != curPage {
162 l.nextPage = m.Page()
163 l.selector.SetPage(curPage)
164 cmds = append(cmds, l.updateCommitsCmd)
165 }
166 cmds = append(cmds, cmd)
167 case commitView:
168 switch key := msg.(type) {
169 case tea.KeyMsg:
170 switch key.String() {
171 case "h", "left":
172 l.activeView = logView
173 }
174 }
175 }
176 case selector.SelectMsg:
177 switch sel := msg.IdentifiableItem.(type) {
178 case LogItem:
179 cmds = append(cmds, l.selectCommitCmd(sel.Commit))
180 }
181 case LogCommitMsg:
182 l.selectedCommit = msg
183 cmds = append(cmds, l.loadDiffCmd)
184 case LogDiffMsg:
185 l.currentDiff = msg
186 l.vp.SetContent(
187 lipgloss.JoinVertical(lipgloss.Top,
188 l.renderCommit(l.selectedCommit),
189 l.renderSummary(msg),
190 l.renderDiff(msg),
191 ),
192 )
193 l.vp.GotoTop()
194 l.activeView = commitView
195 cmds = append(cmds, updateStatusBarCmd)
196 case tea.WindowSizeMsg:
197 if l.selectedCommit != nil && l.currentDiff != nil {
198 l.vp.SetContent(
199 lipgloss.JoinVertical(lipgloss.Top,
200 l.renderCommit(l.selectedCommit),
201 l.renderSummary(l.currentDiff),
202 l.renderDiff(l.currentDiff),
203 ),
204 )
205 }
206 if l.repo != nil {
207 cmds = append(cmds, l.updateCommitsCmd)
208 }
209 }
210 switch l.activeView {
211 case commitView:
212 vp, cmd := l.vp.Update(msg)
213 l.vp = vp.(*viewport.Viewport)
214 if cmd != nil {
215 cmds = append(cmds, cmd)
216 }
217 }
218 return l, tea.Batch(cmds...)
219}
220
221// View implements tea.Model.
222func (l *Log) View() string {
223 switch l.activeView {
224 case logView:
225 return l.selector.View()
226 case commitView:
227 return l.vp.View()
228 default:
229 return ""
230 }
231}
232
233// StatusBarInfo returns the status bar info.
234func (l *Log) StatusBarInfo() string {
235 switch l.activeView {
236 case logView:
237 // We're using l.nextPage instead of l.selector.Paginator.Page because
238 // of the paginator hack above.
239 return fmt.Sprintf("%d/%d", l.nextPage+1, l.selector.TotalPages())
240 case commitView:
241 return fmt.Sprintf("%.f%%", l.vp.ScrollPercent()*100)
242 default:
243 return ""
244 }
245}
246
247func (l *Log) countCommitsCmd() tea.Msg {
248 count, err := l.repo.CountCommits(l.ref)
249 if err != nil {
250 return common.ErrorMsg(err)
251 }
252 return LogCountMsg(count)
253}
254
255func (l *Log) updateCommitsCmd() tea.Msg {
256 count := l.count
257 if l.count == 0 {
258 switch msg := l.countCommitsCmd().(type) {
259 case common.ErrorMsg:
260 return msg
261 case LogCountMsg:
262 count = int64(msg)
263 }
264 }
265 items := make([]list.Item, count)
266 page := l.nextPage
267 limit := l.selector.PerPage()
268 skip := page * limit
269 // CommitsByPage pages start at 1
270 cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
271 if err != nil {
272 return common.ErrorMsg(err)
273 }
274 for i, c := range cc {
275 idx := i + skip
276 if int64(idx) >= count {
277 break
278 }
279 items[idx] = LogItem{c}
280 }
281 return LogItemsMsg(items)
282}
283
284func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
285 return func() tea.Msg {
286 return LogCommitMsg(commit)
287 }
288}
289
290func (l *Log) loadDiffCmd() tea.Msg {
291 diff, err := l.repo.Diff(l.selectedCommit)
292 if err != nil {
293 return common.ErrorMsg(err)
294 }
295 return LogDiffMsg(diff)
296}
297
298func styleConfig() gansi.StyleConfig {
299 noColor := ""
300 s := glamour.DarkStyleConfig
301 s.Document.StylePrimitive.Color = &noColor
302 s.CodeBlock.Chroma.Text.Color = &noColor
303 s.CodeBlock.Chroma.Name.Color = &noColor
304 return s
305}
306
307func renderCtx() gansi.RenderContext {
308 return gansi.NewRenderContext(gansi.Options{
309 ColorProfile: termenv.TrueColor,
310 Styles: styleConfig(),
311 })
312}
313
314func (l *Log) renderCommit(c *ggit.Commit) string {
315 s := strings.Builder{}
316 // FIXME: lipgloss prints empty lines when CRLF is used
317 // sanitize commit message from CRLF
318 msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
319 s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
320 l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()),
321 l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
322 l.common.Styles.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)),
323 l.common.Styles.LogCommitBody.Render(msg),
324 ))
325 return wrap.String(s.String(), l.common.Width-2)
326}
327
328func (l *Log) renderSummary(diff *ggit.Diff) string {
329 stats := strings.Split(diff.Stats().String(), "\n")
330 for i, line := range stats {
331 ch := strings.Split(line, "|")
332 if len(ch) > 1 {
333 adddel := ch[len(ch)-1]
334 adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.LogCommitStatsAdd.Render("+"))
335 adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-"))
336 stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
337 }
338 }
339 return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
340}
341
342func (l *Log) renderDiff(diff *ggit.Diff) string {
343 var s strings.Builder
344 var pr strings.Builder
345 diffChroma := &gansi.CodeBlockElement{
346 Code: diff.Patch(),
347 Language: "diff",
348 }
349 err := diffChroma.Render(&pr, renderCtx())
350 if err != nil {
351 s.WriteString(fmt.Sprintf("\n%s", err.Error()))
352 } else {
353 s.WriteString(fmt.Sprintf("\n%s", pr.String()))
354 }
355 return wrap.String(s.String(), l.common.Width-2)
356}