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