Show the pin, tip and checkout as a spine in the detail pane

Replace the Pinned/Checkout/Tip rows and the list of commits between pin
and tip with one line of history, newest on top: a node per commit with
its roles (pin, tip, you, staged, HEAD) and the number of commits between
two nodes. A staged pin shows the committed pin as its own node and notes
when it moves back. All three on one commit read "in sync".

Add Status.Drift, Committed and CommittedDrift and Status.Spine(); drop
Status.Between and Repo.FetchedAt. The fetch age moves from the header to
the detail pane (right-aligned in the name's line). The header summary is
right-aligned, ordered by importance and cut with an ellipsis if too narrow.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 16:34:14 +02:00
co-authored by Claude Sonnet 5.5
parent aeedfe96d4
commit 4c1be3f9ab
19 changed files with 714 additions and 193 deletions
+6 -7
View File
@@ -59,8 +59,8 @@ sub --mock # built-in sample data, no repository needed
Problems stand out: `unpushed` in red, `off pin` and `changed` in yellow,
everything else muted. Select a submodule to see the details on the right:
every issue in words, the resolved ref and why, the pinned, checked-out and
tip commits, and a preview of uncommitted changes.
every issue in words, the resolved ref and why, where the pin, the tip and
your checkout are in the history, and a preview of uncommitted changes.
```
src/ │ src/
@@ -68,11 +68,10 @@ tip commits, and a preview of uncommitted changes.
▸ net [2] unpushed origin/main ↑1│
ui in sync origin/main │ unpushed pin not pushed to any remote
│
│ Ref origin/main ↑1
│ via checked out branch
│ Pinned b7c8d9e Hotfix null pointer
│ Checkout b7c8d9e Hotfix null pointer
│ Tip 6f5e4d3 Release 2.1.0
│ Ref origin/main · via checked out branch
│ pin · you ● b7c8d9e Hotfix null pointer
│ ┊ 1 commit
│ tip ● 6f5e4d3 Release 2.1.0
```
### Jump to a submodule with `/`
+2 -1
View File
@@ -114,7 +114,8 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps
`Status` calls run at once; the others wait on a semaphore and still
respect `ctx` (D16).
- Superproject: branch from `symbolic-ref`, ahead/behind against
`@{upstream}`, "fetched" from the mtime of `FETCH_HEAD`.
`@{upstream}`. Its fetch age (mtime of `FETCH_HEAD`) is `Status.FetchedAt`
of the root node, like a submodule's.
- `Fetch`: `git fetch --all --prune --no-recurse-submodules --quiet` (we
fetch every submodule ourselves), at most 8 at once, 2 min timeout each.
To never prompt, `GIT_SSH_COMMAND="ssh -o BatchMode=yes"` is set unless
+31 -3
View File
@@ -18,7 +18,7 @@ transparent.
pin, so a mismatch was invisible.
**Decision.** Model all three commits. The tree's ref column always describes
the **pin**; checkout drift is an issue (`off pin`).
**Consequences.** The detail pane shows Pinned / Checkout / Tip rows.
**Consequences.** The detail pane shows all three (see D25).
## D3 — Raw dimensions in the domain, summarized issues in the UI
@@ -303,8 +303,8 @@ not issues.
submodules that is hundreds of network round trips (and credential or
host-key failures) every time the tool opens, whether or not the user wants
fresh remote data.
**Decision.** Supersedes D18. Nothing is fetched on startup. The header and
the detail pane show the last fetch (`FETCH_HEAD`, also from fetches made in
**Decision.** Supersedes D18. Nothing is fetched on startup. The detail pane
shows the last fetch of the selected repository (`FETCH_HEAD`, also from fetches made in
other shells), so the user can judge how stale the remote refs are and press
`f` to fetch. The `--no-fetch` flag is removed with it (it still had the
side effect of switching off the update check; that has
@@ -386,3 +386,31 @@ restores below, without staging.
**Consequences.** `Switcher` is a new optional provider interface; the mock
does not implement it. The switch runs as the restore does (one write
operation at a time, same lines above the footer).
## D25 — Detail pane: a spine instead of three commit rows and a list
**Context.** `Pinned` / `Checkout` / `Tip` were three rows of SHAs to
compare, the distance sat on the `Ref` line, and a list of up to 50 commits
(two lines each) pushed the rest out of view. Where the pin, the checkout and
the tip are had to be worked out by reading.
**Decision.** Draw them as one line of history, newest on top: a node per
commit, the roles in the label column, the number of commits between two
nodes in a gap row. Roles on one commit share a node. The commit list is
dropped (no cap, no popup): the gap count says how far, not what changed.
The `Ref` line loses the distance suffix (the gap says it) and keeps
`via <rule>`.
**Consequences.** `Status.Between` is gone; `Status.Drift` (checkout ↔ pin,
one `rev-list --left-right --count`, only when off pin) and `Status.Spine()`
are new. Author and age of commits are no longer shown anywhere in the pane.
Diverged tip or checkout show `diverged ↑n ↓m` in the gap; drawing a real
fork needs the merge base and is left for later.
**Amendment (with D25).** The header no longer shows `fetched <age>`. It was
the superproject's own fetch, which the tree's `↓n` do not depend on, and read
as the age of everything. The age lives only in the detail pane, right-aligned
in the name's line (the superproject's when the header is selected); the
header keeps the progress and `n failed`. `Repo.FetchedAt` is gone.
**Amendment (with D25).** A staged pin shows both pins on the spine: `staged`
(the index) and `pin` (the superproject's HEAD, `Status.Committed`), so a
bump reads as a distance. Before, only the index pin was shown at all.
+12 -9
View File
@@ -59,11 +59,14 @@ commit hashes. It doesn't answer the questions users actually have:
- **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
differs from the pin is highlighted.
- **F22** Lists the commits between pin and tip (SHA, subject, author, age)
with a title stating ahead/behind/diverged.
- **F21** Shows the resolved ref, how it was resolved, and the **pin** (and
the **staged** pin, if there is one), the
**tip** and the checkout (**you**) as one line of history, newest on top:
a node per commit (SHA + subject) with its roles, and the number of
commits between two nodes. Roles on the same commit share a node. A
checkout that differs from the pin is highlighted.
- **F22** *(dropped)* The commits between pin and tip are not listed; only
their number is shown (F21).
- **F23** Shows child count, issues below, and the remote URL.
- **F24** For a submodule with uncommitted changes, previews the changed
files so the user can remember what they were doing: a flat list with a
@@ -78,8 +81,8 @@ commit hashes. It doesn't answer the questions users actually have:
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).
show them. The detail pane shows it like a submodule (HEAD and tip instead
of pin and you, a summary of all submodules instead of Children).
See [state-model.md](state-model.md#superproject).
### Header and footer
@@ -100,8 +103,8 @@ commit hashes. It doesn't answer the questions users actually have:
### Fetch and refresh
- **F50** Nothing is fetched on startup. The header and the detail pane show
when the repositories were last fetched (`FETCH_HEAD`), whoever did it.
- **F50** Nothing is fetched on startup. The detail pane shows
when the selected repository was last fetched (`FETCH_HEAD`), whoever did it.
`f` fetches the superproject and every initialized submodule in the
background (all remotes, with prune).
- **F51** While fetching, the header shows progress (`fetching 12/174`)
+27 -2
View File
@@ -31,7 +31,8 @@ in several at once.
| `PinStaged` | Pin changed in the superproject index but not committed |
| `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` |
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
| `Between` | Commits that make up the pin ↔ tip difference |
| `Drift` | Checkout compared with the pin: `Ahead` = commits only the checkout has, `Behind` = commits only the pin has |
| `Committed`, `CommittedDrift` | With a staged pin: the pin in the superproject's HEAD (nil if it is not on disk or the submodule was only added) and how it compares with the staged pin, like `Drift` |
| `Changes` | Changed files of the worktree (nil if clean), see below |
| `FetchedAt` | Last fetch of the submodule (`FETCH_HEAD` mtime); zero if never |
@@ -172,7 +173,8 @@ are local; `<pin>` is the index gitlink.
| `Pushed` | `for-each-ref --contains <pin> refs/remotes` is not empty |
| `Pin.Ref` | rules above; the remote is `origin`, else the first remote |
| `Ahead`, `Behind` | `rev-list --left-right --count <pin>...<ref>` |
| `Between` | `log <pin>...<ref>`, at most 50 commits |
| `Drift` | `rev-list --left-right --count <head>...<pin>`, only if the checkout differs from the pin |
| `Committed`, `CommittedDrift` | `ls-tree HEAD` gitlink, if it differs from the pin and exists locally; `rev-list --left-right --count <committed>...<pin>` |
Details:
@@ -196,6 +198,29 @@ Details:
fails with "pinned commit … not fetched" (shown as `error`) instead of
guessing `unpushed`.
## Spine
`Status.Spine()` arranges the pin, the tip and the checkout as one line of
history, newest on top, for the detail pane. Each node is a commit with its
roles (`pin`, `tip`, `you`; `HEAD` for the superproject). It uses `Pin.Ahead` /
`Pin.Behind` (pin ↔ tip) and `Drift` (checkout ↔ pin).
- Roles on the same commit share a node (`pin · tip · you`);
`SpineNode.InSync()` is true if all of them are on it (`HEAD` and `tip` for
the superproject).
- Tip or checkout *ahead* of the pin (it is their ancestor) sit above it,
*behind* below it; the `Link` between two nodes counts the commits
separating them. The count is the distance, so a tip 4 commits ahead
of the pin shows `4 commits`, and an adjacent one `1 commit`.
- A diverged tip is placed above the pin, a diverged checkout below it,
with a diverged `Link` (`Above` and `Below` count the commits only the
upper or lower node has). The link between a diverged node and a node other
than the pin is unknown (zero `Link`).
- With a staged pin the node of `Pinned` (the index) is labelled `staged`, and
the committed pin gets its own `pin` node, placed by `CommittedDrift` like
the checkout. Both can share nodes with `tip` and `you`.
- No tip (the pin resolved to a SHA) or no checkout commit: no node.
## Distance notation
| Display | Meaning |
+55 -21
View File
@@ -15,17 +15,17 @@
## Layout
```
~/coding/my_project main 13 submodules · 1 unpushed · 1 missing · 2 off pin · … fetched 2m ago
~/coding/my_project main 1 unpushed · 1 missing · 2 off pin · 2 changed · 13 submodules
docs origin/main │ src/
misc/tools/ │ my_submodule_3
my_submodule_1 origin/main │
my_submodule_2 missing a1b2c3d │ unpushed pin not pushed to any remote
src/ │
my_submodule_1 off pin origin/main │ Ref origin/main ↑1
▸ my_submodule_2 [2] changed ·2 origin/main │ via checked out branch
my_submodule_3 unpushed origin/main ↑1 │ Pinned b7c8d9e Hotfix null pointer
libs/ │ Checkout b7c8d9e Hotfix null pointer
logging origin/main ↓3 │ Tip 6f5e4d3 Release 2.1.0
my_submodule_1 off pin origin/main │ Ref origin/main · via checked out branch
▸ my_submodule_2 [2] changed ·2 origin/main │ pin · you ● b7c8d9e Hotfix null pointer
my_submodule_3 unpushed origin/main ↑1 │ ┊ 1 commit
libs/ │ tip ● 6f5e4d3 Release 2.1.0
logging origin/main ↓3 │
↑↓/jk move • ←→/hl fold • ⇧←→/HL fold all • space toggle • ? help • q quit
```
@@ -75,23 +75,53 @@ 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
For the superproject (header selected), the `pin` and `you` roles become
`HEAD`, and `Children` becomes `Modules` (count and issues of all submodules). See
[Superproject](state-model.md#superproject).
1. Directory (muted) and name (bold)
1. Directory (muted) and name (bold); `fetched <age>` of the submodule,
muted, right-aligned in the name's line (left out if the pane is too
narrow, or the submodule is missing)
2. One line per issue: word (colored, 10 columns) + explanation;
`in sync nothing to do` if there are none and no pin is staged;
`staged` and `detached HEAD on pin` as information
3. `Changes` (only with uncommitted changes), see
[Change preview](#change-preview)
4. `Ref` with distance and `via <rule>`; `Fetched <age>` of the submodule;
4. `Ref` with `· via <rule>`, a blank line, then the [spine](#spine);
`fetch failed: <git's message>` (yellow word) if the last fetch failed
5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
differs from the pin
6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
7. `Children` (count and issues below), `URL`
5. `Children` (count and issues below), `URL`
#### Spine
```
Ref origin/main · via .gitmodules branch
tip ● 3f1bf20 Fix: update package dependencies
┊ 4 commits
pin ● 9ac01de Add ethercat node
┊ 2 commits
you ● 48005ac Merge branch 'feat/ethercat…
```
- Newest on top, like `git log`: where each commit is, is read from its
position, not by comparing SHAs. The commits between two nodes are not
listed, only counted (`┊ 4 commits`; `1 commit` if adjacent).
- The label column holds the roles (`pin`, `tip`, `you`; `HEAD` for the
superproject), joined by ` · ` if they sit on one commit
(`pin · tip`, `pin · you`). If all of them sit on one commit it says
`in sync` instead, which also keeps the commit column in line with the
other values; the column only grows for a longer label.
- With a staged pin the spine shows both: `staged` (blue label) is the pin
in the index, `pin` the one in the superproject's HEAD, so the bump is
visible as a distance between two nodes. The committed pin is left out if
it is not on disk. If the staged pin is *older* than the committed one it
sits below it, and the gap above it says `(staged pin moves back)` (blue).
- The checkout (`you`) is yellow (label and SHA) when it is on its own node,
i.e. off pin. Everything else is the default text color, the gap muted.
- A diverged pair shows `┊ diverged ↑n ↓m` (arrows from the lower node's
view, like the tree). A real fork is not drawn yet.
- The nodes are `●` (`o` with `SUBMARINE_ASCII`), the gap `┊` (`:`).
- A missing submodule has only the pin node (SHA, no subject).
#### Change preview
@@ -147,7 +177,11 @@ 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 · <count> <issue>… · n pin staged · n loading · <fetch>`
`path branch ↑↓ state n error · <count> <issue>… · n pin staged · n loading · n submodules · <fetch>`
The summary and `<fetch>` form one group at the right edge; the path,
branch and state stay at the left. If the line is too narrow it is cut with
`…` at the right end, so the most important counts stay.
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
changed), each in its level color. Zero counts are omitted. `n loading`
@@ -160,12 +194,12 @@ superproject shows `(detached)` as its branch.
| State | Shows |
|---|---|
| fetching | spinner + `fetching 12/174` (repositories done / total) |
| done | `fetched just now`, then the age (`fetched 5m ago`) |
| some failed | `fetched just now · 3 failed` (count in yellow) |
| never fetched | `fetched never` |
| done | nothing |
| some failed | `3 failed` (count in yellow) |
The age is the newer of the superproject's `FETCH_HEAD` and the end of the
application's own fetch.
The header has no fetch age: the age belongs to one repository, and a header
age would be read as the age of everything. It is in the detail pane, for
the selected repository (the superproject's own when the header is selected).
## Colors
+39 -20
View File
@@ -24,9 +24,6 @@ import (
// are compared besides the preferred refs when resolving a pushed pin.
const maxContainsCandidates = 16
// maxBetween limits the commits listed between pin and tip.
const maxBetween = 50
// Provider reads a superproject and its submodules with git.
type Provider struct {
root string // absolute superproject root
@@ -79,13 +76,6 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
} else if ok {
repo.Ahead, repo.Behind, _ = parseLeftRight(out)
}
if dir, ok, err := try(ctx, p.root, "rev-parse", "--path-format=absolute", "--git-common-dir"); err != nil {
return nil, err
} else if ok {
if fi, err := os.Stat(filepath.Join(dir, "FETCH_HEAD")); err == nil {
repo.FetchedAt = fi.ModTime()
}
}
repo.Root = &submodule.Submodule{Name: filepath.Base(p.root), Path: ""}
if remote, err := remoteName(ctx, p.root); err != nil {
@@ -176,7 +166,8 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
if err != nil {
return nil, err
}
st := &submodule.Status{PinStaged: parseGitlink(out) != pin}
committed := parseGitlink(out)
st := &submodule.Status{PinStaged: committed != pin}
// presence
if _, err := os.Stat(in.dir); errors.Is(err, fs.ErrNotExist) {
@@ -223,9 +214,36 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
if err := pinState(ctx, in, st, pin, head, branch, nil); err != nil {
return nil, err
}
if st.PinStaged && committed != "" {
if err := committedState(ctx, in, st, committed, pin); err != nil {
return nil, err
}
}
return st, nil
}
// committedState fills the pin of the superproject's HEAD and how it compares
// with the staged pin. It stays empty if the commit is not on disk: the staged
// pin is what matters, so this is no reason to fail.
func committedState(ctx context.Context, in info, st *submodule.Status, committed, pin string) error {
if _, ok, err := try(ctx, in.dir, "cat-file", "-e", committed+"^{commit}"); err != nil || !ok {
return err
}
c, err := commit(ctx, in.dir, committed)
if err != nil {
return err
}
out, err := run(ctx, in.dir, "rev-list", "--left-right", "--count", committed+"..."+pin)
if err != nil {
return err
}
if st.CommittedDrift.Ahead, st.CommittedDrift.Behind, err = parseLeftRight(out); err != nil {
return err
}
st.Committed = c
return nil
}
// rootStatus describes the superproject. Its "pin" is HEAD, compared with the
// upstream of the current branch; submodules are ignored, their rows show
// them.
@@ -279,8 +297,9 @@ func (p *Provider) rootStatus(ctx context.Context) (*submodule.Status, error) {
}
// 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.
// ref it resolves to (chosen, if not nil), the commits and the distances: of
// the checkout to the pin and of the pin 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 {
@@ -320,6 +339,13 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
return err
}
out, err = run(ctx, in.dir, "rev-list", "--left-right", "--count", head+"..."+pin)
if err != nil {
return err
}
if st.Drift.Ahead, st.Drift.Behind, err = parseLeftRight(out); err != nil {
return err
}
}
if source == submodule.RefFromSHA {
st.Pin.Ref = st.Pinned.SHA
@@ -335,13 +361,6 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
if st.Pin.Ahead, st.Pin.Behind, err = parseLeftRight(out); err != nil {
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 err
}
st.Between = parseCommits(out)
}
return nil
}
+34 -7
View File
@@ -68,9 +68,6 @@ func TestTree(t *testing.T) {
if repo.Branch != "main" {
t.Errorf("Branch = %q, want main", repo.Branch)
}
if !repo.FetchedAt.IsZero() {
t.Errorf("FetchedAt = %v, want zero (superproject never fetched)", repo.FetchedAt)
}
var paths []string
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) })
want := []string{
@@ -98,8 +95,9 @@ func TestStatus(t *testing.T) {
behind int
staged bool
checkout submodule.Checkout
drift submodule.Distance // checkout compared with the pin
}{
{path: "src/core", issues: []submodule.IssueKind{submodule.IssueOffPin}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDiffers},
{path: "src/core", issues: []submodule.IssueKind{submodule.IssueOffPin}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDiffers, drift: submodule.Distance{Behind: 1}},
{path: "src/ui", ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2},
{path: "src/net", issues: []submodule.IssueKind{submodule.IssueUnpushed}, ref: "origin/main", source: submodule.RefFromCurrentBranch, ahead: 1},
{path: "libs/store", ref: "origin/develop", source: submodule.RefFromGitmodules},
@@ -135,12 +133,18 @@ func TestStatus(t *testing.T) {
if st.Pin.Ahead != tt.ahead || st.Pin.Behind != tt.behind {
t.Errorf("%s: ↑%d↓%d, want ↑%d↓%d", tt.path, st.Pin.Ahead, st.Pin.Behind, tt.ahead, tt.behind)
}
if len(st.Between) != tt.ahead+tt.behind {
t.Errorf("%s: %d commits between, want %d", tt.path, len(st.Between), tt.ahead+tt.behind)
}
if st.PinStaged != tt.staged {
t.Errorf("%s: PinStaged %v, want %v", tt.path, st.PinStaged, tt.staged)
}
if tt.staged != (st.Committed != nil) {
t.Errorf("%s: Committed = %+v with staged = %v", tt.path, st.Committed, tt.staged)
}
if tt.path == "libs/staged" && (st.CommittedDrift.Ahead != 0 || st.CommittedDrift.Behind == 0) {
t.Errorf("%s: CommittedDrift %+v, want the committed pin behind the staged one", tt.path, st.CommittedDrift)
}
if st.Drift != tt.drift {
t.Errorf("%s: drift %+v, want %+v", tt.path, st.Drift, tt.drift)
}
if st.Checkout != tt.checkout {
t.Errorf("%s: Checkout %v, want %v", tt.path, st.Checkout, tt.checkout)
}
@@ -267,3 +271,26 @@ func TestNewNotARepo(t *testing.T) {
t.Error("New(non-repo): want error")
}
}
// A staged pin older than the committed one: the committed pin is ahead.
func TestCommittedPinAheadOfStaged(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
git(t, filepath.Join(super, "src", "ui"), "checkout", "-q", "--detach", "HEAD~1")
git(t, super, "add", "src/ui")
st, err := p.Status(context.Background(), "src/ui")
if err != nil {
t.Fatal(err)
}
if !st.PinStaged || st.Committed == nil {
t.Fatalf("PinStaged = %v, Committed = %v", st.PinStaged, st.Committed)
}
if st.CommittedDrift != (submodule.Distance{Ahead: 1}) {
t.Errorf("CommittedDrift = %+v, want the committed pin 1 ahead", st.CommittedDrift)
}
spine := st.Spine()
if n := len(spine); n < 2 || spine[n-1].Commit.SHA != st.Pinned.SHA || spine[n-2].Commit.SHA != st.Committed.SHA {
t.Errorf("spine = %+v, want the committed pin directly above the staged one", spine)
}
}
-3
View File
@@ -154,9 +154,6 @@ func TestRootStatusRefIsUpstream(t *testing.T) {
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) {
+8 -21
View File
@@ -40,10 +40,9 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
defer p.mu.Unlock()
p.statuses = map[string]*submodule.Status{}
repo := &submodule.Repo{
Path: "~/coding/my_project",
Branch: "main",
Behind: 2,
FetchedAt: time.Now().Add(-2 * time.Minute),
Path: "~/coding/my_project",
Branch: "main",
Behind: 2,
}
repo.Root = &submodule.Submodule{Name: "my_project", URL: "git@github.com:acme/my_project.git"}
p.statuses[""] = rootStatus()
@@ -169,11 +168,7 @@ func rootStatus() *submodule.Status {
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),
FetchedAt: now.Add(-2 * time.Minute),
}
st.Changes = changes(15*time.Minute, []submodule.FileChange{
{Path: "README.md", Kind: submodule.ChangeModified, Added: 4, Removed: 1},
@@ -217,7 +212,7 @@ func fixture() []node {
Pinned: subSubPin,
CheckedOut: subSubTip,
Tip: subSubTip,
Between: []submodule.Commit{*subSubTip},
Drift: submodule.Distance{Ahead: 1},
}
schema := inSync("origin/main", commit("1a2b3c4", "Add v3 schema", "ben", 6*24*time.Hour))
@@ -240,6 +235,8 @@ func fixture() []node {
crypto := inSync("v1.4.2", cryptoNew)
crypto.Pin.Source = submodule.RefFromTag
crypto.PinStaged = true
crypto.Committed = commit("e0f1a2b", "Release v1.4.1", "ben", 40*24*time.Hour)
crypto.CommittedDrift = submodule.Distance{Behind: 3}
zlib := inSync("v1.3.1", commit("51f2c3e", "zlib 1.3.1", "madler", 300*24*time.Hour))
zlib.Pin.Source = submodule.RefFromTag
@@ -259,6 +256,7 @@ func fixture() []node {
Pinned: srcSub1Pin,
CheckedOut: srcSub1Head,
Tip: srcSub1Pin,
Drift: submodule.Distance{Ahead: 2},
}},
{relPath: "src/my_submodule_2", status: srcSub2, children: []node{
{relPath: "modules/my_sub_submodule", status: subSub},
@@ -271,29 +269,18 @@ func fixture() []node {
Pinned: unpushed,
CheckedOut: unpushed,
Tip: sub3Tip,
Between: []submodule.Commit{*unpushed},
}},
{relPath: "libs/logging", status: &submodule.Status{
Pin: submodule.PinInfo{Ref: "origin/main", Behind: 3, Pushed: true},
Pinned: logPin,
CheckedOut: logPin,
Tip: logTip,
Between: []submodule.Commit{
*logTip,
*commit("7d8e9f0", "Fix race in buffered writer", "dana", 3*24*time.Hour),
*commit("6c7d8e9", "Update dependencies", "renovate", 5*24*time.Hour),
},
}},
{relPath: "libs/http", status: &submodule.Status{
Pin: submodule.PinInfo{Ref: "origin/develop", Ahead: 1, Behind: 2, Pushed: true, Source: submodule.RefFromContains},
Pinned: httpPin,
CheckedOut: httpPin,
Tip: httpTip,
Between: []submodule.Commit{
*httpTip,
*commit("d0e1f2a", "Refactor transport", "frank", 2*24*time.Hour),
*httpPin,
},
}},
{relPath: "libs/crypto", status: crypto},
{relPath: "third_party/zlib", status: zlib},
+12 -4
View File
@@ -15,7 +15,6 @@ type Repo struct {
Branch string
Ahead int
Behind int
FetchedAt time.Time
Root *Submodule // the superproject as a selectable node; Path is "", no Children
Submodules []*Submodule
}
@@ -173,6 +172,10 @@ func (c *Changes) Sort() {
})
}
// Distance counts the commits only one side of a comparison has, like
// `git rev-list --left-right --count a...b`.
type Distance struct{ Ahead, Behind int }
// Status is the full state of one submodule.
type Status struct {
Root bool // the superproject: the "pin" is its HEAD
@@ -184,7 +187,12 @@ type Status struct {
Pinned *Commit
CheckedOut *Commit
Tip *Commit
Between []Commit // commits that make up the pin <-> tip difference
Changes *Changes // nil if the worktree is clean or the details are unknown
FetchedAt time.Time // last fetch of the submodule; zero if never
Drift Distance // checkout compared with the pin: Ahead = commits only the checkout has
// Committed is the pin in the superproject's HEAD while Pinned (the index) is
// staged; nil if nothing is staged, the commit is not on disk, or the pin was
// only added. CommittedDrift compares it with Pinned like Drift.
Committed *Commit
CommittedDrift Distance
Changes *Changes // nil if the worktree is clean or the details are unknown
FetchedAt time.Time // last fetch of the submodule; zero if never
}
+161
View File
@@ -0,0 +1,161 @@
package submodule
import "slices"
// Role is what a commit means for a submodule.
type Role int
// The order is the order in which roles are listed for a commit.
const (
RolePin Role = iota // the commit recorded in the superproject's HEAD
RoleHead // the superproject's HEAD, which takes the pin's place
RoleStaged // the pin in the index, if it differs from RolePin
RoleTip // the tip of the ref the pin was resolved to
RoleYou // the commit checked out in the submodule
)
// SpineNode is one commit on the spine and the roles that sit on it.
type SpineNode struct {
Roles []Role
Commit *Commit
// Below describes the history between this node and the next one down;
// it is the zero Link for the last node.
Below Link
}
// InSync reports whether the pin, the tip and the checkout are one commit.
func (n SpineNode) InSync() bool {
return slices.Contains(n.Roles, RoleTip) &&
(slices.Contains(n.Roles, RoleHead) || slices.Contains(n.Roles, RolePin) && slices.Contains(n.Roles, RoleYou))
}
// Link is the history between two spine nodes. Above counts the commits only
// the upper node has, Below those only the lower node has.
type Link struct{ Above, Below int }
// Linear reports whether the lower node is an ancestor of the upper one, so
// that Above is the number of commits between them.
func (l Link) Linear() bool { return l.Above > 0 && l.Below == 0 }
// Diverged reports whether the nodes are on different branches.
func (l Link) Diverged() bool { return l.Above > 0 && l.Below > 0 }
// spineItem is a commit with its roles on its way to becoming a node.
type spineItem struct {
roles []Role
commit *Commit
height int // commits above (+) or below (−) the pin; unused if div
div bool // diverged from the pin
own int // commits only this item has
pin int // commits only the pin has
isPin bool
}
// Spine arranges the pin (and the staged pin, if any), the tip and the checkout as one line of history,
// newest on top. Roles on the same commit share a node. A tip or checkout
// that has diverged from the pin is placed outermost, above or below
// respectively, with a diverged Link. Missing commits are left out.
func (s *Status) Spine() []SpineNode {
if s.Pinned == nil {
return nil
}
pinRole := RolePin
switch {
case s.Root:
pinRole = RoleHead
case s.PinStaged:
pinRole = RoleStaged // the committed pin gets its own node below
}
pin := spineItem{roles: []Role{pinRole}, commit: s.Pinned, isPin: true}
var above, below []spineItem
place := func(role Role, c *Commit, own, pinOnly int, divAbove bool) {
it := spineItem{roles: []Role{role}, commit: c, own: own, pin: pinOnly}
switch {
case own == 0 && pinOnly == 0:
pin.roles = append(pin.roles, role)
return
case own > 0 && pinOnly == 0:
it.height = own
above = append(above, it)
case own == 0:
it.height = -pinOnly
below = append(below, it)
default:
it.div = true
if divAbove {
above = append(above, it)
} else {
below = append(below, it)
}
}
}
if s.Tip != nil {
place(RoleTip, s.Tip, s.Pin.Behind, s.Pin.Ahead, true)
}
if s.PinStaged && s.Committed != nil {
place(RolePin, s.Committed, s.CommittedDrift.Ahead, s.CommittedDrift.Behind, false)
}
switch {
case s.CheckedOut == nil, s.Root: // HEAD is the superproject's pin and checkout
case s.CheckedOut.SHA == s.Pinned.SHA:
pin.roles = append(pin.roles, RoleYou)
default:
place(RoleYou, s.CheckedOut, s.Drift.Ahead, s.Drift.Behind, false)
}
// diverged items are outermost, the rest is ordered by height
byHeight := func(a, b spineItem) int { return b.height - a.height }
slices.SortStableFunc(above, func(a, b spineItem) int {
if a.div != b.div {
if a.div {
return -1
}
return 1
}
return byHeight(a, b)
})
slices.SortStableFunc(below, func(a, b spineItem) int {
if a.div != b.div {
if a.div {
return 1
}
return -1
}
return byHeight(a, b)
})
slices.Sort(pin.roles)
items := append(append(above, pin), below...)
var nodes []SpineNode
var last spineItem
for i, it := range items {
// the same commit twice, e.g. a tip and a checkout side by side
if i > 0 && !it.div && !last.div && it.height == last.height && it.commit.SHA == last.commit.SHA {
n := &nodes[len(nodes)-1]
n.Roles = append(n.Roles, it.roles...)
slices.Sort(n.Roles)
continue
}
if i > 0 {
nodes[len(nodes)-1].Below = link(last, it)
}
nodes = append(nodes, SpineNode{Roles: it.roles, Commit: it.commit})
last = it
}
return nodes
}
// link describes the history between an upper and a lower item.
func link(upper, lower spineItem) Link {
switch {
case upper.div && lower.isPin:
return Link{Above: upper.own, Below: upper.pin}
case lower.div && upper.isPin:
return Link{Above: lower.pin, Below: lower.own}
case upper.div || lower.div:
return Link{} // unknown
default:
return Link{Above: upper.height - lower.height}
}
}
+138
View File
@@ -0,0 +1,138 @@
package submodule
import (
"slices"
"testing"
)
func TestSpine(t *testing.T) {
c := func(sha string) *Commit { return &Commit{SHA: sha} }
tests := []struct {
name string
st Status
nodes []string // "sha:roles"
links []Link // Below of every node but the last
}{
{
name: "in sync",
st: Status{Pinned: c("a"), CheckedOut: c("a"), Tip: c("a")},
nodes: []string{"a:pin,tip,you"},
},
{
name: "pin behind tip, checkout on pin",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Behind: 4}},
nodes: []string{"t:tip", "p:pin,you"},
links: []Link{{Above: 4}},
},
{
name: "pin on tip, checkout behind",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("p"), Drift: Distance{Behind: 2}},
nodes: []string{"p:pin,tip", "y:you"},
links: []Link{{Above: 2}},
},
{
name: "all apart",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("t"), Pin: PinInfo{Behind: 4}, Drift: Distance{Behind: 2}},
nodes: []string{"t:tip", "p:pin", "y:you"},
links: []Link{{Above: 4}, {Above: 2}},
},
{
name: "checkout ahead of the pin",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("t"), Pin: PinInfo{Behind: 5}, Drift: Distance{Ahead: 2}},
nodes: []string{"t:tip", "y:you", "p:pin"},
links: []Link{{Above: 3}, {Above: 2}},
},
{
name: "checkout on the tip",
st: Status{Pinned: c("p"), CheckedOut: c("t"), Tip: c("t"), Pin: PinInfo{Behind: 3}, Drift: Distance{Ahead: 3}},
nodes: []string{"t:tip,you", "p:pin"},
links: []Link{{Above: 3}},
},
{
name: "pin ahead of its ref",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Ahead: 1}},
nodes: []string{"p:pin,you", "t:tip"},
links: []Link{{Above: 1}},
},
{
name: "pin diverged from tip",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Ahead: 2, Behind: 3}},
nodes: []string{"t:tip", "p:pin,you"},
links: []Link{{Above: 3, Below: 2}},
},
{
name: "checkout diverged from pin",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("p"), Drift: Distance{Ahead: 1, Behind: 2}},
nodes: []string{"p:pin,tip", "y:you"},
links: []Link{{Above: 2, Below: 1}},
},
{
name: "staged pin above the committed one, checkout on the staged pin",
st: Status{Pinned: c("s"), CheckedOut: c("s"), Tip: c("s"), PinStaged: true,
Committed: c("p"), CommittedDrift: Distance{Behind: 3}},
nodes: []string{"s:staged,tip,you", "p:pin"},
links: []Link{{Above: 3}},
},
{
name: "checkout stayed on the committed pin",
st: Status{Pinned: c("s"), CheckedOut: c("p"), Tip: c("t"), PinStaged: true, Pin: PinInfo{Behind: 1},
Committed: c("p"), CommittedDrift: Distance{Behind: 2}, Drift: Distance{Behind: 2}},
nodes: []string{"t:tip", "s:staged", "p:pin,you"},
links: []Link{{Above: 1}, {Above: 2}},
},
{
name: "no tip, checkout not loaded",
st: Status{Pinned: c("p")},
nodes: []string{"p:pin"},
},
{
name: "superproject",
st: Status{Root: true, Pinned: c("h"), CheckedOut: c("h"), Tip: c("t"), Pin: PinInfo{Behind: 2}},
nodes: []string{"t:tip", "h:head"},
links: []Link{{Above: 2}},
},
}
words := map[Role]string{RolePin: "pin", RoleStaged: "staged", RoleHead: "head", RoleTip: "tip", RoleYou: "you"}
for _, tt := range tests {
var nodes []string
var links []Link
spine := tt.st.Spine()
for i, n := range spine {
s := n.Commit.SHA + ":"
for j, r := range n.Roles {
if j > 0 {
s += ","
}
s += words[r]
}
nodes = append(nodes, s)
if i < len(spine)-1 {
links = append(links, n.Below)
}
}
if !slices.Equal(nodes, tt.nodes) {
t.Errorf("%s: nodes %v, want %v", tt.name, nodes, tt.nodes)
}
if !slices.Equal(links, tt.links) {
t.Errorf("%s: links %v, want %v", tt.name, links, tt.links)
}
}
}
func TestSpineNodeInSync(t *testing.T) {
tests := []struct {
roles []Role
want bool
}{
{[]Role{RolePin, RoleTip, RoleYou}, true},
{[]Role{RoleHead, RoleTip}, true},
{[]Role{RolePin, RoleTip}, false},
{[]Role{RolePin, RoleYou}, false},
{[]Role{RoleTip}, false},
}
for _, tt := range tests {
if got := (SpineNode{Roles: tt.roles}).InSync(); got != tt.want {
t.Errorf("%v: InSync = %v, want %v", tt.roles, got, tt.want)
}
}
}
+1 -3
View File
@@ -5,7 +5,6 @@ package tui
import (
"context"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
@@ -151,7 +150,7 @@ func (m *Model) startFetch() tea.Cmd {
paths = append(paths, s.Path)
}
})
m.fetch = header.Fetch{Running: true, Total: len(paths), At: m.fetch.At}
m.fetch = header.Fetch{Running: true, Total: len(paths)}
cmds := []tea.Cmd{m.tick()}
for _, p := range paths {
f, ctx := m.fetcher, m.ctx
@@ -227,7 +226,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if m.fetch.Done == m.fetch.Total {
m.fetch.Running = false
m.fetch.At = time.Now()
}
return m, nil
+70 -62
View File
@@ -4,6 +4,7 @@ package detail
import (
"fmt"
"path"
"slices"
"strings"
"github.com/charmbracelet/bubbles/help"
@@ -100,6 +101,14 @@ func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
}
st := s.Status
// the age of the last fetch is right-aligned in the name's line
if st.Presence == submodule.PresenceOK {
fetched := "fetched " + theme.Ago(st.FetchedAt)
if gap := m.width - ansi.StringWidth(s.Name) - len(fetched); gap >= 2 {
out[1] += strings.Repeat(" ", gap) + muted.Render(fetched)
}
}
issues := st.Issues()
for _, is := range issues {
out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail))
@@ -123,56 +132,15 @@ func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
}
out = append(out, "")
// ref
// ref and spine
if st.Pin.Ref != "" {
delta := t.Delta(st.Pin, lipgloss.NewStyle())
if delta != "" {
delta = " " + delta
}
out = append(out,
label("Ref")+text.Render(st.Pin.Ref)+delta,
label("")+muted.Render("via "+st.Pin.Source.String()),
)
}
if st.Presence == submodule.PresenceOK {
out = append(out, label("Fetched")+muted.Render(theme.Ago(st.FetchedAt)))
out = append(out, label("Ref")+text.Render(st.Pin.Ref)+muted.Render(" · via "+st.Pin.Source.String()), "")
}
out = append(out, m.spineLines(st.Spine(), st.Checkout == submodule.CheckoutDiffers)...)
if s.FetchErr != nil {
out = append(out, theme.Fg(t.Warn).Width(labelWidth).Render("fetch")+text.Render("failed: "+s.FetchErr.Error()))
}
// commits
commitLine := func(name string, c *submodule.Commit, highlight bool) string {
if c == nil {
return label(name) + muted.Render("—")
}
shaStyle := theme.Fg(t.Primary)
if highlight {
shaStyle = theme.Fg(t.Warn)
}
return label(name) + shaStyle.Render(c.SHA) + " " + text.Render(c.Subject)
}
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, st.Root)))
for _, c := range st.Between {
out = append(out,
" "+theme.Fg(t.Primary).Render(c.SHA)+" "+text.Render(c.Subject),
" "+muted.Render(fmt.Sprintf("%7s %s · %s", "", c.Author, theme.Ago(c.When))),
)
}
}
// children & meta
out = append(out, "")
if root {
@@ -213,25 +181,65 @@ func (m Model) hiddenParts(h submodule.Hidden) string {
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"
// spineLines draws the pin, the tip and the checkout as one line of history,
// newest on top: a node per commit with its roles in the label column, and
// between two nodes how many commits separate them. offPin colors the
// checkout's own node.
func (m Model) spineLines(nodes []submodule.SpineNode, offPin bool) []string {
t := m.theme
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
labels := make([]string, len(nodes))
width := labelWidth
for i, n := range nodes {
words := make([]string, len(n.Roles))
for j, r := range n.Roles {
words[j] = theme.RoleWord(r)
}
return "commits"
labels[i] = strings.Join(words, " · ")
if n.InSync() {
labels[i] = theme.InSync
}
width = max(width, ansi.StringWidth(labels[i])+1)
}
switch {
case p.Ahead > 0 && p.Behind > 0:
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("%s is %d %s ahead of %s:", subject, p.Ahead, plural(p.Ahead), p.Ref)
default:
return fmt.Sprintf("%s is %d %s behind %s:", subject, p.Behind, plural(p.Behind), p.Ref)
// a staged pin below the committed one moves the pin back
hasRole := func(n submodule.SpineNode, r submodule.Role) bool { return slices.Contains(n.Roles, r) }
pinAt := slices.IndexFunc(nodes, func(n submodule.SpineNode) bool { return hasRole(n, submodule.RolePin) })
stagedAt := slices.IndexFunc(nodes, func(n submodule.SpineNode) bool { return hasRole(n, submodule.RoleStaged) })
var out []string
for i, n := range nodes {
label, sha := muted, theme.Fg(t.Primary)
if hasRole(n, submodule.RoleStaged) {
label = theme.Fg(t.Info)
}
if offPin && len(n.Roles) == 1 && n.Roles[0] == submodule.RoleYou {
label, sha = theme.Fg(t.Warn), theme.Fg(t.Warn)
}
out = append(out, label.Width(width).Render(labels[i])+sha.Render(t.Glyphs.Node+" "+n.Commit.SHA)+" "+text.Render(n.Commit.Subject))
if i < len(nodes)-1 {
gap := muted.Render(t.Glyphs.Gap + m.linkText(n.Below))
if i+1 == stagedAt && pinAt >= 0 && pinAt < stagedAt && n.Below.Linear() {
gap += theme.Fg(t.Info).Render(" (staged pin moves back)")
}
out = append(out, strings.Repeat(" ", width)+gap)
}
}
return out
}
// linkText words the history between two nodes, with a leading space; empty
// if it is unknown.
func (m Model) linkText(l submodule.Link) string {
g := m.theme.Glyphs
switch {
case l.Diverged():
return fmt.Sprintf(" diverged %s%d %s%d", g.Ahead, l.Below, g.Behind, l.Above)
case l.Above == 1:
return " 1 commit"
case l.Above > 1:
return fmt.Sprintf(" %d commits", l.Above)
}
return ""
}
+75
View File
@@ -0,0 +1,75 @@
package detail
import (
"slices"
"testing"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
func TestSpineLines(t *testing.T) {
m := New(theme.Default(), nil)
st := submodule.Status{
Pinned: &submodule.Commit{SHA: "9ac01de", Subject: "Add node"},
CheckedOut: &submodule.Commit{SHA: "48005ac", Subject: "Merge branch"},
Tip: &submodule.Commit{SHA: "3f1bf20", Subject: "Fix deps"},
Pin: submodule.PinInfo{Behind: 4},
Drift: submodule.Distance{Behind: 1},
}
var got []string
for _, l := range m.spineLines(st.Spine(), true) {
got = append(got, ansi.Strip(l))
}
g := theme.Default().Glyphs
want := []string{
"tip " + g.Node + " 3f1bf20 Fix deps",
" " + g.Gap + " 4 commits",
"pin " + g.Node + " 9ac01de Add node",
" " + g.Gap + " 1 commit",
"you " + g.Node + " 48005ac Merge branch",
}
if !slices.Equal(got, want) {
t.Errorf("got\n%q\nwant\n%q", got, want)
}
}
func TestSpineLinesStagedMovesBack(t *testing.T) {
m := New(theme.Default(), nil)
st := submodule.Status{
Pinned: &submodule.Commit{SHA: "87a1059", Subject: "refactor"},
CheckedOut: &submodule.Commit{SHA: "87a1059", Subject: "refactor"},
PinStaged: true,
Committed: &submodule.Commit{SHA: "8f23a6e", Subject: "Merge"},
CommittedDrift: submodule.Distance{Ahead: 15},
}
var got []string
for _, l := range m.spineLines(st.Spine(), false) {
got = append(got, ansi.Strip(l))
}
g := theme.Default().Glyphs
want := []string{
"pin " + g.Node + " 8f23a6e Merge",
" " + g.Gap + " 15 commits (staged pin moves back)",
"staged · you " + g.Node + " 87a1059 refactor",
}
if !slices.Equal(got, want) {
t.Errorf("got\n%q\nwant\n%q", got, want)
}
}
func TestSpineLinesInSync(t *testing.T) {
m := New(theme.Default(), nil)
c := &submodule.Commit{SHA: "3f1bf20", Subject: "Fix deps"}
st := submodule.Status{Pinned: c, CheckedOut: c, Tip: c}
lines := m.spineLines(st.Spine(), false)
if len(lines) != 1 {
t.Fatalf("got %d lines, want 1", len(lines))
}
want := "in sync " + theme.Default().Glyphs.Node + " 3f1bf20 Fix deps"
if got := ansi.Strip(lines[0]); got != want {
t.Errorf("got %q, want %q", got, want)
}
}
+24 -27
View File
@@ -3,9 +3,10 @@ package header
import (
"fmt"
"time"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
@@ -14,10 +15,9 @@ import (
// Fetch is the progress of the application's own background fetch.
type Fetch struct {
Running bool
Done int // repositories fetched (or failed)
Total int // repositories to fetch
Failed int // failed fetches of the last run
At time.Time // when the last run finished
Done int // repositories fetched (or failed)
Total int // repositories to fetch
Failed int // failed fetches of the last run
}
// View renders repo info, state counts and fetch state on one line. spinner
@@ -65,47 +65,44 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
}
})
// most important first: errors, issues by severity, staged pins, then the
// total
sep := seg(t.Muted, " · ")
mid := seg(t.Muted, fmt.Sprintf("%d submodules", total))
var parts []string
if errored > 0 {
mid += sep + t.LevelStyle(submodule.LevelDanger, bar).Render(fmt.Sprintf("%d error", errored))
parts = append(parts, t.LevelStyle(submodule.LevelDanger, bar).Render(fmt.Sprintf("%d error", errored)))
}
for _, k := range []submodule.IssueKind{
submodule.IssueUnpushed, submodule.IssueConflict, submodule.IssueMissing,
submodule.IssueOffPin, submodule.IssueChanged,
} {
if n := perKind[k]; n > 0 {
mid += sep + t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k)))
parts = append(parts, t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k))))
}
}
if staged > 0 {
mid += sep + seg(t.Info, fmt.Sprintf("%d pin staged", staged))
parts = append(parts, seg(t.Info, fmt.Sprintf("%d pin staged", staged)))
}
if loading > 0 {
mid += sep + seg(t.Muted, fmt.Sprintf("%d loading", loading))
parts = append(parts, seg(t.Muted, fmt.Sprintf("%d loading", loading)))
}
parts = append(parts, seg(t.Muted, fmt.Sprintf("%d submodules", total)))
mid := strings.Join(parts, sep)
var right string
// the summary and the fetch state form one group at the right edge
right := mid
switch {
case fetch.Running:
right = seg(t.Primary, spinner) + seg(t.Muted, fmt.Sprintf(" fetching %d/%d ", fetch.Done, fetch.Total))
default:
// our own fetch may have finished before the watcher saw FETCH_HEAD
at := repo.FetchedAt
if fetch.At.After(at) {
at = fetch.At
}
right = seg(t.Muted, "fetched "+theme.Ago(at))
if fetch.Failed > 0 {
right += seg(t.Muted, " · ") + seg(t.Warn, fmt.Sprintf("%d failed", fetch.Failed))
}
right += seg(t.Muted, " ")
right += sep + seg(t.Primary, spinner) + seg(t.Muted, fmt.Sprintf(" fetching %d/%d", fetch.Done, fetch.Total))
case fetch.Failed > 0:
right += sep + seg(t.Warn, fmt.Sprintf("%d failed", fetch.Failed))
}
right += seg(t.Muted, " ")
gap := width - lipgloss.Width(left) - lipgloss.Width(mid) - lipgloss.Width(right)
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 4 {
return bar.Width(width).MaxWidth(width).Render(left + seg(t.Text, " ") + mid)
// too narrow: the least important part, on the right, is cut
return bar.Width(width).Render(ansi.Truncate(left+seg(t.Text, " ")+mid, width, "…"))
}
sp := func(n int) string { return bar.Render(fmt.Sprintf("%*s", n, "")) }
return left + sp(gap/2) + mid + sp(gap-gap/2) + right
return left + bar.Render(fmt.Sprintf("%*s", gap, "")) + right
}
+16
View File
@@ -32,6 +32,22 @@ func IssueWord(k submodule.IssueKind) string {
}
}
// RoleWord is the label of a commit's role on the detail pane's spine.
func RoleWord(r submodule.Role) string {
switch r {
case submodule.RolePin:
return "pin"
case submodule.RoleHead:
return "HEAD"
case submodule.RoleStaged:
return Staged
case submodule.RoleTip:
return "tip"
default:
return "you"
}
}
// 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
+3 -3
View File
@@ -54,17 +54,17 @@ func Fg(c lipgloss.TerminalColor) lipgloss.Style {
// Glyphs are the few symbols used next to the state words.
type Glyphs struct {
Staged, Ahead, Behind, Collapsed, Expanded, Problems, Minus, From, Rule string
Staged, Ahead, Behind, Collapsed, Expanded, Problems, Minus, From, Rule, Node, Gap string
}
func Unicode() Glyphs {
return Glyphs{
Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", Minus: "−", From: "←", Rule: "─",
Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", Minus: "−", From: "←", Rule: "─", Node: "●", Gap: "┊",
}
}
func ASCII() Glyphs {
return Glyphs{
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", Minus: "-", From: "<-", Rule: "-",
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", Minus: "-", From: "<-", Rule: "-", Node: "o", Gap: ":",
}
}