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