Archived
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ea9cc241 | ||
|
|
e4340fb35e | ||
|
|
6cb7bd6967 | ||
|
|
17151a578d | ||
|
|
aec4ec6e3b | ||
|
|
e5fdc875af | ||
|
|
af6d03a5b1 | ||
|
|
69d0f9b704 |
+6
-4
@@ -53,15 +53,17 @@ everyone else's checkout.
|
||||
|
||||
**Context.** Only the first level is shown by default, so nested problems
|
||||
would be invisible.
|
||||
**Decision.** Collapsed parents show `·k` (issues in all descendants), colored
|
||||
by the worst one. Problems in a child do **not** mark the parent itself as
|
||||
`changed`.
|
||||
**Decision.** Collapsed parents show `·k` (issues in all descendants), one
|
||||
count per level: red for danger, then yellow for warn (amended: a single
|
||||
count in the worst color made 1 error + 1 warning look like 2 errors; the
|
||||
status column grew from 14 to 16 for the second count). Problems in a child
|
||||
do **not** mark the parent itself as `changed`.
|
||||
|
||||
## D8 — Collapsed by default, branch-first navigation
|
||||
|
||||
**Decision.** Only the first level is visible. `→` expands and enters the
|
||||
first child, `←` collapses and goes to the parent, `space` toggles,
|
||||
`ctrl+→/←` expand/collapse all (see [ux-guide.md](ux-guide.md#navigation)).
|
||||
`shift+→/←`, `L`/`H` (or `ctrl+→/←`) expand/collapse all (see [ux-guide.md](ux-guide.md#navigation)).
|
||||
`←` on an expanded node collapses it in place, so a node can be closed
|
||||
without leaving it. Remembered expansion is kept on collapse, but reset by
|
||||
"collapse all".
|
||||
|
||||
@@ -34,7 +34,8 @@ commit hashes. It doesn't answer the questions users actually have:
|
||||
- **F5** A collapsed submodule shows the number of its **direct** children,
|
||||
e.g. `[2]`.
|
||||
- **F6** A collapsed submodule shows the number of issues **hidden** in all
|
||||
its descendants (`·2`), colored by the most severe hidden issue.
|
||||
its descendants (`·1 ·2`), one count per level: errors in red, then
|
||||
warnings in yellow.
|
||||
- **F7** Expansion state of descendants is remembered when a parent is
|
||||
collapsed and restored when it is expanded again (except after
|
||||
"collapse all", which resets it).
|
||||
|
||||
+5
-3
@@ -72,7 +72,7 @@ truth for what counts as a problem.
|
||||
| `conflict` | Merge conflict in the worktree | Danger |
|
||||
| `missing` | Not initialized; directory missing | Warn |
|
||||
| `off pin` | Checkout differs from pin | Warn |
|
||||
| `changed` | Uncommitted changes; untracked files | Warn |
|
||||
| `changed` | Uncommitted changes; untracked files (worded "untracked repository" if all of them are git repositories) | Warn |
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -96,8 +96,10 @@ Rules:
|
||||
### Hidden issues
|
||||
|
||||
`Submodule.HiddenIssues()` counts the issues of **all** loaded descendants
|
||||
and returns the most severe level. A collapsed submodule shows this as `·k`
|
||||
in that level's color, so a danger can never hide inside a collapsed subtree.
|
||||
per level (`Hidden{Warn, Danger}`). A collapsed submodule shows them as `·k`
|
||||
per level, red first, then yellow, so a danger can never hide inside a
|
||||
collapsed subtree and a warning is never mistaken for one.
|
||||
A descendant whose load failed (`error`) counts as one danger.
|
||||
|
||||
## Resolving a pin to a ref
|
||||
|
||||
|
||||
+14
-9
@@ -26,7 +26,7 @@
|
||||
my_submodule_3 unpushed origin/main ↑1 │ Pinned b7c8d9e Hotfix null pointer
|
||||
libs/ │ Checkout b7c8d9e Hotfix null pointer
|
||||
logging origin/main ↓3 │ Tip 6f5e4d3 Release 2.1.0
|
||||
↑/↓ move • ←/→ collapse/expand • space toggle • u checkout pin • … • ? help • q quit
|
||||
↑↓/jk move • ←→/hl fold • ⇧←→/HL fold all • space toggle • ? help • q quit
|
||||
```
|
||||
|
||||
| Area | Height / width |
|
||||
@@ -47,7 +47,7 @@ indent ▸ name [n] status ref delta
|
||||
| indent | 2 per depth | |
|
||||
| toggle | 3 | `▸` collapsed, `▾` expanded, blank for leaves |
|
||||
| name | fits the widest visible name | name, `[n]` = direct children (collapsed only) |
|
||||
| status | 14 | issue word, `+n`, `·k` hidden issues; muted `in sync` without issues; spinner while loading |
|
||||
| status | 16 | issue word, `+n`, `·k` hidden issues per level (red, then yellow); muted `in sync` without issues; spinner while loading |
|
||||
| ref | widest ref, max 22 | resolved ref; muted short SHA if unresolved |
|
||||
| delta | 6 | `↑n`, `↓n`, `↑n↓m` |
|
||||
|
||||
@@ -91,7 +91,9 @@ Changes 5 files · 2M 1A 1D 1R · +37 −12
|
||||
meaning, the color only supports it (N4).
|
||||
- Letters and colors: `!` red bold, `M` yellow, `A` green, `D` red, `R`
|
||||
cyan, `?` muted. Line counts: green `+n`, red `−n`; `bin` for binary
|
||||
files, `submodule` for a moved nested pin, nothing for untracked files.
|
||||
files, `submodule` for a moved nested pin, `repository` for an untracked
|
||||
directory that is a git repository itself (cloned, never added as a
|
||||
submodule), nothing for other untracked files.
|
||||
- Order: kind (`!`, `M`, `A`, `D`, `R`, `?`), then changed lines, largest
|
||||
first, because the biggest edits are the ones people remember.
|
||||
- Budget: 10 files. Every kind that occurs gets at least one line, the
|
||||
@@ -118,10 +120,12 @@ Changes 5 files · 2M 1A 1D 1R · +37 −12
|
||||
- Untracked directories are shown as one entry (`tmp/`).
|
||||
### Header
|
||||
|
||||
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · <fetch>`
|
||||
`path branch ↑↓ · n submodules · n error · <count> <issue>… · n pin staged · n loading · <fetch>`
|
||||
|
||||
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
|
||||
changed), each in its level color. Zero counts are omitted. A detached
|
||||
changed), each in its level color. Zero counts are omitted. `n loading`
|
||||
counts only submodules whose status is still being loaded; ones whose load
|
||||
failed are counted separately as `n error` (red, before the issue counts). A detached
|
||||
superproject shows `(detached)` as its branch.
|
||||
|
||||
`<fetch>` on the right:
|
||||
@@ -179,8 +183,8 @@ colors in components.
|
||||
| `space` | Toggle expand/collapse of the selected submodule |
|
||||
| `→` / `l` | Collapsed with children: expand and select the first child. Expanded: select the first child. Leaf: nothing |
|
||||
| `←` / `h` | Expanded: collapse it (stays selected). Otherwise: collapse the parent and select it. Top level: nothing |
|
||||
| `ctrl+→` | Expand all; then follow the first child from the selection down to a submodule without children |
|
||||
| `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
|
||||
| `L` / `shift+→` / `ctrl+→` | Expand all; then follow the first child from the selection down to a submodule without children |
|
||||
| `H` / `shift+←` / `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
|
||||
| `home` / `g`, `end` / `G` | First / last submodule |
|
||||
| `pgup`, `pgdn` | Move by one screen |
|
||||
| `?` | Toggle full help |
|
||||
@@ -205,5 +209,6 @@ is selected, headers above it are shown too.
|
||||
## Accessibility and terminals
|
||||
|
||||
- `SUBMARINE_ASCII=1`: ASCII symbols (`^ v > v . *`) instead of Unicode.
|
||||
- Some terminals or multiplexers capture `ctrl+←/→`; letter aliases can be
|
||||
added if needed.
|
||||
- Many setups capture `ctrl+←/→` (macOS Mission Control, some terminals and
|
||||
multiplexers), so expand/collapse all also work as `shift+→/←` and as `L` / `H`, the
|
||||
shifted vim keys for `→` / `←` (for terminals that don't report shift+arrows).
|
||||
|
||||
@@ -158,7 +158,7 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
|
||||
}
|
||||
pin := parseGitlink(out)
|
||||
if pin == "" {
|
||||
return nil, errors.New("no gitlink in the superproject index")
|
||||
return nil, errors.New("listed in .gitmodules, but has no gitlink in the superproject index (stale entry?)")
|
||||
}
|
||||
out, _, err = try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
|
||||
if err != nil {
|
||||
@@ -387,6 +387,9 @@ func changes(ctx context.Context, dir string, entries []entry) (*submodule.Chang
|
||||
c.Added += n.added
|
||||
c.Removed += n.removed
|
||||
}
|
||||
if e.kind == submodule.ChangeUntracked && strings.HasSuffix(e.path, "/") {
|
||||
f.Repo = initialized(filepath.Join(dir, filepath.FromSlash(e.path)))
|
||||
}
|
||||
if e.kind != submodule.ChangeRemoved {
|
||||
if fi, err := os.Stat(filepath.Join(dir, filepath.FromSlash(e.path))); err == nil {
|
||||
f.Modified = fi.ModTime()
|
||||
|
||||
@@ -132,6 +132,7 @@ type FileChange struct {
|
||||
Removed int // removed lines
|
||||
Binary bool // line counts are unknown
|
||||
Submodule bool // a nested submodule pointer changed
|
||||
Repo bool // an untracked directory that is a git repository itself
|
||||
Modified time.Time // modification time of the file; zero if it doesn't exist
|
||||
}
|
||||
|
||||
|
||||
@@ -55,21 +55,36 @@ func (s *Status) Issues() []Issue {
|
||||
case WorktreeModified:
|
||||
out = append(out, Issue{IssueChanged, LevelWarn, "uncommitted changes"})
|
||||
case WorktreeUntracked:
|
||||
out = append(out, Issue{IssueChanged, LevelWarn, "untracked files"})
|
||||
out = append(out, Issue{IssueChanged, LevelWarn, s.untrackedText()})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HiddenIssues counts the issues of all loaded descendants and returns the
|
||||
// most severe level among them.
|
||||
func (m *Submodule) HiddenIssues() (count int, worst Level) {
|
||||
// Hidden counts issues below a submodule per level.
|
||||
type Hidden struct{ Warn, Danger int }
|
||||
|
||||
// Total is the number of issues of all levels.
|
||||
func (h Hidden) Total() int { return h.Warn + h.Danger }
|
||||
|
||||
// HiddenIssues counts the issues of all loaded descendants per level. A
|
||||
// descendant whose load failed counts as one danger, since its state is
|
||||
// unknown.
|
||||
func (m *Submodule) HiddenIssues() Hidden {
|
||||
var h Hidden
|
||||
Walk(m.Children, func(c *Submodule) {
|
||||
if c.Status == nil && c.Err != nil {
|
||||
h.Danger++
|
||||
}
|
||||
for _, is := range c.Status.Issues() {
|
||||
count++
|
||||
worst = max(worst, is.Level)
|
||||
switch is.Level {
|
||||
case LevelDanger:
|
||||
h.Danger++
|
||||
case LevelWarn:
|
||||
h.Warn++
|
||||
}
|
||||
}
|
||||
})
|
||||
return count, worst
|
||||
return h
|
||||
}
|
||||
|
||||
// Walk calls fn for every submodule in depth-first order.
|
||||
@@ -79,3 +94,18 @@ func Walk(mods []*Submodule, fn func(*Submodule)) {
|
||||
Walk(m.Children, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// untrackedText words the untracked-files issue: "untracked repository" if
|
||||
// everything untracked is a git repository of its own, e.g. a clone that was
|
||||
// never added as a submodule.
|
||||
func (s *Status) untrackedText() string {
|
||||
if s.Changes == nil || s.Changes.Total != len(s.Changes.Files) {
|
||||
return "untracked files"
|
||||
}
|
||||
for _, f := range s.Changes.Files {
|
||||
if f.Kind != ChangeUntracked || !f.Repo {
|
||||
return "untracked files"
|
||||
}
|
||||
}
|
||||
return "untracked repository"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package submodule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
@@ -45,8 +46,35 @@ func TestHiddenIssues(t *testing.T) {
|
||||
loading := &Submodule{}
|
||||
root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}}
|
||||
|
||||
count, worst := root.HiddenIssues()
|
||||
if count != 2 || worst != LevelDanger {
|
||||
t.Errorf("HiddenIssues() = %d, %v; want 2, LevelDanger", count, worst)
|
||||
if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1}) {
|
||||
t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntrackedRepositoryWording(t *testing.T) {
|
||||
repo := FileChange{Path: "lib/x/", Kind: ChangeUntracked, Repo: true}
|
||||
file := FileChange{Path: "a.txt", Kind: ChangeUntracked}
|
||||
for _, c := range []struct {
|
||||
files []FileChange
|
||||
want string
|
||||
}{
|
||||
{[]FileChange{repo}, "untracked repository"},
|
||||
{[]FileChange{repo, file}, "untracked files"},
|
||||
{[]FileChange{file}, "untracked files"},
|
||||
} {
|
||||
s := &Status{Worktree: WorktreeUntracked, Changes: &Changes{Total: len(c.files), Files: c.files}}
|
||||
got := s.Issues()
|
||||
if len(got) == 0 || got[len(got)-1].Detail != c.want {
|
||||
t.Errorf("%v: got %v, want %q", c.files, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenIssuesCountLoadErrors(t *testing.T) {
|
||||
bad := &Submodule{Err: errors.New("boom")}
|
||||
loading := &Submodule{}
|
||||
parent := &Submodule{Children: []*Submodule{bad, loading}}
|
||||
if got := parent.HiddenIssues(); got != (Hidden{Danger: 1}) {
|
||||
t.Errorf("got %+v; want 1 danger", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,8 @@ func (m Model) fileLines(files []submodule.FileChange) []string {
|
||||
switch {
|
||||
case f.Submodule:
|
||||
r.counts = muted.Render("submodule")
|
||||
case f.Repo:
|
||||
r.counts = muted.Render("repository")
|
||||
case f.Binary:
|
||||
r.counts = muted.Render("bin")
|
||||
default:
|
||||
|
||||
@@ -138,8 +138,16 @@ func (m Model) lines(s *submodule.Submodule) []string {
|
||||
out = append(out, "")
|
||||
if n := len(s.Children); n > 0 {
|
||||
children := fmt.Sprintf("%d direct", n)
|
||||
if k, worst := s.HiddenIssues(); k > 0 {
|
||||
children += t.LevelStyle(worst, lipgloss.NewStyle()).Render(fmt.Sprintf(" · %d issue(s) below", k))
|
||||
h := s.HiddenIssues()
|
||||
var parts []string
|
||||
if h.Danger > 0 {
|
||||
parts = append(parts, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render(fmt.Sprintf("%d error(s)", h.Danger)))
|
||||
}
|
||||
if h.Warn > 0 {
|
||||
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
children += muted.Render(" · ") + strings.Join(parts, muted.Render(", ")) + muted.Render(" below")
|
||||
}
|
||||
out = append(out, label("Children")+children)
|
||||
}
|
||||
|
||||
@@ -36,13 +36,17 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
|
||||
}
|
||||
|
||||
// count submodules per issue kind (most severe first), plus staged pins
|
||||
var total, loading, staged int
|
||||
var total, loading, errored, staged int
|
||||
perKind := map[submodule.IssueKind]int{}
|
||||
level := map[submodule.IssueKind]submodule.Level{}
|
||||
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) {
|
||||
total++
|
||||
if s.Status == nil {
|
||||
loading++
|
||||
if s.Err == nil {
|
||||
loading++
|
||||
} else {
|
||||
errored++
|
||||
}
|
||||
return
|
||||
}
|
||||
if s.Status.PinStaged {
|
||||
@@ -56,6 +60,9 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
|
||||
|
||||
sep := seg(t.Muted, " · ")
|
||||
mid := seg(t.Muted, fmt.Sprintf("%d submodules", total))
|
||||
if errored > 0 {
|
||||
mid += sep + 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,
|
||||
|
||||
@@ -12,13 +12,13 @@ type keyMap struct {
|
||||
|
||||
func defaultKeys() keyMap {
|
||||
return keyMap{
|
||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/↓", "move")),
|
||||
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/→", "collapse/expand")),
|
||||
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "collapse, parent")),
|
||||
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "expand, first child")),
|
||||
Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
|
||||
CollapseAll: key.NewBinding(key.WithKeys("ctrl+left"), key.WithHelp("ctrl+←", "collapse all")),
|
||||
ExpandAll: key.NewBinding(key.WithKeys("ctrl+right"), key.WithHelp("ctrl+→", "expand all")),
|
||||
CollapseAll: key.NewBinding(key.WithKeys("shift+left", "ctrl+left", "H"), key.WithHelp("H/⇧←", "collapse all")),
|
||||
ExpandAll: key.NewBinding(key.WithKeys("shift+right", "ctrl+right", "L"), key.WithHelp("L/⇧→", "expand all")),
|
||||
Home: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home/g", "first")),
|
||||
End: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "last")),
|
||||
PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
|
||||
@@ -35,7 +35,10 @@ func defaultKeys() keyMap {
|
||||
}
|
||||
|
||||
func (k keyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Up, k.Left, k.Toggle, k.Update, k.Bump, k.Fetch, k.Help, k.Quit}
|
||||
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"))
|
||||
return []key.Binding{move, fold, all, k.Toggle, k.Help, k.Quit}
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
statusWidth = 14 // "unpushed +1 ·2"
|
||||
statusWidth = 16 // "unpushed +1 ·2 ·3"
|
||||
deltaWidth = 6
|
||||
maxRefWidth = 22
|
||||
)
|
||||
@@ -414,11 +414,19 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
|
||||
default:
|
||||
status = seg(t.Muted, theme.InSync)
|
||||
}
|
||||
if k, worst := s.HiddenIssues(); collapsed && k > 0 {
|
||||
if status != "" {
|
||||
status += pad(1)
|
||||
if h := s.HiddenIssues(); collapsed && h.Total() > 0 {
|
||||
for _, c := range []struct {
|
||||
level submodule.Level
|
||||
n int
|
||||
}{{submodule.LevelDanger, h.Danger}, {submodule.LevelWarn, h.Warn}} {
|
||||
if c.n == 0 {
|
||||
continue
|
||||
}
|
||||
if status != "" {
|
||||
status += pad(1)
|
||||
}
|
||||
status += t.LevelStyle(c.level, base).Render(fmt.Sprintf("%s%d", t.Glyphs.Problems, c.n))
|
||||
}
|
||||
status += t.LevelStyle(worst, base).Render(fmt.Sprintf("%s%d", t.Glyphs.Problems, k))
|
||||
}
|
||||
status = ansi.Truncate(status, statusWidth, "")
|
||||
status += pad(statusWidth - lipgloss.Width(status))
|
||||
|
||||
Reference in New Issue
Block a user