Archived
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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user