Archived
Add switch branch (b): stage the tip and restore to it
b opens a centered popup with a search line and the local and remote branches of the selection. Enter stages the branch tip as the pin of a submodule, restores it (and everything below) to it, and puts HEAD on the branch; a remote branch gets a local tracking branch unless one exists elsewhere. Works on the superproject too. Losses are asked like restore. The backdrop behind the popup is dimmed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package branches
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
|
||||
func list() []submodule.Branch {
|
||||
return []submodule.Branch{
|
||||
{Ref: "refs/heads/main", Name: "main", Local: "main", Current: true, SHA: "aaaaaaaaaa"},
|
||||
{Ref: "refs/heads/dev", Name: "dev", Local: "dev", SHA: "bbbbbbbbbb"},
|
||||
{Ref: "refs/remotes/origin/main", Name: "origin/main", Local: "main", Remote: true, SHA: "aaaaaaaaaa"},
|
||||
{Ref: "refs/remotes/origin/feat/search", Name: "origin/feat/search", Local: "feat/search", Remote: true, SHA: "cccccccccc"},
|
||||
}
|
||||
}
|
||||
|
||||
func typed(m *Model, s string) {
|
||||
for _, r := range s {
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
}
|
||||
|
||||
func name(m *Model) string {
|
||||
if b := m.Selected(); b != nil {
|
||||
return b.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestSelectionMovesAndClamps(t *testing.T) {
|
||||
m := New(theme.Default(), "src/app")
|
||||
m.SetBranches(list())
|
||||
if name(m) != "main" {
|
||||
t.Fatalf("first = %q", name(m))
|
||||
}
|
||||
m.Up()
|
||||
if name(m) != "main" {
|
||||
t.Errorf("up at the top = %q", name(m))
|
||||
}
|
||||
m.Down()
|
||||
m.Down()
|
||||
if name(m) != "origin/main" {
|
||||
t.Errorf("down twice = %q", name(m))
|
||||
}
|
||||
m.PageDown()
|
||||
if name(m) != "origin/feat/search" {
|
||||
t.Errorf("page down = %q, want the last", name(m))
|
||||
}
|
||||
m.Down()
|
||||
if name(m) != "origin/feat/search" {
|
||||
t.Errorf("down at the end = %q", name(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFiltersAndAllWordsMustMatch(t *testing.T) {
|
||||
m := New(theme.Default(), "src/app")
|
||||
m.SetBranches(list())
|
||||
typed(m, "ORIGIN")
|
||||
if len(m.shown) != 2 {
|
||||
t.Errorf("origin matches %d, want 2", len(m.shown))
|
||||
}
|
||||
typed(m, " sea")
|
||||
if len(m.shown) != 1 || name(m) != "origin/feat/search" {
|
||||
t.Errorf("shown = %d, selected %q", len(m.shown), name(m))
|
||||
}
|
||||
typed(m, "zzz")
|
||||
if m.Selected() != nil {
|
||||
t.Errorf("no match must select nothing, got %q", name(m))
|
||||
}
|
||||
if !strings.Contains(m.View(100, 30), "no branch matches") {
|
||||
t.Error("view does not say that nothing matches")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionStaysOnTheSameBranchWhenItStillMatches(t *testing.T) {
|
||||
m := New(theme.Default(), "src/app")
|
||||
m.SetBranches(list())
|
||||
m.Down()
|
||||
m.Down() // origin/main
|
||||
typed(m, "main")
|
||||
if name(m) != "origin/main" {
|
||||
t.Errorf("selected %q, want origin/main kept", name(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewStates(t *testing.T) {
|
||||
m := New(theme.Default(), "src/app")
|
||||
if v := m.View(100, 30); !strings.Contains(v, "reading branches") || !strings.Contains(v, "Switch src/app") {
|
||||
t.Errorf("loading view:\n%s", v)
|
||||
}
|
||||
m.SetErr(errors.New("not initialized; restore it first"))
|
||||
if v := m.View(100, 30); !strings.Contains(v, "not initialized") {
|
||||
t.Errorf("error view:\n%s", v)
|
||||
}
|
||||
m.SetBranches(nil)
|
||||
if v := m.View(100, 30); !strings.Contains(v, "no branches") {
|
||||
t.Errorf("empty view:\n%s", v)
|
||||
}
|
||||
m.SetBranches(list())
|
||||
v := m.View(100, 30)
|
||||
for _, want := range []string{"main", "origin/", "feat/search", "aaaaaaa", "enter switch"} {
|
||||
if !strings.Contains(v, want) {
|
||||
t.Errorf("view lacks %q:\n%s", want, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListScrollsInASmallTerminal(t *testing.T) {
|
||||
var many []submodule.Branch
|
||||
for i := 0; i < 30; i++ {
|
||||
n := string(rune('a'+i%26)) + string(rune('a'+i/26))
|
||||
many = append(many, submodule.Branch{Ref: "refs/heads/" + n, Name: n, Local: n, SHA: "abcdef0123"})
|
||||
}
|
||||
m := New(theme.Default(), "x")
|
||||
m.SetBranches(many)
|
||||
m.View(80, 14) // fewer rows than branches
|
||||
for i := 0; i < 20; i++ {
|
||||
m.Down()
|
||||
}
|
||||
if m.cursor < m.offset || m.cursor >= m.offset+m.rows {
|
||||
t.Errorf("cursor %d outside the window [%d,%d)", m.cursor, m.offset, m.offset+m.rows)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user