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