diff --git a/README.md b/README.md index f678984..f790cc1 100644 --- a/README.md +++ b/README.md @@ -168,15 +168,15 @@ files. | `H` `L` / `⇧←` `⇧→` | Collapse / expand all | | `space` | Toggle the selected submodule | | `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page | -| `/` | Filter | | `!` | Problems only | +| `/` | Filter | | `c` | Copy path | | `u` | Update: move a pin that is behind to the tip of its branch (staged, like `b`); on the header, fast-forward the superproject | | `b` | Switch branch: pick a local or remote branch; its tip is staged and the repository restored to it | -| `x` | Restore: make the worktree exactly what git records (asks if work would be lost) | -| `X` | Hard restore: like `x`, but to the committed pins, dropping all staged pins below | +| `x` | Restore to pin: make the worktree exactly what git records (asks if work would be lost) | +| `X` | Restore committed pins: like `x`, but dropping all staged pins below (asks first) | | `f` / `r` | Fetch / reload | -| `?` | Full help | +| `?` | Full help: every key, then what the words and symbols mean (`esc` closes it) | | `q` | Quit. If a newer release exists, the footer offers `q quit and update to vX` (highlighted) and plain quit moves to `Q` | The mouse works too: click to select, click `▸` to toggle, wheel to move. In diff --git a/docs/README.md b/docs/README.md index 5273f7e..bedeedc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,3 +42,7 @@ Next steps, roughly in order: 1. (done) Write actions: restore, switch branch, update pin, see [actions.md](actions.md). Stage pin was dropped (D29). 2. (done) "Problems only" (`!`) view, reusing the tree's filter. +3. Usability pass for users new to submodules, see + [notes/usability-plan.md](../notes/usability-plan.md). The low-effort + items are done (D31, D32); next are the issue hints and the palette + that fits the selection. diff --git a/docs/actions.md b/docs/actions.md index 1a574f9..8613012 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -390,13 +390,14 @@ runs the switch flow with it (`SwitchLoss`, `Switch`). All three are submodule keys (`keyMap.Submodule`), shown in the detail pane's palette, or in the footer when the pane can't show it. They are always offered, the palette is static: restore is idempotent, and `u` says -why it can't update. +why it can't update. Only `u`'s label follows the selection: on the header +it reads `pull (fast-forward)`. | Key | Action | Pressed when not possible (flash) | |---|---|---| -| `x` restore | Restore the selection | `this data source can't restore` (mock); `wait for the restore of … to finish` | +| `x` restore to pin, `X` restore committed pins | Restore the selection; `X` see [Hard restore](#hard-restore) | `this data source can't restore` (mock); `wait for the restore of … to finish` | | `b` switch branch | [Switch branch](#switch-branch) | `this data source can't switch branches`; `wait for the restore of … to finish`; `src/app is not initialized; restore it first` | -| `u` update pin | [Update pin](#update-pin) | `this data source can't update pins`; `wait for the restore of … to finish`; `the superproject has no upstream branch`; `src/app is still loading`; `src/app is not initialized; restore it first`; `the pin of src/app is not on a branch`; `the pin of src/app has diverged from origin/main; b switches the branch`; `the pin of src/app is ahead of origin/main`; `src/app is up to date with origin/main` (the superproject without `the pin of`); after the check `can't update src/app: main is a local branch; b switches to it` | +| `u` update pin (`pull (fast-forward)` on the header) | [Update pin](#update-pin) | `this data source can't update pins`; `wait for the restore of … to finish`; `the superproject has no upstream branch`; `src/app is still loading`; `src/app is not initialized; restore it first`; `the pin of src/app is not on a branch`; `the pin of src/app has diverged from origin/main; b switches the branch`; `the pin of src/app is ahead of origin/main`; `src/app is up to date with origin/main` (the superproject without `the pin of`); after the check `can't update src/app: main is a local branch; b switches to it` | `R` would sit next to `r` (reload), a dangerous mix-up for a destructive action, hence `x`. diff --git a/docs/architecture.md b/docs/architecture.md index f644471..0a78be1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ internal/tui/ Bubble Tea application detail/ detail pane; changes.go: change preview header/ header line branches/ branch picker popup (search line + list) - footer/ key bar (navigation left, global right) and flash messages + footer/ key bar (navigation and tools left, global right), full help pages (keys, legend) and flash messages ``` ### Layering rules diff --git a/docs/decisions.md b/docs/decisions.md index cfeb00b..7e9ccac 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -608,3 +608,29 @@ submodule's own staged pin (in its parent's index), which `lossOf` doesn't see. The `c` answer of `x` stays silent: the user picked it in a question. **Consequences.** `X` asks more often, but only where it changes what is recorded. Without staged pins or other losses it still runs at once. + +## D32 — The screen explains itself + +**Context.** The target user is a developer who doesn't know submodules +well and doesn't read manuals. The walkthrough found labels that don't say +what a key does (`x restore`, `X hard restore`), important keys found only +in `?` (`!`, `/`, `f`), and status words explained nowhere. +**Decision.** +- Labels name the target: `x restore to pin`, `X restore committed pins`. + "Hard" suggested "discards changes", which both do. +- A fourth key group, Tools (`!`, `/`, `f`), in the footer after the + navigation keys, dropped before them. +- On the header, `u` reads `pull (fast-forward)`: the label follows the + selection, the key doesn't. +- The full help gets titled columns, and a second page, a legend of the + status words, symbols and spine roles in their colors. Issue levels move + to `IssueKind.Level()` so the legend can't drift from the tree. `esc` + closes either page: cycling back with `?` alone was not obvious. +- A start hint above the footer until the first key. +- `fetched · f fetch` in the detail pane when the last fetch is older + than a day. +**Alternatives.** A first-run screen or tutorial: needs state, and is +skipped. Contextual palettes that grey out impossible keys: larger, kept for +later (item 10 of the usability plan). +**Consequences.** `footer.Keys` drops `help.KeyMap` for `Tools`, +`HelpGroups` and `LegendKeys`; the footer renders the full help itself. diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 8962c88..9d21f27 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -26,7 +26,7 @@ my_submodule_3 unpushed origin/main ↑1 │ ┊ 1 commit libs/ │ tip ● 6f5e4d3 Release 2.1.0 logging origin/main ↓3 │ - ↑↓/jk move • ←→/hl fold • ⇧←→/HL fold all • space toggle • ? help • q quit + ↑↓/jk move • ←→/hl collapse/expand • ⇧←→/HL all • space toggle • ! problems only • / filter • f fetch ? help • q quit ``` | Area | Height / width | @@ -34,14 +34,18 @@ | Header | 1 line | | Tree | Remaining width | | Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns | -| Filter line | 1 line above the footer, only while filtering | +| Line above the footer | 1 line, only while filtering, showing problems only, during a restore, after a failure, or until the first key (hint) | | Footer | 1 line (more with full help) | ### Key bar and palette -Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`): +Keys come in four groups (`keyMap.Navigation`, `.Tools`, `.Submodule`, +`.Global`): -- **Navigation** (move, fold, toggle): left in the footer. +- **Navigation** (move, collapse/expand, toggle): left in the footer. +- **Tools** (`!` problems only, `/` filter, `f` fetch): in the footer after + the navigation keys, most useful first. They act on the whole list and + would otherwise be found only in the full help (D32). - **Global** (`?`, `q`): right-aligned in the footer, always kept. If a newer release exists, `q quit and update to vX` follows them as the right-most item, on a highlighted background (`Theme.Highlight`: black on Primary), and @@ -50,11 +54,36 @@ Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`): bottom of the detail pane, wrapped onto a second line if the pane is too narrow for all keys (none is cut off). It is left out if the pane is shorter than 12 lines. Whenever the palette isn't shown (pane hidden or too short), these - keys move into the footer after the navigation keys. + keys move into the footer after the tools. With the header selected, `u` + reads `u pull (fast-forward)`, which is what it does there (D29). If the footer is too narrow, keys are dropped from the left group's end -(submodule keys first); the global keys stay. Full help (`?`) lists all keys -in columns, whatever the size. Keys work whether or not they are shown. +(submodule keys first, then the tools); the global keys stay. Keys work +whether or not they are shown. + +### Full help + +`?` steps through two pages, then back to the key bar; `esc` closes either +page at once (before it would close the filter): + +1. **Keys**: every key in titled columns: `Move`, `Jump`, `Selected + submodule`, `All submodules`, `App`. The `App` column says `? what words + mean` and `esc close`. +2. **Legend**: what the words and symbols mean, in their colors: `Status` + (each issue word, `staged`, `in sync`), `Symbols` (`●`, `↑n ↓n`, `+n`, + `·k`, `[n]`), `Commits` (the spine's roles). Then `App` with `esc close` + (`?` closes it too). + The words come from `Theme.Legend()`; issue colors from + `IssueKind.Level()`, so they are the tree's. + +Titles are muted and bold. Columns that don't fit are left out from the +right, marked with `…`; the `App` column is always kept. + +### Start hint + +Until the first key, the line above the footer reads muted `new here? ? +shows every key and what the words mean`. Every start, no state is kept. +Any other line above the footer (filter, restore, …) takes precedence. ### Tree row @@ -85,7 +114,9 @@ For the superproject (header selected), the `pin` and `you` roles become 1. Directory (muted) and name (bold); `fetched ` of the submodule, muted, right-aligned in the name's line (left out if the pane is too - narrow, or the submodule is missing) + narrow, or the submodule is missing). If the last fetch is older than a + day, or there was none, it continues `· f fetch` (key bold), if there is + room 2. One line per issue: word (colored, 11 columns) + explanation; `in sync nothing to do` if there are none and no pin is staged; `staged` and `detached HEAD on pin` as information @@ -213,7 +244,7 @@ shows the superproject's own state word like a tree row (`in sync`, `changed`, `unpushed +1`, spinner, `error`). The counts that follow are about submodules only. -`path branch ↑↓ state n error · … · n pin staged · n loading · n submodules · ` +`path branch ↑↓ state n error · … · n staged · n loading · n submodules · ` The summary and `` form one group at the right edge; the path, branch and state stay at the left. If the line is too narrow it is cut with @@ -287,7 +318,8 @@ colors in components. | `H` / `shift+←` / `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor | | `home` / `g`, `end` / `G` | Header (superproject) / last submodule | | `pgup`, `pgdn` | Move by one screen | -| `?` | Toggle full help | +| `?` | Full help: keys, then the legend, then closed, see [Full help](#full-help) | +| `esc` | Close the full help; else close the filter | | `q`, `ctrl+c` | Quit. With a newer release: `q` quits and updates, `Q` and `ctrl+c` quit | | `f` | Fetch all repositories (does nothing while a fetch runs) | | `r` | Reload everything (e.g. after editing files) | @@ -295,7 +327,7 @@ colors in components. | `/` | Open the filter, see [Filter](#filter) | | `!` | Toggle problems only, see [Problems only](#problems-only) | | `x` | Restore the selection, see [Restore](#restore) | -| `X` | Hard restore the selection: committed pins, staged pins dropped at every depth, see [Restore](#restore) | +| `X` | Restore the selection to the committed pins, staged pins dropped at every depth, see [Restore](#restore) | "First child" always means the first child **as displayed** (children are grouped by directory). @@ -425,15 +457,16 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette | `home` / `g`, `end` / `G` | Header / last submodule | Navigation | Help | | `pgup`, `pgdn` | Move by one screen | Navigation | Help | | `c` | Copy path | Submodule | Palette, Help | -| `u` | Update pin to the tip of its ref (superproject: fast-forward to upstream) | Submodule | Palette, Help | +| `u` | Update pin to the tip of its ref (superproject: fast-forward to upstream, label `pull (fast-forward)`) | Submodule | Palette, Help | | `b` | Switch branch (popup) | Submodule | Palette, Help | -| `x` | Restore | Submodule | Palette, Help | -| `X` | Hard restore | Submodule | Palette, Help | -| `f` | Fetch all repositories | Global | Help | +| `x` | Restore to pin | Submodule | Palette, Help | +| `X` | Restore committed pins | Submodule | Palette, Help | +| `!` | Problems only (toggle) | Tools | Bar, Help | +| `/` | Filter | Tools | Bar, Help | +| `f` | Fetch all repositories | Tools | Bar, Help | | `r` | Reload everything | Global | Help | -| `/` | Filter | Global | Help | -| `!` | Problems only (toggle) | Global | Help | -| `?` | Toggle full help | Global | Bar, Help | +| `?` | Full help: keys, legend, close | Global | Bar, Help | +| `esc` | Close the full help (and the filter, see [Filter](#filter)) | Global | Help | | `q`, `ctrl+c` | Quit (`Q`, `ctrl+c` while an update is offered) | Global | Bar, Help | | `q` | Quit and update to the newer release (only if there is one) | Global | Bar (highlighted), Help | diff --git a/internal/submodule/status.go b/internal/submodule/status.go index e67a887..d8607d8 100644 --- a/internal/submodule/status.go +++ b/internal/submodule/status.go @@ -24,6 +24,21 @@ const ( IssueOffTarget // pin pushed, but not on the target branch ) +// Kinds lists every issue kind, most severe first. +var Kinds = []IssueKind{IssueUnpushed, IssueConflict, IssueMissing, IssueOffPin, IssueChanged, IssueOffTarget} + +// Level is how much attention an issue of kind k deserves. +func (k IssueKind) Level() Level { + switch k { + case IssueUnpushed, IssueConflict: + return LevelDanger + case IssueOffTarget: + return LevelInfo + default: + return LevelWarn + } +} + // Issue is one problem of a submodule. Detail distinguishes the states that // are summarized into one kind. type Issue struct { @@ -40,9 +55,9 @@ func (s *Status) Issues() []Issue { } switch s.Presence { case NotInitialized: - return []Issue{{IssueMissing, LevelWarn, "not initialized"}} + return []Issue{{IssueMissing, IssueMissing.Level(), "not initialized"}} case DirMissing: - return []Issue{{IssueMissing, LevelWarn, "directory missing"}} + return []Issue{{IssueMissing, IssueMissing.Level(), "directory missing"}} } var out []Issue @@ -51,24 +66,24 @@ func (s *Status) Issues() []Issue { if s.Root { detail = "HEAD not pushed to any remote" } - out = append(out, Issue{IssueUnpushed, LevelDanger, detail}) + out = append(out, Issue{IssueUnpushed, IssueUnpushed.Level(), detail}) } if s.Worktree == WorktreeConflict { - out = append(out, Issue{IssueConflict, LevelDanger, "merge conflict"}) + out = append(out, Issue{IssueConflict, IssueConflict.Level(), "merge conflict"}) } if s.Checkout == CheckoutDiffers { - out = append(out, Issue{IssueOffPin, LevelWarn, "checkout differs from pin"}) + out = append(out, Issue{IssueOffPin, IssueOffPin.Level(), "checkout differs from pin"}) } switch s.Worktree { case WorktreeModified: - out = append(out, Issue{IssueChanged, LevelWarn, "uncommitted changes"}) + out = append(out, Issue{IssueChanged, IssueChanged.Level(), "uncommitted changes"}) case WorktreeUntracked: - out = append(out, Issue{IssueChanged, LevelWarn, s.untrackedText()}) + out = append(out, Issue{IssueChanged, IssueChanged.Level(), s.untrackedText()}) } // a pin that is pushed, but not on the target (usually main); an // unpushed pin is not on it either, but has the worse issue if !s.Root && s.Pin.Pushed && s.Pin.Target != "" && s.Pin.Ref != s.Pin.Target { - out = append(out, Issue{IssueOffTarget, LevelInfo, s.offTargetText()}) + out = append(out, Issue{IssueOffTarget, IssueOffTarget.Level(), s.offTargetText()}) } return out } diff --git a/internal/tui/app.go b/internal/tui/app.go index 56b3104..3355bd6 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -60,6 +60,7 @@ type Model struct { picker *branches.Model // the branch picker, nil if closed pickerPath string // the repository the picker is for notice string // an error to show above the footer until the next key + hint bool // the hint for newcomers above the footer, until the first key checkUpdate func(context.Context) string // looks for a newer release, nil if off update string // the newer release found, "" if none @@ -104,6 +105,7 @@ func New(ctx context.Context, p submodule.Provider, opts ...Option) Model { footer: footer.New(t, k), spinner: sp, filter: fi, + hint: true, } m.fetcher, _ = p.(submodule.Fetcher) m.watcher, _ = p.(submodule.Watcher) @@ -385,14 +387,17 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.answerRestore(msg) } } - if m.notice != "" { - m.notice = "" + if m.notice != "" || m.hint { + m.notice, m.hint = "", false m.layout() } if m.filter.Focused() { return m.typeFilter(msg) } switch { + case m.footer.HelpOpen() && msg.Type == tea.KeyEsc: // before the filter's esc + m.footer.CloseHelp() + m.layout() case m.filtering && msg.Type == tea.KeyEsc: m.closeFilter() case key.Matches(msg, k.Quit): @@ -401,7 +406,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.updateTo = m.update return m, tea.Quit case key.Matches(msg, k.Help): - m.footer.ToggleHelp() + m.footer.CycleHelp() m.layout() case key.Matches(msg, k.Up): m.tree.Up() @@ -517,10 +522,16 @@ func (m Model) filterView() string { } // extraLine reports whether a line above the footer is shown: a restore in -// progress, a failure, the filter, or the notice that only problems are -// shown. +// progress, a failure, the filter, the notice that only problems are shown, +// or the hint for newcomers. func (m Model) extraLine() bool { - return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != "" + return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != "" || m.hint +} + +// hintView is the line above the footer until the first key: where help is. +func (m Model) hintView() string { + muted := theme.Fg(m.theme.Muted) + return m.line(muted.Render("new here? ")+theme.Fg(m.theme.Text).Bold(true).Render("?")+muted.Render(" shows every key and what the words mean"), "") } // problemsView is the line above the footer while only problems are shown, so @@ -581,6 +592,11 @@ func (m Model) View() string { return theme.Fg(m.theme.Danger).Render("error: " + m.err.Error()) } + // the keys as they read for the selection; m is a copy + k := m.keys.forSelection(m.tree.RootSelected()) + m.footer.SetKeys(k) + m.detail.SetActions(k.Submodule()) + body := m.tree.View() if m.showDetail { pane := lipgloss.NewStyle(). @@ -601,6 +617,8 @@ func (m Model) View() string { parts = append(parts, m.filterView()) } else if m.tree.ProblemsOnly() { parts = append(parts, m.problemsView()) + } else if m.hint { + parts = append(parts, m.hintView()) } view := zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...)) if m.picker != nil { // after zone.Scan, so no zone marker is cut diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go index cb7f126..784dcd0 100644 --- a/internal/tui/detail/detail.go +++ b/internal/tui/detail/detail.go @@ -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 } } diff --git a/internal/tui/detail/fetch_test.go b/internal/tui/detail/fetch_test.go new file mode 100644 index 0000000..d03799e --- /dev/null +++ b/internal/tui/detail/fetch_test.go @@ -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) + } +} diff --git a/internal/tui/detail/palette_test.go b/internal/tui/detail/palette_test.go index c997e79..7184fde 100644 --- a/internal/tui/detail/palette_test.go +++ b/internal/tui/detail/palette_test.go @@ -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) } diff --git a/internal/tui/footer/footer.go b/internal/tui/footer/footer.go index 11a816c..387ec24 100644 --- a/internal/tui/footer/footer.go +++ b/internal/tui/footer/footer.go @@ -2,6 +2,7 @@ package footer import ( + "slices" "strings" "time" @@ -18,18 +19,39 @@ const flashDuration = 2 * time.Second type clearFlashMsg struct{ id int } -// Keys provides the key groups. The short bar shows Navigation (and +// Keys provides the key groups. The short bar shows Navigation, Tools (and // Submodule, if requested) on the left, Global and then Highlight (on a -// highlighted background) on the right; full help -// shows FullHelp. +// highlighted background) on the right. The full help has two pages: the +// HelpGroups (the last one is always kept), then the theme's legend with +// LegendKeys next to it. type Keys interface { - help.KeyMap Navigation() []key.Binding + Tools() []key.Binding Submodule() []key.Binding Global() []key.Binding Highlight() []key.Binding + HelpGroups() []Group + LegendKeys() Group } +// Group is a titled column of the full help. +type Group struct { + Title string + Keys []key.Binding +} + +// helpPage is what the footer shows besides a flash. +type helpPage int + +const ( + pageBar helpPage = iota // one line of key hints + pageKeys // every key, in titled columns + pageLegend // what the words and symbols mean +) + +// columnGap separates the columns of the full help. +const columnGap = " " + type Model struct { theme theme.Theme help help.Model @@ -37,6 +59,7 @@ type Model struct { width int flash string flashID int + page helpPage actions bool // show the submodule bindings in the short bar } @@ -56,8 +79,18 @@ func (m *Model) SetWidth(w int) { m.help.Width = w - 2 } -// ToggleHelp switches between short and full key help. -func (m *Model) ToggleHelp() { m.help.ShowAll = !m.help.ShowAll } +// CycleHelp shows the next help page: the short bar, every key, the legend, +// then the short bar again. +func (m *Model) CycleHelp() { m.page = (m.page + 1) % 3 } + +// HelpOpen reports whether a full help page is shown. +func (m Model) HelpOpen() bool { return m.page != pageBar } + +// CloseHelp goes back to the short bar from any help page. +func (m *Model) CloseHelp() { m.page = pageBar } + +// Legend reports whether the legend is shown. +func (m Model) Legend() bool { return m.page == pageLegend } // Flash shows msg for a short time instead of the key hints. func (m *Model) Flash(msg string) tea.Cmd { @@ -78,14 +111,102 @@ func (m Model) View() string { if m.flash != "" { return style.Foreground(m.theme.Warn).Render(m.flash) } - if m.help.ShowAll { - return style.Render(m.help.View(m.keys)) + switch m.page { + case pageKeys: + var cols []column + for _, g := range m.keys.HelpGroups() { + cols = append(cols, m.keyColumn(g)) + } + return style.Render(m.columns(cols[:len(cols)-1], cols[len(cols)-1], m.width-2)) + case pageLegend: + var cols []column + for _, g := range m.theme.Legend() { + c := column{title: g.Title} + for _, r := range g.Rows { + c.rows = append(c.rows, [2]string{r.Word, m.help.Styles.FullDesc.Render(r.Meaning)}) + } + cols = append(cols, c) + } + return style.Render(m.columns(cols, m.keyColumn(m.keys.LegendKeys()), m.width-2)) } return style.Render(m.shortBar(m.width - 2)) } +// column is a titled column of the full help: rows of a rendered key (or +// word) and its description. +type column struct { + title string + rows [][2]string +} + +func (m Model) keyColumn(g Group) column { + c := column{title: g.Title} + for _, b := range g.Keys { + if b.Enabled() { + c.rows = append(c.rows, [2]string{m.help.Styles.FullKey.Render(b.Help().Key), m.help.Styles.FullDesc.Render(b.Help().Desc)}) + } + } + return c +} + +// columns lays cols out side by side, each under its title, and last at the +// end. Like the global keys in the short bar, last is always kept: the +// columns before it that don't fit into w are left out from the right, +// marked with an ellipsis. +func (m Model) columns(cols []column, last column, w int) string { + tail := m.block(last) + avail := w - lipgloss.Width(tail) - len(columnGap) + var out []string + width := 0 + for i, c := range cols { + b := m.block(c) + bw := lipgloss.Width(b) + len(columnGap) + if i == 0 { + bw -= len(columnGap) + } + if width+bw > avail { + for len(out) > 0 && width+2 > avail { // room for the ellipsis + width -= lipgloss.Width(out[len(out)-1]) + len(columnGap) + out = out[:len(out)-1] + } + if width+2 <= avail { + out = append(out, " "+m.help.Styles.Ellipsis.Render("…")) + } + break + } + out = append(out, b) + width += bw + } + var parts []string + for i, b := range out { + if i > 0 && !strings.HasPrefix(b, " ") { + parts = append(parts, columnGap) + } + parts = append(parts, b) + } + if len(parts) > 0 { + parts = append(parts, columnGap) + } + parts = append(parts, tail) + return lipgloss.NewStyle().MaxWidth(w).Render(lipgloss.JoinHorizontal(lipgloss.Top, parts...)) +} + +// block renders a column: its title, then its rows with the descriptions +// aligned. +func (m Model) block(c column) string { + left := 0 + for _, r := range c.rows { + left = max(left, lipgloss.Width(r[0])) + } + lines := []string{theme.Fg(m.theme.Muted).Bold(true).Render(c.title)} + for _, r := range c.rows { + lines = append(lines, r[0]+strings.Repeat(" ", left-lipgloss.Width(r[0])+1)+r[1]) + } + return strings.Join(lines, "\n") +} + // shortBar puts the global keys on the right and as many of the others on the -// left as fit; the submodule keys are dropped first. +// left as fit; the submodule keys are dropped first, then the tools. func (m Model) shortBar(w int) string { h := m.help h.Width = 0 @@ -97,9 +218,9 @@ func (m Model) shortBar(w int) string { } right += " " + m.theme.Highlight().Render(" "+strings.Join(parts, " ")+" ") } - left := m.keys.Navigation() + left := append(slices.Clip(m.keys.Navigation()), m.keys.Tools()...) if m.actions { - left = append(left[:len(left):len(left)], m.keys.Submodule()...) + left = append(left, m.keys.Submodule()...) } for ; ; left = left[:len(left)-1] { l := h.ShortHelpView(left) diff --git a/internal/tui/footer/footer_test.go b/internal/tui/footer/footer_test.go index 117bc12..c8d8214 100644 --- a/internal/tui/footer/footer_test.go +++ b/internal/tui/footer/footer_test.go @@ -15,14 +15,25 @@ type testKeys struct{ highlight []key.Binding } func bind(k, desc string) key.Binding { return key.NewBinding(key.WithKeys(k), key.WithHelp(k, desc)) } -func (testKeys) ShortHelp() []key.Binding { return nil } -func (testKeys) FullHelp() [][]key.Binding { return nil } func (testKeys) Navigation() []key.Binding { return []key.Binding{bind("j", "move"), bind("h", "fold")} } -func (testKeys) Submodule() []key.Binding { return nil } +func (testKeys) Tools() []key.Binding { + return []key.Binding{bind("!", "problems only"), bind("/", "filter"), bind("f", "fetch")} +} +func (testKeys) Submodule() []key.Binding { return []key.Binding{bind("x", "restore to pin")} } func (testKeys) Global() []key.Binding { return []key.Binding{bind("?", "help"), bind("Q", "quit")} } func (k testKeys) Highlight() []key.Binding { return k.highlight } +func (testKeys) HelpGroups() []Group { + return []Group{ + {Title: "Move", Keys: []key.Binding{bind("j", "down"), bind("k", "up")}}, + {Title: "Selected submodule", Keys: []key.Binding{bind("x", "restore to pin")}}, + {Title: "App", Keys: []key.Binding{bind("?", "what words mean"), bind("Q", "quit")}}, + } +} +func (testKeys) LegendKeys() Group { + return Group{Title: "App", Keys: []key.Binding{bind("esc", "close")}} +} func bar(w int, hl ...key.Binding) string { m := New(theme.Default(), testKeys{hl}) @@ -52,3 +63,77 @@ func TestNavigationGivesWayToUpdate(t *testing.T) { t.Errorf("the right group must be kept:\n%q", got) } } + +func TestToolsFollowNavigation(t *testing.T) { + got := bar(80) + if !strings.Contains(got, "h fold • ! problems only • / filter • f fetch") { + t.Errorf("tools must follow the navigation keys:\n%q", got) + } +} + +func TestToolsGiveWayBeforeNavigation(t *testing.T) { + got := bar(40) + if strings.Contains(got, "f fetch") || !strings.Contains(got, "j move") { + t.Errorf("tools must go before navigation:\n%q", got) + } +} + +func TestSubmoduleKeysGoFirst(t *testing.T) { + m := New(theme.Default(), testKeys{}) + m.SetActions(true) + m.SetWidth(80) + got := ansi.Strip(m.View()) + if strings.Contains(got, "restore to pin") || !strings.Contains(got, "f fetch") { + t.Errorf("submodule keys must go before the tools:\n%q", got) + } + m.SetWidth(120) + if got := ansi.Strip(m.View()); !strings.Contains(got, "f fetch • x restore to pin") { + t.Errorf("submodule keys follow the tools:\n%q", got) + } +} + +func TestHelpPages(t *testing.T) { + m := New(theme.Default(), testKeys{}) + m.SetWidth(120) + m.CycleHelp() + keys := ansi.Strip(m.View()) + for _, want := range []string{"Move", "Selected submodule", "App", "x restore to pin", "? what words mean"} { + if !strings.Contains(keys, want) { + t.Errorf("key page lacks %q:\n%s", want, keys) + } + } + m.CycleHelp() + if !m.Legend() { + t.Fatal("second page must be the legend") + } + legend := ansi.Strip(m.View()) + for _, want := range []string{"Status", "off pin", "Symbols", "Commits", "esc close"} { + if !strings.Contains(legend, want) { + t.Errorf("legend lacks %q:\n%s", want, legend) + } + } + for _, l := range strings.Split(m.View(), "\n") { + if w := lipgloss.Width(l); w > 120 { + t.Errorf("legend line is %d wide: %q", w, ansi.Strip(l)) + } + } + m.CycleHelp() + if got := ansi.Strip(m.View()); !strings.Contains(got, "? help") { + t.Errorf("third ? must close the help:\n%q", got) + } +} + +func TestHelpLeavesOutColumnsThatDontFit(t *testing.T) { + m := New(theme.Default(), testKeys{}) + m.SetWidth(30) + m.CycleHelp() + got := ansi.Strip(m.View()) + if !strings.Contains(got, "App") || strings.Contains(got, "Selected") || !strings.Contains(got, "…") { + t.Errorf("want App kept, the columns before it left out with an ellipsis:\n%s", got) + } + for _, l := range strings.Split(m.View(), "\n") { + if w := lipgloss.Width(l); w > 30 { + t.Errorf("line is %d wide: %q", w, ansi.Strip(l)) + } + } +} diff --git a/internal/tui/header/header.go b/internal/tui/header/header.go index 8cb9d17..330c96c 100644 --- a/internal/tui/header/header.go +++ b/internal/tui/header/header.go @@ -72,16 +72,13 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt if errored > 0 { 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, submodule.IssueOffTarget, - } { + for _, k := range submodule.Kinds { if n := perKind[k]; n > 0 { parts = append(parts, t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k)))) } } if staged > 0 { - parts = append(parts, seg(t.Info, fmt.Sprintf("%d pin staged", staged))) + parts = append(parts, seg(t.Info, fmt.Sprintf("%d %s", staged, theme.Staged))) } if loading > 0 { parts = append(parts, seg(t.Muted, fmt.Sprintf("%d loading", loading))) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 917ba8f..c8ae0cc 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -2,6 +2,8 @@ package tui import ( "github.com/charmbracelet/bubbles/key" + + "submarine/internal/tui/footer" ) type keyMap struct { @@ -31,8 +33,8 @@ func defaultKeys() keyMap { Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")), Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")), - Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")), - HardRestore: key.NewBinding(key.WithKeys("X"), key.WithHelp("X", "hard restore")), + Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore to pin")), + HardRestore: key.NewBinding(key.WithKeys("X"), key.WithHelp("X", "restore committed pins")), Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")), Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")), Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")), @@ -52,11 +54,16 @@ func (k *keyMap) SetUpdate(v string) { // Navigation returns the bindings that move the selection or fold the tree. func (k keyMap) Navigation() []key.Binding { move := key.NewBinding(key.WithKeys("up"), key.WithHelp("↑↓/jk", "move")) - fold := key.NewBinding(key.WithKeys("left"), key.WithHelp("←→/hl", "fold")) - all := key.NewBinding(key.WithKeys("H"), key.WithHelp("⇧←→/HL", "fold all")) + fold := key.NewBinding(key.WithKeys("left"), key.WithHelp("←→/hl", "collapse/expand")) + all := key.NewBinding(key.WithKeys("H"), key.WithHelp("⇧←→/HL", "all")) return []key.Binding{move, fold, all, k.Toggle} } +// Tools returns the bindings that act on the whole list: problems only, +// filter and fetch, most useful first, since the footer drops them from the +// end. It shows them after the navigation keys. +func (k keyMap) Tools() []key.Binding { return []key.Binding{k.Issues, k.Filter, k.Fetch} } + // Submodule returns the bindings that act on the selected submodule. They // are shown in the detail pane, or in the footer if the pane can't show them. func (k keyMap) Submodule() []key.Binding { @@ -83,17 +90,31 @@ func (k keyMap) Highlight() []key.Binding { return []key.Binding{k.QuitUpdate} } -func (k keyMap) ShortHelp() []key.Binding { - return append(append(k.Navigation(), k.Submodule()...), k.Global()...) +// forSelection returns the keys as they read for the selection: on the +// superproject, u fast-forwards its branch (D29). +func (k keyMap) forSelection(root bool) keyMap { + if root { + k.Update.SetHelp("u", "pull (fast-forward)") + } + return k } -func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Up, k.Down, k.Left, k.Right, k.Toggle}, - {k.CollapseAll, k.ExpandAll}, - {k.Home, k.End, k.PageUp, k.PageDown}, - {k.Copy, k.Update, k.Switch, k.Restore, k.HardRestore}, - {k.Fetch, k.Reload, k.Filter, k.Issues}, - append([]key.Binding{k.Help, k.Quit}, k.fullUpdate()...), +// HelpGroups returns the columns of the full help, each with a title. +func (k keyMap) HelpGroups() []footer.Group { + help := key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "what words mean")) + esc := key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")) + return []footer.Group{ + {Title: "Move", Keys: []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Toggle, k.CollapseAll, k.ExpandAll}}, + {Title: "Jump", Keys: []key.Binding{k.Home, k.End, k.PageUp, k.PageDown}}, + {Title: "Selected submodule", Keys: k.Submodule()}, + {Title: "All submodules", Keys: []key.Binding{k.Fetch, k.Reload, k.Filter, k.Issues}}, + {Title: "App", Keys: append([]key.Binding{help, esc, k.Quit}, k.fullUpdate()...)}, } } + +// LegendKeys returns the column next to the legend, the help's last page; +// ? closes it too, but esc is the key to tell. +func (k keyMap) LegendKeys() footer.Group { + esc := key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")) + return footer.Group{Title: "App", Keys: append([]key.Binding{esc, k.Quit}, k.fullUpdate()...)} +} diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go new file mode 100644 index 0000000..6e9db5c --- /dev/null +++ b/internal/tui/keys_test.go @@ -0,0 +1,16 @@ +package tui + +import "testing" + +func TestUpdateReadsPullOnTheSuperproject(t *testing.T) { + k := defaultKeys() + if got := k.forSelection(true).Update.Help().Desc; got != "pull (fast-forward)" { + t.Errorf("superproject: u reads %q", got) + } + if got := k.forSelection(false).Update.Help().Desc; got != "update pin" { + t.Errorf("submodule: u reads %q", got) + } + if got := k.Update.Help().Desc; got != "update pin" { + t.Errorf("forSelection changed the key map: u reads %q", got) + } +} diff --git a/internal/tui/theme/legend.go b/internal/tui/theme/legend.go new file mode 100644 index 0000000..7dcb904 --- /dev/null +++ b/internal/tui/theme/legend.go @@ -0,0 +1,62 @@ +package theme + +import "submarine/internal/submodule" + +// LegendRow is one word or symbol of the UI, rendered as it appears, and +// what it means. +type LegendRow struct{ Word, Meaning string } + +// LegendGroup is a titled column of the legend. +type LegendGroup struct { + Title string + Rows []LegendRow +} + +// issueMeaning explains an issue word in a few plain words. +func issueMeaning(k submodule.IssueKind) string { + switch k { + case submodule.IssueUnpushed: + return "pin is on no remote" + case submodule.IssueConflict: + return "merge conflict" + case submodule.IssueMissing: + return "not checked out" + case submodule.IssueOffPin: + return "checkout is not the pin" + case submodule.IssueChanged: + return "uncommitted files" + default: + return "pin not on target branch" + } +} + +// Legend explains the words and symbols of the tree and the detail pane, in +// their colors: what the full help's second page shows. +func (t Theme) Legend() []LegendGroup { + g := t.Glyphs + muted := Fg(t.Muted) + status := LegendGroup{Title: "Status"} + for _, k := range submodule.Kinds { + status.Rows = append(status.Rows, LegendRow{t.LevelStyle(k.Level(), Fg(t.Text)).Render(IssueWord(k)), issueMeaning(k)}) + } + status.Rows = append(status.Rows, + LegendRow{Fg(t.Info).Render(Staged), "new pin, not committed"}, + LegendRow{muted.Render(InSync), "nothing to do"}, + ) + symbols := LegendGroup{Title: "Symbols", Rows: []LegendRow{ + {Fg(t.Info).Render(g.Staged), "pin staged in parent"}, + {muted.Render(g.Ahead + "n " + g.Behind + "n"), "ahead / behind the ref"}, + {muted.Render("+n"), "n more issues"}, + {Fg(t.Warn).Render(g.Problems + "k"), "issues below, collapsed"}, + {muted.Render("[n]"), "children, collapsed"}, + }} + commits := LegendGroup{Title: "Commits", Rows: []LegendRow{ + {muted.Render(RoleWord(submodule.RolePin, "")), "what the parent records"}, + {Fg(t.Text).Bold(true).Render(RoleWord(submodule.RoleYou, "")), "the checkout"}, + {muted.Render(RoleWord(submodule.RoleTip, "")), "newest on the ref"}, + {muted.Render("main"), "tip of target branch"}, + {muted.Render(RoleWord(submodule.RoleBase, "")), "where lines split"}, + {muted.Render(RoleWord(submodule.RoleHead, "")), "superproject checkout"}, + }} + return []LegendGroup{status, symbols, commits} +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 51ae587..bc4fb6a 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -66,3 +66,45 @@ func TestQuitAndUpdate(t *testing.T) { t.Errorf("ctrl+c must quit without updating, got update to %q", m2.(Model).UpdateTo()) } } + +func TestHintUntilTheFirstKey(t *testing.T) { + m := newUpdateModel("") + if !m.(Model).extraLine() || !strings.Contains(m.View(), "new here?") { + t.Fatal("no hint on start") + } + m, _ = press(m, "j") + if m.(Model).extraLine() || strings.Contains(m.View(), "new here?") { + t.Error("the hint must go away with the first key") + } +} + +func TestEscClosesTheHelp(t *testing.T) { + for _, presses := range []int{1, 2} { // the keys page, the legend + m := newUpdateModel("") + for range presses { + m, _ = press(m, "?") + } + if !m.(Model).footer.HelpOpen() { + t.Fatalf("%d × ?: no help", presses) + } + m, _ = pressKey(m, tea.KeyEsc) + if m.(Model).footer.HelpOpen() { + t.Errorf("%d × ?: esc must close the help", presses) + } + } +} + +func TestEscClosesTheHelpBeforeTheFilter(t *testing.T) { + m := newUpdateModel("") + m, _ = press(m, "/") + m, _ = pressKey(m, tea.KeyEnter) // keys back to the tree, the filter stays + m, _ = press(m, "?") + m, _ = pressKey(m, tea.KeyEsc) + if mm := m.(Model); mm.footer.HelpOpen() || !mm.filtering { + t.Errorf("first esc: help open %v, filtering %v; want the help closed, the filter kept", mm.footer.HelpOpen(), mm.filtering) + } + m, _ = pressKey(m, tea.KeyEsc) + if m.(Model).filtering { + t.Error("second esc must close the filter") + } +} diff --git a/notes/usability-plan.md b/notes/usability-plan.md new file mode 100644 index 0000000..cd0ab94 --- /dev/null +++ b/notes/usability-plan.md @@ -0,0 +1,240 @@ +# Usability plan (draft) + +**Status:** items 1–8 and 11 are done ([D31](../docs/decisions.md), +[D32](../docs/decisions.md)); the rest is a proposal. Items that change +behavior need a decision in [decisions.md](../docs/decisions.md) and +updates to [ux-guide.md](../docs/ux-guide.md) / +[actions.md](../docs/actions.md) when they are taken up. + +Done differently than proposed below: the labels are `x restore to pin` and +`X restore committed pins`; the tools read `! problems only • / filter • f +fetch` (most useful first, dropped from the end); `space toggle` stays, as +it now follows `←→/hl collapse/expand`. + +## Goal + +The target user is a developer who doesn't know git submodules well. They +should understand the screen and act safely **without reading a manual**. +At any moment the screen should answer three questions: + +1. **What is wrong?** What does this state mean, in plain words? +2. **What can I do about it?** Which key fixes it? +3. **What will this key do?** What happens to my work, and can I undo it? + +Today the tool answers (1) partly (the issue lines in the detail pane) and +(3) well *after* a key is pressed (loss question, flash). It hardly answers +(2), and it answers (3) only by trying. Most items below move the answers +from "after pressing" to "before pressing". + +## Findings this is based on + +From a walkthrough on the `make-fixture.sh` superproject (2026-10-24): + +- `x restore` and `X hard restore` don't say the target ("to the pin"), the + scope (the submodule and everything below it) or how they differ. "Hard" + suggests "discards my changes", but both do that. The real difference, + staged pins, can't be guessed. +- `X` drops a staged pin without asking (`restored libs/staged to the + committed pins`), one Shift away from `x`. +- `f` fetch, `/` filter and `!` problems only appear only in `?`. The detail + pane says `fetched never` but gives no hint how to fetch. `!` is the most + useful view for many submodules, and nothing points to it. +- The words in the tree (`off pin`, `off target`, `staged`, `●`, `·1`, `↓2`) + and on the spine (`pin`, `tip`, `you`, `base`) are explained nowhere in + the UI. +- `u update pin` on the superproject actually fast-forwards the current + branch; the label stays the same. +- The palette is static: `u` is offered when it can't do anything, and the + user finds out from a flash. +- Mismatched words: the footer says "fold", the help "collapse/expand"; + `space toggle` doesn't say what it toggles. +- Good, and worth keeping: questions only when something would be lost, the + loss listed exactly, flashes that say why and suggest another key. + +## Overview + +Effort: **S** = hours, mostly labels and docs; **M** = a day or two, new +logic in existing components; **L** = several days, new UI or new provider +calls. +Impact: how much it helps the target user's main flows (*understand state*, +*fix a submodule*, *find problems*, *stay safe*). + +| # | Item | Effort | Impact | Flow | +|---|---|---|---|---| +| 1 ✓ | Clear names for restore and hard restore | S | High | fix, safe | +| 2 ✓ | Ask before `X` drops staged pins | S | High | safe | +| 3 ✓ | Show `/`, `!`, `f` in the footer | S | High | find | +| 4 ✓ | Status legend in `?` | S–M | High | understand | +| 5 ✓ | Groups with titles in the full help | S | Medium | understand | +| 6 ✓ | Plain-word fixes for wording | S | Low | understand | +| 7 ✓ | `u` label for the superproject | S | Medium | fix | +| 8 ✓ | Fetch hint at `fetched never` / old fetches | S | Medium | understand | +| 9 | Next step for each issue in the detail pane | M | **Highest** | understand, fix | +| 10 | Palette that fits the selection | M | High | fix | +| 11 ✓ | First-start hint | S | Medium | understand | +| 12 | Undo hint after a restore, switch or update | M | Medium | safe | +| 13 | Action menu on `enter` | M–L | High | fix, understand | +| 14 | "What would happen" preview | L | Medium | safe | + +Quick view by quadrant: + +| | **Low effort** | **Medium effort** | **High effort** | +|---|---|---|---| +| **High impact** | 1, 2, 3, 4 | 9, 10, 13 | | +| **Medium impact** | 5, 7, 8, 11 | 12 | 14 | +| **Low impact** | 6 | | | + +## Items + +### Low effort + +**1. Clear names for restore and hard restore.** *S · High* +The labels should give target and difference without the docs. +Proposal: `x restore to pin`, `X restore, unstage pins`. In the full help, +longer: `x restore to pin (with everything below)`, `X restore to committed +pins, drop staged ones`. The question lines already use "to the committed +pins"; keep the same words everywhere. +Alternative: rename the action to "reset". Rejected for now: `git reset` +moves branches, which restore never does (D23). +Docs: ux-guide key reference, actions.md "Keys and palette", README. + +**2. Ask before `X` drops staged pins.** *S · High* +Amends D30 ("alone they don't make it ask"). A staged pin is a decision +the user made, and `X` sits next to `x`. If `X` would drop at least one +staged pin, ask: `Restore src/app to the committed pins? Unstages 2 pins. +y restore · n cancel`. The loss is already counted, so only the trigger +changes. D30's argument (the commit stays in the submodule) holds for an +expert, not for someone who doesn't know where to find it again. + +**3. Show `/`, `!`, `f` in the footer.** *S · High* +Proposal: `/ filter • ! problems • f fetch` after the navigation keys, +before the global keys. The existing rule drops keys from the left group's +end when the footer is too narrow, so narrow terminals lose them first and +nothing else changes. While problems only is on, the footer could read +`! all` (the action the key does now). `r reload` stays in `?`. + +**4. Status legend in `?`.** *S–M · High* +The full help gets a second block, "What the words mean", with one line per +status word and symbol, using the same colors as the tree: +`off pin the checkout is not the commit the parent records`, `staged`, +`●`, `↑n ↓n`, `·k`, and the spine roles (`pin`, `tip`, `you`, `base`, +target). Wording comes from state-model.md; the explanations the detail +pane already uses are a good source. Layout: below the key columns if there +is room, otherwise `?` pages between keys and legend (`? more`). + +**5. Groups with titles in the full help.** *S · Medium* +The help columns are already grouped, but without titles. Add short titles +on top: `Move`, `Selected submodule`, `All repositories`, `App`. Then it is +clear that `x` acts on the selection and `f` on everything. + +**6. Plain-word fixes.** *S · Low* +- Footer `fold` → `collapse/expand`; `⇧←→/HL fold all` → `H/L all`. +- `space toggle` → `space open/close`. +- `c copy path` → say which path (`copy path` is fine if it is the + relative path; check). +- Header `pin staged` vs tree `staged`: one word. + +**7. `u` label for the superproject.** *S · Medium* +With the header selected the palette says `u pull (fast-forward)`, since +that is what it does (D29). Labels that change with the selection need +item 10's mechanism, but this one is simple on its own: one check for the +header. + +**8. Fetch hint.** *S · Medium* +`fetched never` (and an age above a threshold, e.g. a day) gets a muted +`· f fetches`. Users who don't know that the arrows (`↓2`) are only as fresh +as the last fetch otherwise trust old numbers. + +**11. First-start hint.** *S · Medium* +On start, the footer's flash line shows `? explains keys and states` until +the first key. No config file, no state: it is shown every start, and it is +just a muted line. Makes item 4 discoverable. + +### Medium effort + +**9. Next step for each issue in the detail pane.** *M · Highest* +Each issue line ends with what to do about it, as keys: + +``` +off pin checkout is not the pin x back to pin · b record a branch +changed 3 files changed commit outside, or x discard +missing not initialized x restore +off target pin is not on origin/main b switch branch +``` + +A pin that is only behind (`↓2`, no issue) gets the same kind of hint on +its `Ref` line: `u update pin`. + +This is the single biggest step for the target user: they no longer need to +know what to do, only to read. Layering: which action resolves which issue +is meaning, not looks, so it belongs in `internal/submodule` (e.g. a +`Remedies()` on the issue, returning action kinds) and the words in +`theme`. It must agree with what the actions really allow (e.g. `u` only +when behind and not ahead, D29), which item 10 needs anyway. + +**10. Palette that fits the selection.** *M · High* +Amends actions.md ("the palette is static"). The palette keeps the same keys +in the same places (muscle memory), but: +- keys that can't do anything for the selection are drawn muted (`u` when + up to date, `b` on a missing submodule); pressing them still flashes why; +- the key that item 9 recommends for the most severe issue is highlighted. +The checks for `u` already exist as flash reasons; they are cheap and need +only the status, so this is a matter of calling them for the palette too. +Restore stays always active (it is idempotent). + +**12. Undo hint after a restore, switch or update.** *M · Medium* +The success flash names the old commit when a repository moved: +`restored src/app (was 1a2b3c4)`. A flash only lasts a moment, so the detail +pane could keep `previous 1a2b3c4` until the next reload. For a user who +doesn't know `git reflog`, this is the difference between "lost" and +"recoverable". No new provider call if the SHA is taken before the action. + +**13. Action menu on `enter`.** *M–L · High* +`enter` on a row opens a small popup (reusing the branch popup's frame) +listing the actions for this submodule, each with one line of explanation +and its key; the recommended one first and selected. `enter` runs it, `esc` +closes. Users who know the keys never need it; users who don't need no +memory at all. `enter` is unused in the tree today. + +### High effort + +**14. "What would happen" preview.** *L · Medium* +In the action menu (13), the selected entry shows the effect before +running it: `moves the pin e510d9b → 906f119 (2 commits)`, `discards 3 +changed files`. The loss check exists, but it is async git work, so it runs +when the entry is selected, with a spinner. Valuable, but the loss question +already covers the dangerous cases, so this comes last. + +## Order + +1. **Labels and safety** (1, 2, 3, 5, 6, 7): only labels, docs and one + question trigger. Fixes the worst risk (`X`) and the hidden keys at once. +2. **Explain the screen** (4, 8, 11): the legend and the hints that point + to it. +3. **Guide to the fix** (9, 10): issue → key, and a palette that agrees. + Needs a decision on where remedies live (layering). +4. **Menus and previews** (12, 13, 14). + +## How to check + +Give someone who hasn't used submarine the fixture (`make-fixture.sh`) and +these tasks, without the docs: + +- Find every submodule that needs attention. +- Get `ui` up to date with its branch. +- Put `core` back where the superproject wants it. +- Find out whether the arrows are current. +- Explain what `staged` on `libs/staged` means. + +Note keys tried, wrong actions and questions asked. Run it before phase 1 +and after each phase. + +## Open questions + +- Item 1: are "restore to pin" / "restore, unstage pins" the right words, or + should `X` move under a different key that is not one Shift away? +- Item 2: ask for every dropped staged pin, or only for the selection's own? +- Item 9: are external actions (`commit outside submarine`) good hints, or + only keys? +- Item 13: does `enter` stay free for something else (e.g. opening a shell + in the submodule)?