bubble.go

  1package tui
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	tea "github.com/charmbracelet/bubbletea"
  8	"github.com/charmbracelet/lipgloss"
  9	"github.com/charmbracelet/soft-serve/internal/config"
 10	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 11	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
 12	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
 13	"github.com/charmbracelet/soft-serve/internal/tui/style"
 14	"github.com/gliderlabs/ssh"
 15)
 16
 17const (
 18	repoNameMaxWidth = 32
 19)
 20
 21type sessionState int
 22
 23const (
 24	startState sessionState = iota
 25	errorState
 26	loadedState
 27	quittingState
 28	quitState
 29)
 30
 31type SessionConfig struct {
 32	Width       int
 33	Height      int
 34	InitialRepo string
 35	Session     ssh.Session
 36}
 37
 38type MenuEntry struct {
 39	Name   string `json:"name"`
 40	Note   string `json:"note"`
 41	Repo   string `json:"repo"`
 42	bubble *repo.Bubble
 43}
 44
 45type Bubble struct {
 46	config      *config.Config
 47	styles      *style.Styles
 48	state       sessionState
 49	error       string
 50	width       int
 51	height      int
 52	initialRepo string
 53	repoMenu    []MenuEntry
 54	boxes       []tea.Model
 55	activeBox   int
 56	repoSelect  *selection.Bubble
 57	session     ssh.Session
 58
 59	// remember the last resize so we can re-send it when selecting a different repo.
 60	lastResize tea.WindowSizeMsg
 61}
 62
 63func NewBubble(cfg *config.Config, sCfg *SessionConfig) *Bubble {
 64	b := &Bubble{
 65		config:      cfg,
 66		styles:      style.DefaultStyles(),
 67		width:       sCfg.Width,
 68		height:      sCfg.Height,
 69		repoMenu:    make([]MenuEntry, 0),
 70		boxes:       make([]tea.Model, 2),
 71		initialRepo: sCfg.InitialRepo,
 72		session:     sCfg.Session,
 73	}
 74	b.state = startState
 75	return b
 76}
 77
 78func (b *Bubble) Init() tea.Cmd {
 79	return b.setupCmd
 80}
 81
 82func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 83	cmds := make([]tea.Cmd, 0)
 84	switch msg := msg.(type) {
 85	case tea.KeyMsg:
 86		switch msg.String() {
 87		case "q", "ctrl+c":
 88			return b, tea.Quit
 89		case "tab", "shift+tab":
 90			b.activeBox = (b.activeBox + 1) % 2
 91		}
 92	case errMsg:
 93		b.error = msg.Error()
 94		b.state = errorState
 95		return b, nil
 96	case tea.WindowSizeMsg:
 97		b.lastResize = msg
 98		b.width = msg.Width
 99		b.height = msg.Height
100		if b.state == loadedState {
101			for i, bx := range b.boxes {
102				m, cmd := bx.Update(msg)
103				b.boxes[i] = m
104				if cmd != nil {
105					cmds = append(cmds, cmd)
106				}
107			}
108		}
109	case selection.SelectedMsg:
110		b.activeBox = 1
111		rb := b.repoMenu[msg.Index].bubble
112		b.boxes[1] = rb
113	case selection.ActiveMsg:
114		b.boxes[1] = b.repoMenu[msg.Index].bubble
115		cmds = append(cmds, func() tea.Msg {
116			return b.lastResize
117		})
118	}
119	if b.state == loadedState {
120		ab, cmd := b.boxes[b.activeBox].Update(msg)
121		b.boxes[b.activeBox] = ab
122		if cmd != nil {
123			cmds = append(cmds, cmd)
124		}
125	}
126	return b, tea.Batch(cmds...)
127}
128
129func (b *Bubble) viewForBox(i int) string {
130	isActive := i == b.activeBox
131	switch box := b.boxes[i].(type) {
132	case *selection.Bubble:
133		// Menu
134		var s lipgloss.Style
135		s = b.styles.Menu
136		if isActive {
137			s = s.Copy().BorderForeground(b.styles.ActiveBorderColor)
138		}
139		return s.Render(box.View())
140	case *repo.Bubble:
141		// Repo details
142		box.Active = isActive
143		return box.View()
144	default:
145		panic(fmt.Sprintf("unknown box type %T", box))
146	}
147}
148
149func (b Bubble) headerView() string {
150	w := b.width - b.styles.App.GetHorizontalFrameSize()
151	name := ""
152	if b.config != nil {
153		name = b.config.Name
154	}
155	return b.styles.Header.Copy().Width(w).Render(name)
156}
157
158func (b Bubble) footerView() string {
159	w := &strings.Builder{}
160	var h []gittypes.HelpEntry
161	if b.state != errorState {
162		h = []gittypes.HelpEntry{
163			{"tab", "section"},
164		}
165		if box, ok := b.boxes[b.activeBox].(gittypes.HelpableBubble); ok {
166			help := box.Help()
167			for _, he := range help {
168				h = append(h, he)
169			}
170		}
171	}
172	h = append(h, gittypes.HelpEntry{"q", "quit"})
173	for i, v := range h {
174		fmt.Fprint(w, helpEntryRender(v, b.styles))
175		if i != len(h)-1 {
176			fmt.Fprint(w, b.styles.HelpDivider)
177		}
178	}
179	branch := ""
180	if b.state == loadedState {
181		branch = b.boxes[1].(*repo.Bubble).Reference().Short()
182	}
183	help := w.String()
184	branchMaxWidth := b.width - // bubble width
185		lipgloss.Width(help) - // help width
186		b.styles.App.GetHorizontalFrameSize() // App paddings
187	branch = b.styles.Branch.Render(gittypes.TruncateString(branch, branchMaxWidth-1, "…"))
188	gap := lipgloss.NewStyle().
189		Width(b.width -
190			lipgloss.Width(help) -
191			lipgloss.Width(branch) -
192			b.styles.App.GetHorizontalFrameSize()).
193		Render("")
194	footer := lipgloss.JoinHorizontal(lipgloss.Top, help, gap, branch)
195	return b.styles.Footer.Render(footer)
196}
197
198func (b Bubble) errorView() string {
199	s := b.styles
200	str := lipgloss.JoinHorizontal(
201		lipgloss.Top,
202		s.ErrorTitle.Render("Bummer"),
203		s.ErrorBody.Render(b.error),
204	)
205	h := b.height -
206		s.App.GetVerticalFrameSize() -
207		lipgloss.Height(b.headerView()) -
208		lipgloss.Height(b.footerView()) -
209		s.RepoBody.GetVerticalFrameSize() +
210		3 // TODO: this is repo header height -- get it dynamically
211	return s.Error.Copy().Height(h).Render(str)
212}
213
214func (b Bubble) View() string {
215	s := strings.Builder{}
216	s.WriteString(b.headerView())
217	s.WriteRune('\n')
218	switch b.state {
219	case loadedState:
220		lb := b.viewForBox(0)
221		rb := b.viewForBox(1)
222		s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
223	case errorState:
224		s.WriteString(b.errorView())
225	}
226	s.WriteRune('\n')
227	s.WriteString(b.footerView())
228	return b.styles.App.Render(s.String())
229}
230
231func helpEntryRender(h gittypes.HelpEntry, s *style.Styles) string {
232	return fmt.Sprintf("%s %s", s.HelpKey.Render(h.Key), s.HelpValue.Render(h.Value))
233}