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