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,132 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
// switchDir returns the directory of path ("" is the superproject), and the
|
||||
// submodule's info if it is one.
|
||||
func (p *Provider) switchDir(path string) (dir string, in info, sub bool, err error) {
|
||||
if path == "" {
|
||||
return p.root, info{}, false, nil
|
||||
}
|
||||
p.mu.RLock()
|
||||
in, ok := p.mods[path]
|
||||
p.mu.RUnlock()
|
||||
if !ok {
|
||||
return "", info{}, false, fmt.Errorf("unknown submodule %q", path)
|
||||
}
|
||||
if !initialized(in.dir) {
|
||||
return "", info{}, false, errors.New("not initialized; restore it first")
|
||||
}
|
||||
return in.dir, in, true, nil
|
||||
}
|
||||
|
||||
// Branches lists the local and remote-tracking branches of path, local ones
|
||||
// first, each group by the date of its tip, newest first. It reads only what
|
||||
// is on disk.
|
||||
func (p *Provider) Branches(ctx context.Context, path string) ([]submodule.Branch, error) {
|
||||
dir, _, _, err := p.switchDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := run(ctx, dir, "for-each-ref",
|
||||
"--format=%(refname)%00%(objectname)%00%(committerdate:unix)%00%(HEAD)%00%(symref)",
|
||||
"refs/heads", "refs/remotes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var branches []submodule.Branch
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Split(line, "\x00")
|
||||
if len(f) != 5 || f[4] != "" { // a symref is origin/HEAD
|
||||
continue
|
||||
}
|
||||
b := submodule.Branch{Ref: f[0], SHA: f[1], Current: f[3] == "*"}
|
||||
if secs, err := strconv.ParseInt(f[2], 10, 64); err == nil {
|
||||
b.When = time.Unix(secs, 0)
|
||||
}
|
||||
if name, ok := strings.CutPrefix(f[0], "refs/heads/"); ok {
|
||||
b.Name, b.Local = name, name
|
||||
} else if name, ok := strings.CutPrefix(f[0], "refs/remotes/"); ok {
|
||||
_, local, _ := strings.Cut(name, "/")
|
||||
b.Name, b.Local, b.Remote = name, local, true
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
branches = append(branches, b)
|
||||
}
|
||||
slices.SortStableFunc(branches, func(a, b submodule.Branch) int {
|
||||
switch {
|
||||
case a.Remote != b.Remote:
|
||||
if a.Remote {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
default:
|
||||
return b.When.Compare(a.When)
|
||||
}
|
||||
})
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
// SwitchLoss reports what switching path to b would discard: the same as a
|
||||
// restore to b's tip.
|
||||
func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Branch) (submodule.Loss, error) {
|
||||
dir, _, _, err := p.switchDir(path)
|
||||
if err != nil {
|
||||
return submodule.Loss{}, err
|
||||
}
|
||||
return p.lossOf(ctx, dir, b.SHA), nil
|
||||
}
|
||||
|
||||
// Switch makes b's tip the staged pin of a submodule, restores the repository
|
||||
// to it (see Restore) and puts HEAD on the branch. For a remote branch the
|
||||
// local branch of that name is created, tracking it. A local branch that
|
||||
// exists elsewhere is never moved: HEAD stays detached and "" is returned.
|
||||
func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) (string, error) {
|
||||
dir, in, sub, err := p.switchDir(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sha, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", b.Ref+"^{commit}"); err != nil {
|
||||
return "", err
|
||||
} else if !ok || sha != b.SHA {
|
||||
return "", fmt.Errorf("%s moved; open the list again", b.Name)
|
||||
}
|
||||
if sub { // staged first, so a failed restore can be repeated with x
|
||||
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+b.SHA+","+in.relPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := p.restoreRepo(ctx, dir, b.SHA); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
g := func(args ...string) (string, error) {
|
||||
return p.exec(ctx, dir, nil, append(append([]string{}, restoreArgs...), args...)...)
|
||||
}
|
||||
if !b.Remote {
|
||||
_, err := g("switch", "-q", b.Local)
|
||||
return b.Local, err
|
||||
}
|
||||
switch tip, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", "refs/heads/"+b.Local); {
|
||||
case err != nil:
|
||||
return "", err
|
||||
case !ok:
|
||||
_, err := g("switch", "-q", "-c", b.Local, "--track", b.Name)
|
||||
return b.Local, err
|
||||
case tip == b.SHA:
|
||||
_, err := g("switch", "-q", b.Local)
|
||||
return b.Local, err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
func findBranch(t *testing.T, bs []submodule.Branch, name string) submodule.Branch {
|
||||
t.Helper()
|
||||
for _, b := range bs {
|
||||
if b.Name == name {
|
||||
return b
|
||||
}
|
||||
}
|
||||
t.Fatalf("no branch %q in %v", name, bs)
|
||||
return submodule.Branch{}
|
||||
}
|
||||
|
||||
func TestBranchesListsLocalAndRemote(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
_ = root
|
||||
bs, err := p.Branches(context.Background(), "libs/feature")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
seenRemote := false
|
||||
for _, b := range bs {
|
||||
names = append(names, b.Name)
|
||||
if b.Remote {
|
||||
seenRemote = true
|
||||
} else if seenRemote {
|
||||
t.Errorf("local branch %s after a remote one: %v", b.Name, names)
|
||||
}
|
||||
if b.Name == "origin/HEAD" {
|
||||
t.Error("origin/HEAD must not be listed")
|
||||
}
|
||||
}
|
||||
rb := findBranch(t, bs, "origin/feat/search")
|
||||
if !rb.Remote || rb.Local != "feat/search" {
|
||||
t.Errorf("origin/feat/search = %+v", rb)
|
||||
}
|
||||
findBranch(t, bs, "origin/main")
|
||||
}
|
||||
|
||||
func TestSwitchSubmoduleStagesTipAndTracksRemoteBranch(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
sub := filepath.Join(super, "libs", "feature")
|
||||
ctx := context.Background()
|
||||
|
||||
bs, err := p.Branches(ctx, "libs/feature")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := findBranch(t, bs, "origin/feat/search")
|
||||
got, err := p.Switch(ctx, "libs/feature", b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "feat/search" {
|
||||
t.Errorf("Switch returned %q, want feat/search", got)
|
||||
}
|
||||
if head := out(t, sub, "branch", "--show-current"); head != "feat/search" {
|
||||
t.Errorf("branch = %q", head)
|
||||
}
|
||||
if up := out(t, sub, "rev-parse", "--abbrev-ref", "feat/search@{upstream}"); up != "origin/feat/search" {
|
||||
t.Errorf("upstream = %q", up)
|
||||
}
|
||||
if head := out(t, sub, "rev-parse", "HEAD"); head != b.SHA {
|
||||
t.Errorf("HEAD = %s, want %s", head, b.SHA)
|
||||
}
|
||||
if staged := out(t, super, "ls-files", "-s", "--", "libs/feature"); !strings.Contains(staged, b.SHA) {
|
||||
t.Errorf("index = %q, want the tip %s staged", staged, b.SHA)
|
||||
}
|
||||
assertExact(t, sub)
|
||||
}
|
||||
|
||||
func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
sub := filepath.Join(root, "super", "libs", "feature")
|
||||
ctx := context.Background()
|
||||
|
||||
git(t, sub, "branch", "feat/search", "origin/main") // same name, elsewhere
|
||||
before := out(t, sub, "rev-parse", "feat/search")
|
||||
bs, err := p.Branches(ctx, "libs/feature")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := findBranch(t, bs, "origin/feat/search")
|
||||
got, err := p.Switch(ctx, "libs/feature", b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("Switch returned %q, want detached", got)
|
||||
}
|
||||
if head := out(t, sub, "branch", "--show-current"); head != "" {
|
||||
t.Errorf("HEAD on %q, want detached", head)
|
||||
}
|
||||
if after := out(t, sub, "rev-parse", "feat/search"); after != before {
|
||||
t.Errorf("branch moved: %s -> %s", before, after)
|
||||
}
|
||||
if head := out(t, sub, "rev-parse", "HEAD"); head != b.SHA {
|
||||
t.Errorf("HEAD = %s, want %s", head, b.SHA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchAbortsWhenTheBranchMoved(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
sub := filepath.Join(root, "super", "libs", "feature")
|
||||
ctx := context.Background()
|
||||
|
||||
bs, err := p.Branches(ctx, "libs/feature")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := findBranch(t, bs, "origin/main")
|
||||
b.SHA = out(t, sub, "rev-parse", "origin/feat/search") // what the list showed is stale
|
||||
if _, err := p.Switch(ctx, "libs/feature", b); err == nil || !strings.Contains(err.Error(), "moved") {
|
||||
t.Errorf("err = %v, want a moved error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchSuperprojectRestoresSubmodulesToItsPins(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
ctx := context.Background()
|
||||
|
||||
git(t, super, "branch", "older", "HEAD~1")
|
||||
bs, err := p.Branches(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := findBranch(t, bs, "older")
|
||||
got, err := p.Switch(ctx, "", b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "older" || out(t, super, "branch", "--show-current") != "older" {
|
||||
t.Errorf("Switch = %q, branch = %q", got, out(t, super, "branch", "--show-current"))
|
||||
}
|
||||
assertExact(t, super)
|
||||
}
|
||||
|
||||
func TestSwitchLossCountsChangedFiles(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
sub := filepath.Join(root, "super", "libs", "feature")
|
||||
ctx := context.Background()
|
||||
|
||||
bs, err := p.Branches(ctx, "libs/feature")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := findBranch(t, bs, "origin/feat/search")
|
||||
loss, err := p.SwitchLoss(ctx, "libs/feature", b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !loss.Empty() {
|
||||
t.Errorf("clean checkout: loss = %+v", loss)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "new.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loss, err = p.SwitchLoss(ctx, "libs/feature", b); err != nil || loss.Files != 1 {
|
||||
t.Errorf("loss = %+v, %v; want 1 file", loss, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchesOfAMissingSubmoduleSaysSo(t *testing.T) {
|
||||
p, _ := ownFixture(t)
|
||||
if _, err := p.Branches(context.Background(), "libs/missing"); err == nil || !strings.Contains(err.Error(), "not initialized") {
|
||||
t.Errorf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package submodule
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Provider supplies repository data. Tree is expected to be fast (structure
|
||||
// only); Status may be slow and is called once per submodule, concurrently.
|
||||
@@ -44,6 +47,31 @@ type Restorer interface {
|
||||
Restore(ctx context.Context, path string, to RestoreTarget) error
|
||||
}
|
||||
|
||||
// Branch is a local or remote-tracking branch of a repository.
|
||||
type Branch struct {
|
||||
Ref string // full ref, e.g. "refs/remotes/origin/feat"
|
||||
Name string // short name, e.g. "origin/feat"
|
||||
Local string // name of the local branch it stands for, e.g. "feat"
|
||||
Remote bool // a remote-tracking branch
|
||||
Current bool // HEAD is on this branch
|
||||
SHA string // the tip when the list was read
|
||||
When time.Time // committer date of the tip
|
||||
}
|
||||
|
||||
// Switcher is implemented by providers that can switch a repository to a
|
||||
// branch: the tip becomes the staged pin of a submodule, and the repository
|
||||
// (with everything below it) is restored to it. Path "" is the superproject.
|
||||
// Like Restore, Switch may clone or fetch what is missing below, but must
|
||||
// never prompt the user. Branches uses no network.
|
||||
type Switcher interface {
|
||||
Branches(ctx context.Context, path string) ([]Branch, error)
|
||||
// SwitchLoss reports what Switch would discard, read from the disk now.
|
||||
SwitchLoss(ctx context.Context, path string, b Branch) (Loss, error)
|
||||
// Switch returns the branch HEAD ends up on, "" if it is detached. It
|
||||
// fails if b.Ref no longer points at b.SHA.
|
||||
Switch(ctx context.Context, path string, b Branch) (string, error)
|
||||
}
|
||||
|
||||
// RestoreTarget is the pin a submodule is restored to. It doesn't matter for
|
||||
// the superproject, whose target is always HEAD.
|
||||
type RestoreTarget int
|
||||
|
||||
+22
-4
@@ -15,6 +15,7 @@ import (
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/tui/branches"
|
||||
"submarine/internal/tui/detail"
|
||||
"submarine/internal/tui/footer"
|
||||
"submarine/internal/tui/header"
|
||||
@@ -56,8 +57,10 @@ type Model struct {
|
||||
// exactly then. The input is focused while typing and blurred after enter.
|
||||
filtering bool
|
||||
|
||||
restore *restoreState // the restore in progress, nil if none
|
||||
notice string // an error to show above the footer until the next key
|
||||
restore *restoreState // the restore or switch in progress, nil if none
|
||||
picker *branches.Model // the branch picker, nil if closed
|
||||
pickerPath string // the repository the picker is for
|
||||
notice string // an error to show above the footer until the next key
|
||||
|
||||
width, height int
|
||||
showDetail bool
|
||||
@@ -159,7 +162,7 @@ func (m *Model) startFetch() tea.Cmd {
|
||||
|
||||
// busy reports whether something shows a spinner.
|
||||
func (m Model) busy() bool {
|
||||
if m.repo == nil || m.fetch.Running || (m.restore != nil && (m.restore.phase == restoreChecking || m.restore.phase == restoreRunning)) {
|
||||
if m.repo == nil || m.fetch.Running || (m.picker != nil && m.picker.Loading()) || (m.restore != nil && (m.restore.phase == restoreChecking || m.restore.phase == restoreRunning)) {
|
||||
return true
|
||||
}
|
||||
loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil
|
||||
@@ -205,6 +208,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case changeMsg:
|
||||
return m.changed(msg)
|
||||
|
||||
case branchesMsg:
|
||||
return m.branchesLoaded(msg)
|
||||
|
||||
case lossMsg:
|
||||
return m.lossChecked(msg)
|
||||
|
||||
@@ -237,6 +243,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, cmd
|
||||
|
||||
case tea.MouseMsg:
|
||||
if m.picker != nil {
|
||||
return m, nil
|
||||
}
|
||||
m.tree.Update(msg)
|
||||
return m, nil
|
||||
|
||||
@@ -331,6 +340,9 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := m.keys
|
||||
if m.picker != nil {
|
||||
return m.pickBranch(msg)
|
||||
}
|
||||
if m.restore != nil {
|
||||
switch m.restore.phase {
|
||||
case restoreChoosing:
|
||||
@@ -400,6 +412,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, m.filter.Focus()
|
||||
case key.Matches(msg, k.Restore):
|
||||
return m.startRestore()
|
||||
case key.Matches(msg, k.Switch):
|
||||
return m.startSwitch()
|
||||
case key.Matches(msg, k.Issues):
|
||||
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
|
||||
m.layout()
|
||||
@@ -547,5 +561,9 @@ func (m Model) View() string {
|
||||
} else if m.tree.ProblemsOnly() {
|
||||
parts = append(parts, m.problemsView())
|
||||
}
|
||||
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
|
||||
view := zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
|
||||
if m.picker != nil { // after zone.Scan, so no zone marker is cut
|
||||
view = overlay(view, m.picker.View(m.width, m.height), m.width, m.height, theme.Fg(m.theme.Backdrop))
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ type keyMap struct {
|
||||
CollapseAll, ExpandAll key.Binding
|
||||
Home, End, PageUp, PageDown key.Binding
|
||||
Fetch, Reload, Filter, Issues key.Binding
|
||||
Copy, Restore, Help, Quit key.Binding
|
||||
Copy, Restore, Switch key.Binding
|
||||
Help, Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeys() keyMap {
|
||||
@@ -28,6 +29,7 @@ func defaultKeys() keyMap {
|
||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
||||
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
|
||||
Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
|
||||
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
@@ -44,7 +46,7 @@ func (k keyMap) Navigation() []key.Binding {
|
||||
|
||||
// Submodule returns the bindings that act on the selected submodule. They
|
||||
// are shown in the detail pane, or in the footer if the pane can't show them.
|
||||
func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy, k.Restore} }
|
||||
func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy, k.Switch, k.Restore} }
|
||||
|
||||
// Global returns the bindings that don't depend on the selection.
|
||||
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
|
||||
@@ -58,7 +60,7 @@ func (k keyMap) FullHelp() [][]key.Binding {
|
||||
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
|
||||
{k.CollapseAll, k.ExpandAll},
|
||||
{k.Home, k.End, k.PageUp, k.PageDown},
|
||||
{k.Copy, k.Restore},
|
||||
{k.Copy, k.Switch, k.Restore},
|
||||
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||
{k.Help, k.Quit},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// overlay draws fg centered over bg, which is w x h cells. bg must be the
|
||||
// final string (after zone.Scan): its lines are cut at the popup's edges.
|
||||
// The backdrop loses its colors and is drawn in dim, so the popup stands out.
|
||||
func overlay(bg, fg string, w, h int, dim lipgloss.Style) string {
|
||||
lines := strings.Split(bg, "\n")
|
||||
for i, l := range lines {
|
||||
lines[i] = dim.Render(ansi.Strip(l))
|
||||
}
|
||||
pop := strings.Split(fg, "\n")
|
||||
pw := lipgloss.Width(fg)
|
||||
top := max((h-len(pop))/2, 0)
|
||||
left := max((w-pw)/2, 0)
|
||||
for i, p := range pop {
|
||||
y := top + i
|
||||
if y >= len(lines) {
|
||||
break
|
||||
}
|
||||
line := lines[y]
|
||||
if lw := ansi.StringWidth(line); lw < left {
|
||||
line += strings.Repeat(" ", left-lw)
|
||||
}
|
||||
lines[y] = ansi.Truncate(line, left, "") + "\x1b[0m" + p + "\x1b[0m" + ansi.TruncateLeft(line, left+pw, "")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func TestOverlayCentersThePopup(t *testing.T) {
|
||||
bg := strings.Repeat(strings.Repeat(".", 10)+"\n", 4) + strings.Repeat(".", 10)
|
||||
got := strings.Split(ansi.Strip(overlay(bg, "ab\ncd", 10, 5, lipgloss.NewStyle())), "\n")
|
||||
want := []string{"..........", "....ab....", "....cd....", "..........", ".........."}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d lines", len(got))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("line %d = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayDimsTheBackdrop(t *testing.T) {
|
||||
bg := "\x1b[31mredredred\x1b[0m"
|
||||
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||
got := overlay(bg, "X", 9, 1, dim)
|
||||
if ansi.StringWidth(got) != 9 || ansi.Strip(got) != "redrXdred" {
|
||||
t.Errorf("got %q", ansi.Strip(got))
|
||||
}
|
||||
if strings.Contains(got, "\x1b[31m") {
|
||||
t.Errorf("the backdrop keeps its colors: %q", got)
|
||||
}
|
||||
}
|
||||
+65
-20
@@ -27,6 +27,7 @@ type restoreState struct {
|
||||
phase restorePhase
|
||||
to submodule.RestoreTarget
|
||||
loss submodule.Loss
|
||||
branch *submodule.Branch // set for a branch switch, which reuses this flow
|
||||
}
|
||||
|
||||
// lossMsg is the result of the check before a restore.
|
||||
@@ -37,8 +38,9 @@ type lossMsg struct {
|
||||
}
|
||||
|
||||
type restoreDoneMsg struct {
|
||||
path string
|
||||
err error
|
||||
path string
|
||||
branch string // the branch a switch ended on, "" if detached
|
||||
err error
|
||||
}
|
||||
|
||||
// startRestore checks what restoring the selection would discard. If nothing
|
||||
@@ -75,10 +77,15 @@ func (m Model) startRestore() (tea.Model, tea.Cmd) {
|
||||
func (m Model) checkRestore() (tea.Model, tea.Cmd) {
|
||||
m.restore.phase = restoreChecking
|
||||
m.layout()
|
||||
r := m.provider.(submodule.Restorer)
|
||||
ctx, path, to := m.ctx, m.restore.path, m.restore.to
|
||||
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
|
||||
check := func() tea.Msg {
|
||||
loss, err := r.RestoreLoss(ctx, path, to)
|
||||
var loss submodule.Loss
|
||||
var err error
|
||||
if b != nil {
|
||||
loss, err = m.provider.(submodule.Switcher).SwitchLoss(ctx, path, *b)
|
||||
} else {
|
||||
loss, err = m.provider.(submodule.Restorer).RestoreLoss(ctx, path, to)
|
||||
}
|
||||
return lossMsg{path, loss, err}
|
||||
}
|
||||
return m, tea.Batch(check, m.tick())
|
||||
@@ -139,40 +146,78 @@ func (m Model) answerRestore(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
func (m Model) runRestore() (tea.Model, tea.Cmd) {
|
||||
m.restore.phase = restoreRunning
|
||||
m.layout()
|
||||
r := m.provider.(submodule.Restorer)
|
||||
ctx, path, to := m.ctx, m.restore.path, m.restore.to
|
||||
run := func() tea.Msg { return restoreDoneMsg{path, r.Restore(ctx, path, to)} }
|
||||
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
|
||||
run := func() tea.Msg {
|
||||
if b != nil {
|
||||
branch, err := m.provider.(submodule.Switcher).Switch(ctx, path, *b)
|
||||
return restoreDoneMsg{path, branch, err}
|
||||
}
|
||||
return restoreDoneMsg{path, "", m.provider.(submodule.Restorer).Restore(ctx, path, to)}
|
||||
}
|
||||
return m, tea.Batch(run, m.tick())
|
||||
}
|
||||
|
||||
// restoreDone shows the result and reloads everything: the structure may have
|
||||
// changed.
|
||||
func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
|
||||
name := "the superproject"
|
||||
if m.restore != nil {
|
||||
name = m.restore.name + m.restore.toText()
|
||||
}
|
||||
r := m.restore
|
||||
m.restore = nil
|
||||
var cmds []tea.Cmd
|
||||
if msg.err != nil {
|
||||
m.notice = "restore of " + name + " failed: " + msg.err.Error()
|
||||
} else {
|
||||
cmds = append(cmds, m.footer.Flash("restored "+name))
|
||||
switch {
|
||||
case r != nil && r.branch != nil && msg.err != nil:
|
||||
m.notice = "switch of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
|
||||
case r != nil && r.branch != nil:
|
||||
to := r.branch.Name
|
||||
if msg.branch != "" {
|
||||
to = msg.branch
|
||||
} else if r.branch.Remote {
|
||||
to += " (detached: local " + r.branch.Local + " is elsewhere)"
|
||||
}
|
||||
cmds = append(cmds, m.footer.Flash("switched "+r.name+" to "+to))
|
||||
default:
|
||||
name := "the superproject"
|
||||
if r != nil {
|
||||
name = r.name + r.toText()
|
||||
}
|
||||
if msg.err != nil {
|
||||
m.notice = "restore of " + name + " failed: " + msg.err.Error()
|
||||
} else {
|
||||
cmds = append(cmds, m.footer.Flash("restored "+name))
|
||||
}
|
||||
}
|
||||
cmds = append(cmds, m.reloadTree(true))
|
||||
m.layout()
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// toText is " to the committed pin" if that was chosen; the staged pin is the
|
||||
// toText is " to origin/feat" for a switch, " to the committed pin" if that was chosen; the staged pin is the
|
||||
// usual target and needs no words.
|
||||
func (r *restoreState) toText() string {
|
||||
if r.branch != nil {
|
||||
return " to " + r.branch.Name
|
||||
}
|
||||
if r.to == submodule.RestoreCommitted {
|
||||
return " to the committed pin"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// verb and verbIng word the operation: a switch is shown like a restore, but
|
||||
// names its branch.
|
||||
func (r *restoreState) verb() string {
|
||||
if r.branch != nil {
|
||||
return "Switch"
|
||||
}
|
||||
return "Restore"
|
||||
}
|
||||
|
||||
func (r *restoreState) verbIng() string {
|
||||
if r.branch != nil {
|
||||
return "switching"
|
||||
}
|
||||
return "restoring"
|
||||
}
|
||||
|
||||
func restoreName(s *submodule.Submodule) string {
|
||||
if s == nil || s.IsRoot() {
|
||||
return "the superproject"
|
||||
@@ -194,11 +239,11 @@ func (m Model) restoreView() string {
|
||||
case restoreChecking:
|
||||
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Muted).Render("checking "+r.name+"…")
|
||||
case restoreRunning:
|
||||
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Warn).Render("restoring "+r.name+r.toText()+"…")
|
||||
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Warn).Render(r.verbIng()+" "+r.name+r.toText()+"…")
|
||||
case restoreAsking:
|
||||
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+r.toText()+"?") + " " +
|
||||
left = theme.Fg(m.theme.Warn).Render(r.verb()+" "+r.name+r.toText()+"?") + " " +
|
||||
theme.Fg(m.theme.Danger).Render("Discards "+lossText(r.loss)+".")
|
||||
right = theme.Fg(m.theme.Text).Bold(true).Render("y") + theme.Fg(m.theme.Muted).Render(" restore · ") +
|
||||
right = theme.Fg(m.theme.Text).Bold(true).Render("y") + theme.Fg(m.theme.Muted).Render(" "+strings.ToLower(r.verb())+" · ") +
|
||||
theme.Fg(m.theme.Text).Bold(true).Render("n") + theme.Fg(m.theme.Muted).Render(" cancel")
|
||||
}
|
||||
return m.line(left, right)
|
||||
|
||||
@@ -92,7 +92,7 @@ func newRestoreModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeRestore
|
||||
func feed(m tea.Model, cmd tea.Cmd) tea.Model {
|
||||
for _, msg := range run(cmd) {
|
||||
switch msg.(type) {
|
||||
case lossMsg, restoreDoneMsg:
|
||||
case lossMsg, restoreDoneMsg, branchesMsg:
|
||||
m, _ = m.Update(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/tui/branches"
|
||||
)
|
||||
|
||||
// branchesMsg is the branch list for the picker of path.
|
||||
type branchesMsg struct {
|
||||
path string
|
||||
branches []submodule.Branch
|
||||
err error
|
||||
}
|
||||
|
||||
// startSwitch opens the branch picker for the selection and reads its
|
||||
// branches.
|
||||
func (m Model) startSwitch() (tea.Model, tea.Cmd) {
|
||||
s := m.tree.Selected()
|
||||
sw, ok := m.provider.(submodule.Switcher)
|
||||
var flash string
|
||||
switch {
|
||||
case s == nil:
|
||||
return m, nil
|
||||
case !ok:
|
||||
flash = "this data source can't switch branches"
|
||||
case m.restore != nil:
|
||||
flash = "wait for the restore of " + m.restore.name + " to finish"
|
||||
case !s.IsRoot() && s.Status != nil && s.Status.Presence != submodule.PresenceOK:
|
||||
flash = s.Path + " is not initialized; restore it first"
|
||||
}
|
||||
if flash != "" {
|
||||
cmd := m.footer.Flash(flash)
|
||||
m.layout()
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
m.picker, m.pickerPath = branches.New(m.theme, restoreName(s)), s.Path
|
||||
m.picker.SetSpinner(m.spinner.View())
|
||||
ctx, path := m.ctx, s.Path
|
||||
load := func() tea.Msg {
|
||||
bs, err := sw.Branches(ctx, path)
|
||||
return branchesMsg{path, bs, err}
|
||||
}
|
||||
return m, tea.Batch(load, m.tick())
|
||||
}
|
||||
|
||||
// branchesLoaded fills the picker, unless it was closed or is for another
|
||||
// repository meanwhile.
|
||||
func (m Model) branchesLoaded(msg branchesMsg) (tea.Model, tea.Cmd) {
|
||||
if m.picker == nil || m.pickerPath != msg.path {
|
||||
return m, nil
|
||||
}
|
||||
if msg.err != nil {
|
||||
m.picker.SetErr(msg.err)
|
||||
} else {
|
||||
m.picker.SetBranches(msg.branches)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// pickBranch handles a key while the picker is open: arrows move, enter
|
||||
// switches, esc closes, everything else edits the search.
|
||||
func (m Model) pickBranch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case tea.KeyEsc:
|
||||
m.picker = nil
|
||||
case tea.KeyUp:
|
||||
m.picker.Up()
|
||||
case tea.KeyDown:
|
||||
m.picker.Down()
|
||||
case tea.KeyPgUp:
|
||||
m.picker.PageUp()
|
||||
case tea.KeyPgDown:
|
||||
m.picker.PageDown()
|
||||
case tea.KeyEnter:
|
||||
b := m.picker.Selected()
|
||||
if b == nil {
|
||||
return m, nil
|
||||
}
|
||||
s := m.byPath[m.pickerPath]
|
||||
m.picker = nil
|
||||
if s == nil || m.restore != nil {
|
||||
return m, nil
|
||||
}
|
||||
m.restore = &restoreState{path: s.Path, name: restoreName(s), branch: b}
|
||||
return m.checkRestore()
|
||||
default:
|
||||
return m, m.picker.Update(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/submodule/mock"
|
||||
)
|
||||
|
||||
// fakeSwitcher is the mock data with a branch switch that records its calls.
|
||||
type fakeSwitcher struct {
|
||||
submodule.Provider
|
||||
loss submodule.Loss
|
||||
branches []submodule.Branch
|
||||
switched []string // path + " " + ref
|
||||
ends string // the branch Switch reports
|
||||
}
|
||||
|
||||
func (f *fakeSwitcher) Branches(context.Context, string) ([]submodule.Branch, error) {
|
||||
return f.branches, nil
|
||||
}
|
||||
|
||||
func (f *fakeSwitcher) SwitchLoss(context.Context, string, submodule.Branch) (submodule.Loss, error) {
|
||||
return f.loss, nil
|
||||
}
|
||||
|
||||
func (f *fakeSwitcher) Switch(_ context.Context, path string, b submodule.Branch) (string, error) {
|
||||
f.switched = append(f.switched, path+" "+b.Ref)
|
||||
return f.ends, nil
|
||||
}
|
||||
|
||||
func newSwitchModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeSwitcher) {
|
||||
t.Helper()
|
||||
f := &fakeSwitcher{
|
||||
Provider: mock.New(),
|
||||
loss: loss,
|
||||
ends: "feat",
|
||||
branches: []submodule.Branch{
|
||||
{Ref: "refs/heads/main", Name: "main", Local: "main", Current: true, SHA: "aaaaaaa"},
|
||||
{Ref: "refs/remotes/origin/feat", Name: "origin/feat", Local: "feat", Remote: true, SHA: "bbbbbbb"},
|
||||
},
|
||||
}
|
||||
var m tea.Model = New(context.Background(), f)
|
||||
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
|
||||
repo, err := f.Tree(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ = m.Update(treeLoadedMsg{repo: repo})
|
||||
return m, f
|
||||
}
|
||||
|
||||
// openPicker presses b and delivers the branch list.
|
||||
func openPicker(t *testing.T, m tea.Model) tea.Model {
|
||||
t.Helper()
|
||||
m, cmd := press(m, "b")
|
||||
m = feed(m, cmd)
|
||||
if m.(Model).picker == nil {
|
||||
t.Fatal("the picker did not open")
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func pressKey(m tea.Model, t tea.KeyType) (tea.Model, tea.Cmd) {
|
||||
return m.Update(tea.KeyMsg{Type: t})
|
||||
}
|
||||
|
||||
// settle delivers the loss check and, if it ran at once, the switch.
|
||||
func settle(m tea.Model, cmd tea.Cmd) tea.Model {
|
||||
var next tea.Cmd
|
||||
for _, msg := range run(cmd) {
|
||||
if l, ok := msg.(lossMsg); ok {
|
||||
m, next = m.Update(l)
|
||||
}
|
||||
}
|
||||
return feed(m, next)
|
||||
}
|
||||
|
||||
func TestSwitchOpensThePickerWithTheBranches(t *testing.T) {
|
||||
m, _ := newSwitchModel(t, submodule.Loss{})
|
||||
m = openPicker(t, m)
|
||||
v := m.View()
|
||||
for _, want := range []string{"Switch the superproject", "main", "origin/", "feat"} {
|
||||
if !strings.Contains(v, want) {
|
||||
t.Errorf("view lacks %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchTypingFiltersAndEnterSwitchesWithoutLoss(t *testing.T) {
|
||||
m, f := newSwitchModel(t, submodule.Loss{})
|
||||
m = openPicker(t, m)
|
||||
m, _ = press(m, "fe") // j and k would be text, not navigation
|
||||
m, cmd := pressKey(m, tea.KeyEnter)
|
||||
if m.(Model).picker != nil {
|
||||
t.Error("enter must close the picker")
|
||||
}
|
||||
m = settle(m, cmd)
|
||||
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
||||
t.Fatalf("switched %q", f.switched)
|
||||
}
|
||||
if m.(Model).restore != nil {
|
||||
t.Error("switch still in progress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchArrowsSelect(t *testing.T) {
|
||||
m, f := newSwitchModel(t, submodule.Loss{})
|
||||
m = openPicker(t, m)
|
||||
m, _ = pressKey(m, tea.KeyDown)
|
||||
m, cmd := pressKey(m, tea.KeyEnter)
|
||||
settle(m, cmd)
|
||||
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
||||
t.Fatalf("switched %q, want the second branch", f.switched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchAsksWhenSomethingIsLost(t *testing.T) {
|
||||
m, f := newSwitchModel(t, submodule.Loss{Files: 2, Modules: 1})
|
||||
m = openPicker(t, m)
|
||||
m, cmd := pressKey(m, tea.KeyEnter)
|
||||
var next tea.Cmd
|
||||
for _, msg := range run(cmd) {
|
||||
if l, ok := msg.(lossMsg); ok {
|
||||
m, next = m.Update(l)
|
||||
}
|
||||
}
|
||||
r := m.(Model).restore
|
||||
if next != nil || r == nil || r.phase != restoreAsking {
|
||||
t.Fatalf("want a question, got %+v", r)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "Switch the superproject to main?") || !strings.Contains(v, "y switch") {
|
||||
t.Errorf("question not shown:\n%s", v)
|
||||
}
|
||||
if c, _ := press(m, "n"); c.(Model).restore != nil || len(f.switched) != 0 {
|
||||
t.Fatal("n must cancel")
|
||||
}
|
||||
m, cmd = press(m, "y")
|
||||
feed(m, cmd)
|
||||
if len(f.switched) != 1 {
|
||||
t.Fatalf("switched %q", f.switched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchEscClosesThePicker(t *testing.T) {
|
||||
m, f := newSwitchModel(t, submodule.Loss{})
|
||||
m = openPicker(t, m)
|
||||
m, _ = pressKey(m, tea.KeyEsc)
|
||||
if m.(Model).picker != nil || len(f.switched) != 0 {
|
||||
t.Fatal("esc must close without switching")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchWithoutSwitcherSaysSo(t *testing.T) {
|
||||
var m tea.Model = New(context.Background(), mock.New())
|
||||
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
|
||||
repo, _ := mock.New().Tree(context.Background())
|
||||
m, _ = m.Update(treeLoadedMsg{repo: repo})
|
||||
m, _ = press(m, "b")
|
||||
if m.(Model).picker != nil {
|
||||
t.Fatal("the mock can't switch")
|
||||
}
|
||||
if !strings.Contains(m.View(), "can't switch branches") {
|
||||
t.Error("no flash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchReportsADetachedHead(t *testing.T) {
|
||||
m, f := newSwitchModel(t, submodule.Loss{})
|
||||
f.ends = ""
|
||||
m = openPicker(t, m)
|
||||
m, _ = pressKey(m, tea.KeyDown)
|
||||
m, cmd := pressKey(m, tea.KeyEnter)
|
||||
m = settle(m, cmd)
|
||||
if v := m.View(); !strings.Contains(v, "detached") {
|
||||
t.Errorf("flash lacks the detached note:\n%s", v)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
)
|
||||
|
||||
// Theme uses the 16 ANSI colors for everything that carries meaning, so
|
||||
// the TUI follows the user's terminal color scheme. Only background shades,
|
||||
// which have no ANSI equivalent, use adaptive 256-color values.
|
||||
// the TUI follows the user's terminal color scheme. Only background shades
|
||||
// and the dimmed backdrop, which have no ANSI equivalent, use adaptive
|
||||
// 256-color values.
|
||||
type Theme struct {
|
||||
Primary lipgloss.TerminalColor // accent: branch names, commit hashes
|
||||
Info lipgloss.TerminalColor
|
||||
@@ -21,6 +22,7 @@ type Theme struct {
|
||||
Border lipgloss.TerminalColor // pane separators, help separators
|
||||
Bar lipgloss.TerminalColor // background of the header bar
|
||||
Selected lipgloss.TerminalColor // background of the selected row
|
||||
Backdrop lipgloss.TerminalColor // the screen behind a popup, dimmed
|
||||
Glyphs Glyphs
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ func Default() Theme {
|
||||
Border: lipgloss.Color("8"),
|
||||
Bar: lipgloss.AdaptiveColor{Light: "254", Dark: "235"},
|
||||
Selected: lipgloss.AdaptiveColor{Light: "253", Dark: "237"},
|
||||
Backdrop: lipgloss.AdaptiveColor{Light: "252", Dark: "238"},
|
||||
Glyphs: g,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user