Archived
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>
185 lines
4.9 KiB
Go
185 lines
4.9 KiB
Go
package theme
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/x/ansi"
|
|
|
|
"submarine/internal/submodule"
|
|
)
|
|
|
|
// InSync is the word for a submodule without issues.
|
|
const InSync = "in sync"
|
|
|
|
// Staged is the word for a pin change staged in the superproject. It is not
|
|
// an issue: it replaces InSync, or follows an issue word as a marker.
|
|
const Staged = "staged"
|
|
|
|
// IssueWord is the short label for an issue kind shown in the tree.
|
|
func IssueWord(k submodule.IssueKind) string {
|
|
switch k {
|
|
case submodule.IssueMissing:
|
|
return "missing"
|
|
case submodule.IssueUnpushed:
|
|
return "unpushed"
|
|
case submodule.IssueConflict:
|
|
return "conflict"
|
|
case submodule.IssueOffPin:
|
|
return "off pin"
|
|
case submodule.IssueOffTarget:
|
|
return "off target"
|
|
default:
|
|
return "changed"
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
case submodule.RoleHead:
|
|
return "HEAD"
|
|
case submodule.RoleStaged:
|
|
return Staged
|
|
case submodule.RoleBase:
|
|
return "base"
|
|
case submodule.RoleTarget:
|
|
if target == "" {
|
|
return "target"
|
|
}
|
|
return ShortRef(target)
|
|
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
|
|
// background.
|
|
func (t Theme) StatusWord(s *submodule.Submodule, base lipgloss.Style, spinner string) string {
|
|
seg := func(c lipgloss.TerminalColor, text string) string { return base.Foreground(c).Render(text) }
|
|
switch {
|
|
case s.Err != nil:
|
|
return t.LevelStyle(submodule.LevelDanger, base).Render("error")
|
|
case s.Status == nil:
|
|
return seg(t.Primary, spinner)
|
|
}
|
|
if issues := s.Status.Issues(); len(issues) > 0 {
|
|
out := t.LevelStyle(issues[0].Level, base).Render(IssueWord(issues[0].Kind))
|
|
if len(issues) > 1 {
|
|
out += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
|
|
}
|
|
return out
|
|
}
|
|
if s.Status.PinStaged {
|
|
return seg(t.Info, Staged)
|
|
}
|
|
return seg(t.Muted, InSync)
|
|
}
|
|
|
|
// ChangeLetter is the one-letter label of a changed file.
|
|
func ChangeLetter(k submodule.ChangeKind) string {
|
|
switch k {
|
|
case submodule.ChangeConflicted:
|
|
return "!"
|
|
case submodule.ChangeModified:
|
|
return "M"
|
|
case submodule.ChangeAdded:
|
|
return "A"
|
|
case submodule.ChangeRemoved:
|
|
return "D"
|
|
case submodule.ChangeRenamed:
|
|
return "R"
|
|
default:
|
|
return "?"
|
|
}
|
|
}
|
|
|
|
// ChangeStyle returns base with the color of a change kind.
|
|
func (t Theme) ChangeStyle(k submodule.ChangeKind, base lipgloss.Style) lipgloss.Style {
|
|
switch k {
|
|
case submodule.ChangeConflicted:
|
|
return base.Foreground(t.Danger).Bold(true)
|
|
case submodule.ChangeModified:
|
|
return base.Foreground(t.Warn)
|
|
case submodule.ChangeAdded:
|
|
return base.Foreground(t.Added)
|
|
case submodule.ChangeRemoved:
|
|
return base.Foreground(t.Danger)
|
|
case submodule.ChangeRenamed:
|
|
return base.Foreground(t.Primary)
|
|
default:
|
|
return base.Foreground(t.Muted)
|
|
}
|
|
}
|
|
|
|
// LevelStyle returns base with the text color (and weight) for a level.
|
|
func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style {
|
|
switch l {
|
|
case submodule.LevelDanger:
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Delta renders ahead/behind counts like "↑1↓2", muted unless the pin is
|
|
// unpushed. Empty if both are zero. base is applied to every segment, e.g.
|
|
// to keep a row background.
|
|
func (t Theme) Delta(p submodule.PinInfo, base lipgloss.Style) string {
|
|
var s string
|
|
if p.Ahead > 0 {
|
|
c := t.Muted
|
|
if !p.Pushed {
|
|
c = t.Danger
|
|
}
|
|
s += base.Foreground(c).Render(fmt.Sprintf("%s%d", t.Glyphs.Ahead, p.Ahead))
|
|
}
|
|
if p.Behind > 0 {
|
|
s += base.Foreground(t.Muted).Render(fmt.Sprintf("%s%d", t.Glyphs.Behind, p.Behind))
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Ago formats a time relative to now, e.g. "5m ago"; the zero time is "never".
|
|
func Ago(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "never"
|
|
}
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
case d < 24*time.Hour:
|
|
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
default:
|
|
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
|
}
|
|
}
|