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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 16:34:14 +02:00
co-authored by Claude Sonnet 5.5
parent aeedfe96d4
commit 4c1be3f9ab
19 changed files with 714 additions and 193 deletions
+70 -62
View File
@@ -4,6 +4,7 @@ package detail
import (
"fmt"
"path"
"slices"
"strings"
"github.com/charmbracelet/bubbles/help"
@@ -100,6 +101,14 @@ func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
}
st := s.Status
// the age of the last fetch is right-aligned in the name's line
if st.Presence == submodule.PresenceOK {
fetched := "fetched " + theme.Ago(st.FetchedAt)
if gap := m.width - ansi.StringWidth(s.Name) - len(fetched); gap >= 2 {
out[1] += strings.Repeat(" ", gap) + muted.Render(fetched)
}
}
issues := st.Issues()
for _, is := range issues {
out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail))
@@ -123,56 +132,15 @@ func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
}
out = append(out, "")
// ref
// ref and spine
if st.Pin.Ref != "" {
delta := t.Delta(st.Pin, lipgloss.NewStyle())
if delta != "" {
delta = " " + delta
}
out = append(out,
label("Ref")+text.Render(st.Pin.Ref)+delta,
label("")+muted.Render("via "+st.Pin.Source.String()),
)
}
if st.Presence == submodule.PresenceOK {
out = append(out, label("Fetched")+muted.Render(theme.Ago(st.FetchedAt)))
out = append(out, label("Ref")+text.Render(st.Pin.Ref)+muted.Render(" · via "+st.Pin.Source.String()), "")
}
out = append(out, m.spineLines(st.Spine(), st.Checkout == submodule.CheckoutDiffers)...)
if s.FetchErr != nil {
out = append(out, theme.Fg(t.Warn).Width(labelWidth).Render("fetch")+text.Render("failed: "+s.FetchErr.Error()))
}
// commits
commitLine := func(name string, c *submodule.Commit, highlight bool) string {
if c == nil {
return label(name) + muted.Render("—")
}
shaStyle := theme.Fg(t.Primary)
if highlight {
shaStyle = theme.Fg(t.Warn)
}
return label(name) + shaStyle.Render(c.SHA) + " " + text.Render(c.Subject)
}
if st.Root {
out = append(out, commitLine("HEAD", st.Pinned, false), commitLine("Tip", st.Tip, false))
} else {
out = append(out,
commitLine("Pinned", st.Pinned, false),
commitLine("Checkout", st.CheckedOut, st.Checkout == submodule.CheckoutDiffers),
commitLine("Tip", st.Tip, false),
)
}
// difference between pin and tip
if len(st.Between) > 0 {
out = append(out, "", text.Render(m.betweenTitle(st.Pin, st.Root)))
for _, c := range st.Between {
out = append(out,
" "+theme.Fg(t.Primary).Render(c.SHA)+" "+text.Render(c.Subject),
" "+muted.Render(fmt.Sprintf("%7s %s · %s", "", c.Author, theme.Ago(c.When))),
)
}
}
// children & meta
out = append(out, "")
if root {
@@ -213,25 +181,65 @@ func (m Model) hiddenParts(h submodule.Hidden) string {
return strings.Join(parts, theme.Fg(t.Muted).Render(", "))
}
// betweenTitle words the commit list; root says HEAD instead of Pin.
func (m Model) betweenTitle(p submodule.PinInfo, root bool) string {
subject := "Pin"
if root {
subject = "HEAD"
}
plural := func(n int) string {
if n == 1 {
return "commit"
// spineLines draws the pin, the tip and the checkout as one line of history,
// newest on top: a node per commit with its roles in the label column, and
// between two nodes how many commits separate them. offPin colors the
// checkout's own node.
func (m Model) spineLines(nodes []submodule.SpineNode, offPin bool) []string {
t := m.theme
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
labels := make([]string, len(nodes))
width := labelWidth
for i, n := range nodes {
words := make([]string, len(n.Roles))
for j, r := range n.Roles {
words[j] = theme.RoleWord(r)
}
return "commits"
labels[i] = strings.Join(words, " · ")
if n.InSync() {
labels[i] = theme.InSync
}
width = max(width, ansi.StringWidth(labels[i])+1)
}
switch {
case p.Ahead > 0 && p.Behind > 0:
return fmt.Sprintf("%s and %s have diverged (%s%d %s%d):",
subject, p.Ref, m.theme.Glyphs.Ahead, p.Ahead, m.theme.Glyphs.Behind, p.Behind)
case p.Ahead > 0:
return fmt.Sprintf("%s is %d %s ahead of %s:", subject, p.Ahead, plural(p.Ahead), p.Ref)
default:
return fmt.Sprintf("%s is %d %s behind %s:", subject, p.Behind, plural(p.Behind), p.Ref)
// a staged pin below the committed one moves the pin back
hasRole := func(n submodule.SpineNode, r submodule.Role) bool { return slices.Contains(n.Roles, r) }
pinAt := slices.IndexFunc(nodes, func(n submodule.SpineNode) bool { return hasRole(n, submodule.RolePin) })
stagedAt := slices.IndexFunc(nodes, func(n submodule.SpineNode) bool { return hasRole(n, submodule.RoleStaged) })
var out []string
for i, n := range nodes {
label, sha := muted, theme.Fg(t.Primary)
if hasRole(n, submodule.RoleStaged) {
label = theme.Fg(t.Info)
}
if offPin && len(n.Roles) == 1 && n.Roles[0] == submodule.RoleYou {
label, sha = theme.Fg(t.Warn), theme.Fg(t.Warn)
}
out = append(out, label.Width(width).Render(labels[i])+sha.Render(t.Glyphs.Node+" "+n.Commit.SHA)+" "+text.Render(n.Commit.Subject))
if i < len(nodes)-1 {
gap := muted.Render(t.Glyphs.Gap + m.linkText(n.Below))
if i+1 == stagedAt && pinAt >= 0 && pinAt < stagedAt && n.Below.Linear() {
gap += theme.Fg(t.Info).Render(" (staged pin moves back)")
}
out = append(out, strings.Repeat(" ", width)+gap)
}
}
return out
}
// linkText words the history between two nodes, with a leading space; empty
// if it is unknown.
func (m Model) linkText(l submodule.Link) string {
g := m.theme.Glyphs
switch {
case l.Diverged():
return fmt.Sprintf(" diverged %s%d %s%d", g.Ahead, l.Below, g.Behind, l.Above)
case l.Above == 1:
return " 1 commit"
case l.Above > 1:
return fmt.Sprintf(" %d commits", l.Above)
}
return ""
}
+75
View File
@@ -0,0 +1,75 @@
package detail
import (
"slices"
"testing"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
func TestSpineLines(t *testing.T) {
m := New(theme.Default(), nil)
st := submodule.Status{
Pinned: &submodule.Commit{SHA: "9ac01de", Subject: "Add node"},
CheckedOut: &submodule.Commit{SHA: "48005ac", Subject: "Merge branch"},
Tip: &submodule.Commit{SHA: "3f1bf20", Subject: "Fix deps"},
Pin: submodule.PinInfo{Behind: 4},
Drift: submodule.Distance{Behind: 1},
}
var got []string
for _, l := range m.spineLines(st.Spine(), true) {
got = append(got, ansi.Strip(l))
}
g := theme.Default().Glyphs
want := []string{
"tip " + g.Node + " 3f1bf20 Fix deps",
" " + g.Gap + " 4 commits",
"pin " + g.Node + " 9ac01de Add node",
" " + g.Gap + " 1 commit",
"you " + g.Node + " 48005ac Merge branch",
}
if !slices.Equal(got, want) {
t.Errorf("got\n%q\nwant\n%q", got, want)
}
}
func TestSpineLinesStagedMovesBack(t *testing.T) {
m := New(theme.Default(), nil)
st := submodule.Status{
Pinned: &submodule.Commit{SHA: "87a1059", Subject: "refactor"},
CheckedOut: &submodule.Commit{SHA: "87a1059", Subject: "refactor"},
PinStaged: true,
Committed: &submodule.Commit{SHA: "8f23a6e", Subject: "Merge"},
CommittedDrift: submodule.Distance{Ahead: 15},
}
var got []string
for _, l := range m.spineLines(st.Spine(), false) {
got = append(got, ansi.Strip(l))
}
g := theme.Default().Glyphs
want := []string{
"pin " + g.Node + " 8f23a6e Merge",
" " + g.Gap + " 15 commits (staged pin moves back)",
"staged · you " + g.Node + " 87a1059 refactor",
}
if !slices.Equal(got, want) {
t.Errorf("got\n%q\nwant\n%q", got, want)
}
}
func TestSpineLinesInSync(t *testing.T) {
m := New(theme.Default(), nil)
c := &submodule.Commit{SHA: "3f1bf20", Subject: "Fix deps"}
st := submodule.Status{Pinned: c, CheckedOut: c, Tip: c}
lines := m.spineLines(st.Spine(), false)
if len(lines) != 1 {
t.Fatalf("got %d lines, want 1", len(lines))
}
want := "in sync " + theme.Default().Glyphs.Node + " 3f1bf20 Fix deps"
if got := ansi.Strip(lines[0]); got != want {
t.Errorf("got %q, want %q", got, want)
}
}