From 4c1be3f9abd8698e19a294fe49ad490a8d2dccea Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Fri, 23 Oct 2026 16:34:14 +0200 Subject: [PATCH] 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 --- README.md | 13 +- docs/architecture.md | 3 +- docs/decisions.md | 34 ++++- docs/requirements.md | 21 ++-- docs/state-model.md | 29 ++++- docs/ux-guide.md | 76 +++++++---- internal/submodule/git/provider.go | 59 ++++++--- internal/submodule/git/provider_test.go | 41 ++++-- internal/submodule/git/root_test.go | 3 - internal/submodule/mock/mock.go | 29 ++--- internal/submodule/model.go | 16 ++- internal/submodule/spine.go | 161 ++++++++++++++++++++++++ internal/submodule/spine_test.go | 138 ++++++++++++++++++++ internal/tui/app.go | 4 +- internal/tui/detail/detail.go | 132 ++++++++++--------- internal/tui/detail/spine_test.go | 75 +++++++++++ internal/tui/header/header.go | 51 ++++---- internal/tui/theme/issues.go | 16 +++ internal/tui/theme/theme.go | 6 +- 19 files changed, 714 insertions(+), 193 deletions(-) create mode 100644 internal/submodule/spine.go create mode 100644 internal/submodule/spine_test.go create mode 100644 internal/tui/detail/spine_test.go diff --git a/README.md b/README.md index f817dc4..4c25a4d 100644 --- a/README.md +++ b/README.md @@ -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 `/` diff --git a/docs/architecture.md b/docs/architecture.md index f94973e..55892b4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/decisions.md b/docs/decisions.md index b63214c..f5ca218 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 `. +**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 `. 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. diff --git a/docs/requirements.md b/docs/requirements.md index 3136dfa..6613552 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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`) diff --git a/docs/state-model.md b/docs/state-model.md index e9827cd..f5c9056 100644 --- a/docs/state-model.md +++ b/docs/state-model.md @@ -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; `` is the index gitlink. | `Pushed` | `for-each-ref --contains refs/remotes` is not empty | | `Pin.Ref` | rules above; the remote is `origin`, else the first remote | | `Ahead`, `Behind` | `rev-list --left-right --count ...` | -| `Between` | `log ...`, at most 50 commits | +| `Drift` | `rev-list --left-right --count ...`, 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 ...` | 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 | diff --git a/docs/ux-guide.md b/docs/ux-guide.md index bf00608..e619ca0 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -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 ` 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 `; `Fetched ` of the submodule; +4. `Ref` with `· via `, a blank line, then the [spine](#spine); `fetch failed: ` (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 · … · n pin staged · n loading · ` +`path branch ↑↓ state n error · … · n pin staged · n loading · n submodules · ` + +The summary and `` 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 diff --git a/internal/submodule/git/provider.go b/internal/submodule/git/provider.go index f04e631..505b38a 100644 --- a/internal/submodule/git/provider.go +++ b/internal/submodule/git/provider.go @@ -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 } diff --git a/internal/submodule/git/provider_test.go b/internal/submodule/git/provider_test.go index 39e6b7e..9a8148b 100644 --- a/internal/submodule/git/provider_test.go +++ b/internal/submodule/git/provider_test.go @@ -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) + } +} diff --git a/internal/submodule/git/root_test.go b/internal/submodule/git/root_test.go index 26a0404..944aa64 100644 --- a/internal/submodule/git/root_test.go +++ b/internal/submodule/git/root_test.go @@ -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) { diff --git a/internal/submodule/mock/mock.go b/internal/submodule/mock/mock.go index 9cbddce..b37fc3e 100644 --- a/internal/submodule/mock/mock.go +++ b/internal/submodule/mock/mock.go @@ -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}, diff --git a/internal/submodule/model.go b/internal/submodule/model.go index db6150c..cce1fca 100644 --- a/internal/submodule/model.go +++ b/internal/submodule/model.go @@ -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 } diff --git a/internal/submodule/spine.go b/internal/submodule/spine.go new file mode 100644 index 0000000..2ec9f2a --- /dev/null +++ b/internal/submodule/spine.go @@ -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} + } +} diff --git a/internal/submodule/spine_test.go b/internal/submodule/spine_test.go new file mode 100644 index 0000000..33c39e4 --- /dev/null +++ b/internal/submodule/spine_test.go @@ -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) + } + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 1f7ab0f..8eeb5b7 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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 diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go index 61bd01a..c3efa1b 100644 --- a/internal/tui/detail/detail.go +++ b/internal/tui/detail/detail.go @@ -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 "" } diff --git a/internal/tui/detail/spine_test.go b/internal/tui/detail/spine_test.go new file mode 100644 index 0000000..c68eb6c --- /dev/null +++ b/internal/tui/detail/spine_test.go @@ -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) + } +} diff --git a/internal/tui/header/header.go b/internal/tui/header/header.go index d4dd3f3..3642a07 100644 --- a/internal/tui/header/header.go +++ b/internal/tui/header/header.go @@ -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 } diff --git a/internal/tui/theme/issues.go b/internal/tui/theme/issues.go index 778752b..55ca2d2 100644 --- a/internal/tui/theme/issues.go +++ b/internal/tui/theme/issues.go @@ -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 diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go index 7f15a86..a2e23bc 100644 --- a/internal/tui/theme/theme.go +++ b/internal/tui/theme/theme.go @@ -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: ":", } }