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:
+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