Archived
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa12774eb | ||
|
|
5cb809428b | ||
|
|
90ea9cc241 | ||
|
|
e4340fb35e | ||
|
|
6cb7bd6967 | ||
|
|
17151a578d | ||
|
|
aec4ec6e3b | ||
|
|
e5fdc875af | ||
|
|
af6d03a5b1 | ||
|
|
69d0f9b704 |
@@ -31,7 +31,7 @@ internal/tui/ Bubble Tea application
|
||||
tree/ tree component: rows, navigation, mouse, view
|
||||
detail/ detail pane; changes.go: change preview
|
||||
header/ header line
|
||||
footer/ help and flash messages
|
||||
footer/ key bar (navigation left, global right) and flash messages
|
||||
```
|
||||
|
||||
### Layering rules
|
||||
@@ -64,6 +64,12 @@ type Watcher interface {
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type Locator interface {
|
||||
Dir(path string) string // absolute directory, "" if unknown
|
||||
}
|
||||
```
|
||||
|
||||
- `Tree` returns the superproject and all submodules with `Parent` and
|
||||
`Children` linked. `Status` fields are nil.
|
||||
- `Status` is called once per submodule, concurrently. It must be safe to
|
||||
|
||||
+38
-5
@@ -7,7 +7,8 @@ Each entry: context, decision, consequences. Newest last.
|
||||
**Context.** Hashes don't tell users whether a pin is current.
|
||||
**Decision.** Resolve each pin to a ref and show the distance (`origin/main ↓1`).
|
||||
The resolution order is fixed and the applied rule is shown in the detail pane
|
||||
(see [state-model.md](state-model.md#resolving-a-pin-to-a-ref)).
|
||||
(see [state-model.md](state-model.md#resolving-a-pin-to-a-ref); amended:
|
||||
the closest branch wins, see "Pin resolves to the closest branch").
|
||||
**Consequences.** Resolution is a heuristic; showing the rule keeps it
|
||||
transparent.
|
||||
|
||||
@@ -53,15 +54,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".
|
||||
@@ -198,3 +201,33 @@ package so versions stay clean.
|
||||
corrupt or partial downloads, not a compromised registry (no signatures).
|
||||
The update check is a deliberate, outside-the-provider network use: only
|
||||
on `update` and a once-a-day background check that can be disabled.
|
||||
|
||||
## Copy path with `c` via OSC 52
|
||||
|
||||
`c` copies the selected submodule's absolute directory (optional
|
||||
`submodule.Locator`; the relative path as fallback). The absolute path is
|
||||
what a shell needs (`cd <paste>`). OSC 52 instead of a clipboard tool
|
||||
(`xclip`, `pbcopy`): no external dependency and it works over SSH. The cost:
|
||||
terminals that block OSC 52 copy nothing silently; the footer still says
|
||||
`copied`, since the application can't know.
|
||||
|
||||
## Key groups: global, navigation, submodule
|
||||
|
||||
Keys that act on the selected submodule (copy path, later checkout, bump) are
|
||||
shown in a palette in the detail pane, next to what they act on; global keys
|
||||
(`?`, `q`) sit right in the footer. Where the pane can't show the palette
|
||||
(hidden below 90 columns, or under 12 lines) the footer shows these keys
|
||||
instead, so small terminals need no extra UI (no overlay or action menu).
|
||||
Keys never depend on what is visible.
|
||||
|
||||
## Pin resolves to the closest branch
|
||||
|
||||
A pin on a pushed feature branch showed as diverged from `origin/main`,
|
||||
because `branch = main` in `.gitmodules` won before any distance was looked
|
||||
at. Now the closest branch containing the pin wins; the `.gitmodules`
|
||||
branch, the upstream and the remote default branch only break ties (and are
|
||||
always compared, even past the 16-branch cap). An unpushed pin compares only
|
||||
those preferred refs, by commits on either side: scanning every branch's
|
||||
merge base would be too slow. Cost: a pin on main far behind its tip shows a
|
||||
younger branch that contains it if that branch's tip is closer, so "n behind
|
||||
main" is no longer visible there; `via <rule>` still tells which ref it is.
|
||||
|
||||
@@ -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).
|
||||
|
||||
+33
-17
@@ -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,23 +96,38 @@ 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
|
||||
|
||||
The pin is displayed as a ref, not a hash. A commit can be on several
|
||||
branches, on none, or deep in the history, so the git provider resolves it by
|
||||
this order and records which rule applied in `PinInfo.Source`:
|
||||
branches, on none, or deep in the history. The git provider shows the
|
||||
**closest** branch and records which rule proposed it in `PinInfo.Source`.
|
||||
|
||||
Preferred refs, in this order (each only if it exists):
|
||||
|
||||
| Preference | Ref | `RefSource` |
|
||||
|---|---|---|
|
||||
| a | `branch = …` in `.gitmodules` | `RefFromGitmodules` |
|
||||
| b | Upstream of the branch checked out in the submodule | `RefFromCurrentBranch` |
|
||||
| c | Remote default branch (`<remote>/HEAD`) | `RefFromDefaultBranch` |
|
||||
|
||||
Resolution:
|
||||
|
||||
| # | Rule | `RefSource` |
|
||||
|---|---|---|
|
||||
| 1 | `branch = …` in `.gitmodules` | `RefFromGitmodules` |
|
||||
| 2 | Branch currently checked out in the submodule | `RefFromCurrentBranch` |
|
||||
| 3 | Remote default branch, if it contains the pin | `RefFromDefaultBranch` |
|
||||
| 4 | Any remote branch containing the pin, preferring the closest tip (`git branch -r --contains`) | `RefFromContains` |
|
||||
| 5 | A tag (`git describe`) | `RefFromTag` |
|
||||
| 6 | Short SHA | `RefFromSHA` |
|
||||
| 1 | Pin pushed: the branch containing the pin with the fewest commits after it. Candidates are the preferred refs that contain the pin plus any remote branch containing it (`for-each-ref --contains`); a tie goes to the preferred ref | preferred ref's, else `RefFromContains` |
|
||||
| 2 | Pin unpushed: the preferred ref with the fewest commits on either side (`↑n↓m`), ties by preference | preferred ref's |
|
||||
| 3 | A tag (`git describe`) | `RefFromTag` |
|
||||
| 4 | Short SHA | `RefFromSHA` |
|
||||
|
||||
So a `.gitmodules` branch is shown when it is as close as any other branch,
|
||||
but a pin that sits on a feature branch shows that branch rather than
|
||||
"diverged from main". The trade-off: a pin on main that a younger branch
|
||||
also contains shows the younger branch if its tip is fewer commits away.
|
||||
|
||||
Distances: `git rev-list --left-right --count <pin>...<ref>` gives
|
||||
`Ahead` (left) and `Behind` (right).
|
||||
@@ -141,12 +156,13 @@ are local; `<pin>` is the index gitlink.
|
||||
|
||||
Details:
|
||||
|
||||
- Rule 1: `branch = .` means the superproject's current branch. The rule
|
||||
applies only if `<remote>/<branch>` exists locally.
|
||||
- Rule 2 uses the checked-out branch's upstream (`<branch>@{upstream}`).
|
||||
- Rule 4 compares at most 16 containing branches (oldest tip first) and
|
||||
picks the one with the fewest commits after the pin.
|
||||
- Rule 6 shows the short SHA; there is no tip and no distance.
|
||||
- Preference a: `branch = .` means the superproject's current branch. The
|
||||
ref counts only if `<remote>/<branch>` exists locally.
|
||||
- Preference b uses the checked-out branch's upstream (`<branch>@{upstream}`).
|
||||
- Rule 1 counts `rev-list --count <pin>..<ref>`. Besides the preferred refs
|
||||
it compares at most 16 containing branches (oldest tip first).
|
||||
- Rule 2 counts `rev-list --count <pin>...<ref>`.
|
||||
- Rule 4 shows the short SHA; there is no tip and no distance.
|
||||
- `--ignore-submodules=dirty`: changes inside a nested submodule do not make
|
||||
its parent `changed` (D7), but a moved nested pin does.
|
||||
- Renames are detected only for staged moves (`git mv`); an unstaged move
|
||||
|
||||
+56
-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 |
|
||||
@@ -36,6 +36,21 @@
|
||||
| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns |
|
||||
| Footer | 1 line (more with full help) |
|
||||
|
||||
### Key bar and palette
|
||||
|
||||
Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`):
|
||||
|
||||
- **Navigation** (move, fold, toggle): left in the footer.
|
||||
- **Global** (`?`, `q`): right-aligned in the footer, always kept.
|
||||
- **Submodule** (act on the selected one, e.g. `c`): a palette on the last
|
||||
line of the detail pane. 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.
|
||||
|
||||
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.
|
||||
|
||||
### Tree row
|
||||
|
||||
```
|
||||
@@ -47,7 +62,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 +106,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 +135,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,14 +198,15 @@ 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 |
|
||||
| `q`, `ctrl+c` | Quit |
|
||||
| `f` | Fetch all repositories (does nothing while a fetch runs) |
|
||||
| `r` | Reload everything (e.g. after editing files) |
|
||||
| `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied <path>`. Without a local provider (mock) the path relative to the superproject is copied |
|
||||
| `u`, `b`, `/`, `!` | Planned actions (see requirements) |
|
||||
|
||||
"First child" always means the first child **as displayed** (children are
|
||||
@@ -196,6 +216,32 @@ Collapsing an ancestor of the selection moves the selection to that ancestor.
|
||||
The view scrolls so the selection is always visible; when the first submodule
|
||||
is selected, headers above it are shown too.
|
||||
|
||||
### Key reference
|
||||
|
||||
Every key in the UI, where it is shown, and whether it works. Update this
|
||||
table when a key is added, moved or implemented. "Bar" is the short footer
|
||||
bar, "Palette" the detail pane's palette (the footer takes its keys over
|
||||
where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette)),
|
||||
"Help" the full help (`?`).
|
||||
|
||||
| Key | Action | Group | Shown in | Implemented |
|
||||
|---|---|---|---|---|
|
||||
| `↑` / `k`, `↓` / `j` | Move selection | Navigation | Bar, Help | yes |
|
||||
| `←` / `h`, `→` / `l` | Fold / unfold, parent / first child | Navigation | Bar, Help | yes |
|
||||
| `H` / `⇧←`, `L` / `⇧→` | Collapse / expand all | Navigation | Bar, Help | yes |
|
||||
| `space` | Toggle expand/collapse | Navigation | Bar, Help | yes |
|
||||
| `home` / `g`, `end` / `G` | First / last submodule | Navigation | Help | yes |
|
||||
| `pgup`, `pgdn` | Move by one screen | Navigation | Help | yes |
|
||||
| `c` | Copy path | Submodule | Palette, Help | yes |
|
||||
| `u` | Checkout pin | Submodule | Help | no (flashes "not implemented") |
|
||||
| `b` | Bump pin to tip | Submodule | Help | no (flashes "not implemented") |
|
||||
| `f` | Fetch all repositories | Global | Help | yes |
|
||||
| `r` | Reload everything | Global | Help | yes |
|
||||
| `/` | Filter | Global | Help | no (flashes "not implemented") |
|
||||
| `!` | Problems only | Global | Help | no (flashes "not implemented") |
|
||||
| `?` | Toggle full help | Global | Bar, Help | yes |
|
||||
| `q`, `ctrl+c` | Quit | Global | Bar, Help | yes |
|
||||
|
||||
### Mouse
|
||||
|
||||
- Click a row: select it.
|
||||
@@ -205,5 +251,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).
|
||||
|
||||
@@ -3,6 +3,7 @@ module submarine
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
@@ -11,7 +12,6 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
|
||||
@@ -36,6 +36,17 @@ func (p *Provider) Fetchable(path string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// Dir returns the absolute directory of path ("" is the superproject),
|
||||
// whether or not it is initialized.
|
||||
func (p *Provider) Dir(path string) string {
|
||||
if path == "" {
|
||||
return p.root
|
||||
}
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.mods[path].dir
|
||||
}
|
||||
|
||||
// repoDir returns the worktree of path if it is initialized.
|
||||
func (p *Provider) repoDir(path string) (string, bool) {
|
||||
if path == "" {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
// maxContainsCandidates limits how many remote branches containing the pin
|
||||
// are compared when resolving the pin by RefFromContains.
|
||||
// are compared besides the preferred refs when resolving a pushed pin.
|
||||
const maxContainsCandidates = 16
|
||||
|
||||
// maxBetween limits the commits listed between pin and tip.
|
||||
@@ -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 {
|
||||
@@ -269,80 +269,130 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
|
||||
}
|
||||
|
||||
// resolve finds the ref that describes the pin, following the order in
|
||||
// docs/state-model.md. containing lists the remote branches containing the
|
||||
// pin, oldest tip first.
|
||||
// docs/state-model.md: the closest branch wins, the preferred refs (see
|
||||
// preferredRefs) break ties. containing lists the remote branches containing
|
||||
// the pin, oldest tip first.
|
||||
func resolve(ctx context.Context, in info, pin, branch string, containing []string) (string, submodule.RefSource, error) {
|
||||
remote, err := remoteName(ctx, in.dir)
|
||||
preferred, err := preferredRefs(ctx, in, branch)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// 1. branch from .gitmodules
|
||||
if b := in.branch; b != "" && remote != "" {
|
||||
if b == "." {
|
||||
b, _, err = try(ctx, in.superDir, "symbolic-ref", "--short", "-q", "HEAD")
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
if b != "" {
|
||||
if _, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/remotes/"+remote+"/"+b); err != nil {
|
||||
// 1. pushed: the branch containing the pin with the fewest commits after it
|
||||
if len(containing) > 0 {
|
||||
var cands []candidate
|
||||
for _, c := range preferred {
|
||||
if _, ok, err := try(ctx, in.dir, "merge-base", "--is-ancestor", pin, c.ref); err != nil {
|
||||
return "", 0, err
|
||||
} else if ok {
|
||||
return remote + "/" + b, submodule.RefFromGitmodules, nil
|
||||
cands = append(cands, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. upstream of the checked-out branch
|
||||
if branch != "" {
|
||||
if up, ok, err := try(ctx, in.dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}"); err != nil {
|
||||
return "", 0, err
|
||||
} else if ok && up != "" {
|
||||
return up, submodule.RefFromCurrentBranch, nil
|
||||
for _, r := range containing[:min(len(containing), maxContainsCandidates)] {
|
||||
if !slices.ContainsFunc(cands, func(c candidate) bool { return c.ref == r }) {
|
||||
cands = append(cands, candidate{r, submodule.RefFromContains})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. remote default branch, if it contains the pin
|
||||
if remote != "" {
|
||||
if def, ok, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "refs/remotes/"+remote+"/HEAD"); err != nil {
|
||||
if c, ok, err := closest(ctx, in.dir, pin+"..", cands); err != nil {
|
||||
return "", 0, err
|
||||
} else if ok {
|
||||
if _, ok, err := try(ctx, in.dir, "merge-base", "--is-ancestor", pin, def); err != nil {
|
||||
return "", 0, err
|
||||
} else if ok {
|
||||
return def, submodule.RefFromDefaultBranch, nil
|
||||
}
|
||||
return c.ref, c.source, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 4. remote branch containing the pin with the closest tip
|
||||
best, bestN := "", -1
|
||||
for _, r := range containing[:min(len(containing), maxContainsCandidates)] {
|
||||
out, err := run(ctx, in.dir, "rev-list", "--count", pin+".."+r)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscan(out, &n); err == nil && (bestN < 0 || n < bestN) {
|
||||
best, bestN = r, n
|
||||
}
|
||||
}
|
||||
if best != "" {
|
||||
return best, submodule.RefFromContains, nil
|
||||
// 2. unpushed: the preferred ref with the fewest commits on either side
|
||||
if c, ok, err := closest(ctx, in.dir, pin+"...", preferred); err != nil {
|
||||
return "", 0, err
|
||||
} else if ok {
|
||||
return c.ref, c.source, nil
|
||||
}
|
||||
|
||||
// 5. nearest tag
|
||||
// 3. nearest tag
|
||||
if tag, ok, err := try(ctx, in.dir, "describe", "--tags", "--abbrev=0", pin); err != nil {
|
||||
return "", 0, err
|
||||
} else if ok && tag != "" {
|
||||
return tag, submodule.RefFromTag, nil
|
||||
}
|
||||
|
||||
// 6. the commit itself
|
||||
// 4. the commit itself
|
||||
return "", submodule.RefFromSHA, nil
|
||||
}
|
||||
|
||||
// candidate is a ref the pin may resolve to and the rule that proposed it.
|
||||
type candidate struct {
|
||||
ref string
|
||||
source submodule.RefSource
|
||||
}
|
||||
|
||||
// preferredRefs returns the refs the user most likely means, in order of
|
||||
// preference and without duplicates: the .gitmodules branch, the upstream of
|
||||
// the checked-out branch, the remote default branch. Each must exist.
|
||||
func preferredRefs(ctx context.Context, in info, branch string) ([]candidate, error) {
|
||||
remote, err := remoteName(ctx, in.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var refs []candidate
|
||||
add := func(ref string, source submodule.RefSource) {
|
||||
if ref != "" && !slices.ContainsFunc(refs, func(c candidate) bool { return c.ref == ref }) {
|
||||
refs = append(refs, candidate{ref, source})
|
||||
}
|
||||
}
|
||||
|
||||
// branch from .gitmodules
|
||||
if b := in.branch; b != "" && remote != "" {
|
||||
if b == "." {
|
||||
b, _, err = try(ctx, in.superDir, "symbolic-ref", "--short", "-q", "HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if b != "" {
|
||||
if _, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/remotes/"+remote+"/"+b); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
add(remote+"/"+b, submodule.RefFromGitmodules)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// upstream of the checked-out branch
|
||||
if branch != "" {
|
||||
if up, ok, err := try(ctx, in.dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}"); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
add(up, submodule.RefFromCurrentBranch)
|
||||
}
|
||||
}
|
||||
|
||||
// remote default branch
|
||||
if remote != "" {
|
||||
if def, ok, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "refs/remotes/"+remote+"/HEAD"); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
add(def, submodule.RefFromDefaultBranch)
|
||||
}
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// closest returns the candidate with the fewest commits in the range
|
||||
// `<from><ref>` (from is e.g. "<pin>.."); the first one wins a tie.
|
||||
func closest(ctx context.Context, dir, from string, cands []candidate) (candidate, bool, error) {
|
||||
best, bestN := candidate{}, -1
|
||||
for _, c := range cands {
|
||||
out, err := run(ctx, dir, "rev-list", "--count", from+c.ref)
|
||||
if err != nil {
|
||||
return candidate{}, false, err
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscan(out, &n); err == nil && (bestN < 0 || n < bestN) {
|
||||
best, bestN = c, n
|
||||
}
|
||||
}
|
||||
return best, bestN >= 0, nil
|
||||
}
|
||||
|
||||
// remoteName returns "origin" if it exists, else the first remote, else "".
|
||||
func remoteName(ctx context.Context, dir string) (string, error) {
|
||||
out, err := run(ctx, dir, "remote")
|
||||
@@ -387,6 +437,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()
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestTree(t *testing.T) {
|
||||
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) })
|
||||
want := []string{
|
||||
"src/core", "src/ui", "src/net", "libs/store", "libs/log", "libs/log/modules/nested",
|
||||
"libs/feature", "libs/conflict", "libs/staged", "libs/missing", "vendor", "libs/work",
|
||||
"libs/feature", "libs/conflict", "libs/staged", "libs/missing", "vendor", "libs/work", "libs/topic",
|
||||
}
|
||||
if !slices.Equal(paths, want) {
|
||||
t.Errorf("paths = %v\nwant %v", paths, want)
|
||||
@@ -106,6 +106,7 @@ func TestStatus(t *testing.T) {
|
||||
{path: "libs/log", ref: "origin/main", source: submodule.RefFromCurrentBranch},
|
||||
{path: "libs/log/modules/nested", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDetached},
|
||||
{path: "libs/feature", ref: "origin/feat/search", source: submodule.RefFromContains, behind: 1, checkout: submodule.CheckoutDetached},
|
||||
{path: "libs/topic", ref: "origin/feat/search", source: submodule.RefFromContains, checkout: submodule.CheckoutDetached},
|
||||
{path: "libs/conflict", issues: []submodule.IssueKind{submodule.IssueConflict}, ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
|
||||
{path: "libs/staged", ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
|
||||
{path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ type Watcher interface {
|
||||
Watch(ctx context.Context) <-chan Change
|
||||
}
|
||||
|
||||
// Locator is implemented by providers whose submodules live on the local
|
||||
// disk. Dir returns the absolute directory of path ("" is the superproject),
|
||||
// or "" if it is unknown.
|
||||
type Locator interface {
|
||||
Dir(path string) string
|
||||
}
|
||||
|
||||
// Change reports that the git metadata of a repository changed.
|
||||
type Change struct {
|
||||
Path string // "" is the superproject
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -73,7 +73,7 @@ func New(ctx context.Context, p submodule.Provider, opts Options) Model {
|
||||
loads: newReloader(),
|
||||
ticking: true, // Init schedules the first tick
|
||||
tree: tree.New(t),
|
||||
detail: detail.New(t),
|
||||
detail: detail.New(t, k.Submodule()),
|
||||
footer: footer.New(t, k),
|
||||
spinner: sp,
|
||||
}
|
||||
@@ -353,6 +353,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, m.startFetch()
|
||||
case key.Matches(msg, k.Reload):
|
||||
return m, m.reloadTree(true)
|
||||
case key.Matches(msg, k.Copy):
|
||||
return m.copyPath()
|
||||
case key.Matches(msg, k.Update, k.Bump, k.Filter, k.Issues):
|
||||
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
||||
m.layout()
|
||||
@@ -361,6 +363,24 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// copyPath copies the absolute directory of the selected submodule to the
|
||||
// clipboard, or its path in the superproject if the provider can't tell.
|
||||
func (m Model) copyPath() (tea.Model, tea.Cmd) {
|
||||
s := m.tree.Selected()
|
||||
if s == nil {
|
||||
return m, nil
|
||||
}
|
||||
path := s.Path
|
||||
if l, ok := m.provider.(submodule.Locator); ok {
|
||||
if dir := l.Dir(s.Path); dir != "" {
|
||||
path = dir
|
||||
}
|
||||
}
|
||||
cmd := tea.Batch(copyToClipboard(path), m.footer.Flash("copied "+path))
|
||||
m.layout()
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// layout distributes the terminal size among the components.
|
||||
func (m *Model) layout() {
|
||||
m.footer.SetWidth(m.width)
|
||||
@@ -373,6 +393,7 @@ func (m *Model) layout() {
|
||||
treeWidth = m.width - detailWidth
|
||||
m.detail.SetSize(detailWidth-2, bodyHeight) // border + padding
|
||||
}
|
||||
m.footer.SetActions(!m.showDetail || !m.detail.PaletteVisible())
|
||||
m.tree.SetSize(treeWidth, bodyHeight)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/aymanbagabas/go-osc52/v2"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// copyToClipboard sets the system clipboard with the OSC 52 escape sequence,
|
||||
// which works over SSH and in terminals that allow it; others ignore it.
|
||||
func copyToClipboard(text string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
seq := osc52.New(text)
|
||||
if os.Getenv("TMUX") != "" {
|
||||
seq = seq.Tmux()
|
||||
}
|
||||
_, _ = seq.WriteTo(os.Stderr)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
@@ -20,9 +22,20 @@ type Model struct {
|
||||
width int
|
||||
height int
|
||||
spinner string
|
||||
help help.Model
|
||||
actions []key.Binding
|
||||
}
|
||||
|
||||
func New(t theme.Theme) Model { return Model{theme: t} }
|
||||
// paletteMinHeight is the pane height below which the palette is left out:
|
||||
// the information is worth more than the hints.
|
||||
const paletteMinHeight = 12
|
||||
|
||||
func New(t theme.Theme, actions []key.Binding) Model {
|
||||
return Model{theme: t, help: theme.NewHelp(t), actions: actions}
|
||||
}
|
||||
|
||||
// PaletteVisible reports whether the palette of submodule actions fits.
|
||||
func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
|
||||
|
||||
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
|
||||
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
|
||||
@@ -38,10 +51,21 @@ func (m Model) View(s *submodule.Submodule) string {
|
||||
for i, l := range lines {
|
||||
lines[i] = ansi.Truncate(l, m.width, "…")
|
||||
}
|
||||
if len(lines) > m.height {
|
||||
lines = lines[:m.height]
|
||||
height := m.height
|
||||
var palette string
|
||||
if m.PaletteVisible() {
|
||||
height-- // the palette is the last line
|
||||
m.help.Width = 0
|
||||
palette = ansi.Truncate(m.help.ShortHelpView(m.actions), m.width, "…")
|
||||
}
|
||||
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(lines, "\n"))
|
||||
if len(lines) > height {
|
||||
lines = lines[:height]
|
||||
}
|
||||
body := lipgloss.NewStyle().Width(m.width).Height(height).Render(strings.Join(lines, "\n"))
|
||||
if palette == "" {
|
||||
return body
|
||||
}
|
||||
return body + "\n" + palette
|
||||
}
|
||||
|
||||
func (m Model) lines(s *submodule.Submodule) []string {
|
||||
@@ -138,8 +162,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)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
package footer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
@@ -15,26 +18,34 @@ const flashDuration = 2 * time.Second
|
||||
|
||||
type clearFlashMsg struct{ id int }
|
||||
|
||||
// Keys provides the key groups. The short bar shows Navigation (and
|
||||
// Submodule, if requested) on the left and Global on the right; full help
|
||||
// shows FullHelp.
|
||||
type Keys interface {
|
||||
help.KeyMap
|
||||
Navigation() []key.Binding
|
||||
Submodule() []key.Binding
|
||||
Global() []key.Binding
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
theme theme.Theme
|
||||
help help.Model
|
||||
keys help.KeyMap
|
||||
keys Keys
|
||||
width int
|
||||
flash string
|
||||
flashID int
|
||||
actions bool // show the submodule bindings in the short bar
|
||||
}
|
||||
|
||||
func New(t theme.Theme, keys help.KeyMap) Model {
|
||||
h := help.New()
|
||||
h.Styles.ShortKey = theme.Fg(t.Text).Bold(true)
|
||||
h.Styles.ShortDesc = theme.Fg(t.Muted)
|
||||
h.Styles.ShortSeparator = theme.Fg(t.Border)
|
||||
h.Styles.FullKey = h.Styles.ShortKey
|
||||
h.Styles.FullDesc = h.Styles.ShortDesc
|
||||
h.Styles.FullSeparator = h.Styles.ShortSeparator
|
||||
return Model{theme: t, help: h, keys: keys}
|
||||
func New(t theme.Theme, keys Keys) Model {
|
||||
return Model{theme: t, help: theme.NewHelp(t), keys: keys}
|
||||
}
|
||||
|
||||
// SetActions shows the submodule bindings in the short bar. Set it if the
|
||||
// detail pane doesn't show them.
|
||||
func (m *Model) SetActions(on bool) { m.actions = on }
|
||||
|
||||
func (m *Model) SetWidth(w int) {
|
||||
m.width = w
|
||||
m.help.Width = w - 2
|
||||
@@ -62,5 +73,29 @@ func (m Model) View() string {
|
||||
if m.flash != "" {
|
||||
return style.Foreground(m.theme.Warn).Render(m.flash)
|
||||
}
|
||||
return style.Render(m.help.View(m.keys))
|
||||
if m.help.ShowAll {
|
||||
return style.Render(m.help.View(m.keys))
|
||||
}
|
||||
return style.Render(m.shortBar(m.width - 2))
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m Model) shortBar(w int) string {
|
||||
h := m.help
|
||||
h.Width = 0
|
||||
right := h.ShortHelpView(m.keys.Global())
|
||||
left := m.keys.Navigation()
|
||||
if m.actions {
|
||||
left = append(left[:len(left):len(left)], m.keys.Submodule()...)
|
||||
}
|
||||
for ; ; left = left[:len(left)-1] {
|
||||
l := h.ShortHelpView(left)
|
||||
if gap := w - lipgloss.Width(l) - lipgloss.Width(right); gap >= 2 || len(left) == 0 {
|
||||
if gap < 0 { // not even the global keys fit
|
||||
return ansi.Truncate(right, w, "…")
|
||||
}
|
||||
return l + strings.Repeat(" ", gap) + right
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+24
-7
@@ -7,18 +7,18 @@ type keyMap struct {
|
||||
CollapseAll, ExpandAll key.Binding
|
||||
Home, End, PageUp, PageDown key.Binding
|
||||
Update, Bump, Fetch, Reload, Filter, Issues key.Binding
|
||||
Help, Quit key.Binding
|
||||
Copy, Help, Quit key.Binding
|
||||
}
|
||||
|
||||
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")),
|
||||
@@ -29,13 +29,29 @@ 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")),
|
||||
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
}
|
||||
}
|
||||
|
||||
// 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"))
|
||||
return []key.Binding{move, fold, all, k.Toggle}
|
||||
}
|
||||
|
||||
// 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 { return []key.Binding{k.Copy} }
|
||||
|
||||
// Global returns the bindings that don't depend on the selection.
|
||||
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
|
||||
|
||||
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}
|
||||
return append(append(k.Navigation(), k.Submodule()...), k.Global()...)
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
@@ -43,7 +59,8 @@ func (k keyMap) FullHelp() [][]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.Update, k.Bump, k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||
{k.Copy, k.Update, k.Bump},
|
||||
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||
{k.Help, k.Quit},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package theme
|
||||
|
||||
import "github.com/charmbracelet/bubbles/help"
|
||||
|
||||
// NewHelp returns a help renderer in the theme's colors.
|
||||
func NewHelp(t Theme) help.Model {
|
||||
h := help.New()
|
||||
h.Styles.ShortKey = Fg(t.Text).Bold(true)
|
||||
h.Styles.ShortDesc = Fg(t.Muted)
|
||||
h.Styles.ShortSeparator = Fg(t.Border)
|
||||
h.Styles.FullKey = h.Styles.ShortKey
|
||||
h.Styles.FullDesc = h.Styles.ShortDesc
|
||||
h.Styles.FullSeparator = h.Styles.ShortSeparator
|
||||
return h
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -72,6 +72,8 @@ g "$w" push origin main
|
||||
g "$root/work/log" submodule add "$root/remotes/nested.git" modules/nested
|
||||
g "$root/work/log" commit -m "Add nested"
|
||||
g "$root/work/log" push origin main
|
||||
# "archive" ties with main (same tip, sorts first) -> the preferred ref wins
|
||||
g "$root/work/log" push origin main:archive
|
||||
|
||||
super="$root/super"
|
||||
git init -q "$super"
|
||||
@@ -88,6 +90,7 @@ add ui libs/staged
|
||||
add net libs/missing
|
||||
add ui vendor
|
||||
add work libs/work
|
||||
add feature libs/topic -b main
|
||||
g "$super" submodule update --init --recursive
|
||||
g "$super" commit -m "Add submodules"
|
||||
|
||||
@@ -109,6 +112,13 @@ g "$super/libs/feature" checkout origin/feat/search~1
|
||||
g "$super" add libs/feature
|
||||
g "$super" commit -m "Pin feature search"
|
||||
|
||||
# libs/topic: .gitmodules says main, but the pin is the tip of feat/search
|
||||
# -> the closest branch wins over the configured one
|
||||
g "$super/libs/topic" fetch
|
||||
g "$super/libs/topic" checkout origin/feat/search
|
||||
g "$super" add libs/topic
|
||||
g "$super" commit -m "Pin topic search"
|
||||
|
||||
# src/core: checkout moved away from the pin -> off pin
|
||||
g "$super/src/core" checkout HEAD~1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user