bubble.go

  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	noteWidth := b.width -
121		b.widthMargin -
122		lipgloss.Width(title) -
123		b.styles.RepoTitleBox.GetHorizontalFrameSize()
124	// Hard-wrap the clone command only, without the usual word-wrapping. since
125	// a long repo name isn't going to be a series of space-separated "words",
126	// we'll always want it to be perfectly hard-wrapped.
127	note = wrap.String(note, noteWidth-b.styles.RepoNote.GetHorizontalFrameSize())
128	note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note)
129
130	// Render borders on name and command
131	height := max(lipgloss.Height(title), lipgloss.Height(note))
132	titleBoxStyle := b.styles.RepoTitleBox.Copy().Height(height)
133	noteBoxStyle := b.styles.RepoNoteBox.Copy().Height(height)
134	if b.Active {
135		titleBoxStyle = titleBoxStyle.BorderForeground(b.styles.ActiveBorderColor)
136		noteBoxStyle = noteBoxStyle.BorderForeground(b.styles.ActiveBorderColor)
137	}
138	title = titleBoxStyle.Render(title)
139	note = noteBoxStyle.Render(note)
140
141	// Render
142	return lipgloss.JoinHorizontal(lipgloss.Top, title, note)
143}
144
145func (b *Bubble) View() string {
146	header := b.headerView()
147	bs := b.styles.RepoBody.Copy()
148	if b.Active {
149		bs = bs.BorderForeground(b.styles.ActiveBorderColor)
150	}
151	body := bs.Width(b.width - b.widthMargin - b.styles.RepoBody.GetVerticalFrameSize()).
152		Height(b.height - b.heightMargin - lipgloss.Height(header)).
153		Render(b.readmeViewport.View())
154	return header + body
155}
156
157func (b Bubble) sshAddress() string {
158	p := ":" + strconv.Itoa(int(b.Port))
159	if p == ":22" {
160		p = ""
161	}
162	return fmt.Sprintf("ssh://%s%s/%s", b.Host, p, b.name)
163}
164
165func (b *Bubble) setupCmd() tea.Msg {
166	ct := time.Now()
167	r, err := b.repoSource.GetRepo(b.name)
168	if err == git.ErrMissingRepo {
169		return nil
170	}
171	if err != nil {
172		return ErrMsg{err}
173	}
174	md := r.Readme
175	if b.templateObject != nil {
176		md, err = b.templatize(md)
177		if err != nil {
178			return ErrMsg{err}
179		}
180	}
181	b.readme = md
182	md, err = b.glamourize(md)
183	if err != nil {
184		return ErrMsg{err}
185	}
186	b.readmeViewport.Viewport.SetContent(md)
187	b.GotoTop()
188	log.Printf("Repo bubble loaded in %s", time.Since(ct))
189	return nil
190}
191
192func (b *Bubble) templatize(mdt string) (string, error) {
193	t, err := template.New("readme").Parse(mdt)
194	if err != nil {
195		return "", err
196	}
197	buf := &bytes.Buffer{}
198	err = t.Execute(buf, b.templateObject)
199	if err != nil {
200		return "", err
201	}
202	return buf.String(), nil
203}
204
205func (b *Bubble) glamourize(md string) (string, error) {
206	w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize()
207	if w > glamourMaxWidth {
208		w = glamourMaxWidth
209	}
210	tr, err := glamour.NewTermRenderer(
211		glamour.WithStandardStyle("dark"),
212		glamour.WithWordWrap(w),
213	)
214
215	if err != nil {
216		return "", err
217	}
218	mdt, err := tr.Render(md)
219	if err != nil {
220		return "", err
221	}
222	// Enforce a maximum width for cases when glamour lines run long.
223	//
224	// TODO: This should utlimately be implemented as a Glamour option.
225	mdt = wrap.String(wordwrap.String((mdt), w), w)
226	return mdt, nil
227}
228
229func max(a, b int) int {
230	if a > b {
231		return a
232	}
233	return b
234}