Make the superproject selectable via the header

The header stands for the root repository: it is selected on startup,
shows the root's own state word, and the detail pane shows its state
like a submodule's (HEAD vs. upstream, changes, unpushed, leftover
submodule clones as untracked repositories). Submodules are ignored in
the root's changes; their rows show them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 11:54:40 +02:00
co-authored by Claude Sonnet 5.5
parent f9838c0c99
commit 72c060882b
18 changed files with 688 additions and 92 deletions
+90 -15
View File
@@ -85,6 +85,13 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
}
}
repo.Root = &submodule.Submodule{Name: filepath.Base(p.root), Path: ""}
if remote, err := remoteName(ctx, p.root); err != nil {
return nil, err
} else if remote != "" {
repo.Root.URL, _, _ = try(ctx, p.root, "config", "--get", "remote."+remote+".url")
}
mods := map[string]info{}
subs, err := p.build(ctx, p.root, nil, mods)
if err != nil {
@@ -141,6 +148,9 @@ func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status,
case <-ctx.Done():
return nil, ctx.Err()
}
if path == "" {
return p.rootStatus(ctx)
}
p.mu.RLock()
in, ok := p.mods[path]
p.mu.RUnlock()
@@ -208,17 +218,79 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
return nil, err
}
if err := pinState(ctx, in, st, pin, head, branch, nil); err != nil {
return nil, err
}
return st, nil
}
// rootStatus describes the superproject. Its "pin" is HEAD, compared with the
// upstream of the current branch; submodules are ignored, their rows show
// them.
func (p *Provider) rootStatus(ctx context.Context) (*submodule.Status, error) {
in := info{superDir: p.root, dir: p.root}
st := &submodule.Status{Root: true}
if gitDir, _, err := gitDirs(in.dir); err == nil {
if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil {
st.FetchedAt = fi.ModTime()
}
}
head, err := run(ctx, in.dir, "rev-parse", "HEAD")
if err != nil {
return nil, err
}
branch, onBranch, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "HEAD")
if err != nil {
return nil, err
}
if !onBranch {
st.Checkout = submodule.CheckoutDetached
}
out, err := run(ctx, in.dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal", "--ignore-submodules=all")
if err != nil {
return nil, err
}
entries := parseStatus(out)
// a leftover of --ignore-submodules=all in older git versions would
// report the submodules the rows already show
entries = slices.DeleteFunc(entries, func(e entry) bool { return e.submodule })
st.Worktree = worktreeOf(entries)
if st.Changes, err = changes(ctx, in.dir, entries); err != nil {
return nil, err
}
// the root is compared with its upstream, so that the header and the
// detail pane agree; only without one the closest branch is looked for
var chosen *candidate
if branch != "" {
if up, ok, err := try(ctx, in.dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}"); err != nil {
return nil, err
} else if ok {
chosen = &candidate{up, submodule.RefFromCurrentBranch}
}
}
if err := pinState(ctx, in, st, head, head, branch, chosen); err != nil {
return nil, err
}
return st, nil
}
// pinState fills the fields of st that describe pin: whether it is pushed, the
// ref it resolves to (chosen, if not nil), the commits and the distance to the
// ref. head is the checked-out commit.
func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, branch string, chosen *candidate) error {
// the pin must exist locally to say anything about it
if _, ok, err := try(ctx, in.dir, "cat-file", "-e", pin+"^{commit}"); err != nil {
return nil, err
return err
} else if !ok {
return nil, fmt.Errorf("pinned commit %.7s not fetched", pin)
return fmt.Errorf("pinned commit %.7s not fetched", pin)
}
// remote branches containing the pin, oldest tip first
out, err = run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
out, err := run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
if err != nil {
return nil, err
return err
}
var containing []string
for _, r := range strings.Fields(out) {
@@ -228,44 +300,47 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
}
st.Pin.Pushed = len(containing) > 0
ref, source, err := resolve(ctx, in, pin, branch, containing)
if err != nil {
return nil, err
var ref string
var source submodule.RefSource
if chosen != nil {
ref, source = chosen.ref, chosen.source
} else if ref, source, err = resolve(ctx, in, pin, branch, containing); err != nil {
return err
}
st.Pin.Ref, st.Pin.Source = ref, source
// commits
if st.Pinned, err = commit(ctx, in.dir, pin); err != nil {
return nil, err
return err
}
st.CheckedOut = st.Pinned
if head != pin {
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
return nil, err
return err
}
}
if source == submodule.RefFromSHA {
st.Pin.Ref = st.Pinned.SHA
return st, nil
return nil
}
if st.Tip, err = commit(ctx, in.dir, ref); err != nil {
return nil, err
return err
}
out, err = run(ctx, in.dir, "rev-list", "--left-right", "--count", pin+"..."+ref)
if err != nil {
return nil, err
return err
}
if st.Pin.Ahead, st.Pin.Behind, err = parseLeftRight(out); err != nil {
return nil, err
return err
}
if st.Pin.Ahead+st.Pin.Behind > 0 {
out, err = run(ctx, in.dir, "log", commitFormat, fmt.Sprintf("--max-count=%d", maxBetween), pin+"..."+ref)
if err != nil {
return nil, err
return err
}
st.Between = parseCommits(out)
}
return st, nil
return nil
}
// resolve finds the ref that describes the pin, following the order in
+172
View File
@@ -0,0 +1,172 @@
package git
import (
"context"
"os"
"path/filepath"
"slices"
"testing"
"submarine/internal/submodule"
)
// rootFixture is ownFixture with a bare origin for the superproject, main
// pushed and tracking it. It returns the provider, the fixture root and the
// path of the bare repository.
func rootFixture(t *testing.T) (*Provider, string, string) {
t.Helper()
p, root := ownFixture(t)
bare := filepath.Join(root, "remotes", "super.git")
git(t, root, "init", "-q", "--bare", bare)
git(t, p.root, "remote", "add", "origin", bare)
git(t, p.root, "push", "-q", "-u", "origin", "main")
return p, root, bare
}
func rootStatus(t *testing.T, p *Provider) *submodule.Status {
t.Helper()
st, err := p.Status(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if !st.Root {
t.Error("Status.Root = false for the superproject")
}
return st
}
func issueKinds(st *submodule.Status) []submodule.IssueKind {
var kinds []submodule.IssueKind
for _, is := range st.Issues() {
kinds = append(kinds, is.Kind)
}
return kinds
}
func TestTreeRoot(t *testing.T) {
p, root, bare := rootFixture(t)
repo, err := p.Tree(context.Background())
if err != nil {
t.Fatal(err)
}
if r := repo.Root; r == nil || !r.IsRoot() || r.Name != "super" || r.URL != bare || len(r.Children) != 0 {
t.Errorf("Root = %+v (fixture %s)", r, root)
}
}
// The fixture has dirty, conflicting and staged submodules; none of them
// makes the superproject itself changed, their rows show them.
func TestRootStatusIgnoresSubmodules(t *testing.T) {
p, _, _ := rootFixture(t)
st := rootStatus(t, p)
if kinds := issueKinds(st); len(kinds) != 0 {
t.Errorf("issues = %v, want none", kinds)
}
if st.Changes != nil || st.PinStaged || st.Presence != submodule.PresenceOK {
t.Errorf("Changes = %+v, PinStaged = %v", st.Changes, st.PinStaged)
}
if st.Pin.Ref != "origin/main" || st.Pin.Source != submodule.RefFromCurrentBranch || !st.Pin.Pushed {
t.Errorf("Pin = %+v", st.Pin)
}
if st.Pinned == nil || st.Pinned != st.CheckedOut && *st.Pinned != *st.CheckedOut {
t.Errorf("Pinned = %+v, CheckedOut = %+v, want HEAD for both", st.Pinned, st.CheckedOut)
}
}
func TestRootStatusUnpushed(t *testing.T) {
p, _, _ := rootFixture(t)
git(t, p.root, "commit", "--allow-empty", "-m", "Local work")
st := rootStatus(t, p)
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueUnpushed}) {
t.Errorf("issues = %v, want unpushed", kinds)
}
if st.Pin.Ahead != 1 || st.Pin.Behind != 0 {
t.Errorf("ahead/behind = %d/%d, want 1/0", st.Pin.Ahead, st.Pin.Behind)
}
if d := st.Issues()[0].Detail; d != "HEAD not pushed to any remote" {
t.Errorf("detail = %q", d)
}
}
// A submodule the current branch no longer has stays on disk after a branch
// switch (its .git is a file into .git/modules). It is an untracked
// repository of the superproject.
func TestRootStatusLeftoverSubmodule(t *testing.T) {
p, _, _ := rootFixture(t)
git(t, p.root, "switch", "-q", "-c", "nosub")
git(t, p.root, "rm", "-q", "--cached", "src/ui")
git(t, p.root, "commit", "-q", "-m", "Drop src/ui")
git(t, p.root, "switch", "-q", "main")
git(t, p.root, "switch", "-q", "nosub")
if _, err := os.Stat(filepath.Join(p.root, "src", "ui", ".git")); err != nil {
t.Fatalf("src/ui should be left behind: %v", err)
}
st := rootStatus(t, p)
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueUnpushed, submodule.IssueChanged}) {
t.Fatalf("issues = %v, want unpushed (new branch) and changed", kinds)
}
if d := st.Issues()[1].Detail; d != "untracked repository" {
t.Errorf("detail = %q, want untracked repository", d)
}
if st.Changes == nil || len(st.Changes.Files) != 1 {
t.Fatalf("Changes = %+v", st.Changes)
}
if f := st.Changes.Files[0]; f.Path != "src/ui/" || f.Kind != submodule.ChangeUntracked || !f.Repo {
t.Errorf("file = %+v", f)
}
}
func TestRootStatusModified(t *testing.T) {
p, _, _ := rootFixture(t)
if err := os.WriteFile(filepath.Join(p.root, "README"), []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
st := rootStatus(t, p)
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueChanged}) {
t.Errorf("issues = %v, want changed", kinds)
}
if st.Worktree != submodule.WorktreeModified {
t.Errorf("Worktree = %v", st.Worktree)
}
}
// The ref of the superproject is the upstream of its branch, even if another
// remote branch is closer, so that it agrees with the header's ahead/behind.
func TestRootStatusRefIsUpstream(t *testing.T) {
p, root, bare := rootFixture(t)
other := filepath.Join(root, "other")
git(t, root, "clone", "-q", bare, other)
git(t, other, "switch", "-q", "-c", "feat")
git(t, other, "commit", "--allow-empty", "-m", "Feature")
git(t, other, "push", "-q", "origin", "feat")
git(t, other, "switch", "-q", "main")
for range 3 {
git(t, other, "commit", "--allow-empty", "-m", "Upstream")
}
git(t, other, "push", "-q", "origin", "main")
git(t, p.root, "fetch", "-q")
st := rootStatus(t, p)
if st.Pin.Ref != "origin/main" || st.Pin.Source != submodule.RefFromCurrentBranch {
t.Errorf("ref = %q (%v), want origin/main via the current branch", st.Pin.Ref, st.Pin.Source)
}
if st.Pin.Ahead != 0 || st.Pin.Behind != 3 {
t.Errorf("ahead/behind = %d/%d, want 0/3", st.Pin.Ahead, st.Pin.Behind)
}
if len(st.Between) != 3 {
t.Errorf("Between = %d commits, want 3", len(st.Between))
}
}
func TestRootStatusDetached(t *testing.T) {
p, _, _ := rootFixture(t)
git(t, p.root, "switch", "-q", "--detach")
st := rootStatus(t, p)
if st.Checkout != submodule.CheckoutDetached {
t.Errorf("Checkout = %v, want detached", st.Checkout)
}
if !st.Pin.Pushed {
t.Error("detached HEAD on a pushed commit should be pushed")
}
}
+28
View File
@@ -42,8 +42,11 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
repo := &submodule.Repo{
Path: "~/coding/my_project",
Branch: "main",
Behind: 2,
FetchedAt: time.Now().Add(-2 * time.Minute),
}
repo.Root = &submodule.Submodule{Name: "my_project", URL: "git@github.com:acme/my_project.git"}
p.statuses[""] = rootStatus()
repo.Submodules = p.build(fixture(), nil)
return repo, nil
}
@@ -154,6 +157,31 @@ func changes(age time.Duration, files []submodule.FileChange) *submodule.Changes
return c
}
// rootStatus is a superproject that is behind its upstream and still has a
// clone of a submodule the current branch no longer has.
func rootStatus() *submodule.Status {
tip := commit("e4f5a6b", "Move docs into a submodule", "anna", 3*time.Hour)
head := commit("0a1b2c3", "Update README", "ben", 2*24*time.Hour)
st := &submodule.Status{
Root: true,
Worktree: submodule.WorktreeModified,
Pin: submodule.PinInfo{Ref: "origin/main", Behind: 2, Pushed: true, Source: submodule.RefFromCurrentBranch},
Pinned: head,
CheckedOut: head,
Tip: tip,
Between: []submodule.Commit{
*tip,
*commit("9d8c7b6", "Drop the legacy module", "chris", 20*time.Hour),
},
FetchedAt: now.Add(-2 * time.Minute),
}
st.Changes = changes(15*time.Minute, []submodule.FileChange{
{Path: "README.md", Kind: submodule.ChangeModified, Added: 4, Removed: 1},
{Path: "old_module/", Kind: submodule.ChangeUntracked, Repo: true},
})
return st
}
func fixture() []node {
c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour)
c2 := commit("a1b2c3d", "Initial import", "ben", 40*24*time.Hour)
+5
View File
@@ -16,6 +16,7 @@ type Repo struct {
Ahead int
Behind int
FetchedAt time.Time
Root *Submodule // the superproject as a selectable node; Path is "", no Children
Submodules []*Submodule
}
@@ -32,6 +33,9 @@ type Submodule struct {
FetchErr error // set if the last fetch failed; not an issue
}
// IsRoot reports whether s is the superproject itself.
func (s *Submodule) IsRoot() bool { return s != nil && s.Path == "" }
// Presence describes whether the submodule exists on disk.
type Presence int
@@ -171,6 +175,7 @@ func (c *Changes) Sort() {
// Status is the full state of one submodule.
type Status struct {
Root bool // the superproject: the "pin" is its HEAD
Presence Presence
Checkout Checkout
Worktree Worktree
+11 -3
View File
@@ -43,7 +43,11 @@ func (s *Status) Issues() []Issue {
var out []Issue
if !s.Pin.Pushed {
out = append(out, Issue{IssueUnpushed, LevelDanger, "pin not pushed to any remote"})
detail := "pin not pushed to any remote"
if s.Root {
detail = "HEAD not pushed to any remote"
}
out = append(out, Issue{IssueUnpushed, LevelDanger, detail})
}
if s.Worktree == WorktreeConflict {
out = append(out, Issue{IssueConflict, LevelDanger, "merge conflict"})
@@ -70,9 +74,13 @@ func (h Hidden) Total() int { return h.Warn + h.Danger }
// HiddenIssues counts the issues and staged pins of all loaded descendants. A
// descendant whose load failed counts as one danger, since its state is
// unknown.
func (m *Submodule) HiddenIssues() Hidden {
func (m *Submodule) HiddenIssues() Hidden { return HiddenIn(m.Children) }
// HiddenIn counts the issues and staged pins of mods and all their loaded
// descendants, like HiddenIssues.
func HiddenIn(mods []*Submodule) Hidden {
var h Hidden
Walk(m.Children, func(c *Submodule) {
Walk(mods, func(c *Submodule) {
if c.Status == nil && c.Err != nil {
h.Danger++
}
+11
View File
@@ -78,3 +78,14 @@ func TestHiddenIssuesCountLoadErrors(t *testing.T) {
t.Errorf("got %+v; want 1 danger", got)
}
}
func TestRootIssueWording(t *testing.T) {
st := &Status{Root: true}
got := st.Issues()
if len(got) != 1 || got[0].Kind != IssueUnpushed || got[0].Level != LevelDanger || got[0].Detail != "HEAD not pushed to any remote" {
t.Errorf("Issues() = %+v", got)
}
if !(&Submodule{}).IsRoot() || (&Submodule{Path: "a"}).IsRoot() {
t.Error("IsRoot should be true exactly for the empty path")
}
}
+18 -5
View File
@@ -166,7 +166,7 @@ func (m Model) busy() bool {
if m.repo == nil || m.fetch.Running {
return true
}
loading := false
loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
loading = loading || (s.Status == nil && s.Err == nil)
})
@@ -267,7 +267,7 @@ func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
m.byPath = map[string]*submodule.Submodule{}
reload := initial || m.reloadStatuses
m.reloadStatuses = false
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
track := func(s *submodule.Submodule) {
m.byPath[s.Path] = s
if o, ok := old[s.Path]; ok {
s.Status, s.Err, s.FetchErr = o.Status, o.Err, o.FetchErr
@@ -275,7 +275,12 @@ func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
if reload || s.Status == nil {
cmds = append(cmds, m.reloadStatus(s.Path))
}
})
}
if m.repo.Root != nil {
track(m.repo.Root)
}
submodule.Walk(m.repo.Submodules, track)
m.tree.SetRoot(m.repo.Root)
if initial {
m.tree.SetRoots(m.repo.Submodules)
@@ -305,6 +310,10 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
// superproject: pins of all submodules may have moved (local), or
// only its header (remote); .gitmodules: the structure changed
cmds = append(cmds, m.reloadTree(c.Local))
if c.Path == "" {
// the root's own state: files, HEAD, and (for a fetch) its upstream
cmds = append(cmds, m.reloadStatus(""))
}
default:
s, ok := m.byPath[c.Path]
if !ok {
@@ -444,6 +453,9 @@ func (m Model) copyPath() (tea.Model, tea.Cmd) {
return m, nil
}
path := s.Path
if s.IsRoot() && m.repo != nil {
path = m.repo.Path
}
if l, ok := m.provider.(submodule.Locator); ok {
if dir := l.Dir(s.Path); dir != "" {
path = dir
@@ -488,11 +500,12 @@ func (m Model) View() string {
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(m.theme.Border).
PaddingLeft(1).
Render(m.detail.View(m.tree.Selected()))
Render(m.detail.View(m.tree.Selected(), m.repo))
body = lipgloss.JoinHorizontal(lipgloss.Top, body, pane)
}
parts := []string{header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width), body}
head := header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width, m.tree.RootSelected())
parts := []string{zone.Mark(m.tree.RootZone(), head), body}
if m.filtering {
parts = append(parts, m.filterView())
}
+67 -31
View File
@@ -40,13 +40,15 @@ func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
func (m Model) View(s *submodule.Submodule) string {
// View renders s; repo is the loaded superproject (nil while loading), which
// the superproject's own view needs for its path and submodule summary.
func (m Model) View(s *submodule.Submodule, repo *submodule.Repo) string {
if m.width <= 0 || m.height <= 0 {
return ""
}
var lines []string
if s != nil {
lines = m.lines(s)
lines = m.lines(s, repo)
}
for i, l := range lines {
lines[i] = ansi.Truncate(l, m.width, "…")
@@ -68,12 +70,17 @@ func (m Model) View(s *submodule.Submodule) string {
return body + "\n" + palette
}
func (m Model) lines(s *submodule.Submodule) []string {
func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
t := m.theme
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
label := func(l string) string { return muted.Width(labelWidth).Render(l) }
dir := path.Dir(s.Path)
root := s.IsRoot() && repo != nil
full := s.Path
if root {
full = repo.Path
}
dir := path.Dir(full)
if dir == "." {
dir = ""
} else {
@@ -104,7 +111,11 @@ func (m Model) lines(s *submodule.Submodule) []string {
out = append(out, theme.Fg(t.Info).Width(labelWidth).Render(theme.Staged)+text.Render("pin change staged, not committed"))
}
if st.Checkout == submodule.CheckoutDetached {
out = append(out, label("")+muted.Render("detached HEAD on pin"))
text := "detached HEAD on pin"
if st.Root {
text = "detached HEAD"
}
out = append(out, label("")+muted.Render(text))
}
if st.Changes != nil {
out = append(out, "")
@@ -141,15 +152,19 @@ func (m Model) lines(s *submodule.Submodule) []string {
}
return label(name) + shaStyle.Render(c.SHA) + " " + text.Render(c.Subject)
}
out = append(out,
commitLine("Pinned", st.Pinned, false),
commitLine("Checkout", st.CheckedOut, st.Checkout == submodule.CheckoutDiffers),
commitLine("Tip", st.Tip, false),
)
if st.Root {
out = append(out, commitLine("HEAD", st.Pinned, false), commitLine("Tip", st.Tip, false))
} else {
out = append(out,
commitLine("Pinned", st.Pinned, false),
commitLine("Checkout", st.CheckedOut, st.Checkout == submodule.CheckoutDiffers),
commitLine("Tip", st.Tip, false),
)
}
// difference between pin and tip
if len(st.Between) > 0 {
out = append(out, "", text.Render(m.betweenTitle(st.Pin)))
out = append(out, "", text.Render(m.betweenTitle(st.Pin, st.Root)))
for _, c := range st.Between {
out = append(out,
" "+theme.Fg(t.Primary).Render(c.SHA)+" "+text.Render(c.Subject),
@@ -160,29 +175,50 @@ func (m Model) lines(s *submodule.Submodule) []string {
// children & meta
out = append(out, "")
if n := len(s.Children); n > 0 {
if root {
n := 0
submodule.Walk(repo.Submodules, func(*submodule.Submodule) { n++ })
summary := fmt.Sprintf("%d", n)
if parts := m.hiddenParts(submodule.HiddenIn(repo.Submodules)); parts != "" {
summary += muted.Render(" · ") + parts
}
out = append(out, label("Modules")+summary)
} else if n := len(s.Children); n > 0 {
children := fmt.Sprintf("%d direct", n)
h := s.HiddenIssues()
var parts []string
if h.Danger > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render(fmt.Sprintf("%d error(s)", h.Danger)))
}
if h.Warn > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
}
if h.Staged > 0 {
parts = append(parts, theme.Fg(t.Info).Render(fmt.Sprintf("%d %s", h.Staged, theme.Staged)))
}
if len(parts) > 0 {
children += muted.Render(" · ") + strings.Join(parts, muted.Render(", ")) + muted.Render(" below")
if parts := m.hiddenParts(s.HiddenIssues()); parts != "" {
children += muted.Render(" · ") + parts + muted.Render(" below")
}
out = append(out, label("Children")+children)
}
out = append(out, label("URL")+muted.Render(s.URL))
if s.URL != "" {
out = append(out, label("URL")+muted.Render(s.URL))
}
return out
}
func (m Model) betweenTitle(p submodule.PinInfo) string {
// hiddenParts words issue counts like "1 error(s), 2 warning(s)"; empty if
// there are none.
func (m Model) hiddenParts(h submodule.Hidden) string {
t := m.theme
var parts []string
if h.Danger > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render(fmt.Sprintf("%d error(s)", h.Danger)))
}
if h.Warn > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
}
if h.Staged > 0 {
parts = append(parts, theme.Fg(t.Info).Render(fmt.Sprintf("%d %s", h.Staged, theme.Staged)))
}
return strings.Join(parts, theme.Fg(t.Muted).Render(", "))
}
// betweenTitle words the commit list; root says HEAD instead of Pin.
func (m Model) betweenTitle(p submodule.PinInfo, root bool) string {
subject := "Pin"
if root {
subject = "HEAD"
}
plural := func(n int) string {
if n == 1 {
return "commit"
@@ -191,11 +227,11 @@ func (m Model) betweenTitle(p submodule.PinInfo) string {
}
switch {
case p.Ahead > 0 && p.Behind > 0:
return fmt.Sprintf("Pin and %s have diverged (%s%d %s%d):",
p.Ref, m.theme.Glyphs.Ahead, p.Ahead, m.theme.Glyphs.Behind, p.Behind)
return fmt.Sprintf("%s and %s have diverged (%s%d %s%d):",
subject, p.Ref, m.theme.Glyphs.Ahead, p.Ahead, m.theme.Glyphs.Behind, p.Behind)
case p.Ahead > 0:
return fmt.Sprintf("Pin is %d %s ahead of %s:", p.Ahead, plural(p.Ahead), p.Ref)
return fmt.Sprintf("%s is %d %s ahead of %s:", subject, p.Ahead, plural(p.Ahead), p.Ref)
default:
return fmt.Sprintf("Pin is %d %s behind %s:", p.Behind, plural(p.Behind), p.Ref)
return fmt.Sprintf("%s is %d %s behind %s:", subject, p.Behind, plural(p.Behind), p.Ref)
}
}
+9 -2
View File
@@ -21,9 +21,13 @@ type Fetch struct {
}
// View renders repo info, state counts and fetch state on one line. spinner
// is the current spinner frame.
func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, width int) string {
// is the current spinner frame. The header stands for the superproject: with
// selected it is drawn like a selected tree row.
func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, width int, selected bool) string {
bar := lipgloss.NewStyle().Background(t.Bar)
if selected {
bar = lipgloss.NewStyle().Background(t.Selected).Bold(true)
}
seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) }
if repo == nil {
@@ -34,6 +38,9 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
if d := t.Delta(submodule.PinInfo{Ahead: repo.Ahead, Behind: repo.Behind, Pushed: true}, bar); d != "" {
left += seg(t.Text, " ") + d
}
if repo.Root != nil {
left += seg(t.Text, " ") + t.StatusWord(repo.Root, bar, spinner)
}
// count submodules per issue kind (most severe first), plus staged pins
var total, loading, errored, staged int
+25
View File
@@ -32,6 +32,31 @@ func IssueWord(k submodule.IssueKind) string {
}
}
// StatusWord renders the state of s as one word: a spinner while loading,
// `error`, the most severe issue with "+n" for further ones, `staged`, or a
// muted "in sync". base is applied to every segment, e.g. to keep a row
// background.
func (t Theme) StatusWord(s *submodule.Submodule, base lipgloss.Style, spinner string) string {
seg := func(c lipgloss.TerminalColor, text string) string { return base.Foreground(c).Render(text) }
switch {
case s.Err != nil:
return t.LevelStyle(submodule.LevelDanger, base).Render("error")
case s.Status == nil:
return seg(t.Primary, spinner)
}
if issues := s.Status.Issues(); len(issues) > 0 {
out := t.LevelStyle(issues[0].Level, base).Render(IssueWord(issues[0].Kind))
if len(issues) > 1 {
out += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
}
return out
}
if s.Status.PinStaged {
return seg(t.Info, Staged)
}
return seg(t.Muted, InSync)
}
// ChangeLetter is the one-letter label of a changed file.
func ChangeLetter(k submodule.ChangeKind) string {
switch k {
+7
View File
@@ -48,6 +48,10 @@ func parentOf(s *submodule.Submodule) *submodule.Submodule {
// visible any more, the closest visible one after it in display order
// becomes the selection, else the closest one before it.
func (m *Model) reselect(old *submodule.Submodule) {
if old != nil && old == m.root { // the superproject is never filtered out
m.cursor = old
return
}
visible := map[*submodule.Submodule]bool{}
var first *submodule.Submodule
for _, r := range m.rows() {
@@ -63,6 +67,9 @@ func (m *Model) reselect(old *submodule.Submodule) {
return
}
m.cursor = first
if first == nil {
m.cursor = m.root // nothing matches: the superproject remains
}
if old == nil || first == nil {
return
}
+62 -24
View File
@@ -23,6 +23,7 @@ const (
type Model struct {
id string // zone prefix, unique to this instance
theme theme.Theme
root *submodule.Submodule // the superproject: selectable, above the first row; may be nil
roots []*submodule.Submodule
expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse
filter string // lower-case; empty if not filtering
@@ -37,12 +38,21 @@ func New(t theme.Theme) Model {
return Model{id: zone.NewPrefix(), theme: t, expanded: map[string]bool{}}
}
// SetRoots replaces the tree. All nodes start collapsed.
// SetRoot sets the superproject, which can be selected like a row above the
// first one. Call it before SetRoots / ReplaceRoots.
func (m *Model) SetRoot(root *submodule.Submodule) { m.root = root }
// SetRoots replaces the tree. All nodes start collapsed. The superproject is
// selected if there is one, else the first submodule.
func (m *Model) SetRoots(roots []*submodule.Submodule) {
m.roots = roots
m.expanded = map[string]bool{}
m.cursor = nil
m.offset = 0
if m.root != nil {
m.cursor = m.root
return
}
m.cursor = m.firstNode()
}
@@ -54,10 +64,14 @@ func (m *Model) ReplaceRoots(roots []*submodule.Submodule) {
if m.cursor != nil {
path = m.cursor.Path
}
wasRoot := m.cursor.IsRoot() // by path: SetRoot may have installed a new node
m.roots = roots
m.cursor = nil
if wasRoot && m.root != nil {
m.cursor = m.root
}
for _, r := range m.rows() {
if r.kind == rowNode && r.node.Path == path {
if m.cursor == nil && r.kind == rowNode && r.node.Path == path {
m.cursor = r.node
break
}
@@ -76,9 +90,19 @@ func (m *Model) SetSize(w, h int) {
// SetSpinner sets the frame drawn for submodules that are still loading.
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
// Selected returns the submodule under the cursor, or nil.
// Selected returns the submodule under the cursor, or nil. It is the
// superproject (empty Path) while that is selected.
func (m Model) Selected() *submodule.Submodule { return m.cursor }
// RootSelected reports whether the superproject is selected.
func (m Model) RootSelected() bool { return m.onRoot() }
func (m Model) onRoot() bool { return m.root != nil && m.cursor == m.root }
// RootZone is the zone mark of the superproject's display; a click on it
// selects the superproject. The caller wraps the header in it.
func (m Model) RootZone() string { return m.id + "root" }
// --- navigation --------------------------------------------------------------
// Up selects the previous submodule row.
@@ -104,6 +128,10 @@ func (m *Model) Toggle() {
// Right expands the selected submodule and selects its first child.
func (m *Model) Right() {
if m.onRoot() {
m.Down()
return
}
if m.cursor == nil || len(m.cursor.Children) == 0 {
return
}
@@ -120,6 +148,9 @@ func (m *Model) Left() {
m.setExpanded(m.cursor, false)
case m.cursor.Parent != nil:
m.setExpanded(m.cursor.Parent, false)
case m.root != nil && !m.onRoot():
m.cursor = m.root // the superproject is the parent of the top level
m.offset = 0
}
}
@@ -179,24 +210,37 @@ func isAncestor(a, s *submodule.Submodule) bool {
}
// moveBy moves the cursor by n submodule rows, skipping directory headers
// and stopping at the ends.
// and stopping at the ends. The superproject counts as a row before the
// first one.
func (m *Model) moveBy(n int) {
rows := m.rows()
i := m.cursorRow(rows)
if i < 0 {
if m.onRoot() {
i = -1
} else if i < 0 {
return
}
step := 1
if n < 0 {
step, n = -1, -n
}
for j := i + step; n > 0 && j >= 0 && j < len(rows); j += step {
if rows[j].kind == rowNode {
for j := i + step; n > 0 && j >= -1 && j < len(rows); j += step {
switch {
case j == -1:
if m.root != nil {
i = -1
n--
}
case rows[j].kind == rowNode:
i = j
n--
}
}
m.cursor = rows[i].node
if i < 0 {
m.cursor = m.root
} else {
m.cursor = rows[i].node
}
m.clampOffset()
}
@@ -233,6 +277,10 @@ func (m *Model) clampOffset() {
if m.height <= 0 {
return
}
if m.onRoot() {
m.offset = 0
return
}
rows := m.rows()
i := m.cursorRow(rows)
if i < 0 {
@@ -269,6 +317,11 @@ func (m *Model) Update(msg tea.Msg) {
if mm.Action != tea.MouseActionPress || mm.Button != tea.MouseButtonLeft {
return
}
if m.root != nil && zone.Get(m.RootZone()).InBounds(mm) {
m.cursor = m.root
m.offset = 0
return
}
for _, r := range m.visibleRows() {
if r.kind != rowNode {
continue
@@ -416,22 +469,7 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
staged := s.Status != nil && s.Status.PinStaged
h := s.HiddenIssues()
stagedMark := staged && len(issues) > 0
var status string
switch {
case s.Err != nil:
status = t.LevelStyle(submodule.LevelDanger, base).Render("error")
case s.Status == nil:
status = seg(t.Primary, m.spinner)
case len(issues) > 0:
status = t.LevelStyle(issues[0].Level, base).Render(theme.IssueWord(issues[0].Kind))
if len(issues) > 1 {
status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
}
case staged:
status = seg(t.Info, theme.Staged)
default:
status = seg(t.Muted, theme.InSync)
}
status := t.StatusWord(s, base, m.spinner)
if stagedMark || collapsed && h.Staged > 0 {
status += pad(1) + seg(t.Info, t.Glyphs.Staged)
}
+106
View File
@@ -305,3 +305,109 @@ func equal(a, b []string) bool {
}
return true
}
// newTreeWithRoot is newTree with a selectable superproject.
func newTreeWithRoot(t *testing.T) *Model {
t.Helper()
m := New(theme.Default())
m.SetRoot(&submodule.Submodule{Name: "root"})
m.SetRoots(fixture())
m.SetSize(80, 20)
return &m
}
func TestRootIsSelectedInitially(t *testing.T) {
m := newTreeWithRoot(t)
if !m.RootSelected() {
t.Fatal("root not selected initially")
}
assertCursor(t, m, "")
}
func TestRootIsAboveFirstRow(t *testing.T) {
m := newTreeWithRoot(t)
m.Down()
assertCursor(t, m, "a")
if m.RootSelected() {
t.Error("root still selected after Down")
}
m.Up()
if !m.RootSelected() {
t.Error("Up from the first submodule should select the root")
}
m.Up() // stays
if !m.RootSelected() {
t.Error("Up on the root should stay on the root")
}
m.End()
assertCursor(t, m, "f")
m.Home()
if !m.RootSelected() {
t.Error("Home should select the root")
}
m.PageDown()
assertCursor(t, m, "f")
m.PageUp()
if !m.RootSelected() {
t.Error("PageUp should reach the root")
}
}
func TestRootNavigationKeys(t *testing.T) {
m := newTreeWithRoot(t)
m.Right() // like Down
assertCursor(t, m, "a")
m.Left() // top level, nothing to collapse: the parent is the root
if !m.RootSelected() {
t.Error("Left on a top-level submodule should select the root")
}
m.Left()
m.Toggle()
m.CollapseAll()
m.ExpandAll()
if !m.RootSelected() {
t.Error("fold keys must not move the root selection")
}
m.Down()
m.Down() // b
m.Right()
m.Right() // d
m.CollapseAll()
assertCursor(t, m, "b")
}
func TestReplaceRootsKeepsRootSelected(t *testing.T) {
m := newTreeWithRoot(t)
newRoot := &submodule.Submodule{Name: "root"}
m.SetRoot(newRoot)
m.ReplaceRoots(fixture())
if !m.RootSelected() || m.Selected() != newRoot {
t.Error("root selection should move to the reloaded root")
}
m.Down()
m.SetRoot(&submodule.Submodule{Name: "root"})
m.ReplaceRoots(fixture())
assertCursor(t, m, "a")
}
func TestFilterKeepsRoot(t *testing.T) {
m := newTreeWithRoot(t)
m.SetFilter("f")
if !m.RootSelected() {
t.Error("the root stays selected while filtering")
}
m.Down()
assertCursor(t, m, "f")
m.SetFilter("zzz") // nothing matches: the root remains, not nil
if !m.RootSelected() {
t.Error("no match should fall back to the root")
}
m.SetFilter("")
if !m.RootSelected() {
t.Error("closing the filter should keep the root")
}
}