1package repo
2
3import (
4 "bytes"
5 "fmt"
6 "strconv"
7 "text/template"
8
9 "github.com/charmbracelet/bubbles/viewport"
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/glamour"
12 "github.com/charmbracelet/glamour/ansi"
13 "github.com/charmbracelet/lipgloss"
14 "github.com/charmbracelet/soft-serve/internal/git"
15 "github.com/charmbracelet/soft-serve/internal/tui/style"
16 "github.com/muesli/reflow/truncate"
17 "github.com/muesli/reflow/wrap"
18)
19
20const (
21 glamourMaxWidth = 120
22 repoNameMaxWidth = 32
23)
24
25var glamourStyle = func() ansi.StyleConfig {
26 noColor := ""
27 s := glamour.DarkStyleConfig
28 s.Document.StylePrimitive.Color = &noColor
29 s.CodeBlock.Chroma.Text.Color = &noColor
30 s.CodeBlock.Chroma.Name.Color = &noColor
31 return s
32}()
33
34type ErrMsg struct {
35 Error error
36}
37
38type Bubble struct {
39 templateObject interface{}
40 repoSource *git.RepoSource
41 name string
42 repo *git.Repo
43 styles *style.Styles
44 readmeViewport *ViewportBubble
45 readme string
46 height int
47 heightMargin int
48 width int
49 widthMargin int
50 Active bool
51
52 // XXX: ideally, we get these from the parent as a pointer. Currently, we
53 // can't add a *tui.Config because it's an illegal import cycle. One
54 // solution would be to (rename and) move this Bubble into the parent
55 // package.
56 Host string
57 Port int
58}
59
60func NewBubble(rs *git.RepoSource, name string, styles *style.Styles, width, wm, height, hm int, tmp interface{}) *Bubble {
61 b := &Bubble{
62 templateObject: tmp,
63 repoSource: rs,
64 name: name,
65 styles: styles,
66 heightMargin: hm,
67 widthMargin: wm,
68 readmeViewport: &ViewportBubble{
69 Viewport: &viewport.Model{},
70 },
71 }
72 b.SetSize(width, height)
73 return b
74}
75
76func (b *Bubble) Init() tea.Cmd {
77 return b.setupCmd
78}
79
80func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
81 var cmds []tea.Cmd
82 switch msg := msg.(type) {
83 case tea.WindowSizeMsg:
84 b.SetSize(msg.Width, msg.Height)
85 // XXX: if we find that longer readmes take more than a few
86 // milliseconds to render we may need to move Glamour rendering into a
87 // command.
88 md, err := b.glamourize(b.readme)
89 if err != nil {
90 return b, nil
91 }
92 b.readmeViewport.Viewport.SetContent(md)
93 }
94 rv, cmd := b.readmeViewport.Update(msg)
95 b.readmeViewport = rv.(*ViewportBubble)
96 cmds = append(cmds, cmd)
97 return b, tea.Batch(cmds...)
98}
99
100func (b *Bubble) SetSize(w, h int) {
101 b.width = w
102 b.height = h
103 b.readmeViewport.Viewport.Width = w - b.widthMargin
104 b.readmeViewport.Viewport.Height = h - lipgloss.Height(b.headerView()) - b.heightMargin
105}
106
107func (b *Bubble) GotoTop() {
108 b.readmeViewport.Viewport.GotoTop()
109}
110
111func (b Bubble) headerView() string {
112 // Render repo title
113 title := b.name
114 if title == "config" {
115 title = "Home"
116 }
117 title = truncate.StringWithTail(title, repoNameMaxWidth, "…")
118 title = b.styles.RepoTitle.Render(title)
119
120 // Render clone command
121 var note string
122 if b.name == "config" {
123 note = ""
124 } else {
125 note = fmt.Sprintf("git clone %s", b.sshAddress())
126 }
127 noteWidth := b.width -
128 b.widthMargin -
129 lipgloss.Width(title) -
130 b.styles.RepoTitleBox.GetHorizontalFrameSize()
131 // Hard-wrap the clone command only, without the usual word-wrapping. since
132 // a long repo name isn't going to be a series of space-separated "words",
133 // we'll always want it to be perfectly hard-wrapped.
134 note = wrap.String(note, noteWidth-b.styles.RepoNote.GetHorizontalFrameSize())
135 note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note)
136
137 // Render borders on name and command
138 height := max(lipgloss.Height(title), lipgloss.Height(note))
139 titleBoxStyle := b.styles.RepoTitleBox.Copy().Height(height)
140 noteBoxStyle := b.styles.RepoNoteBox.Copy().Height(height)
141 if b.Active {
142 titleBoxStyle = titleBoxStyle.BorderForeground(b.styles.ActiveBorderColor)
143 noteBoxStyle = noteBoxStyle.BorderForeground(b.styles.ActiveBorderColor)
144 }
145 title = titleBoxStyle.Render(title)
146 note = noteBoxStyle.Render(note)
147
148 // Render
149 return lipgloss.JoinHorizontal(lipgloss.Top, title, note)
150}
151
152func (b *Bubble) View() string {
153 header := b.headerView()
154 bs := b.styles.RepoBody.Copy()
155 if b.Active {
156 bs = bs.BorderForeground(b.styles.ActiveBorderColor)
157 }
158 body := bs.Width(b.width - b.widthMargin - b.styles.RepoBody.GetVerticalFrameSize()).
159 Height(b.height - b.heightMargin - lipgloss.Height(header)).
160 Render(b.readmeViewport.View())
161 return header + body
162}
163
164func (b Bubble) sshAddress() string {
165 p := ":" + strconv.Itoa(int(b.Port))
166 if p == ":22" {
167 p = ""
168 }
169 return fmt.Sprintf("ssh://%s%s/%s", b.Host, p, b.name)
170}
171
172func (b *Bubble) setupCmd() tea.Msg {
173 r, err := b.repoSource.GetRepo(b.name)
174 if err == git.ErrMissingRepo {
175 return nil
176 }
177 if err != nil {
178 return ErrMsg{err}
179 }
180 md := r.Readme
181 if b.templateObject != nil {
182 md, err = b.templatize(md)
183 if err != nil {
184 return ErrMsg{err}
185 }
186 }
187 b.readme = md
188 md, err = b.glamourize(md)
189 if err != nil {
190 return ErrMsg{err}
191 }
192 b.readmeViewport.Viewport.SetContent(md)
193 b.GotoTop()
194 return nil
195}
196
197func (b *Bubble) templatize(mdt string) (string, error) {
198 t, err := template.New("readme").Parse(mdt)
199 if err != nil {
200 return "", err
201 }
202 buf := &bytes.Buffer{}
203 err = t.Execute(buf, b.templateObject)
204 if err != nil {
205 return "", err
206 }
207 return buf.String(), nil
208}
209
210func (b *Bubble) glamourize(md string) (string, error) {
211 w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize()
212 if w > glamourMaxWidth {
213 w = glamourMaxWidth
214 }
215 tr, err := glamour.NewTermRenderer(
216 glamour.WithStyles(glamourStyle),
217 glamour.WithWordWrap(w),
218 )
219
220 if err != nil {
221 return "", err
222 }
223 mdt, err := tr.Render(md)
224 if err != nil {
225 return "", err
226 }
227 // For now, hard-wrap long lines in Glamour that would otherwise break the
228 // layout when wrapping. This may be due to #43 in Reflow, which has to do
229 // with a bug in the way lines longer than the given width are wrapped.
230 //
231 // https://github.com/muesli/reflow/issues/43
232 //
233 // TODO: solve this upstream in Glamour/Reflow.
234 return wrap.String(mdt, w), nil
235}
236
237func max(a, b int) int {
238 if a > b {
239 return a
240 }
241 return b
242}