1package repo
2
3import (
4 "bytes"
5 "fmt"
6 "log"
7 "soft-serve/git"
8 "soft-serve/tui/style"
9 "strconv"
10 "text/template"
11 "time"
12
13 "github.com/charmbracelet/bubbles/viewport"
14 tea "github.com/charmbracelet/bubbletea"
15 "github.com/charmbracelet/glamour"
16 "github.com/charmbracelet/lipgloss"
17 "github.com/muesli/reflow/truncate"
18 "github.com/muesli/reflow/wordwrap"
19 "github.com/muesli/reflow/wrap"
20)
21
22const (
23 glamourMaxWidth = 120
24 repoNameMaxWidth = 32
25)
26
27type ErrMsg struct {
28 Error error
29}
30
31type Bubble struct {
32 templateObject interface{}
33 repoSource *git.RepoSource
34 name string
35 repo *git.Repo
36 styles *style.Styles
37 readmeViewport *ViewportBubble
38 readme string
39 height int
40 heightMargin int
41 width int
42 widthMargin int
43 Active bool
44
45 // XXX: ideally, we get these from the parent as a pointer. Currently, we
46 // can't add a *tui.Config because it's an illegal import cycle. One
47 // solution would be to (rename and) move this Bubble into the parent
48 // package.
49 Host string
50 Port int64
51}
52
53func NewBubble(rs *git.RepoSource, name string, styles *style.Styles, width, wm, height, hm int, tmp interface{}) *Bubble {
54 b := &Bubble{
55 templateObject: tmp,
56 repoSource: rs,
57 name: name,
58 styles: styles,
59 heightMargin: hm,
60 widthMargin: wm,
61 readmeViewport: &ViewportBubble{
62 Viewport: &viewport.Model{},
63 },
64 }
65 b.SetSize(width, height)
66 return b
67}
68
69func (b *Bubble) Init() tea.Cmd {
70 return b.setupCmd
71}
72
73func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74 var cmds []tea.Cmd
75 switch msg := msg.(type) {
76 case tea.WindowSizeMsg:
77 b.SetSize(msg.Width, msg.Height)
78 // XXX: if we find that longer readmes take more than a few
79 // milliseconds to render we may need to move Glamour rendering into a
80 // command.
81 md, err := b.glamourize(b.readme)
82 if err != nil {
83 return b, nil
84 }
85 b.readmeViewport.Viewport.SetContent(md)
86 }
87 rv, cmd := b.readmeViewport.Update(msg)
88 b.readmeViewport = rv.(*ViewportBubble)
89 cmds = append(cmds, cmd)
90 return b, tea.Batch(cmds...)
91}
92
93func (b *Bubble) SetSize(w, h int) {
94 b.width = w
95 b.height = h
96 b.readmeViewport.Viewport.Width = w - b.widthMargin
97 b.readmeViewport.Viewport.Height = h - lipgloss.Height(b.headerView()) - b.heightMargin
98}
99
100func (b *Bubble) GotoTop() {
101 b.readmeViewport.Viewport.GotoTop()
102}
103
104func (b Bubble) headerView() string {
105 // Render repo title
106 title := b.name
107 if title == "config" {
108 title = "Home"
109 }
110 title = truncate.StringWithTail(title, repoNameMaxWidth, "…")
111 title = b.styles.RepoTitle.Render(title)
112
113 // Render clone command
114 var note string
115 if b.name == "config" {
116 note = ""
117 } else {
118 note = fmt.Sprintf("git clone %s", b.sshAddress())
119 }
120 noteStyle := b.styles.RepoNote.Copy().Width(
121 b.width - b.widthMargin - lipgloss.Width(title) -
122 b.styles.RepoTitleBox.GetHorizontalFrameSize(),
123 )
124 note = noteStyle.Render(note)
125
126 // Render borders on name and command
127 height := max(lipgloss.Height(title), lipgloss.Height(note))
128 titleBoxStyle := b.styles.RepoTitleBox.Copy().Height(height)
129 noteBoxStyle := b.styles.RepoNoteBox.Copy().Height(height)
130 if b.Active {
131 titleBoxStyle = titleBoxStyle.BorderForeground(b.styles.ActiveBorderColor)
132 noteBoxStyle = noteBoxStyle.BorderForeground(b.styles.ActiveBorderColor)
133 }
134 title = titleBoxStyle.Render(title)
135 note = noteBoxStyle.Render(note)
136
137 // Render
138 return lipgloss.JoinHorizontal(lipgloss.Top, title, note)
139}
140
141func (b *Bubble) View() string {
142 header := b.headerView()
143 bs := b.styles.RepoBody.Copy()
144 if b.Active {
145 bs = bs.BorderForeground(b.styles.ActiveBorderColor)
146 }
147 body := bs.
148 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 ct := time.Now()
164 r, err := b.repoSource.GetRepo(b.name)
165 if err == git.ErrMissingRepo {
166 return nil
167 }
168 if err != nil {
169 return ErrMsg{err}
170 }
171 md := r.Readme
172 if b.templateObject != nil {
173 md, err = b.templatize(md)
174 if err != nil {
175 return ErrMsg{err}
176 }
177 }
178 b.readme = md
179 md, err = b.glamourize(md)
180 if err != nil {
181 return ErrMsg{err}
182 }
183 b.readmeViewport.Viewport.SetContent(md)
184 b.GotoTop()
185 log.Printf("Repo bubble loaded in %s", time.Since(ct))
186 return nil
187}
188
189func (b *Bubble) templatize(mdt string) (string, error) {
190 t, err := template.New("readme").Parse(mdt)
191 if err != nil {
192 return "", err
193 }
194 buf := &bytes.Buffer{}
195 err = t.Execute(buf, b.templateObject)
196 if err != nil {
197 return "", err
198 }
199 return buf.String(), nil
200}
201
202func (b *Bubble) glamourize(md string) (string, error) {
203 // TODO: read gaps in appropriate style to remove the magic number below.
204 w := b.width - b.widthMargin - 4
205 if w > glamourMaxWidth {
206 w = glamourMaxWidth
207 }
208 tr, err := glamour.NewTermRenderer(
209 glamour.WithStandardStyle("dark"),
210 glamour.WithWordWrap(w),
211 )
212
213 if err != nil {
214 return "", err
215 }
216 mdt, err := tr.Render(md)
217 if err != nil {
218 return "", err
219 }
220 // Enforce a maximum width for cases when glamour lines run long.
221 //
222 // TODO: This should utlimately be implemented as a Glamour option.
223 mdt = wrap.String(wordwrap.String((mdt), w), w)
224 return mdt, nil
225}
226
227func max(a, b int) int {
228 if a > b {
229 return a
230 }
231 return b
232}