// Package branches is the branch picker: a search line above a list of // branches. It holds the state and renders itself; the app decides what the // keys do. package branches import ( "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "submarine/internal/submodule" "submarine/internal/tui/theme" ) const ( maxWidth = 72 minWidth = 36 maxRows = 14 frameRows = 6 // border, title, search, rule, hint, border ) // Model is the picker. The zero value is not usable, use New. type Model struct { theme theme.Theme title string input textinput.Model all []submodule.Branch shown []int // indexes into all that match the search cursor int // index into shown offset int // first visible index into shown rows int // visible list rows loading bool err error spinner string } // New returns a picker in the loading state. func New(t theme.Theme, title string) *Model { in := textinput.New() in.Prompt = "/ " in.PromptStyle = theme.Fg(t.Primary) in.Cursor.Style = theme.Fg(t.Primary) in.Placeholder = "filter branches" in.PlaceholderStyle = theme.Fg(t.Muted) in.Focus() return &Model{theme: t, title: title, input: in, loading: true, rows: maxRows} } // SetBranches fills the list and ends the loading state. func (m *Model) SetBranches(bs []submodule.Branch) { m.all, m.loading, m.err = bs, false, nil m.refilter() } // SetErr shows err instead of the list. func (m *Model) SetErr(err error) { m.loading, m.err = false, err } // SetSpinner sets the spinner frame shown while loading. func (m *Model) SetSpinner(s string) { m.spinner = s } // Loading reports whether the list is still being read. func (m *Model) Loading() bool { return m.loading } // Selected returns the branch under the cursor, nil if none matches. func (m *Model) Selected() *submodule.Branch { if m.cursor < 0 || m.cursor >= len(m.shown) { return nil } b := m.all[m.shown[m.cursor]] return &b } // Title is what the picker switches, e.g. "src/app". func (m *Model) Title() string { return m.title } // Search is the text in the search line. func (m *Model) Search() string { return m.input.Value() } func (m *Model) Up() { m.move(-1) } func (m *Model) Down() { m.move(1) } func (m *Model) PageUp() { m.move(-max(m.rows-1, 1)) } func (m *Model) PageDown() { m.move(max(m.rows-1, 1)) } func (m *Model) move(d int) { m.cursor = max(min(m.cursor+d, len(m.shown)-1), 0) m.scroll() } func (m *Model) scroll() { if m.cursor < m.offset { m.offset = m.cursor } if m.cursor >= m.offset+m.rows { m.offset = m.cursor - m.rows + 1 } m.offset = max(min(m.offset, len(m.shown)-m.rows), 0) } // Update edits the search line. The cursor stays on the same branch if it // still matches. func (m *Model) Update(msg tea.Msg) tea.Cmd { var cmd tea.Cmd before := m.input.Value() m.input, cmd = m.input.Update(msg) if m.input.Value() != before { m.refilter() } return cmd } // refilter recomputes shown: every word of the search must be in the name. func (m *Model) refilter() { var keep string if b := m.Selected(); b != nil { keep = b.Ref } words := strings.Fields(strings.ToLower(m.input.Value())) m.shown = m.shown[:0] m.cursor = 0 for i, b := range m.all { name := strings.ToLower(b.Name) ok := true for _, w := range words { ok = ok && strings.Contains(name, w) } if !ok { continue } if b.Ref == keep { m.cursor = len(m.shown) } m.shown = append(m.shown, i) } m.offset = 0 m.scroll() } // View renders the popup for a terminal of the given size. func (m *Model) View(termWidth, termHeight int) string { width := max(min(maxWidth, termWidth-4), minWidth) width = min(width, termWidth) inner := max(width-4, 1) // border and padding // the list is as tall as the branches, so filtering doesn't resize it m.rows = max(min(max(len(m.all), 3), maxRows, termHeight-frameRows-2), 1) m.scroll() m.input.Width = max(inner-3, 1) muted := theme.Fg(m.theme.Muted) lines := []string{ theme.Fg(m.theme.Primary).Bold(true).Render(ansi.Truncate("Switch "+m.title, inner, "…")), m.input.View(), muted.Render(strings.Repeat(m.theme.Glyphs.Rule, inner)), } var body []string switch { case m.loading: body = []string{theme.Fg(m.theme.Primary).Render(m.spinner) + " " + muted.Render("reading branches…")} case m.err != nil: body = []string{theme.Fg(m.theme.Danger).Render(ansi.Truncate(m.err.Error(), inner, "…"))} case len(m.shown) == 0 && len(m.all) == 0: body = []string{muted.Render("no branches")} case len(m.shown) == 0: body = []string{muted.Render("no branch matches")} default: end := min(m.offset+m.rows, len(m.shown)) for i := m.offset; i < end; i++ { body = append(body, m.row(m.all[m.shown[i]], i == m.cursor, inner)) } } for len(body) < m.rows { body = append(body, "") } lines = append(lines, body[:m.rows]...) lines = append(lines, muted.Render(ansi.Truncate("↑↓ select · enter switch · esc cancel", inner, "…"))) return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(m.theme.Border). Padding(0, 1). Render(strings.Join(lines, "\n")) } // row renders one branch: the name, right-aligned the short SHA and age. func (m *Model) row(b submodule.Branch, selected bool, width int) string { base := lipgloss.NewStyle() if selected { base = base.Background(m.theme.Selected) } marker := " " if b.Current { marker = "*" } name, prefix := b.Name, "" if b.Remote { if remote, rest, ok := strings.Cut(b.Name, "/"); ok { prefix, name = remote+"/", rest } } right := base.Foreground(m.theme.Muted).Render(short(b.SHA) + " " + theme.Ago(b.When)) room := max(width-lipgloss.Width(right)-3, 1) nameStyle := base.Foreground(m.theme.Text) if b.Current { nameStyle = nameStyle.Bold(true) } left := base.Foreground(m.theme.Primary).Render(marker) + base.Render(" ") + base.Foreground(m.theme.Muted).Render(prefix) + nameStyle.Render(name) left = ansi.Truncate(left, room, "…") gap := max(width-lipgloss.Width(left)-lipgloss.Width(right), 1) return left + base.Render(strings.Repeat(" ", gap)) + right } func short(sha string) string { if len(sha) > 7 { return sha[:7] } return sha }