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