Make the screen explain itself: labels, tools, help pages, hints

For developers new to submodules (notes/usability-plan.md, D32):
- x and X read "restore to pin" and "restore committed pins"
- problems only, filter and fetch are shown in the footer
- ? shows every key in titled columns, then a legend of the status
  words, symbols and spine roles; esc closes it
- u reads "pull (fast-forward)" on the superproject
- the detail pane suggests f when the last fetch is old
- a hint above the footer until the first key
- the header says "staged", like the tree

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 23:26:32 +02:00
co-authored by Claude Opus 5.5
parent b205576759
commit c9932932bb
19 changed files with 816 additions and 78 deletions
+17 -3
View File
@@ -6,6 +6,7 @@ import (
"path"
"slices"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
@@ -18,6 +19,9 @@ import (
const labelWidth = 11
// staleFetch is the age of the last fetch from which the pane suggests f.
const staleFetch = 24 * time.Hour
type Model struct {
theme theme.Theme
width int
@@ -38,6 +42,9 @@ func New(t theme.Theme, actions []key.Binding) Model {
// PaletteVisible reports whether the palette of submodule actions fits.
func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
// SetActions replaces the bindings of the palette.
func (m *Model) SetActions(actions []key.Binding) { m.actions = actions }
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
@@ -123,10 +130,17 @@ 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
// with a hint to fetch if the last fetch is old, and room for it
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)
fetched := muted.Render("fetched " + theme.Ago(st.FetchedAt))
if time.Since(st.FetchedAt) > staleFetch {
hinted := fetched + muted.Render(" · ") + text.Bold(true).Render("f") + muted.Render(" fetch")
if m.width-ansi.StringWidth(s.Name)-lipgloss.Width(hinted) >= 2 {
fetched = hinted
}
}
if gap := m.width - ansi.StringWidth(s.Name) - lipgloss.Width(fetched); gap >= 2 {
out[1] += strings.Repeat(" ", gap) + fetched
}
}
+43
View File
@@ -0,0 +1,43 @@
package detail
import (
"strings"
"testing"
"time"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
func TestFetchHintForOldFetches(t *testing.T) {
m := New(theme.Default(), nil)
m.SetSize(60, 20)
for _, c := range []struct {
fetched time.Time
hint bool
}{
{time.Time{}, true},
{time.Now().Add(-48 * time.Hour), true},
{time.Now().Add(-time.Hour), false},
} {
s := &submodule.Submodule{Path: "src/app", Name: "app", Status: &submodule.Status{
Presence: submodule.PresenceOK, FetchedAt: c.fetched, Pin: submodule.PinInfo{Pushed: true},
}}
got := ansi.Strip(m.View(s, nil))
if strings.Contains(got, "f fetch") != c.hint {
t.Errorf("fetched %s: hint %v, want %v:\n%s", theme.Ago(c.fetched), !c.hint, c.hint, got)
}
if !strings.Contains(got, "fetched "+theme.Ago(c.fetched)) {
t.Errorf("the age is missing:\n%s", got)
}
}
// too narrow for the hint: the age alone
m.SetSize(24, 20)
s := &submodule.Submodule{Path: "src/app", Name: "app", Status: &submodule.Status{Presence: submodule.PresenceOK}}
if got := ansi.Strip(m.View(s, nil)); strings.Contains(got, "f fetch") || !strings.Contains(got, "fetched never") {
t.Errorf("narrow pane:\n%s", got)
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ func TestPaletteWrapsInsteadOfCuttingKeys(t *testing.T) {
key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore to pin")),
}
m := New(theme.Default(), keys)
m.SetSize(36, 20)
@@ -24,7 +24,7 @@ func TestPaletteWrapsInsteadOfCuttingKeys(t *testing.T) {
t.Fatalf("want 2 lines, got %q", lines)
}
all := strings.Join(lines, "\n")
for _, want := range []string{"copy path", "update pin", "switch branch", "x restore"} {
for _, want := range []string{"copy path", "update pin", "switch branch", "x restore to pin"} {
if !strings.Contains(all, want) {
t.Errorf("palette lacks %q: %q", want, lines)
}