diff --git a/docs/README.md b/docs/README.md index f6a319a..cf9af88 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,8 @@ # submarine — documentation submarine is a terminal UI for git repositories with many (recursively nested) -submodules. It shows **only the submodules**, as a collapsible tree, and +submodules. It shows **the submodules**, as a collapsible tree (plus the state +of the superproject itself in the header), and describes each pin as a **branch plus distance** (`origin/main ↓1`) instead of a bare commit hash. diff --git a/docs/decisions.md b/docs/decisions.md index 82e0a58..5da68b9 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -258,3 +258,27 @@ one word, D3). It is not counted in `+n`, because `+n` and the header count issues and a staged pin stays quiet (D4). A collapsed row shows `●` if a staged pin is hidden below it (`Hidden.Staged`), placed before the `·k` counts so truncation of the 16-column cell cuts the counts first. + +## Superproject is selectable in the header + +The state of the root repository was invisible: a submodule the current +branch no longer has stays on disk as an untracked repository, root files +can be modified, HEAD can be unpushed. The root becomes a node with an +empty path (the convention `Fetcher`/`Change` already use), selected on +startup, and the **header** is its row: it already holds the root's path, +branch and ahead/behind, and a tree row would cost a line and an indent +level for an item that is not part of the tree. Selecting the header shows +the usual detail pane; the header also shows the root's state word, so a +problem is visible while a submodule is selected. + +- *Changes ignore submodules* (`--ignore-submodules=all`): off-pin and staged + pins already have their rows; counting them again would make the root + `changed` in most real repositories. +- *Unpushed stays Danger*: same issue kind and level as a submodule, one + rule for "work that exists only here" (D4). +- *The ref is the upstream*, not the closest branch: the header shows + ahead/behind of the upstream, and "up to date" means relative to the + tracked branch. A nearer feature branch would make the two disagree. + Without an upstream the usual resolution applies. +- The root is not in `Walk`, so counts, fetch and loading logic stay + submodule-only; the app tracks it by the path `""`. diff --git a/docs/requirements.md b/docs/requirements.md index d4e3bb1..e74293e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -11,7 +11,8 @@ commit hashes. It doesn't answer the questions users actually have: ## Goals -- Show **only submodules** (not files), recursively nested, as a tree. +- Show **only submodules** (not files), recursively nested, as a tree; the + superproject itself is shown by the header, which is selectable. - Describe pins as **ref + distance** instead of a commit hash. - Let **problems stand out** and keep healthy submodules quiet. - Work comfortably with **dozens to hundreds** of submodules. @@ -56,7 +57,7 @@ commit hashes. It doesn't answer the questions users actually have: ### Detail pane -- **F20** Shows the selected submodule: full path, name, all issues in +- **F20** Shows the selected submodule (or the superproject): full path, name, all issues in words, staged-pin and detached-HEAD notes. - **F21** Shows the resolved ref, how it was resolved, and three commits: **Pinned**, **Checkout** and **Tip** (SHA + subject). A checkout that @@ -72,10 +73,19 @@ commit hashes. It doesn't answer the questions users actually have: shown; the rest is summarized as "n more" with their kinds. Staged and unstaged changes are combined. +- **F25** The superproject is selectable (the header; selected on startup) + and has a state of its own, so a stale checkout shows up: files changed + or untracked, untracked repositories (e.g. a submodule clone the current + branch no longer has), conflicts, and HEAD not pushed. Its HEAD is compared + with the upstream of the branch. Submodules do not count for it, their rows + show them. The detail pane shows it like a submodule (HEAD and Tip instead + of Pinned and Checkout, a summary of all submodules instead of Children). + See [state-model.md](state-model.md#superproject). + ### Header and footer - **F30** The header shows the superproject path, its branch and - ahead/behind, the number of submodules, a count per issue kind (most + ahead/behind, its own state word (**F25**), the number of submodules, a count per issue kind (most severe first), staged pins, how many are still loading, and the time since the last fetch. - **F31** The footer shows key hints; `?` toggles the full help. It @@ -108,7 +118,7 @@ commit hashes. It doesn't answer the questions users actually have: A text input above the footer takes the term; `esc` closes it and removes the filter. A selection that is filtered out moves to the next visible submodule (none if nothing matches); closing the filter keeps the - selection. See [Filter](ux-guide.md#filter). + selection (the superproject if none is left). See [Filter](ux-guide.md#filter). ### Actions (planned, not implemented) diff --git a/docs/state-model.md b/docs/state-model.md index 9362670..e9827cd 100644 --- a/docs/state-model.md +++ b/docs/state-model.md @@ -103,6 +103,24 @@ per level, red first, then yellow, so a danger can never hide inside a collapsed subtree and a warning is never mistaken for one. A descendant whose load failed (`error`) counts as one danger. +## Superproject + +The superproject is a node with an empty `Path` (`Repo.Root`, no children; +`Submodule.IsRoot()`) and a `Status` with `Root` set. It reuses the pin +fields with **pin = HEAD**: `Pinned` and `CheckedOut` are HEAD, `Checkout` +is never `CheckoutDiffers` (`CheckoutDetached` for a detached HEAD), +`PinStaged` is false. `Issues()` is the same code; only `unpushed` says +"HEAD not pushed to any remote" (Danger, like a submodule's pin). + +- **Ref** is the upstream of the current branch (`RefFromCurrentBranch`), so + ahead/behind agrees with the header. Closest-branch resolution is only the + fallback without an upstream (detached, or no upstream configured). +- **Changes** ignore submodules (`status --ignore-submodules=all`): an off-pin + or staged submodule is shown by its own row, not twice. A leftover + directory of a submodule the branch no longer has is an untracked + repository. +- `Walk` and the header counts do not include the root. + ## Resolving a pin to a ref The pin is displayed as a ref, not a hash. A commit can be on several diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 5950261..40fac47 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -75,6 +75,11 @@ indent ▸ name [n] status ref delta ### Detail pane +For the superproject (header selected), `Pinned` / `Checkout` become one +`HEAD` line, the commit list says "HEAD is n commits behind …", and +`Children` becomes `Modules` (count and issues of all submodules). See +[Superproject](state-model.md#superproject). + 1. Directory (muted) and name (bold) 2. One line per issue: word (colored, 10 columns) + explanation; `in sync nothing to do` if there are none and no pin is staged; @@ -136,7 +141,13 @@ Changes 5 files · 2M 1A 1D 1R · +37 −12 - Untracked directories are shown as one entry (`tmp/`). ### Header -`path branch ↑↓ · n submodules · n error · … · n pin staged · n loading · ` +The header is the superproject's row: it is selected on startup and drawn +with the selected background (bold) while selected. After the branch it +shows the superproject's own state word like a tree row (`in sync`, +`changed`, `unpushed +1`, spinner, `error`). The counts that follow are +about submodules only. + +`path branch ↑↓ state · n submodules · n error · … · n pin staged · n loading · ` Issue counts are ordered by severity (unpushed, conflict, missing, off pin, changed), each in its level color. Zero counts are omitted. `n loading` @@ -195,13 +206,13 @@ colors in components. | Key | Behavior | |---|---| -| `↑` / `k`, `↓` / `j` | Previous / next submodule; directory headers are skipped | +| `↑` / `k`, `↓` / `j` | Previous / next submodule; directory headers are skipped. `↑` from the first submodule selects the header (superproject) | | `space` | Toggle expand/collapse of the selected submodule | -| `→` / `l` | Collapsed with children: expand and select the first child. Expanded: select the first child. Leaf: nothing | -| `←` / `h` | Expanded: collapse it (stays selected). Otherwise: collapse the parent and select it. Top level: nothing | +| `→` / `l` | Header selected: first submodule. Collapsed with children: expand and select the first child. Expanded: select the first child. Leaf: nothing | +| `←` / `h` | Expanded: collapse it (stays selected). Otherwise: collapse the parent and select it. Top level: select the header (superproject) | | `L` / `shift+→` / `ctrl+→` | Expand all; then follow the first child from the selection down to a submodule without children | | `H` / `shift+←` / `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor | -| `home` / `g`, `end` / `G` | First / last submodule | +| `home` / `g`, `end` / `G` | Header (superproject) / last submodule | | `pgup`, `pgdn` | Move by one screen | | `?` | Toggle full help | | `q`, `ctrl+c` | Quit | @@ -235,6 +246,7 @@ match vanish. If nothing matches the tree says `no submodule matches`. `/` edits the term again. - **`esc`** closes the input and removes the filter, in both modes. The selection stays; its ancestors are expanded so it is still visible. +- The superproject is never filtered out; if nothing matches it is selected. - The filter line shows the keys that apply on its right. - The header counts are not filtered. @@ -252,7 +264,7 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette | `←` / `h`, `→` / `l` | Fold / unfold, parent / first child | Navigation | Bar, Help | yes | | `H` / `⇧←`, `L` / `⇧→` | Collapse / expand all | Navigation | Bar, Help | yes | | `space` | Toggle expand/collapse | Navigation | Bar, Help | yes | -| `home` / `g`, `end` / `G` | First / last submodule | Navigation | Help | yes | +| `home` / `g`, `end` / `G` | Header / last submodule | Navigation | Help | yes | | `pgup`, `pgdn` | Move by one screen | Navigation | Help | yes | | `c` | Copy path | Submodule | Palette, Help | yes | | `u` | Checkout pin | Submodule | Help | no (flashes "not implemented") | @@ -266,7 +278,7 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette ### Mouse -- Click a row: select it. +- Click a row: select it. Click the header: select the superproject. - Click `▸` / `▾`: select and toggle. - Scroll wheel: move the selection. diff --git a/internal/submodule/git/provider.go b/internal/submodule/git/provider.go index f769582..1c5e625 100644 --- a/internal/submodule/git/provider.go +++ b/internal/submodule/git/provider.go @@ -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 diff --git a/internal/submodule/git/root_test.go b/internal/submodule/git/root_test.go new file mode 100644 index 0000000..26a0404 --- /dev/null +++ b/internal/submodule/git/root_test.go @@ -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") + } +} diff --git a/internal/submodule/mock/mock.go b/internal/submodule/mock/mock.go index afd8178..9cbddce 100644 --- a/internal/submodule/mock/mock.go +++ b/internal/submodule/mock/mock.go @@ -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) diff --git a/internal/submodule/model.go b/internal/submodule/model.go index ed3b455..db6150c 100644 --- a/internal/submodule/model.go +++ b/internal/submodule/model.go @@ -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 diff --git a/internal/submodule/status.go b/internal/submodule/status.go index c10bafd..4055f69 100644 --- a/internal/submodule/status.go +++ b/internal/submodule/status.go @@ -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++ } diff --git a/internal/submodule/status_test.go b/internal/submodule/status_test.go index 6a4ad3b..09d3fdb 100644 --- a/internal/submodule/status_test.go +++ b/internal/submodule/status_test.go @@ -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") + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index bcdcd72..d2603ef 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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()) } diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go index f3b3aab..61bd01a 100644 --- a/internal/tui/detail/detail.go +++ b/internal/tui/detail/detail.go @@ -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) } } diff --git a/internal/tui/header/header.go b/internal/tui/header/header.go index ade855d..d4dd3f3 100644 --- a/internal/tui/header/header.go +++ b/internal/tui/header/header.go @@ -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 diff --git a/internal/tui/theme/issues.go b/internal/tui/theme/issues.go index e057ddd..778752b 100644 --- a/internal/tui/theme/issues.go +++ b/internal/tui/theme/issues.go @@ -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 { diff --git a/internal/tui/tree/filter.go b/internal/tui/tree/filter.go index 9145939..929109f 100644 --- a/internal/tui/tree/filter.go +++ b/internal/tui/tree/filter.go @@ -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 } diff --git a/internal/tui/tree/tree.go b/internal/tui/tree/tree.go index 6038c98..f5d2270 100644 --- a/internal/tui/tree/tree.go +++ b/internal/tui/tree/tree.go @@ -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) } diff --git a/internal/tui/tree/tree_test.go b/internal/tui/tree/tree_test.go index 6bb5bfa..ac59dde 100644 --- a/internal/tui/tree/tree_test.go +++ b/internal/tui/tree/tree_test.go @@ -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") + } +}