Archived
Compare pins with a target branch and flag pins off it
The ref of a pin was the closest branch containing it, so a pin on a feature branch looked like a perfect one, and the ref changed with the branch checked out in the submodule. Split it in two: - Pin.Target is the branch the pin should be on: `branch =` of .gitmodules (none if it does not exist locally), else the remote's default branch. - Pin.Ref is shown: the target if the pin is on it, else the branch containing the pin with the fewest commits after it. A tie goes to the upstream of the checked-out branch, then to the first name; no branch is a favorite. A pushed pin that is not on its target is `off target`, a new issue at the new level Info (blue), counted in the header and below collapsed rows. The spine shows the target's tip, labelled with its branch name, so the distance from it is visible; a diverged one is the fork. The role words in a spine label are styled separately and `you` is bold. Add Status.TargetTip, TargetDrift and Bases.Target, Pin.Target and Pin.Branches; drop RefSource.IsTarget. The detail pane's label column is 11 wide for the word `off target`. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ import (
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
|
||||
const labelWidth = 10
|
||||
const labelWidth = 11
|
||||
|
||||
type Model struct {
|
||||
theme theme.Theme
|
||||
@@ -136,7 +136,7 @@ func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
|
||||
if st.Pin.Ref != "" {
|
||||
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)...)
|
||||
out = append(out, m.spineLines(st.Spine(), st.Checkout == submodule.CheckoutDiffers, st.Pin.Target)...)
|
||||
if s.FetchErr != nil {
|
||||
out = append(out, theme.Fg(t.Warn).Width(labelWidth).Render("fetch")+text.Render("failed: "+s.FetchErr.Error()))
|
||||
}
|
||||
@@ -175,6 +175,9 @@ func (m Model) hiddenParts(h submodule.Hidden) string {
|
||||
if h.Warn > 0 {
|
||||
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
|
||||
}
|
||||
if h.Info > 0 {
|
||||
parts = append(parts, t.LevelStyle(submodule.LevelInfo, lipgloss.NewStyle()).Render(fmt.Sprintf("%d note(s)", h.Info)))
|
||||
}
|
||||
if h.Staged > 0 {
|
||||
parts = append(parts, theme.Fg(t.Info).Render(fmt.Sprintf("%d %s", h.Staged, theme.Staged)))
|
||||
}
|
||||
@@ -185,22 +188,37 @@ func (m Model) hiddenParts(h submodule.Hidden) string {
|
||||
// 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 {
|
||||
func (m Model) spineLines(nodes []submodule.SpineNode, offPin bool, target string) []string {
|
||||
t := m.theme
|
||||
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
|
||||
|
||||
// the roles of a node, each word in its own style: the checkout stands
|
||||
// out from the rest, and a staged pin is blue
|
||||
labels := make([]string, len(nodes))
|
||||
labelWidths := make([]int, 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)
|
||||
var plain, styled []string
|
||||
for _, r := range n.Roles {
|
||||
word := theme.RoleWord(r, target)
|
||||
style := muted
|
||||
switch {
|
||||
case r == submodule.RoleStaged:
|
||||
style = theme.Fg(t.Info)
|
||||
case r == submodule.RoleYou && offPin && len(n.Roles) == 1:
|
||||
style = theme.Fg(t.Warn)
|
||||
case r == submodule.RoleYou:
|
||||
style = text.Bold(true)
|
||||
}
|
||||
plain = append(plain, word)
|
||||
styled = append(styled, style.Render(word))
|
||||
}
|
||||
labels[i] = strings.Join(words, " · ")
|
||||
labels[i] = strings.Join(styled, muted.Render(" · "))
|
||||
labelWidths[i] = ansi.StringWidth(strings.Join(plain, " · "))
|
||||
if n.InSync() {
|
||||
labels[i] = theme.InSync
|
||||
labels[i], labelWidths[i] = muted.Render(theme.InSync), len(theme.InSync)
|
||||
}
|
||||
width = max(width, ansi.StringWidth(labels[i])+1)
|
||||
width = max(width, labelWidths[i]+1)
|
||||
}
|
||||
|
||||
// a staged pin below the committed one moves the pin back
|
||||
@@ -224,12 +242,9 @@ func (m Model) spineLines(nodes []submodule.SpineNode, offPin bool) []string {
|
||||
|
||||
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)
|
||||
}
|
||||
sha := theme.Fg(t.Primary)
|
||||
if offPin && len(n.Roles) == 1 && n.Roles[0] == submodule.RoleYou {
|
||||
label, sha = theme.Fg(t.Warn), theme.Fg(t.Warn)
|
||||
sha = theme.Fg(t.Warn)
|
||||
}
|
||||
node := sha.Render(t.Glyphs.Node)
|
||||
var cells string
|
||||
@@ -243,7 +258,7 @@ func (m Model) spineLines(nodes []submodule.SpineNode, offPin bool) []string {
|
||||
default:
|
||||
cells = lanes(node, " ")
|
||||
}
|
||||
out = append(out, label.Width(width).Render(labels[i])+cells+" "+sha.Render(n.Commit.SHA)+" "+text.Render(n.Commit.Subject))
|
||||
out = append(out, labels[i]+strings.Repeat(" ", width-labelWidths[i])+cells+" "+sha.Render(n.Commit.SHA)+" "+text.Render(n.Commit.Subject))
|
||||
if i < len(nodes)-1 {
|
||||
// the main lane starts below the fork's head, the fork's lane ends at the base
|
||||
main, side := " ", " "
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package detail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
@@ -20,16 +22,16 @@ func TestSpineLines(t *testing.T) {
|
||||
Drift: submodule.Distance{Behind: 1},
|
||||
}
|
||||
var got []string
|
||||
for _, l := range m.spineLines(st.Spine(), true) {
|
||||
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",
|
||||
lab("tip") + g.Node + " 3f1bf20 Fix deps",
|
||||
gap + g.Gap + " 4 commits",
|
||||
lab("pin") + g.Node + " 9ac01de Add node",
|
||||
gap + g.Gap + " 1 commit",
|
||||
lab("you") + g.Node + " 48005ac Merge branch",
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("got\n%q\nwant\n%q", got, want)
|
||||
@@ -46,7 +48,7 @@ func TestSpineLinesStagedMovesBack(t *testing.T) {
|
||||
CommittedDrift: submodule.Distance{Ahead: 15},
|
||||
}
|
||||
var got []string
|
||||
for _, l := range m.spineLines(st.Spine(), false) {
|
||||
for _, l := range m.spineLines(st.Spine(), false, "") {
|
||||
got = append(got, ansi.Strip(l))
|
||||
}
|
||||
g := theme.Default().Glyphs
|
||||
@@ -64,11 +66,11 @@ 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)
|
||||
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"
|
||||
want := lab("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)
|
||||
}
|
||||
@@ -83,18 +85,43 @@ func TestSpineLinesFork(t *testing.T) {
|
||||
Bases: submodule.Bases{Tip: c("a1b2c3d", "Release 1.4")},
|
||||
}
|
||||
var got []string
|
||||
for _, l := range m.spineLines(st.Spine(), false) {
|
||||
for _, l := range m.spineLines(st.Spine(), false, "") {
|
||||
got = append(got, ansi.Strip(l))
|
||||
}
|
||||
g := theme.Default().Glyphs
|
||||
want := []string{
|
||||
"tip " + " " + g.Node + " e1f2a3b Drop Go 1.21",
|
||||
" " + " " + g.Gap + " 2 commits",
|
||||
"pin · you " + g.Node + " " + g.Gap + " c4d5e6f Experimental",
|
||||
" " + g.Gap + " " + g.Gap + " 1 commit",
|
||||
"base " + g.Node + g.Rule + g.Join + " a1b2c3d Release 1.4",
|
||||
lab("tip") + " " + g.Node + " e1f2a3b Drop Go 1.21",
|
||||
gap + " " + g.Gap + " 2 commits",
|
||||
lab("pin · you") + g.Node + " " + g.Gap + " c4d5e6f Experimental",
|
||||
gap + g.Gap + " " + g.Gap + " 1 commit",
|
||||
lab("base") + g.Node + g.Rule + g.Join + " a1b2c3d Release 1.4",
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("got\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// lab pads a label like the label column; gap is the empty label column.
|
||||
func lab(s string) string { return fmt.Sprintf("%-*s", labelWidth, s) }
|
||||
|
||||
var gap = lab("")
|
||||
|
||||
func TestSpineLinesTargetName(t *testing.T) {
|
||||
m := New(theme.Default(), nil)
|
||||
c := func(sha string) *submodule.Commit { return &submodule.Commit{SHA: sha, Subject: "s"} }
|
||||
st := submodule.Status{
|
||||
Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: submodule.PinInfo{Behind: 1},
|
||||
TargetTip: c("m"), TargetDrift: submodule.Distance{Behind: 3},
|
||||
}
|
||||
for target, want := range map[string]string{
|
||||
"origin/marco-dev": "marco-dev",
|
||||
"origin/feature/a-very-long-branch-name": "feature/a-very-…",
|
||||
"": "target",
|
||||
} {
|
||||
lines := m.spineLines(st.Spine(), false, target)
|
||||
last := ansi.Strip(lines[len(lines)-1])
|
||||
if !strings.HasPrefix(last, want) {
|
||||
t.Errorf("target %q: last line %q, want label %q", target, last, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
|
||||
}
|
||||
for _, k := range []submodule.IssueKind{
|
||||
submodule.IssueUnpushed, submodule.IssueConflict, submodule.IssueMissing,
|
||||
submodule.IssueOffPin, submodule.IssueChanged,
|
||||
submodule.IssueOffPin, submodule.IssueChanged, submodule.IssueOffTarget,
|
||||
} {
|
||||
if n := perKind[k]; n > 0 {
|
||||
parts = append(parts, t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k))))
|
||||
|
||||
@@ -2,9 +2,11 @@ package theme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
@@ -27,13 +29,28 @@ func IssueWord(k submodule.IssueKind) string {
|
||||
return "conflict"
|
||||
case submodule.IssueOffPin:
|
||||
return "off pin"
|
||||
case submodule.IssueOffTarget:
|
||||
return "off target"
|
||||
default:
|
||||
return "changed"
|
||||
}
|
||||
}
|
||||
|
||||
// RoleWord is the label of a commit's role on the detail pane's spine.
|
||||
func RoleWord(r submodule.Role) string {
|
||||
// maxTargetWidth limits the branch name that labels the target on the spine.
|
||||
const maxTargetWidth = 16
|
||||
|
||||
// ShortRef is a remote ref without its remote, e.g. "marco-dev" for
|
||||
// "origin/marco-dev", cut to maxTargetWidth with an ellipsis.
|
||||
func ShortRef(ref string) string {
|
||||
if _, name, ok := strings.Cut(ref, "/"); ok {
|
||||
ref = name
|
||||
}
|
||||
return ansi.Truncate(ref, maxTargetWidth, "…")
|
||||
}
|
||||
|
||||
// RoleWord is the label of a commit's role on the detail pane's spine; the
|
||||
// target is labelled with the name of its branch.
|
||||
func RoleWord(r submodule.Role, target string) string {
|
||||
switch r {
|
||||
case submodule.RolePin:
|
||||
return "pin"
|
||||
@@ -43,6 +60,11 @@ func RoleWord(r submodule.Role) string {
|
||||
return Staged
|
||||
case submodule.RoleBase:
|
||||
return "base"
|
||||
case submodule.RoleTarget:
|
||||
if target == "" {
|
||||
return "target"
|
||||
}
|
||||
return ShortRef(target)
|
||||
case submodule.RoleTip:
|
||||
return "tip"
|
||||
default:
|
||||
@@ -118,6 +140,8 @@ func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style
|
||||
return base.Foreground(t.Danger).Bold(true)
|
||||
case submodule.LevelWarn:
|
||||
return base.Foreground(t.Warn)
|
||||
case submodule.LevelInfo:
|
||||
return base.Foreground(t.Info)
|
||||
default:
|
||||
return base.Foreground(t.Muted)
|
||||
}
|
||||
|
||||
@@ -483,7 +483,7 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
|
||||
for _, c := range []struct {
|
||||
level submodule.Level
|
||||
n int
|
||||
}{{submodule.LevelDanger, h.Danger}, {submodule.LevelWarn, h.Warn}} {
|
||||
}{{submodule.LevelDanger, h.Danger}, {submodule.LevelWarn, h.Warn}, {submodule.LevelInfo, h.Info}} {
|
||||
if c.n == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user