Archived
Compare commits
10
Commits
633de5aab1
..
v0.3.0
| 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
|
tree/ tree component: rows, navigation, mouse, view
|
||||||
detail/ detail pane; changes.go: change preview
|
detail/ detail pane; changes.go: change preview
|
||||||
header/ header line
|
header/ header line
|
||||||
footer/ help and flash messages
|
footer/ key bar (navigation left, global right) and flash messages
|
||||||
```
|
```
|
||||||
|
|
||||||
### Layering rules
|
### 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
|
- `Tree` returns the superproject and all submodules with `Parent` and
|
||||||
`Children` linked. `Status` fields are nil.
|
`Children` linked. `Status` fields are nil.
|
||||||
- `Status` is called once per submodule, concurrently. It must be safe to
|
- `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.
|
**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`).
|
**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
|
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
|
**Consequences.** Resolution is a heuristic; showing the rule keeps it
|
||||||
transparent.
|
transparent.
|
||||||
|
|
||||||
@@ -53,15 +54,17 @@ everyone else's checkout.
|
|||||||
|
|
||||||
**Context.** Only the first level is shown by default, so nested problems
|
**Context.** Only the first level is shown by default, so nested problems
|
||||||
would be invisible.
|
would be invisible.
|
||||||
**Decision.** Collapsed parents show `·k` (issues in all descendants), colored
|
**Decision.** Collapsed parents show `·k` (issues in all descendants), one
|
||||||
by the worst one. Problems in a child do **not** mark the parent itself as
|
count per level: red for danger, then yellow for warn (amended: a single
|
||||||
`changed`.
|
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
|
## D8 — Collapsed by default, branch-first navigation
|
||||||
|
|
||||||
**Decision.** Only the first level is visible. `→` expands and enters the
|
**Decision.** Only the first level is visible. `→` expands and enters the
|
||||||
first child, `←` collapses and goes to the parent, `space` toggles,
|
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
|
`←` 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
|
without leaving it. Remembered expansion is kept on collapse, but reset by
|
||||||
"collapse all".
|
"collapse all".
|
||||||
@@ -198,3 +201,33 @@ package so versions stay clean.
|
|||||||
corrupt or partial downloads, not a compromised registry (no signatures).
|
corrupt or partial downloads, not a compromised registry (no signatures).
|
||||||
The update check is a deliberate, outside-the-provider network use: only
|
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.
|
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,
|
- **F5** A collapsed submodule shows the number of its **direct** children,
|
||||||
e.g. `[2]`.
|
e.g. `[2]`.
|
||||||
- **F6** A collapsed submodule shows the number of issues **hidden** in all
|
- **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
|
- **F7** Expansion state of descendants is remembered when a parent is
|
||||||
collapsed and restored when it is expanded again (except after
|
collapsed and restored when it is expanded again (except after
|
||||||
"collapse all", which resets it).
|
"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 |
|
| `conflict` | Merge conflict in the worktree | Danger |
|
||||||
| `missing` | Not initialized; directory missing | Warn |
|
| `missing` | Not initialized; directory missing | Warn |
|
||||||
| `off pin` | Checkout differs from pin | 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:
|
Rules:
|
||||||
|
|
||||||
@@ -96,23 +96,38 @@ Rules:
|
|||||||
### Hidden issues
|
### Hidden issues
|
||||||
|
|
||||||
`Submodule.HiddenIssues()` counts the issues of **all** loaded descendants
|
`Submodule.HiddenIssues()` counts the issues of **all** loaded descendants
|
||||||
and returns the most severe level. A collapsed submodule shows this as `·k`
|
per level (`Hidden{Warn, Danger}`). A collapsed submodule shows them as `·k`
|
||||||
in that level's color, so a danger can never hide inside a collapsed subtree.
|
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
|
## Resolving a pin to a ref
|
||||||
|
|
||||||
The pin is displayed as a ref, not a hash. A commit can be on several
|
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
|
branches, on none, or deep in the history. The git provider shows the
|
||||||
this order and records which rule applied in `PinInfo.Source`:
|
**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` |
|
| # | Rule | `RefSource` |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | `branch = …` in `.gitmodules` | `RefFromGitmodules` |
|
| 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 | Branch currently checked out in the submodule | `RefFromCurrentBranch` |
|
| 2 | Pin unpushed: the preferred ref with the fewest commits on either side (`↑n↓m`), ties by preference | preferred ref's |
|
||||||
| 3 | Remote default branch, if it contains the pin | `RefFromDefaultBranch` |
|
| 3 | A tag (`git describe`) | `RefFromTag` |
|
||||||
| 4 | Any remote branch containing the pin, preferring the closest tip (`git branch -r --contains`) | `RefFromContains` |
|
| 4 | Short SHA | `RefFromSHA` |
|
||||||
| 5 | A tag (`git describe`) | `RefFromTag` |
|
|
||||||
| 6 | 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
|
Distances: `git rev-list --left-right --count <pin>...<ref>` gives
|
||||||
`Ahead` (left) and `Behind` (right).
|
`Ahead` (left) and `Behind` (right).
|
||||||
@@ -141,12 +156,13 @@ are local; `<pin>` is the index gitlink.
|
|||||||
|
|
||||||
Details:
|
Details:
|
||||||
|
|
||||||
- Rule 1: `branch = .` means the superproject's current branch. The rule
|
- Preference a: `branch = .` means the superproject's current branch. The
|
||||||
applies only if `<remote>/<branch>` exists locally.
|
ref counts only if `<remote>/<branch>` exists locally.
|
||||||
- Rule 2 uses the checked-out branch's upstream (`<branch>@{upstream}`).
|
- Preference b uses the checked-out branch's upstream (`<branch>@{upstream}`).
|
||||||
- Rule 4 compares at most 16 containing branches (oldest tip first) and
|
- Rule 1 counts `rev-list --count <pin>..<ref>`. Besides the preferred refs
|
||||||
picks the one with the fewest commits after the pin.
|
it compares at most 16 containing branches (oldest tip first).
|
||||||
- Rule 6 shows the short SHA; there is no tip and no distance.
|
- 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
|
- `--ignore-submodules=dirty`: changes inside a nested submodule do not make
|
||||||
its parent `changed` (D7), but a moved nested pin does.
|
its parent `changed` (D7), but a moved nested pin does.
|
||||||
- Renames are detected only for staged moves (`git mv`); an unstaged move
|
- 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
|
my_submodule_3 unpushed origin/main ↑1 │ Pinned b7c8d9e Hotfix null pointer
|
||||||
libs/ │ Checkout b7c8d9e Hotfix null pointer
|
libs/ │ Checkout b7c8d9e Hotfix null pointer
|
||||||
logging origin/main ↓3 │ Tip 6f5e4d3 Release 2.1.0
|
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 |
|
| Area | Height / width |
|
||||||
@@ -36,6 +36,21 @@
|
|||||||
| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns |
|
| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns |
|
||||||
| Footer | 1 line (more with full help) |
|
| 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
|
### Tree row
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -47,7 +62,7 @@ indent ▸ name [n] status ref delta
|
|||||||
| indent | 2 per depth | |
|
| indent | 2 per depth | |
|
||||||
| toggle | 3 | `▸` collapsed, `▾` expanded, blank for leaves |
|
| toggle | 3 | `▸` collapsed, `▾` expanded, blank for leaves |
|
||||||
| name | fits the widest visible name | name, `[n]` = direct children (collapsed only) |
|
| 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 |
|
| ref | widest ref, max 22 | resolved ref; muted short SHA if unresolved |
|
||||||
| delta | 6 | `↑n`, `↓n`, `↑n↓m` |
|
| 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).
|
meaning, the color only supports it (N4).
|
||||||
- Letters and colors: `!` red bold, `M` yellow, `A` green, `D` red, `R`
|
- Letters and colors: `!` red bold, `M` yellow, `A` green, `D` red, `R`
|
||||||
cyan, `?` muted. Line counts: green `+n`, red `−n`; `bin` for binary
|
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
|
- Order: kind (`!`, `M`, `A`, `D`, `R`, `?`), then changed lines, largest
|
||||||
first, because the biggest edits are the ones people remember.
|
first, because the biggest edits are the ones people remember.
|
||||||
- Budget: 10 files. Every kind that occurs gets at least one line, the
|
- 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/`).
|
- Untracked directories are shown as one entry (`tmp/`).
|
||||||
### Header
|
### 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,
|
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.
|
superproject shows `(detached)` as its branch.
|
||||||
|
|
||||||
`<fetch>` on the right:
|
`<fetch>` on the right:
|
||||||
@@ -179,14 +198,15 @@ colors in components.
|
|||||||
| `space` | Toggle expand/collapse of the selected submodule |
|
| `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 |
|
| `→` / `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 |
|
| `←` / `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 |
|
| `L` / `shift+→` / `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 |
|
| `H` / `shift+←` / `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
|
||||||
| `home` / `g`, `end` / `G` | First / last submodule |
|
| `home` / `g`, `end` / `G` | First / last submodule |
|
||||||
| `pgup`, `pgdn` | Move by one screen |
|
| `pgup`, `pgdn` | Move by one screen |
|
||||||
| `?` | Toggle full help |
|
| `?` | Toggle full help |
|
||||||
| `q`, `ctrl+c` | Quit |
|
| `q`, `ctrl+c` | Quit |
|
||||||
| `f` | Fetch all repositories (does nothing while a fetch runs) |
|
| `f` | Fetch all repositories (does nothing while a fetch runs) |
|
||||||
| `r` | Reload everything (e.g. after editing files) |
|
| `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) |
|
| `u`, `b`, `/`, `!` | Planned actions (see requirements) |
|
||||||
|
|
||||||
"First child" always means the first child **as displayed** (children are
|
"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
|
The view scrolls so the selection is always visible; when the first submodule
|
||||||
is selected, headers above it are shown too.
|
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
|
### Mouse
|
||||||
|
|
||||||
- Click a row: select it.
|
- Click a row: select it.
|
||||||
@@ -205,5 +251,6 @@ is selected, headers above it are shown too.
|
|||||||
## Accessibility and terminals
|
## Accessibility and terminals
|
||||||
|
|
||||||
- `SUBMARINE_ASCII=1`: ASCII symbols (`^ v > v . *`) instead of Unicode.
|
- `SUBMARINE_ASCII=1`: ASCII symbols (`^ v > v . *`) instead of Unicode.
|
||||||
- Some terminals or multiplexers capture `ctrl+←/→`; letter aliases can be
|
- Many setups capture `ctrl+←/→` (macOS Mission Control, some terminals and
|
||||||
added if needed.
|
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
|
go 1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
@@ -11,7 +12,6 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ func (p *Provider) Fetchable(path string) bool {
|
|||||||
return ok
|
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.
|
// repoDir returns the worktree of path if it is initialized.
|
||||||
func (p *Provider) repoDir(path string) (string, bool) {
|
func (p *Provider) repoDir(path string) (string, bool) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// maxContainsCandidates limits how many remote branches containing the pin
|
// 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
|
const maxContainsCandidates = 16
|
||||||
|
|
||||||
// maxBetween limits the commits listed between pin and tip.
|
// 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)
|
pin := parseGitlink(out)
|
||||||
if pin == "" {
|
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)
|
out, _, err = try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
|
||||||
if err != nil {
|
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
|
// resolve finds the ref that describes the pin, following the order in
|
||||||
// docs/state-model.md. containing lists the remote branches containing the
|
// docs/state-model.md: the closest branch wins, the preferred refs (see
|
||||||
// pin, oldest tip first.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. branch from .gitmodules
|
// 1. pushed: the branch containing the pin with the fewest commits after it
|
||||||
if b := in.branch; b != "" && remote != "" {
|
if len(containing) > 0 {
|
||||||
if b == "." {
|
var cands []candidate
|
||||||
b, _, err = try(ctx, in.superDir, "symbolic-ref", "--short", "-q", "HEAD")
|
for _, c := range preferred {
|
||||||
if err != nil {
|
if _, ok, err := try(ctx, in.dir, "merge-base", "--is-ancestor", pin, c.ref); err != nil {
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if b != "" {
|
|
||||||
if _, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/remotes/"+remote+"/"+b); err != nil {
|
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return remote + "/" + b, submodule.RefFromGitmodules, nil
|
cands = append(cands, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
for _, r := range containing[:min(len(containing), maxContainsCandidates)] {
|
||||||
|
if !slices.ContainsFunc(cands, func(c candidate) bool { return c.ref == r }) {
|
||||||
// 2. upstream of the checked-out branch
|
cands = append(cands, candidate{r, submodule.RefFromContains})
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
if c, ok, err := closest(ctx, in.dir, pin+"..", cands); err != nil {
|
||||||
|
|
||||||
// 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 {
|
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
if _, ok, err := try(ctx, in.dir, "merge-base", "--is-ancestor", pin, def); err != nil {
|
return c.ref, c.source, nil
|
||||||
return "", 0, err
|
|
||||||
} else if ok {
|
|
||||||
return def, submodule.RefFromDefaultBranch, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. remote branch containing the pin with the closest tip
|
// 2. unpushed: the preferred ref with the fewest commits on either side
|
||||||
best, bestN := "", -1
|
if c, ok, err := closest(ctx, in.dir, pin+"...", preferred); err != nil {
|
||||||
for _, r := range containing[:min(len(containing), maxContainsCandidates)] {
|
return "", 0, err
|
||||||
out, err := run(ctx, in.dir, "rev-list", "--count", pin+".."+r)
|
} else if ok {
|
||||||
if err != nil {
|
return c.ref, c.source, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. nearest tag
|
// 3. nearest tag
|
||||||
if tag, ok, err := try(ctx, in.dir, "describe", "--tags", "--abbrev=0", pin); err != nil {
|
if tag, ok, err := try(ctx, in.dir, "describe", "--tags", "--abbrev=0", pin); err != nil {
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
} else if ok && tag != "" {
|
} else if ok && tag != "" {
|
||||||
return tag, submodule.RefFromTag, nil
|
return tag, submodule.RefFromTag, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. the commit itself
|
// 4. the commit itself
|
||||||
return "", submodule.RefFromSHA, nil
|
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 "".
|
// remoteName returns "origin" if it exists, else the first remote, else "".
|
||||||
func remoteName(ctx context.Context, dir string) (string, error) {
|
func remoteName(ctx context.Context, dir string) (string, error) {
|
||||||
out, err := run(ctx, dir, "remote")
|
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.Added += n.added
|
||||||
c.Removed += n.removed
|
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 e.kind != submodule.ChangeRemoved {
|
||||||
if fi, err := os.Stat(filepath.Join(dir, filepath.FromSlash(e.path))); err == nil {
|
if fi, err := os.Stat(filepath.Join(dir, filepath.FromSlash(e.path))); err == nil {
|
||||||
f.Modified = fi.ModTime()
|
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) })
|
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) })
|
||||||
want := []string{
|
want := []string{
|
||||||
"src/core", "src/ui", "src/net", "libs/store", "libs/log", "libs/log/modules/nested",
|
"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) {
|
if !slices.Equal(paths, want) {
|
||||||
t.Errorf("paths = %v\nwant %v", 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", 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/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/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/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/staged", ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
|
||||||
{path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}},
|
{path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}},
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ type FileChange struct {
|
|||||||
Removed int // removed lines
|
Removed int // removed lines
|
||||||
Binary bool // line counts are unknown
|
Binary bool // line counts are unknown
|
||||||
Submodule bool // a nested submodule pointer changed
|
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
|
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
|
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.
|
// Change reports that the git metadata of a repository changed.
|
||||||
type Change struct {
|
type Change struct {
|
||||||
Path string // "" is the superproject
|
Path string // "" is the superproject
|
||||||
|
|||||||
@@ -55,21 +55,36 @@ func (s *Status) Issues() []Issue {
|
|||||||
case WorktreeModified:
|
case WorktreeModified:
|
||||||
out = append(out, Issue{IssueChanged, LevelWarn, "uncommitted changes"})
|
out = append(out, Issue{IssueChanged, LevelWarn, "uncommitted changes"})
|
||||||
case WorktreeUntracked:
|
case WorktreeUntracked:
|
||||||
out = append(out, Issue{IssueChanged, LevelWarn, "untracked files"})
|
out = append(out, Issue{IssueChanged, LevelWarn, s.untrackedText()})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// HiddenIssues counts the issues of all loaded descendants and returns the
|
// Hidden counts issues below a submodule per level.
|
||||||
// most severe level among them.
|
type Hidden struct{ Warn, Danger int }
|
||||||
func (m *Submodule) HiddenIssues() (count int, worst Level) {
|
|
||||||
|
// 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) {
|
Walk(m.Children, func(c *Submodule) {
|
||||||
|
if c.Status == nil && c.Err != nil {
|
||||||
|
h.Danger++
|
||||||
|
}
|
||||||
for _, is := range c.Status.Issues() {
|
for _, is := range c.Status.Issues() {
|
||||||
count++
|
switch is.Level {
|
||||||
worst = max(worst, 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.
|
// 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)
|
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
|
package submodule
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -45,8 +46,35 @@ func TestHiddenIssues(t *testing.T) {
|
|||||||
loading := &Submodule{}
|
loading := &Submodule{}
|
||||||
root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}}
|
root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}}
|
||||||
|
|
||||||
count, worst := root.HiddenIssues()
|
if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1}) {
|
||||||
if count != 2 || worst != LevelDanger {
|
t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger", got)
|
||||||
t.Errorf("HiddenIssues() = %d, %v; want 2, LevelDanger", count, worst)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
loads: newReloader(),
|
||||||
ticking: true, // Init schedules the first tick
|
ticking: true, // Init schedules the first tick
|
||||||
tree: tree.New(t),
|
tree: tree.New(t),
|
||||||
detail: detail.New(t),
|
detail: detail.New(t, k.Submodule()),
|
||||||
footer: footer.New(t, k),
|
footer: footer.New(t, k),
|
||||||
spinner: sp,
|
spinner: sp,
|
||||||
}
|
}
|
||||||
@@ -353,6 +353,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.startFetch()
|
return m, m.startFetch()
|
||||||
case key.Matches(msg, k.Reload):
|
case key.Matches(msg, k.Reload):
|
||||||
return m, m.reloadTree(true)
|
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):
|
case key.Matches(msg, k.Update, k.Bump, k.Filter, k.Issues):
|
||||||
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
||||||
m.layout()
|
m.layout()
|
||||||
@@ -361,6 +363,24 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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.
|
// layout distributes the terminal size among the components.
|
||||||
func (m *Model) layout() {
|
func (m *Model) layout() {
|
||||||
m.footer.SetWidth(m.width)
|
m.footer.SetWidth(m.width)
|
||||||
@@ -373,6 +393,7 @@ func (m *Model) layout() {
|
|||||||
treeWidth = m.width - detailWidth
|
treeWidth = m.width - detailWidth
|
||||||
m.detail.SetSize(detailWidth-2, bodyHeight) // border + padding
|
m.detail.SetSize(detailWidth-2, bodyHeight) // border + padding
|
||||||
}
|
}
|
||||||
|
m.footer.SetActions(!m.showDetail || !m.detail.PaletteVisible())
|
||||||
m.tree.SetSize(treeWidth, bodyHeight)
|
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 {
|
switch {
|
||||||
case f.Submodule:
|
case f.Submodule:
|
||||||
r.counts = muted.Render("submodule")
|
r.counts = muted.Render("submodule")
|
||||||
|
case f.Repo:
|
||||||
|
r.counts = muted.Render("repository")
|
||||||
case f.Binary:
|
case f.Binary:
|
||||||
r.counts = muted.Render("bin")
|
r.counts = muted.Render("bin")
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/charmbracelet/x/ansi"
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
@@ -20,9 +22,20 @@ type Model struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
spinner string
|
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) SetSize(w, h int) { m.width, m.height = w, h }
|
||||||
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
|
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 {
|
for i, l := range lines {
|
||||||
lines[i] = ansi.Truncate(l, m.width, "…")
|
lines[i] = ansi.Truncate(l, m.width, "…")
|
||||||
}
|
}
|
||||||
if len(lines) > m.height {
|
height := m.height
|
||||||
lines = lines[: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 {
|
func (m Model) lines(s *submodule.Submodule) []string {
|
||||||
@@ -138,8 +162,16 @@ func (m Model) lines(s *submodule.Submodule) []string {
|
|||||||
out = append(out, "")
|
out = append(out, "")
|
||||||
if n := len(s.Children); n > 0 {
|
if n := len(s.Children); n > 0 {
|
||||||
children := fmt.Sprintf("%d direct", n)
|
children := fmt.Sprintf("%d direct", n)
|
||||||
if k, worst := s.HiddenIssues(); k > 0 {
|
h := s.HiddenIssues()
|
||||||
children += t.LevelStyle(worst, lipgloss.NewStyle()).Render(fmt.Sprintf(" · %d issue(s) below", k))
|
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)
|
out = append(out, label("Children")+children)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
package footer
|
package footer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/help"
|
"github.com/charmbracelet/bubbles/help"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
"submarine/internal/tui/theme"
|
"submarine/internal/tui/theme"
|
||||||
)
|
)
|
||||||
@@ -15,26 +18,34 @@ const flashDuration = 2 * time.Second
|
|||||||
|
|
||||||
type clearFlashMsg struct{ id int }
|
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 {
|
type Model struct {
|
||||||
theme theme.Theme
|
theme theme.Theme
|
||||||
help help.Model
|
help help.Model
|
||||||
keys help.KeyMap
|
keys Keys
|
||||||
width int
|
width int
|
||||||
flash string
|
flash string
|
||||||
flashID int
|
flashID int
|
||||||
|
actions bool // show the submodule bindings in the short bar
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(t theme.Theme, keys help.KeyMap) Model {
|
func New(t theme.Theme, keys Keys) Model {
|
||||||
h := help.New()
|
return Model{theme: t, help: theme.NewHelp(t), keys: keys}
|
||||||
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}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func (m *Model) SetWidth(w int) {
|
||||||
m.width = w
|
m.width = w
|
||||||
m.help.Width = w - 2
|
m.help.Width = w - 2
|
||||||
@@ -62,5 +73,29 @@ func (m Model) View() string {
|
|||||||
if m.flash != "" {
|
if m.flash != "" {
|
||||||
return style.Foreground(m.theme.Warn).Render(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
|
// 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{}
|
perKind := map[submodule.IssueKind]int{}
|
||||||
level := map[submodule.IssueKind]submodule.Level{}
|
level := map[submodule.IssueKind]submodule.Level{}
|
||||||
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) {
|
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) {
|
||||||
total++
|
total++
|
||||||
if s.Status == nil {
|
if s.Status == nil {
|
||||||
loading++
|
if s.Err == nil {
|
||||||
|
loading++
|
||||||
|
} else {
|
||||||
|
errored++
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.Status.PinStaged {
|
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, " · ")
|
sep := seg(t.Muted, " · ")
|
||||||
mid := seg(t.Muted, fmt.Sprintf("%d submodules", total))
|
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{
|
for _, k := range []submodule.IssueKind{
|
||||||
submodule.IssueUnpushed, submodule.IssueConflict, submodule.IssueMissing,
|
submodule.IssueUnpushed, submodule.IssueConflict, submodule.IssueMissing,
|
||||||
submodule.IssueOffPin, submodule.IssueChanged,
|
submodule.IssueOffPin, submodule.IssueChanged,
|
||||||
|
|||||||
+24
-7
@@ -7,18 +7,18 @@ type keyMap struct {
|
|||||||
CollapseAll, ExpandAll key.Binding
|
CollapseAll, ExpandAll key.Binding
|
||||||
Home, End, PageUp, PageDown key.Binding
|
Home, End, PageUp, PageDown key.Binding
|
||||||
Update, Bump, Fetch, Reload, Filter, Issues key.Binding
|
Update, Bump, Fetch, Reload, Filter, Issues key.Binding
|
||||||
Help, Quit key.Binding
|
Copy, Help, Quit key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultKeys() keyMap {
|
func defaultKeys() keyMap {
|
||||||
return 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")),
|
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")),
|
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "expand, first child")),
|
||||||
Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
|
Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
|
||||||
CollapseAll: key.NewBinding(key.WithKeys("ctrl+left"), key.WithHelp("ctrl+←", "collapse all")),
|
CollapseAll: key.NewBinding(key.WithKeys("shift+left", "ctrl+left", "H"), key.WithHelp("H/⇧←", "collapse all")),
|
||||||
ExpandAll: key.NewBinding(key.WithKeys("ctrl+right"), key.WithHelp("ctrl+→", "expand 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")),
|
Home: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home/g", "first")),
|
||||||
End: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "last")),
|
End: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "last")),
|
||||||
PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
|
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")),
|
Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")),
|
||||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
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")),
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
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 {
|
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 {
|
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.Up, k.Down, k.Left, k.Right, k.Toggle},
|
||||||
{k.CollapseAll, k.ExpandAll},
|
{k.CollapseAll, k.ExpandAll},
|
||||||
{k.Home, k.End, k.PageUp, k.PageDown},
|
{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},
|
{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 (
|
const (
|
||||||
statusWidth = 14 // "unpushed +1 ·2"
|
statusWidth = 16 // "unpushed +1 ·2 ·3"
|
||||||
deltaWidth = 6
|
deltaWidth = 6
|
||||||
maxRefWidth = 22
|
maxRefWidth = 22
|
||||||
)
|
)
|
||||||
@@ -414,11 +414,19 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
|
|||||||
default:
|
default:
|
||||||
status = seg(t.Muted, theme.InSync)
|
status = seg(t.Muted, theme.InSync)
|
||||||
}
|
}
|
||||||
if k, worst := s.HiddenIssues(); collapsed && k > 0 {
|
if h := s.HiddenIssues(); collapsed && h.Total() > 0 {
|
||||||
if status != "" {
|
for _, c := range []struct {
|
||||||
status += pad(1)
|
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 = ansi.Truncate(status, statusWidth, "")
|
||||||
status += pad(statusWidth - lipgloss.Width(status))
|
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" submodule add "$root/remotes/nested.git" modules/nested
|
||||||
g "$root/work/log" commit -m "Add nested"
|
g "$root/work/log" commit -m "Add nested"
|
||||||
g "$root/work/log" push origin main
|
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"
|
super="$root/super"
|
||||||
git init -q "$super"
|
git init -q "$super"
|
||||||
@@ -88,6 +90,7 @@ add ui libs/staged
|
|||||||
add net libs/missing
|
add net libs/missing
|
||||||
add ui vendor
|
add ui vendor
|
||||||
add work libs/work
|
add work libs/work
|
||||||
|
add feature libs/topic -b main
|
||||||
g "$super" submodule update --init --recursive
|
g "$super" submodule update --init --recursive
|
||||||
g "$super" commit -m "Add submodules"
|
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" add libs/feature
|
||||||
g "$super" commit -m "Pin feature search"
|
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
|
# src/core: checkout moved away from the pin -> off pin
|
||||||
g "$super/src/core" checkout HEAD~1
|
g "$super/src/core" checkout HEAD~1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user